diff --git a/open-sse/config/providers/registry/ollama-cloud/index.ts b/open-sse/config/providers/registry/ollama-cloud/index.ts index bd74e0d218..37f560fa0d 100644 --- a/open-sse/config/providers/registry/ollama-cloud/index.ts +++ b/open-sse/config/providers/registry/ollama-cloud/index.ts @@ -12,6 +12,18 @@ export const ollama_cloudProvider: RegistryEntry = { // Note: rate limits vary by plan (free = "Light usage", Pro = more, Max = 5x Pro). // Users can generate API keys at https://ollama.com/settings/keys models: [ + { + id: "gpt-oss:20b", + name: "GPT-OSS 20B", + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high"], + }, + { + id: "gpt-oss:120b", + name: "GPT-OSS 120B", + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high"], + }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, { id: "kimi-k2.6", name: "Kimi K2.6" }, diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 2c5de756fe..b41d24256e 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -48,6 +48,7 @@ export interface RegistryModel { aliases?: readonly string[]; toolCalling?: boolean; supportsReasoning?: boolean; + supportedThinkingEfforts?: readonly string[]; supportsVision?: boolean; supportsXHighEffort?: boolean; maxOutputTokens?: number; diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index 3050930ac4..70b62a04e3 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -1,5 +1,6 @@ import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts"; import { shouldParseTextualReasoningTags } from "../handlers/responseSanitizer.ts"; +import { getReadableReasoningValue } from "../utils/reasoningFields.ts"; import { isInternalReasoningPlaceholder, stripInternalReasoningPlaceholder, @@ -528,10 +529,13 @@ export function createResponsesApiTransformStream( }); } - // Handle reasoning_content (OpenAI native format) - if (delta.reasoning_content && !isInternalReasoningPlaceholder(delta.reasoning_content)) { + // Handle OpenAI-compatible reasoning fields. Some providers use the + // standard `reasoning_content` key while others use the string alias + // `reasoning`; prefer the standard key when both are present. + const reasoning = getReadableReasoningValue(delta); + if (reasoning && !isInternalReasoningPlaceholder(reasoning)) { startReasoning(controller, idx); - emitReasoningDelta(controller, delta.reasoning_content); + emitReasoningDelta(controller, reasoning); } // Handle text content. Generic prompt-format tags are visible text; diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 22fb7d73c1..112355381f 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -7,6 +7,7 @@ import { FORMATS } from "../formats.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; import { fallbackToolCallId } from "../helpers/toolCallHelper.ts"; import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts"; +import { getReadableReasoningValue } from "../../utils/reasoningFields.ts"; import { isInternalReasoningPlaceholder, stripInternalReasoningPlaceholder, @@ -80,9 +81,7 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { return flushEvents(state); } - // Capture usage from all chunks that carry it (usage-only chunks OR final chunks with finish_reason) - // Normalize Chat Completions format (prompt_tokens/completion_tokens) to Responses API format - // (input_tokens/output_tokens) so response.completed always has the fields Codex expects. + // Normalize usage from any chunk so response.completed has Responses token fields. if (chunk.usage) { const u = chunk.usage; const input_tokens = u.input_tokens ?? u.prompt_tokens ?? 0; @@ -193,9 +192,10 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { }); } - if (delta.reasoning_content && !isInternalReasoningPlaceholder(delta.reasoning_content)) { + const reasoning = getReadableReasoningValue(delta); + if (reasoning && !isInternalReasoningPlaceholder(reasoning)) { startReasoning(state, emit, idx); - emitReasoningDelta(state, emit, delta.reasoning_content); + emitReasoningDelta(state, emit, reasoning); } // Strip the internal reasoning placeholder if the model echoed it // through ordinary content (#8081). Only the text-content emission is diff --git a/open-sse/utils/ollamaTransform.ts b/open-sse/utils/ollamaTransform.ts index b87b39bf63..62fc36ec29 100644 --- a/open-sse/utils/ollamaTransform.ts +++ b/open-sse/utils/ollamaTransform.ts @@ -1,4 +1,5 @@ import { CORS_HEADERS } from "./cors.ts"; +import { getReadableReasoningValue } from "./reasoningFields.ts"; type PendingToolCall = { id?: string; @@ -38,6 +39,7 @@ export function transformToOllama(response, model) { const parsed = JSON.parse(data); const delta = parsed.choices?.[0]?.delta || {}; const content = delta.content || ""; + const thinking = getReadableReasoningValue(delta); const toolCalls = delta.tool_calls; if (toolCalls) { @@ -47,7 +49,11 @@ export function transformToOllama(response, model) { const toolCallId = tc.id != null ? String(tc.id) : tc.id; // T37: Prevent merging tool_calls on same index if ID changes - if (pendingToolCalls[idx] && toolCallId && pendingToolCalls[idx].id !== toolCallId) { + if ( + pendingToolCalls[idx] && + toolCallId && + pendingToolCalls[idx].id !== toolCallId + ) { completedToolCalls.push(pendingToolCalls[idx]); delete pendingToolCalls[idx]; } @@ -64,6 +70,16 @@ export function transformToOllama(response, model) { } } + if (thinking) { + const ollama = + JSON.stringify({ + model, + message: { role: "assistant", content: "", thinking }, + done: false, + }) + "\n"; + controller.enqueue(new TextEncoder().encode(ollama)); + } + if (content) { const ollama = JSON.stringify({ model, message: { role: "assistant", content }, done: false }) + diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index d817cd796e..d4229cf64d 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -461,7 +461,12 @@ async function buildUnifiedModelsResponseCore( } Object.assign( capabilities, - getThinkingCapabilityFields(providerId, modelId, canonical.capabilities.supportsThinking) + getThinkingCapabilityFields( + providerId, + modelId, + canonical.capabilities.supportsThinking, + registryModel?.supportedThinkingEfforts + ) ); return { diff --git a/src/app/api/v1/models/catalogHelpers.ts b/src/app/api/v1/models/catalogHelpers.ts index 1dcd106c36..71acac3628 100644 --- a/src/app/api/v1/models/catalogHelpers.ts +++ b/src/app/api/v1/models/catalogHelpers.ts @@ -83,7 +83,8 @@ export function minKnownNumber(values: Array): number | unde export function getThinkingCapabilityFields( providerId: string, modelId: string, - resolvedThinking?: boolean | null + resolvedThinking?: boolean | null, + supportedThinkingEfforts?: readonly string[] ): Record { const supportsThinking = resolvedThinking; if (typeof supportsThinking !== "boolean") return {}; @@ -92,7 +93,10 @@ export function getThinkingCapabilityFields( supportsThinking, ...(supportsThinking ? { - effort_tiers: extendCodexGpt56EffortValues(providerId, modelId, CANONICAL_EFFORT_VALUES), + effort_tiers: + supportedThinkingEfforts && supportedThinkingEfforts.length > 0 + ? [...supportedThinkingEfforts] + : extendCodexGpt56EffortValues(providerId, modelId, CANONICAL_EFFORT_VALUES), } : {}), }; diff --git a/src/lib/modelMetadataRegistry.ts b/src/lib/modelMetadataRegistry.ts index af1702adf0..2e03a981d3 100644 --- a/src/lib/modelMetadataRegistry.ts +++ b/src/lib/modelMetadataRegistry.ts @@ -2,10 +2,7 @@ import { randomUUID } from "node:crypto"; import { parseModel } from "@omniroute/open-sse/services/model.ts"; import { getModelInfo } from "@/sse/services/model"; import { getModelAliases } from "@/lib/db/models"; -import { - getResolvedModelCapabilities, - isNonChatCatalogSurface, -} from "@/lib/modelCapabilities"; +import { getResolvedModelCapabilities, isNonChatCatalogSurface } from "@/lib/modelCapabilities"; import { getAuthoritativeContextWindow, getAuthoritativeProviderContextWindow, @@ -346,6 +343,10 @@ export function enrichCatalogModelEntry( const metadata = getCanonicalModelMetadata({ provider, model }); if (!metadata) return entry; + const registryModel = getRegistryModel( + metadata.providerAlias || metadata.provider, + metadata.model + ); const nextEntry: JsonRecord = { ...entry }; const existingName = asNonEmptyString(entry.name); @@ -382,11 +383,15 @@ export function enrichCatalogModelEntry( supportsThinking: metadata.capabilities.supportsThinking, ...(metadata.capabilities.supportsThinking ? { - effort_tiers: extendCodexGpt56EffortValues( - metadata.provider, - metadata.model, - CANONICAL_EFFORT_VALUES - ), + effort_tiers: + registryModel?.supportedThinkingEfforts && + registryModel.supportedThinkingEfforts.length > 0 + ? [...registryModel.supportedThinkingEfforts] + : extendCodexGpt56EffortValues( + metadata.provider, + metadata.model, + CANONICAL_EFFORT_VALUES + ), } : {}), } diff --git a/src/lib/vscode/reasoningMetadata.ts b/src/lib/vscode/reasoningMetadata.ts index 52e7262207..a11ff0c257 100644 --- a/src/lib/vscode/reasoningMetadata.ts +++ b/src/lib/vscode/reasoningMetadata.ts @@ -8,7 +8,7 @@ export type VscodeCatalogModel = { name?: string; root?: string; owned_by?: string; - capabilities?: Record; + capabilities?: Record; supportsReasoningEffort?: string[]; supportedReasoningEfforts?: string[]; supports_reasoning_effort?: string[]; @@ -66,6 +66,9 @@ function normalizeReasoningEffortValue(value: string) { function getNativeReasoningEffortValues(model: VscodeCatalogModel) { const candidates = [ + model.owned_by !== "combo" && Array.isArray(model.capabilities?.effort_tiers) + ? model.capabilities.effort_tiers + : undefined, model.supportsReasoningEffort, model.supportedReasoningEfforts, model.supports_reasoning_effort, @@ -111,7 +114,7 @@ export function getReasoningEffortValues(model: VscodeCatalogModel) { if (!isReasoningCapableModel(model)) return undefined; const modelId = getCatalogModelName(model); - const parsed = parseModel(modelId, ""); + const parsed = parseModel(modelId); const providerId = parsed.provider || model.owned_by || ""; const providerModelId = parsed.model || model.root || modelId.split("/").pop() || modelId; const values = ["none", "low", "medium", "high"]; @@ -179,7 +182,7 @@ export function getReasoningVariantBaseModelId(modelId: string) { function getCodexGpt56DefaultReasoningEffort(model: VscodeCatalogModel) { const modelId = getCatalogModelName(model); - const parsed = parseModel(modelId, ""); + const parsed = parseModel(modelId); const providerId = (parsed.provider || model.owned_by || "").trim().toLowerCase(); if (providerId !== "codex" && providerId !== "cx") return undefined; @@ -194,9 +197,18 @@ function getCodexGpt56DefaultReasoningEffort(model: VscodeCatalogModel) { } export function getDefaultReasoningEffort(model: VscodeCatalogModel, supportedValues?: string[]) { + const nativeDefault = normalizeReasoningEffortValue( + model.defaultReasoningEffort || model.default_reasoning_effort || "" + ); return ( inferSelectedReasoningEffort(model, supportedValues) || + (nativeDefault && (!supportedValues?.length || supportedValues.includes(nativeDefault)) + ? nativeDefault + : undefined) || getCodexGpt56DefaultReasoningEffort(model) || + (supportedValues?.includes(DEFAULT_REASONING_EFFORT) + ? DEFAULT_REASONING_EFFORT + : supportedValues?.[0]) || DEFAULT_REASONING_EFFORT ); } diff --git a/tests/unit/ollama-transform.test.ts b/tests/unit/ollama-transform.test.ts index 279d2973fe..bee6f095c2 100644 --- a/tests/unit/ollama-transform.test.ts +++ b/tests/unit/ollama-transform.test.ts @@ -10,18 +10,22 @@ test("transformToOllama coerces numeric tool_call id to string without crashing" object: "chat.completion.chunk", created: 1, model: "gpt-4", - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: 0, - id: 12345, - type: "function", - function: { name: "test", arguments: "{}" } - }] + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 12345, + type: "function", + function: { name: "test", arguments: "{}" }, + }, + ], + }, + finish_reason: "tool_calls", }, - finish_reason: "tool_calls" - }] + ], })}\n`, ].join(""); @@ -87,12 +91,88 @@ test("transformToOllama handles string tool_call id normally", async () => { const result = transformToOllama(mockResponse, "test-model"); const text = await result.text(); - const lines = text.trim().split("\n").map((line) => JSON.parse(line)); + const lines = text + .trim() + .split("\n") + .map((line) => JSON.parse(line)); const toolCallLine = lines.find((line) => line.message?.tool_calls); assert.ok(toolCallLine, "Should produce a tool call line"); }); +test("transformToOllama emits reasoning aliases as native thinking", async () => { + const inputSSE = [ + `data: ${JSON.stringify({ + choices: [{ index: 0, delta: { reasoning: "plan ", content: "" } }], + })}\n`, + `data: ${JSON.stringify({ + choices: [{ index: 0, delta: { reasoning: "carefully", content: "answer" } }], + })}\n`, + `data: ${JSON.stringify({ + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n`, + ].join(""); + + const mockResponse = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(inputSSE)); + controller.close(); + }, + }), + { headers: { "Content-Type": "text/event-stream" } } + ); + + const lines = (await transformToOllama(mockResponse, "gpt-oss:20b").text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const thinking = lines.filter((line) => typeof line.message?.thinking === "string"); + const content = lines.filter((line) => line.message?.content === "answer"); + + assert.deepEqual( + thinking.map((line) => line.message.thinking), + ["plan ", "carefully"] + ); + assert.equal( + thinking.every((line) => line.message.content === ""), + true + ); + assert.equal(content.length, 1); + assert.equal(content[0].message.thinking, undefined); +}); + +test("transformToOllama prefers reasoning_content without duplicating aliases", async () => { + const inputSSE = `data: ${JSON.stringify({ + choices: [ + { + index: 0, + delta: { reasoning_content: "canonical", reasoning: "alias" }, + finish_reason: "stop", + }, + ], + })}\n`; + const mockResponse = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(inputSSE)); + controller.close(); + }, + }), + { headers: { "Content-Type": "text/event-stream" } } + ); + + const lines = (await transformToOllama(mockResponse, "test-model").text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + + assert.deepEqual( + lines.filter((line) => line.message?.thinking).map((line) => line.message.thinking), + ["canonical"] + ); +}); + test("transformToOllama merges multi-chunk numeric tool_call id", async () => { const inputSSE = [ `data: ${JSON.stringify({ @@ -153,7 +233,10 @@ test("transformToOllama merges multi-chunk numeric tool_call id", async () => { const result = transformToOllama(mockResponse, "test-model"); const text = await result.text(); - const lines = text.trim().split("\n").map((line) => JSON.parse(line)); + const lines = text + .trim() + .split("\n") + .map((line) => JSON.parse(line)); const toolCallLines = lines.filter((line) => line.message?.tool_calls); assert.equal(toolCallLines.length, 1); diff --git a/tests/unit/openai-responses-reasoning-effort.test.ts b/tests/unit/openai-responses-reasoning-effort.test.ts index a8a2927d64..747720426a 100644 --- a/tests/unit/openai-responses-reasoning-effort.test.ts +++ b/tests/unit/openai-responses-reasoning-effort.test.ts @@ -38,6 +38,21 @@ test("Responses -> Chat promotes reasoning.effort for non-Copilot clients", () = assert.equal(out.reasoning, undefined); }); +test("Responses -> Ollama Cloud Chat preserves every advertised reasoning effort", () => { + for (const effort of ["low", "medium", "high"]) { + const out = asRecord( + openaiResponsesToOpenAIRequest( + "ollama-cloud/gpt-oss:20b", + { input: "hello", reasoning: { effort } }, + true, + { _provider: "ollama-cloud" } + ) + ); + assert.equal(out.reasoning_effort, effort); + assert.equal(out.reasoning, undefined); + } +}); + test("Responses -> Chat preserves reasoning.effort via the helper wrapper", () => { const out = asRecord( convertResponsesApiFormat({ input: "hello", reasoning: { effort: "medium" } }) diff --git a/tests/unit/responses-transformer.test.ts b/tests/unit/responses-transformer.test.ts index 68670d4095..6ea4bd6bfe 100644 --- a/tests/unit/responses-transformer.test.ts +++ b/tests/unit/responses-transformer.test.ts @@ -175,6 +175,48 @@ test("createResponsesApiTransformStream handles native reasoning content and too ); }); +test("createResponsesApiTransformStream converts OpenAI-compatible reasoning aliases", async () => { + const output = await runTransformStream([ + 'data: {"id":"chatcmpl_1","model":"gpt-oss:20b","choices":[{"index":0,"delta":{"reasoning":"plan "}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{"reasoning":"carefully","content":"answer"}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":4,"total_tokens":7}}\n\n', + ]); + + const events = parseSseOutput(output); + const reasoningDeltas = events + .filter((event) => event.event === "response.reasoning_summary_text.delta") + .map((event) => JSON.parse(event.data).delta); + const addedItems = events + .filter((event) => event.event === "response.output_item.added") + .map((event) => JSON.parse(event.data).item); + const completed = JSON.parse( + events.find((event) => event.event === "response.completed").data + ).response; + + assert.deepEqual(reasoningDeltas, ["plan ", "carefully"]); + assert.deepEqual( + addedItems.map((item) => item.type), + ["reasoning", "message"] + ); + assert.equal(completed.output[0].type, "reasoning"); + assert.equal(completed.output[0].summary[0].text, "plan carefully"); + assert.equal(completed.output[1].content[0].text, "answer"); +}); + +test("createResponsesApiTransformStream prefers reasoning_content without duplicating aliases", async () => { + const output = await runTransformStream([ + 'data: {"choices":[{"index":0,"delta":{"reasoning_content":"canonical","reasoning":"alias"}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}\n\n', + ]); + + const events = parseSseOutput(output); + const reasoningDeltas = events + .filter((event) => event.event === "response.reasoning_summary_text.delta") + .map((event) => JSON.parse(event.data).delta); + + assert.deepEqual(reasoningDeltas, ["canonical"]); +}); + test("createResponsesApiTransformStream hides the internal reasoning replay placeholder", async () => { const output = await runTransformStream([ 'data: {"choices":[{"index":0,"delta":{"reasoning_content":"(prior reasoning summary unavailable)"}}]}\n\n', diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 0e5cffc293..3239412464 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -18,6 +18,43 @@ function collectEvents(chunks) { return events; } +test("OpenAI -> Responses: accepts the reasoning alias without duplicating the canonical field", () => { + const events = collectEvents([ + { + id: "chatcmpl-1", + model: "gpt-oss:20b", + choices: [ + { + index: 0, + delta: { reasoning: "alias ", reasoning_content: "canonical " }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-1", + model: "gpt-oss:20b", + choices: [{ index: 0, delta: { reasoning: "continued" }, finish_reason: null }], + }, + { + id: "chatcmpl-1", + model: "gpt-oss:20b", + choices: [{ index: 0, delta: { content: "answer" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + }, + ]); + + assert.deepEqual( + events + .filter((event) => event.event === "response.reasoning_summary_text.delta") + .map((event) => event.data.delta), + ["canonical ", "continued"] + ); + const completed = events.find((event) => event.event === "response.completed").data.response; + assert.equal(completed.output[0].summary[0].text, "canonical continued"); + assert.equal(completed.output[1].content[0].text, "answer"); +}); + test("OpenAI -> Responses: emits lifecycle, reasoning, text, tool calls and completed usage", () => { const events = collectEvents([ { diff --git a/tests/unit/vscode-token-routes-gpt56.test.ts b/tests/unit/vscode-token-routes-gpt56.test.ts index af6b884161..c3e33f006b 100644 --- a/tests/unit/vscode-token-routes-gpt56.test.ts +++ b/tests/unit/vscode-token-routes-gpt56.test.ts @@ -39,6 +39,52 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); +test("vscode models route preserves gateway-owned Ollama Cloud effort tiers", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await providersDb.createProviderConnection({ + provider: "ollama-cloud", + authType: "apikey", + name: "ollama-cloud-vscode-efforts", + apiKey: "ollama-test-key", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + const key = await apiKeysDb.createApiKey( + "vscode-ollama-cloud-efforts", + "machine-vscode-ollama-cloud-efforts" + ); + const vscodeModelsRoute = await import("../../src/app/api/v1/vscode/[token]/models/route.ts"); + + const response = await vscodeModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/models`) + ); + const body = (await response.json()) as { + data?: Array<{ + id?: string; + root?: string; + supportsReasoningEffort?: string[]; + supportedReasoningEfforts?: string[]; + defaultReasoningEffort?: string; + capabilities?: { effort_tiers?: string[] }; + }>; + }; + const model = (body.data || []).find( + (entry) => entry.root === "gpt-oss:20b" || entry.id === "ollamacloud/gpt-oss:20b" + ); + + assert.equal(response.status, 200); + assert.ok(model, "missing Ollama Cloud GPT-OSS model"); + assert.deepEqual(model.capabilities?.effort_tiers, ["low", "medium", "high"]); + assert.deepEqual(model.supportsReasoningEffort, ["low", "medium", "high"]); + assert.deepEqual(model.supportedReasoningEfforts, ["low", "medium", "high"]); + assert.equal(model.defaultReasoningEffort, "low"); +}); + test("vscode raw models route exposes native GPT-5.6 IDs and effort tiers", async () => { await settingsDb.updateSettings({ requireLogin: true,