Skip to content
Closed
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
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/remote-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ listener, not the management port. Local clients use the configured loopback com
Advisor model used by managed native Anthropic Messages. Bare model IDs and the hub's configured
`provider/model` names are accepted. A disallowed or unnamed Advisor destination returns HTTP 403
before provider dispatch; provider-only and unrestricted keys retain their existing scope.
- GitHub Copilot Auto rechecks the concrete model selected during negotiation and
renewal. A model outside the client's allowed scope ends the request with
`model_not_allowed_for_key` (HTTP 403 before streaming, or a terminal error in
an already-started stream). Credential rotation or a fallback target cannot
override that refusal.
- A raw management admin token can perform ordinary administration, but it cannot mint a browser
session or authorize consent-bearing actions such as starring the repository. Those actions
require a server-issued `gui-session`, matching browser origin, and CSRF token.
Expand Down
8 changes: 7 additions & 1 deletion src/lib/translator-budget.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isNonReplayableResponse, markResponseNonReplayable } from "./upstream-retry";

export const TRANSLATOR_MAX_CALL_ARGUMENT_BYTES = 2 * 1024 * 1024;
export const TRANSLATOR_MAX_TURN_BYTES = 32 * 1024 * 1024;
export const TRANSLATOR_MAX_SSE_EVENT_BYTES = 32 * 1024 * 1024;
Expand Down Expand Up @@ -410,5 +412,9 @@ export function finalizeTranslatorBudgetResponse(response: Response, budget: Tra
});
if (signal?.aborted) finalize();
else signal?.addEventListener("abort", finalize, { once: true });
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
const finalizedResponse = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
// The rebuild above drops identity markers. A refusal that must not be replayed
// stays non-replayable through accounting finalization.
if (isNonReplayableResponse(response)) markResponseNonReplayable(finalizedResponse);
return finalizedResponse;
}
5 changes: 4 additions & 1 deletion src/server/admission-model-scope.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DataPlaneAdmission } from "./auth-cors";
import type { OcxApiKeyEntry, OcxConfig } from "../types";
import { markResponseNonReplayable } from "../lib/upstream-retry";

