Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/chatParts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { hasUsageSnapshot, toProviderUsagePayload, type UsageSnapshot } from "./

export const OPENCODE_USAGE_DATA_MIME = "application/vnd.opencode.usage+json";
export const COPILOT_USAGE_DATA_MIME = "usage";
export const OPENCODE_REASONING_DATA_MIME = "application/vnd.opencode.reasoning+json";

export function createUsageDataPart(usage: UsageSnapshot): vscode.LanguageModelDataPart | undefined {
return createUsageDataParts(usage)[0];
Expand All @@ -28,3 +29,31 @@ export function createUsageDataParts(usage: UsageSnapshot): vscode.LanguageModel
export function isInternalDataPart(part: vscode.LanguageModelDataPart): boolean {
return part.mimeType === OPENCODE_USAGE_DATA_MIME || part.mimeType === COPILOT_USAGE_DATA_MIME;
}

/**
* A data part that marks text the model produced as reasoning while the
* request had thinking OFF. The Go gateway wraps such responses in
* `reasoning_content`, which the extension surfaces as visible text (gateway
* bug #37635). The marker rides along in the conversation transcript so the
* next turn can echo that text back as `reasoning_content` — DeepSeek's
* thinking-mode validator 400s ("The reasoning_content in the thinking mode
* must be passed back to the API") when it is missing.
*/
export function createReasoningMarkerPart(reasoning: string): vscode.LanguageModelDataPart {
const data = new TextEncoder().encode(JSON.stringify({ reasoning }));
return new vscode.LanguageModelDataPart(data, OPENCODE_REASONING_DATA_MIME);
}

export function isReasoningMarkerPart(part: vscode.LanguageModelDataPart): boolean {
return part.mimeType === OPENCODE_REASONING_DATA_MIME;
}

/** Read the reasoning text carried by a marker part, if any. */
export function readReasoningMarker(part: vscode.LanguageModelDataPart): string | undefined {
try {
const payload = JSON.parse(new TextDecoder().decode(part.data)) as { reasoning?: unknown };
return typeof payload.reasoning === "string" ? payload.reasoning : undefined;
} catch {
return undefined;
}
}
13 changes: 12 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
type ProviderVendor,
} from "./providerTypes";
import { providerEnabledSetting } from "./providerEnablement";
import { isInternalDataPart } from "./chatParts";
import { isInternalDataPart, isReasoningMarkerPart, readReasoningMarker } from "./chatParts";
import { getImageDataUrlBase64Bytes, MAX_IMAGE_BASE64_BYTES, normalizeImageDataUrl } from "./imageNormalizer";
import { imageDescriptionKey, lookupImageDescriptions, storeImageDescriptions } from "./visionProxyCache";
import { providerModelDisplayName } from "./modelNames";
Expand Down Expand Up @@ -3610,6 +3610,17 @@ async function convertMessage(
continue;
}

if (part instanceof vscode.LanguageModelDataPart && isReasoningMarkerPart(part)) {
// Thinking-off responses carry their reasoning in a marker data part
// (see streaming.ts / gateway bug #37635); echo it as reasoning_content
// on the next turn or DeepSeek's validator 400s.
const reasoning = readReasoningMarker(part);
if (reasoning) {
thinkingTextParts.push(reasoning);
}
continue;
}

