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
1 change: 1 addition & 0 deletions changelog.d/fixes/10550-responses-reasoning-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Preserve portable plaintext reasoning by default across streaming and non-streaming Chat Completions and Responses routes while keeping provider-bound opaque state target-compatible. Combos now drop incompatible continuation reasoning by default and can explicitly skip incompatible targets, while known providers no longer show redundant encrypted-reasoning controls. (#10550)
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"_rebaseline_2026_08_20_10574_reasoning_transport_fallback": "PR #10574 (jackjinke, fix/responses-reasoning-transport, fixes #10550) own growth: src/sse/handlers/chatHelpers.ts 1017->1019 (+2 = the new reasoningTransportFallback option threaded through executeChatWithBreaker's options destructure and its downstream handleSingleModel call, at the existing per-attempt options-passthrough chokepoint; not extractable without splitting the option-forwarding call itself). Covered by the PR's own reasoning-policy test suite (tests/unit/chatcore-translation-paths.test.ts, tests/unit/combo-attempt-body-isolation-7847.test.ts, tests/unit/reasoning-cache.test.ts, tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts among others), 446/446 focused tests passing.",
"_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).",
"_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).",
"_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)",
Expand Down Expand Up @@ -450,7 +451,7 @@
"src/lib/modelCapabilities.ts": 1006,
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
"open-sse/config/imageRegistry.ts": 1034,
"src/sse/handlers/chatHelpers.ts": 1017,
"src/sse/handlers/chatHelpers.ts": 1019,
"src/shared/middleware/chatBodyAdmission.ts": 1005,
"_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file)."
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const chatgpt_web_codexProvider: RegistryEntry = {
format: "openai-responses",
executor: "chatgpt-web-codex",
baseUrl: "https://chatgpt.com",
reasoningTransport: "opaque",
authType: "apikey",
authHeader: "cookie",
forceStream: true,
Expand Down
1 change: 1 addition & 0 deletions open-sse/config/providers/registry/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const codexProvider: RegistryEntry = {
format: "openai-responses",
executor: "codex",
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
reasoningTransport: "opaque",
authType: "oauth",
authHeader: "bearer",
defaultContextLength: 400000,
Expand Down
1 change: 1 addition & 0 deletions open-sse/config/providers/registry/grok-cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const grok_cliProvider: RegistryEntry = {
// Keep the generic translate-path contract stable. GrokCliExecutor owns the
// official Grok Build upstream URL and always dispatches to /v1/responses.
baseUrl: "https://cli-chat-proxy.grok.com/v1/chat/completions",
reasoningTransport: "opaque",
modelsUrl: GROK_BUILD_MODELS_URL,
clientVersion: getGrokBuildClientVersion(),
authType: "oauth",
Expand Down
1 change: 1 addition & 0 deletions open-sse/config/providers/registry/muse-code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEnt
id: "muse-code",
alias: "mc",
passthroughModels: true,
reasoningTransport: "opaque",
defaultContextLength: 200000,
models: [
{
Expand Down
1 change: 1 addition & 0 deletions open-sse/config/providers/registry/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const openaiProvider: RegistryEntry = {
format: "openai",
executor: "default",
baseUrl: "https://api.openai.com/v1/chat/completions",
reasoningTransport: "opaque",
authType: "apikey",
authHeader: "bearer",
defaultContextLength: 128000,
Expand Down
2 changes: 2 additions & 0 deletions open-sse/config/providers/registry/xai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const xaiProvider: RegistryEntry = {
// XaiExecutor.buildUrl (open-sse/executors/xai.ts) for models tagged
// targetFormat: "openai-responses" below.
responsesBaseUrl: "https://api.x.ai/v1/responses",
reasoningTransport: "opaque",
authType: "apikey",
authHeader: "bearer",
models: [
Expand Down Expand Up @@ -54,6 +55,7 @@ export const xai_oauthProvider: RegistryEntry = {
executor: "xai-oauth",
baseUrl: xaiProvider.baseUrl,
responsesBaseUrl: xaiProvider.responsesBaseUrl,
reasoningTransport: "opaque",
authType: "oauth",
authHeader: xaiProvider.authHeader,
passthroughModels: true,
Expand Down
4 changes: 4 additions & 0 deletions open-sse/config/providers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export interface RegistryOAuth {
pollUrlBase?: string;
}

export type ReasoningTransport = "plaintext" | "opaque" | "none";

export interface RegistryEntry {
id: string;
alias?: string;
Expand All @@ -120,6 +122,8 @@ export interface RegistryEntry {
/** Override models URL used only for API key validation, not catalog discovery. */
testKeyModelsUrl?: string;
responsesBaseUrl?: string;
/** Provider-bound replay format; omitted providers accept portable plaintext reasoning. */
reasoningTransport?: ReasoningTransport;
/** Anthropic-native /v1/messages endpoint (e.g. GitHub Copilot's shim) used
* for models tagged `targetFormat: "claude"` on an otherwise openai-format
* provider — see registry/github/index.ts. */
Expand Down
12 changes: 7 additions & 5 deletions open-sse/executors/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
} from "../config/codexIdentity.ts";
import { getAccessToken } from "../services/tokenRefresh.ts";
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts";
import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts";
import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
Expand Down Expand Up @@ -1389,10 +1389,12 @@ export class CodexExecutor extends BaseExecutor {
delete body.session_id;
delete body.conversation_id;

applyResponsesInputPolicy(
body,
credentials?.providerSpecificData?.preserveEncryptedReasoning === true
);
applyReasoningInputPolicy(body, "responses", {
provider: "codex",
preserveEncryptedReasoning:
credentials?.providerSpecificData?.preserveEncryptedReasoning === true,
onIncompatibleReasoning: "drop",
});

if (nativeCodexPassthrough) {
return body;
Expand Down
28 changes: 24 additions & 4 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHe
import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts";
import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts";
import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts";
import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts";
import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts";
import {
createRoutingEvent,
emitRoutingEvent,
Expand Down Expand Up @@ -511,6 +511,7 @@ export async function handleChatCore({
conversationId = null,
modelPinned = false,
skipResourcePressureGuard = false,
reasoningTransportFallback = "skip",
managedLease = null,
}) {
let { provider, model, extendedContext } = modelInfo;
Expand Down Expand Up @@ -1193,11 +1194,30 @@ export async function handleChatCore({
return cacheHit;
}

if (targetFormat === FORMATS.OPENAI_RESPONSES && body && typeof body === "object") {
applyResponsesInputPolicy(
const reasoningInputFormat =
sourceFormat === FORMATS.OPENAI_RESPONSES
? "responses"
: sourceFormat === FORMATS.OPENAI
? "chat"
: null;
if (reasoningInputFormat && body && typeof body === "object") {
const policy = applyReasoningInputPolicy(
body as Record<string, unknown>,
credentials?.providerSpecificData?.preserveEncryptedReasoning === true
reasoningInputFormat,
{
provider,
preserveEncryptedReasoning:
credentials?.providerSpecificData?.preserveEncryptedReasoning === true,
onIncompatibleReasoning: reasoningTransportFallback === "drop" ? "drop" : "reject",
}
);
if (policy.incompatibleReasoning) {
trackPendingRequest(model, provider, connectionId, false);
return createErrorResult(
HTTP_STATUS.BAD_REQUEST,
"Reasoning continuation is not compatible with the selected target"
);
}
}

body = sanitizeChatRequestBody(body, sourceFormat, targetFormat);
Expand Down
14 changes: 10 additions & 4 deletions open-sse/handlers/responseSanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
collapseExcessiveNewlines,
extractThinkingFromContent,
} from "./responseSanitizer/reasoning.ts";
import { applyCacheHitTokensToUsage, applyCacheHitTokensToResponsesUsage } from "./responseSanitizer/cacheHitTokens.ts";
import {
applyCacheHitTokensToUsage,
applyCacheHitTokensToResponsesUsage,
} from "./responseSanitizer/cacheHitTokens.ts";
export {
extractThinkingFromContent,
shouldParseTextualReasoningTags,
Expand All @@ -31,7 +34,9 @@ const ALLOWED_USAGE_FIELDS = new Set([
"total_tokens",
"cached_tokens",
"prompt_tokens_details",
"completion_tokens_details", "cache_read_input_tokens", "cache_creation_input_tokens",
"completion_tokens_details",
"cache_read_input_tokens",
"cache_creation_input_tokens",
// Keep through sanitize → applyClientUsageBuffer so heuristic web usage is
// not inflated by the default USAGE_TOKEN_BUFFER (2000).
"estimated",
Expand Down Expand Up @@ -550,7 +555,7 @@ function sanitizeResponsesUsage(usage: unknown): unknown {
!(toRecord(normalized.input_tokens_details) ?? {}).cached_tokens
) {
normalized.input_tokens_details = {
...(normalized.input_tokens_details as Record<string, unknown> || {}),
...((normalized.input_tokens_details as Record<string, unknown>) || {}),
cached_tokens: normalized.prompt_cache_hit_tokens,
};
}
Expand All @@ -562,7 +567,7 @@ function sanitizeResponsesUsage(usage: unknown): unknown {
!(toRecord(normalized.input_tokens_details) ?? {}).cached_tokens
) {
normalized.input_tokens_details = {
...(normalized.input_tokens_details as Record<string, unknown> || {}),
...((normalized.input_tokens_details as Record<string, unknown>) || {}),
cached_tokens: normalized.cache_read_input_tokens,
};
}
Expand Down Expand Up @@ -863,6 +868,7 @@ function sanitizeResponsesOutputItem(item: unknown, index: number): JsonRecord |
: [];

return {
...itemRecord,
id: toString(itemRecord.id) || `rs_${index}`,
type: "reasoning",
summary,
Expand Down
33 changes: 22 additions & 11 deletions open-sse/handlers/responseTranslator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
caseInsensitiveToolNameLookup,
restoreOpenAIToolNames,
} from "../translator/helpers/toolCallHelper.ts";
import { extractReplayableResponsesReasoningText } from "../services/reasoningInputPolicy.ts";
import { sanitizeToolId } from "../translator/helpers/schemaCoercion.ts";

type JsonRecord = Record<string, unknown>;
Expand Down Expand Up @@ -178,7 +179,8 @@ export function translateNonStreamingResponse(

const messageSelection = findBestMessageText(output);
let textContent = messageSelection.text;
let reasoningContent = "";
let replayableReasoningContent = "";
let reasoningSummary = "";
const toolCalls: JsonRecord[] = [];

for (const item of output) {
Expand All @@ -192,16 +194,22 @@ export function translateNonStreamingResponse(
if (partObj.type === "summary_text" && typeof partObj.text === "string") {
// #9500 — reasoning summary parts are discrete segments; join with "\n\n"
// (matches extractThinkingFromContent convention) so they don't glue back-to-back.
reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text;
reasoningSummary += reasoningSummary ? `\n\n${partObj.text}` : partObj.text;
}
}
} else if (itemObj.type === "reasoning" && Array.isArray(itemObj.summary)) {
for (const part of itemObj.summary) {
const partObj = toRecord(part);
if (partObj.type === "summary_text" && typeof partObj.text === "string") {
// #9500 — reasoning summary parts are discrete segments; join with "\n\n"
// (matches extractThinkingFromContent convention) so they don't glue back-to-back.
reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text;
} else if (itemObj.type === "reasoning") {
const replayable = extractReplayableResponsesReasoningText(itemObj);
if (replayable) {
replayableReasoningContent += replayableReasoningContent
? `\n\n${replayable}`
: replayable;
}
if (Array.isArray(itemObj.summary)) {
for (const part of itemObj.summary) {
const partObj = toRecord(part);
if (partObj.type === "summary_text" && typeof partObj.text === "string") {
reasoningSummary += reasoningSummary ? `\n\n${partObj.text}` : partObj.text;
}
}
}
} else if (itemObj.type === "function_call") {
Expand Down Expand Up @@ -238,8 +246,11 @@ export function translateNonStreamingResponse(
if (textContent) {
message.content = textContent;
}
if (reasoningContent) {
message.reasoning_content = reasoningContent;
if (replayableReasoningContent) {
message.reasoning_content = replayableReasoningContent;
}
if (reasoningSummary) {
message.reasoning_summary = [{ type: "summary_text", text: reasoningSummary }];
}
if (toolCalls.length > 0) {
message.tool_calls = toolCalls;
Expand Down
Loading
Loading