diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 13def1b9b67..bf6fc88f746 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -86,6 +86,11 @@ a caller `Authorization` header addressed to the source route is stripped, as fo A disabled destination, a forward destination, or a key/OAuth destination without usable stored credentials keeps the source route's legacy behavior. Under a routing policy, every redirect target must itself be a declared eligible candidate, even when it keeps the same provider. +The initial evaluation's eligible provider/model set remains fixed through fallback and subagent +recovery. Retries use concrete candidates, so a combo alias cannot replace a candidate's destination. +Different candidates that resolve to a destination already attempted do not send the turn there again. +An eligible virtual model keeps its normal wire-model mapping without adding that wire id to the +profile's allowed candidates. If a local replacement is skipped, fallback retains the last upstream failure. Malformed redirect maps are ignored with a warning on load and rejected on configuration writes. Redirected routes record `blocked-model-redirect`; omitting the setting leaves routing unchanged. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 402eede4dc3..f1913bd034b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1534,6 +1534,7 @@ "routing-compatibility.test.ts": "routing", "routing-identity-domains.test.ts": "routing", "routing-intelligence-ui.test.ts": "gui", + "routing-policy-dispatch-scope.test.ts": "routing", "routing-policy-fallback.test.ts": "routing", "routing-policy-pool-quota.test.ts": "routing", "routing-policy-surface-parity.test.ts": "routing", diff --git a/src/router.ts b/src/router.ts index 4a682c78e27..a0516afc361 100644 --- a/src/router.ts +++ b/src/router.ts @@ -89,6 +89,12 @@ export interface RouteResult { combo?: ComboPick; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** + * Full eligible provider/model membership from a policy evaluation, keyed + * "provider\u0000model". The trace's candidate list is bounded; fallback + * redirect checks need membership for eligible rows that were truncated out. + */ + policyEligibility?: ReadonlySet; /** * Set when a blocked-model redirect moved the request to a different * provider. Caller credentials addressed to the source route must not follow @@ -733,6 +739,9 @@ function routeModelInternal( ...routed, routeKind: "policy" as const, routeReason: routed.routeReason === "blocked-model-redirect" ? "blocked-model-redirect" : "policy-selected", + policyEligibility: new Set(evaluation.candidates + .filter(candidate => candidate.eligible) + .map(candidate => candidate.provider + "\u0000" + candidate.model)), routeDecision: { ...evaluation.trace, selected: { diff --git a/src/server/request-log.ts b/src/server/request-log.ts index f7986a07e0e..e34737ba04f 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -247,6 +247,8 @@ export interface RequestLogContext { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Full eligible provider/model membership from a policy route; never logged. */ + policyEligibility?: ReadonlySet; /** Privacy-bounded JEV selection metadata; downstream usage is recorded on attempts[]. */ jevDecision?: PersistedJevDecisionV1; /** Opt-in shadow evidence, normalized again at the logging boundary. */ diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 526a8caeaa0..961af3a3ba9 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -691,6 +691,7 @@ export async function handleResponsesCompact( logCtx.requestedModel = compactRequestedModel; logCtx.model = selectedModelId; logCtx.routeDecision = route.routeDecision; + logCtx.policyEligibility = route.policyEligibility; logCtx.provider = route.codexAccountNamespace ? `${route.providerName}-${route.codexAccountNamespace}` : route.providerName; diff --git a/src/server/responses/core-normalize.ts b/src/server/responses/core-normalize.ts index 39bc3b7e292..4ab4378d1a1 100644 --- a/src/server/responses/core-normalize.ts +++ b/src/server/responses/core-normalize.ts @@ -186,6 +186,7 @@ export async function applyFinalRouteRequestNormalization(args: { logCtx.provider = route.providerName; logCtx.providerAdapter = route.provider.adapter; logCtx.routeDecision = route.routeDecision; + logCtx.policyEligibility = route.policyEligibility; if (route.routeReason === "model-alias" || route.modelId !== responseModelId && responseModelId.includes("/")) logCtx.requestedAlias = responseModelId; if (responsesUpstreamStreaming === false && route.provider.adapter === "openai-responses") { diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index aea7f850800..4bd3b62342c 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -19,6 +19,7 @@ import type { TransientSendBudget } from "../../lib/upstream-retry"; import type { RequestLogContext } from "../request-log"; import type { UpstreamHostAdmissionLease } from "../../codex/upstream-host-health"; import type { AccountLease } from "../../oauth/kiro-account-load"; +import type { PolicyRequestScope } from "./policy-request-scope"; export interface ConsumedComboFailure { response: Response; @@ -57,6 +58,10 @@ export interface ClientEncoderOption { } export interface HandleResponsesOptions { + /** Internal request-owned policy authorization; never read from client headers or body. */ + policyRequestScope?: PolicyRequestScope; + /** Internal concrete selector chosen from the original policy evaluation. */ + policyFallbackCandidate?: { provider: string; model: string }; /** Internal routed-compaction recovery: one logical request, one emergency target. */ compactionRecoveryAttempted?: boolean; compactionRecoveryPermit?: SingleUseDispatchPermit; diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index c78809913e2..803289626a1 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -11,6 +11,8 @@ import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar" import { captureCallerDirectAuth } from "../../providers/caller-authorization"; import { resolvePolicyProfileId } from "../../routing/profile"; import { parseSyntheticRowId } from "../fast-row"; +import { routeConcreteModel } from "../../router"; +import { isPolicyCandidateRefusal, policyWireDestination, type PolicyRequestScope } from "./policy-request-scope"; type CoreHandler = typeof handleResponsesCore; type CoreOptions = Parameters[3]; @@ -23,6 +25,25 @@ function candidateKey(candidate: Pick return `${candidate.provider}\u0000${candidate.model}`; } +function staysWithinPolicyAfterRedirect( + config: OcxConfig, + policyEligibility: ReadonlySet, + candidate: RouteCandidateTrace, + triedDestinations: ReadonlySet, +): boolean { + try { + // Both inspection and execution resolve the original concrete candidate, without public + // combo/profile aliases or combo selection writes. A redirect must remain in the evaluation. + const routed = routeConcreteModel(config, `${candidate.provider}/${candidate.model}`); + return policyEligibility.has(`${routed.providerName}\u0000${routed.modelId}`) + && !triedDestinations.has(policyWireDestination(routed)); + } catch { + // A route that fails to resolve (e.g. a redirect cycle) cannot succeed on retry; its terminal + // error is rarely hop-worthy, so keep the fallback alive for the next eligible candidate. + return false; + } +} + /** * Rank the remaining candidates from the ORIGINAL policy trace. The initial * decision stays immutable; fallback execution belongs in attempts[], not in a @@ -87,6 +108,7 @@ async function shouldHopPolicyCandidate(response: Response, signal?: AbortSignal if (response.status < 400 || signal?.aborted) return false; // A response that must not be sent again cannot open a policy-candidate retry either. if (isNonReplayableResponse(response)) return false; + if (isPolicyCandidateRefusal(response)) return true; try { const inspected = await readBoundedResponseBody(response.clone(), { signal }); const text = inspected.displaySafe ? inspected.text : ""; @@ -131,11 +153,13 @@ export async function handleResponsesWithPolicyFallback( deps: PolicyFallbackDeps = {}, ): Promise { const runCore = deps.runCore ?? handleResponsesCore; + const policyScope: PolicyRequestScope = options.policyRequestScope ?? { triedDestinations: new Set() }; let requestBodyReadNotified = false; let storedPool401ReplayDispatched = false; let rawBody: Record | null = null; const coreOptions: CoreOptions = { ...options, + policyRequestScope: policyScope, openAiSidecarAuth: options.openAiSidecarAuth === undefined ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth, nativeCallerAuth: options.nativeCallerAuth === undefined @@ -175,25 +199,52 @@ export async function handleResponsesWithPolicyFallback( if (overload) return overload; throw error; } - const initialTrace = logCtx.routeDecision; + const initialTrace = policyScope.decision ?? logCtx.routeDecision; const initialRequestedModel = logCtx.requestedModel; if (!rawBody || !isPolicyDecision(initialTrace)) return response; const tried = new Set([ candidateKey({ provider: initialTrace.selected.provider, model: initialTrace.selected.model }), ]); + const selectedCandidate = initialTrace.selected.candidateIndex === undefined ? undefined + : initialTrace.candidates[initialTrace.selected.candidateIndex]; + if (selectedCandidate) tried.add(candidateKey(selectedCandidate)); + // Redirect eligibility needs the full evaluation membership, not the bounded trace list. + const policyEligibility: ReadonlySet = policyScope.eligibility ?? logCtx.policyEligibility + ?? new Set(initialTrace.candidates + .filter(candidate => candidate.eligible) + .map(candidate => candidateKey(candidate))); + policyScope.eligibility = policyEligibility; + policyScope.decision = initialTrace; + policyScope.triedDestinations.add(policyScope.preparedDestination ?? policyWireDestination({ + providerName: initialTrace.selected.provider, modelId: initialTrace.selected.model, + })); while (!storedPool401ReplayDispatched && await shouldHopPolicyCandidate(response, req.signal)) { if (req.signal.aborted) return response; - const next = rankPolicyFallbackCandidates(initialTrace, tried)[0]; + const next = rankPolicyFallbackCandidates(initialTrace, tried) + .find(candidate => staysWithinPolicyAfterRedirect(config, policyEligibility, candidate, policyScope.triedDestinations)); if (!next) return response; tried.add(candidateKey(next)); + // Retain the failed response's log owner until a candidate actually replaces it. This is + // deliberately shallow: completed attempts and the live spend tracker keep their identity. + const failureLog = { ...logCtx }; finishFailedPolicyAttempt(logCtx, response.status); const retryRequest = requestWithCandidate(req, rawBody, next); + delete policyScope.preparedDestination; try { try { - response = await runCore(retryRequest, config, logCtx, coreOptions); + const nextResponse = await runCore(retryRequest, config, logCtx, { ...coreOptions, policyFallbackCandidate: next }); + // A locally skipped route owns neither the returned failure nor its usage/settlement. + // Preparation can replace route metadata and the tracker, or add fields absent before it. + if (isPolicyCandidateRefusal(nextResponse)) { + for (const key of Object.keys(logCtx)) { + if (!Object.hasOwn(failureLog, key)) Reflect.deleteProperty(logCtx, key); + } + Object.assign(logCtx, failureLog); + } else response = nextResponse; + if (policyScope.preparedDestination) policyScope.triedDestinations.add(policyScope.preparedDestination); } catch (error) { const overload = requestPacingOverloadResponse(error); if (overload) return overload; @@ -202,6 +253,7 @@ export async function handleResponsesWithPolicyFallback( } finally { logCtx.requestedModel = initialRequestedModel; logCtx.routeDecision = initialTrace; + logCtx.policyEligibility = policyEligibility; } } diff --git a/src/server/responses/policy-request-scope.ts b/src/server/responses/policy-request-scope.ts new file mode 100644 index 00000000000..3504fcd7419 --- /dev/null +++ b/src/server/responses/policy-request-scope.ts @@ -0,0 +1,80 @@ +import { formatErrorResponse } from "../../bridge"; +import type { RouteResult } from "../../router"; +import type { RouteDecisionTraceV1 } from "../../routing/trace"; +import { resolveOpenAiVirtualModel } from "../../providers/openai-virtual-models"; + +/** Request-owned authorization, separate from the diagnostic trace and each physical attempt. */ +export interface PolicyRequestScope { + decision?: RouteDecisionTraceV1; + eligibility?: ReadonlySet; + readonly triedDestinations: Set; + preparedDestination?: string; +} + +export function policyDestinationKey(provider: string, model: string): string { + return `${provider}\u0000${model}`; +} + +export function policyWireDestination(route: Pick): string { + const model = resolveOpenAiVirtualModel(route.providerName, route.modelId)?.wireModelId ?? route.modelId; + return policyDestinationKey(route.providerName, model); +} + +export class PolicyCandidateUnavailableError extends Error { + constructor() { + super("The candidate cannot serve this request within its original routing policy."); + this.name = "PolicyCandidateUnavailableError"; + } +} + +const localRefusals = new WeakSet(); + +export function policyCandidateRefusalResponse(error: unknown): Response | undefined { + if (!(error instanceof PolicyCandidateUnavailableError)) return undefined; + const response = formatErrorResponse(409, "policy_candidate_unavailable", error.message); + localRefusals.add(response); + return response; +} + +/** Upstream text or a matching error code cannot authorize another policy attempt. */ +export function isPolicyCandidateRefusal(response: Response): boolean { + return localRefusals.has(response); +} + +export function capturePolicyRequestRoute(scope: PolicyRequestScope, route: RouteResult): RouteResult { + if (!scope.eligibility && route.policyEligibility && route.routeDecision?.routeKind === "policy") { + scope.eligibility = new Set(route.policyEligibility); + scope.decision = route.routeDecision; + } + if (!scope.eligibility) return route; + if (!scope.eligibility.has(policyDestinationKey(route.providerName, route.modelId))) { + throw new PolicyCandidateUnavailableError(); + } + // Preparation and normalization both project from RouteResult into the log. Keep the original + // authorization and decision on every allowed replacement, including recovery's second pass. + route.routeKind = "policy"; + route.policyEligibility = scope.eligibility; + route.routeDecision = scope.decision; + return route; +} + +/** Only the admitted selector's existing virtual-model rewrite may change its final route id. */ +export function assertPolicyNormalizedRoute( + scope: PolicyRequestScope, + admitted: Pick, + route: RouteResult, +): void { + if (!scope.eligibility) return; + if (route.providerName !== admitted.providerName || (route.modelId !== admitted.modelId + && route.modelId !== resolveOpenAiVirtualModel(admitted.providerName, admitted.modelId)?.wireModelId)) { + throw new PolicyCandidateUnavailableError(); + } +} + +/** Called once preparation has settled the wire, before dispatch; account retries keep their own budget. */ +export function recordPolicyPreparedDestination(scope: PolicyRequestScope, provider: string, model: string): void { + if (!scope.eligibility) return; + const destination = policyDestinationKey(provider, model); + if (scope.triedDestinations.has(destination)) throw new PolicyCandidateUnavailableError(); + scope.preparedDestination = destination; +} diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 60e0ed6fa38..ec29c98ace1 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -1,4 +1,5 @@ import type { ResponsesRequestContext, ResponsesAdmissionState, ResponsesDispatchers } from "./core-options"; +import { capturePolicyRequestRoute, assertPolicyNormalizedRoute, recordPolicyPreparedDestination, policyCandidateRefusalResponse } from "./policy-request-scope"; import { agentTaskRecoveryConfig, restoreCachedEncryptedAgentTasks, @@ -153,6 +154,8 @@ export async function prepareResponsesRequest( requestDispatchers: ResponsesDispatchers, ) { const { options, config, req, logCtx } = requestContext; + const policyScope = options.policyRequestScope ?? { triedDestinations: new Set() }; + const concreteSelection = options.comboAttempt || options.policyFallbackCandidate !== undefined; // The Chat and Anthropic surfaces replay through here with a Responses-shaped body, // so an omitted value means a genuine Responses inbound. @@ -176,7 +179,7 @@ export async function prepareResponsesRequest( ? "system-derived" : options.promptCacheKeyIsSharedCohort === false ? "metadata-derived" : "caller", ); - if (!options.comboAttempt && !options.compactionRoutingOverride && inboundWire === "responses") { + if (!concreteSelection && !options.compactionRoutingOverride && inboundWire === "responses") { options.compactionRoutingOverride = applyCompactionRoutingOverride(body, req.headers, config, { endpoint: "responses", transport: options.inboundTransport, @@ -188,18 +191,18 @@ export async function prepareResponsesRequest( // more specific of the two settings and must be the one that survives when both match one request. const memoryModelPhase = options.memoryModelPhase ?? ((config.memoryModels?.extract || config.memoryModels?.consolidation) - && !options.comboAttempt && !options.compactionRoutingOverride && inboundWire === "responses" + && !concreteSelection && !options.compactionRoutingOverride && inboundWire === "responses" ? detectMemoryModelPhase(body, req.headers, { transport: options.inboundTransport }) ?? undefined : undefined); const memoryModelTarget = memoryModelPhase ? configuredMemoryModel(config, memoryModelPhase) : undefined; // A combo child is a synthetic replay of the parent's decision: its model is already the target's // concrete provider/model, so neither site below may rewrite or re-resolve it. It keeps the phase // through `options.memoryModelPhase` instead, which is what applies the phase effort. - const memoryModelApplies = memoryModelTarget !== undefined && options.comboAttempt !== true; + const memoryModelApplies = memoryModelTarget !== undefined && !concreteSelection; options.onRequestBodyParsed?.(body); // An effort row naming a table-less combo (`combo/x--high`) must reach the combo dispatcher // as its base id, so the selector is normalized here, before comboIdFromRawBody reads model. - const comboRows = !options.comboAttempt && body && typeof body === "object" && !Array.isArray(body) + const comboRows = !concreteSelection && body && typeof body === "object" && !Array.isArray(body) && typeof (body as { model?: unknown }).model === "string" // One parse for both grammars, from the selector as the client sent it. Parsing them // separately made the outcome depend on which ran first. @@ -228,7 +231,7 @@ export async function prepareResponsesRequest( } // Compaction may send the last client-visible bare model after a combo switch. // Configured selectors take precedence; otherwise recall before combo dispatch (#3891). - if (!options.comboAttempt && !options.compactionRoutingOverride && body && typeof body === "object" && !Array.isArray(body)) { + if (!concreteSelection && !options.compactionRoutingOverride && body && typeof body === "object" && !Array.isArray(body)) { const rawModel = (body as { model?: unknown }).model; const rawInput = (body as { input?: unknown }).input; const isCompactionTrigger = Array.isArray(rawInput) @@ -265,7 +268,7 @@ export async function prepareResponsesRequest( // A spawned sub-agent turn names its model on purpose; gpt-6-luna is both the helper // slug and a default sub-agent model, so neither intercept site may rewrite that turn. const threadSpawn = isThreadSpawnRequest(req.headers); - if (!options.comboAttempt && !options.compactionRoutingOverride && !threadSpawn && !memoryModelApplies && body && typeof body === "object" && !Array.isArray(body)) { + if (!concreteSelection && !options.compactionRoutingOverride && !threadSpawn && !memoryModelApplies && body && typeof body === "object" && !Array.isArray(body)) { const shadowIntercept = config.shadowCallIntercept; const rawShadowModel = (body as { model?: unknown }).model; if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string" @@ -283,7 +286,7 @@ export async function prepareResponsesRequest( } } } - const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null; + const comboId = !concreteSelection ? comboIdFromRawBody(body, config) : null; if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) { options.onRequestBodyRead?.(); return requestDispatchers.handleComboResponses(req, body, comboId, config, logCtx, { @@ -514,6 +517,7 @@ export async function prepareResponsesRequest( // the key's scope at this one point is what stops a rewrite from reaching // a destination the front door would have refused. assertRouteAllowedByScope(admissionScope, inboundSelector, candidate); + capturePolicyRequestRoute(policyScope, candidate); candidate.staticPolicy = captureRouteStaticPolicy( candidate.providerName, candidate.modelId, @@ -528,7 +532,7 @@ export async function prepareResponsesRequest( // no canonical OpenAI route for (#2901). Only the initial compaction route // may fall back to the configured default provider; combo attempts and the // later fallback/recovery re-routes keep the ordinary reservation. - const resolveRoute = (modelId: string) => captureInboundRoutePolicy(options.comboAttempt + const resolveRoute = (modelId: string) => captureInboundRoutePolicy(concreteSelection ? routeConcreteModel(config, modelId) : parsed._compactionRequest === true ? routeCompactionModel(config, modelId, evidenceFromBody(parsed._rawBody)) @@ -554,7 +558,7 @@ export async function prepareResponsesRequest( } const _sci = config.shadowCallIntercept; let shadowRoute: RouteResult | undefined; - if (!memoryRoute && !options.memoryModelPhase && !options.compactionRoutingOverride && !threadSpawn && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) { + if (!concreteSelection && !memoryRoute && !options.memoryModelPhase && !options.compactionRoutingOverride && !threadSpawn && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) { const sourcePrefix = shadowSourceModelPrefix(parsed.modelId, _sci.sourceModels)!; let sourceIdentity = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourcePrefix }; try { @@ -591,7 +595,8 @@ export async function prepareResponsesRequest( } } if (parsed._compactionRequest === true || options.compactionRoutingOverride) parsed._cursorIsolateConversation = true; - route = memoryRoute ?? shadowRoute ?? resolveRoute(parsed.modelId); + route = memoryRoute ?? shadowRoute ?? resolveRoute(options.policyFallbackCandidate + ? `${options.policyFallbackCandidate.provider}/${options.policyFallbackCandidate.model}` : parsed.modelId); // Name the phase in the persisted route decision, so the request log says why this turn went to // the memory destination instead of leaving it looking like a plain user selection. Set here, on // the resolved route, so a combo child's own route carries it too. @@ -615,7 +620,10 @@ export async function prepareResponsesRequest( if (parsed._compactionRequest === true) parsed._portableCompaction = true; } logCtx.routeDecision = route.routeDecision; + logCtx.policyEligibility = route.policyEligibility; } catch (err) { + const policyRefusal = policyCandidateRefusalResponse(err); + if (policyRefusal) return policyRefusal; if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { return comboUnavailable(err.comboId); @@ -827,7 +835,10 @@ export async function prepareResponsesRequest( ); credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; + logCtx.policyEligibility = route.policyEligibility; } catch (err) { + const policyRefusal = policyCandidateRefusalResponse(err); + if (policyRefusal) return policyRefusal; if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { return comboUnavailable(err.comboId); @@ -1038,7 +1049,10 @@ export async function prepareResponsesRequest( ); credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; + logCtx.policyEligibility = route.policyEligibility; } catch (err) { + const policyRefusal = policyCandidateRefusalResponse(err); + if (policyRefusal) return policyRefusal; if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { return comboUnavailable(err.comboId); @@ -1178,6 +1192,7 @@ export async function prepareResponsesRequest( // upstream for reliability (#875); the answer must then be reframed to SSE // for streaming clients. const clientRequestedStream = parsed.stream; + const admittedPolicyRoute = { providerName: route.providerName, modelId: route.modelId }; await applyFinalRouteRequestNormalization({ parsed, route, @@ -1188,6 +1203,13 @@ export async function prepareResponsesRequest( inboundTransport: options.inboundTransport, claudeGoAffinity: options.claudeGoAffinity, }); + try { + assertPolicyNormalizedRoute(policyScope, admittedPolicyRoute, route); + } catch (error) { + const policyRefusal = policyCandidateRefusalResponse(error); + if (policyRefusal) return policyRefusal; + throw error; + } // Normalization is the last thing that can move the destination: resolving an // OpenAI virtual model rewrites route.modelId to the wire id that will // actually be billed. A scope checked only before this would authorize the @@ -1304,6 +1326,13 @@ export async function prepareResponsesRequest( route.provider = applyCodexAuthContextToProvider(route.provider, admissionState.authCtx, route.codexAccountMode); applyCodexAccountGatedWireNormalization(parsed, route, logCtx); + try { + recordPolicyPreparedDestination(policyScope, route.providerName, parsed._wireModelOverride ?? parsed.modelId); + } catch (error) { + const policyRefusal = policyCandidateRefusalResponse(error); + if (policyRefusal) return policyRefusal; + throw error; + } logCtx.provider = route.codexAccountNamespace ? `${route.providerName}-${route.codexAccountNamespace}` : formatCodexProviderForLog(route.providerName, codexLogAccountId(admissionState.authCtx), config); diff --git a/structure/INDEX.md b/structure/INDEX.md index 53f9e34cebd..18208c6474f 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -40,6 +40,7 @@ The wire surfaces a client actually talks to. | [`transports/responses.md`](transports/responses.md) | The Responses HTTP/SSE endpoint, dispatch, credential and upload boundaries, and core module ownership. | | [`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) | Mixed-wire model defaults, xAI agent-message continuation, declared-tool membership, and passthrough SSE stream shapes. | | [`transports/responses-failover.md`](transports/responses-failover.md) | Upstream reset retry, the ambiguous-resend gate, combo failover and commit boundaries, compaction routing, and output headroom. | +| [`transports/policy-fallback.md`](transports/policy-fallback.md) | Original policy authorization, concrete candidate retries and physical destination deduplication. | | [`transports/responses-spend.md`](transports/responses-spend.md) | Credential-hop and durable spend reservations, and what a spent budget tells the client. | | [`transports/streaming-health.md`](transports/streaming-health.md) | Heartbeat and stall deadlines, plus the opt-in WebSocket transport. | | [`transports/inventory.md`](transports/inventory.md) | The per-provider transport table and diagnostic outbound safety. | @@ -147,7 +148,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/responses/` | [`runtime.md`](runtime.md)
[`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/kiro.md`](providers/kiro.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | | `src/router.ts` | [`runtime.md`](runtime.md) | | `src/routing/` | [`catalog.md`](catalog.md) | -| `src/server/` | [`runtime.md`](runtime.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | +| `src/server/` | [`runtime.md`](runtime.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/server/index.ts` | [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | | `src/service-manager-probe.ts` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 533183c97bd..645dbaf9077 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -199,7 +199,16 @@ ] }, { - "path": "transports/responses-spend.md", + "path": "transports/policy-fallback.md", + "tier": 3, + "title": "Policy Candidate Fallback", + "scope": "Original policy authorization, concrete candidate retries and physical destination deduplication.", + "documents": [ + "src/server/" + ] + }, + { + "path": "transports/responses-spend.md", "tier": 3, "title": "Responses Spend Reservations", "scope": "Credential-hop and durable spend reservations, and what a spent budget tells the client.", diff --git a/structure/runtime.md b/structure/runtime.md index 85e0a73cb16..5f8b9862422 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -17,7 +17,7 @@ replace `route.staticPolicy` before adapter or capability decisions continue. Mo resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. Live selector hints obey the [credential-scoped cache contract](catalog.md): a selection change cannot reuse the previous credential's roster to choose an alias target. Passive OAuth observation -neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. +neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions and candidate fallback retries, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. Routed Meta Muse requests use the registry-owned [Muse effort and header contract](providers-and-adapters.md); `max` reaches the provider through the existing reasoning mapper. diff --git a/structure/subagents.md b/structure/subagents.md index 0c477c5e12e..81e2ec72898 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -9,6 +9,9 @@ Native steering follows [the shared WebSocket contract](transports/streaming-hea Encrypted-task and fallback request handling follow the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. +For policy-selected turns, both subagent selection and the post-recovery selection retain the +[original policy authorization](transports/policy-fallback.md); +ordinary subagent fallback configuration does not authorize a destination outside that evaluation. Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). diff --git a/structure/transports/policy-fallback.md b/structure/transports/policy-fallback.md new file mode 100644 index 00000000000..a2bb9338c07 --- /dev/null +++ b/structure/transports/policy-fallback.md @@ -0,0 +1,26 @@ +# Policy Candidate Fallback + +`src/server/responses/policy-request-scope.ts` retains the first policy evaluation's complete +eligible provider/model membership and decision throughout Responses preparation and fallback. +Subagent selection and encrypted-task recovery keep that membership; a replacement outside it is +refused before dispatch, and policy fallback may continue with another originally eligible candidate. +`src/server/responses/policy-fallback.ts` inspects and executes each retry as a concrete candidate, +so a public combo or profile alias cannot reselect its destination. Redirects remain inside the +original evaluation, including eligible destinations omitted from the bounded diagnostic trace. + +Normal virtual-model wire mapping remains attached to its admitted selector and does not add a new +eligible model. Policy traversal records settled physical destinations and skips a destination already +attempted through another candidate or redirect; same-target credential and transient retries retain +their existing budgets. Local skips preserve the last upstream failure and its log context, including +route identity, usage, active attempt and spend tracker; preparation-only fields are discarded. +Completed physical attempt rows remain intact, and final logging settles the last actual send's usage. +The initial decision remains the selection evidence while physical attempts record execution. + +Cancellation, non-replayable responses and committed output keep the +[shared replay boundaries](responses-failover.md). A local policy refusal is identified by its +in-process response identity; matching upstream prose or error codes cannot authorize another attempt. + +Coverage: `tests/routing/routing-policy-dispatch-scope.test.ts` exercises real request preparation, +normalization and configured subagent selection with synthetic dispatch. It also covers recovery, +alias collisions, redirects, bounded traces and virtual models. `tests/routing/routing-policy-fallback.test.ts` +pins request snapshots, terminal responses, cancellation, credential handoff and attempt accounting. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 1bd7ebde4cb..3c55fdacd46 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -28,6 +28,8 @@ ambiguous-resend gate and replay boundary, combo quota fallback and commit bound routing overrides, and output headroom. The endpoint and dispatch rules they build on are in [Responses transport](responses.md). `src/lib/errors.ts` classifies an HTTP 400 input-token-count overflow as `context_length_exceeded`, including the counted-token variant; the wording is Google's, but the shared classifier matches it for any provider. Output-token limits and protected failures retain their existing categories. Classification does not itself shorten input or authorize replay. +Policy-selected turns also retain their [original candidate authorization](policy-fallback.md) through fallback and subagent recovery. + ## Chat-to-Responses message phase inference Chat Completions streams do not carry the Responses `message.phase` field. The bridge keeps an diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 46ea7fa69c2..3761e291e02 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -414,7 +414,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | Owner | Responsibility | | --- | --- | -| `request-prepare.ts` | Body parsing, combo handoff, final route, encrypted-task recovery and initial admission. | +| `request-prepare.ts` | Body parsing, combo handoff, final route, encrypted-task recovery and initial admission, retaining [original policy authorization](policy-fallback.md). | | `shadow-target-availability.ts` | Shadow-call target resolution for `request-prepare.ts`: an unavailable target fails once with `409 intercept_target_unavailable` instead of reaching the native source model or the default provider. | | `request-transport.ts` | Live credential selection, dispatch bindings, adapter replacement and same-target request identity. | | `request-sidecar-auth.ts` | Routed-compaction image projection, sidecar credential resolution and vision preprocessing. | diff --git a/tests/claude-integration/claude-native-affinity.test.ts b/tests/claude-integration/claude-native-affinity.test.ts index c52abc3743d..740197bfd6f 100644 --- a/tests/claude-integration/claude-native-affinity.test.ts +++ b/tests/claude-integration/claude-native-affinity.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; import { handleClaudeMessages } from "../../src/server/claude-messages"; import { handleResponses } from "../../src/server/responses/core"; -import { handleResponsesWithPolicyFallback, rankPolicyFallbackCandidates } from "../../src/server/responses/policy-fallback"; +import { handleResponsesWithPolicyFallback } from "../../src/server/responses/policy-fallback"; import { tryAdmitTurn } from "../../src/server/lifecycle"; import { providerConfigSeed } from "../../src/providers/derive"; import { getProviderRegistryEntry } from "../../src/providers/registry"; @@ -129,14 +129,10 @@ describe("Claude final canonical native affinity after a Go preliminary pick", ( test("native failure leaves policy-hop request headers free of synthesized identity", async () => { clearHealthHistoryCacheForTests(); const cfg = config(); - cfg.routingProfiles = { "native-hop": { candidates: [{ provider: "openai", model: "gpt-5.6-luna" }] } }; - const trace = { version: 1, decisionId: "native-hop", createdAt: Date.now(), requestedModel: "policy/native-hop", - routeKind: "policy", profile: { id: "native-hop", revision: "1" }, requirements: [], - candidates: [ - { provider: "openai", model: "gpt-5.6-luna", eligible: true, exclusions: [], score: { total: 2 } }, - { provider: "other", model: "m", eligible: true, exclusions: [], score: { total: 1 } }, - ], selected: { candidateIndex: 0, provider: "openai", model: "gpt-5.6-luna", reason: "fixture" }, - } as unknown as Parameters[0]; + // Both physical routes belong to the original evaluation; a diagnostic trace cannot add one. + cfg.routingProfiles = { "native-hop": { candidates: [ + { provider: "openai", model: "gpt-5.6-luna" }, { provider: "other", model: "m" }, + ] } }; const requests: Request[] = []; const wires: Headers[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -149,9 +145,7 @@ describe("Claude final canonical native affinity after a Go preliminary pick", ( body: JSON.stringify({ model: "policy/native-hop", input: "ping", stream: false }) }); const runCore: NonNullable[4]>["runCore"] = async (request, current, log, options) => { requests.push(request); - const response = await handleResponses(request, current, log, options); - if (requests.length === 1) log.routeDecision = trace; - return response; + return handleResponses(request, current, log, options); }; const response = await handleResponsesWithPolicyFallback(req, cfg, { model: "", provider: "" }, { claudeNativeSessionId: expectedSession }, { runCore }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4303c879bac..45e3d363d56 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1547,6 +1547,7 @@ "routing-compatibility.test.ts": "routing", "routing-identity-domains.test.ts": "routing", "routing-intelligence-ui.test.ts": "gui", + "routing-policy-dispatch-scope.test.ts": "routing", "routing-policy-fallback.test.ts": "routing", "routing-policy-pool-quota.test.ts": "routing", "routing-policy-surface-parity.test.ts": "routing", diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 50632431819..2dea4e7d4bb 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -30,6 +30,7 @@ export const RESPONSES_CORE_MODULES = [ "core-combo.ts", "core-combo-native.ts", "request-prepare.ts", + "policy-request-scope.ts", "skills-snapshot.ts", "shadow-target-availability.ts", "compaction-routing.ts", diff --git a/tests/providers/opencode-go-session-header.test.ts b/tests/providers/opencode-go-session-header.test.ts index bedf4358909..125e13920e3 100644 --- a/tests/providers/opencode-go-session-header.test.ts +++ b/tests/providers/opencode-go-session-header.test.ts @@ -608,7 +608,7 @@ describe("OpenCode Go affinity across the policy fallback retry (#4172)", () => return Response.json({ id: "resp_policy_go", object: "response", status: "completed", output: [] }); }) as unknown as NonNullable[4]>["runCore"]; - const config = { providers: { "opencode-go": opencodeGo() } } as unknown as OcxConfig; + const config = { providers: { "opencode-go": opencodeGo(), "opencode-go-2": opencodeGo() } } as unknown as OcxConfig; const response = await handleResponsesWithPolicyFallback( req, config, { model: "", provider: "" } as never, {}, { runCore }, ); diff --git a/tests/responses/responses-shadow-intercept.test.ts b/tests/responses/responses-shadow-intercept.test.ts index 55e866118aa..d5f3ecc2b0c 100644 --- a/tests/responses/responses-shadow-intercept.test.ts +++ b/tests/responses/responses-shadow-intercept.test.ts @@ -405,10 +405,11 @@ describe("a combo shadow-call target enters the failover loop (#4129)", () => { test("carries helper conversation isolation into concrete combo children", () => { const prepare = readFileSync(repoPath("src/server/responses/request-prepare.ts"), "utf8"); - const comboDispatch = prepare.slice( - prepare.indexOf("const comboId = !options.comboAttempt"), - prepare.indexOf("let unreadableEncryptedAgentTask"), - ); + const comboDispatchStart = prepare.indexOf("return requestDispatchers.handleComboResponses("); + const comboDispatchEnd = prepare.indexOf("let unreadableEncryptedAgentTask", comboDispatchStart); + expect(comboDispatchStart).toBeGreaterThanOrEqual(0); + expect(comboDispatchEnd).toBeGreaterThan(comboDispatchStart); + const comboDispatch = prepare.slice(comboDispatchStart, comboDispatchEnd); const parsedHandoff = prepare.slice( prepare.indexOf("if (cursorClientThreadId) parsed._cursorClientThreadId"), prepare.indexOf("} catch (err)", prepare.indexOf("if (cursorClientThreadId) parsed._cursorClientThreadId")), diff --git a/tests/routing/router-blocked-cross-provider.test.ts b/tests/routing/router-blocked-cross-provider.test.ts index 623d9abfbd6..a51eb3530d2 100644 --- a/tests/routing/router-blocked-cross-provider.test.ts +++ b/tests/routing/router-blocked-cross-provider.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { routeModel } from "../../src/router"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; import { codexRouteCredentialDomainHeaders } from "../../src/server/responses/core-auth"; import type { HandleResponsesOptions } from "../../src/server/responses/core-options"; import type { OcxConfig } from "../../src/types"; +// Policy routing opens the history index; release it before the isolated home is removed. +afterEach(() => closeRequestHistoryIndex()); + function config(redirects: Record): OcxConfig { return { port: 10100, diff --git a/tests/routing/routing-policy-dispatch-scope.test.ts b/tests/routing/routing-policy-dispatch-scope.test.ts new file mode 100644 index 00000000000..2c64d61570c --- /dev/null +++ b/tests/routing/routing-policy-dispatch-scope.test.ts @@ -0,0 +1,371 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { OcxConfig, OcxUsage } from "../../src/types"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { handleResponsesWithPolicyFallback, type PolicyFallbackDeps } from "../../src/server/responses/policy-fallback"; +import { prepareResponsesRequest } from "../../src/server/responses/request-prepare"; +import type { ResponsesAdmissionState } from "../../src/server/responses/core-options"; +import { addFinalRequestLog, beginRequestAttempt, noteAttemptSend, type RequestLogContext, type RequestLogEntry } from "../../src/server/request-log"; +import { releaseUpstreamHostAdmission } from "../../src/codex/upstream-host-health"; +import * as subagentFallback from "../../src/codex/subagent-model-fallback"; +import * as recovery from "../../src/server/responses/agent-task-recovery"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; +import { routeModel } from "../../src/router"; +import { PolicyCandidateUnavailableError, policyCandidateRefusalResponse } from "../../src/server/responses/policy-request-scope"; + +const originalFetch = globalThis.fetch; +let home: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let restoreSpies: Array<() => void> = []; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-policy-scope-")); + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + globalThis.fetch = (() => { throw new Error("unexpected network access in policy scope fixture"); }) as typeof fetch; + subagentFallback.resetSubagentModelFallbackStateForTests(); + closeRequestHistoryIndex(); +}); + +afterEach(() => { + for (const restore of restoreSpies.reverse()) restore(); + restoreSpies = []; + globalThis.fetch = originalFetch; + subagentFallback.resetSubagentModelFallbackStateForTests(); + closeRequestHistoryIndex(); + removeTreeWithRetry(home); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; +}); + +function configFor(ids = ["a", "b", "c", "d"]): OcxConfig { + return { + port: 0, defaultProvider: "a", multiAgentGuidanceEnabled: false, + providers: Object.fromEntries([...ids, "outside"].map(id => [id, { + adapter: "openai-chat", baseUrl: `https://${id}.example/v1`, authMode: "key", apiKey: "fixture-key", + models: ["model"], modelContextWindows: { model: 200_000 }, + }])), + routingProfiles: { daily: { candidates: ids.map(provider => ({ provider, model: "model" })) } }, + } as OcxConfig; +} + +/** Exercise the real parser, route selection, subagent recovery, normalization and auth preparation. + * Only physical dispatch is replaced, so no fixture can fall through to a real provider. */ +async function execute(config: OcxConfig, options: { + spawn?: boolean; failures?: number; selector?: string; + onDispatch?: (context: RequestLogContext) => void; +} = {}) { + const destinations: string[] = []; + const selectors: string[] = []; + const scopes: Array | undefined> = []; + const routeKinds: string[] = []; + const log: RequestLogContext = { attempts: [] }; + let comboDispatches = 0; + const runCore: NonNullable = async (req, currentConfig, ctx, coreOptions = {}) => { + selectors.push((await req.clone().json() as { model: string }).model); + const budget = createTranslatorBudget(); + const admission: ResponsesAdmissionState = { pendingHostAdmissionLease: null, authCtx: { kind: "main", accountId: null } }; + try { + const prepared = await prepareResponsesRequest({ req, config: currentConfig, logCtx: ctx, + options: { ...coreOptions, translatorBudget: budget } }, admission, { + handleResponses: async () => { throw new Error("unexpected recursive dispatch"); }, + handleComboResponses: async () => { comboDispatches += 1; throw new Error("policy candidate entered a combo"); }, + }); + if (prepared instanceof Response) return prepared; + destinations.push(`${prepared.route.providerName}/${prepared.parsed.modelId}`); + routeKinds.push(prepared.route.routeKind); + scopes.push(ctx.policyEligibility); + const attempt = beginRequestAttempt(destinations.length, prepared.route.providerName, prepared.parsed.modelId, "test"); + ctx.attempts!.push(attempt); + ctx.activeAttempt = attempt; + ctx.activeAttemptStartedAt = Date.now(); + options.onDispatch?.(ctx); + return destinations.length <= (options.failures ?? 1) + ? Response.json({ error: { type: "rate_limit_error", message: "fixture busy" } }, { status: 429 }) + : Response.json({ status: "completed" }); + } finally { + if (admission.pendingHostAdmissionLease) releaseUpstreamHostAdmission(admission.pendingHostAdmissionLease); + budget.dispose(); + } + }; + const response = await handleResponsesWithPolicyFallback(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", ...(options.spawn ? { "x-openai-subagent": "collab_spawn" } : {}) }, + body: JSON.stringify({ model: options.selector ?? "policy/daily", input: "hello", stream: false }), + }), config, log, {}, { runCore }); + return { response, destinations, selectors, log, scopes, comboDispatches, routeKinds }; +} + +describe("policy scope through real request preparation", () => { + test.each([false, true])("a concrete retry ignores a colliding combo alias (redirects=%s)", async redirects => { + const config = configFor(); + config.combos = { hijack: { alias: "b/model", strategy: "round-robin", targets: [ + { provider: "outside", model: "model" }, { provider: "d", model: "model" }, + ] } }; + if (redirects) config.blockedModelRedirects = { "unrelated/model": "a/model" }; + const result = await execute(config); + expect(result.response.status).toBe(200); + expect(result.destinations).toEqual(["a/model", "b/model"]); + expect(result.comboDispatches).toBe(0); + // Compaction recovery reads the physical route kind, not the log's preserved decision. + expect(result.routeKinds).toEqual(["policy", "policy"]); + expect(result.log.routeDecision?.routeKind).toBe("policy"); + expect(result.scopes[1]).toBe(result.scopes[0]); + }); + + test("redirected candidates do not send the same physical destination twice", async () => { + const config = configFor(); + config.blockedModelRedirects = { "b/model": "c/model" }; + const result = await execute(config, { failures: 2 }); + expect(result.response.status).toBe(200); + expect(result.destinations).toEqual(["a/model", "c/model", "d/model"]); + expect(result.selectors).toEqual(["policy/daily", "b/model", "d/model"]); + expect(result.log.attempts).toHaveLength(3); + }); + + test("an initially redirected selection and later redirects cannot revisit the initial destination", async () => { + const config = configFor(); + config.blockedModelRedirects = { "a/model": "b/model", "c/model": "b/model" }; + const result = await execute(config); + expect(result.destinations).toEqual(["b/model", "d/model"]); + expect(result.selectors).toEqual(["policy/daily", "d/model"]); + }); + + test.each(["selection", "recovery"])("%s subagent rerouting cannot replace the original policy", async stage => { + const config = configFor(["a", "b"]); + let selections = 0; + const fallback = spyOn(subagentFallback, "applySubagentModelFallback").mockImplementation(parsed => { + selections += 1; + if (selections !== (stage === "recovery" ? 2 : 1)) return null; + const from = parsed.modelId; + parsed.modelId = "outside/model"; + return { from, to: "outside/model" }; + }); + restoreSpies.push(() => fallback.mockRestore()); + if (stage === "recovery") { + config.agentTaskRecovery = { enabled: true }; + const recovered = spyOn(recovery, "restoreCachedEncryptedAgentTasks").mockReturnValue(1); + restoreSpies.push(() => recovered.mockRestore()); + } + const result = await execute(config, { spawn: true, failures: 0 }); + expect(result.response.status).toBe(200); + expect(result.destinations).toEqual(["b/model"]); + expect(result.log.routeDecision?.routeKind).toBe("policy"); + expect(result.log.routeDecision?.selected.provider).toBe("a"); + expect([...result.log.policyEligibility!]).toEqual(["a\u0000model", "b\u0000model"]); + }); + + test("an allowed subagent replacement retains the full evaluation through normalization", async () => { + const config = configFor(["a", "b", "c"]); + const fallback = spyOn(subagentFallback, "applySubagentModelFallback").mockImplementation(parsed => { + if (parsed.modelId !== "policy/daily") return null; + parsed.modelId = "b/model"; + return { from: "policy/daily", to: "b/model" }; + }); + restoreSpies.push(() => fallback.mockRestore()); + const result = await execute(config, { spawn: true }); + expect(result.destinations).toEqual(["b/model", "c/model"]); + expect(result.routeKinds).toEqual(["policy", "policy"]); + expect(result.log.routeDecision?.selected.provider).toBe("a"); + expect([...result.log.policyEligibility!]).toEqual(["a\u0000model", "b\u0000model", "c\u0000model"]); + }); + + test("normal direct requests keep their configured subagent fallback", async () => { + const config = configFor(["a", "b"]); + const fallback = spyOn(subagentFallback, "applySubagentModelFallback").mockImplementation(parsed => { + parsed.modelId = "outside/model"; + return { from: "a/model", to: "outside/model" }; + }); + restoreSpies.push(() => fallback.mockRestore()); + const result = await execute(config, { spawn: true, failures: 0, selector: "a/model" }); + expect(result.destinations).toEqual(["outside/model"]); + expect(result.log.policyEligibility).toBeUndefined(); + }); + + test("the configured subagent ladder cannot send outside the initial evaluation", async () => { + const config = configFor(["a", "b"]); + config.subagentModelFallback = ["outside/model", "b/model"]; + subagentFallback.noteSubagentModelFailure("policy/daily", "rate limit exceeded", config); + const result = await execute(config, { spawn: true, failures: 0 }); + expect(result.response.status).toBe(200); + expect(result.destinations).toEqual(["b/model"]); + expect(result.log.routeDecision?.selected.provider).toBe("a"); + expect(result.log.policyEligibility?.has("outside\u0000model")).toBe(false); + }); + + test("redirect membership includes eligible candidates beyond the bounded trace", async () => { + const ids = ["a", "b", "c", "d", "e", "f", "g", "h", "last"]; + const config = configFor(ids); + config.blockedModelRedirects = { "b/model": "last/model" }; + const result = await execute(config); + expect(result.destinations).toEqual(["a/model", "last/model"]); + expect(result.log.routeDecision?.candidates.some(candidate => candidate.provider === "last")).toBe(false); + expect(result.log.policyEligibility?.has("last\u0000model")).toBe(true); + }); + + test("an admitted virtual model keeps its wire mapping without expanding policy membership", async () => { + const config = configFor(["a"]); + config.providers["openai-apikey"] = { + adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key", apiKey: "fixture-key", + models: ["gpt-5.6-sol-pro", "gpt-5.6-sol"], + }; + config.routingProfiles!.daily.candidates.push({ provider: "openai-apikey", model: "gpt-5.6-sol-pro" }); + const result = await execute(config); + expect(result.response.status).toBe(200); + expect(result.destinations).toEqual(["a/model", "openai-apikey/gpt-5.6-sol"]); + expect(result.log.policyEligibility?.has("openai-apikey\u0000gpt-5.6-sol-pro")).toBe(true); + expect(result.log.policyEligibility?.has("openai-apikey\u0000gpt-5.6-sol")).toBe(false); + }); + + test("virtual and wire-model candidates do not repeat one physical destination", async () => { + const config = configFor(["a"]); + config.providers["openai-apikey"] = { + adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key", apiKey: "fixture-key", + models: ["gpt-5.6-sol-pro", "gpt-5.6-sol"], + }; + config.routingProfiles!.daily.candidates = [ + { provider: "openai-apikey", model: "gpt-5.6-sol-pro" }, + { provider: "openai-apikey", model: "gpt-5.6-sol" }, + { provider: "a", model: "model" }, + ]; + const result = await execute(config); + expect(result.destinations).toEqual(["openai-apikey/gpt-5.6-sol", "a/model"]); + expect(result.selectors).toEqual(["policy/daily", "a/model"]); + }); + + test("a duplicate discovered after subagent preparation preserves the last upstream failure", async () => { + const config = configFor(["a", "b"]); + const fallback = spyOn(subagentFallback, "applySubagentModelFallback").mockImplementation(parsed => { + if (parsed.modelId !== "b/model") return null; + parsed.modelId = "a/model"; + return { from: "b/model", to: "a/model" }; + }); + restoreSpies.push(() => fallback.mockRestore()); + const result = await execute(config, { spawn: true }); + expect(result.destinations).toEqual(["a/model"]); + expect(result.response.status).toBe(429); + expect((await result.response.json()).error.message).toBe("fixture busy"); + }); + + test("a locally refused wire duplicate preserves the actual failure's final log and settlement", async () => { + const config = configFor(["b"]); + config.providers["openai-apikey"] = { + adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key", apiKey: "fixture-key", + models: ["gpt-5.6-sol-pro", "gpt-5.6-sol"], + }; + config.routingProfiles!.daily.candidates = [ + { provider: "openai-apikey", model: "gpt-5.6-sol-pro" }, + { provider: "b", model: "model" }, + { provider: "openai-apikey", model: "gpt-5.6-sol" }, + ]; + const fallback = spyOn(subagentFallback, "applySubagentModelFallback").mockImplementation(parsed => { + if (parsed.modelId !== "b/model") return null; + parsed.modelId = "openai-apikey/gpt-5.6-sol"; + return { from: "b/model", to: parsed.modelId }; + }); + restoreSpies.push(() => fallback.mockRestore()); + const usage: OcxUsage = { inputTokens: 17, outputTokens: 3 }; + const settled: Array = []; + const tracker = { settle: (value: OcxUsage | undefined) => { settled.push(value); } }; + const result = await execute(config, { spawn: true, onDispatch: context => { + context.usage = usage; + context.usageFromBridge = true; + context.upstreamError = "fixture busy"; + context.terminalHttpStatus = 429; + context.terminalSource = "upstream"; + context.spendTracker = tracker; + noteAttemptSend(context.activeAttempt, 17); + } }); + expect(result.destinations).toEqual(["openai-apikey/gpt-5.6-sol"]); + expect(result.selectors).toEqual(["policy/daily", "b/model"]); + expect(result.response.status).toBe(429); + expect(result.log).toMatchObject({ + model: "gpt-5.6-sol-pro", provider: "openai-apikey", usage, + usageFromBridge: true, upstreamError: "fixture busy", terminalHttpStatus: 429, + }); + expect(result.log.activeAttempt).toBe(result.log.attempts?.[0]); + expect(result.log.spendTracker).toBe(tracker); + const rows: RequestLogEntry[] = []; + addFinalRequestLog("policy-skipped-duplicate", Date.now(), result.log, result.response.status, undefined, row => rows.push(row)); + expect(settled).toEqual([usage]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + provider: "openai-apikey", model: "gpt-5.6-sol-pro", usage, totalTokens: 20, + upstreamError: "fixture busy", status: 429, spend: { sends: 1, settled: 1, unresolved: 0 }, + }); + expect(rows[0]?.attempts).toHaveLength(1); + expect(rows[0]?.attempts?.[0]).toMatchObject({ provider: "openai-apikey", model: "gpt-5.6-sol", usage, status: 429, sendCount: 1 }); + }); + + test.each(["all-skipped", "skip-then-success", "failure-then-skip"])("local refusal log rollback keeps physical attempts (%s)", async scenario => { + const config = configFor(["a", "b", "c"]); + const initial = routeModel(config, "policy/daily"); + const log: RequestLogContext = { model: "", provider: "", attempts: [] }; + const settled: Array = []; + const tracker = { settle: (usage: OcxUsage | undefined) => { settled.push(usage); } }; + let lastResponse: Response | undefined; + let lastUsage: OcxUsage | undefined; + const sent: string[] = []; + const response = await handleResponsesWithPolicyFallback(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "policy/daily", input: "hello" }), + }), config, log, {}, { runCore: async (req, _config, context, options) => { + const body = await req.json() as { model: string }; + options.onRequestBodyParsed?.(body); + context.routeDecision = initial.routeDecision; + context.policyEligibility = initial.policyEligibility; + const provider = body.model === "policy/daily" ? "a" : body.model.split("/")[0]!; + const skip = provider !== "a" && (scenario === "all-skipped" + || provider === (scenario === "skip-then-success" ? "b" : "c")); + if (skip) { + // Preparation may replace top-level state and attach a fresh, unused tracker. + Object.assign(context, { provider, model: "skipped-model", usage: { inputTokens: 999 }, + requestedAlias: "skipped-alias", localTerminalReason: "skipped-candidate", errorCode: "skipped-code", + terminalSource: "synthetic", spendTracker: { settle: () => { throw new Error("settled an unsent candidate"); } }, + }); + return policyCandidateRefusalResponse(new PolicyCandidateUnavailableError())!; + } + expect(context.activeAttempt).toBeUndefined(); + expect(context.usage).toBeUndefined(); + expect(context.localTerminalReason).toBeUndefined(); + sent.push(provider); + context.provider = provider; + context.model = `served-${provider}`; + const attempt = beginRequestAttempt(sent.length, provider, context.model, "test"); + context.attempts!.push(attempt); + context.activeAttempt = attempt; + context.activeAttemptStartedAt = Date.now(); + noteAttemptSend(attempt, 10); + lastUsage = { inputTokens: 10 * sent.length, outputTokens: sent.length }; + context.usage = lastUsage; + context.spendTracker = tracker; + context.terminalSource = "upstream"; + const status = provider === "c" ? 200 : provider === "b" ? 503 : 429; + if (status !== 200) context.upstreamError = `busy-${provider}`; + lastResponse = Response.json(status === 200 ? { status: "completed" } : { error: { message: `busy-${provider}` } }, { status }); + return lastResponse; + } }); + const expected = scenario === "all-skipped" ? ["a"] : ["a", scenario === "skip-then-success" ? "c" : "b"]; + expect(sent).toEqual(expected); + expect(response).toBe(lastResponse!); + expect(log.activeAttempt).toBe(log.attempts?.at(-1)); + expect(log).toMatchObject({ provider: expected.at(-1), model: `served-${expected.at(-1)}`, usage: lastUsage }); + for (const key of ["requestedAlias", "localTerminalReason", "errorCode"] as const) expect(Object.hasOwn(log, key)).toBe(false); + const rows: RequestLogEntry[] = []; + addFinalRequestLog("policy-skipped-candidate", Date.now(), log, response.status, undefined, row => rows.push(row)); + expect(settled).toEqual([lastUsage]); + expect(rows[0]).toMatchObject({ provider: expected.at(-1), model: `served-${expected.at(-1)}`, usage: lastUsage, + status: response.status, spend: { sends: expected.length, settled: expected.length, unresolved: 0 }, + }); + expect(rows[0]?.attempts?.map(attempt => attempt.provider)).toEqual(expected); + expect(rows[0]?.attempts?.every(attempt => attempt.sendCount === 1 && attempt.usage !== undefined)).toBe(true); + expect(rows[0]?.upstreamError).toBe(scenario === "skip-then-success" ? undefined : `busy-${expected.at(-1)}`); + }); +}); diff --git a/tests/routing/routing-policy-fallback.test.ts b/tests/routing/routing-policy-fallback.test.ts index c09d720d0d7..2e12daa62b6 100644 --- a/tests/routing/routing-policy-fallback.test.ts +++ b/tests/routing/routing-policy-fallback.test.ts @@ -15,6 +15,15 @@ import { type PolicyFallbackDeps, } from "../../src/server/responses/policy-fallback"; +function fixtureConfig(): OcxConfig { + return { + port: 0, defaultProvider: "provider-a", + providers: Object.fromEntries(["a", "b", "c", "d"].map(id => [`provider-${id}`, { + adapter: "openai-chat", baseUrl: `https://${id}.example/v1`, authMode: "key", + apiKey: "fixture-key", models: [`model-${id}`], + }])), + } as OcxConfig; +} function policyTrace(): RouteDecisionTraceV1 { return { version: 1, @@ -58,7 +67,7 @@ describe("policy candidate fallback", () => { } }, { status: 400 }); markResponseNonReplayable(failure); let coreCalls = 0; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, {} as RequestLogContext, {}, { + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), {} as RequestLogContext, {}, { runCore: async (req, _config, context, options) => { coreCalls += 1; options.onRequestBodyParsed?.(await req.json()); @@ -76,7 +85,7 @@ describe("policy candidate fallback", () => { test.each([false, true])("reset refusal stays terminal across policy and account recovery (replacement=%s)", async replacement => { let sends = 0; let coreCalls = 0; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, {} as RequestLogContext, {}, { + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), {} as RequestLogContext, {}, { runCore: async (req, _config, context, options) => { coreCalls += 1; const body = await req.json(); @@ -109,7 +118,7 @@ describe("policy candidate fallback", () => { const snapshots: unknown[] = []; const primaryAuth: Array = []; const response = await handleResponsesWithPolicyFallback(new Request(initial, { headers }), { - port: 0, defaultProvider: "provider-a", providers: {}, + ...fixtureConfig(), }, log, {}, { runCore: async (req, _config, context, options) => { options.onRequestBodyParsed?.(await req.json()); @@ -138,6 +147,111 @@ describe("policy candidate fallback", () => { ]); }); + test("does not let a redirected fallback escape the original policy", async () => { + const trace = policyTrace(); + const config = { + port: 10100, + defaultProvider: "provider-a", + blockedModelRedirects: { "provider-b/model-b": "remote/remote-model" }, + providers: { + "provider-a": { adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-a"] }, + "provider-b": { adapter: "openai-chat", baseUrl: "http://localhost:11435/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-b"] }, + "provider-c": { adapter: "openai-chat", baseUrl: "http://localhost:11436/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-c"] }, + remote: { adapter: "openai-chat", baseUrl: "https://remote.example/v1", apiKey: "remote-key", models: ["remote-model"] }, + }, + } as OcxConfig; + const seenModels: string[] = []; + const response = await handleResponsesWithPolicyFallback(request(), config, {} as RequestLogContext, {}, { + runCore: async (req, _config, context, options) => { + const body = await req.json() as { model: string }; + options.onRequestBodyParsed?.(body); + seenModels.push(body.model); + context.routeDecision = trace; + return seenModels.length === 1 + ? Response.json({ error: { type: "rate_limit_error" } }, { status: 429 }) + : Response.json({ status: "completed" }); + }, + }); + + expect(response.status).toBe(200); + expect(seenModels).toEqual(["policy/daily", "provider-c/model-c"]); + }); + + test("a fallback candidate with an unresolvable redirect is skipped instead of ending the fallback", async () => { + const trace = policyTrace(); + const config = { + port: 10100, + defaultProvider: "provider-a", + blockedModelRedirects: { + "provider-b/model-b": "remote/remote-model", + "remote/remote-model": "provider-b/model-b", + }, + providers: { + "provider-a": { adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-a"] }, + "provider-b": { adapter: "openai-chat", baseUrl: "http://localhost:11435/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-b"] }, + "provider-c": { adapter: "openai-chat", baseUrl: "http://localhost:11436/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-c"] }, + remote: { adapter: "openai-chat", baseUrl: "https://remote.example/v1", apiKey: "remote-key", models: ["remote-model"] }, + }, + } as OcxConfig; + const seenModels: string[] = []; + const response = await handleResponsesWithPolicyFallback(request(), config, {} as RequestLogContext, {}, { + runCore: async (req, _config, context, options) => { + const body = await req.json() as { model: string }; + options.onRequestBodyParsed?.(body); + seenModels.push(body.model); + context.routeDecision = trace; + return seenModels.length === 1 + ? Response.json({ error: { type: "rate_limit_error" } }, { status: 429 }) + : Response.json({ status: "completed" }); + }, + }); + + // The cyclic redirect on provider-b cannot resolve, so it is skipped and the + // healthy eligible candidate still serves the request. + expect(response.status).toBe(200); + expect(seenModels).toEqual(["policy/daily", "provider-c/model-c"]); + }); + + test("a redirect into an eligible candidate truncated out of the trace still hops", async () => { + const trace = policyTrace(); + const config = { + port: 10100, + defaultProvider: "provider-a", + blockedModelRedirects: { "provider-b/model-b": "remote/remote-model" }, + providers: { + "provider-a": { adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-a"] }, + "provider-b": { adapter: "openai-chat", baseUrl: "http://localhost:11435/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-b"] }, + "provider-c": { adapter: "openai-chat", baseUrl: "http://localhost:11436/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-c"] }, + remote: { adapter: "openai-chat", baseUrl: "https://remote.example/v1", apiKey: "remote-key", models: ["remote-model"] }, + }, + } as OcxConfig; + // Full evaluation membership, as stored on RequestLogContext for traces whose + // bounded candidate list dropped eligible rows. + const log: RequestLogContext = { + policyEligibility: new Set([ + "provider-a\u0000model-a", + "provider-b\u0000model-b", + "provider-c\u0000model-c", + "remote\u0000remote-model", + ]), + }; + const seenModels: string[] = []; + const response = await handleResponsesWithPolicyFallback(request(), config, log, {}, { + runCore: async (req, _config, context, options) => { + const body = await req.json() as { model: string }; + options.onRequestBodyParsed?.(body); + seenModels.push(body.model); + context.routeDecision = trace; + return seenModels.length === 1 + ? Response.json({ error: { type: "rate_limit_error" } }, { status: 429 }) + : Response.json({ status: "completed" }); + }, + }); + + expect(response.status).toBe(200); + expect(seenModels).toEqual(["policy/daily", "provider-b/model-b"]); + }); + test("leaves request body parsing to the core handler", async () => { const req = request(); let cloneCalls = 0; @@ -150,7 +264,7 @@ describe("policy candidate fallback", () => { const response = await handleResponsesWithPolicyFallback( req, - {} as OcxConfig, + fixtureConfig(), {} as RequestLogContext, {}, { runCore: async () => new Response(null, { status: 204 }) }, @@ -165,7 +279,7 @@ describe("policy candidate fallback", () => { const logCtx = { routeDecision: trace } as RequestLogContext; const seenInputs: unknown[] = []; let calls = 0; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, {}, { + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), logCtx, {}, { runCore: async (req, _config, context, options) => { calls += 1; const body = await req.json() as { input: unknown; model: string }; @@ -189,7 +303,7 @@ describe("policy candidate fallback", () => { model: "provider-a/model-a", input: { get content(): string { throw new Error("unexpected deep clone"); } }, }; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, {} as RequestLogContext, {}, { + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), {} as RequestLogContext, {}, { runCore: async (_req, _config, _context, options) => { options.onRequestBodyParsed?.(body); return new Response(null, { status: 204 }); @@ -249,7 +363,7 @@ describe("policy candidate fallback", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "policy/daily", input: [{ role: "user", content: "hello" }], stream: false }), }); - const response = await handleResponsesWithPolicyFallback(req, {} as OcxConfig, logCtx, {}, { + const response = await handleResponsesWithPolicyFallback(req, fixtureConfig(), logCtx, {}, { runCore: async (req, _config, context, options) => { calls += 1; const body = await req.json() as { input: { role: string; content: string }[]; model: string }; @@ -299,7 +413,7 @@ describe("policy candidate fallback", () => { return Response.json({ id: "resp", object: "response", status: "completed", output: [] }); }; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, {}, { runCore }); + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), logCtx, {}, { runCore }); expect(response.status).toBe(200); expect(seenModels).toEqual(["policy/daily", "provider-b/model-b"]); @@ -324,7 +438,7 @@ describe("policy candidate fallback", () => { ); }; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, {}, { runCore }); + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), logCtx, {}, { runCore }); expect(response.status).toBe(400); expect(seenModels).toEqual(["policy/daily"]); @@ -351,7 +465,7 @@ describe("policy candidate fallback", () => { return Response.json({ id: "resp", object: "response", status: "completed", output: [] }); }; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, {}, { runCore }); + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), logCtx, {}, { runCore }); expect(response.status).toBe(200); expect(seenModels).toEqual(["policy/daily", "provider-b/model-b"]); @@ -371,7 +485,7 @@ describe("policy candidate fallback", () => { initialHeaders.set("chatgpt-account-id", "caller-account"); const credentialedRequest = new Request(initialRequest, { headers: initialHeaders }); - const response = await handleResponsesWithPolicyFallback(credentialedRequest, {} as OcxConfig, logCtx, { + const response = await handleResponsesWithPolicyFallback(credentialedRequest, fixtureConfig(), logCtx, { onRequestBodyRead: () => { bodyAcceptedCount += 1; }, @@ -421,7 +535,7 @@ describe("policy candidate fallback", () => { const seenModels: string[] = []; let replaySignals = 0; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, { + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), logCtx, { onStoredPool401ReplayDispatched: () => { replaySignals += 1; }, }, { runCore: async (req, _config, childLog, options) => { @@ -447,7 +561,7 @@ describe("policy candidate fallback", () => { const trace = policyTrace(); const logCtx = { requestedModel: "policy/daily", routeDecision: trace, attempts: [] } as unknown as RequestLogContext; let calls = 0; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, {}, { + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), logCtx, {}, { runCore: async (req, _config, _ctx, options) => { calls += 1; options.onRequestBodyParsed?.(await req.json()); @@ -463,7 +577,7 @@ describe("policy candidate fallback", () => { const trace = policyTrace(); const logCtx = { requestedModel: "policy/daily", routeDecision: trace, attempts: [] } as unknown as RequestLogContext; let calls = 0; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, {}, { + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), logCtx, {}, { runCore: async (req, _config, childLog, options) => { calls += 1; options.onRequestBodyParsed?.(await req.json()); @@ -484,7 +598,7 @@ describe("policy candidate fallback", () => { const controller = new AbortController(); const logCtx = { requestedModel: "policy/daily", routeDecision: trace, attempts: [] } as unknown as RequestLogContext; let calls = 0; - const response = await handleResponsesWithPolicyFallback(request(controller.signal), {} as OcxConfig, logCtx, {}, { + const response = await handleResponsesWithPolicyFallback(request(controller.signal), fixtureConfig(), logCtx, {}, { runCore: async (req, _config, childLog, options) => { calls += 1; options.onRequestBodyParsed?.(await req.json()); @@ -502,7 +616,7 @@ describe("policy candidate fallback", () => { const logCtx = { requestedModel: "policy/daily", routeDecision: trace, attempts: [] } as unknown as RequestLogContext; let calls = 0; const body = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\ndata: {\"type\":\"response.failed\"}\n\n"; - const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, {}, { + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), logCtx, {}, { runCore: async (req, _config, childLog, options) => { calls += 1; options.onRequestBodyParsed?.(await req.json());