const text = partToText(part);
if (text) {
textParts.push(text);
Expand Down
26 changes: 25 additions & 1 deletion src/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
normalizeResponsesFullResponse,
normalizeResponsesStreamEvent,
} from "./routing";
import { createUsageDataParts } from "./chatParts";
import { createReasoningMarkerPart, createUsageDataParts } from "./chatParts";
import {
clearContextWindowRequest,
reportProgressWithContextWindowRequest,
Expand Down Expand Up @@ -120,6 +120,12 @@ export async function streamChatCompletions(options: StreamRequestOptions): Prom

extractor.flushRemainingToolCalls(options.progress, options.requestHeaders["x-opencode-request"]);
extractor.flushReasoningFallback(options.progress, options.requestHeaders["x-opencode-request"]);
// Thinking-off responses surfaced the reasoning as visible text (gateway
// bug #37635); attach the marker so the next turn echoes reasoning_content.
const reasoningMarker = extractor.flushReasoningMarker();
if (reasoningMarker) {
options.progress.report(reasoningMarker);
}
options.output?.appendLine(
`[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`,
);
Expand Down Expand Up @@ -953,6 +959,23 @@ class OpenAiResponseExtractor {
*
* Returns the reasoning string that was handled (for logging/debug).
*/
/** Reasoning emitted as visible text (gateway bug #37635, thinking OFF). */
private reasoningAsContent = "";

/**
* Emit an internal marker part carrying the reasoning that was surfaced as
* visible text, so the next turn can echo it back as reasoning_content.
* Called after the stream completes; the transcript keeps the marker.
*/
flushReasoningMarker(): vscode.LanguageModelResponsePart2 | undefined {
if (!this.treatReasoningAsContent || !this.reasoningAsContent) {
return undefined;
}
const part = createReasoningMarkerPart(this.reasoningAsContent);
this.reasoningAsContent = "";
return part;
}

private handleReasoning(reasoning: string): string {
if (!reasoning) {
return "";
Expand Down Expand Up @@ -1047,6 +1070,7 @@ class OpenAiResponseExtractor {
if (this.treatReasoningAsContent && !visible && text.length === 0) {
if (!this.shouldSuppressThinkingEmit(reasoning)) {
this.emittedTextLength += reasoning.length;
this.reasoningAsContent += reasoning;
parts.push(new vscode.LanguageModelTextPart(reasoning));
}
} else {
Expand Down
72 changes: 72 additions & 0 deletions src/test/chatParts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import Module from "node:module";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";

// ── vscode stub ─────────────────────────────────────────────────────────────
// chatParts.ts instantiates vscode.LanguageModelDataPart; redirect require
// ("vscode") to a tiny stub like the other vscode-dependent tests do.

const vscodeMockPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "vscode-mock-opencode-")), "index.js");
fs.writeFileSync(
vscodeMockPath,
`"use strict";
class LanguageModelDataPart {
constructor(data, mimeType) {
this.data = data;
this.mimeType = mimeType;
}
}
module.exports = { LanguageModelDataPart };
`,
"utf-8",
);

type ResolveFilename = (request: string, parent: unknown, ...args: unknown[]) => string;
const moduleResolver = Module as unknown as { _resolveFilename: ResolveFilename };
const originalResolveFilename = moduleResolver._resolveFilename;
moduleResolver._resolveFilename = function (request: string, parent: unknown, ...args: unknown[]): string {
if (request === "vscode") {
return vscodeMockPath;
}
return originalResolveFilename.call(this, request, parent, ...args);
};

let createReasoningMarkerPart: (reasoning: string) => { data: Uint8Array; mimeType: string };
let isReasoningMarkerPart: (part: { data: Uint8Array; mimeType: string }) => boolean;
let readReasoningMarker: (part: { data: Uint8Array; mimeType: string }) => string | undefined;

describe("chatParts reasoning marker", () => {
before(async () => {
const mod = await import("../chatParts.js");
createReasoningMarkerPart = mod.createReasoningMarkerPart;
isReasoningMarkerPart = mod.isReasoningMarkerPart;
readReasoningMarker = mod.readReasoningMarker;
});

it("round-trips the reasoning text through the marker part", () => {
const part = createReasoningMarkerPart("step 1\nstep 2");
assert.equal(part.mimeType, "application/vnd.opencode.reasoning+json");
assert.equal(isReasoningMarkerPart(part), true);
assert.equal(readReasoningMarker(part), "step 1\nstep 2");
});

it("is not mistaken for other internal data parts", () => {
const part = createReasoningMarkerPart("x");
const usageLike = { mimeType: "application/vnd.opencode.usage+json", data: new Uint8Array() };
assert.equal(isReasoningMarkerPart(usageLike), false);
assert.equal(part.mimeType === usageLike.mimeType, false);
});

it("returns undefined for malformed or foreign marker payloads", () => {
const malformed = { data: new TextEncoder().encode("not json"), mimeType: "application/vnd.opencode.reasoning+json" };
assert.equal(readReasoningMarker(malformed), undefined);
const wrongShape = {
data: new TextEncoder().encode(JSON.stringify({ other: 1 })),
mimeType: "application/vnd.opencode.reasoning+json",
};
assert.equal(readReasoningMarker(wrongShape), undefined);
});
});
Loading