fix(reasoning): forward Ollama Cloud thinking (#9290)

This commit is contained in:
Xiangzhe
2026-08-05 01:34:02 +08:00
committed by GitHub
parent b6bcc491bc
commit 455906c181
14 changed files with 319 additions and 37 deletions

View File

@@ -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" },

View File

@@ -48,6 +48,7 @@ export interface RegistryModel {
aliases?: readonly string[];
toolCalling?: boolean;
supportsReasoning?: boolean;
supportedThinkingEfforts?: readonly string[];
supportsVision?: boolean;
supportsXHighEffort?: boolean;
maxOutputTokens?: number;

View File

@@ -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;

View File

@@ -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

View File

@@ -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 }) +

View File

@@ -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 {

View File

@@ -83,7 +83,8 @@ export function minKnownNumber(values: Array<number | undefined>): number | unde
export function getThinkingCapabilityFields(
providerId: string,
modelId: string,
resolvedThinking?: boolean | null
resolvedThinking?: boolean | null,
supportedThinkingEfforts?: readonly string[]
): Record<string, boolean | string[]> {
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),
}
: {}),
};

View File

@@ -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<T extends JsonRecord>(
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<T extends JsonRecord>(
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
),
}
: {}),
}

View File

@@ -8,7 +8,7 @@ export type VscodeCatalogModel = {
name?: string;
root?: string;
owned_by?: string;
capabilities?: Record<string, boolean>;
capabilities?: Record<string, boolean | string[]>;
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
);
}

View File

@@ -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);

View File

@@ -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" } })

View File

@@ -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',

View File

@@ -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([
{

View File

@@ -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,