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
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ 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.
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 `<tool_call>` 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,
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 8 additions & 5 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, splitInlineThinkContent } from "./inline-think-tags";
import { mapReasoningEffort } from "../reasoning-effort";
import { debugProviderDiagnostic } from "../lib/debug";
import { sseFieldValue } from "../lib/sse-decoder";
Expand All @@ -28,6 +27,7 @@ import {
type InvalidToolCallDiagnostic,
} from "./openai-chat/tool-call-validation";
import {
chatContentEvents, createChatContentSplitter, splitChatContentEvents,
createReasoningDetailSnapshotTracker,
invalidChoicesEvent,
invalidToolCallsEvent,
Expand Down Expand Up @@ -368,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
// <think> 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<AdapterEvent> {
for (const event of events) {
// Any other event keeps its place behind held text instead of overtaking it.
Expand Down Expand Up @@ -444,8 +444,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* emitContent(inlineThink.feed(event));
}

const rawToolCalls = delta.tool_calls;
Expand Down Expand Up @@ -767,7 +768,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.
Expand Down
60 changes: 59 additions & 1 deletion src/adapters/openai-chat/response-events.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -18,6 +19,63 @@ export function reasoningTextFrom(record: Record<string, unknown>): string | und
: undefined;
}

type ChatContentEvent = Extract<AdapterEvent, { type: "text_delta" | "reasoning_raw_delta" | "error" }>;

/** 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;
}

/** 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 = createChatContentSplitter(provider, modelId, budget);
try {
return [...events.flatMap(event => splitter.feed(event)), ...splitter.flush()];
} finally {
splitter.dispose();
}
}

export interface ReasoningDetailSegment {
key: string;
text: string;
Expand Down
3 changes: 3 additions & 0 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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

A gateway that serves a thinking model without a server-side reasoning parser returns the chain
Expand Down
Loading
Loading