/**
* Per-admission-key model and provider scope.
Expand Down Expand Up @@ -155,10 +156,12 @@ export function admissionModelDeniedBody(error: AdmissionModelDeniedError): {
* indistinguishable from a typo in the model name.
*/
export function admissionModelDeniedResponse(error: AdmissionModelDeniedError): Response {
return new Response(JSON.stringify(admissionModelDeniedBody(error)), {
const response = new Response(JSON.stringify(admissionModelDeniedBody(error)), {
status: 403,
headers: { "Content-Type": "application/json" },
});
markResponseNonReplayable(response);
return response;
}

/**
Expand Down
44 changes: 27 additions & 17 deletions src/server/responses/adapter-continuation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
isNonReplayableResponse,
prepareSameTarget429Wait,
} from "../../lib/upstream-retry";
import { unwrapUpstreamRetryEvidenceError } from "../../codex/auth-context";
import { AdmissionModelDeniedError, MODEL_NOT_ALLOWED_FOR_KEY } from "../admission-model-scope";
import { redactSecretString } from "../../lib/redact";
import { resolveWireProtocolOverride } from "../adapter-resolve";
import { bindRouteReasoningReplayScope } from "./core-replay";
Expand Down Expand Up @@ -146,6 +148,22 @@ export function createAdapterContinuations(
sendBudgetExhausted,
} = sendBudgetState;

const continuationFailureEvent = (error: unknown): Extract<AdapterEvent, { type: "error" }> => {
if (options.abortSignal?.aborted || upstream.signal.aborted) {
return { type: "error", message: "client closed request during terminal continuation", status: 499 };
}
const refusal = unwrapUpstreamRetryEvidenceError(error);
if (refusal instanceof AdmissionModelDeniedError) {
return {
type: "error", message: refusal.message, status: 403,
errorType: MODEL_NOT_ALLOWED_FOR_KEY, code: MODEL_NOT_ALLOWED_FOR_KEY, retryable: false,
};
}
return {
type: "error",
message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}`,
};
};

// One bounded internal continuation re-ask for clean end_turn turns that announced an edit
// without emitting a tool call. Anthropic gets this by default; openai-chat providers opt in
Expand Down Expand Up @@ -304,12 +322,7 @@ export function createAdapterContinuations(
kiroRefusalPendingReplay = undefined;
kiroReplaySetupFailed = true;
} else {
if (options.abortSignal?.aborted || upstream.signal.aborted) {
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
} else {
yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` };
}
return;
yield continuationFailureEvent(error); return;
}
}

Expand Down Expand Up @@ -360,12 +373,7 @@ export function createAdapterContinuations(
try {
response = await fetchContinuation("rate-limit-429");
} catch (error) {
if (options.abortSignal?.aborted || upstream.signal.aborted) {
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
} else {
yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` };
}
return;
yield continuationFailureEvent(error); return;
}
}

Expand All @@ -381,9 +389,7 @@ export function createAdapterContinuations(
route.provider = rotated;
try { await transportState.resolveCopilotSelection(nextParsed); }
catch (error) {
yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}`,
...(options.abortSignal?.aborted || upstream.signal.aborted ? { status: 499 } : {}) };
return;
yield continuationFailureEvent(error); return;
}
invalidateSameTargetRequest();
transportState.activeAdapter = resolveSelectionAdapter(
Expand Down Expand Up @@ -630,13 +636,17 @@ export function createAdapterContinuations(
nextContinuationRecoveryKind = "oauth-account-429";
continue;
}
} catch {
} catch (error) {
// Everything in this try runs before the replay: the send happens on the next
// iteration, after `continue`. A throw here therefore leaves a reservation that
// never dispatched, and holding it would refuse a later recovery in this same
// request for a send that never left the process.
hop.permit?.release();
// fall through to emit continuation error below
if (unwrapUpstreamRetryEvidenceError(error) instanceof AdmissionModelDeniedError) {
yield continuationFailureEvent(error);
return;
}
// Other negotiation failures preserve the original provider refusal.
}
}
}
Expand Down
24 changes: 23 additions & 1 deletion src/server/responses/adapter-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
import { chargeWorkflowSends } from "../../lib/workflow-budget";
import { isAntigravityValidationRefusal } from "./antigravity-validation-refusal";
import { unboundPoolSpendRefusalResponse } from "../workflow-refusal";
import { AdmissionModelDeniedError, admissionModelDeniedResponse } from "../admission-model-scope";

/** One responsibility of the Responses request pipeline; state owners are explicit. */
export async function prepareAdapterExchange(
Expand Down Expand Up @@ -541,6 +542,10 @@ export async function prepareAdapterExchange(
return storageRefusal;
}
const refusal = err instanceof UpstreamRetryEvidenceError ? err.cause : err;
if (refusal instanceof AdmissionModelDeniedError) {
releaseCodexAuthContextProbeLease(admissionState.authCtx);
return admissionModelDeniedResponse(refusal);
}
const codexRefusal = mapCodexAuthContextErrorToResponse(unwrapUpstreamRetryEvidenceError(err), {
now: Date.now(), accountSelector: route.codexAccountNamespace,
});
Expand Down Expand Up @@ -810,6 +815,13 @@ export async function prepareAdapterExchange(
releaseCodexAuthContextProbeLease(admissionState.authCtx);
return { failed: storageRefusal };
}
const scopeRefusal = unwrapUpstreamRetryEvidenceError(err);
if (!options.abortSignal?.aborted && scopeRefusal instanceof AdmissionModelDeniedError) {
cleanupUpstreamAbort();
upstream.abort();
releaseCodexAuthContextProbeLease(admissionState.authCtx);
return { failed: admissionModelDeniedResponse(scopeRefusal) };
}
const codexRefusal = !options.abortSignal?.aborted && mapCodexAuthContextErrorToResponse(unwrapUpstreamRetryEvidenceError(err), {
now: Date.now(), accountSelector: route.codexAccountNamespace,
});
Expand Down Expand Up @@ -1539,7 +1551,17 @@ export async function prepareAdapterExchange(
// The hop's permit is already settled by the dispatch boundary above; continuing
// only skips the remaining arms, it does not abandon a reservation.
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
} catch {
} catch (err) {
// A local admission denial is terminal for the whole request: rotating to another
// account cannot re-scope the key, so the refusal answers in place of the 429.
const scopeRefusal = unwrapUpstreamRetryEvidenceError(err);
if (!options.abortSignal?.aborted && scopeRefusal instanceof AdmissionModelDeniedError) {
hop.permit?.release();
cleanupUpstreamAbort();
upstream.abort();
releaseCodexAuthContextProbeLease(admissionState.authCtx);
return admissionModelDeniedResponse(scopeRefusal);
}
// A throw before the send — snapshot fetch, credential application, adapter
// resolution — must hand the reservation back. Without this the ladder charges the
// request for a send it never made, and a later recovery in the same request is
Expand Down
2 changes: 2 additions & 0 deletions src/server/responses/core-lifetime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TranslatorBudget } from "../../lib/translator-budget";
import { finalizeTranslatorBudgetResponse } from "../../lib/translator-budget";
import { isNonReplayableResponse, markResponseNonReplayable } from "../../lib/upstream-retry";
import {
isNativePassthroughSseResponse,
markNativePassthroughSseResponse,
Expand Down Expand Up @@ -67,6 +68,7 @@ export function finalizeAccountLease(response: Response, release: () => void): R
async cancel(reason) { try { await reader.cancel(reason); } finally { finish(); } },
});
const wrapped = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
if (isNonReplayableResponse(response)) markResponseNonReplayable(wrapped);
if (isNativePassthroughSseResponse(response)) markNativePassthroughSseResponse(wrapped);
if (isEagerRelaySseResponse(response)) markEagerRelaySseResponse(wrapped);
if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(wrapped);
Expand Down
13 changes: 13 additions & 0 deletions src/server/responses/request-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ import { stampOAuthAccountLabel, usesApiKeyAccount } from "../../providers/label
import { resolveProviderTransport } from "../../providers/xai-transport";
import { captureRouteStaticPolicy } from "../../router";
import { CopilotAutoHttpError, resolveCopilotAuto } from "../../providers/github-copilot-auto";
import {
AdmissionModelDeniedError,
admissionModelDeniedResponse,
assertRouteAllowedByScope,
resolveAdmissionModelScope,
} from "../admission-model-scope";
import { renameRoutedIdentityInContext } from "../../adapters/identity";
import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
import {
Expand Down Expand Up @@ -297,6 +303,7 @@ export async function prepareResponsesTransport(
const copilotRequestedModel = route.modelId;
/** Project only bounded Copilot refusal metadata; upstream bodies and session tokens never reach the client. */
const copilotRefusalResponse = (error: unknown): Response | undefined => {
if (error instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(error);
if (!(error instanceof CopilotAutoHttpError) || ![401, 403, 429].includes(error.status)) return;
const response = formatErrorResponse(error.status, error.status === 429 ? "rate_limit_error" : "authentication_error",
error.message);
Expand Down Expand Up @@ -357,6 +364,11 @@ export async function prepareResponsesTransport(
replayOAuthCredentialSnapshot = { accountId: refreshed.accountId, generation: refreshed.generation };
selected = await negotiate();
}
assertRouteAllowedByScope(
resolveAdmissionModelScope(config, options.admission),
copilotRequestedModel,
{ providerName: route.providerName, modelId: selected.modelId },
);
copilotSessionExpiresAt = selected.expiresAt ?? Infinity;
route.provider = selected.provider;
route.modelId = selected.modelId;
Expand Down Expand Up @@ -725,6 +737,7 @@ export async function prepareResponsesTransport(
let nextAdapter: ProviderAdapter;
try { nextAdapter = await refreshDispatchAdapter(requestParsed); }
catch (error) {
if (error instanceof AdmissionModelDeniedError) throw error;
const refusal = copilotRefusalResponse(error);
if (refusal) return refusal;
throw error;
Expand Down
2 changes: 1 addition & 1 deletion structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ When a successful streamed native response has a missing or unrecognized non-JSO
HTTP ingress applies the [caller header contract](../data-planes/inbound-compat.md#http-caller-conversation-identity) before admission and dispatch. Optional Codex memory selection enters `src/server/responses/request-prepare.ts` for HTTP and WebSocket frames, as specified in [memory phase routing](responses-failover.md#memory-phase-routing); an unset phase keeps its existing route and starts no background work. Responses request preparation stabilizes incoming `<skills_instructions>` under `skills.catalog_refresh`: `per_session` (default) reuses the first received catalog for a conversation; `per_turn` leaves the supplied catalog unchanged. Other instruction sections and user/tool content remain untouched. Requests without a reliable conversation identity bypass snapshots; shared prompt-cache cohorts are not conversation identities. Only a body with exactly one catalog block across its instructions and developer/system content takes part; two or more pass through unchanged. A known snapshot is substituted before parsing, but a new catalog is stored only when preparation reaches its success return, so a request rejected by parsing or admission pins nothing. Without a named principal, snapshots are shared by conversation id only on a server that requires no data-plane auth. Snapshots are process-local, expire after four idle hours, and use bounded LRU retention; oversized blocks bypass caching. The dashboard's `src/codex/prompt-layers.ts` and `src/codex/prompt-text-probe.ts` continue observing current files for previews and do not own session snapshots.
`/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a
provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to
Responses-compatible streaming output. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding).
Responses-compatible streaming output. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). GitHub Copilot Auto admission rechecks the negotiated provider/model against the original inbound selector's key scope before route mutation or dispatch, on initial and renewal negotiation; denial returns the standard `model_not_allowed_for_key` 403 without disclosing the destination. Local model-scope refusals are non-replayable. A denial during Copilot dispatch renewal propagates as an admission exception to the request boundary, never as a provider fetch response; account recovery and combo/policy fallback cannot continue after that denial. A committed continuation, including OAuth account rotation after a 429, emits the same refusal type in-stream without dispatching another inference; ordinary negotiation failures still preserve the original provider refusal.

The `openai-responses` adapter preserves caller `User-Agent` unless provider headers own it, in either auth mode.
`forwardClientHeaders` additionally opts into `originator`, `x-client-request-id`, `x-codex-app-version`, or `user-agent`.
Expand Down
Loading
Loading