From cfc61ec9d772ee4d32d91716243573ac3cfc4dbc Mon Sep 17 00:00:00 2001 From: Ivan Mezentsev Date: Mon, 4 May 2026 04:29:40 +0300 Subject: [PATCH] fix(copilot): emit compatible reasoning text deltas --- open-sse/handlers/chatCore.ts | 29 +++++++++++++++++-- open-sse/handlers/responseSanitizer.ts | 3 ++ .../translator/response/openai-responses.ts | 5 +++- open-sse/utils/stream.ts | 8 ++++- open-sse/utils/streamHelpers.ts | 1 + tests/integration/chat-pipeline.test.ts | 1 - tests/unit/response-sanitizer.test.ts | 14 +++++++++ .../unit/responses-translation-fixes.test.ts | 23 +++++++++++++++ tests/unit/streamHelpers.test.ts | 5 ++++ 9 files changed, 84 insertions(+), 5 deletions(-) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index dbf58ed0cf15..cce83024d8e1 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -922,6 +922,28 @@ function buildExecutorClientHeaders( return Object.keys(normalized).length > 0 ? normalized : null; } +function isCopilotClient( + headers: Headers | Record | null | undefined, + userAgent?: string | null +) { + const isMatch = (value: unknown) => + typeof value === "string" && value.toLowerCase().includes("copilot"); + + if (isMatch(userAgent)) return true; + + if (headers instanceof Headers) { + for (const [key, value] of headers) { + if (isMatch(key) || isMatch(value)) return true; + } + } else if (headers && typeof headers === "object") { + for (const [key, value] of Object.entries(headers)) { + if (isMatch(key) || isMatch(value)) return true; + } + } + + return false; +} + export async function handleChatCore({ body, modelInfo, @@ -1099,6 +1121,7 @@ export async function handleChatCore({ }); const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); + const copilotCompatibleReasoning = isCopilotClient(clientRawRequest?.headers, userAgent); const clientResponseFormat = sourceFormat === FORMATS.OPENAI_RESPONSES && !isResponsesEndpoint && !isDroidCLI ? FORMATS.OPENAI @@ -3777,7 +3800,8 @@ export async function handleChatCore({ streamStateBody, onStreamComplete, apiKeyInfo, - handleStreamFailure + handleStreamFailure, + copilotCompatibleReasoning ); } else if (needsTranslation(targetFormat, clientResponseFormat)) { // Standard translation for other providers @@ -3793,7 +3817,8 @@ export async function handleChatCore({ streamStateBody, onStreamComplete, apiKeyInfo, - handleStreamFailure + handleStreamFailure, + copilotCompatibleReasoning ); } else { log?.debug?.("STREAM", `Standard passthrough mode`); diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index aa2c0f1e9da1..4b85cb011927 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -691,6 +691,9 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { } if (deltaRecord.reasoning_content !== undefined) { delta.reasoning_content = deltaRecord.reasoning_content; + } + if (deltaRecord.reasoning_text !== undefined) { + delta.reasoning_text = deltaRecord.reasoning_text; } else if (typeof deltaRecord.reasoning === "string" && deltaRecord.reasoning) { // Alias: some providers use 'reasoning' instead of 'reasoning_content' delta.reasoning_content = deltaRecord.reasoning; diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 77289dfb7148..07ff88938511 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -827,6 +827,9 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { if (eventType === "response.reasoning_summary_text.delta") { const reasoningDelta = data.delta || ""; if (!reasoningDelta) return null; + const reasoningDeltaShape = state.copilotCompatibleReasoning + ? { reasoning_text: reasoningDelta } + : { reasoning: { summary: reasoningDelta } }; return { id: state.chatId, object: "chat.completion.chunk", @@ -835,7 +838,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { choices: [ { index: 0, - delta: { reasoning: { summary: reasoningDelta } }, + delta: reasoningDeltaShape, finish_reason: null, }, ], diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 111fd1095de1..b75acecf510e 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -134,6 +134,7 @@ type StreamOptions = { targetFormat?: string; sourceFormat?: string; clientResponseFormat?: string | null; + copilotCompatibleReasoning?: boolean; provider?: string | null; reqLogger?: StreamLogger | null; toolNameMap?: unknown; @@ -150,6 +151,7 @@ type TranslateState = ReturnType & { toolNameMap?: unknown; usage?: unknown; finishReason?: unknown; + copilotCompatibleReasoning?: boolean; /** Accumulated message content for call log response body */ accumulatedContent?: string; upstreamError?: { @@ -531,6 +533,7 @@ export function createSSEStream(options: StreamOptions = {}) { targetFormat, sourceFormat, clientResponseFormat = null, + copilotCompatibleReasoning = false, provider = null, reqLogger = null, toolNameMap = null, @@ -563,6 +566,7 @@ export function createSSEStream(options: StreamOptions = {}) { ...(initState(sourceFormat) as TranslateState), provider, toolNameMap, + copilotCompatibleReasoning, accumulatedContent: "", } : null; @@ -1904,7 +1908,8 @@ export function createSSETransformStreamWithLogger( body: unknown = null, onComplete: ((payload: StreamCompletePayload) => void) | null = null, apiKeyInfo: unknown = null, - onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null + onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null, + copilotCompatibleReasoning = false ) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, @@ -1919,6 +1924,7 @@ export function createSSETransformStreamWithLogger( body, onComplete, onFailure, + copilotCompatibleReasoning, }); } diff --git a/open-sse/utils/streamHelpers.ts b/open-sse/utils/streamHelpers.ts index 7221cd1035b1..2146f8cccd6d 100644 --- a/open-sse/utils/streamHelpers.ts +++ b/open-sse/utils/streamHelpers.ts @@ -45,6 +45,7 @@ export function hasValuableContent(chunk, format) { if (typeof delta.content === "string" && delta.content.length > 0) return true; if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) return true; + if (typeof delta.reasoning_text === "string" && delta.reasoning_text.length > 0) return true; if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) return true; if (chunk.choices[0].finish_reason) return true; if (typeof delta.role === "string" && delta.role.length > 0) return true; diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index f1628033f1b1..5ff4bfa0deb6 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -27,7 +27,6 @@ const { BaseExecutor } = await import("../../open-sse/executors/base.ts"); const { getCircuitBreaker, resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); const { clearProviderFailure } = await import("../../open-sse/services/accountFallback.ts"); -const { setCliCompatProviders } = await import("../../open-sse/config/cliFingerprints.ts"); const originalFetch = globalThis.fetch; const originalRetryDelayMs = BaseExecutor.RETRY_CONFIG.delayMs; diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 0eef836c5462..72a1ced0b6b1 100644 --- a/tests/unit/response-sanitizer.test.ts +++ b/tests/unit/response-sanitizer.test.ts @@ -295,6 +295,20 @@ test("sanitizeStreamingChunk converts reasoning_details arrays in deltas", () => assert.equal((sanitized as any).choices[0].delta.reasoning_content, "alphabeta"); }); +test("sanitizeStreamingChunk preserves Copilot reasoning_text deltas", () => { + const sanitized = sanitizeStreamingChunk({ + choices: [ + { + delta: { + reasoning_text: "copilot reasoning", + }, + }, + ], + }); + + assert.equal((sanitized as any).choices[0].delta.reasoning_text, "copilot reasoning"); +}); + test("sanitize functions return non-object inputs unchanged", () => { assert.equal(sanitizeOpenAIResponse(null), null); assert.equal(sanitizeStreamingChunk("raw text"), "raw text"); diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index 5d83a9587587..1abf64d3eb85 100644 --- a/tests/unit/responses-translation-fixes.test.ts +++ b/tests/unit/responses-translation-fixes.test.ts @@ -409,6 +409,29 @@ test("Responses→Chat streaming: reasoning delta emits reasoning_content in Cha assert.equal(result.choices[0].delta.reasoning.summary, "thinking step..."); }); +test("Responses→Chat streaming: Copilot mode emits reasoning_text for summary deltas", () => { + const state = { + started: false, + chatId: null, + created: null, + toolCallIndex: 0, + finishReasonSent: false, + copilotCompatibleReasoning: true, + }; + + const chunk = { + type: "response.reasoning_summary_text.delta", + delta: "thinking step...", + item_id: "rs_1", + output_index: 0, + summary_index: 0, + }; + const result = openaiResponsesToOpenAIResponse(chunk, state); + assert.ok(result, "should return a chunk"); + assert.equal(result.choices[0].delta.reasoning_text, "thinking step..."); + assert.equal(result.choices[0].delta.reasoning, undefined); +}); + test("Chat→Responses streaming: multiple tags in one chunk handled", () => { const state = initState(FORMATS.OPENAI_RESPONSES); diff --git a/tests/unit/streamHelpers.test.ts b/tests/unit/streamHelpers.test.ts index 0dd175835449..ffc037c1cf9d 100644 --- a/tests/unit/streamHelpers.test.ts +++ b/tests/unit/streamHelpers.test.ts @@ -25,6 +25,11 @@ describe("hasValuableContent", () => { assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true); }); + it("returns true for Copilot reasoning_text", () => { + const chunk = { choices: [{ delta: { reasoning_text: "thinking" } }] }; + assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true); + }); + it("returns true for finish_reason", () => { const chunk = { choices: [{ delta: {}, finish_reason: "stop" }] }; assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true);