From 27e89f75efc98d53c2f54e6754476b2feac69556 Mon Sep 17 00:00:00 2001 From: Ada Urbaniec Date: Tue, 13 Oct 2026 13:19:49 +0200 Subject: [PATCH 1/2] fix(openai-chat): preserve Mistral typed text and thinking blocks * Preserve typed content in both response paths. * Add regression tests and update adapter documentation. --- .../src/content/docs/reference/adapters.md | 4 + scripts/test-layout/layout.json | 2 +- src/adapters/openai-chat.ts | 12 +- src/adapters/openai-chat/response-events.ts | 43 +++- structure/providers/chat-compat.md | 3 + .../openai-chat-mistral-content.test.ts | 212 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 271 insertions(+), 6 deletions(-) create mode 100644 tests/adapters/openai/openai-chat-mistral-content.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 4e050d5d247..e46bd6dc8e7 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -77,6 +77,10 @@ transport; it does not infer subscription attribution from the inbound protocol. collects `usage`. Providers listed in `reasoningDetailsModels` (MiniMax M-series) instead read structured `delta.reasoning_details` segments, whose `text` arrives as cumulative snapshots and is prefix-diffed, and replay preserved reasoning as a `reasoning_details` array. +- For `api.mistral.ai`, both response paths also accept typed `text` and `thinking` + arrays. Text keeps its bytes and order; thinking stays separate from the answer. + Unsupported or malformed array blocks return an upstream error. Other hosts + keep their existing behavior. This does not add Mistral signature replay. - Suppresses bare `` text when it duplicates a structured call, and collapses two immediately adjacent identical blocks when exactly one structured call agrees with their function and input. A doubled `input` is reduced to one copy, joined either directly or by one newline, diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e90f18ff399..9d53f42e4ce 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1319,7 +1319,7 @@ "ollama-show-ignore-abort.test.ts": "providers/ollama", "omo-client.test.ts": "clients", "omp-path-contract.test.ts": "clients", "omp-yaml-source-inline-comments.test.ts": "clients", "opaque-blob-wrapped-rejection.test.ts": "responses", "openai-api-virtual-models.test.ts": "adapters/openai", "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", "openai-chat-developer-position.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", "openai-empty-assistant-history.test.ts": "adapters/openai", "openai-chat-image-normalization.test.ts": "adapters/openai", - "openai-chat-inline-think-tags.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", + "openai-chat-inline-think-tags.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-mistral-content.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-qwen38-leading-system.test.ts": "adapters/openai", "openai-chat-reasoning-wire-policy.test.ts": "adapters/openai", "openai-chat-sanitization-review-regressions.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-content.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-hold-bound.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-think.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", "responses-chat-tool-call-content.test.ts": "responses", "openai-chat-tool-result-images.test.ts": "adapters/openai", "openai-chat-url.test.ts": "adapters/openai", diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 56ef351845e..be6a6b81ffc 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -5,7 +5,7 @@ import { applyExplicitChatReasoningWirePolicy } from "./openai-chat/reasoning-wi import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; import { modelInList } from "../types"; -import { createInlineThinkContentSplitter, splitInlineThinkContent } from "./inline-think-tags"; +import { createInlineThinkContentSplitter } from "./inline-think-tags"; import { mapReasoningEffort } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; @@ -28,6 +28,7 @@ import { type InvalidToolCallDiagnostic, } from "./openai-chat/tool-call-validation"; import { + chatContentEvents, splitChatContentEvents, createReasoningDetailSnapshotTracker, invalidChoicesEvent, invalidToolCallsEvent, @@ -444,8 +445,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const reasoningText = reasoningTextFrom(delta); if (reasoningText !== undefined) yield* toolCallContent.hold({ type: "reasoning_raw_delta", text: reasoningText }); } - if (typeof delta.content === "string" && delta.content.length > 0) { - yield* emitContent(inlineThink.feed(delta.content)); + for (const event of chatContentEvents(delta.content, provider.baseUrl, pendingUsage)) { + if (event.type === "error") return yield* terminateWithError(event); + yield* event.type === "text_delta" ? emitContent(inlineThink.feed(event.text)) : toolCallContent.hold(event); } const rawToolCalls = delta.tool_calls; @@ -767,7 +769,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText }); const contentStart = events.length; - if (typeof msg.content === "string") events.push(...splitInlineThinkContent(provider.inlineThinkTagModels, lastRequestedModelId, budget, msg.content)); + const contentEvents = splitChatContentEvents(msg.content, provider, lastRequestedModelId, budget, usage); + if (contentEvents.some(event => event.type === "error")) return contentEvents; + events.push(...contentEvents); const contentEnd = events.length; const answerText = events.slice(contentStart).map(event => (event.type === "text_delta" ? event.text : "")).join(""); // Each call holds the delta event it emitted, so the batch repair sets its arguments later. diff --git a/src/adapters/openai-chat/response-events.ts b/src/adapters/openai-chat/response-events.ts index 3078accadd4..e6a9750610c 100644 --- a/src/adapters/openai-chat/response-events.ts +++ b/src/adapters/openai-chat/response-events.ts @@ -1,6 +1,7 @@ import { diagnoseInvalidToolCalls, isRecord, type InvalidToolCallDiagnostic } from "./tool-call-validation"; import { TranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; -import type { AdapterEvent, OcxUsage } from "../../types"; +import type { AdapterEvent, OcxProviderConfig, OcxUsage } from "../../types"; +import { createInlineThinkContentSplitter } from "../inline-think-tags"; export function stopReasonFor(finishReason: unknown): "max_tokens" | "content_filter" | undefined { return finishReason === "length" @@ -18,6 +19,46 @@ export function reasoningTextFrom(record: Record): string | und : undefined; } +type ChatContentEvent = Extract; + +/** Mistral can mix typed blocks and strings in one response. Other destinations keep their parser. */ +export function chatContentEvents(content: unknown, baseUrl: string, usage?: OcxUsage, buffered = false): ChatContentEvent[] { + if (typeof content === "string") return content.length > 0 || buffered ? [{ type: "text_delta", text: content }] : []; + if (!Array.isArray(content)) return []; + let host: string; + try { host = new URL(baseUrl).hostname; } catch { return []; } + if (host !== "api.mistral.ai") return []; + const invalid = (): ChatContentEvent[] => [{ + type: "error", status: 502, errorType: "upstream_error", + message: "unsupported Mistral content block", ...(usage ? { usage } : {}), + }]; + const events: ChatContentEvent[] = []; + for (const block of content) { + if (!isRecord(block)) return invalid(); + if (block.type === "text" && typeof block.text === "string") { + if (block.text.length > 0 || buffered) events.push({ type: "text_delta", text: block.text }); + } else if (block.type === "thinking" && Array.isArray(block.thinking)) { + for (const inner of block.thinking) { + if (!isRecord(inner) || inner.type !== "text" || typeof inner.text !== "string") return invalid(); + if (inner.text.length > 0) events.push({ type: "reasoning_raw_delta", text: inner.text }); + } + } else return invalid(); + } + return events; +} + +/** Use one splitter for the whole buffered answer, including adjacent typed text blocks. */ +export function splitChatContentEvents(content: unknown, provider: OcxProviderConfig, modelId: string | undefined, budget: TranslatorBudget, usage?: OcxUsage): AdapterEvent[] { + const events = chatContentEvents(content, provider.baseUrl, usage, true); + if (events.some(event => event.type === "error")) return events; + const splitter = createInlineThinkContentSplitter(provider.inlineThinkTagModels, modelId, budget); + try { + return [...events.flatMap(event => event.type === "text_delta" ? splitter.feed(event.text) : [event]), ...splitter.flush()]; + } finally { + splitter.dispose(); + } +} + export interface ReasoningDetailSegment { key: string; text: string; diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 1f994021705..6f253af6a2d 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -47,6 +47,9 @@ Shared parsing and streaming follow the [request-copy](../transports/byte-accoun ## Reasoning and tool-result compatibility +For `api.mistral.ai`, `src/adapters/openai-chat/response-events.ts` preserves typed text/thinking arrays in both response paths and rejects unsupported blocks. It adds no signature replay. +Tests: `tests/adapters/openai/openai-chat-mistral-content.test.ts`; other hosts, byte budgets and configured replay rules keep their existing behavior. + ### Inline think-tag recovery A gateway that serves a thinking model without a server-side reasoning parser returns the chain diff --git a/tests/adapters/openai/openai-chat-mistral-content.test.ts b/tests/adapters/openai/openai-chat-mistral-content.test.ts new file mode 100644 index 00000000000..44f07f1a1f5 --- /dev/null +++ b/tests/adapters/openai/openai-chat-mistral-content.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { buildResponseJSON } from "../../../src/bridge"; +import { createTranslatorBudget, releaseTranslatedEvent, type TranslatorBudget } from "../../../src/lib/translator-budget"; +import { parseRequest } from "../../../src/responses/parser"; +import type { AdapterEvent } from "../../../src/types"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +const BASE_URL = "https://api.mistral.ai/v1"; +const MODEL = "zai-glm-5-3"; +const ANSWER = '{"outcome":"deny"}'; +const USAGE = { prompt_tokens: 10, completion_tokens: 5 }; +const thinking = (text: string) => ({ type: "thinking", thinking: [{ type: "text", text }], closed: true }); +const text = (value: string) => ({ type: "text", text: value }); +const terminal = { choices: [{ delta: {}, finish_reason: "stop" }], usage: USAGE }; + +function adapter(baseUrl = BASE_URL) { + return createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl }); +} + +function streamResponse(chunks: unknown[]): Response { + return new Response(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n"); +} + +function responsePayload(content: unknown) { + return { choices: [{ message: { role: "assistant", content }, finish_reason: "stop" }], usage: USAGE }; +} + +async function collect(chunks: unknown[], budget = createTestTranslatorBudget(), baseUrl = BASE_URL) { + const events: AdapterEvent[] = []; + for await (const event of adapter(baseUrl).parseStream(streamResponse(chunks), budget)) { + if (event.type !== "heartbeat") events.push(event); + } + return events; +} + +function joined(events: AdapterEvent[], type: "text_delta" | "reasoning_raw_delta") { + return events.filter(event => event.type === type).map(event => ("text" in event ? event.text : "")).join(""); +} + +async function parse(mode: "stream" | "buffered", content: unknown, baseUrl = BASE_URL) { + return mode === "stream" + ? collect([{ choices: [{ delta: { content } }], usage: USAGE }, terminal], undefined, baseUrl) + : adapter(baseUrl).parseResponse!(Response.json(responsePayload(content)), createTestTranslatorBudget()); +} + +describe("Mistral typed response content", () => { + test("mixed array and string deltas preserve the JSON prefix", async () => { + const events = await collect([ + { choices: [{ delta: { content: [thinking("Synthetic reasoning."), text('{"outcome')] } }] }, + { choices: [{ delta: { content: '":"deny"}' } }] }, + terminal, + ]); + expect(joined(events, "text_delta")).toBe(ANSWER); + expect(JSON.parse(joined(events, "text_delta"))).toEqual({ outcome: "deny" }); + expect(joined(events, "reasoning_raw_delta")).toBe("Synthetic reasoning."); + expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 10, outputTokens: 5 } }); + const response = buildResponseJSON(events, MODEL); + expect(response.output).toContainEqual(expect.objectContaining({ type: "message", content: [{ type: "output_text", text: ANSWER, annotations: [] }] })); + }); + + for (const mode of ["stream", "buffered"] as const) { + test(`${mode}: text and thinking remain in source order`, async () => { + const events = await parse(mode, [text("A\n"), thinking("first"), text("B😀"), thinking("second"), text(" C")]); + expect(events.filter(event => event.type !== "done")).toEqual([ + { type: "text_delta", text: "A\n" }, { type: "reasoning_raw_delta", text: "first" }, + { type: "text_delta", text: "B😀" }, { type: "reasoning_raw_delta", text: "second" }, + { type: "text_delta", text: " C" }, + ]); + }); + + test(`${mode}: complete typed JSON becomes complete Responses text`, async () => { + const events = await parse(mode, [thinking("Synthetic reasoning."), text(ANSWER)]); + expect(joined(events, "text_delta")).toBe(ANSWER); + expect(joined(events, "reasoning_raw_delta")).toBe("Synthetic reasoning."); + expect(events.at(-1)?.type).toBe("done"); + }); + + test.each([BASE_URL, "https://api.openai.com/v1"])(`${mode}: ordinary strings remain unchanged at %s`, async baseUrl => { + const events = await parse(mode, ANSWER, baseUrl); + expect(joined(events, "text_delta")).toBe(ANSWER); + expect(joined(events, "reasoning_raw_delta")).toBe(""); + }); + + test.each(["https://api.openai.com/v1", "https://api.mistral.ai.example.test/v1", "https://example.test/api.mistral.ai/v1"])(`${mode}: array handling is limited to the Mistral host (%s)`, async baseUrl => { + const events = await parse(mode, [thinking("Synthetic reasoning."), text(ANSWER)], baseUrl); + expect(joined(events, "text_delta")).toBe(""); + expect(joined(events, "reasoning_raw_delta")).toBe(""); + expect(events.at(-1)?.type).toBe("done"); + }); + + test.each([[null], [[]], [[thinking("")]]])(`${mode}: empty content remains valid (%j)`, async content => { + const events = await parse(mode, content); + expect(joined(events, "text_delta")).toBe(""); + expect(joined(events, "reasoning_raw_delta")).toBe(""); + expect(events.at(-1)?.type).toBe("done"); + }); + + test.each([ + [null], [7], [{ type: "text", text: 7 }], [{ type: "image_url", image_url: "fixture" }], + [{ type: "thinking", thinking: null }], [{ type: "thinking", thinking: [null] }], + [{ type: "thinking", thinking: [{ type: "text", text: 7 }] }], + [{ type: "thinking", thinking: [{ type: "image", text: "fixture" }] }], + ])(`${mode}: malformed or unsupported blocks fail (%j)`, async block => { + // A valid prefix must not escape from an invalid content array. + const events = await parse(mode, [text("do not release this prefix"), block]); + expect(events).toEqual([{ type: "error", status: 502, errorType: "upstream_error", + message: "unsupported Mistral content block", usage: { inputTokens: 10, outputTokens: 5 } }]); + }); + } + + test("empty stream padding does not count as output at a truncated EOF", async () => { + const events: AdapterEvent[] = []; + const response = new Response('data: {"choices":[{"delta":{"content":""}}]}\n\n'); + for await (const event of adapter().parseStream(response, createTestTranslatorBudget())) events.push(event); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test("empty buffered strings retain their existing empty text event", async () => { + const events = await parse("buffered", ""); + expect(events[0]).toEqual({ type: "text_delta", text: "" }); + }); + + test("thinking supports several typed text items", async () => { + const events = await parse("stream", [{ type: "thinking", thinking: [text("one"), text("two")] }, text(ANSWER)]); + expect(joined(events, "reasoning_raw_delta")).toBe("onetwo"); + expect(joined(events, "text_delta")).toBe(ANSWER); + }); + + test.each(["stream", "buffered"] as const)("%s: inline thinking tags can cross typed text blocks", async mode => { + const provider = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: BASE_URL, inlineThinkTagModels: [MODEL] }); + await provider.buildRequest(parseRequest({ model: MODEL, input: "fixture" })); + const content = [text("inneranswer")]; + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = mode === "buffered" + ? await provider.parseResponse!(Response.json(responsePayload(content)), budget) + : []; + if (mode === "stream") { + for await (const event of provider.parseStream(streamResponse([{ choices: [{ delta: { content } }] }, terminal]), budget)) events.push(event); + } + expect(joined(events, "reasoning_raw_delta")).toBe("inner"); + expect(joined(events, "text_delta")).toBe("answer"); + }); + + test("typed reasoning and a tool call cross Responses continuation", async () => { + const events = await collect([ + { choices: [{ delta: { content: [thinking("Inspect the file."), text("Checking.")], + tool_calls: [{ index: 0, id: "call_read", type: "function", function: { name: "read_file", arguments: '{"path":"fixture.txt"}' } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: USAGE }, + ]); + const response = buildResponseJSON(events, MODEL, { hideThinkingSummary: true }); + const next = parseRequest({ model: MODEL, input: [...response.output, + { type: "function_call_output", call_id: "call_read", output: "fixture result" }] }); + expect(next.context.messages.some(message => message.role === "assistant" && message.content.some( + part => part.type === "thinking" && part.thinking === "Inspect the file.", + ))).toBe(true); + const wire = JSON.parse((await adapter().buildRequest(next)).body); + expect(wire.messages).toContainEqual(expect.objectContaining({ role: "assistant", tool_calls: [ + { id: "call_read", type: "function", function: { name: "read_file", arguments: '{"path":"fixture.txt"}' } }, + ] })); + expect(wire.messages).toContainEqual(expect.objectContaining({ role: "tool", tool_call_id: "call_read", content: "fixture result" })); + expect(joined(events, "text_delta")).toBe("Checking."); + }); + + test("invalid typed content discards an open tool call", async () => { + const events = await collect([ + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_read", type: "function", function: { name: "read_file", arguments: "{}" } }] } }] }, + { choices: [{ delta: { content: [{ type: "text", text: 7 }] } }], usage: USAGE }, + terminal, + ]); + expect(events.some(event => event.type === "tool_call_start" || event.type === "done")).toBe(false); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502 }); + }); + + test("buffered typed output keeps event-byte accounting and releases its leases", async () => { + const budget = createTestTranslatorBudget(); + const events = await adapter().parseResponse!(Response.json(responsePayload([thinking("why"), text(ANSWER)])), budget); + expect(budget.snapshot().currentBytes).toBe(Buffer.byteLength(JSON.stringify(events))); + for (const event of events) releaseTranslatedEvent(event, budget); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("buffered typed events cannot exceed the translator budget", async () => { + const payload = responsePayload([thinking("why"), text(ANSWER)]); + const budget = createTestTranslatorBudget({ maxTurnBytes: Buffer.byteLength(JSON.stringify(payload)) + 1 }); + await expect(adapter().parseResponse!(Response.json(payload), budget)).rejects.toThrow(/translator/); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, overflows: 1 }); + }); + + test("stream overflow cancels the reader and releases tool and text buffers", async () => { + const budget = createTranslatorBudget({ maxTurnBytes: 512 }); + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ choices: [{ delta: { + content: [thinking("x".repeat(1024)), text(ANSWER)], + } }] })}\n\n`)); + }, + cancel() { cancelled = true; }, + }); + try { + const events: AdapterEvent[] = []; + for await (const event of adapter().parseStream(new Response(body), budget)) events.push(event); + expect(events).toContainEqual(expect.objectContaining({ type: "error", code: "translation_buffer_limit" })); + expect(cancelled).toBe(true); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 1 }); + } finally { + budget.dispose(); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 15f21072772..be1d2c37339 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -838,6 +838,7 @@ "openai-chat-hardening.test.ts": "adapters/openai", "openai-empty-assistant-history.test.ts": "adapters/openai", "openai-chat-image-normalization.test.ts": "adapters/openai", "openai-chat-inline-think-tags.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", + "openai-chat-mistral-content.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-qwen38-leading-system.test.ts": "adapters/openai", From 1d8d96bd653c817a89208ad29fd4c2ae14d963df Mon Sep 17 00:00:00 2001 From: Ada Urbaniec Date: Tue, 13 Oct 2026 14:17:07 +0200 Subject: [PATCH 2/2] fix(openai-chat): reset inline tags at typed reasoning boundaries * Flush pending text before typed reasoning in both response paths. * Add four regression cases and document the boundary. Review: https://github.com/lidge-jun/opencodex/pull/7047#discussion_r4258351146 --- .../src/content/docs/reference/adapters.md | 1 + src/adapters/openai-chat.ts | 7 ++-- src/adapters/openai-chat/response-events.ts | 23 +++++++++++-- structure/providers/chat-compat.md | 2 +- .../openai-chat-mistral-content.test.ts | 32 +++++++++++++++++++ 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index e46bd6dc8e7..70cf9514f98 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -79,6 +79,7 @@ transport; it does not infer subscription attribution from the inbound protocol. is prefix-diffed, and replay preserved reasoning as a `reasoning_details` array. - For `api.mistral.ai`, both response paths also accept typed `text` and `thinking` arrays. Text keeps its bytes and order; thinking stays separate from the answer. + Adjacent text blocks share inline-tag parsing; typed thinking resets it. Unsupported or malformed array blocks return an upstream error. Other hosts keep their existing behavior. This does not add Mistral signature replay. - Suppresses bare `` text when it duplicates a structured call, and collapses two diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index be6a6b81ffc..2badf4772f1 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -5,7 +5,6 @@ import { applyExplicitChatReasoningWirePolicy } from "./openai-chat/reasoning-wi import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; import { modelInList } from "../types"; -import { createInlineThinkContentSplitter } from "./inline-think-tags"; import { mapReasoningEffort } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; @@ -28,7 +27,7 @@ import { type InvalidToolCallDiagnostic, } from "./openai-chat/tool-call-validation"; import { - chatContentEvents, splitChatContentEvents, + chatContentEvents, createChatContentSplitter, splitChatContentEvents, createReasoningDetailSnapshotTracker, invalidChoicesEvent, invalidToolCallsEvent, @@ -369,7 +368,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const reasoningDetailsOptIn = modelInList(provider.reasoningDetailsModels, lastRequestedModelId ?? ""); // A gateway with no server-side reasoning parser leaves thinking inline in `content` as // blocks, which would otherwise render as the answer. Passthrough unless opted in. - const inlineThink = createInlineThinkContentSplitter(provider.inlineThinkTagModels, lastRequestedModelId, budget); + const inlineThink = createChatContentSplitter(provider, lastRequestedModelId, budget); const emitContent = function* (events: AdapterEvent[]): Generator { for (const event of events) { // Any other event keeps its place behind held text instead of overtaking it. @@ -447,7 +446,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } for (const event of chatContentEvents(delta.content, provider.baseUrl, pendingUsage)) { if (event.type === "error") return yield* terminateWithError(event); - yield* event.type === "text_delta" ? emitContent(inlineThink.feed(event.text)) : toolCallContent.hold(event); + yield* emitContent(inlineThink.feed(event)); } const rawToolCalls = delta.tool_calls; diff --git a/src/adapters/openai-chat/response-events.ts b/src/adapters/openai-chat/response-events.ts index e6a9750610c..ae0d53b019e 100644 --- a/src/adapters/openai-chat/response-events.ts +++ b/src/adapters/openai-chat/response-events.ts @@ -47,13 +47,30 @@ export function chatContentEvents(content: unknown, baseUrl: string, usage?: Ocx return events; } -/** Use one splitter for the whole buffered answer, including adjacent typed text blocks. */ +/** Keep adjacent text together; typed reasoning flushes and resets inline-tag parsing. */ +export function createChatContentSplitter(provider: OcxProviderConfig, modelId: string | undefined, budget: TranslatorBudget) { + let splitter = createInlineThinkContentSplitter(provider.inlineThinkTagModels, modelId, budget); + return { + feed(event: ChatContentEvent): AdapterEvent[] { + if (event.type === "error") return [event]; + if (event.type === "text_delta") return splitter.feed(event.text); + const pending = splitter.flush(); + splitter.dispose(); + splitter = createInlineThinkContentSplitter(provider.inlineThinkTagModels, modelId, budget); + return [...pending, event]; + }, + flush: () => splitter.flush(), + dispose: () => splitter.dispose(), + }; +} + +/** Split a buffered answer with the same typed-reasoning boundaries as a stream. */ export function splitChatContentEvents(content: unknown, provider: OcxProviderConfig, modelId: string | undefined, budget: TranslatorBudget, usage?: OcxUsage): AdapterEvent[] { const events = chatContentEvents(content, provider.baseUrl, usage, true); if (events.some(event => event.type === "error")) return events; - const splitter = createInlineThinkContentSplitter(provider.inlineThinkTagModels, modelId, budget); + const splitter = createChatContentSplitter(provider, modelId, budget); try { - return [...events.flatMap(event => event.type === "text_delta" ? splitter.feed(event.text) : [event]), ...splitter.flush()]; + return [...events.flatMap(event => splitter.feed(event)), ...splitter.flush()]; } finally { splitter.dispose(); } diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 6f253af6a2d..d59bf5821e6 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -47,7 +47,7 @@ Shared parsing and streaming follow the [request-copy](../transports/byte-accoun ## Reasoning and tool-result compatibility -For `api.mistral.ai`, `src/adapters/openai-chat/response-events.ts` preserves typed text/thinking arrays in both response paths and rejects unsupported blocks. It adds no signature replay. +For `api.mistral.ai`, `src/adapters/openai-chat/response-events.ts` preserves typed text/thinking arrays in both response paths and rejects unsupported blocks. Inline-tag parsing crosses adjacent text blocks but resets at typed reasoning. It adds no signature replay. Tests: `tests/adapters/openai/openai-chat-mistral-content.test.ts`; other hosts, byte budgets and configured replay rules keep their existing behavior. ### Inline think-tag recovery diff --git a/tests/adapters/openai/openai-chat-mistral-content.test.ts b/tests/adapters/openai/openai-chat-mistral-content.test.ts index 44f07f1a1f5..2273b55c168 100644 --- a/tests/adapters/openai/openai-chat-mistral-content.test.ts +++ b/tests/adapters/openai/openai-chat-mistral-content.test.ts @@ -143,6 +143,38 @@ describe("Mistral typed response content", () => { expect(joined(events, "text_delta")).toBe("answer"); }); + for (const mode of ["stream", "buffered"] as const) { + test.each([ + { name: "partial opening tag", content: [text("answer")], + answer: "answer", reasoning: "typed", + leading: { type: "text_delta", text: "answer" }, + { name: "open thinking block", content: [text("before"), thinking("typed"), text("answer")], + answer: "answer", reasoning: "beforetyped", + leading: { type: "reasoning_raw_delta", text: "before" }, trailing: "answer" }, + ])(`${mode}: typed reasoning ends inline-tag parsing ($name)`, async ({ content, answer, reasoning, leading, trailing }) => { + const provider = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: BASE_URL, inlineThinkTagModels: [MODEL] }); + await provider.buildRequest(parseRequest({ model: MODEL, input: "fixture" })); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = mode === "buffered" + ? await provider.parseResponse!(Response.json(responsePayload(content)), budget) + : []; + if (mode === "stream") { + // Separate deltas also test carry ownership across SSE chunks. + const chunks = content.map(block => ({ choices: [{ delta: { content: [block] } }] })); + for await (const event of provider.parseStream(streamResponse([...chunks, terminal]), budget)) events.push(event); + } + expect(joined(events, "text_delta")).toBe(answer); + expect(joined(events, "reasoning_raw_delta")).toBe(reasoning); + expect(events.filter(event => event.type !== "done")).toEqual([ + leading, + { type: "reasoning_raw_delta", text: "typed" }, + { type: "text_delta", text: trailing }, + ]); + for (const event of events) releaseTranslatedEvent(event, budget); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + } + test("typed reasoning and a tool call cross Responses continuation", async () => { const events = await collect([ { choices: [{ delta: { content: [thinking("Inspect the file."), text("Checking.")],