From 2a32dcea7852198c5baa519aea8fc4a85772e1da Mon Sep 17 00:00:00 2001 From: Rahul Sharma Date: Sun, 21 Jun 2026 19:43:25 +0530 Subject: [PATCH 1/2] feat(models): add modelNameDisplay option to show id instead of name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `modelNameDisplay` option to `OmniRouteConfig`: - `"name"` (default): use the human-readable `name` field from `/v1/models`, unchanged from previous behaviour - `"id"`: use the model `id` as the display name Multiple OmniRoute providers can serve the same base model under different IDs — for example `gh/gpt-5.5`, `cx/gpt-5.5`, `opencode-zen/gpt-5.5`, and `newapi.285743/gpt-5.5` all expose `name: "GPT-5.5"`. The picker currently shows them as identical "GPT-5.5" entries and the user cannot tell which provider they selected. Two complementary fixes are possible: 1. **Server-side** (OmniRoute PR #4516): `disambiguateCatalogModelNames()` — qualify ambiguous names server-side (e.g. `"gh/GPT-5.5"`) 2. **Client-side** (this PR): `modelNameDisplay: "id"` — always show the model ID, which is guaranteed to be unique The client-side option is useful when: - the OmniRoute instance is older than v3.8.32 and PR #4516 is not deployed yet - the user prefers seeing provider-prefixed IDs regardless of whether disambiguation is enabled on the server - models.dev enrichment overwrites the server-supplied name with a different value ```js // opencode.js import OmniRouteAuthPlugin from 'opencode-omniroute-auth'; export default { plugins: [OmniRouteAuthPlugin], provider: { omniroute: { options: { baseURL: 'http://localhost:20128/v1', modelNameDisplay: 'id', // show gh/gpt-5.5 instead of GPT-5.5 }, }, }, }; ``` - `OmniRouteConfig.modelNameDisplay?: "name" | "id"` in `types.ts` - `getModelNameDisplay()` helper validates and extracts the option - `toProviderModel()` uses `model.id` as `name` when `modelNameDisplay === "id"` - All four call sites of `toProviderModels()` pass the option through - 3 new tests (modelNameDisplay=id, default, invalid) Related: diegosouzapw/OmniRoute#4516 --- src/plugin.ts | 92 ++++++++++++++++++++++--------- src/types.ts | 14 +++++ test/plugin.test.mjs | 127 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 26 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index a993a8b..84c5a0b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -102,9 +102,10 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { setRawUserModelMetadata(providerOptions, rawUserModelMetadata); const shouldRefreshModels = shouldRefreshProviderModels(existingProvider); + const modelNameDisplay = getModelNameDisplay(existingProvider?.options); const providerModels = shouldRefreshModels - ? toProviderModels(effectiveModels, baseUrl, providerNpm) - : reconcileExplicitModelsNpm(existingProvider?.models, providerNpm); + ? toProviderModels(effectiveModels, baseUrl, providerNpm, modelNameDisplay) + : reconcileExplicitModels(existingProvider?.models, providerNpm, modelNameDisplay); setModelsGeneratedByPlugin(providerOptions, shouldRefreshModels); providers[OMNIROUTE_PROVIDER_ID] = { @@ -123,11 +124,11 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { provider: { id: OMNIROUTE_PROVIDER_ID, models: async (provider, ctx) => { - const baseUrl = getBaseUrl(provider.options); - const providerNpm = resolveProviderNpm( - isRecord(provider) ? provider.npm : undefined, - isRecord(provider) ? getApiMode(provider.options) : 'chat', - ); + const baseUrl = getBaseUrl(provider.options); + const providerNpm = resolveProviderNpm( + isRecord(provider) ? provider.npm : undefined, + isRecord(provider) ? getApiMode(provider.options) : 'chat', + ); // Auth available — fetch /v1/models (fetchModels falls back to defaults on error) if (ctx.auth?.type === 'api' && ctx.auth.key) { @@ -137,7 +138,12 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { models, getRawUserModelMetadata(provider.options), ); - return toProviderModels(effectiveModels, baseUrl, providerNpm); + return toProviderModels( + effectiveModels, + baseUrl, + providerNpm, + runtimeConfig.modelNameDisplay, + ); } // No auth yet (user hasn't /connect'd): return built-in defaults. @@ -146,7 +152,12 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { OMNIROUTE_DEFAULT_MODELS, getRawUserModelMetadata(provider.options), ); - return toProviderModels(effectiveModels, baseUrl, providerNpm); + return toProviderModels( + effectiveModels, + baseUrl, + providerNpm, + getModelNameDisplay(provider.options), + ); }, }, auth: createAuthHook(), @@ -196,7 +207,7 @@ async function loadProviderOptions( const providerNpm = resolveProviderNpm(provider.npm, config.apiMode); replaceProviderModels( provider, - toProviderModels(effectiveModels, config.baseUrl, providerNpm), + toProviderModels(effectiveModels, config.baseUrl, providerNpm, config.modelNameDisplay), ); if (isRecord(provider.models)) { debug(`Provider models hydrated: ${Object.keys(provider.models).length}`); @@ -218,6 +229,7 @@ function createRuntimeConfig( const refreshOnList = getBoolean(options, 'refreshOnList'); const modelsDev = getModelsDevConfig(options); const modelMetadata = getModelMetadataConfig(options); + const modelNameDisplay = getModelNameDisplay(options); return { baseUrl, @@ -227,6 +239,7 @@ function createRuntimeConfig( refreshOnList, modelsDev, modelMetadata, + modelNameDisplay, }; } @@ -361,6 +374,19 @@ function getBoolean( return undefined; } +function getModelNameDisplay( + options: Record | undefined, +): 'name' | 'id' | undefined { + const value = options?.modelNameDisplay; + if (value === 'name' || value === 'id') { + return value; + } + if (value !== undefined) { + warn(`Unsupported modelNameDisplay option: ${sanitizeForLog(String(value))}. Using name.`); + } + return undefined; +} + function getModelsDevConfig(options: Record | undefined): OmniRouteModelsDevConfig | undefined { const raw = options?.modelsDev; if (!isRecord(raw)) return undefined; @@ -502,30 +528,43 @@ function isGeneratedOmniRouteProviderModel(value: unknown): boolean { return typeof value.api.npm === 'string' && isOmniRouteProviderNpm(value.api.npm); } -function reconcileExplicitModelsNpm( +function reconcileExplicitModels( models: Record | undefined, providerNpm: string, + modelNameDisplay?: 'name' | 'id', ): Record | undefined { if (!isRecord(models)) return models; let changed = false; const next: Record = {}; for (const [id, model] of Object.entries(models)) { - if (!isRecord(model) || !isRecord(model.api)) { + if (!isRecord(model)) { next[id] = model; continue; } - if (model.api.npm === providerNpm) { - next[id] = model; - continue; + + let updatedModel = model; + if (isRecord(model.api) && model.api.npm !== providerNpm) { + updatedModel = { + ...updatedModel, + api: { + ...model.api, + npm: providerNpm, + }, + }; } - changed = true; - next[id] = { - ...model, - api: { - ...model.api, - npm: providerNpm, - }, - }; + + const expectedName = modelNameDisplay === 'id' ? id : (model.name ?? id); + if (typeof model.name === 'string' ? model.name !== expectedName : modelNameDisplay === 'id') { + updatedModel = { + ...updatedModel, + name: expectedName, + }; + } + + if (updatedModel !== model) { + changed = true; + } + next[id] = updatedModel; } return changed ? next : models; } @@ -836,10 +875,11 @@ function toProviderModels( models: OmniRouteModel[], baseUrl: string, providerNpm: string, + modelNameDisplay?: 'name' | 'id', ): Record { const entries: Array<[string, OmniRouteProviderModel]> = models.map((model) => [ model.id, - toProviderModel(model, baseUrl, providerNpm), + toProviderModel(model, baseUrl, providerNpm, modelNameDisplay), ]); return Object.fromEntries(entries); } @@ -848,8 +888,8 @@ function toProviderModel( model: OmniRouteModel, baseUrl: string, providerNpm: string, + modelNameDisplay?: 'name' | 'id', ): OmniRouteProviderModel { - const supportsVision = model.supportsVision === true; // Default to true: if API doesn't explicitly say no tools, assume capability exists // This aligns with OpenAI-compatible behavior where most models support tools const supportsTools = model.supportsTools !== false; @@ -859,7 +899,7 @@ function toProviderModel( return { id: model.id, - name: model.name || model.id, + name: modelNameDisplay === 'id' ? model.id : (model.name || model.id), providerID: OMNIROUTE_PROVIDER_ID, family: getModelFamily(model.id), release_date: '', diff --git a/src/types.ts b/src/types.ts index 1f82bbb..f16bea4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -127,6 +127,20 @@ export interface OmniRouteConfig { modelsDev?: OmniRouteModelsDevConfig; /** Optional metadata overrides/additions for custom/virtual models */ modelMetadata?: OmniRouteModelMetadataConfig; + /** + * Controls how model names are displayed in the model picker. + * + * - `"name"` (default): use the `name` field returned by `/v1/models` + * (e.g. `"GPT-5.5"`). When OmniRoute's `MODELS_CATALOG_PREFIX_MODE` is + * set to `dual` (the default), different providers that serve the same + * base model will all share the same display name, making them + * indistinguishable in the picker. + * - `"id"`: use the model `id` instead (e.g. `"gh/gpt-5.5"`, + * `"cx/gpt-5.5"`). Always unique per entry — useful when multiple + * providers serve the same model or when OmniRoute name disambiguation + * is not enabled. + */ + modelNameDisplay?: "name" | "id"; } export interface OmniRouteProviderModelModalities { diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 8d76a9b..e262852 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -2169,3 +2169,130 @@ test('provider hook creates synthetic base model when only variants are returned assert.equal(result['codex/gpt-5.5-high'], undefined); assert.equal(result['codex/gpt-5.5-xhigh'], undefined); }); + +test('provider hook uses model id as display name when modelNameDisplay is "id"', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [ + { id: 'gh/gpt-5.5', name: 'GPT-5.5' }, + { id: 'cx/gpt-5.5', name: 'GPT-5.5' }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: { baseURL: 'http://localhost:20128/v1', apiMode: 'chat', modelNameDisplay: 'id' }, + models: {}, + }, + { auth: { type: 'api', key: 'test-key' } }, + ); + + // Plugin remaps aliases to canonical keys (gh→github, cx→codex) + // With modelNameDisplay:'id' the name field reflects the canonical id + assert.ok(result['github/gpt-5.5'] || result['gh/gpt-5.5'], 'expected github/gpt-5.5 entry'); + assert.ok(result['codex/gpt-5.5'] || result['cx/gpt-5.5'], 'expected codex/gpt-5.5 entry'); + const ghEntry = result['github/gpt-5.5'] ?? result['gh/gpt-5.5']; + const cxEntry = result['codex/gpt-5.5'] ?? result['cx/gpt-5.5']; + // name should equal the model's id (not the human-readable display name) + assert.equal(ghEntry.name, ghEntry.id); + assert.equal(cxEntry.name, cxEntry.id); +}); + +test('provider hook uses model name as display name by default', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [ + { id: 'gh/gpt-5.5', name: 'GPT-5.5' }, + { id: 'cx/gpt-5.5', name: 'GPT-5.5' }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: { baseURL: 'http://localhost:20128/v1', apiMode: 'chat' }, + models: {}, + }, + { auth: { type: 'api', key: 'test-key' } }, + ); + + // Default: display name is the name field from /v1/models (not the id) + const ghEntry = result['github/gpt-5.5'] ?? result['gh/gpt-5.5']; + assert.ok(ghEntry, 'expected github/gpt-5.5 entry'); + assert.equal(ghEntry.name, 'GPT-5.5'); + assert.notEqual(ghEntry.name, ghEntry.id); +}); + +test('provider hook warns and falls back for invalid modelNameDisplay', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'gh/gpt-5.5', name: 'GPT-5.5' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + // invalid value — should fall back to 'name' mode + options: { baseURL: 'http://localhost:20128/v1', apiMode: 'chat', modelNameDisplay: 'invalid' }, + models: {}, + }, + { auth: { type: 'api', key: 'test-key' } }, + ); + + // Falls back to 'name' mode on invalid value + const ghEntry = result['github/gpt-5.5'] ?? result['gh/gpt-5.5']; + assert.ok(ghEntry, 'expected github/gpt-5.5 entry'); + assert.equal(ghEntry.name, 'GPT-5.5'); +}); From 25af02e8bc52084d6e16dde27982b59ac2588986 Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Thu, 25 Jun 2026 19:12:08 +0200 Subject: [PATCH 2/2] fix(review): address PR #34 review feedback and reconcile with PR #28 - Use single quotes for modelNameDisplay literal type - Combine toProviderModels/toProviderModel signatures with providerNpm and modelNameDisplay - Reconcile explicit models for both npm and display-name changes - Add config-hook and auth-loader tests for modelNameDisplay - Add empty-name fallback test - Document modelNameDisplay in README options table and OmniRouteConfig - Add 1.2.3 changelog section covering all four PRs --- CHANGELOG.md | 12 +++++ README.md | 3 ++ src/plugin.ts | 12 ++++- src/types.ts | 2 +- test/plugin.test.mjs | 113 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6980c66..85a3780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project are documented in this file. +## [1.2.3] - Unreleased + +### Added + +- **`modelNameDisplay` option** — New `OmniRouteConfig` option that controls how model names appear in the model picker. Set to `"id"` to show provider-qualified model IDs (e.g. `gh/gpt-5.5`) instead of human-readable names, which disambiguates entries when multiple providers serve the same base model. (`src/plugin.ts`, `src/types.ts`) (@Rahulsharma0810) + +### Fixed + +- **Exclude cached tokens from chat usage** — `prompt_tokens` and `total_tokens` in `/chat/completions` responses now exclude `prompt_tokens_details.cached_tokens`, matching OpenCode's separate cached-input accounting. Applies to both JSON and streaming responses. (`src/plugin.ts`) (@makcimbx) +- **Use `@ai-sdk/openai` for responses mode** — When `apiMode` is `responses`, the plugin now selects `@ai-sdk/openai` as the provider package instead of `@ai-sdk/openai-compatible`, and reconciles explicit model `api.npm` values when the provider package changes. (`src/plugin.ts`) (@makcimbx) +- **Strip Claude title reasoning effort** — The fetch interceptor now removes `reasoning_effort`/`reasoningEffort` from identified Claude title-generation requests, avoiding Anthropic OAuth temperature/thinking validation errors while preserving reasoning options for normal Claude chat requests. (`src/plugin.ts`) (@thomasmaerz) + ## [1.2.2] - 2026-05-22 ### Added diff --git a/README.md b/README.md index d8447ae..5b8ff09 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ Use `/connect omniroute` to store your API key in `~/.local/share/opencode/auth. | `provider.omniroute.options.refreshOnList` | boolean | No | Whether to refresh models when provider options load (default: true) | | `provider.omniroute.options.modelsDev` | object | No | Enrich model metadata from models.dev on refresh (default: enabled) | | `provider.omniroute.options.modelMetadata` | object \| array | No | Override/add metadata for custom/virtual models (works well in `opencode.js`) | +| `provider.omniroute.options.modelNameDisplay` | `'name' \| 'id'` | No | Use model `name` (default) or `id` in the model picker; `id` disambiguates duplicate display names | ### Model Metadata Enrichment (models.dev) @@ -273,6 +274,8 @@ interface OmniRouteConfig { refreshOnList?: boolean; modelsDev?: OmniRouteModelsDevConfig; modelMetadata?: OmniRouteModelMetadataConfig; + /** Controls how model names appear in the picker: `"name"` (default) or `"id"`. */ + modelNameDisplay?: 'name' | 'id'; } type OmniRouteApiMode = 'chat' | 'responses'; diff --git a/src/plugin.ts b/src/plugin.ts index 84c5a0b..eeec584 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -207,7 +207,12 @@ async function loadProviderOptions( const providerNpm = resolveProviderNpm(provider.npm, config.apiMode); replaceProviderModels( provider, - toProviderModels(effectiveModels, config.baseUrl, providerNpm, config.modelNameDisplay), + toProviderModels( + effectiveModels, + config.baseUrl, + providerNpm, + config.modelNameDisplay, + ), ); if (isRecord(provider.models)) { debug(`Provider models hydrated: ${Object.keys(provider.models).length}`); @@ -890,12 +895,15 @@ function toProviderModel( providerNpm: string, modelNameDisplay?: 'name' | 'id', ): OmniRouteProviderModel { + const supportsVision = model.supportsVision === true; // Default to true: if API doesn't explicitly say no tools, assume capability exists // This aligns with OpenAI-compatible behavior where most models support tools const supportsTools = model.supportsTools !== false; const supportsTemperature = model.supportsTemperature !== false; const supportsReasoning = model.supportsReasoning === true; - const supportsAttachment = model.supportsAttachment !== undefined ? model.supportsAttachment : supportsVision; + const supportsAttachment = model.supportsAttachment !== undefined + ? model.supportsAttachment + : supportsVision; return { id: model.id, diff --git a/src/types.ts b/src/types.ts index f16bea4..9a11d99 100644 --- a/src/types.ts +++ b/src/types.ts @@ -140,7 +140,7 @@ export interface OmniRouteConfig { * providers serve the same model or when OmniRoute name disambiguation * is not enabled. */ - modelNameDisplay?: "name" | "id"; + modelNameDisplay?: 'name' | 'id'; } export interface OmniRouteProviderModelModalities { diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index e262852..5e36a6c 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -2296,3 +2296,116 @@ test('provider hook warns and falls back for invalid modelNameDisplay', async () assert.ok(ghEntry, 'expected github/gpt-5.5 entry'); assert.equal(ghEntry.name, 'GPT-5.5'); }); + +test('config hook uses model id as display name when modelNameDisplay is "id"', async () => { + const plugin = await OmniRouteAuthPlugin({}); + process.env.XDG_DATA_HOME = join(tmpdir(), `opencode-test-no-auth-${Date.now()}`); + const config = { + provider: { + omniroute: { + options: { + baseURL: 'http://localhost:20128/v1', + apiMode: 'chat', + modelNameDisplay: 'id', + }, + }, + }, + }; + + await plugin.config(config); + + const entry = config.provider.omniroute.models['gpt-4o'] ?? config.provider.omniroute.models['gpt-4.1-mini']; + assert.ok(entry, 'expected default model entry'); + assert.equal(entry.name, entry.id); +}); + +test('config hook uses model name as display name by default', async () => { + const plugin = await OmniRouteAuthPlugin({}); + process.env.XDG_DATA_HOME = join(tmpdir(), `opencode-test-no-auth-${Date.now()}`); + const config = { + provider: { + omniroute: { + options: { + baseURL: 'http://localhost:20128/v1', + apiMode: 'chat', + }, + }, + }, + }; + + await plugin.config(config); + + const entry = config.provider.omniroute.models['gpt-4o'] ?? config.provider.omniroute.models['gpt-4.1-mini']; + assert.ok(entry, 'expected default model entry'); + assert.notEqual(entry.name, entry.id); + assert.equal(entry.name, 'GPT-4o'); +}); + +test('auth loader uses model id as display name when modelNameDisplay is "id"', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'gh/gpt-5.5', name: 'GPT-5.5' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat', modelNameDisplay: 'id' }, + models: {}, + }; + + await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + + const entry = provider.models['github/gpt-5.5'] ?? provider.models['gh/gpt-5.5']; + assert.ok(entry, 'expected github/gpt-5.5 entry'); + assert.equal(entry.name, entry.id); +}); + +test('modelNameDisplay falls back to id when name is empty', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'gh/gpt-5.5', name: '' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: { baseURL: 'http://localhost:20128/v1', apiMode: 'chat', modelNameDisplay: 'invalid' }, + models: {}, + }, + { auth: { type: 'api', key: 'test-key' } }, + ); + + const ghEntry = result['github/gpt-5.5'] ?? result['gh/gpt-5.5']; + assert.ok(ghEntry, 'expected github/gpt-5.5 entry'); + assert.equal(ghEntry.name, 'gh/gpt-5.5'); +});