Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1535,6 +1535,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",
Expand Down
9 changes: 9 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/**
* Set when a blocked-model redirect moved the request to a different
* provider. Caller credentials addressed to the source route must not follow
Expand Down Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/server/request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/** Privacy-bounded JEV selection metadata; downstream usage is recorded on attempts[]. */
jevDecision?: PersistedJevDecisionV1;
/** Opt-in shadow evidence, normalized again at the logging boundary. */
Expand Down
1 change: 1 addition & 0 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,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;
Expand Down
1 change: 1 addition & 0 deletions src/server/responses/core-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,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") {
Expand Down
5 changes: 5 additions & 0 deletions src/server/responses/core-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
58 changes: 55 additions & 3 deletions src/server/responses/policy-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoreHandler>[3];
Expand All @@ -23,6 +25,25 @@ function candidateKey(candidate: Pick<RouteCandidateTrace, "provider" | "model">
return `${candidate.provider}\u0000${candidate.model}`;
}

function staysWithinPolicyAfterRedirect(
config: OcxConfig,
policyEligibility: ReadonlySet<string>,
candidate: RouteCandidateTrace,
triedDestinations: ReadonlySet<string>,
): 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
Expand Down Expand Up @@ -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 : "";
Expand Down Expand Up @@ -131,11 +153,13 @@ export async function handleResponsesWithPolicyFallback(
deps: PolicyFallbackDeps = {},
): Promise<Response> {
const runCore = deps.runCore ?? handleResponsesCore;
const policyScope: PolicyRequestScope = options.policyRequestScope ?? { triedDestinations: new Set() };
let requestBodyReadNotified = false;
let storedPool401ReplayDispatched = false;
let rawBody: Record<string, unknown> | null = null;
const coreOptions: CoreOptions = {
...options,
policyRequestScope: policyScope,
openAiSidecarAuth: options.openAiSidecarAuth === undefined
? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth,
nativeCallerAuth: options.nativeCallerAuth === undefined
Expand Down Expand Up @@ -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<string>([
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<string> = 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;
Expand All @@ -202,6 +253,7 @@ export async function handleResponsesWithPolicyFallback(
} finally {
logCtx.requestedModel = initialRequestedModel;
logCtx.routeDecision = initialTrace;
logCtx.policyEligibility = policyEligibility;
}
}

Expand Down
80 changes: 80 additions & 0 deletions src/server/responses/policy-request-scope.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
readonly triedDestinations: Set<string>;
preparedDestination?: string;
}

export function policyDestinationKey(provider: string, model: string): string {
return `${provider}\u0000${model}`;
}

export function policyWireDestination(route: Pick<RouteResult, "providerName" | "modelId">): 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<Response>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the refusal marker across response wrapping

On the normal /v1/responses path, handleResponsesCore owns a translator budget, and finalizeOwnedTranslatorBudget replaces every body-bearing response with a new Response. Because this marker is keyed only by the original object, isPolicyCandidateRefusal returns false after the core call. If policy scope locally rejects a subagent/recovery destination or a duplicate candidate after an upstream failure, fallback therefore returns the synthetic 409 or overwrites the last real failure instead of skipping to another eligible candidate. Propagate the marker through response wrappers or use internal metadata that survives wrapping, and cover the default runCore path rather than only the test stub.

Useful? React with 👍 / 👎.


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<RouteResult, "providerName" | "modelId">,
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;
}
Loading
Loading