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 a993a8b..eeec584 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,12 @@ 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 +234,7 @@ function createRuntimeConfig( const refreshOnList = getBoolean(options, 'refreshOnList'); const modelsDev = getModelsDevConfig(options); const modelMetadata = getModelMetadataConfig(options); + const modelNameDisplay = getModelNameDisplay(options); return { baseUrl, @@ -227,6 +244,7 @@ function createRuntimeConfig( refreshOnList, modelsDev, modelMetadata, + modelNameDisplay, }; } @@ -361,6 +379,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 +533,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 +880,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,6 +893,7 @@ 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 @@ -855,11 +901,13 @@ function toProviderModel( 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, - 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..9a11d99 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..5e36a6c 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -2169,3 +2169,243 @@ 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'); +}); + +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'); +});