diff --git a/scripts/test-layout/layout-replay.json b/scripts/test-layout/layout-replay.json index b9c41e0c9f7..6621ddba789 100644 --- a/scripts/test-layout/layout-replay.json +++ b/scripts/test-layout/layout-replay.json @@ -7,5 +7,6 @@ "combo-replay-refusal-attribution.test.ts": "responses", "responses-replay-metadata-principal.test.ts": "responses", "responses-combo-replay-principal.test.ts": "responses", - "responses-translated-replay-principal.test.ts": "responses" + "responses-translated-replay-principal.test.ts": "responses", + "messages-native-combo.test.ts": "claude-integration" } diff --git a/src/protocols/envelope.ts b/src/protocols/envelope.ts index cdd2af534f1..a5aeae64ac5 100644 --- a/src/protocols/envelope.ts +++ b/src/protocols/envelope.ts @@ -19,6 +19,11 @@ export interface ProtocolEnvelope { features(): ReadonlySet; /** A structured clone of the source body, charged to the translator budget. */ freshBody(): Record; + /** + * The source body by reference, uncharged and read-only: for a consumer that only inspects the + * request (a candidate eligibility check) and so needs no copy. Never mutate or retain it. + */ + peekBody(): Readonly>; } export function createProtocolEnvelope(input: { @@ -34,6 +39,9 @@ export function createProtocolEnvelope(input: { features ??= featuresFromBody(inbound, body); return features; }, + peekBody() { + return body; + }, freshBody() { // Charged before the copy exists, so an over-budget request fails without allocating it. translatorBudget.chargeRetained(jsonUtf8Bytes(body), { kind: "request_copies" }); diff --git a/src/protocols/plan-snapshot.ts b/src/protocols/plan-snapshot.ts index 8c65b642cb9..3ae5acc7b9b 100644 --- a/src/protocols/plan-snapshot.ts +++ b/src/protocols/plan-snapshot.ts @@ -127,10 +127,13 @@ function candidateFor( let declineReasons: ProtocolReasonCode[] = []; let nativeEligible = false; let directEligible = false; - // With `nativeChatCombos` on, the combo loop judges each Chat candidate as the concrete route it - // is (PF-07), so the preview must too; a policy still resolves one candidate on the bridge. - const comboChildNative = inbound === "chat" && routeKind === "combo" - && resolveProtocolSettings(config).rollout.nativeChatCombos; + // With `nativeChatCombos` on, or with `managedMessagesNative` on for a Messages combo candidate, + // the combo loop judges each candidate as the concrete route it is (PF-07), so the preview + // must too; a policy still resolves one candidate on the bridge. + const comboChildNative = routeKind === "combo" && ( + (inbound === "chat" && resolveProtocolSettings(config).rollout.nativeChatCombos) + || (inbound === "messages" && resolveProtocolSettings(config, route.providerName).rollout.managedMessagesNative) + ); const settled: RouteResult = { ...route, provider, diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 1a7192e91f4..c30cb93e95d 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -87,11 +87,12 @@ import { sessionLaneIdFromRequest, } from "./request-log-conversation"; import { responseWithDeferredRequestLog } from "./relay"; -import { clientWireOf } from "./inference/client-wire"; +import { clientWireLogOf, clientWireOf } from "./inference/client-wire"; import { directEncodersApply } from "./inference/client-encoder-delivery"; import type { ClientEncoderOption } from "./responses/core-options"; import { handleResponses } from "./responses"; import { previewXaiOauthWireModel } from "./responses/core-normalize"; +import { getCombo } from "../combos"; import { upstreamWireForAdapter } from "../protocols/contract"; import { createProtocolEnvelope, type ProtocolEnvelope } from "../protocols/envelope"; import { featuresFromMessagesBody, type ProtocolFeature } from "../protocols/features"; @@ -638,8 +639,9 @@ async function handleClaudeMessagesWithBudget( let directPlan: MessagesDirectPlan | undefined; /** The retained charge for the settings-only body, handed back if the plan is left. */ let directSettingsBodyBytes = 0; - // Built only under the reject policy; the legacy default leaves this request untouched. + // Built only under the reject policy or for native combos; the legacy default leaves this request untouched. let envelope: ProtocolEnvelope | undefined; + let nativeMessagesCombo = false; let messagesFeatures: () => Iterable = () => []; // The Advisor caller policy is the admitted snapshot: capture it before the first await (#7859). const advisorAdmissionPolicy = logIds?.advisorAdmissionPolicy ?? captureAdmissionPolicy(config, logIds?.admission); @@ -916,6 +918,13 @@ async function handleClaudeMessagesWithBudget( route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy); logCtx.routeDecision = route.routeDecision; settledRoute = route; + const comboConfig = settledRoute?.combo ? getCombo(config, settledRoute.combo.comboId) : undefined; + nativeMessagesCombo = !!comboConfig && !effortRow && !fastRow && comboConfig.targets.some( + target => resolveProtocolSettings(config, target.provider).rollout.managedMessagesNative, + ); + if (!envelope && isRec(anthropicBody) && nativeMessagesCombo) { + envelope = createProtocolEnvelope({ inbound: "messages", body: anthropicBody, translatorBudget }); + } // Before any route-specific shaping, so a request that leaves the lane is shaped as the bridge's. if (directPlan && !messagesDirectPlanHolds(directPlan, route, anthropicBody as Rec, config)) directLeaveError = leaveDirectPlan(); if (directEncodersApply(config, route)) { @@ -1269,15 +1278,37 @@ async function handleClaudeMessagesWithBudget( ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }), + ...(envelope && nativeMessagesCombo ? { + protocolSource: (await import("./messages-native")).createNativeMessagesComboSource({ + req, + config, + envelope, + selector: nativeSelector, + requestedModel: clientFacingModelText(clientModelLabel), + translatorBudget, + callerAnthropicBeta: req.headers.get("anthropic-beta"), + clientIdentity: captureAnthropicClientIdentity(req.headers), + sessionKey: anthropicSessionKeyFromParts({ + sessionIdHeader: req.headers.get("session_id")?.trim() || req.headers.get("x-claude-code-session-id"), + threadIdHeader: req.headers.get("thread_id"), + clientThreadId: conversationIdFromClaudeMetadata(isRec(anthropicBody) && isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined), + promptCacheKey: typeof internalBody.prompt_cache_key === "string" ? internalBody.prompt_cache_key : null, + promptCacheKeyIsSharedCohort: cacheKeySource === "system", + }), + modelScope: resolveAdmissionModelScope(config, logIds?.admission), + }), + } : {}), clientModelLabel, ...(clientEncoder ? { clientEncoder } : {}), ...(directRoute && directPlan ? { directRequest: messagesDirectRequestOption(directPlan, anthropicBody, cc, translatorBudget) } : {}), }); + if (clientWireOf(upstream) === "messages") { + if (!logIds || (upstream.ok && !clientWireLogOf(upstream))) return upstream; + return responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx); + } const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream; - // Already in the Messages wire (direct encoder): no conversion. - if (clientWireOf(upstream) === "messages") return response; if (!response.ok) { // Read the shared provenance verdict before consuming and re-wrapping the body. A refusal diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index 544552fc0e6..7952b0b56c2 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -1,6 +1,8 @@ import { captureAccountAutoPauseAttempt, wasAccountAutoPaused } from "../oauth/account-auto-pause"; -import { createPhysicalSendReporter } from "../lib/request-execution-budget"; +import { createPhysicalSendReporter, type RequestExecutionBudget, type SingleUseDispatchPermit } from "../lib/request-execution-budget"; import { createInferenceSendBudget } from "./inference/context"; +import type { ComboProtocolSource } from "./responses/core-combo-native"; +import type { ProtocolEnvelope } from "../protocols/envelope"; /** * Managed native Messages lane (PF-08, behind `protocols.rollout.managedMessagesNative`). * @@ -96,7 +98,7 @@ import { sanitizePassthroughToolCallIds, tapAnthropicSseForLog, } from "./messages-shared"; -import { beginInferenceAttempt } from "./inference/attempt"; +import { beginInferenceAttempt, type InferenceAttempt } from "./inference/attempt"; import { createFinalRequestLog, type FinalRequestLogMeta } from "./inference/final-log"; import { registerTurn, unregisterTurn } from "./lifecycle"; import { nativeMessagesDeclineReason, type NativeMessagesSelector } from "./messages-native-eligibility"; @@ -185,7 +187,20 @@ export interface HandleNativeMessagesOptions { modelScope?: AdmissionModelScope; } -type FinishLog = (status: number, message?: string, meta?: FinalRequestLogMeta) => void; +export type NativeMessagesFinishLog = ( + status: number, + message?: string, + meta?: FinalRequestLogMeta | "non_stream" | "terminal" | "client_cancel", +) => void; +type FinishLog = NativeMessagesFinishLog; + +export interface NativeMessagesExecution extends HandleNativeMessagesOptions { + finishLog: NativeMessagesFinishLog; + sendBudget?: RequestExecutionBudget; + comboDispatchPermit?: SingleUseDispatchPermit; + onFirstOutput?: () => void; + turnAdmissionLease?: AdmissionLease; +} /** Relay an upstream body unchanged, recording first output on its first non-empty chunk. */ function observeFirstChunk(body: ReadableStream, onFirst: () => void): ReadableStream { @@ -320,7 +335,35 @@ async function prepareNativeBody(body: Rec, signal: AbortSignal): Promise * log row, and answers in the Messages wire (SSE or JSON per the caller's own `stream`). */ export async function handleNativeMessages(options: HandleNativeMessagesOptions): Promise { - const { req, config, logCtx, logIds, route, body, requestedModel, translatorBudget } = options; + const { logCtx, logIds, route } = options; + logCtx.inboundProtocol = "messages"; + const attemptHandle = beginInferenceAttempt(logCtx, { + provider: route.providerName, + model: route.modelId, + adapter: "anthropic", + }); + attemptHandle.seal(logCtx.accountLogLabel); + const finalLog = createFinalRequestLog(logIds, logCtx); + const finishLog: NativeMessagesFinishLog = (status, message, meta = { closeReason: "non_stream" }) => { + if (finalLog.finished()) return; + if (message) logCtx.upstreamError = redactSecretString(message).slice(0, 500); + const resolvedMeta: FinalRequestLogMeta = typeof meta === "string" ? { closeReason: meta } : meta; + finalLog.finish(status, resolvedMeta); + }; + return runNativeMessagesAttempt({ ...options, finishLog }, attemptHandle); +} + +/** + * Run one native Messages attempt on an already-open attempt row. + * Used for both standalone native Messages requests and native combo children. + */ +export async function runNativeMessagesAttempt( + execution: NativeMessagesExecution, + attemptHandle: InferenceAttempt, +): Promise { + const options = execution; + const { req, config, logCtx, logIds, route, body, requestedModel, translatorBudget, finishLog } = execution; + const { sendBudget } = execution; const nativeInstance = configuredAnthropicInstance(config, route.providerName); const requestedStream = body.stream === true; const clientDeclaredEmptyToolCatalog = Array.isArray(body.tools) && body.tools.length === 0; @@ -351,20 +394,8 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) logCtx.spendOutputCeilingTokens = Math.trunc(body.max_tokens); } - const attemptHandle = beginInferenceAttempt(logCtx, { - provider: route.providerName, - model: route.modelId, - adapter: "anthropic", - }); - attemptHandle.seal(logCtx.accountLogLabel); recordAttemptRequestedEffort(logCtx); const { attempt } = attemptHandle; - const finalLog = createFinalRequestLog(logIds, logCtx); - const finishLog: FinishLog = (status, message, meta = { closeReason: "non_stream" }) => { - if (finalLog.finished()) return; - if (message) logCtx.upstreamError = redactSecretString(message).slice(0, 500); - finalLog.finish(status, meta); - }; const bindUsage = (usage: OcxUsage | undefined) => { if (!usage) return; if (!recordKeyWireAttemptUsage(logCtx, usage)) { @@ -418,7 +449,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) const cleanupAbort = linkAbortSignal(upstream, req.signal); let streamTurnRegistered = false; const transferTurnToStream = () => { - const lease = logIds?.turnAdmissionLease; + const lease = execution.turnAdmissionLease ?? logIds?.turnAdmissionLease; if (!lease || typeof (lease as { bindAbortController?: unknown }).bindAbortController !== "function") return; registerTurn(upstream, lease); streamTurnRegistered = true; @@ -464,8 +495,9 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) let activeProvider: OcxProviderConfig = oauthBinding ? oauthProvider(oauthBinding) : route.provider; stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider); if (oauthBinding) logCtx.provider = formatAnthropicProviderForLog(route.providerName, oauthBinding.snapshot.accountId, config); - const spendTracker = attachRequestSpendTracker(req, logCtx); - const physicalBudget = createInferenceSendBudget(req, logCtx); + const spendTracker = sendBudget ? undefined : attachRequestSpendTracker(req, logCtx); + const physicalBudget = sendBudget ?? createInferenceSendBudget(req, logCtx); + const onFirstOutput = execution.onFirstOutput ?? (logIds ? () => recordFirstOutput(logCtx, logIds.start) : undefined); // Observe-only spend is already booked by the tracker; count billing sends without booking again. let physicalSendsUsed = 0; let activeRequest: AnthropicMessagesPassthroughRequest; @@ -630,16 +662,18 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) let dispatched: Response; const spendReport = createPhysicalSendReporter(physicalBudget, () => ({ poolId: logCtx.spendPoolId ?? route.providerName, identityId: logCtx.accountLogLabel, - })); + }), execution.comboDispatchPermit); let sendOwner: AnthropicPhysicalSendOwnership | null = null; try { if (init.signal?.aborted) throw init.signal.reason; // Capture before either accounting path so this request retains its start policy. if (spendReport.beforeSend && !spendReport.beforeSend()) { - if (spendTracker.refusals) throw new NativeMessagesSpendRefusal(); + if (spendTracker?.refusals || (logCtx.spendTracker as { refusals?: number } | undefined)?.refusals) { + throw new NativeMessagesSpendRefusal(); + } throw new SendBudgetExhaustedError(); } - if (!physicalBudget.spendEnforced && !spendTracker.charge()) throw new NativeMessagesSpendRefusal(); + if (spendTracker && !physicalBudget.spendEnforced && !spendTracker.charge()) throw new NativeMessagesSpendRefusal(); noteProviderAttemptSend(logCtx, route.providerName, activeProvider, logCtx.usageLogInputTokens, transportRecovery ?? recovery); // Last synchronous ownership reservation before the physical fetch. Retain this // exact incarnation across its await; a returned response can never reserve one. @@ -896,7 +930,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) ...resolvePassthroughBodyGuard(config, upstream.signal), ...(activeProvider.anthropicEofTolerance === true ? { eofTolerant: true } : {}), }; - const observed = logIds ? observeFirstChunk(response.body, () => recordFirstOutput(logCtx, logIds.start)) : response.body; + const observed = onFirstOutput ? observeFirstChunk(response.body, onFirstOutput) : response.body; const renamed = activeRequest.oauthToolNames ? restoreOAuthToolNamesInSse(observed, activeRequest.oauthToolNames, translatorBudget) : observed; @@ -1009,7 +1043,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) return fail(502, undeclaredToolCallMessage(), "api_error", UNDECLARED_TOOL_CALL_ERROR_CODE); } bindUsage(anthropicUsageToOcx(isRec(message.usage) ? message.usage : undefined)); - if (logIds) recordFirstOutput(logCtx, logIds.start); + if (onFirstOutput) onFirstOutput(); try { translatorBudget.chargeRetained(Buffer.byteLength(read.text) * 2, { kind: "live_transient" }); } catch (error) { @@ -1150,3 +1184,44 @@ export function nativeMessagesCountBody( return undefined; } } + +/** + * The Messages source a combo hands its native children. A child runs on the attempt the + * combo opened and reports through the combo's callbacks, so the final row stays the parent's. + */ +export function createNativeMessagesComboSource(input: { + req: Request; + config: OcxConfig; + envelope: ProtocolEnvelope; + requestedModel: string; + translatorBudget: TranslatorBudget; + callerAnthropicBeta?: string | null; + clientIdentity?: AnthropicClientIdentity; + sessionKey?: string | null; + modelScope?: AdmissionModelScope; + selector?: NativeMessagesSelector; +}): ComboProtocolSource { + return { + inbound: "messages", + ...(input.selector ? { selector: input.selector } : {}), + envelope: input.envelope, + dispatchNativeChild: child => runNativeMessagesAttempt({ + req: input.req, + config: input.config, + logCtx: child.childLog, + route: child.route, + body: child.body, + requestedModel: input.requestedModel, + translatorBudget: input.translatorBudget, + finishLog: child.finishLog, + sendBudget: child.sendBudget, + comboDispatchPermit: child.comboDispatchPermit, + onFirstOutput: child.onFirstOutput, + callerAnthropicBeta: input.callerAnthropicBeta, + clientIdentity: input.clientIdentity, + sessionKey: input.sessionKey, + modelScope: input.modelScope, + ...(child.turnAdmissionLease ? { turnAdmissionLease: child.turnAdmissionLease } : {}), + }, child.attemptHandle), + }; +} diff --git a/src/server/responses/core-combo-native.ts b/src/server/responses/core-combo-native.ts index 022bc2cf808..5c25cef9536 100644 --- a/src/server/responses/core-combo-native.ts +++ b/src/server/responses/core-combo-native.ts @@ -22,7 +22,6 @@ import type { AdmissionLease } from "../../lib/admission"; import type { RequestExecutionBudget, SingleUseDispatchPermit } from "../../lib/request-execution-budget"; import type { TransientSendBudget } from "../../lib/upstream-retry"; import type { ResponsesTerminalStatus } from "../../bridge"; -import type { NativeChatFinishLog } from "../chat-native"; import type { InferenceAttempt } from "../inference/attempt"; import type { ProtocolEnvelope } from "../../protocols/envelope"; import type { ProtocolFeature } from "../../protocols/features"; @@ -41,7 +40,10 @@ import { resolveOpenCodeGoTransport } from "../../providers/opencode-go-transpor import { getOrAllocateRequestSessionLane } from "../request-log-conversation"; import { assertRouteAllowedByScope } from "../admission-model-scope"; import { isNativeChatRouteEligible } from "../chat-native-eligibility"; +import { isNativeMessagesRouteEligible, type NativeMessagesSelector } from "../messages-native-eligibility"; import { chatCompletionsErrorResponse } from "../../chat/outbound"; +import { anthropicErrorResponse } from "../../claude/outbound"; +import type { FinalRequestLogMeta } from "../inference/final-log"; import { isRequestExecutionBudget } from "../../lib/request-execution-budget"; import { markResponseNonReplayable } from "../../lib/upstream-retry"; import { redactSecretString } from "../../lib/redact"; @@ -55,10 +57,19 @@ import { markClientWire } from "../inference/client-wire"; type Rec = Record; type ComboTarget = { provider: string; model: string }; +/** Records the outcome on whichever final row owns this attempt; the first call wins. */ +export type NativeComboChildFinishLog = ( + status: number, + message?: string, + closeReasonOrMeta?: "non_stream" | "terminal" | "client_cancel" | FinalRequestLogMeta, +) => void; + +export type NativeChatFinishLog = NativeComboChildFinishLog; + /** What the source needs to run one native child on the combo's open attempt. */ export interface NativeComboChildRun { route: RouteResult; - /** A fresh copy of the source Chat body, never another candidate's rewritten one. */ + /** A fresh copy of the source body, never another candidate's rewritten one. */ body: Rec; childLog: RequestLogContext; attemptHandle: InferenceAttempt; @@ -67,14 +78,16 @@ export interface NativeComboChildRun { turnAdmissionLease?: AdmissionLease; onFirstOutput: () => void; /** Reports to the combo's child callbacks; it never writes the parent's final row itself. */ - finishLog: NativeChatFinishLog; + finishLog: NativeComboChildFinishLog; } -/** Supplied by the Chat ingress for a combo route when `nativeChatCombos` is on. */ +/** Supplied by the Chat or Messages ingress for a combo route when native combo candidates are active. */ export interface ComboProtocolSource { - readonly inbound: "chat"; + readonly inbound: "chat" | "messages"; + /** The request facts the standalone Messages path judges eligibility with (selector-keyed effort pins). */ + readonly selector?: NativeMessagesSelector; readonly envelope: ProtocolEnvelope; - /** Runs `runNativeChatAttempt`; the response it returns is in the Chat wire. */ + /** Runs native child attempt; the response it returns is in the child's wire. */ dispatchNativeChild(input: NativeComboChildRun): Promise; } @@ -121,18 +134,19 @@ export function createComboProtocolLanes(input: { const reject = resolveProtocolSettings(config).unrepresentable === "reject"; const selector = (target: ComboTarget): string => `${target.provider}/${target.model}`; - // The Chat ingress's own settlement of a single route, applied to the concrete target: the - // key's model scope, the static policy captured for a Chat inbound, the wire override and the + // The ingress's own settlement of a single route, applied to the concrete target: the + // key's model scope, the static policy captured for an inbound wire, the wire override and the // OpenCode Go transport. A refusal here keeps the bridge, which reports it in its own shape. const settle = (target: ComboTarget, targetRoute: RouteResult): RouteResult | undefined => { try { const route: RouteResult = { ...targetRoute }; assertRouteAllowedByScope(admissionScope, clientFacingModelLabel(undefined), route); const routedProvider = route.provider; + const inboundWire = source.inbound === "messages" ? "anthropic" : "chat"; route.staticPolicy = captureRouteStaticPolicy( - route.providerName, route.modelId, routedProvider, route.staticPolicy.effectiveAlias, "chat", + route.providerName, route.modelId, routedProvider, route.staticPolicy.effectiveAlias, inboundWire, ); - const wireProvider = resolveWireProtocolOverride(route.providerName, route.modelId, routedProvider, "chat", route.staticPolicy); + const wireProvider = resolveWireProtocolOverride(route.providerName, route.modelId, routedProvider, inboundWire, route.staticPolicy); route.provider = resolveOpenCodeGoTransport(wireProvider, getOrAllocateRequestSessionLane(req), routedProvider); return route; } catch { @@ -143,12 +157,31 @@ export function createComboProtocolLanes(input: { // Eligibility reads a body, and a body is a charged copy, so the adapter is checked first: // a candidate that can never go native costs no copy at all. const nativeBody = (target: ComboTarget, route: RouteResult | undefined): Rec | undefined => { - if (!route || route.provider.adapter !== "openai-chat") return undefined; - const body = envelope.freshBody(); - // The concrete selector, as the bridge child's body carries it; the wire model comes from - // the route either way, and the pinned-effort lookup reads this. - body.model = selector(target); - return isNativeChatRouteEligible(route, body, config) ? body : undefined; + if (!route) return undefined; + if (source.inbound === "chat") { + if (route.provider.adapter !== "openai-chat") return undefined; + const body = envelope.freshBody(); + body.model = selector(target); + return isNativeChatRouteEligible(route, body, config) ? body : undefined; + } + if (source.inbound === "messages") { + if (route.provider.adapter !== "anthropic") return undefined; + const body = envelope.freshBody(); + body.model = selector(target); + return isNativeMessagesRouteEligible(route, body, config, source.selector) ? body : undefined; + } + return undefined; + }; + + // Judging a candidate only reads the request, so a Messages candidate is checked against the + // source body by reference (the model swapped in a shallow view) and never charges a copy; the + // charged fresh body is made once, for the child that actually sends. + const nativeEligible = (target: ComboTarget, route: RouteResult | undefined): boolean => { + if (source.inbound === "messages") { + if (!route || route.provider.adapter !== "anthropic") return false; + return isNativeMessagesRouteEligible(route, { ...envelope.peekBody(), model: selector(target) }, config, source.selector); + } + return nativeBody(target, route) !== undefined; }; const verdicts = new Map(); @@ -167,11 +200,11 @@ export function createComboProtocolLanes(input: { } if (targetRoute) { const settled = settle(target, targetRoute); - const native = nativeBody(target, settled) !== undefined; - const upstream = native ? "chat" : upstreamWireForAdapter((settled ?? targetRoute).provider.adapter); + const native = nativeEligible(target, settled); + const upstream = native ? source.inbound : upstreamWireForAdapter((settled ?? targetRoute).provider.adapter); const checked = checkRepresentable({ - inbound: "chat", - requestPath: requestPathForLane("chat", native ? "native" : "bridge", upstream), + inbound: source.inbound, + requestPath: requestPathForLane(source.inbound, native ? "native" : "bridge", upstream), features: envelope.features(), policy: "reject", }); @@ -199,13 +232,18 @@ export function createComboProtocolLanes(input: { const skipped = enabled.map(judge); if (skipped.some(verdict => verdict.skip === undefined)) return undefined; const features = [...new Set(skipped.flatMap(verdict => verdict.skip ?? []))]; - // The same refusal the ingress gives a single route: 400 in the Chat shape, feature keys + // The same refusal the ingress gives a single route: 400 in the inbound shape, feature keys // only, a blocked trace, and no upstream send. - markProtocolBlocked(logCtx, { inbound: "chat", reasonCodes: ["feature-unrepresentable"], features }); + markProtocolBlocked(logCtx, { inbound: source.inbound, reasonCodes: ["feature-unrepresentable"], features }); logCtx.errorCode = "unsupported_feature"; - return markClientWire(chatCompletionsErrorResponse( - 400, unrepresentableMessage(features), "invalid_request_error", "unsupported_feature", - ), "chat"); + return markClientWire( + source.inbound === "messages" + ? anthropicErrorResponse(400, unrepresentableMessage(features), "invalid_request_error") + : chatCompletionsErrorResponse( + 400, unrepresentableMessage(features), "invalid_request_error", "unsupported_feature", + ), + source.inbound, + ); }, nativeChild: (target, targetRoute, targetSendBudget, initialDecision) => { // Without the request's execution budget a native child could not share its sends, so it @@ -218,22 +256,27 @@ export function createComboProtocolLanes(input: { provider: targetRoute.provider, modelId: targetRoute.modelId, }); - if (initialDecision) { - // A validated JEV choice owns only this first dispatch, including explicit no-effort. - // Do not run ordinary force defaults for null: they require a non-null default. - delete body.service_tier; - delete body.thinking_budget; - delete body.thinking; - const reasoning = body.reasoning; - if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) { - const preserved = { ...reasoning as Rec }; - delete preserved.effort; - if (Object.keys(preserved).length > 0) body.reasoning = preserved; - else delete body.reasoning; - } - if (initialDecision.effort === null) delete body.reasoning_effort; - else body.reasoning_effort = initialDecision.effort; - } else applyComboEffort(body, config, comboId, target, targetRoute); + // Native Messages has no effort rewrite yet: keep the bridge when combo effort policy applies. + if (source.inbound === "messages" + && (initialDecision !== undefined || comboDefaultEffort(config, comboId) != null)) return undefined; + if (source.inbound === "chat") { + if (initialDecision) { + // A validated JEV choice owns only this first dispatch, including explicit no-effort. + // Do not run ordinary force defaults for null: they require a non-null default. + delete body.service_tier; + delete body.thinking_budget; + delete body.thinking; + const reasoning = body.reasoning; + if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) { + const preserved = { ...reasoning as Rec }; + delete preserved.effort; + if (Object.keys(preserved).length > 0) body.reasoning = preserved; + else delete body.reasoning; + } + if (initialDecision.effort === null) delete body.reasoning_effort; + else body.reasoning_effort = initialDecision.effort; + } else applyComboEffort(body, config, comboId, target, targetRoute); + } return { route, body, sendBudget: targetSendBudget }; }, }; @@ -281,7 +324,7 @@ export interface ComboChildCallbacks { } /** - * Run one native child on the attempt the combo opened, and mark its answer as Chat wire. + * Run one native child on the attempt the combo opened, and mark its answer as client wire. * * The child's outcomes map onto the combo's existing child callbacks: a terminal or cancellation * after the response was handed over publishes through the gate (and so reaches the parent's @@ -301,14 +344,24 @@ export async function dispatchNativeComboChild(input: { onFirstOutput: () => void; callbacks: ComboChildCallbacks; }): Promise { - const { plan, logCtx, childLog, attempt, startedAt, callbacks } = input; + const { plan, logCtx, childLog, attempt, startedAt, callbacks, source } = input; childLog.providerAdapter = plan.route.provider.adapter; + const pathHop = source.inbound; markAttemptProtocolPath(attempt, { - mode: deliveryModeForLane("chat", "native", "chat"), - requestPath: requestPathForLane("chat", "native", "chat"), + mode: deliveryModeForLane(source.inbound, "native", source.inbound), + requestPath: requestPathForLane(source.inbound, "native", source.inbound), + responsePath: [pathHop, pathHop], }); let outputSeen = false; - const finishLog: NativeChatFinishLog = (status, message, closeReason = "non_stream") => { + const finishLog: NativeComboChildFinishLog = ( + status: number, + message?: string, + closeReasonOrMeta: "non_stream" | "terminal" | "client_cancel" | FinalRequestLogMeta = "non_stream", + ) => { + const meta: FinalRequestLogMeta = typeof closeReasonOrMeta === "string" + ? { closeReason: closeReasonOrMeta } + : closeReasonOrMeta; + const closeReason = meta.closeReason ?? "non_stream"; if (message) childLog.upstreamError = redactSecretString(message).slice(0, 500); if (closeReason !== "non_stream") { // A streamed body ends after the combo merged this child into the parent row, so what the @@ -321,7 +374,10 @@ export async function dispatchNativeComboChild(input: { callbacks.onResponseComplete(plan.route.modelId); return; } - if (closeReason === "terminal") { + if (meta.terminalStatus) { + childLog.terminalHttpStatus = status; + callbacks.onTerminal(meta.terminalStatus); + } else if (closeReason === "terminal") { childLog.terminalHttpStatus = status; callbacks.onTerminal("failed"); } @@ -332,6 +388,11 @@ export async function dispatchNativeComboChild(input: { seal: label => sealRequestAttemptIdentity(attempt, childLog.provider, childLog.providerAdapter ?? attempt.adapter, label), finish: (status, usage) => finishRequestAttempt(attempt, status, Date.now() - startedAt, usage), }; + markAttemptProtocolPath(attemptHandle, { + mode: deliveryModeForLane(source.inbound, "native", source.inbound), + requestPath: requestPathForLane(source.inbound, "native", source.inbound), + responsePath: [pathHop, pathHop], + }); const response = await input.source.dispatchNativeChild({ route: plan.route, body: plan.body, @@ -347,5 +408,5 @@ export async function dispatchNativeComboChild(input: { finishLog, }); if (!response.ok && outputSeen) markResponseNonReplayable(response); - return markClientWire(response, "chat"); + return markClientWire(response, source.inbound); } diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 9715cb0907f..f59a5adec44 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -1250,9 +1250,9 @@ export async function executeComboResponses( return clientCancelledResponse(); } - // A native Chat child reports a pre-stream failure by status before any byte, so its body + // A native child reports a pre-stream failure by status before any byte, so its body // is never peeked; a non-OK one takes the ordinary failure path below. - if (response.ok && !runTurnAdapterSseResponses.has(response) && clientWireOf(response) !== "chat") { + if (response.ok && !runTurnAdapterSseResponses.has(response) && !clientWireOf(response)) { const nativePassthrough = isNativePassthroughSseResponse(response); const eagerRelay = isEagerRelaySseResponse(response); let preflight; diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 4b7ed7e8599..214981a9d83 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -124,7 +124,7 @@ by `nativeChatDeclineReason` against a structural body built from the requested `nativeChatCombos` on, a combo's candidates are judged as the concrete routes they are, as the combo loop judges them; policy candidates keep `combo-or-policy-route`. With `protocols.rollout.managedMessagesNative` on, a Messages candidate is judged the same way by -`nativeMessagesDeclineReason`; with it off Messages candidates carry no decline reason, exactly as +`nativeMessagesDeclineReason`, and an eligible one runs natively through `createNativeMessagesComboSource` (any `baseUrl`; Chat children only get combo default-effort rewrites; `tests/claude-integration/messages-native-combo.test.ts`); with it off Messages candidates carry no decline reason, exactly as before the lane existed. Messages caller-forward passthrough depends on the caller's own credential, so it is reported as `caller-credential-required` and never assumed. The OpenCode Go session-lane transport is not diff --git a/tests/claude-integration/messages-native-combo.test.ts b/tests/claude-integration/messages-native-combo.test.ts new file mode 100644 index 00000000000..6977c7085cd --- /dev/null +++ b/tests/claude-integration/messages-native-combo.test.ts @@ -0,0 +1,382 @@ +/** + * Native Anthropic Messages candidates inside a combo. + * + * Tests a Messages combo with an OAuth `anthropic` candidate + key-auth `anthropic-apikey` candidate: + * 1) When candidate 1 succeeds, it sends native `/v1/messages` (messages -> messages). + * 2) When candidate 1 returns 429/500, combo fails over to candidate 2 and sends native `/v1/messages` + * (messages -> messages) from a fresh body copy. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearAnthropicAccountPoolState, forgetAnthropicFailoverQuorum } from "../../src/oauth/anthropic-routing"; +import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { getRequestLogEntries } from "../../src/server/request-log"; +import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +type Rec = Record; + +interface Sent { + url: string; + headers: Headers; + body: Rec; +} + +let sentOAuth: Sent[] = []; +let sentApiKey: Sent[] = []; +let testDir = ""; +let previousHome: string | undefined; +let releaseSpendHome: (() => void) | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-messages-native-combo-")); + process.env.OPENCODEX_HOME = testDir; + sentOAuth = []; + sentApiKey = []; + clearComboSelectionState(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + clearAnthropicAccountPoolState(); + forgetAnthropicFailoverQuorum(); + releaseSpendHome = acquireOwnedSpendHome(); +}); + +afterEach(() => { + resetProviderRequestPacingForTest(); + releaseSpendHome?.(); + releaseSpendHome = undefined; + clearComboSelectionState(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + clearAnthropicAccountPoolState(); + forgetAnthropicFailoverQuorum(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) removeTreeWithRetry(testDir); +}); + +function credential(index: number) { + return { + access: `synthetic-anthropic-access-${index}`, + refresh: `synthetic-anthropic-refresh-${index}`, + expires: Date.now() + 3_600_000, + accountId: `synthetic-account-${index}`, + source: "oauth" as const, + }; +} + +async function seedOAuth(count = 1): Promise { + for (let index = 0; index < count; index++) await saveCredential("anthropic", credential(index)); + const ids = getAccountSet("anthropic")!.accounts.map(account => account.id); + await setActiveAccount("anthropic", ids[0]!); + forgetAnthropicFailoverQuorum(); + return ids; +} + +const MESSAGE_RESPONSE: Rec = { + id: "msg_success_fixture", + type: "message", + role: "assistant", + model: "claude-sonnet-4-5", + content: [{ type: "text", text: "native combo reply" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 15, output_tokens: 6 }, +}; + +function comboConfig(options: { + oauthResponder: (sent: Sent, index: number) => Response | Promise; + apiKeyResponder: (sent: Sent, index: number) => Response | Promise; +}): OcxConfig { + const oauthTransport = (async (input: Parameters[0], init?: RequestInit) => { + const entry: Sent = { + url: String(input), + headers: new Headers(init?.headers), + body: JSON.parse(String(init?.body)) as Rec, + }; + sentOAuth.push(entry); + return options.oauthResponder(entry, sentOAuth.length - 1); + }) as typeof fetch; + + const apiKeyTransport = (async (input: Parameters[0], init?: RequestInit) => { + const entry: Sent = { + url: String(input), + headers: new Headers(init?.headers), + body: JSON.parse(String(init?.body)) as Rec, + }; + sentApiKey.push(entry); + return options.apiKeyResponder(entry, sentApiKey.length - 1); + }) as typeof fetch; + + const oauthProvider: OcxProviderConfig & { fetch: typeof fetch } = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "oauth", + models: ["claude-sonnet-4-5"], + fetch: oauthTransport, + }; + + const apiKeyProvider: OcxProviderConfig & { fetch: typeof fetch } = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "key", + apiKey: "sk-ant-apikey-fixture", + models: ["claude-sonnet-4-5"], + fetch: apiKeyTransport, + }; + + return { + port: 0, + defaultProvider: "anthropic", + anthropicAccountPool: { enabled: false }, + providers: { + anthropic: oauthProvider, + "anthropic-apikey": apiKeyProvider, + }, + combos: { + pair: { + strategy: "failover", + targets: [ + { provider: "anthropic", model: "claude-sonnet-4-5" }, + { provider: "anthropic-apikey", model: "claude-sonnet-4-5" }, + ], + }, + }, + protocols: { + rollout: { + managedMessagesNative: true, + managedMessagesNativeOAuth: true, + }, + }, + }; +} + +describe("Native Anthropic Messages support for Combos", () => { + test("when candidate 1 (OAuth) succeeds, sends native /v1/messages and stops", async () => { + await seedOAuth(1); + + const config = comboConfig({ + oauthResponder: () => Response.json(MESSAGE_RESPONSE), + apiKeyResponder: () => Response.json(MESSAGE_RESPONSE), + }); + + const requestId = `test-native-combo-succeed-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "combo/pair", + max_tokens: 1024, + messages: [{ role: "user", content: "hello combo" }], + }), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + + expect(response.status).toBe(200); + const result = await response.json() as Rec; + expect(result.id).toBe("msg_success_fixture"); + expect(result.model).toBe("combo/pair"); + + // Candidate 1 (OAuth) was dispatched natively + expect(sentOAuth).toHaveLength(1); + expect(sentOAuth[0]!.url).toBe("https://api.anthropic.com/v1/messages"); + expect(sentOAuth[0]!.headers.get("authorization")).toBe(["Bearer", "synthetic-anthropic-access-0"].join(" ")); + expect(sentOAuth[0]!.headers.get("x-api-key")).toBeNull(); + expect(sentOAuth[0]!.body.model).toBe("claude-sonnet-4-5"); + expect(sentOAuth[0]!.body.messages).toEqual([{ role: "user", content: "hello combo" }]); + + // Candidate 2 was never dispatched + expect(sentApiKey).toHaveLength(0); + + const rows = getRequestLogEntries().filter(e => e.requestId === requestId); + expect(rows).toHaveLength(1); + expect(rows[0]!.status).toBe(200); + expect(rows[0]!.provider).toBe("combo"); + expect(rows[0]!.protocolTrace).toMatchObject({ + inbound: "messages", + mode: "native", + requestPath: ["messages", "messages"], + attempts: [{ ordinal: 1, mode: "native", requestPath: ["messages", "messages"] }], + }); + }); + + test("when candidate 1 returns 429, combo fails over to candidate 2 and sends native /v1/messages from a fresh body copy", async () => { + await seedOAuth(1); + + const config = comboConfig({ + oauthResponder: () => new Response(JSON.stringify({ + type: "error", + error: { type: "rate_limit_error", message: "OAuth rate limited" }, + }), { + status: 429, + headers: { "content-type": "application/json" }, + }), + apiKeyResponder: () => Response.json({ + ...MESSAGE_RESPONSE, + id: "msg_apikey_fallback", + }), + }); + + const requestId = `test-native-combo-failover-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "combo/pair", + max_tokens: 1024, + messages: [{ role: "user", content: "hello failover" }], + }), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + + expect(response.status).toBe(200); + const result = await response.json() as Rec; + expect(result.id).toBe("msg_apikey_fallback"); + expect(result.model).toBe("combo/pair"); + + // Candidate 1 was called first and returned 429 + expect(sentOAuth).toHaveLength(1); + expect(sentOAuth[0]!.url).toBe("https://api.anthropic.com/v1/messages"); + expect(sentOAuth[0]!.headers.get("authorization")).toBe(["Bearer", "synthetic-anthropic-access-0"].join(" ")); + expect(sentOAuth[0]!.body.messages).toEqual([{ role: "user", content: "hello failover" }]); + + // Candidate 2 (API key) was called after failover and succeeded from a fresh body copy + expect(sentApiKey).toHaveLength(1); + expect(sentApiKey[0]!.url).toBe("https://api.anthropic.com/v1/messages"); + expect(sentApiKey[0]!.headers.get("x-api-key")).toBe("sk-ant-apikey-fixture"); + expect(sentApiKey[0]!.headers.get("authorization")).toBeNull(); + expect(sentApiKey[0]!.body.model).toBe("claude-sonnet-4-5"); + expect(sentApiKey[0]!.body.messages).toEqual([{ role: "user", content: "hello failover" }]); + + const rows = getRequestLogEntries().filter(e => e.requestId === requestId); + expect(rows).toHaveLength(1); + expect(rows[0]!.status).toBe(200); + expect(rows[0]!.provider).toBe("combo"); + expect(rows[0]!.protocolTrace).toMatchObject({ + inbound: "messages", + mode: "native", + requestPath: ["messages", "messages"], + attempts: [ + { ordinal: 1, mode: "native", requestPath: ["messages", "messages"] }, + { ordinal: 2, mode: "native", requestPath: ["messages", "messages"] }, + ], + }); + }); + + test("streams a native SSE answer from candidate 2 after candidate 1 returns 429 before any bytes", async () => { + await seedOAuth(1); + const sse = [ + `event: message_start +data: ${JSON.stringify({ type: "message_start", message: { ...MESSAGE_RESPONSE, id: "msg_stream_fallback", content: [], usage: { input_tokens: 3, output_tokens: 0 } } })} + +`, + `event: message_stop +data: ${JSON.stringify({ type: "message_stop" })} + +`, + ].join(""); + const config = comboConfig({ + oauthResponder: () => new Response(JSON.stringify({ type: "error", error: { type: "rate_limit_error", message: "limited" } }), { + status: 429, headers: { "content-type": "application/json" }, + }), + apiKeyResponder: () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }), + }); + const requestId = `test-native-combo-stream-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, + body: JSON.stringify({ model: "combo/pair", max_tokens: 1024, stream: true, messages: [{ role: "user", content: "hello stream" }] }), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + const text = await response.text(); + expect(text).toContain("msg_stream_fallback"); + expect(text).toContain("message_stop"); + expect(sentOAuth).toHaveLength(1); + expect(sentApiKey).toHaveLength(1); + expect(sentApiKey[0]!.body.stream).toBe(true); + expect(getRequestLogEntries().filter(e => e.requestId === requestId)).toHaveLength(1); + }); + + test("a request-selector effort pin keeps the combo child on the bridge", async () => { + await seedOAuth(1); + const frames = [ + ["message_start", { type: "message_start", message: { ...MESSAGE_RESPONSE, content: [], usage: { input_tokens: 3, output_tokens: 0 } } }], + ["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }], + ["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }], + ["content_block_stop", { type: "content_block_stop", index: 0 }], + ["message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }], + ["message_stop", { type: "message_stop" }], + ].map(([event, data]) => `event: ${event} +data: ${JSON.stringify(data)} + +`).join(""); + const sse = () => new Response(frames, { status: 200, headers: { "content-type": "text/event-stream" } }); + const config = comboConfig({ oauthResponder: sse, apiKeyResponder: sse }); + config.modelPinnedEfforts = { "combo/pair": "high" }; + const requestId = `test-native-combo-selector-pin-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, + body: JSON.stringify({ model: "combo/pair", max_tokens: 1024, messages: [{ role: "user", content: "hello pin" }] }), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + expect(response.status).toBe(200); + await response.text(); + const rows = getRequestLogEntries().filter(e => e.requestId === requestId); + expect(rows).toHaveLength(1); + const attempts = (rows[0]!.protocolTrace as { attempts?: Array<{ mode: string }> } | undefined)?.attempts ?? []; + expect(attempts.every(attempt => attempt.mode !== "native")).toBe(true); + }); + + test("when candidate 1 returns 500, combo fails over to candidate 2", async () => { + await seedOAuth(1); + + const config = comboConfig({ + oauthResponder: () => new Response(JSON.stringify({ + type: "error", + error: { type: "api_error", message: "Internal server error" }, + }), { + status: 500, + headers: { "content-type": "application/json" }, + }), + apiKeyResponder: () => Response.json({ + ...MESSAGE_RESPONSE, + id: "msg_apikey_fallback_500", + }), + }); + + const requestId = `test-native-combo-failover-500-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "combo/pair", + max_tokens: 1024, + messages: [{ role: "user", content: "hello 500 failover" }], + }), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + + expect(response.status).toBe(200); + const result = await response.json() as Rec; + expect(result.id).toBe("msg_apikey_fallback_500"); + + expect(sentOAuth).toHaveLength(1); + expect(sentApiKey).toHaveLength(1); + expect(sentApiKey[0]!.body.messages).toEqual([{ role: "user", content: "hello 500 failover" }]); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c9204b18355..21aa6988cfb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -107,6 +107,7 @@ "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "messages-native-empty-inline.test.ts": "claude-integration", + "messages-native-combo.test.ts": "claude-integration", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "current-turn-tool-catalog-direct.test.ts": "responses", diff --git a/tests/responses/messages-native-eligibility.test.ts b/tests/responses/messages-native-eligibility.test.ts index d159674e7d5..a5602a80742 100644 --- a/tests/responses/messages-native-eligibility.test.ts +++ b/tests/responses/messages-native-eligibility.test.ts @@ -119,11 +119,21 @@ describe("planner mirrors the native Messages rule", () => { expect(plan.guaranteedFeatures).toEqual(["request.top_k"]); }); - test("switch on: OAuth and combo candidates report the rule that declined them", () => { + test("switch on: OAuth candidate reports the rule that declined it, while combo judges concrete candidates", () => { const oauth = buildProtocolPlanSnapshot(config(ON), { model: "anthoauth/claude-o", inbound: "messages", features: [] }); expect(oauth.candidates[0]).toMatchObject({ nativeEligible: false, declineReasons: ["auth-mode-not-native"] }); const combo = buildProtocolPlanSnapshot(config(ON), { model: "combo/pair", inbound: "messages", features: [] }); - expect(combo.candidates[0]).toMatchObject({ provider: "anth", nativeEligible: false, declineReasons: ["combo-or-policy-route"] }); + expect(combo.candidates[0]).toMatchObject({ provider: "anth", nativeEligible: true, declineReasons: [] }); + expect(combo.candidates[1]).toMatchObject({ provider: "chat", nativeEligible: false, declineReasons: ["cross-wire-ir"] }); + const plan = previewProtocolPlan(config(ON), { model: "combo/pair", inbound: "messages", features: [] }); + expect(plan.mode).toBe("native"); + }); + + test("switch off: combo candidate declines native Messages lane", () => { + const combo = buildProtocolPlanSnapshot(config(), { model: "combo/pair", inbound: "messages", features: [] }); + expect(combo.candidates[0]).toMatchObject({ provider: "anth", nativeEligible: false, declineReasons: [] }); + const plan = previewProtocolPlan(config(), { model: "combo/pair", inbound: "messages", features: [] }); + expect(plan.mode).toBe("legacy-bridge"); }); }); diff --git a/tests/responses/protocol-envelope.test.ts b/tests/responses/protocol-envelope.test.ts index 06344360de0..fe2aaaa2d80 100644 --- a/tests/responses/protocol-envelope.test.ts +++ b/tests/responses/protocol-envelope.test.ts @@ -33,6 +33,16 @@ describe("createProtocolEnvelope", () => { expect(envelope.inbound).toBe("chat"); }); + test("peekBody hands out the source by reference without charging the budget", () => { + const source = chatBody(); + const budget = createTestTranslatorBudget(); + const envelope = createProtocolEnvelope({ inbound: "chat", body: source, translatorBudget: budget }); + expect(envelope.peekBody()).toBe(source); + expect(budget.snapshot().currentBytes).toBe(0); + envelope.freshBody(); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + }); + test("freshBody returns an independent copy each time", () => { const source = chatBody(); const envelope = createProtocolEnvelope({ inbound: "chat", body: source, translatorBudget: createTestTranslatorBudget() }); diff --git a/tests/responses/protocol-plan-snapshot.test.ts b/tests/responses/protocol-plan-snapshot.test.ts index dbaa4446bc7..3a88b98c57b 100644 --- a/tests/responses/protocol-plan-snapshot.test.ts +++ b/tests/responses/protocol-plan-snapshot.test.ts @@ -95,6 +95,33 @@ describe("buildProtocolPlanSnapshot", () => { expect(plan.candidates.map(c => c.mode)).toEqual(["native", "translated"]); }); + test("with managedMessagesNative on, a combo's Anthropic candidate is judged as its concrete route", () => { + const config = baseConfig({ + providers: { + ...baseConfig().providers, + anth: { adapter: "anthropic", baseUrl: "https://anth.example/v1", authMode: "key", apiKey: "ka", models: ["claude-x"] }, + }, + combos: { + msgCombo: { + strategy: "failover", + targets: [ + { provider: "anth", model: "claude-x" }, + { provider: "r", model: "m3" }, + ], + }, + }, + protocols: { rollout: { managedMessagesNative: true } }, + } as Partial); + const snapshot = buildProtocolPlanSnapshot(config, { model: "combo/msgCombo", inbound: "messages", features: [] }); + expect(snapshot.candidates.map(c => [c.provider, c.nativeEligible, c.declineReasons])).toEqual([ + ["anth", true, []], + ["r", false, ["cross-wire-ir"]], + ]); + const plan = previewProtocolPlan(config, { model: "combo/msgCombo", inbound: "messages", features: [] }); + expect(plan.mode).toBe("native"); + expect(plan.candidates.map(c => c.mode)).toEqual(["native", "translated"]); + }); + test("a policy alias expands its configured candidates", () => { const snapshot = buildProtocolPlanSnapshot(baseConfig(), { model: "ocx/fast", inbound: "responses", features: [] }); expect(snapshot.routeKind).toBe("policy");