From 3e2caa3c31ef0aaf97a3ede71be64a1d6cb3d0a9 Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Thu, 25 Jun 2026 21:04:19 +0200 Subject: [PATCH 1/3] feat(models): extend modelNameDisplay with prefixed mode and add hideModelAliases (#29) --- CHANGELOG.md | 2 + README.md | 9 +- src/constants.ts | 20 +++++ src/models.ts | 10 ++- src/plugin.ts | 87 +++++++++++++++++-- src/types.ts | 12 ++- test/plugin.test.mjs | 197 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 324 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a3780..a4923ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,11 @@ All notable changes to this project are documented in this file. ### 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) +- **`modelNameDisplay: "prefixed"` and `hideModelAliases`** — Extended `modelNameDisplay` with a `"prefixed"` mode that renders model names as `${provider} / ${name}` (e.g. `"OpenCode / Big Pickle"`) using a provider label map, and added `hideModelAliases` to filter out alias models that have a `parent` field in `/v1/models`. (`src/plugin.ts`, `src/models.ts`, `src/types.ts`, `src/constants.ts`) ### Fixed +- **Preserve provider/origin prefix in model picker** — OmniRoute models from different providers no longer share the same display name when `modelNameDisplay` is set to `"prefixed"`; aliases can also be hidden with `hideModelAliases`. (#29) - **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) diff --git a/README.md b/README.md index 5b8ff09..8212a97 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,8 @@ 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 | +| `provider.omniroute.options.modelNameDisplay` | `'name' \| 'id' \| 'prefixed'` | No | Use model `name` (default), `id`, or provider-prefixed name (e.g. `"OpenCode / GPT-4o"`) in the model picker | +| `provider.omniroute.options.hideModelAliases` | `boolean` | No | Hide alias models that have a `parent` field in `/v1/models` (default: `false`) | ### Model Metadata Enrichment (models.dev) @@ -274,8 +275,10 @@ interface OmniRouteConfig { refreshOnList?: boolean; modelsDev?: OmniRouteModelsDevConfig; modelMetadata?: OmniRouteModelMetadataConfig; - /** Controls how model names appear in the picker: `"name"` (default) or `"id"`. */ - modelNameDisplay?: 'name' | 'id'; + /** Controls how model names appear in the picker: `"name"` (default), `"id"`, or `"prefixed"`. */ + modelNameDisplay?: 'name' | 'id' | 'prefixed'; + /** Hide alias models that have a `parent` field in `/v1/models`. */ + hideModelAliases?: boolean; } type OmniRouteApiMode = 'chat' | 'responses'; diff --git a/src/constants.ts b/src/constants.ts index 9d21159..72d2980 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -97,3 +97,23 @@ export const PROVIDER_ALIAS_TO_CANONICAL: Record = { kr: 'kiro', if: 'qoder', }; + +/** + * Friendly display labels for provider origins. + * Used when modelNameDisplay is "prefixed". + */ +export const PROVIDER_DISPLAY_LABELS: Record = { + oc: 'OpenCode Free', + opencode: 'OpenCode', + openrouter: 'OpenRouter', + anthropic: 'Anthropic', + claude: 'Anthropic', + openai: 'OpenAI', + google: 'Google', + gemini: 'Google', + cx: 'Codex', + codex: 'Codex', + gh: 'GitHub', + github: 'GitHub', + antigravity: 'Antigravity', +}; diff --git a/src/models.ts b/src/models.ts index 71f6fac..d6798d7 100644 --- a/src/models.ts +++ b/src/models.ts @@ -65,6 +65,11 @@ function normalizeModel(model: OmniRouteModel): OmniRouteModel { name: model.name || model.id, description: model.description || `OmniRoute model: ${model.id}`, + // Preserve OpenAI-compatible origin metadata for display/filtering + owned_by: model.owned_by, + root: model.root, + parent: model.parent, + // Context limits: prefer explicit camelCase, fallback to snake_case contextWindow: model.contextWindow ?? model.context_length ?? model.max_input_tokens, @@ -337,7 +342,10 @@ export async function fetchModels( ) .map(normalizeModel); - const dedupedModels = deduplicateModels(rawModels); + const visibleModels = config.hideModelAliases + ? rawModels.filter((model) => !model.parent) + : rawModels; + const dedupedModels = deduplicateModels(visibleModels); const groupedModels = groupVariantModels(dedupedModels); const models = await enrichModelMetadata(groupedModels, config); diff --git a/src/plugin.ts b/src/plugin.ts index eeec584..f0e6a0c 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -18,6 +18,8 @@ import { OMNIROUTE_ENDPOINTS, DEFAULT_CONTEXT_LIMIT, DEFAULT_OUTPUT_LIMIT, + PROVIDER_ALIAS_TO_CANONICAL, + PROVIDER_DISPLAY_LABELS, } from './constants.js'; import { fetchModels, resolveProviderAliasForMetadata } from './models.js'; import { warn, debug } from './logger.js'; @@ -235,6 +237,7 @@ function createRuntimeConfig( const modelsDev = getModelsDevConfig(options); const modelMetadata = getModelMetadataConfig(options); const modelNameDisplay = getModelNameDisplay(options); + const hideModelAliases = getHideModelAliases(options); return { baseUrl, @@ -245,6 +248,7 @@ function createRuntimeConfig( modelsDev, modelMetadata, modelNameDisplay, + hideModelAliases, }; } @@ -381,9 +385,9 @@ function getBoolean( function getModelNameDisplay( options: Record | undefined, -): 'name' | 'id' | undefined { +): 'name' | 'id' | 'prefixed' | undefined { const value = options?.modelNameDisplay; - if (value === 'name' || value === 'id') { + if (value === 'name' || value === 'id' || value === 'prefixed') { return value; } if (value !== undefined) { @@ -392,6 +396,14 @@ function getModelNameDisplay( return undefined; } +function getHideModelAliases(options: Record | undefined): boolean | undefined { + const value = options?.hideModelAliases; + if (typeof value === 'boolean') { + return value; + } + return undefined; +} + function getModelsDevConfig(options: Record | undefined): OmniRouteModelsDevConfig | undefined { const raw = options?.modelsDev; if (!isRecord(raw)) return undefined; @@ -536,7 +548,7 @@ function isGeneratedOmniRouteProviderModel(value: unknown): boolean { function reconcileExplicitModels( models: Record | undefined, providerNpm: string, - modelNameDisplay?: 'name' | 'id', + modelNameDisplay?: 'name' | 'id' | 'prefixed', ): Record | undefined { if (!isRecord(models)) return models; let changed = false; @@ -558,8 +570,15 @@ function reconcileExplicitModels( }; } - const expectedName = modelNameDisplay === 'id' ? id : (model.name ?? id); - if (typeof model.name === 'string' ? model.name !== expectedName : modelNameDisplay === 'id') { + const expectedName = formatModelDisplayName( + id, + typeof model.name === 'string' ? model.name : id, + modelNameDisplay, + ); + const hasName = typeof model.name === 'string'; + const nameMismatch = hasName && model.name !== expectedName; + const needsSyntheticName = !hasName && (modelNameDisplay === 'id' || modelNameDisplay === 'prefixed'); + if (nameMismatch || needsSyntheticName) { updatedModel = { ...updatedModel, name: expectedName, @@ -876,11 +895,63 @@ function isValidModelMetadata(value: unknown): { valid: boolean; field?: string return { valid: true }; } +const PROVIDER_DISPLAY_LABEL_VALUES = new Set( + Object.values(PROVIDER_DISPLAY_LABELS).map((label) => label.toLowerCase()), +); + +function formatModelDisplayName( + id: string, + baseName: string, + modelNameDisplay?: 'name' | 'id' | 'prefixed', +): string { + if (modelNameDisplay === 'id') { + return id; + } + if (modelNameDisplay === 'prefixed') { + const origin = getModelOrigin(id); + if (!origin) { + return baseName; + } + const cleanBaseName = stripProviderPrefix(baseName); + return `${getPrettyOrigin(origin)} / ${cleanBaseName}`; + } + return baseName; +} + +function getModelOrigin(modelId: string): string | undefined { + const slashIndex = modelId.indexOf('/'); + if (slashIndex > 0) { + return modelId.slice(0, slashIndex); + } + return undefined; +} + +function getPrettyOrigin(origin: string): string { + return PROVIDER_DISPLAY_LABELS[origin] ?? origin; +} + +function stripProviderPrefix(name: string): string { + const slashIndex = name.indexOf(' / '); + if (slashIndex <= 0) { + return name; + } + const prefix = name.slice(0, slashIndex); + const lowerPrefix = prefix.toLowerCase(); + if ( + PROVIDER_DISPLAY_LABELS[lowerPrefix] || + PROVIDER_ALIAS_TO_CANONICAL[lowerPrefix] || + PROVIDER_DISPLAY_LABEL_VALUES.has(lowerPrefix) + ) { + return name.slice(slashIndex + 3); + } + return name; +} + function toProviderModels( models: OmniRouteModel[], baseUrl: string, providerNpm: string, - modelNameDisplay?: 'name' | 'id', + modelNameDisplay?: 'name' | 'id' | 'prefixed', ): Record { const entries: Array<[string, OmniRouteProviderModel]> = models.map((model) => [ model.id, @@ -893,7 +964,7 @@ function toProviderModel( model: OmniRouteModel, baseUrl: string, providerNpm: string, - modelNameDisplay?: 'name' | 'id', + modelNameDisplay?: 'name' | 'id' | 'prefixed', ): OmniRouteProviderModel { const supportsVision = model.supportsVision === true; // Default to true: if API doesn't explicitly say no tools, assume capability exists @@ -907,7 +978,7 @@ function toProviderModel( return { id: model.id, - name: modelNameDisplay === 'id' ? model.id : (model.name || model.id), + name: formatModelDisplayName(model.id, model.name || model.id, modelNameDisplay), providerID: OMNIROUTE_PROVIDER_ID, family: getModelFamily(model.id), release_date: '', diff --git a/src/types.ts b/src/types.ts index 9a11d99..4f585b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,11 @@ export interface OmniRouteModel { vision?: boolean; tool_calling?: boolean; + // OpenAI-compatible model metadata fields used by OmniRoute + owned_by?: string; + root?: string; + parent?: string | null; + // OmniRoute capabilities object capabilities?: { vision?: boolean; @@ -139,8 +144,13 @@ export interface OmniRouteConfig { * `"cx/gpt-5.5"`). Always unique per entry — useful when multiple * providers serve the same model or when OmniRoute name disambiguation * is not enabled. + * - `"prefixed"`: prefix the human-readable `name` with the provider + * origin (e.g. `"OpenCode Free / Big Pickle"`). Combines readability + * with disambiguation. */ - modelNameDisplay?: 'name' | 'id'; + modelNameDisplay?: 'name' | 'id' | 'prefixed'; + /** Hide alias models that have a `parent` field in `/v1/models`. */ + hideModelAliases?: boolean; } export interface OmniRouteProviderModelModalities { diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 5e36a6c..60f6e27 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -2409,3 +2409,200 @@ test('modelNameDisplay falls back to id when name is empty', async () => { assert.ok(ghEntry, 'expected github/gpt-5.5 entry'); assert.equal(ghEntry.name, 'gh/gpt-5.5'); }); + +test('provider hook prefixes model names with provider origin when modelNameDisplay is "prefixed"', 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: 'oc/big-pickle', name: 'Big Pickle', owned_by: 'opencode', parent: null }, + { + id: 'opencode/big-pickle', + name: 'Big Pickle', + owned_by: 'opencode', + parent: 'oc/big-pickle', + }, + { + id: 'oc/deepseek-v4-flash-free', + name: 'DeepSeek V4 Flash Free', + owned_by: 'opencode', + parent: null, + }, + ], + }), + { 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: 'prefixed', + }, + models: {}, + }, + { auth: { type: 'api', key: 'test-key' } }, + ); + + assert.ok(result['oc/big-pickle'], 'expected oc/big-pickle entry'); + assert.ok(result['opencode/big-pickle'], 'expected opencode/big-pickle entry'); + assert.equal(result['oc/big-pickle'].name, 'OpenCode Free / Big Pickle'); + assert.equal(result['opencode/big-pickle'].name, 'OpenCode / Big Pickle'); + assert.equal( + result['oc/deepseek-v4-flash-free'].name, + 'OpenCode Free / DeepSeek V4 Flash Free', + ); +}); + +test('provider hook hides alias models when hideModelAliases is true', 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: 'oc/big-pickle', name: 'Big Pickle', owned_by: 'opencode', parent: null }, + { + id: 'opencode/big-pickle', + name: 'Big Pickle', + owned_by: 'opencode', + parent: 'oc/big-pickle', + }, + { + id: 'oc/deepseek-v4-flash-free', + name: 'DeepSeek V4 Flash Free', + owned_by: 'opencode', + parent: null, + }, + ], + }), + { 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', + hideModelAliases: true, + }, + models: {}, + }, + { auth: { type: 'api', key: 'test-key' } }, + ); + + assert.ok(result['oc/big-pickle'], 'expected canonical oc/big-pickle entry'); + assert.equal(result['opencode/big-pickle'], undefined, 'alias should be hidden'); + assert.ok(result['oc/deepseek-v4-flash-free'], 'expected non-alias deepseek entry'); +}); + +test('provider hook uses raw origin prefix when no pretty label exists', 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: 'custom-xyz/my-model', name: 'My Model', parent: null }], + }), + { 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: 'prefixed', + }, + models: {}, + }, + { auth: { type: 'api', key: 'test-key' } }, + ); + + const entry = result['custom-xyz/my-model']; + assert.ok(entry, 'expected custom-xyz/my-model entry'); + assert.equal(entry.name, 'custom-xyz / My Model'); +}); + +test('modelNameDisplay "prefixed" does not double-prefix an already prefixed name', 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: 'oc/big-pickle', name: 'OpenCode Free / Big Pickle', parent: null }], + }), + { 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: 'prefixed', + }, + models: {}, + }, + { auth: { type: 'api', key: 'test-key' } }, + ); + + const entry = result['oc/big-pickle']; + assert.ok(entry, 'expected oc/big-pickle entry'); + assert.equal(entry.name, 'OpenCode Free / Big Pickle'); +}); From 0cb4702392155e1c4db06894178a92c988801ef1 Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Thu, 25 Jun 2026 21:45:11 +0200 Subject: [PATCH 2/3] fix(plugin): address Gemini review - SSE boundary corruption and title prompt short-circuit --- CHANGELOG.md | 2 + src/plugin.ts | 17 +++++--- test/plugin.test.mjs | 96 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4923ff..4e09c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ All notable changes to this project are documented in this file. - **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) +- **Fix SSE newline boundary corruption** — Corrected `normalizeSseChatUsageResponse` so `\r\n` sequences split across stream chunks no longer inject spurious empty lines into the event stream. (`src/plugin.ts`) +- **Fix Claude title prompt detection short-circuit** — `isOpenCodeTitlePrompt` now checks `payload.input` even when `payload.messages` is present but does not contain the title prompt. (`src/plugin.ts`) ## [1.2.2] - 2026-05-22 diff --git a/src/plugin.ts b/src/plugin.ts index f0e6a0c..e495e69 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1160,17 +1160,22 @@ function normalizeSseChatUsageResponse(response: Response): Response { const stream = response.body.pipeThrough(new TransformStream({ transform(chunk, controller) { pending += decoder.decode(chunk, { stream: true }); - pending = pending.replace(/\r\n?/g, '\n'); const lines = pending.split('\n'); pending = lines.pop() ?? ''; - for (const line of lines) { + for (let line of lines) { + if (line.endsWith('\r')) { + line = line.slice(0, -1); + } controller.enqueue(encoder.encode(`${normalizeSseChatUsageLine(line)}\n`)); } }, flush(controller) { - const tail = (pending + decoder.decode()).replace(/\r\n?/g, '\n'); + let tail = pending + decoder.decode(); if (tail) { + if (tail.endsWith('\r')) { + tail = tail.slice(0, -1); + } controller.enqueue(encoder.encode(`${normalizeSseChatUsageLine(tail)}\n`)); } }, @@ -1339,18 +1344,20 @@ function isOpenCodeTitlePrompt(payload: Record): boolean { const messages = payload.messages; if (Array.isArray(messages)) { - return messages.some((message) => { + const hasTitlePrompt = messages.some((message) => { if (!isRecord(message) || message.role !== 'system') return false; return contentContainsTitlePrompt(message.content); }); + if (hasTitlePrompt) return true; } const input = payload.input; if (Array.isArray(input)) { - return input.some((item) => { + const hasTitlePrompt = input.some((item) => { if (!isRecord(item) || item.role !== 'system') return false; return contentContainsTitlePrompt(item.content); }); + if (hasTitlePrompt) return true; } return false; diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 60f6e27..b1eb0ef 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -551,6 +551,55 @@ test('chat completion streaming passes through content-only chunks unchanged', a assert.ok(text.includes('data: [DONE]')); }); +test('chat completion streaming preserves CRLF boundaries split across chunks', 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(createModelsResponse()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const encoder = new TextEncoder(); + // Split a CRLF across two chunks: first chunk ends with '\r', second starts with '\n' + const chunk1 = encoder.encode('data: {"choices":[{"delta":{"content":"hi"}}]}\r'); + const chunk2 = encoder.encode('\ndata: [DONE]'); + + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(chunk1); + controller.enqueue(chunk2); + controller.close(); + }, + }), + { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }, + ); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, + models: {}, + }; + + const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + const response = await options.fetch(`${getDummyBaseUrl()}/chat/completions`, { + method: 'POST', + body: JSON.stringify({ model: 'gpt-4.1-mini', messages: [], stream: true }), + }); + const text = await response.text(); + + // Should not inject an empty line between the data event and [DONE] + assert.ok(text.includes('data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\ndata: [DONE]')); + assert.ok(!text.includes('\n\n\ndata: [DONE]'), 'should not produce extra blank lines'); +}); + test('auth loader applies user modelMetadata override to provider models', async () => { const plugin = await OmniRouteAuthPlugin({}); @@ -895,6 +944,53 @@ test('claude title requests strip reasoning_effort from input array', async () = assert.equal(forwardedBody.reasoning_effort, undefined); }); +test('claude title requests strip reasoning_effort when messages lacks title but input has it', async () => { + const plugin = await OmniRouteAuthPlugin({}); + let forwardedBody; + + global.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response(JSON.stringify(createModelsResponse()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + forwardedBody = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { baseURL: getDummyBaseUrl(), apiMode: 'responses' }, + models: {}, + }; + + const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + const interceptedFetch = options.fetch; + + await interceptedFetch(`${getDummyBaseUrl()}/responses`, { + method: 'POST', + body: JSON.stringify({ + model: 'claude/claude-haiku-4-5-20251001', + reasoning_effort: 'low', + messages: [{ role: 'user', content: 'Hi' }], + input: [ + { + role: 'system', + content: 'You are a title generator. You output ONLY a thread title.', + }, + ], + }), + }); + + assert.ok(forwardedBody); + assert.equal(forwardedBody.reasoning_effort, undefined); +}); + test('claude title requests strip reasoning_effort from top-level system field', async () => { const plugin = await OmniRouteAuthPlugin({}); let forwardedBody; From 4010e5d5f2aaec8c267f359d3191a8179536ccce Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Thu, 25 Jun 2026 21:58:02 +0200 Subject: [PATCH 3/3] docs: clarify changelog attribution for modelNameDisplay extension --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e09c11..ea7c0c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to this project are documented in this file. ### 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) -- **`modelNameDisplay: "prefixed"` and `hideModelAliases`** — Extended `modelNameDisplay` with a `"prefixed"` mode that renders model names as `${provider} / ${name}` (e.g. `"OpenCode / Big Pickle"`) using a provider label map, and added `hideModelAliases` to filter out alias models that have a `parent` field in `/v1/models`. (`src/plugin.ts`, `src/models.ts`, `src/types.ts`, `src/constants.ts`) +- **`modelNameDisplay: "prefixed"` and `hideModelAliases`** — Extended the `modelNameDisplay` option with a `"prefixed"` mode that renders model names as `${provider} / ${name}` (e.g. `"OpenCode / Big Pickle"`) using a provider label map, and added `hideModelAliases` to filter out alias models that have a `parent` field in `/v1/models`. (`src/plugin.ts`, `src/models.ts`, `src/types.ts`, `src/constants.ts`) ### Fixed