@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-E64Rzjap.js +50 -0
- package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
- package/gui/dist/assets/index-DpdfZWMK.js +86 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +10 -0
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-listener.ts +2 -5
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/schema.ts +3 -0
- package/src/router.ts +17 -2
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { DataPlaneAdmission } from "./auth-cors";
|
|
2
|
+
import type { OcxApiKeyEntry, OcxConfig } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-admission-key model and provider scope.
|
|
6
|
+
*
|
|
7
|
+
* A hub that serves several clients with their own `ocx_data_…` keys needs a
|
|
8
|
+
* mail or cron key to be unable to spend a coding key's Grok or Claude quota.
|
|
9
|
+
* Hub-level model selection cannot express that: it hides a model from
|
|
10
|
+
* everyone or from no one (#5049).
|
|
11
|
+
*
|
|
12
|
+
* Two rules decide what this is and is not:
|
|
13
|
+
*
|
|
14
|
+
* A scope names DESTINATIONS, not selectors. It is evaluated against the
|
|
15
|
+
* resolved route — the provider and model a turn will actually bill — never
|
|
16
|
+
* against the string the client sent. A client-supplied label is not a
|
|
17
|
+
* permission subject: alias resolution, a combo pick, a policy fallback and a
|
|
18
|
+
* compaction override all rewrite that string, so a scope checked before them
|
|
19
|
+
* would authorize one destination and reach another.
|
|
20
|
+
*
|
|
21
|
+
* A scope is never management authority. It narrows which models an inference
|
|
22
|
+
* key may call and grants nothing else; reading or editing a scope stays on
|
|
23
|
+
* the management credential.
|
|
24
|
+
*/
|
|
25
|
+
export interface AdmissionModelScope {
|
|
26
|
+
/** Resolved provider names this key may reach. Empty means every provider. */
|
|
27
|
+
readonly providers: readonly string[];
|
|
28
|
+
/** Resolved destinations this key may reach. Empty means every model. */
|
|
29
|
+
readonly models: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The resolved destination a scope decision is made about. */
|
|
33
|
+
export interface ScopedRoute {
|
|
34
|
+
readonly providerName: string;
|
|
35
|
+
readonly modelId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const normalize = (value: string): string => value.trim().toLowerCase();
|
|
39
|
+
|
|
40
|
+
/** Does this scope admit this provider on its own terms? */
|
|
41
|
+
function providerAllowedByScope(scope: AdmissionModelScope, providerName: string): boolean {
|
|
42
|
+
return scope.providers.length === 0 || scope.providers.includes(normalize(providerName));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizedList(values: readonly string[] | undefined): readonly string[] {
|
|
46
|
+
if (!Array.isArray(values)) return [];
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
for (const value of values) {
|
|
49
|
+
if (typeof value !== "string") continue;
|
|
50
|
+
const normalized = normalize(value);
|
|
51
|
+
if (normalized) seen.add(normalized);
|
|
52
|
+
}
|
|
53
|
+
return [...seen];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read the scope a stored key declares. An entry with neither list is unrestricted. */
|
|
57
|
+
export function admissionModelScopeOf(entry: Pick<OcxApiKeyEntry, "allowedProviders" | "allowedModels">): AdmissionModelScope | undefined {
|
|
58
|
+
const providers = normalizedList(entry.allowedProviders);
|
|
59
|
+
const models = normalizedList(entry.allowedModels);
|
|
60
|
+
return providers.length === 0 && models.length === 0 ? undefined : { providers, models };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The scope that applies to one request, or undefined when nothing is scoped.
|
|
65
|
+
*
|
|
66
|
+
* Only a configured key carries a scope. The environment token and loopback
|
|
67
|
+
* admission have no stored record to attach one to, so they stay unrestricted;
|
|
68
|
+
* an operator who wants them narrowed issues a configured key instead.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveAdmissionModelScope(
|
|
71
|
+
config: Pick<OcxConfig, "apiKeys">,
|
|
72
|
+
admission: DataPlaneAdmission | undefined,
|
|
73
|
+
): AdmissionModelScope | undefined {
|
|
74
|
+
if (!admission || admission.kind !== "configured") return undefined;
|
|
75
|
+
const entry = (config.apiKeys ?? []).find(key => key.id === admission.keyId);
|
|
76
|
+
return entry ? admissionModelScopeOf(entry) : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Does this scope admit this resolved destination?
|
|
81
|
+
*
|
|
82
|
+
* The two lists are independent conditions and both must hold when both are
|
|
83
|
+
* declared: a key allowed one provider and one model may not reach that
|
|
84
|
+
* model on a different provider, which is what a combo child or a policy
|
|
85
|
+
* fallback would otherwise do while the requested selector stayed the same.
|
|
86
|
+
*
|
|
87
|
+
* A model entry matches the bare resolved model id or the fully qualified
|
|
88
|
+
* `provider/model` form, so an operator can scope one model everywhere or
|
|
89
|
+
* pin it to a single provider without a second field.
|
|
90
|
+
*/
|
|
91
|
+
export function routeAllowedByScope(
|
|
92
|
+
scope: AdmissionModelScope | undefined,
|
|
93
|
+
route: ScopedRoute,
|
|
94
|
+
): boolean {
|
|
95
|
+
if (!scope) return true;
|
|
96
|
+
const model = normalize(route.modelId);
|
|
97
|
+
if (!providerAllowedByScope(scope, route.providerName)) return false;
|
|
98
|
+
if (scope.models.length === 0) return true;
|
|
99
|
+
return scope.models.includes(model) || scope.models.includes(normalize(route.providerName) + "/" + model);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A request that asked for a destination its key may not reach.
|
|
104
|
+
*
|
|
105
|
+
* Carries the selector the client sent rather than the destination it resolved
|
|
106
|
+
* to: the caller needs to know which of its own requests was refused, and a
|
|
107
|
+
* key that may not reach a provider has no business learning that an alias it
|
|
108
|
+
* named points there. The resolved destination goes to the server log.
|
|
109
|
+
*/
|
|
110
|
+
export class AdmissionModelDeniedError extends Error {
|
|
111
|
+
readonly requestedModel: string;
|
|
112
|
+
readonly deniedProvider: string;
|
|
113
|
+
readonly deniedModel: string;
|
|
114
|
+
constructor(requestedModel: string, route: ScopedRoute) {
|
|
115
|
+
super("model " + requestedModel + " is not allowed for this API key");
|
|
116
|
+
this.name = "AdmissionModelDeniedError";
|
|
117
|
+
this.requestedModel = requestedModel;
|
|
118
|
+
this.deniedProvider = route.providerName;
|
|
119
|
+
this.deniedModel = route.modelId;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Stable wire type for a scope refusal. */
|
|
124
|
+
export const MODEL_NOT_ALLOWED_FOR_KEY = "model_not_allowed_for_key";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* How a refusal names a destination whose model nobody stated.
|
|
128
|
+
*
|
|
129
|
+
* This is message vocabulary, never a model id. It is not compared against
|
|
130
|
+
* `allowedModels`, so an operator who copies it out of a refusal into a list
|
|
131
|
+
* cannot grant "whatever the upstream picks" — the one destination a model list
|
|
132
|
+
* is unable to describe.
|
|
133
|
+
*/
|
|
134
|
+
export const UNNAMED_DESTINATION_MODEL = "(unnamed)";
|
|
135
|
+
|
|
136
|
+
/** The HTTP body a scope refusal returns. 403: authenticated, not permitted. */
|
|
137
|
+
export function admissionModelDeniedBody(error: AdmissionModelDeniedError): {
|
|
138
|
+
error: { type: string; message: string; model: string };
|
|
139
|
+
} {
|
|
140
|
+
return {
|
|
141
|
+
error: {
|
|
142
|
+
type: MODEL_NOT_ALLOWED_FOR_KEY,
|
|
143
|
+
message: error.message,
|
|
144
|
+
model: error.requestedModel,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The refusal a scoped request gets: 403, not 404.
|
|
151
|
+
*
|
|
152
|
+
* The key authenticated; it simply may not reach this destination. Reporting
|
|
153
|
+
* "not found" instead would tell a client its credential is wrong and invite
|
|
154
|
+
* it to retry with another, and would make an operator's own denial
|
|
155
|
+
* indistinguishable from a typo in the model name.
|
|
156
|
+
*/
|
|
157
|
+
export function admissionModelDeniedResponse(error: AdmissionModelDeniedError): Response {
|
|
158
|
+
return new Response(JSON.stringify(admissionModelDeniedBody(error)), {
|
|
159
|
+
status: 403,
|
|
160
|
+
headers: { "Content-Type": "application/json" },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Refuse a resolved destination this key may not reach.
|
|
166
|
+
*
|
|
167
|
+
* Every request-path site that produces or re-produces a route calls this, so
|
|
168
|
+
* the direct name, an alias, a combo child, a policy or subagent fallback and
|
|
169
|
+
* a compaction override are all checked at the point they become concrete
|
|
170
|
+
* rather than once at the front door.
|
|
171
|
+
*/
|
|
172
|
+
export function assertRouteAllowedByScope(
|
|
173
|
+
scope: AdmissionModelScope | undefined,
|
|
174
|
+
requestedModel: string,
|
|
175
|
+
route: ScopedRoute,
|
|
176
|
+
): void {
|
|
177
|
+
if (!routeAllowedByScope(scope, route)) {
|
|
178
|
+
throw new AdmissionModelDeniedError(requestedModel, route);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The refusal a non-routed data-plane surface returns, or undefined when the
|
|
184
|
+
* destination is permitted.
|
|
185
|
+
*
|
|
186
|
+
* The Responses path resolves a route inside a try/catch and throws from the
|
|
187
|
+
* point the destination becomes concrete. The endpoints beside it -- images,
|
|
188
|
+
* audio, voice and the search relay -- have no router and no such boundary:
|
|
189
|
+
* each picks its own upstream inline and returns a Response. This is the same
|
|
190
|
+
* predicate against the same kind of resolved destination, shaped for that
|
|
191
|
+
* control flow, so a key that may not reach a provider is refused identically
|
|
192
|
+
* whichever surface it asked through.
|
|
193
|
+
*
|
|
194
|
+
* `destination.modelId` is undefined when nobody named the model this request
|
|
195
|
+
* will run: the relay copies the body and the upstream picks, or a voice join
|
|
196
|
+
* attaches to a call this process never recorded. A key with a model list is
|
|
197
|
+
* then refused, because no entry in that list can describe the destination; a
|
|
198
|
+
* key scoped only by provider is judged on the provider alone.
|
|
199
|
+
*/
|
|
200
|
+
export function admissionScopeDenial(
|
|
201
|
+
config: Pick<OcxConfig, "apiKeys">,
|
|
202
|
+
admission: DataPlaneAdmission | undefined,
|
|
203
|
+
requestedModel: string | undefined,
|
|
204
|
+
destination: { readonly providerName: string; readonly modelId: string | undefined },
|
|
205
|
+
): Response | undefined {
|
|
206
|
+
const scope = resolveAdmissionModelScope(config, admission);
|
|
207
|
+
if (!scope) return undefined;
|
|
208
|
+
const allowed = destination.modelId === undefined
|
|
209
|
+
? providerAllowedByScope(scope, destination.providerName) && scope.models.length === 0
|
|
210
|
+
: routeAllowedByScope(scope, { providerName: destination.providerName, modelId: destination.modelId });
|
|
211
|
+
if (allowed) return undefined;
|
|
212
|
+
return admissionModelDeniedResponse(new AdmissionModelDeniedError(
|
|
213
|
+
requestedModel ?? UNNAMED_DESTINATION_MODEL,
|
|
214
|
+
{
|
|
215
|
+
providerName: destination.providerName,
|
|
216
|
+
modelId: destination.modelId ?? UNNAMED_DESTINATION_MODEL,
|
|
217
|
+
},
|
|
218
|
+
));
|
|
219
|
+
}
|
package/src/server/audio-live.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { LIVE_AUDIO_MODEL, resolveAudioUpstream, type AudioUpstream } from "./au
|
|
|
11
11
|
import { registerTurn, unregisterTurn } from "./lifecycle";
|
|
12
12
|
import {
|
|
13
13
|
backendJsonBodyFromApiMultipart, buildLiveSidebandUpstreamWsUrl, forwardLiveUrl, keyedLiveUrl,
|
|
14
|
-
LIVE_CLIENT_PROTOCOL_HEADERS, LIVE_REQUEST_MAX_BYTES, LIVE_RESPONSE_MAX_BYTES,
|
|
14
|
+
LIVE_CLIENT_PROTOCOL_HEADERS, LIVE_REQUEST_MAX_BYTES, LIVE_RESPONSE_MAX_BYTES, liveSidebandModel,
|
|
15
|
+
readBodyCapped,
|
|
15
16
|
type LiveSidebandTarget,
|
|
16
17
|
} from "./live";
|
|
17
18
|
import { LIVE_CALL_TTL_MS, LiveCallBindings, upstreamLiveCallId } from "./live-call-bindings";
|
|
@@ -115,6 +116,8 @@ export async function handleExternalLive(
|
|
|
115
116
|
owner: options.client.owner, upstreamCallId: callId,
|
|
116
117
|
joinStyle: frameless ? "frameless-path" : "realtime-query",
|
|
117
118
|
providerName: relay.providerName,
|
|
119
|
+
// A join carries no model of its own; this is the one the call settled on.
|
|
120
|
+
model,
|
|
118
121
|
accountId: context ? context.kind === "main" ? callerOwned ? undefined : MAIN_CODEX_ACCOUNT_ID : context.accountId : undefined,
|
|
119
122
|
chatgptAccountId: new Headers(relay.headers).get("chatgpt-account-id") ?? undefined,
|
|
120
123
|
keyedCredentialDigest: relay.keyed ? createHash("sha256").update(new Headers(relay.headers).get("authorization") ?? "").digest("hex") : undefined,
|
|
@@ -150,13 +153,16 @@ export async function resolveExternalLiveSocket(
|
|
|
150
153
|
}
|
|
151
154
|
let upstreamTarget = binding ? { style: binding.joinStyle, callId: binding.upstreamCallId } as LiveSidebandTarget : target;
|
|
152
155
|
const frameless = upstreamTarget.style === "frameless-path" || upstreamTarget.style === "frameless-standalone";
|
|
153
|
-
let model = LIVE_AUDIO_MODEL;
|
|
154
156
|
if (upstreamTarget.style === "frameless-standalone") {
|
|
155
157
|
const query = new URLSearchParams(upstreamTarget.query);
|
|
156
158
|
if (!query.has("model") || query.get("model") === "gpt-live-1") query.set("model", LIVE_AUDIO_MODEL);
|
|
157
|
-
model = query.get("model") ?? LIVE_AUDIO_MODEL;
|
|
158
159
|
upstreamTarget = { ...upstreamTarget, query: query.toString() };
|
|
159
160
|
}
|
|
161
|
+
// What this socket will actually run: the model a bound call settled on when
|
|
162
|
+
// this same key created it, or the one a standalone session forwards in its
|
|
163
|
+
// own query. Reading only the Frameless query left a Realtime standalone
|
|
164
|
+
// socket judged as the default while its query carried another model.
|
|
165
|
+
const model = binding?.model ?? liveSidebandModel(upstreamTarget) ?? LIVE_AUDIO_MODEL;
|
|
160
166
|
const relay = await resolveAudioUpstream(client.headers, config, log, {
|
|
161
167
|
admission: client.admission, model, lease: options.lease,
|
|
162
168
|
signal: options.signal,
|
|
@@ -14,6 +14,7 @@ import { formatCodexProviderForLog } from "../codex/routing";
|
|
|
14
14
|
import type { AdmissionLease } from "../lib/admission";
|
|
15
15
|
import { captureExplicitOpenAiCallerAuth, resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
|
|
16
16
|
import type { OcxConfig } from "../types";
|
|
17
|
+
import { admissionScopeDenial } from "./admission-model-scope";
|
|
17
18
|
import {
|
|
18
19
|
isProxyAdmissionSecret,
|
|
19
20
|
resolveDataPlaneAdmissionSecret,
|
|
@@ -119,6 +120,18 @@ export async function resolveAudioUpstream(
|
|
|
119
120
|
return formatErrorResponse(401, "authentication_error", "Selected audio account is unavailable");
|
|
120
121
|
}
|
|
121
122
|
validateForwardAdmissionCredential(selected, config);
|
|
123
|
+
// Every audio surface — file transcription, the dictation socket, voice
|
|
124
|
+
// call-create and the sideband join — resolves its upstream here, and
|
|
125
|
+
// `options.model` is the model that upstream will run, not a selector the
|
|
126
|
+
// caller can rewrite afterwards. One check therefore covers all of them.
|
|
127
|
+
const forwardDenial = admissionScopeDenial(config, options.admission, options.model, {
|
|
128
|
+
providerName: candidate.providerName,
|
|
129
|
+
modelId: options.model,
|
|
130
|
+
});
|
|
131
|
+
if (forwardDenial) {
|
|
132
|
+
releaseCodexAuthContextProbeLease(context);
|
|
133
|
+
return forwardDenial;
|
|
134
|
+
}
|
|
122
135
|
log.provider = formatCodexProviderForLog(candidate.providerName, context.accountId, config);
|
|
123
136
|
log.model = options.model;
|
|
124
137
|
return {
|
|
@@ -136,6 +149,11 @@ export async function resolveAudioUpstream(
|
|
|
136
149
|
const selected = new Headers(provider.headers);
|
|
137
150
|
selected.set("authorization", `Bearer ${apiKey}`);
|
|
138
151
|
validateForwardAdmissionCredential(selected, config);
|
|
152
|
+
const keyedDenial = admissionScopeDenial(config, options.admission, options.model, {
|
|
153
|
+
providerName,
|
|
154
|
+
modelId: options.model,
|
|
155
|
+
});
|
|
156
|
+
if (keyedDenial) return keyedDenial;
|
|
139
157
|
log.provider = providerName;
|
|
140
158
|
log.model = options.model;
|
|
141
159
|
return {
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
providerWebSearchBridgeConfigError,
|
|
12
12
|
requestPacingConfigError,
|
|
13
13
|
retryOn429PolicyConfigError,
|
|
14
|
+
retryOnResetPolicyConfigError,
|
|
14
15
|
sanitizeModelCostsForDisplay,
|
|
15
16
|
} from "../config";
|
|
16
17
|
import {
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
upstreamHttpVersionConfigError,
|
|
30
31
|
} from "../config/provider-validation";
|
|
31
32
|
import { providerDestinationConfigError } from "../lib/destination-policy";
|
|
33
|
+
import { providerEgressConfigError } from "../lib/provider-egress";
|
|
32
34
|
import { redactSecretString } from "../lib/redact";
|
|
33
35
|
import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy";
|
|
34
36
|
import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
|
|
@@ -722,6 +724,10 @@ export function providerManagementConfigError(
|
|
|
722
724
|
delete canonicalCandidate.modelCosts;
|
|
723
725
|
// requestPacing is a user-owned transport overlay, not part of the canonical seed.
|
|
724
726
|
delete canonicalCandidate.requestPacing;
|
|
727
|
+
// retryOnReset is the same kind of overlay: it tunes how this provider's own Responses
|
|
728
|
+
// sends recover, not what the canonical forward seed is. Validated below
|
|
729
|
+
// (retryOnResetPolicyConfigError).
|
|
730
|
+
delete canonicalCandidate.retryOnReset;
|
|
725
731
|
// Context windows are the same kind of user-owned overlay as requestPacing: the operator
|
|
726
732
|
// narrowing what their own native rows advertise. They can only ever LOWER the measured
|
|
727
733
|
// window (see nativeOpenAiContextWindow), so admitting them cannot widen what the proxy
|
|
@@ -768,6 +774,10 @@ export function providerManagementConfigError(
|
|
|
768
774
|
// it before it reaches the management API response.
|
|
769
775
|
return `provider ${JSON.stringify(redactSecretString(name))} ${retryOn429Error}`;
|
|
770
776
|
}
|
|
777
|
+
const retryOnResetError = retryOnResetPolicyConfigError(raw.retryOnReset);
|
|
778
|
+
if (retryOnResetError) {
|
|
779
|
+
return `provider ${JSON.stringify(redactSecretString(name))} ${retryOnResetError}`;
|
|
780
|
+
}
|
|
771
781
|
const requestPacingError = requestPacingConfigError(raw.requestPacing);
|
|
772
782
|
if (requestPacingError) {
|
|
773
783
|
return `provider ${JSON.stringify(redactSecretString(name))} ${requestPacingError}`;
|
|
@@ -780,6 +790,13 @@ export function providerManagementConfigError(
|
|
|
780
790
|
if (upstreamHttpVersionError) {
|
|
781
791
|
return `provider ${JSON.stringify(redactSecretString(name))} ${upstreamHttpVersionError}`;
|
|
782
792
|
}
|
|
793
|
+
// Per-provider egress shares one definition with the transports and the config loader, so a
|
|
794
|
+
// value the dashboard accepts is one a request can actually leave by. The message never
|
|
795
|
+
// echoes the value: a proxy URL routinely embeds `user:password@`.
|
|
796
|
+
const egressError = providerEgressConfigError(typed);
|
|
797
|
+
if (egressError) {
|
|
798
|
+
return `provider ${JSON.stringify(redactSecretString(name))} ${egressError}`;
|
|
799
|
+
}
|
|
783
800
|
const modelCostsError = providerModelCostsConfigError(raw.modelCosts);
|
|
784
801
|
if (modelCostsError) {
|
|
785
802
|
// The provider name is caller-controlled and can be token-shaped; redact and JSON-escape
|
|
@@ -947,6 +964,13 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
|
|
|
947
964
|
decodesNativeCompactionBlobs: "editor",
|
|
948
965
|
allowEncryptedV2AgentTasks: "editor",
|
|
949
966
|
allowPrivateNetwork: "editor",
|
|
967
|
+
// A proxy URL routinely embeds `user:password@`, so it never reaches the dashboard DTO and
|
|
968
|
+
// the editor may not write it. `ocx config set` and the config file remain the way to set
|
|
969
|
+
// it, which is the same boundary `apiKey` sits behind and for the same reason.
|
|
970
|
+
proxy: "redacted",
|
|
971
|
+
// A bypass list names destinations, carries no credential, and is only meaningful next to a
|
|
972
|
+
// route the operator can already see.
|
|
973
|
+
noProxy: "editor",
|
|
950
974
|
upstreamHttpVersion: "editor",
|
|
951
975
|
upstreamWebsocket: "editor",
|
|
952
976
|
directGeminiWireRenames: "editor",
|
|
@@ -1024,6 +1048,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
|
|
|
1024
1048
|
pinParallelToolCallsFalse: "editor",
|
|
1025
1049
|
terminalContinuationGuard: "editor",
|
|
1026
1050
|
openaiChatEofTolerance: "editor",
|
|
1051
|
+
foldDeveloperRoleToSystem: "editor",
|
|
1027
1052
|
promptCacheKey: "editor",
|
|
1028
1053
|
chatServiceTier: "editor",
|
|
1029
1054
|
responsesItemIdRepair: "editor",
|
|
@@ -1033,6 +1058,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
|
|
|
1033
1058
|
showThinkingSummary: "editor",
|
|
1034
1059
|
retryOn429: "editor",
|
|
1035
1060
|
transientRetryOn5xx: "editor",
|
|
1061
|
+
retryOnReset: "editor",
|
|
1036
1062
|
reasoningSplitModels: "editor",
|
|
1037
1063
|
reasoningDetailsModels: "editor",
|
|
1038
1064
|
thinkingToggleModels: "editor",
|
|
@@ -22,6 +22,15 @@ import {
|
|
|
22
22
|
import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors";
|
|
23
23
|
import { redactSecretString } from "../lib/redact";
|
|
24
24
|
import { resolveClientRetryAfter } from "../lib/retry-after";
|
|
25
|
+
import {
|
|
26
|
+
applyReplayRefusalClientHeaders,
|
|
27
|
+
isReplayRefusalCode,
|
|
28
|
+
isReplayRefusalResponse,
|
|
29
|
+
REPLAY_REFUSAL_CLIENT_HEADERS,
|
|
30
|
+
REPLAY_REFUSED_STATUS,
|
|
31
|
+
retainReplayRefusal,
|
|
32
|
+
UPSTREAM_RESET_REPLAY_REFUSED_CODE,
|
|
33
|
+
} from "../lib/upstream-retry";
|
|
25
34
|
import { estimateTokens } from "../lib/token-estimate";
|
|
26
35
|
import { captureRouteStaticPolicy, NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
|
|
27
36
|
import { evidenceFromBody } from "../routing/request-evidence";
|
|
@@ -46,6 +55,12 @@ import { providerConsumesCallerAuthorization } from "../providers/caller-authori
|
|
|
46
55
|
import { captureExplicitOpenAiCallerAuth } from "../providers/openai-sidecar";
|
|
47
56
|
import { captureCallerDirectAuth } from "../providers/caller-authorization";
|
|
48
57
|
import type { AdmissionLease } from "../lib/admission";
|
|
58
|
+
import {
|
|
59
|
+
admissionModelDeniedResponse,
|
|
60
|
+
AdmissionModelDeniedError,
|
|
61
|
+
assertRouteAllowedByScope,
|
|
62
|
+
resolveAdmissionModelScope,
|
|
63
|
+
} from "./admission-model-scope";
|
|
49
64
|
import type { DataPlaneAdmission } from "./auth-cors";
|
|
50
65
|
import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission";
|
|
51
66
|
import {
|
|
@@ -150,6 +165,10 @@ async function handleChatCompletionsWithBudget(
|
|
|
150
165
|
let chatNativeRoute: ReturnType<typeof routeModel> | null = null;
|
|
151
166
|
try {
|
|
152
167
|
const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody));
|
|
168
|
+
// The native Chat lane sends without re-entering the Responses path, so it
|
|
169
|
+
// has to apply the key's scope itself. Translated traffic is checked where
|
|
170
|
+
// every rewrite converges instead.
|
|
171
|
+
assertRouteAllowedByScope(resolveAdmissionModelScope(config, logIds?.admission), requestedModel, route);
|
|
153
172
|
// Preserve the routed destination for Go recognition, then settle the wire before
|
|
154
173
|
// deriving protocol-scoped affinity. Recognition must not inspect the flipped adapter.
|
|
155
174
|
const routedProvider = route.provider;
|
|
@@ -182,6 +201,11 @@ async function handleChatCompletionsWithBudget(
|
|
|
182
201
|
// effort, failover, and per-attempt telemetry run before any native Chat send.
|
|
183
202
|
if (!route.combo && !effortRow && isNativeChatRouteEligible(route, chatBody, config)) chatNativeRoute = route;
|
|
184
203
|
} catch (err) {
|
|
204
|
+
if (err instanceof AdmissionModelDeniedError) {
|
|
205
|
+
logCtx.requestedModel = requestedModel;
|
|
206
|
+
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" });
|
|
207
|
+
return admissionModelDeniedResponse(err);
|
|
208
|
+
}
|
|
185
209
|
if (err instanceof UnknownRoutingPolicyError) {
|
|
186
210
|
logCtx.requestedModel = requestedModel;
|
|
187
211
|
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" });
|
|
@@ -404,9 +428,19 @@ async function handleChatCompletionsWithBudget(
|
|
|
404
428
|
: "invalid_request_error"),
|
|
405
429
|
message,
|
|
406
430
|
);
|
|
431
|
+
// The same verdict the native Chat surface reads, from the same two places: the response
|
|
432
|
+
// this wrapper still holds, and the code a body kept through an intermediate formatter.
|
|
433
|
+
// Not the status -- a refusal and a real rate limit are both 429, which is the whole
|
|
434
|
+
// reason this surface used to report one as the other.
|
|
435
|
+
const replayRefusal = isReplayRefusalResponse(upstream) || isReplayRefusalCode(upstreamCode);
|
|
407
436
|
if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) {
|
|
408
437
|
classified.code = CYBER_POLICY_ERROR_CODE;
|
|
409
438
|
classified.type = cyberPolicyErrorType(upstreamType);
|
|
439
|
+
} else if (replayRefusal) {
|
|
440
|
+
// 429 classifies as a rate limit, which already carries a code, so the empty-code branch
|
|
441
|
+
// below could never restore this one -- the translated client was told the provider
|
|
442
|
+
// throttled the turn, and handed a two-second wait to send it again.
|
|
443
|
+
classified.code = UPSTREAM_RESET_REPLAY_REFUSED_CODE;
|
|
410
444
|
} else if (upstreamCode === "model_not_found") {
|
|
411
445
|
// Structured model_not_found must win over classifyError's generic remaps.
|
|
412
446
|
classified.code = "model_not_found";
|
|
@@ -414,8 +448,10 @@ async function handleChatCompletionsWithBudget(
|
|
|
414
448
|
} else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) {
|
|
415
449
|
classified.code = upstreamCode;
|
|
416
450
|
}
|
|
417
|
-
const status = isCyberPolicyCode(classified.code) ? 400
|
|
418
|
-
|
|
451
|
+
const status = isCyberPolicyCode(classified.code) ? 400
|
|
452
|
+
: replayRefusal ? REPLAY_REFUSED_STATUS
|
|
453
|
+
: upstream.status;
|
|
454
|
+
const retryAfter = isCyberPolicyCode(classified.code) || replayRefusal
|
|
419
455
|
? undefined
|
|
420
456
|
: resolveClientRetryAfter({
|
|
421
457
|
status: upstream.status,
|
|
@@ -434,9 +470,12 @@ async function handleChatCompletionsWithBudget(
|
|
|
434
470
|
headers: {
|
|
435
471
|
"Content-Type": "application/json",
|
|
436
472
|
...(retryAfter ? { "Retry-After": retryAfter } : {}),
|
|
473
|
+
...(replayRefusal ? REPLAY_REFUSAL_CLIENT_HEADERS : {}),
|
|
437
474
|
},
|
|
438
475
|
});
|
|
476
|
+
if (replayRefusal) retainReplayRefusal(rewritten);
|
|
439
477
|
return logIds
|
|
478
|
+
// Deferred logging re-wraps this response and carries the verdict with it.
|
|
440
479
|
? responseWithDeferredRequestLog(rewritten, logIds.requestId, logIds.start, logCtx)
|
|
441
480
|
: rewritten;
|
|
442
481
|
}
|
|
@@ -503,11 +542,25 @@ async function handleChatCompletionsWithBudget(
|
|
|
503
542
|
} else if (isCyberPolicyCode(error?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
|
|
504
543
|
classified.code = CYBER_POLICY_ERROR_CODE;
|
|
505
544
|
classified.type = cyberPolicyErrorType(error?.type);
|
|
545
|
+
} else if (isReplayRefusalCode(error?.code)) {
|
|
546
|
+
// The refusal can also arrive as a failed Responses envelope rather than a non-2xx.
|
|
547
|
+
// Reporting that as the 502 below would invite the four resends the refusal prevents.
|
|
548
|
+
classified.code = UPSTREAM_RESET_REPLAY_REFUSED_CODE;
|
|
506
549
|
} else if (error?.code === "model_not_found") {
|
|
507
550
|
// Same deliberate preserve as the non-OK path: structured code beats generic classify.
|
|
508
551
|
classified.code = "model_not_found";
|
|
509
552
|
classified.type = "invalid_request_error";
|
|
510
553
|
}
|
|
554
|
+
if (isReplayRefusalCode(classified.code)) {
|
|
555
|
+
const refusal = chatCompletionsErrorResponse(
|
|
556
|
+
REPLAY_REFUSED_STATUS, message, classified.type, classified.code,
|
|
557
|
+
);
|
|
558
|
+
const headers = new Headers(refusal.headers);
|
|
559
|
+
applyReplayRefusalClientHeaders(headers);
|
|
560
|
+
return finishJson(retainReplayRefusal(
|
|
561
|
+
new Response(refusal.body, { status: refusal.status, headers }),
|
|
562
|
+
));
|
|
563
|
+
}
|
|
511
564
|
return finishJson(chatCompletionsErrorResponse(
|
|
512
565
|
classified.code === "translation_buffer_limit"
|
|
513
566
|
? 502
|
|
@@ -29,6 +29,9 @@ import {
|
|
|
29
29
|
isReplayRefusalCode,
|
|
30
30
|
isReplayRefusalResponse,
|
|
31
31
|
prepareSameTarget429Wait,
|
|
32
|
+
REPLAY_REFUSAL_CLIENT_HEADERS,
|
|
33
|
+
REPLAY_REFUSED_STATUS,
|
|
34
|
+
retainReplayRefusal,
|
|
32
35
|
type UpstreamSendRecovery,
|
|
33
36
|
UPSTREAM_RESET_REPLAY_REFUSED_CODE,
|
|
34
37
|
} from "../lib/upstream-retry";
|
|
@@ -359,6 +362,11 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
359
362
|
applyUpstreamRecoveryInit({
|
|
360
363
|
...init, method: request.method, headers, body: request.body,
|
|
361
364
|
}, transportRecovery),
|
|
365
|
+
// Reselection can replace the provider transport and the wire shape, so the
|
|
366
|
+
// egress route is bound to the provider this send actually uses. Omitting it
|
|
367
|
+
// here would let a provider transport bypass its configured route entirely,
|
|
368
|
+
// because that transport wins over the executor that carries the binding.
|
|
369
|
+
{ providerName: route.providerName, provider: activeProvider },
|
|
362
370
|
);
|
|
363
371
|
if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal);
|
|
364
372
|
return dispatched;
|
|
@@ -492,10 +500,13 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
492
500
|
: response.status >= 500 ? "server_error" : "invalid_request_error"),
|
|
493
501
|
clientMessage,
|
|
494
502
|
);
|
|
503
|
+
// The verdict is read once, from the response that carries it and from the code a
|
|
504
|
+
// re-wrapped body kept -- never from the status, which a real rate limit shares.
|
|
505
|
+
const replayRefusal = isReplayRefusalResponse(response) || isReplayRefusalCode(upstreamCode);
|
|
495
506
|
if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) {
|
|
496
507
|
classified.code = CYBER_POLICY_ERROR_CODE;
|
|
497
508
|
classified.type = cyberPolicyErrorType(upstreamType);
|
|
498
|
-
} else if (
|
|
509
|
+
} else if (replayRefusal) {
|
|
499
510
|
// 429 classifies as a rate limit and a rate limit already carries a code, so the branch
|
|
500
511
|
// below -- which only fills an EMPTY code -- could never restore this one. Without it the
|
|
501
512
|
// client is told the provider throttled the turn, when what happened is that this proxy
|
|
@@ -507,11 +518,13 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
507
518
|
} else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) {
|
|
508
519
|
classified.code = upstreamCode;
|
|
509
520
|
}
|
|
510
|
-
const status = isCyberPolicyCode(classified.code) ? 400
|
|
521
|
+
const status = isCyberPolicyCode(classified.code) ? 400
|
|
522
|
+
: replayRefusal ? REPLAY_REFUSED_STATUS
|
|
523
|
+
: response.status;
|
|
511
524
|
// A refusal this proxy made has no wait to report. Synthesizing one here would hand the
|
|
512
525
|
// client the default two-second retry for a rate limit that never happened, which is the
|
|
513
526
|
// duplicate send the refusal exists to prevent.
|
|
514
|
-
const retryAfter = isCyberPolicyCode(classified.code) ||
|
|
527
|
+
const retryAfter = isCyberPolicyCode(classified.code) || replayRefusal
|
|
515
528
|
? undefined
|
|
516
529
|
: resolveClientRetryAfter({
|
|
517
530
|
status: response.status,
|
|
@@ -519,13 +532,15 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
519
532
|
upstreamRetryAfter: response.headers.get("retry-after"),
|
|
520
533
|
});
|
|
521
534
|
finishLog(status, classified.message);
|
|
522
|
-
|
|
535
|
+
const rewritten = new Response(JSON.stringify(chatCompletionsErrorBody(status, classified.message, classified.type, classified.code)), {
|
|
523
536
|
status,
|
|
524
537
|
headers: {
|
|
525
538
|
"Content-Type": "application/json",
|
|
526
539
|
...(retryAfter ? { "Retry-After": retryAfter } : {}),
|
|
540
|
+
...(replayRefusal ? REPLAY_REFUSAL_CLIENT_HEADERS : {}),
|
|
527
541
|
},
|
|
528
542
|
});
|
|
543
|
+
return replayRefusal ? retainReplayRefusal(rewritten) : rewritten;
|
|
529
544
|
}
|
|
530
545
|
|
|
531
546
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|