diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 55faba32e8d..ff9088e41b1 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -455,10 +455,21 @@ Combo's `decisionProvider`: - Self-hosted services receive each target/effort option as a plain description string (for example `Target openai/gpt-5.6-sol (provider openai, model gpt-5.6-sol) with low reasoning effort.`), because Ollama accepts only string or `null` option descriptions. TypeSafe keeps - receiving the structured option objects. -- OpenCodex offers 2–26 options to a System One-compatible row. Each target contributes one option - per offered reasoning effort; use per-target `reasoningEfforts` to trim them. Fewer than 2 or - more than 26 options fail open without a request. + receiving the structured option objects. When targeting Vercel AI Gateway (`ai-gateway.vercel.sh`), + route options include both `value` and detailed `description` fields. Other self-hosted endpoints, + including a custom `/decisions` path, keep the System One request format. +- OpenCodex offers 2–26 options to a System One-compatible row (`SELF_HOSTED_MIN_OPTIONS = 2`, `SELF_HOSTED_MAX_OPTIONS = 26`). + Each target contributes one option per offered reasoning effort; use per-target `reasoningEfforts` to trim them. + Fewer than 2 or more than 26 options fail open without a request. For Vercel AI Gateway, whose + [OpenAI-compatible Decisions reference](https://vercel.com/docs/ai-gateway/sdks-and-apis/openai-decisions) + supports 1..255 choices, the upper bound expands to 255 options (`DECISIONS_MAX_OPTIONS = 255`); OpenCodex + enforces the 2-choice minimum as local routing policy to select among candidate targets. +- Vercel AI Gateway endpoints (`https://ai-gateway.vercel.sh/v1`) automatically normalize to `/decisions`. + OpenCodex projects structured conversation context into a formatted plain-text `input` string + (`Task:`, `Signals:`, `Recent Assistant:`, `Recent Tool Call:`, and `Tool Output:`). Route answers are accepted + from object (`answers.route`) or choice-array formats, resolving from either `choice` or `value`. Probability + distributions are accepted as key-value maps or probability records; duplicate choice keys or invalid distributions + strictly fail open to the first eligible target. - A cold model load can take tens of seconds, and an aborted decision request makes Ollama abandon the load. Pre-warm the model and keep it resident (`OLLAMA_KEEP_ALIVE=-1`, or `keep_alive`), and raise `decisionTimeoutMs` (1000–120000 ms, default 4000) when the service is slower than four seconds. diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index c25fe2eca78..4dfc4fe57eb 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -7,7 +7,7 @@ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../ import { identifyRoutedModel } from "../identity"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "../tool-catalog-nudge"; import { peekReasoningForCall } from "../../responses/reasoning-replay-cache"; -import { chatReasoningReplayPolicy } from "./reasoning-replay"; +import { chatReasoningReplayPolicy, isReflectionThinkingModel } from "./reasoning-replay"; import type { ClaudeThinkingProjection } from "../../lib/claude-request-projection"; import { inlineDocumentDataUrl } from "../../responses/inline-document"; import type { OcxAssistantMessage, OcxContentPart, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types"; @@ -282,9 +282,15 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv const chatMsg: Record = { role: "assistant" }; if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join(""); let reasoningContent = thinkingParts.map(p => p.thinking).join(""); + // Reflection/Stealth replay a placeholder even on a tool-less turn, but never on an + // empty turn inside an unfinished tool batch: that turn is dropped below, and a + // fabricated reasoning block there would split the call from its real result. + const reflectionPlaceholder = isReflectionThinkingModel(parsed.modelId) + && chatReasoningReplayPolicy(provider, parsed.modelId).placeholder + && !(pendingToolCalls.length > 0 && !chatMsg.content && toolCalls.length === 0 && thinkingParts.length === 0); if ( reasoningContent.length === 0 - && (toolCalls.length > 0 || thinkingParts.length > 0) + && (toolCalls.length > 0 || thinkingParts.length > 0 || reflectionPlaceholder) && wireSerializesThinking ) { const cached = toolCalls diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index 73823aaebcd..5a7165e5947 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -170,15 +170,13 @@ export function buildOpenAIChatPassthroughRequest( // The binding constraint is the final size base + net, so a shrinking replay may legitimately // measure a base above the cap; only a base above max - net makes the result unserializable. let baseBytes = 0; - if (replayPlan.hasChanges) { - try { - baseBytes = jsonUtf8Bytes(transformedBody(), maxSerializedBytes - replayPlan.serializedBytes); - } catch (error) { - if (isTranslatorBudgetExceededError(error)) { - throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes); - } - throw error; + try { + baseBytes = jsonUtf8Bytes(transformedBody(), maxSerializedBytes - replayPlan.serializedBytes); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) { + throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes); } + throw error; } if (replayPlan.serializedBytes > maxSerializedBytes - baseBytes) { throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes); diff --git a/src/adapters/openai-chat/reasoning-replay.ts b/src/adapters/openai-chat/reasoning-replay.ts index 327ee4c1531..96b717c9a89 100644 --- a/src/adapters/openai-chat/reasoning-replay.ts +++ b/src/adapters/openai-chat/reasoning-replay.ts @@ -9,9 +9,15 @@ export const isChatRecord = (value: unknown): value is Rec => const REASONING_CONTENT_FIELD_BYTES = Buffer.byteLength('"reasoning_content":', "utf8"); -const isDeepSeekThinkingModel = (modelId: string): boolean => +export const isDeepSeekThinkingModel = (modelId: string): boolean => /(?:^|\/)deepseek-(?:v\d+(?:\.\d+)*-(?:flash|pro)|flash|pro)(?:$|[-_:])/i.test(modelId); +export const isReflectionThinkingModel = (modelId: string): boolean => + /(?:^|\/)(?:reflection|stealth)(?:$|[-_:/.])/i.test(modelId); + +export const isThinkingModelFamily = (modelId: string): boolean => + isDeepSeekThinkingModel(modelId) || isReflectionThinkingModel(modelId); + /** DeepSeek thinking tool replay is a model contract, independent of the gateway's name. */ export function chatReasoningReplayPolicy(provider: OcxProviderConfig, modelId: string): { preserve: boolean; placeholder: boolean; @@ -19,7 +25,7 @@ export function chatReasoningReplayPolicy(provider: OcxProviderConfig, modelId: // Match whole model-family tokens, including dotted versions, namespace and deployment suffixes. // Legacy chat/reasoner and distilled R1 models do not share the thinking tool-call contract. const preserve = provider.preserveReasoningContentModels === undefined - ? isDeepSeekThinkingModel(modelId) + ? isThinkingModelFamily(modelId) : modelInList(provider.preserveReasoningContentModels, modelId); return { preserve, @@ -59,21 +65,26 @@ export function planNativeChatReasoningReplay( const plans: ReplayMessagePlan[] = []; let serializedBytes = 0; for (const message of messages) { - if (!isChatRecord(message) || message.role !== "assistant" - || (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0)) continue; - const calls = Array.isArray(message.tool_calls) ? message.tool_calls : []; - // DeepSeek's tools contract covers all prior assistant turns, even plain answers. - // Those turns have no call id for original replay; only the configured fallback applies. - if (calls.length === 0 && !(hasTools && isDeepSeekThinkingModel(modelId))) continue; - const cached = new Set(); - for (const tool of calls) { - if (!isChatRecord(tool) || typeof tool.id !== "string") continue; - const text = peekReasoningForCall(tool.id, scope); - if (text) cached.add(text); + if (!isChatRecord(message) || message.role !== "assistant") continue; + if (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0) continue; + let parts: string[]; + if (typeof message.reasoning === "string" && message.reasoning.length > 0) { + parts = [message.reasoning]; + } else { + const calls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + // DeepSeek tools contract covers prior assistant turns when tools are present. + // Thinking models like reflection/beam require reasoning replay even without tools. + if (calls.length === 0 && !(hasTools && isDeepSeekThinkingModel(modelId)) && !isReflectionThinkingModel(modelId)) continue; + const cached = new Set(); + for (const tool of calls) { + if (!isChatRecord(tool) || typeof tool.id !== "string") continue; + const text = peekReasoningForCall(tool.id, scope); + if (text) cached.add(text); + } + parts = cached.size > 0 + ? [...cached] + : policy.placeholder ? [" "] : []; } - const parts = cached.size > 0 - ? [...cached] - : policy.placeholder ? [" "] : []; if (parts.length === 0) continue; // The envelope delta around the joined value is exact for the plain JSON the Chat diff --git a/src/combos/jev-decision-contract.ts b/src/combos/jev-decision-contract.ts index 33e3aa59ee4..9afc8a574c4 100644 --- a/src/combos/jev-decision-contract.ts +++ b/src/combos/jev-decision-contract.ts @@ -38,6 +38,27 @@ export function isSystemOneEndpoint(baseUrl: string): boolean { */ export function jevDecisionEndpointUrl(baseUrl: string): string { const raw = baseUrl.trim(); - const stripped = raw.replace(/\/+$/, ""); + let stripped = raw.replace(/\/+$/, ""); + try { + const parsed = new URL(raw); + if (parsed.hostname === "ai-gateway.vercel.sh") { + if (!stripped.endsWith("/decisions")) { + stripped = stripped.replace(/\/systemone$/, "").replace(/\/+$/, "") + "/decisions"; + } + return stripped; + } + } catch { + return stripped.endsWith("/systemone") ? stripped : raw; + } return stripped.endsWith("/systemone") ? stripped : raw; } + +/** Check if endpoint path targets /decisions or Vercel AI Gateway */ +export function isDecisionsEndpoint(url: string): boolean { + try { + const parsed = new URL(url.trim()); + return parsed.hostname === "ai-gateway.vercel.sh"; + } catch { + return false; + } +} diff --git a/src/combos/jev-service-exchange.ts b/src/combos/jev-service-exchange.ts index 835141f1397..f8d955487b0 100644 --- a/src/combos/jev-service-exchange.ts +++ b/src/combos/jev-service-exchange.ts @@ -12,6 +12,7 @@ import { providerMatchesRegistryTransport } from "../providers/registry"; import type { OcxConfig, OcxProviderConfig } from "../types"; import type { JevDecision, ResolveJevDecisionOptions } from "./jev"; import { + isDecisionsEndpoint, isSystemOneEndpoint, jevDecisionEndpointUrl, JEV_DECISION_TIMEOUT_DEFAULT_MS, @@ -48,6 +49,7 @@ export type JevServiceExchangeOptions = Pick( if (endpoint === null) return { gate: "invalid" }; if (!endpoint) return { gate: "missing_key" }; - const request = prepareRequestBody(prepare, { model: endpoint.model, descriptiveCriteria: endpoint.descriptiveCriteria }); + const request = prepareRequestBody(prepare, { model: endpoint.model, descriptiveCriteria: endpoint.descriptiveCriteria, ...(isDecisionsEndpoint(endpoint.url) ? { url: endpoint.url } : {}) }); if (options.signal?.aborted) throw options.signal.reason; if ("gate" in request) return request; const requestBody = request.body; diff --git a/src/combos/jev.ts b/src/combos/jev.ts index aa4dd02eedd..256b8d38985 100644 --- a/src/combos/jev.ts +++ b/src/combos/jev.ts @@ -8,6 +8,7 @@ import { JEV_MAX_CANDIDATE_FIELD_CHARS, } from "./types"; +import { isDecisionsEndpoint } from "./jev-decision-contract"; import { exchangeJevDecision, JEV_PROVIDER_ID, @@ -25,6 +26,8 @@ export const JEV_MAX_CANDIDATES = 64; /** Ollama's System One choice questions accept 2..26 options; other services share the floor. */ const SELF_HOSTED_MIN_OPTIONS = 2; const SELF_HOSTED_MAX_OPTIONS = 26; +const DECISIONS_MIN_OPTIONS = 2; +const DECISIONS_MAX_OPTIONS = 255; const TASK_CHARS = 500; const TASK_HEAD_CHARS = 320; @@ -570,19 +573,46 @@ export function parseJevDecision( payload: unknown, candidates: readonly JevCandidate[], ): Pick { - if (!isRecord(payload) || !isRecord(payload.answers) || !isRecord(payload.answers.route)) { + if (!isRecord(payload)) { + throw new Error("missing JEV route decision"); + } + let answer: Record | undefined; + if (isRecord(payload.answers) && isRecord(payload.answers.route)) { + answer = payload.answers.route as Record; + } else if (Array.isArray(payload.answers)) { + const routeAnswers = payload.answers.filter((a): a is Record => isRecord(a) && a.name === "route"); + if (routeAnswers.length === 1) { + answer = routeAnswers[0]; + } else if (routeAnswers.length > 1) { + throw new Error("duplicate JEV route decision"); + } + } + if (!answer) { throw new Error("missing JEV route decision"); } - const answer = payload.answers.route; const options = candidateOptions(candidates); - if (typeof answer.choice !== "string" || !options.has(answer.choice)) { + const choiceValue = typeof answer.choice === "string" ? answer.choice : (typeof answer.value === "string" ? answer.value : undefined); + if (!choiceValue || !options.has(choiceValue)) { throw new Error("unknown JEV route choice"); } let chosenProbability: number | undefined; if (answer.probabilities !== undefined) { - const probabilities = answer.probabilities; - if (!isRecord(probabilities)) throw new Error("invalid JEV route probabilities"); + let probabilities: Record; + if (Array.isArray(answer.probabilities)) { + probabilities = {}; + for (const item of answer.probabilities) { + if (!isRecord(item)) throw new Error("invalid JEV route probabilities"); + const k = typeof item.choice === "string" ? item.choice : (typeof item.value === "string" ? item.value : undefined); + if (!k) throw new Error("invalid JEV route probabilities"); + if (Object.hasOwn(probabilities, k)) throw new Error("invalid JEV route probabilities"); + probabilities[k] = item.probability; + } + } else if (isRecord(answer.probabilities)) { + probabilities = answer.probabilities as Record; + } else { + throw new Error("invalid JEV route probabilities"); + } const expected = [...options.keys()].sort(); const actual = Object.keys(probabilities).sort(); if (expected.length !== actual.length || expected.some((key, index) => key !== actual[index])) { @@ -593,7 +623,7 @@ export function parseJevDecision( throw new Error("invalid JEV route probabilities"); } const numeric = values as number[]; - const selected = probabilities[answer.choice] as number; + const selected = probabilities[choiceValue] as number; if (Math.abs(numeric.reduce((sum, value) => sum + value, 0) - 1) > 0.02 || selected < Math.max(...numeric) - 1e-6) { throw new Error("inconsistent JEV route distribution"); @@ -601,7 +631,7 @@ export function parseJevDecision( chosenProbability = selected; } - const option = options.get(answer.choice)!; + const option = options.get(choiceValue)!; const confidence = typeof answer.confidence === "number" && Number.isFinite(answer.confidence) && answer.confidence >= 0 @@ -634,6 +664,31 @@ export function fallbackDecision( * Every operational or response failure returns the supplied first-eligible fallback. A caller * abort is the exception: request cancellation remains cancellation and is rethrown by identity. */ +function formatDecisionsInput(state: Record): string { + const parts: string[] = []; + if (typeof state.task === "string" && state.task.trim()) { + parts.push(`Task: ${state.task.trim()}`); + } + if (isRecord(state.signals)) { + const sigs: string[] = []; + if (state.signals.has_image === true) sigs.push("has_image"); + if (state.signals.tool_history === true) sigs.push("tool_history"); + if (sigs.length > 0) parts.push(`Signals: ${sigs.join(", ")}`); + } + if (typeof state.previous_assistant === "string" && state.previous_assistant.trim()) { + parts.push(`Recent Assistant: ${state.previous_assistant.trim()}`); + } + if (isRecord(state.step) && state.step.type === "tool_step") { + const step = state.step; + const toolName = isRecord(step.tool_call) && typeof step.tool_call.name === "string" ? step.tool_call.name : ""; + const toolOutput = typeof step.last_tool_output_tail === "string" ? step.last_tool_output_tail : ""; + if (toolName || toolOutput) { + parts.push(`Recent Tool Call: ${toolName}\nTool Output: ${toolOutput}`.trim()); + } + } + return parts.length > 0 ? parts.join("\n\n") : JSON.stringify(state); +} + export async function resolveJevDecision(options: ResolveJevDecisionOptions): Promise { const now = options.now ?? Date.now; const startedAt = now(); @@ -647,15 +702,46 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr let candidates = options.candidates; const exchanged = await exchangeJevDecision(options, (endpoint) => { - if (endpoint.descriptiveCriteria) { + const isDecisions = Boolean(endpoint.url && isDecisionsEndpoint(endpoint.url)); + const optionCount = candidateOptions(options.candidates).size; + if (isDecisions) { + if (optionCount < DECISIONS_MIN_OPTIONS) return "no_choices"; + if (optionCount > DECISIONS_MAX_OPTIONS) return "invalid"; + } else if (endpoint.descriptiveCriteria) { // Self-hosted choice questions accept 2..26 options; decide locally instead of spending a // round-trip on a request the service will refuse. - const optionCount = candidateOptions(options.candidates).size; if (optionCount < SELF_HOSTED_MIN_OPTIONS) return "no_choices"; if (optionCount > SELF_HOSTED_MAX_OPTIONS) return "invalid"; } const state = buildJevState(options.body, options.candidates); if (!hasJevDecisionState(state)) return "no_state"; + if (isDecisions && endpoint.url) { + const payload = boundedJevQuotaPayload(jevQuotaCandidates(options), rows => { + const routeOptions = candidateOptions(rows); + return JSON.stringify({ + model: endpoint.model, + input: formatDecisionsInput(state), + questions: [ + { + name: "route", + type: "choice", + instructions: "Which target AND reasoning effort together best fit the next model call? Select sufficient capability and reasoning for a correct next step while avoiding unnecessary resource use.", + choices: [...routeOptions.entries()].map(([choice, opt]) => { + const cand = rows.find(r => r.key === opt.targetKey); + const desc = criterionDescription(opt.criterion, cand?.quota); + const note = cand?.modelProfile?.trim(); + return { + value: choice, + description: note ? `${desc} (${note})` : desc, + }; + }), + }, + ], + }); + }, REQUEST_LIMIT); + candidates = payload.candidates; + return { body: payload.body }; + } const payload = boundedJevQuotaPayload(jevQuotaCandidates(options), rows => JSON.stringify({ model: endpoint.model, state, questions: buildJevRouteQuestion(rows, { descriptiveCriteria: endpoint.descriptiveCriteria }), diff --git a/src/server/chat-native-reasoning.ts b/src/server/chat-native-reasoning.ts index 1c60f1aa963..c7afeceab44 100644 --- a/src/server/chat-native-reasoning.ts +++ b/src/server/chat-native-reasoning.ts @@ -82,8 +82,13 @@ export function nativeChatReasoningCapture( return previous + fragment; } finally { reservation.release(); } }; - if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { - const text = append(state.text, delta.reasoning_content); + const rText = typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0 + ? delta.reasoning_content + : typeof delta.reasoning === "string" && delta.reasoning.length > 0 + ? delta.reasoning + : undefined; + if (rText !== undefined) { + const text = append(state.text, rText); if (text === undefined) return; state.text = text; } @@ -121,8 +126,13 @@ export function nativeChatReasoningCapture( for (const choice of value.choices) { if (!isChatRecord(choice) || !isChatRecord(choice.message)) continue; const message = choice.message; - if (typeof message.reasoning_content !== "string" || !Array.isArray(message.tool_calls)) continue; - remember(message.reasoning_content, message.tool_calls.filter(isChatRecord) + const rText = typeof message.reasoning_content === "string" && message.reasoning_content.length > 0 + ? message.reasoning_content + : typeof message.reasoning === "string" && message.reasoning.length > 0 + ? message.reasoning + : undefined; + if (rText === undefined || !Array.isArray(message.tool_calls)) continue; + remember(rText, message.tool_calls.filter(isChatRecord) .flatMap(tool => typeof tool.id === "string" ? [tool.id] : [])); } }, diff --git a/src/server/management/decision-discovery.ts b/src/server/management/decision-discovery.ts index 8002a9553b4..b58d75108d6 100644 --- a/src/server/management/decision-discovery.ts +++ b/src/server/management/decision-discovery.ts @@ -39,7 +39,7 @@ export function isDecisionModelCandidate(row: DiscoveryModelRow, query = ""): bo const term = normalize(query); const haystack = `${row.id} ${row.owned_by ?? ""} ${row.displayName ?? ""}`.toLowerCase(); if (term.length > 0) return haystack.includes(term); - return DECISION_MODEL_HINT.test(row.id) || DECISION_MODEL_HINT.test(row.owned_by ?? ""); + return DECISION_MODEL_HINT.test(row.id) || DECISION_MODEL_HINT.test(row.owned_by ?? "") || row.id.toLowerCase().includes("laya"); } /** @@ -53,7 +53,17 @@ export function isDecisionModelCandidate(row: DiscoveryModelRow, query = ""): bo export function systemOneEndpoint(baseUrl: string | undefined): string | null { const trimmed = (baseUrl ?? "").trim().replace(/\/+$/, ""); if (!/^https?:\/\//i.test(trimmed)) return null; - return trimmed.endsWith("/systemone") ? trimmed : `${trimmed}/systemone`; + try { + const parsed = new URL(trimmed); + if (parsed.hostname === "ai-gateway.vercel.sh") { + if (trimmed.endsWith("/decisions")) return trimmed; + return `${trimmed.replace(/\/systemone$/, "").replace(/\/+$/, "")}/decisions`; + } + } catch { + return null; + } + if (trimmed.endsWith("/systemone") || trimmed.endsWith("/decisions")) return trimmed; + return `${trimmed}/systemone`; } /** Deduplicate by provider+model so a row that appears twice is probed once. */ diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 1a8472338da..4e24d97a203 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -560,31 +560,9 @@ Managed native Messages retain a coherent observed CLI identity bundle only for ### Native Chat thinking continuation -`src/adapters/openai-chat/reasoning-replay.ts` repairs assistant tool-call history missing -`reasoning_content` before native serialization. Caller-supplied nonempty text wins. The native -lane honors the same preserve/placeholder policy as translated Chat; absent preserve declarations -recognize DeepSeek Flash/Pro families across dotted versions, namespaces and gateway suffixes. -Explicit preserve lists (including `[]`) remain authoritative, and an explicit placeholder list -can disable fabricated placeholders while allowing original cached reasoning. -For tool-enabled DeepSeek requests the configured placeholder also repairs earlier assistant -answers without tool calls. Those turns have no call-id replay entry; supplied reasoning wins. -Before copying replay into the request, native passthrough measures the exact escaped-JSON net -change and checks the final request size (`baseBytes + serializedBytes`) against the fixed -`maxSerializedBytes` cap. For a changed replay, it then reserves that full size as transient -`request_copies` against the translator budget remaining after other retained copies, before -applying replay and serializing the body. The transient reservation is released in `finally`. -Exceeding either limit returns the standard 413 `translation_buffer_limit` response; history is -never silently truncated. Key rotation and retry rebuild and charge the request after releasing -the previous body, preserving the same scope and normal replay behavior within budget. - -`src/server/chat-native-reasoning.ts` records upstream JSON and fragmented SSE reasoning under -all parallel call ids, separately for each choice. Replay requires a sanitized `thread-id`, -`session_id` or `session-id` and the existing exact provider/destination/adapter/model/credential -scope. The native send rebinds that scope before every request build, including key rotation. -Missing identity, a restarted/expired/evicted cache, and another route receive only the configured -placeholder; they never recover another conversation's raw text. SSE capture retains at most -256 KiB and 64 calls within the translator budget and releases on terminal/error/cancellation. -Unfinished tool streams cannot seed replay. This leaves the native wire and response fields intact. +`src/adapters/openai-chat/reasoning-replay.ts` repairs assistant history missing `reasoning_content` before native serialization. Caller-supplied nonempty text wins. The native lane honors the same preserve/placeholder policy as translated Chat; absent preserve declarations recognize DeepSeek Flash/Pro families and Reflection/Stealth families (`isReflectionThinkingModel`). Explicit preserve lists (including `[]`) remain authoritative, and an explicit placeholder list can disable fabricated placeholders while allowing original cached reasoning. While DeepSeek models require reasoning replay for tool-enabled requests, Reflection and Stealth families require continuous reasoning replay across all prior assistant turns even when tools are absent (`hasTools: false`), synthesizing the whitespace placeholder `[" "]` when placeholder is enabled. Upstream reasoning capture in `src/server/chat-native-reasoning.ts` extracts thinking from both `reasoning_content` and `reasoning` across SSE stream deltas and buffered choices. In `src/adapters/openai-chat/passthrough.ts`, passthrough body sizing measures `baseBytes` unconditionally against `maxSerializedBytes - replayPlan.serializedBytes` via `jsonUtf8Bytes(transformedBody(), ...)`. For changed replays, it reserves `baseBytes + replayPlan.serializedBytes` as transient `request_copies` against the remaining translator budget before serializing the body, releasing the reservation in `finally`. Exceeding either limit returns HTTP 413 `translation_buffer_limit` (`TranslatorBudgetExceededError("request_copies", maxSerializedBytes)`); history is never silently truncated. Key rotation and retry rebuild and charge the request after releasing the previous body, preserving scope and normal replay behavior within budget. Model-contract evidence and compatibility policy: Verification date: 2026-10-25. The Reflection/Stealth family default is a defensive compatibility fallback policy based on observed vendor behavior: live testing against Vercel AI Gateway (`reflection-beam`, 2026-10-23) observed rejection of tool-free multi-turn requests with HTTP 400. Because this destination-specific observation is not a vendor guarantee across all possible stealth or reflection hosts, OpenCodex applies this heuristic as a fallback policy while providing explicit operator overrides via `preserveReasoningContentModels` and `requiresReasoningPlaceholderModels`. Continuous CI verification exercises hermetic local fixtures in `tests/responses/chat-native-reasoning-scope.test.ts` (pinning multi-turn non-tool replay, `reasoning` delta capture, and passthrough size checks) without live network requests. + +`src/server/chat-native-reasoning.ts` records upstream JSON and fragmented SSE reasoning under all parallel call ids, separately for each choice. Replay requires a sanitized `thread-id`, `session_id` or `session-id` and the existing exact provider/destination/adapter/model/credential scope. The native send rebinds that scope before every request build, including key rotation. Missing identity, a restarted/expired/evicted cache, and another route receive only the configured placeholder; they never recover another conversation's raw text. SSE capture retains at most 256 KiB and 64 calls within the translator budget and releases on terminal/error/cancellation. Unfinished tool streams cannot seed replay. This leaves the native wire and response fields intact. ## Codex runtime agent assertions diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 4adb8f434cd..34e9dfae339 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -330,9 +330,7 @@ Its scanner `src/adapters/openai-chat/serialized-tool-call-markup.ts` also recog Structured-call reconciliation holds a possible serialized block, resumes ordinary text delivery when the header cannot match, and removes the block only when its function name and freeform body match a structured call's parsed `input` or exact raw arguments from a declared freeform/custom tool in the same response. Valid JSON primitives, arrays, and objects can be raw -freeform input too; the declared tool name and namespace determine whether the bridge would -unwrap an alternate field, and JSON text that changes on dispatch is not treated as executed input. -Malformed JSON on an ordinary function is not raw input. +freeform input too; the declared tool name and namespace fit whether the bridge unwraps an alternate field. A block may close a freeform body with a stray `` and may omit ``, and one newline after the function header is template layout, so MiMo's echoes of those shapes match too (#5724). A forward-only next-line-header cursor keeps adjacent-block scanning linear; closing tags must match the opener’s exact ID (`` for bare blocks). @@ -485,9 +483,7 @@ grouping evidence: `devlog/_fin/260709_native_response_pattern/`. Provider policy `hideRawReasoning` hides only this raw channel (summaries keep streaming); it controls display, not confidentiality ([Responses wire shapes](../transports/responses-wire-shapes.md)). -For models requiring a reasoning placeholder, a preserved thinking-only assistant receives it even -without tools, keeping strict DeepSeek continuations valid (#5421). Chat replay policy in -`src/adapters/openai-chat/reasoning-replay.ts` honors explicit lists and otherwise recognizes DeepSeek Flash/Pro tokens across versions and gateways; [native continuation](../data-planes/inbound-compat.md#native-chat-thinking-continuation) shares this policy and fills configured placeholders on plain assistant history when DeepSeek tools require them. +For models requiring a reasoning placeholder, a preserved thinking-only assistant receives it even without tools, keeping strict DeepSeek continuations valid (#5421). Chat replay policy in `src/adapters/openai-chat/reasoning-replay.ts` honors explicit lists and otherwise recognizes thinking model families (`isThinkingModelFamily`): DeepSeek Flash/Pro (`isDeepSeekThinkingModel`) across versions and gateways, and Reflection/Stealth families (`isReflectionThinkingModel`: `/(?:^|\/)(?:reflection|stealth)(?:$|[-_:/.])/i`). Default replay policy sets `preserve: true` for all thinking model families; operators may override preservation via `provider.preserveReasoningContentModels` or placeholder synthesis via `provider.requiresReasoningPlaceholderModels`. While DeepSeek models scope reasoning replay to tool-enabled turns (`hasTools: true`), Reflection and Stealth families require reasoning replay across all prior assistant turns even when no tools are present (`hasTools: false`), synthesizing whitespace placeholder `[" "]` when `placeholder: true` and cached reasoning is absent. In `src/adapters/openai-chat/messages.ts`, `messagesToChatFormat` checks `isReflectionThinkingModel` to synthesize thinking placeholders when `wireSerializesThinking` is true. Upstream reasoning capture in `src/server/chat-native-reasoning.ts` extracts thinking from both `reasoning_content` and `reasoning` in SSE stream deltas and non-streaming choices. In `src/adapters/openai-chat/passthrough.ts`, `baseBytes` is measured unconditionally against `maxSerializedBytes - replayPlan.serializedBytes` via `jsonUtf8Bytes(transformedBody(), ...)` for both unchanged bodies and changed replays; exceeding budget raises `TranslatorBudgetExceededError("request_copies", maxSerializedBytes)` (HTTP 413), guaranteeing non-replay requests also enforce `maxSerializedBytes` and reserve transient budget. [Native continuation](../data-planes/inbound-compat.md#native-chat-thinking-continuation) shares this replay and byte-accounting policy. Model-contract evidence and compatibility policy: Verification date: 2026-10-25. The Reflection/Stealth family default is a defensive compatibility fallback policy based on observed vendor behavior: live testing against Vercel AI Gateway (`reflection-beam`, 2026-10-23) observed rejection of tool-free multi-turn requests with HTTP 400 (`Missing required parameter: 'messages[12].reasoning_content'`). Because this destination-specific observation is not a vendor guarantee across all possible stealth or reflection hosts, OpenCodex applies this heuristic as a fallback policy while providing explicit operator overrides via `preserveReasoningContentModels` and `requiresReasoningPlaceholderModels`. Continuous CI verification exercises hermetic local fixtures in `tests/responses/chat-native-reasoning-scope.test.ts` (pinning multi-turn non-tool replay, `reasoning` delta capture, and passthrough size checks) without live network requests. The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client thread plus an exact provider destination, wire adapter, final model, and physical credential @@ -514,87 +510,72 @@ explicitly, and image-bearing legacy results remain unsupported rather than losi ## Chat streamed tool-call identity -`src/adapters/openai-chat.ts` retains a call's first observed non-negative safe integer -index as an alias when the call started by ID. Every present, non-null index must -be a number in that range: strings (including numeric and empty strings), booleans, -objects, arrays, negative numbers, fractions and unsafe integers terminate the stream -before any key, alias, ID or last-call matching. `Number.MAX_SAFE_INTEGER` is accepted; -larger integers are rejected because distinct wire literals can parse to the same number. -The invalid-index error releases all pending call reservations without emitting -those calls or a successful completion; invalid indexes are never treated as absent. -Only missing and null indexes are absent-index placeholders. Repeated ID, name and -argument string-field tolerance retains its existing rules. - -For valid indexes, lookup preserves direct-key precedence, then index alias, then -ID fallback. The initial key continues to own all translator budget reservations -and release; learning an alias creates no additional owner. Unassociated index-only -fragments are not guessed onto pending ID-only calls. -`tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, -parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum -safe-integer boundary, invalid index types, missing/null continuations and UTF-8 -byte-limit boundaries. +`src/adapters/openai-chat.ts` retains a call's first observed non-negative safe integer index as an +alias when the call started by ID. Every present, non-null index must be a number in that range: +strings (including numeric and empty strings), booleans, objects, arrays, negative numbers, fractions, +and unsafe integers terminate the stream before any key, alias, ID or last-call matching. +`Number.MAX_SAFE_INTEGER` is accepted; larger integers are rejected because distinct wire literals can parse +to the same number. The invalid-index error releases all pending call reservations without emitting +those calls or a successful completion; invalid indexes are never treated as absent. Only missing and +null indexes are absent-index placeholders. Repeated ID, name and argument string-field tolerance retains +its existing rules. + +For valid indexes, lookup preserves direct-key precedence, then index alias, then ID fallback. The +initial key continues to own all translator budget reservations and release; learning an alias creates +no additional owner. Unassociated index-only fragments are not guessed onto pending ID-only calls. +`tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, parallel/colliding +identities, distinct unsafe raw JSON index literals, the maximum safe-integer boundary, invalid index +types, missing/null continuations and UTF-8 byte-limit boundaries. Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](../transports/responses.md). Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached, rejects inputs above the safe decoded-pixel ceiling, caps native decode work process-wide, and stops queued work when the request is cancelled. ## Anthropic parallel tool use -`options.parallelToolCalls === false` maps onto Anthropic's nested -`tool_choice.disable_parallel_tool_use`. Because the flag lives inside -`tool_choice`, a request that carries only the parallel intent and no explicit -choice gets a synthesized `{type:"auto"}` so the flag has somewhere to live; -`required` maps to `{type:"any"}` and a named choice to `{type:"tool"}`, and both -accept it. `{type:"none"}` does not receive the flag because tool use is already off, -and a request with no tools on the wire emits no `tool_choice` at all. An unset or -true `parallelToolCalls` is byte-identical to previous behavior. - -The flag constrains the model's output, not execution ordering. Sequential tool use -is enforced by the caller's own loop returning each `tool_result` before issuing the -next request; this mapping does not provide that. - -Claude Opus 5.5, Fable 5.1 and Sonnet 5.5 are upstream exceptions to the forced-choice mapping: Anthropic rejects -`tool_choice: {type:"any"}` and `{type:"tool",name:...}` for that model, with or without -adaptive thinking, for all three. The adapter sends `{type:"auto"}` for those choices so the -request succeeds, but the caller's forced-tool guarantee cannot be preserved; the prompt -must provide any required tool-use instruction. Other Claude model families retain the -normal forced-choice mapping: Haiku 5.5 retains `any`/named tools, uses adaptive thinking for low..max, and reasoning none sends `disabled` without effort. Its non-default sampling fields are dropped on every path. +`options.parallelToolCalls === false` maps onto Anthropic's nested `tool_choice.disable_parallel_tool_use`. +Because the flag lives inside `tool_choice`, a request that carries only the parallel intent and no +explicit choice gets a synthesized `{type:"auto"}` so the flag has somewhere to live; `required` maps +to `{type:"any"}` and a named choice to `{type:"tool"}`, and both accept it. `{type:"none"}` does not +receive the flag because tool use is already off, and a request with no tools on the wire emits no +`tool_choice` at all. An unset or true `parallelToolCalls` is byte-identical to previous behavior. + +The flag constrains the model's output, not execution ordering. Sequential tool use is enforced by +the caller's own loop returning each `tool_result` before issuing the next request; this mapping does not provide that. + +Claude Opus 5.5, Fable 5.1 and Sonnet 5.5 are upstream exceptions to the forced-choice mapping: Anthropic +rejects `tool_choice: {type:"any"}` and `{type:"tool",name:...}` for that model, with or without adaptive +thinking, for all three. The adapter sends `{type:"auto"}` for those choices so the request succeeds, +but the caller's forced-tool guarantee cannot be preserved; the prompt must provide any required tool-use +instruction. Other Claude model families retain the normal forced-choice mapping: Haiku 5.5 retains +`any`/named tools, uses adaptive thinking for low..max, and reasoning none sends `disabled` without effort. +Its non-default sampling fields are dropped on every path. ## Unmapped modalities are recorded, not dropped -The translated Chat route has no video mapping — this adapter does not implement one. -Both serialization branches emit a bounded marker for a video part: the image-bearing -branch previously produced `{type:"text", text: undefined}`, a malformed part, and the -text-only branch joined it to `""` so a video-only or text-plus-video message was -dropped entirely. The marker names opencodex's own missing mapping; it does not assert -anything about the provider's or model's capability, which the proxy has not -established. Native Chat passthrough and Google inline video are separate routes and -are unaffected. - -`input_audio` parts with an inline WAV/MP3 data URL, or raw base64 with an explicit -`wav`/`mp3` format, are supported on the translated Chat wire. -The shared parser preserves the bytes as an `OcxAudioContent` part; the Chat serializer emits -`input_audio` for user content and promotes tool-result audio into a follow-up user message -because `role:"tool"` content is text-only. The parser stays non-throwing for native Responses -passthrough; native Responses and native Chat retain their existing wire behavior. Remote audio, -unsupported formats, the top-level `audio` spelling, developer audio, and system or assistant -audio are refused. The final adapter checks the original input under the -[untranslated-media contract](../adapters/registry.md#untranslated-input-media); translated -adapters refuse unsupported inputs while native Responses retains the original body. The -opposite Chat-to-Responses projection still rejects audio/file parts, except user-content inline -documents. No audio transport or URL fetch is added, and no client filename, payload, URL or -metadata enters errors. Admission charges supported audio by decoded size at the conservative -256-bytes-per-token proxy, not an upstream token formula. +The translated Chat route has no video mapping — this adapter does not implement one. Both serialization +branches emit a bounded marker for a video part: the image-bearing branch previously produced `{type:"text", text: undefined}`, +a malformed part, and the text-only branch joined it to `""` so a video-only or text-plus-video message +was dropped entirely. The marker names opencodex's own missing mapping; it does not assert anything about +the provider's or model's capability, which the proxy has not established. Native Chat passthrough and +Google inline video are separate routes and are unaffected. + +`input_audio` parts with an inline WAV/MP3 data URL, or raw base64 with an explicit `wav`/`mp3` format, +are supported on the translated Chat wire. The shared parser preserves the bytes as an `OcxAudioContent` part; +the Chat serializer emits `input_audio` for user content and promotes tool-result audio into a follow-up +user message because `role:"tool"` content is text-only. The parser stays non-throwing for native Responses +passthrough; native Responses and native Chat retain their existing wire behavior. Remote audio, unsupported +formats, the top-level `audio` spelling, developer audio, and system or assistant audio are refused. The final +adapter checks the original input under the [untranslated-media contract](../adapters/registry.md#untranslated-input-media); +translated adapters refuse unsupported inputs while native Responses retains the original body. The opposite +Chat-to-Responses projection still rejects audio/file parts, except user-content inline documents. No audio +transport or URL fetch is added, and no client filename, payload, URL or metadata enters errors. Admission +charges supported audio by decoded size at the conservative 256-bytes-per-token proxy, not an upstream token formula. The shared coding-agent projection in `src/adapters/coding-agent/` (CodeBuddy, Qoder) carries supported tool-result image references as real image blocks. Current tool-result prose marks supported images as `[image attached below]` and unsupported references as `[image omitted: unsupported reference]`; historical tool-result prose retains `[image]` while supported references are also emitted as image blocks. In multi-turn projection, image blocks follow conversation order — history before current. Vendor tool execution stays disabled on both -adapters. CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` -container followed by a named bare or namespaced invoke control line in either output channel; it -preserves preceding answer text, never promotes vendor prose into execution authority, -and leaves discussed or quoted literals and code examples untouched. Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter in `src/adapters/qoder/` through the shared coding-agent projection without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its sidecar classification and rejects residual raw-image requests that reach the adapter. OpenCodex serializes image blocks in conversation order, with history before the current turn. Because Qoder runs with `--no-session-persistence`, prior images are re-projected on later turns; in observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied, a known limitation of the current stateless Qoder projection path. +adapters. CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` container followed by a named bare or namespaced invoke control line in either output channel; it preserves preceding answer text, never promotes vendor prose into execution authority, and leaves discussed or quoted literals and code examples untouched. Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter in `src/adapters/qoder/` through the shared coding-agent projection without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its sidecar classification and rejects residual raw-image requests that reach the adapter. OpenCodex serializes image blocks in conversation order, with history before the current turn. Because Qoder runs with `--no-session-persistence`, prior images are re-projected on later turns; in observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied, a known limitation of the current stateless Qoder projection path. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. - Upstream API-key usage follows the [physical-attempt account attribution contract](../dashboard-and-usage.md#upstream-key-account-attribution), independently of subscription quota observations. - Unicode pattern normalization uses [copy-on-write traversal](../transports/byte-accounting.md#unicode-pattern-normalization) while preserving the existing schema and wire semantics. DeepSeek Artifact compatibility: `src/adapters/openai-chat/tool-schema.ts` omits schema `pattern` and `anyOf` constraints and strict mode for unnamespaced `Artifact` tools on `api.deepseek.com`. Surrounding properties and required fields remain; union-only nodes become unconstrained, so tool execution must validate inputs. Property names, literal defaults/examples, and caller schemas are preserved; other tools and hosts retain existing normalization. `tests/providers/deepseek-artifact-tool-schema.test.ts` checks the serialized request. diff --git a/structure/providers/jev-decision.md b/structure/providers/jev-decision.md index 3ece3fd11ca..a6d2b06b2f7 100644 --- a/structure/providers/jev-decision.md +++ b/structure/providers/jev-decision.md @@ -21,12 +21,19 @@ canonical registry transport, with `TYPESAFE_API_KEY` and the standard provider- either credential through the JEV client; a retargeted `jev` row is ignored, never a custom destination. Automated coverage mocks TypeSafe; live-key behavior is an operator smoke boundary. A Combo's `decisionProvider` selects the service: omitted or `"jev"` (stored as omission) is that canonical path with `jev-latest`; any other id must be an enabled `jev-decision` row with a full -full HTTPS decision `baseUrl` (any path) or a local HTTP `/systemone` URL and a `defaultModel`/`models[0]`, sending only its own `apiKey` (a TypeSafe -env reference or foreign keychain entry makes it unusable). The row's `authMode` attaches no other credential: only its own `apiKey` is sent, as a Bearer header. URLs with userinfo, query strings, or fragments (including an empty `?`, `#`, or `@`) are refused before a send; an HTTPS path is sent exactly as configured, while a `/systemone` path keeps its trailing-slash normalization. Shared validation rejects public HTTP hosts; the local-literal check is tested against the transport allowlist. `allowLocalCleartextPost` in +HTTPS decision `baseUrl` (any path) or a local HTTP `/systemone` URL and a `defaultModel`/`models[0]`, sending only its own `apiKey` (a TypeSafe +env reference or foreign keychain entry makes it unusable). The row's `authMode` attaches no other credential: only its own `apiKey` is sent, as a Bearer header. URLs with userinfo, query strings, or fragments (including an empty `?`, `#`, or `@`) are refused before a send; an HTTPS path is sent exactly as configured, while a `/systemone` path keeps its trailing-slash normalization. +Destinations targeting Vercel AI Gateway (`ai-gateway.vercel.sh`) normalize automatically to `/decisions` +via `jevDecisionEndpointUrl` in `src/combos/jev-decision-contract.ts` and `systemOneEndpoint` in +`src/server/management/decision-discovery.ts` (stripping trailing `/systemone` or slashes). +Shared validation rejects public HTTP hosts; the local-literal check is tested against the transport allowlist. `allowLocalCleartextPost` in `src/lib/provider-outbound.ts` admits `http:` only with the row's explicit `allowPrivateNetwork`, a `localhost`/loopback/RFC 1918/ULA host whose answers stay in that set, and no proxy. Options go out as -strings (Ollama requires them); under 2 or over 26 fail open locally (`no_choices`/`invalid`), unusable -rows reuse `missing_key`, and `decisionTimeoutMs` (1000..120000) replaces the 4 s default. +strings (Ollama requires them); under 2 fail open locally (`no_choices`). For option ceilings, Ollama +System One endpoints allow 2..26 options (`SELF_HOSTED_MIN_OPTIONS = 2`, `SELF_HOSTED_MAX_OPTIONS = 26`), +while Vercel Decisions endpoints admit 2..255 options (`DECISIONS_MIN_OPTIONS = 2`, `DECISIONS_MAX_OPTIONS = 255`). +Option counts exceeding the endpoint's maximum fail open locally (`invalid`), unusable rows reuse `missing_key`, +and `decisionTimeoutMs` (1000..120000) replaces the 4 s default. Decision backends. `src/combos/jev-dispatch.ts` derives the backend from the Combo and never stores it: `decisionModel` set means `model`, a `decisionProvider` other than `jev` means `systemone`, and @@ -50,8 +57,8 @@ PATCH cannot turn a referenced row into one). `src/server/management/decision-ro body's `decisionProvider` / `decisionModel` select the method, none means TypeSafe; `comboId` only scopes the recursion rules, and a disabled, model-less or non-System-One row is refused by name), and `GET /api/combos/decision-discovery`, read-only System One and catalog hints built by -`src/server/management/decision-discovery.ts`. Persisted decisions carry an optional `backend`, and -the usage aggregate reports per-backend counts and latency with older rows as `unknown`. +`src/server/management/decision-discovery.ts` (identifying candidate models including Apple Silicon `"laya"` models). +Persisted decisions carry an optional `backend`, and the usage aggregate reports per-backend counts and latency with older rows as `unknown`. `src/combos/jev.ts` extracts bounded user-task, previous-assistant, and latest-tool-output text plus the tool name and boolean signals; raw image data, tool arguments, encrypted reasoning, headers, and @@ -59,28 +66,49 @@ the JEV credential are excluded. All three text samples omit recognized Codex pr and Claude Code `` blocks before clipping, including nested and unclosed blocks. Only an envelope-only `codex_internal_context` goal outside a reminder may supply a fallback task; reminder-only text supplies no task. Reminder-free inputs keep their existing sampling behavior. -It owns the joint target/effort choice map and strict answer -validation, including the complete probability distribution. The service exchange owns the canonical -`jev-latest` destination, the self-hosted URL (shared `jevDecisionEndpointUrl`: an HTTPS path as -configured, `/systemone` trailing slashes normalized), credentials, 64 KiB serialized request and response caps, default -four-second deadline, no-redirect policy, and caller-cancellation ownership. It rejects an already -aborted caller by reason identity before endpoint, credential or preparation work. It checks again -after endpoint resolution and preparation (including failures), POST, redirect inspection, HTTP -error-body cleanup and response reads, and when parsing returns or throws; caller cancellation takes -precedence over the local outcome at those checkpoints. HTTP error-body cancellation is best effort -and never awaited. The separate decision deadline remains a `timeout` gate. Its -request builder receives only `model` and `descriptiveCriteria` after authorization/credential -resolution; its answer parser runs inside the cancellation boundary. Without caller cancellation, -question-specific local refusals and invalid answers retain their existing gates. `tests/routing/jev-service-exchange.test.ts` exercises this -seam independently of route questions; `tests/routing/jev-typesafe-golden.test.ts` keeps the -unchanged route bytes authoritative. Missing credentials or safe state, transport failures, and -invalid answers fail open to the first eligible target; no response can escape the configured choice -map. -The direct TypeSafe and System One decision destinations are checked against the parent API key's -resolved provider/model scope before reading decision credentials or extracting state. A denied -optional decision uses the existing fail-open inference target without sending a decision request; -management probes and unrestricted admissions retain their existing behavior. -Telemetry never retains extracted state or credentials. +When routing to a Decisions endpoint (`isDecisionsEndpoint`), `formatDecisionsInput` projects this state into +a formatted plain-text `input` string: `Task:` with user task text, `Signals:` (`has_image`, `tool_history` +when present), `Recent Assistant:` prior assistant text, and `Recent Tool Call:` / `Tool Output:` from step +history. Route choices are sent in `questions` as `{ value, description }` entries, joining target criterion +descriptions with operator `modelProfile` notes when configured. + +Answer selection and probability validation (`parseJevDecision` in `src/combos/jev.ts`): +The parser accepts answers as an object (`payload.answers.route`) or an array (`payload.answers`), +selecting the unique route answer (`name === "route"`) while ignoring unrelated choices and throwing on +duplicate route answers. Selected route values read from `choice` or `value`. The probability +distribution accepts an object mapping option keys to numbers or an array of `{ choice | value, probability }` +entries. Array distributions strictly refuse duplicate choice keys (`invalid JEV route probabilities`). +Parsing validates that: (1) distribution keys match expected candidate choices exactly, (2) probabilities are +finite numbers between 0 and 1, (3) total probability sums to 1.00 ± 0.02, and (4) the selected option holds +the maximum probability (within 1e-6). Missing route answers, unknown choices, malformed distributions, duplicate +route answers or array keys, or inconsistent totals throw and fail open to the first eligible target. + +The service exchange owns the canonical `jev-latest` destination, the self-hosted URL (shared +`jevDecisionEndpointUrl`: an HTTPS path as configured with Vercel `/decisions` rewrite, `/systemone` trailing slashes normalized), +credentials, 64 KiB serialized request and response caps, default four-second deadline, no-redirect policy, +and caller-cancellation ownership. It rejects an already aborted caller by reason identity before endpoint, +credential or preparation work. It checks again after endpoint resolution and preparation (including failures), +POST, redirect inspection, HTTP error-body cleanup and response reads, and when parsing returns or throws; +caller cancellation takes precedence over the local outcome at those checkpoints. HTTP error-body cancellation +is best effort and never awaited. The separate decision deadline remains a `timeout` gate. Its request builder +receives `model`, `descriptiveCriteria`, and optional canonical gateway `url` after authorization/credential resolution; its answer parser +runs inside the cancellation boundary. Without caller cancellation, question-specific local refusals and +invalid answers retain their existing gates. `tests/routing/jev-service-exchange.test.ts` exercises this seam +independently of route questions; `tests/routing/jev-typesafe-golden.test.ts` keeps the unchanged route bytes authoritative. +Missing credentials or safe state, transport failures, and invalid answers fail open to the first eligible +target; no response can escape the configured choice map. +The direct TypeSafe and System One decision destinations are checked against the parent API key's resolved +provider/model scope before reading decision credentials or extracting state. A denied optional decision uses +the existing fail-open inference target without sending a decision request; management probes and unrestricted +admissions retain their existing behavior. Telemetry never retains extracted state or credentials. + +Primary endpoint/model-contract evidence and verification: +Verification date: 2026-10-25. The [official Vercel OpenAI-compatible Decisions reference](https://vercel.com/docs/ai-gateway/sdks-and-apis/openai-decisions) +(checked 2026-10-25) specifies the `/v1/decisions` contract, string input, named array questions/answers, and the value/probability schema. +The upstream API supports a choice range of 1..255; OpenCodex enforces a 2-choice minimum as local routing policy since routing requires +at least two distinct targets. Continuous CI verification exercises hermetic local fixtures in `tests/routing/jev-decision.test.ts` and +`tests/routing/jev-service-exchange.test.ts` (covering `/decisions` endpoint rewriting, context projection, 2..255 option bounds, +array/object answers, and duplicate-probability refusal) without live vendor network calls. ## Loaded advisory quota diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index b9fce3b32c6..13ae6cd51e5 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -362,17 +362,7 @@ The [compaction routing override](responses-failover.md#compaction-routing-overr `src/lib/sse-decoder.ts` recognizes CR, LF and CRLF line endings, including CRLF split between fetch chunks. Delimiters are consumed before field retention; event/comment ordering, EOF dispatch and translator-budget release remain shared across all three forms. The delimiter search keeps native `indexOf` cursors for the next CR and LF in each decoded chunk, so scanning stays linear for every framing. -Native Chat's optional reasoning capture (`src/server/chat-native-reasoning.ts`) charges retained -reasoning and tool ids before concatenation and reserves the previous string during replacement. -Its separate 256 KiB/64-call bound drops the fallback capture without interrupting the wire relay. -Terminal, error and cancellation paths release collector retention; the shared replay cache owns -its own bounded text after insertion. For a changed replay, native Chat measures the exact -escaped-JSON net change and checks the final request size (`baseBytes + serializedBytes`) -against the fixed `maxSerializedBytes` cap before expansion. It then reserves that full size as -transient `request_copies` against the remaining translator budget before applying replay and -serializing the request, releasing the reservation in `finally`. Exceeding either limit returns -413 without truncating history. The post-build request charge remains owned and released by -the existing retry/key-rotation lifecycle. The [native continuation contract](../data-planes/inbound-compat.md#native-chat-thinking-continuation) defines replay scope and cache-miss behavior. +Native Chat's optional reasoning capture (`src/server/chat-native-reasoning.ts`) captures thinking from both `reasoning_content` and `reasoning` properties across SSE stream deltas and non-streaming choices. It charges retained reasoning and tool ids before concatenation and reserves the previous string during replacement. Its separate 256 KiB/64-call bound drops the fallback capture without interrupting the wire relay. Terminal, error and cancellation paths release collector retention; the shared replay cache owns its own bounded text after insertion. In `src/adapters/openai-chat/passthrough.ts`, passthrough body sizing measures `baseBytes` unconditionally against `maxSerializedBytes - replayPlan.serializedBytes` via `jsonUtf8Bytes(transformedBody(), maxSerializedBytes - replayPlan.serializedBytes)` for both unchanged bodies and changed replays. Exceeding the remaining serialized byte budget immediately throws `TranslatorBudgetExceededError("request_copies", maxSerializedBytes)` (HTTP 413), enforcing `maxSerializedBytes` even on non-replay requests without eager string serialization. For a changed replay, native Chat measures the exact escaped-JSON net change, checks final request size (`baseBytes + serializedBytes`) against `maxSerializedBytes`, and reserves that full size as transient `request_copies` against the remaining translator budget before applying replay and serializing the request, releasing the reservation in `finally`. Exceeding either limit returns 413 without truncating history. The post-build request charge remains owned and released by the existing retry/key-rotation lifecycle. The [native continuation contract](../data-planes/inbound-compat.md#native-chat-thinking-continuation) defines replay scope and cache-miss behavior. Model-contract evidence and compatibility policy: Verification date: 2026-10-25. The Reflection/Stealth family default is a defensive compatibility fallback policy based on observed vendor behavior: live testing against Vercel AI Gateway (`reflection-beam`, 2026-10-23) observed rejection of tool-free multi-turn requests with HTTP 400. Because this destination-specific observation is not a vendor guarantee across all possible stealth or reflection hosts, OpenCodex applies this heuristic as a fallback policy while providing explicit operator overrides via `preserveReasoningContentModels` and `requiresReasoningPlaceholderModels`. Continuous CI verification exercises hermetic local fixtures in `tests/responses/chat-native-reasoning-scope.test.ts` (pinning multi-turn non-tool replay, `reasoning` delta capture, and passthrough size checks) without live network requests. The Opus 5.5 progress normalizer in `src/adapters/anthropic-progress.ts` reserves its one-block thinking/signature buffer before concatenation, replaces signatures rather than appending, and diff --git a/tests/adapters/openai/openai-empty-assistant-history.test.ts b/tests/adapters/openai/openai-empty-assistant-history.test.ts index ffb03751edb..147247589a8 100644 --- a/tests/adapters/openai/openai-empty-assistant-history.test.ts +++ b/tests/adapters/openai/openai-empty-assistant-history.test.ts @@ -148,3 +148,27 @@ describe("translated Chat empty assistant projection", () => { expect(body.messages[3].content).toBe(" \n"); }); }); + +describe("Reflection/Stealth placeholder replay keeps tool pairs intact (#7574 review)", () => { + test.each(["reflection/beam", "stealth/beam"])("%s: an empty assistant between a call and its result is dropped", model => { + const parsed = parseRequest({ model, input: [ + { type: "function_call", call_id: "call_1", name: "read_file", arguments: "{}" }, + assistant([{ type: "output_text", text: "" }]), + { type: "function_call_output", call_id: "call_1", output: "result" }, + user, + ] }); + const adapter = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture" }); + const messages = JSON.parse(adapter.buildRequest(parsed).body).messages as Array>; + const calls = messages.filter(m => m.role === "assistant" && m.tool_calls?.length); + expect(calls).toHaveLength(1); + expect(calls[0]!.tool_calls[0].id).toBe("call_1"); + expect(messages.filter(m => m.role === "tool")).toEqual([{ role: "tool", tool_call_id: "call_1", content: "result" }]); + }); + + test("a standalone empty Reflection assistant still receives its placeholder", () => { + const parsed = parseRequest({ model: "reflection/beam", input: [user, assistant([{ type: "output_text", text: "" }]), user] }); + const adapter = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture" }); + const messages = JSON.parse(adapter.buildRequest(parsed).body).messages as Array>; + expect(messages.some(m => m.role === "assistant" && typeof m.reasoning_content === "string")).toBe(true); + }); +}); diff --git a/tests/responses/chat-native-reasoning-scope.test.ts b/tests/responses/chat-native-reasoning-scope.test.ts index eb1295bed48..c996df1ea02 100644 --- a/tests/responses/chat-native-reasoning-scope.test.ts +++ b/tests/responses/chat-native-reasoning-scope.test.ts @@ -26,7 +26,8 @@ afterEach(() => clearReasoningReplayCacheForTests()); describe("native Chat reasoning policy and physical replay ownership", () => { test("family detection is version-agnostic and independent of provider names", () => { for (const model of [MODEL, "deepseek-v4-flash", "deepseek-v15.12-pro-20261015", - "deepseek/deepseek-v4.1-flash-free", "gateway/DeepSeek-V5.2-Flash:thinking", "deepseek-flash"]) { + "deepseek/deepseek-v4.1-flash-free", "gateway/DeepSeek-V5.2-Flash:thinking", "deepseek-flash", + "reflection-beam", "reflection/beam", "command-code/reflection-beam:free", "stealth-glyph-cluster:free"]) { expect(chatReasoningReplayPolicy(PROVIDER, model)).toEqual({ preserve: true, placeholder: true }); } for (const model of ["deepseek-chat", "deepseek-reasoner", "deepseek-r1-distill-llama", @@ -178,8 +179,29 @@ describe("native Chat reasoning policy and physical replay ownership", () => { expect(() => planNativeChatReasoningReplay(absent, PROVIDER, MODEL, owner, false, max)) .toThrow(expect.objectContaining({ code: "translation_buffer_limit", limitBytes: max })); } + // An assistant message carrying reasoning instead of tool_calls also pays the envelope delta. + const withReasoning = [{ role: "assistant", content: null, reasoning: "aa" }]; + expect(planNativeChatReasoningReplay(withReasoning, PROVIDER, MODEL, owner, false, 25).serializedBytes).toBe(25); + for (const max of [1, 10, 24]) { + expect(() => planNativeChatReasoningReplay(withReasoning, PROVIDER, MODEL, owner, false, max)) + .toThrow(expect.objectContaining({ code: "translation_buffer_limit", limitBytes: max })); + } budget.dispose(); }); + test("enforces maxSerializedBytes even when replayPlan has no changes", () => { + const raw = { messages: [{ role: "user", content: "hello world" }] }; + const exactBytes = Buffer.byteLength( + buildOpenAIChatPassthroughRequest(PROVIDER, raw, MODEL, false).body, + ); + expect( + JSON.parse( + buildOpenAIChatPassthroughRequest(PROVIDER, raw, MODEL, false, undefined, undefined, "fixture", undefined, exactBytes).body, + ).messages[0].content, + ).toBe("hello world"); + expect(() => + buildOpenAIChatPassthroughRequest(PROVIDER, raw, MODEL, false, undefined, undefined, "fixture", undefined, exactBytes - 1), + ).toThrow(expect.objectContaining({ code: "translation_buffer_limit" })); + }); test("plain-answer placeholders require DeepSeek, tools and both policy opt-ins", () => { const messages = [{ role: "assistant", content: "Earlier answer" }]; const tools = [{ type: "function", function: { name: "inspect", parameters: { type: "object", properties: {} } } }]; @@ -195,6 +217,61 @@ describe("native Chat reasoning policy and physical replay ownership", () => { } expect(messages).toEqual([{ role: "assistant", content: "Earlier answer" }]); }); + test("reflection models replay placeholder on prior assistant turns even without tools", () => { + const messages = [{ role: "assistant", content: "Earlier plain answer" }]; + const request = buildOpenAIChatPassthroughRequest(PROVIDER, { messages, tools: [] }, "reflection/beam", false); + expect(JSON.parse(request.body).messages[0].reasoning_content).toBe(" "); + }); + test("replays captured reasoning from delta.reasoning and message.reasoning rather than placeholder", async () => { + const replayModel = "reflection/beam"; + const owner = scope("fixture-thread", PROVIDER, "fixture", replayModel); + const budget = createTranslatorBudget(); + const capture = nativeChatReasoningCapture(PROVIDER, replayModel, owner, budget); + + capture.json({ choices: [ + { message: { reasoning: "from-json-message-reasoning", tool_calls: [tool("call_json")] } }, + ] }); + + const frames = [ + { choices: [{ index: 0, delta: { reasoning: "from-sse-delta-reasoning", tool_calls: [{ index: 0, id: "call_sse" }] } }] }, + ]; + const source = new Response(frames.map(v => `data: ${JSON.stringify(v)}\n\n`).join("") + "data: [DONE]\n\n").body!; + const stream = nativeChatSse(source, { + requestedModel: replayModel, + translatorBudget: budget, + signal: new AbortController().signal, + onUsage() {}, + onChunk: capture.observe, + onTerminal: s => capture.finish(s < 400), + onCancel: () => capture.finish(false), + }); + await new Response(stream).text(); + + const reqJson = buildOpenAIChatPassthroughRequest( + PROVIDER, + { messages: [{ role: "assistant", content: null, tool_calls: [tool("call_json")] }] }, + "reflection/beam", + false, + undefined, + undefined, + "test-session", + owner, + ); + expect(JSON.parse(reqJson.body).messages[0].reasoning_content).toBe("from-json-message-reasoning"); + + const reqSse = buildOpenAIChatPassthroughRequest( + PROVIDER, + { messages: [{ role: "assistant", content: null, tool_calls: [tool("call_sse")] }] }, + "reflection/beam", + false, + undefined, + undefined, + "test-session", + owner, + ); + expect(JSON.parse(reqSse.body).messages[0].reasoning_content).toBe("from-sse-delta-reasoning"); + budget.dispose(); + }); }); describe("native SSE reasoning capture lifecycle", () => { diff --git a/tests/routing/jev-decision.test.ts b/tests/routing/jev-decision.test.ts index f1a5b228c80..ff46caa26b2 100644 --- a/tests/routing/jev-decision.test.ts +++ b/tests/routing/jev-decision.test.ts @@ -359,6 +359,68 @@ describe("JEV decision parser", () => { }); }); + test("accepts Vercel decisions array-format answers and array probabilities", () => { + const payload = { + answers: [ + { + name: "route", + type: "choice", + value: "openai/gpt-6-astra:high", + probabilities: [ + { value: "openai/gpt-6-astra:medium", probability: 0.1 }, + { value: "openai/gpt-6-astra:high", probability: 0.7 }, + { value: "openai/gpt-5.6-sol:low", probability: 0.2 }, + ], + }, + ], + }; + + expect(parseJevDecision(payload, candidates)).toEqual({ + targetKey: "openai/gpt-6-astra", + effort: "high", + chosenProbability: 0.7, + }); + }); + + test("selects the unique route answer when array answers include earlier unrelated choices", () => { + const payload = { + answers: [ + { + name: "sentiment", + type: "choice", + value: "positive", + }, + { + name: "route", + type: "choice", + value: "openai/gpt-6-astra:high", + }, + ], + }; + + expect(parseJevDecision(payload, candidates)).toEqual({ + targetKey: "openai/gpt-6-astra", + effort: "high", + }); + }); + + test("rejects array answers with duplicate route choices or without a route answer", () => { + const duplicateRoute = { + answers: [ + { name: "route", type: "choice", value: "openai/gpt-6-astra:high" }, + { name: "route", type: "choice", value: "openai/gpt-5.6-sol:low" }, + ], + }; + expect(() => parseJevDecision(duplicateRoute, candidates)).toThrow("duplicate JEV route decision"); + + const unrelatedOnly = { + answers: [ + { name: "survey", type: "choice", value: "positive" }, + ], + }; + expect(() => parseJevDecision(unrelatedOnly, candidates)).toThrow("missing JEV route decision"); + }); + test("treats malformed confidence as absent without weakening the route choice", () => { expect(parseJevDecision({ answers: { route: { choice: "openai/gpt-5.6-sol:low", confidence: 2 } }, @@ -390,6 +452,24 @@ describe("JEV decision parser", () => { }), candidates)).toThrow(); expect(() => parseJevDecision(answer("openai/gpt-5.6-sol:low", complete), candidates)).toThrow(); expect(() => parseJevDecision({ answers: null }, candidates)).toThrow(); + expect(() => parseJevDecision({ + answers: [{ name: "route", value: "openai/gpt-6-astra:high", probabilities: [null] }], + }, candidates)).toThrow(); + expect(() => parseJevDecision({ + answers: [{ name: "route", value: "openai/gpt-6-astra:high", probabilities: [{ probability: 0.7 }] }], + }, candidates)).toThrow(); + expect(() => parseJevDecision({ + answers: [{ + name: "route", + value: "openai/gpt-6-astra:high", + probabilities: [ + { value: "openai/gpt-6-astra:medium", probability: 0.1 }, + { value: "openai/gpt-6-astra:high", probability: 0.7 }, + { value: "openai/gpt-5.6-sol:low", probability: 0.2 }, + { value: "openai/gpt-6-astra:high", probability: 0.7 }, + ], + }], + }, candidates)).toThrow(); }); }); @@ -951,6 +1031,49 @@ describe("JEV configurable decision provider", () => { }).questions.route.criteria)).toHaveLength(26); }); + test("Decisions endpoint permits more than 26 choices up to 255", async () => { + const calls: Call[] = []; + const post = recordingPost(calls, () => Response.json({ + answers: { route: { choice: "p/m-0:low" } }, + })); + const many: JevCandidate[] = Array.from({ length: 9 }, (_, index) => ({ + key: `p/m-${index}`, + provider: "p", + model: `m-${index}`, + reasoningEfforts: ["low", "medium", "high"], + modelProfile: `profile for m-${index}`, + })); + const manyFallback = { targetKey: many[0]!.key, effort: "low" as const }; + const vercelConfig: OcxConfig = { + providers: { + "vercel-decider": { + adapter: "jev-decision", + baseUrl: "https://ai-gateway.vercel.sh/v1/systemone", + defaultModel: "tev1:4b", + apiKey: "fixture-key", + }, + }, + }; + const decision = await resolveJevDecision({ + body: { ...decisionBody, input: [{ role: "user", content: "hello" }, { type: "function_call_output", output: "ok" }] }, + candidates: many, + fallback: manyFallback, + config: vercelConfig, + decisionProvider: "vercel-decider", + post, + }); + expect(decision.gate).toBe("apply"); + expect(decision.targetKey).toBe("p/m-0"); + expect(decision.effort).toBe("low"); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://ai-gateway.vercel.sh/v1/decisions"); + const parsed = JSON.parse(String(calls[0]!.init.body)); + expect(parsed.questions[0].choices).toHaveLength(27); + expect(parsed.input).toContain("Task: hello"); + expect(parsed.input).toContain("Signals: tool_history"); + expect(parsed.questions[0].choices[0].description).toContain("profile for m-0"); + }); + test("a self-hosted row sends only its own key and never a TypeSafe credential", async () => { await withEnvKeys(async () => { const calls: Call[] = []; diff --git a/tests/routing/jev-service-exchange.test.ts b/tests/routing/jev-service-exchange.test.ts index 664eb54893f..6fae43299ae 100644 --- a/tests/routing/jev-service-exchange.test.ts +++ b/tests/routing/jev-service-exchange.test.ts @@ -1,3 +1,4 @@ +import { isDecisionsEndpoint, jevDecisionEndpointUrl } from "../../src/combos/jev-decision-contract"; import { describe, expect, test } from "bun:test"; import { parseJevDecision, resolveJevDecision, type JevCandidate } from "../../src/combos/jev"; import { @@ -494,6 +495,38 @@ describe("bounded JEV service exchange", () => { expect(route.gate).toBe("apply"); }); + test("rewrites ai-gateway.vercel.sh endpoint to /decisions and sends Decisions questions body", async () => { + expect(jevDecisionEndpointUrl("https://ai-gateway.vercel.sh/v1/systemone")).toBe("https://ai-gateway.vercel.sh/v1/decisions"); + expect(jevDecisionEndpointUrl("https://ai-gateway.vercel.sh/v1")).toBe("https://ai-gateway.vercel.sh/v1/decisions"); + expect(jevDecisionEndpointUrl("https://my-ai-gateway.vercel.sh/v1/systemone")).toBe("https://my-ai-gateway.vercel.sh/v1/systemone"); + expect(isDecisionsEndpoint("https://ai-gateway.vercel.sh/v1/decisions")).toBeTrue(); + expect(isDecisionsEndpoint("https://custom.vercel.sh/v1/decisions")).toBeFalse(); + + const { sent, direct, route } = await sendTo("https://ai-gateway.vercel.sh/v1/systemone"); + expect(sent.map(r => r.url)).toEqual([ + "https://ai-gateway.vercel.sh/v1/decisions", + "https://ai-gateway.vercel.sh/v1/decisions", + ]); + expect(direct).toEqual({ value: answer }); + expect(route.gate).toBe("apply"); + + const parsedRouteBody = JSON.parse(sent[1].body); + expect(parsedRouteBody.model).toBe("tev1:4b"); + expect(parsedRouteBody.input).toContain("Task: Choose a target."); + expect(Array.isArray(parsedRouteBody.questions)).toBeTrue(); + expect(parsedRouteBody.questions).toHaveLength(1); + const q = parsedRouteBody.questions[0]; + expect(q.name).toBe("route"); + expect(q.type).toBe("choice"); + expect(Array.isArray(q.choices)).toBeTrue(); + expect(q.choices.length).toBeGreaterThan(0); + for (const choice of q.choices) { + expect(typeof choice.value).toBe("string"); + expect(typeof choice.description).toBe("string"); + expect(choice.description.length).toBeGreaterThan(0); + } + }); + test("the request body does not depend on the endpoint path", async () => { const bodies = new Set(); for (const baseUrl of ["https://decider.example/v1/decisions/", "https://decider.example/v1/systemone/"]) {