@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.0
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/gui/dist/assets/index-CWXut3rG.js +115 -0
- package/gui/dist/assets/index-EdoPnm9_.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/devin.svg +49 -0
- package/gui/dist/provider-icons/omo.svg +42 -0
- package/package.json +3 -1
- package/src/AGENTS.md +1 -1
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
- package/src/adapters/command-code.ts +0 -1
- package/src/adapters/cursor/checkpoint-store.ts +37 -0
- package/src/adapters/cursor/live-transport.ts +74 -2
- package/src/adapters/cursor.ts +6 -0
- package/src/adapters/devin/cloud-direct/auth.ts +264 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
- package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
- package/src/adapters/devin/cloud-direct/index.ts +65 -0
- package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
- package/src/adapters/devin/cloud-direct/wire.ts +206 -0
- package/src/adapters/devin/live-models.ts +133 -0
- package/src/adapters/devin-cli/acp.ts +204 -0
- package/src/adapters/devin-cli/adapter.ts +345 -0
- package/src/adapters/devin-cli/binary.ts +69 -0
- package/src/adapters/devin-cli/models.ts +57 -0
- package/src/adapters/devin.ts +326 -0
- package/src/adapters/google.ts +1 -1
- package/src/adapters/openai-chat.ts +31 -10
- package/src/adapters/openai-responses.ts +16 -1
- package/src/adapters/registry.ts +25 -1
- package/src/bridge.ts +8 -2
- package/src/claude/inbound-cache-stabilize.ts +130 -0
- package/src/claude/inbound.ts +45 -5
- package/src/cli/account-auth.ts +60 -1
- package/src/cli/account-extended.ts +23 -30
- package/src/cli/account.ts +2 -1
- package/src/cli/capabilities.ts +50 -8
- package/src/cli/config-command.ts +2 -2
- package/src/cli/dispatch.ts +14 -2
- package/src/cli/export-command.ts +11 -25
- package/src/cli/help.ts +2 -2
- package/src/cli/opencode.ts +5 -0
- package/src/cli/registry.ts +15 -4
- package/src/clients/config-export/cline.ts +71 -0
- package/src/clients/config-export/contracts.ts +10 -1
- package/src/clients/config-export/model-metadata.ts +33 -0
- package/src/clients/config-export/zcode.ts +23 -12
- package/src/clients/config-export.ts +156 -4
- package/src/codex/account-store.ts +7 -2
- package/src/codex/auth-context.ts +1 -1
- package/src/codex/catalog/effort.ts +4 -3
- package/src/codex/catalog/metadata.ts +8 -4
- package/src/codex/catalog/native-models.ts +24 -4
- package/src/codex/catalog/parsing.ts +19 -1
- package/src/codex/catalog/provider-fetch.ts +57 -0
- package/src/codex/catalog/sync.ts +1 -1
- package/src/codex/context-compat.ts +97 -0
- package/src/codex/context-owner.ts +201 -0
- package/src/codex/history-provider.ts +161 -14
- package/src/codex/inject-coordination.ts +3 -2
- package/src/codex/inject.ts +128 -9
- package/src/codex/pool-rotation.ts +8 -292
- package/src/codex/quota.ts +41 -12
- package/src/codex/retired-model-migration.ts +41 -0
- package/src/codex/routing.ts +327 -22
- package/src/codex/warmup.ts +2 -2
- package/src/combos/failover.ts +5 -0
- package/src/combos/resolve.ts +11 -15
- package/src/config.ts +46 -14
- package/src/generated/compatibility-version.json +293 -113
- package/src/grok/grpc-web.ts +120 -0
- package/src/grok/reset-coupon-ledger.ts +139 -0
- package/src/grok/reset-coupons.ts +278 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/cline-document.ts +73 -0
- package/src/integrations/cline-io.ts +149 -0
- package/src/integrations/cline-transaction.ts +42 -0
- package/src/integrations/config-io.ts +21 -2
- package/src/integrations/journal.ts +43 -1
- package/src/integrations/omp-yaml-source.ts +123 -4
- package/src/integrations/ownership-policy.ts +7 -0
- package/src/integrations/ownership.ts +36 -0
- package/src/integrations/registry.ts +27 -0
- package/src/integrations/state.ts +5 -2
- package/src/integrations/store.ts +6 -0
- package/src/integrations/writer.ts +53 -9
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/abort.ts +36 -0
- package/src/lib/local-destinations.ts +1 -1
- package/src/lib/upstream-retry.ts +36 -1
- package/src/oauth/account-quota-rank.ts +11 -0
- package/src/oauth/callback-server.ts +10 -5
- package/src/oauth/devin/api-base.ts +63 -0
- package/src/oauth/devin/login.ts +1 -0
- package/src/oauth/devin/register-user.ts +186 -0
- package/src/oauth/devin/types.ts +71 -0
- package/src/oauth/devin-cli.ts +149 -0
- package/src/oauth/devin.ts +170 -0
- package/src/oauth/generic-account-failover.ts +169 -1
- package/src/oauth/index.ts +20 -1
- package/src/oauth/login-cli.ts +19 -5
- package/src/oauth/pool-kernel.ts +321 -0
- package/src/oauth/pool-settings-capability.ts +127 -9
- package/src/oauth/store.ts +7 -3
- package/src/oauth/token-guardian.ts +1 -1
- package/src/providers/codebuddy-models.ts +0 -3
- package/src/providers/command-code-efforts.ts +23 -4
- package/src/providers/default-aliases.ts +4 -0
- package/src/providers/derive.ts +8 -0
- package/src/providers/devin-cli-authmode-migration.ts +57 -0
- package/src/providers/key-failover.ts +175 -0
- package/src/providers/model-rename-startup.ts +21 -1
- package/src/providers/qoder-models.ts +0 -1
- package/src/providers/quota-key-accounts.ts +50 -0
- package/src/providers/quota-routing-cache.ts +65 -6
- package/src/providers/quota-types.ts +7 -0
- package/src/providers/quota.ts +113 -30
- package/src/providers/registry.ts +237 -77
- package/src/providers/stale-context-window-migration.ts +92 -0
- package/src/providers/zai-responses-migration.ts +45 -0
- package/src/quota/reset-observer.ts +2 -1
- package/src/quota/reset-seen-store.ts +9 -1
- package/src/remote-control/crypto.ts +442 -0
- package/src/remote-control/host.ts +175 -0
- package/src/remote-control/index.ts +100 -0
- package/src/remote-control/protocol.ts +200 -0
- package/src/remote-control/relay.ts +162 -0
- package/src/remote-control/workspace-agent-protocol.ts +246 -0
- package/src/remote-control/workspace-rpc-framing.ts +128 -0
- package/src/remote-control/workspace-tools.ts +237 -0
- package/src/remote-control/workspace-utf8.ts +24 -0
- package/src/responses/custom-tool-compat.ts +23 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/behavior.ts +3 -0
- package/src/server/adapter-resolve.ts +6 -2
- package/src/server/auth-cors.ts +71 -6
- package/src/server/chat-completions.ts +5 -3
- package/src/server/chat-native.ts +9 -0
- package/src/server/claude-messages.ts +25 -30
- package/src/server/context-history.ts +207 -0
- package/src/server/images.ts +28 -1
- package/src/server/index.ts +42 -22
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/config-routes.ts +3 -3
- package/src/server/management/grok-coupon-routes.ts +287 -0
- package/src/server/management/integration-routes.ts +6 -1
- package/src/server/management/oauth-account-routes.ts +124 -7
- package/src/server/management/provider-routes.ts +77 -2
- package/src/server/management/route-registry.ts +6 -1
- package/src/server/management-api.ts +7 -0
- package/src/server/request-log.ts +4 -0
- package/src/server/responses/codex-ws-exchange.ts +90 -14
- package/src/server/responses/codex-ws-wire.ts +51 -1
- package/src/server/responses/compact.ts +8 -1
- package/src/server/responses/core.ts +188 -22
- package/src/server/responses/ws-upstream.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +261 -12
- package/src/server/zai-responses-startup.ts +21 -0
- package/src/types/config.ts +33 -3
- package/src/types/provider.ts +54 -4
- package/src/types/request.ts +1 -1
- package/src/types/tools.ts +36 -6
- package/src/update/job.ts +33 -11
- package/src/vision/plan.ts +40 -37
- package/src/web-search/index.ts +1 -0
- package/gui/dist/assets/index-BoBRSehJ.css +0 -1
- package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
package/src/router.ts
CHANGED
|
@@ -349,6 +349,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
349
349
|
const noTemperatureModels = mergeStringArray(registryEntry.noTemperatureModels, provider.noTemperatureModels);
|
|
350
350
|
const noTopPModels = mergeStringArray(registryEntry.noTopPModels, provider.noTopPModels);
|
|
351
351
|
const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);
|
|
352
|
+
const noJsonSchemaModels = mergeStringArray(registryEntry.noJsonSchemaModels, provider.noJsonSchemaModels);
|
|
352
353
|
const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels);
|
|
353
354
|
const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
|
|
354
355
|
const requiresReasoningPlaceholderModels = mergeStringArray(registryEntry.requiresReasoningPlaceholderModels, provider.requiresReasoningPlaceholderModels);
|
|
@@ -376,6 +377,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
376
377
|
...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined
|
|
377
378
|
? { responsesPath: registryEntry.responsesPath }
|
|
378
379
|
: {}),
|
|
380
|
+
...(provider.chatCompletionsPath === undefined && registryEntry.chatCompletionsPath !== undefined
|
|
381
|
+
? { chatCompletionsPath: registryEntry.chatCompletionsPath }
|
|
382
|
+
: {}),
|
|
379
383
|
...(provider.requiresAdjacentResponsesToolResults === undefined
|
|
380
384
|
&& registryEntry.requiresAdjacentResponsesToolResults !== undefined
|
|
381
385
|
? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults }
|
|
@@ -473,6 +477,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
473
477
|
...(noTemperatureModels ? { noTemperatureModels } : {}),
|
|
474
478
|
...(noTopPModels ? { noTopPModels } : {}),
|
|
475
479
|
...(noPenaltyModels ? { noPenaltyModels } : {}),
|
|
480
|
+
...(noJsonSchemaModels ? { noJsonSchemaModels } : {}),
|
|
476
481
|
...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}),
|
|
477
482
|
...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
|
|
478
483
|
...(requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels } : {}),
|
|
@@ -681,7 +686,7 @@ function routeModelInternal(
|
|
|
681
686
|
}
|
|
682
687
|
}
|
|
683
688
|
|
|
684
|
-
// 0. Explicit "<provider>/<model>" namespace (e.g. "opencode-go/deepseek-v4-
|
|
689
|
+
// 0. Explicit "<provider>/<model>" namespace (e.g. "opencode-go/deepseek-v4.1-flash").
|
|
685
690
|
// Only triggers when the prefix matches a CONFIGURED provider, so genuine
|
|
686
691
|
// slash-containing model ids (e.g. "anthropic/claude-...") fall through when
|
|
687
692
|
// no such provider exists.
|
|
@@ -14,6 +14,8 @@ export function upstreamProtocolForAdapter(adapter: string): string {
|
|
|
14
14
|
case "openai-chat":
|
|
15
15
|
case "command-code":
|
|
16
16
|
case "cursor":
|
|
17
|
+
case "devin-cli":
|
|
18
|
+
case "devin":
|
|
17
19
|
case "azure":
|
|
18
20
|
case "azure-openai":
|
|
19
21
|
case "kiro":
|
|
@@ -147,6 +149,7 @@ export function resolveProductionBehaviorValues(
|
|
|
147
149
|
"wire.adapter": behaviorRow("provider_config", adapter),
|
|
148
150
|
"wire.upstreamProtocol": behaviorRow("provider_config", upstreamProtocol),
|
|
149
151
|
"wire.responsesPath": behaviorRow("provider_config", effective.responsesPath ?? null),
|
|
152
|
+
"wire.chatCompletionsPath": behaviorRow("provider_config", effective.chatCompletionsPath ?? null),
|
|
150
153
|
"wire.commandCodeVersion": behaviorRow("provider_config", effective.commandCodeVersion ?? null),
|
|
151
154
|
"wire.modelSuffixMode": behaviorRow(
|
|
152
155
|
"provider_config",
|
|
@@ -48,6 +48,10 @@ export function resolveWireProtocolOverride(
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** Build the provider adapter for a resolved provider config. */
|
|
51
|
-
export function resolveAdapter(
|
|
52
|
-
|
|
51
|
+
export function resolveAdapter(
|
|
52
|
+
providerConfig: OcxProviderConfig,
|
|
53
|
+
cacheRetention?: "none" | "short" | "long",
|
|
54
|
+
providerId?: string,
|
|
55
|
+
) {
|
|
56
|
+
return createRegisteredAdapter(providerConfig, { cacheRetention, ...(providerId ? { providerId } : {}) });
|
|
53
57
|
}
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { initialModelSelection } from "../providers/initial-model-selection";
|
|
3
3
|
import { extractAccountId } from "../oauth/chatgpt";
|
|
4
4
|
import { formatErrorResponse } from "../bridge";
|
|
@@ -353,9 +353,29 @@ function secretEquals(actual: string, expected: string | undefined): boolean {
|
|
|
353
353
|
*/
|
|
354
354
|
export type DataPlaneAdmissionSource = "loopback" | "dedicated" | "bearer" | "x-api-key";
|
|
355
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Process-local, salted identity of the admission secret that was actually matched.
|
|
358
|
+
*
|
|
359
|
+
* Context-relay ownership is partitioned by this value, so two operators holding different
|
|
360
|
+
* keys cannot reach each other's sessions even when both resolve to the same upstream
|
|
361
|
+
* workspace. `source` is deliberately excluded: the same key arriving as a bearer or in the
|
|
362
|
+
* dedicated header is one principal. Rotating or replacing a secret mints a new principal and
|
|
363
|
+
* drops continuity, which is the safe direction — a reused key id or a replaced environment
|
|
364
|
+
* secret must not inherit the previous holder's sessions. Loopback admission carries no caller
|
|
365
|
+
* identity and mints nothing, so the relay refuses it rather than treating every local process
|
|
366
|
+
* as one user.
|
|
367
|
+
*/
|
|
368
|
+
const CONTEXT_PRINCIPAL_SALT = randomBytes(32);
|
|
369
|
+
|
|
370
|
+
function mintContextPrincipal(kind: string, keyId: string, credential: string): string {
|
|
371
|
+
return createHmac("sha256", CONTEXT_PRINCIPAL_SALT)
|
|
372
|
+
.update(kind).update("\0").update(keyId).update("\0").update(credential)
|
|
373
|
+
.digest("hex");
|
|
374
|
+
}
|
|
375
|
+
|
|
356
376
|
export type DataPlaneAdmission =
|
|
357
|
-
| { kind: "configured"; keyId: string; source: DataPlaneAdmissionSource }
|
|
358
|
-
| { kind: "environment"; source: DataPlaneAdmissionSource }
|
|
377
|
+
| { kind: "configured"; keyId: string; source: DataPlaneAdmissionSource; contextPrincipalId?: string }
|
|
378
|
+
| { kind: "environment"; source: DataPlaneAdmissionSource; contextPrincipalId?: string }
|
|
359
379
|
| { kind: "loopback"; source: "loopback" };
|
|
360
380
|
|
|
361
381
|
/**
|
|
@@ -374,17 +394,52 @@ export function resolveDataPlaneAdmissionSecret(
|
|
|
374
394
|
): DataPlaneAdmission | null {
|
|
375
395
|
const actual = token.trim();
|
|
376
396
|
if (!actual) return null;
|
|
377
|
-
if (secretEquals(actual, configuredApiAuthToken(config)))
|
|
397
|
+
if (secretEquals(actual, configuredApiAuthToken(config))) {
|
|
398
|
+
return { kind: "environment", source, contextPrincipalId: mintContextPrincipal("environment", "", actual) };
|
|
399
|
+
}
|
|
378
400
|
for (const k of config.apiKeys ?? []) {
|
|
379
|
-
if (secretEquals(actual, k.key))
|
|
401
|
+
if (secretEquals(actual, k.key)) {
|
|
402
|
+
return { kind: "configured", keyId: k.id, source, contextPrincipalId: mintContextPrincipal("configured", k.id, actual) };
|
|
403
|
+
}
|
|
380
404
|
const pending = k.pendingRotation;
|
|
381
405
|
if (pending && Date.parse(pending.expiresAt) > Date.now() && secretEquals(actual, pending.key)) {
|
|
382
|
-
return { kind: "configured", keyId: k.id, source };
|
|
406
|
+
return { kind: "configured", keyId: k.id, source, contextPrincipalId: mintContextPrincipal("configured", k.id, pending.key) };
|
|
383
407
|
}
|
|
384
408
|
}
|
|
385
409
|
return null;
|
|
386
410
|
}
|
|
387
411
|
|
|
412
|
+
/** The principal an authenticated admission belongs to, or undefined for loopback. */
|
|
413
|
+
export function contextPrincipalIdOf(admission: DataPlaneAdmission | undefined): string | undefined {
|
|
414
|
+
return admission && "contextPrincipalId" in admission ? admission.contextPrincipalId : undefined;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The caller principal for a context-relay request, which is a stricter question than admission.
|
|
419
|
+
*
|
|
420
|
+
* The default bind is loopback, where admission deliberately never reads a token, so an admitted
|
|
421
|
+
* request carries no caller identity. History ownership needs one, so the relay asks separately:
|
|
422
|
+
* a caller that presents a real opencodex API key gets that key’s principal even on loopback,
|
|
423
|
+
* and a caller that presents none gets nothing and is refused. This adds identity where the caller
|
|
424
|
+
* volunteered it; it does not admit anyone who was not already admitted, and it does not change
|
|
425
|
+
* which credential goes upstream.
|
|
426
|
+
*/
|
|
427
|
+
export function resolveContextPrincipal(req: Request, config: OcxConfig, admission: DataPlaneAdmission | undefined): string | undefined {
|
|
428
|
+
const named = contextPrincipalIdOf(admission);
|
|
429
|
+
if (named) return named;
|
|
430
|
+
if (admission?.kind !== "loopback") return undefined;
|
|
431
|
+
const dedicated = req.headers.get("x-opencodex-api-key")?.trim();
|
|
432
|
+
const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
433
|
+
const apiKey = req.headers.get("x-api-key")?.trim();
|
|
434
|
+
for (const [token, source] of [[dedicated, "dedicated"], [bearer, "bearer"], [apiKey, "x-api-key"]] as const) {
|
|
435
|
+
if (!token) continue;
|
|
436
|
+
const resolved = resolveDataPlaneAdmissionSecret(token, config, source);
|
|
437
|
+
const principal = contextPrincipalIdOf(resolved ?? undefined);
|
|
438
|
+
if (principal) return principal;
|
|
439
|
+
}
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
|
|
388
443
|
/** Whether `token` is a data-plane admission secret. */
|
|
389
444
|
export function isDataPlaneAdmissionSecret(token: string, config: OcxConfig): boolean {
|
|
390
445
|
return resolveDataPlaneAdmissionSecret(token, config) !== null;
|
|
@@ -712,6 +767,11 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
712
767
|
"noStructuredOutputModels",
|
|
713
768
|
);
|
|
714
769
|
if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`;
|
|
770
|
+
const jsonSchemaOptOutError = nonBlankStringArrayConfigError(
|
|
771
|
+
raw.noJsonSchemaModels,
|
|
772
|
+
"noJsonSchemaModels",
|
|
773
|
+
);
|
|
774
|
+
if (jsonSchemaOptOutError) return `provider ${name} ${jsonSchemaOptOutError}`;
|
|
715
775
|
const retainModelsError = nonBlankStringArrayConfigError(raw.retainModels, "retainModels");
|
|
716
776
|
if (retainModelsError) return `provider ${name} ${retainModelsError}`;
|
|
717
777
|
const toolReasoningOptOutError = nonBlankStringArrayConfigError(
|
|
@@ -794,6 +854,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
|
|
|
794
854
|
fastWire: "editor",
|
|
795
855
|
baseUrl: "editor",
|
|
796
856
|
responsesPath: "editor",
|
|
857
|
+
chatCompletionsPath: "editor",
|
|
797
858
|
commandCodeVersion: "editor",
|
|
798
859
|
statelessResponses: "editor",
|
|
799
860
|
requiresAdjacentResponsesToolResults: "editor",
|
|
@@ -812,6 +873,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
|
|
|
812
873
|
apiKey: "redacted",
|
|
813
874
|
apiKeyTransport: "editor",
|
|
814
875
|
apiKeyPool: "redacted",
|
|
876
|
+
// Ordering preference only; it names no key material, so an editor may read and set it.
|
|
877
|
+
apiKeyPoolStrategy: "editor",
|
|
815
878
|
apiKeySelectionRevision: "runtime",
|
|
816
879
|
_apiKeyAttempt: "runtime",
|
|
817
880
|
defaultModel: "editor",
|
|
@@ -855,6 +918,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
|
|
|
855
918
|
supportsOpenAiWebSearchToolFields: "editor",
|
|
856
919
|
xaiResponsesXSearch: "editor",
|
|
857
920
|
xaiResponsesDefaultVersion: "runtime",
|
|
921
|
+
zaiResponsesDefaultVersion: "runtime",
|
|
858
922
|
supportsResponsesCustomTools: "editor",
|
|
859
923
|
responsesSnapshotRepair: "editor",
|
|
860
924
|
webSearchBridge: "editor",
|
|
@@ -866,6 +930,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
|
|
|
866
930
|
noTopPModels: "editor",
|
|
867
931
|
noPenaltyModels: "editor",
|
|
868
932
|
noStructuredOutputModels: "editor",
|
|
933
|
+
noJsonSchemaModels: "editor",
|
|
869
934
|
omitReasoningEffortWithToolsModels: "editor",
|
|
870
935
|
parallelToolCalls: "editor",
|
|
871
936
|
pinParallelToolCallsFalse: "editor",
|
|
@@ -260,9 +260,9 @@ async function handleChatCompletionsWithBudget(
|
|
|
260
260
|
const value = req.headers.get(name);
|
|
261
261
|
if (value) headers.set(name, value);
|
|
262
262
|
}
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
if (!callerAuthorizationRoute
|
|
263
|
+
// Existing primary enrichment stays on non-caller-auth routes. Caller-auth routes defer
|
|
264
|
+
// optional stored sidecar auth until the final helper plan actually needs it.
|
|
265
|
+
if (!callerAuthorizationRoute) {
|
|
266
266
|
// This enrichment is optional for routed/non-main providers. If native main
|
|
267
267
|
// is fenced, omit it and let auth-context reject only a final physical-main
|
|
268
268
|
// selection while healthy pool/provider routes continue.
|
|
@@ -317,6 +317,8 @@ async function handleChatCompletionsWithBudget(
|
|
|
317
317
|
};
|
|
318
318
|
const upstream = await handleResponses(internalReq, config, logCtx, {
|
|
319
319
|
openAiSidecarAuth,
|
|
320
|
+
allowStoredOpenAiSidecarAuth: !!(callerAuthorizationRoute && settledRoute
|
|
321
|
+
&& !isCanonicalOpenAiForwardProvider(settledRoute.provider)),
|
|
320
322
|
nativeCallerAuth,
|
|
321
323
|
callerDirectAuth,
|
|
322
324
|
...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}),
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "../lib/translator-budget";
|
|
34
34
|
import {
|
|
35
35
|
hasKeyPoolFailover,
|
|
36
|
+
selectProactiveApiKeyTransport,
|
|
36
37
|
rateLimitRetryDelayMs,
|
|
37
38
|
rateLimitRetryPolicyFor,
|
|
38
39
|
rotateProviderTransportOn429,
|
|
@@ -235,6 +236,14 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
235
236
|
unregisterTurn(upstream);
|
|
236
237
|
};
|
|
237
238
|
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
239
|
+
// Native chat is its own entry path -- chat-completions.ts routes here directly and never
|
|
240
|
+
// through the Responses core -- so the pre-dispatch key preference is applied again here
|
|
241
|
+
// rather than inherited. Assigned before the adapter binds below, for the same reason it is
|
|
242
|
+
// assigned before the transport pin in core.ts.
|
|
243
|
+
// Transport variant: the bare picker answers with the persisted row, which for a built-in
|
|
244
|
+
// provider carries no adapter id or base URL until routedProviderConfig backfills it.
|
|
245
|
+
const proactiveKeyProvider = selectProactiveApiKeyTransport(config, route.providerName, route.provider);
|
|
246
|
+
if (proactiveKeyProvider) route.provider = proactiveKeyProvider;
|
|
238
247
|
let activeProvider: OcxProviderConfig = route.provider;
|
|
239
248
|
let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider);
|
|
240
249
|
let activeRequest: AdapterRequest;
|
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
import { clearableDeadline, idleDeadline } from "../lib/abort";
|
|
31
31
|
import { estimateTokens } from "../lib/token-estimate";
|
|
32
32
|
import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
|
|
33
|
-
import { registryEntryForProviderDestination } from "../providers/registry";
|
|
34
33
|
import { evidenceFromBody } from "../routing/request-evidence";
|
|
35
34
|
import { resolveWireProtocolOverride } from "./adapter-resolve";
|
|
36
35
|
import type { OcxConfig } from "../types";
|
|
@@ -38,6 +37,7 @@ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-dec
|
|
|
38
37
|
import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
|
|
39
38
|
import {
|
|
40
39
|
conversationIdFromClaudeMetadata,
|
|
40
|
+
getOrAllocateRequestSessionLane,
|
|
41
41
|
linkRequestSessionLane,
|
|
42
42
|
normalizeLogConversationId,
|
|
43
43
|
sessionLaneIdFromRequest,
|
|
@@ -637,6 +637,12 @@ export async function handleClaudeMessages(
|
|
|
637
637
|
}
|
|
638
638
|
}
|
|
639
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Translate a Claude Messages request, route it through the Responses pipeline,
|
|
642
|
+
* and translate the reply back. Runs under a translator budget owned by the
|
|
643
|
+
* caller; Go session affinity is derived here and handed to the final Go
|
|
644
|
+
* transport out of band rather than through replay headers.
|
|
645
|
+
*/
|
|
640
646
|
async function handleClaudeMessagesWithBudget(
|
|
641
647
|
req: Request,
|
|
642
648
|
config: OcxConfig,
|
|
@@ -791,19 +797,13 @@ async function handleClaudeMessagesWithBudget(
|
|
|
791
797
|
// Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped
|
|
792
798
|
// bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens",
|
|
793
799
|
// verified live 2026-07-11). Strip them for that route; routed providers keep them.
|
|
794
|
-
let nativeRoute = false;
|
|
795
|
-
let opencodeGoRoute = false;
|
|
796
800
|
try {
|
|
797
801
|
const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody));
|
|
798
|
-
// Match the fixed key-auth destination before per-model wire overrides, including
|
|
799
|
-
// renamed Go providers without treating custom or lookalike URLs as Go.
|
|
800
|
-
opencodeGoRoute = registryEntryForProviderDestination(route.provider)?.id === "opencode-go";
|
|
801
802
|
// Settle the wire once so the sampling decision below reads the effective
|
|
802
803
|
// adapter rather than the provider-wide default (#404).
|
|
803
804
|
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic");
|
|
804
805
|
logCtx.routeDecision = route.routeDecision;
|
|
805
806
|
if (route.provider.adapter === "openai-responses") {
|
|
806
|
-
nativeRoute = true;
|
|
807
807
|
delete internalBody.max_output_tokens;
|
|
808
808
|
delete internalBody.temperature;
|
|
809
809
|
delete internalBody.top_p;
|
|
@@ -867,30 +867,23 @@ async function handleClaudeMessagesWithBudget(
|
|
|
867
867
|
};
|
|
868
868
|
}
|
|
869
869
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
const hasExplicitGoSession = opencodeGoRoute
|
|
875
|
-
&& (sessionLaneIdFromRequest(headers) !== undefined
|
|
876
|
-
|| normalizeLogConversationId(headers.get("x-opencode-session")) !== undefined);
|
|
877
|
-
const synthesizeGoSession = opencodeGoRoute && !hasExplicitGoSession
|
|
870
|
+
// Carry Go identity out of band: a combo's preflight target may differ from its
|
|
871
|
+
// actual dispatch/fallback target. Never add Go-only identity to replay headers.
|
|
872
|
+
const claudeNativeSessionId = cacheKeySource === "metadata"
|
|
873
|
+
&& typeof internalBody.prompt_cache_key === "string"
|
|
878
874
|
&& isRec(anthropicBody)
|
|
879
|
-
&& conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key));
|
|
892
|
-
}
|
|
893
|
-
}
|
|
875
|
+
&& conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined
|
|
876
|
+
? uuidFromHex(internalBody.prompt_cache_key)
|
|
877
|
+
: undefined;
|
|
878
|
+
const metadataGoLane = normalizeLogConversationId(claudeNativeSessionId);
|
|
879
|
+
// Without any valid conversation identity, fall back to the request-scoped lane
|
|
880
|
+
// allocated on the admitted client request (#4172): stable across retries and
|
|
881
|
+
// route reconstruction, distinct per request, and never derived from a shared
|
|
882
|
+
// system-prompt cache key or from a later synthesized native session_id header.
|
|
883
|
+
const claudeGoSessionLane = sessionLaneIdFromRequest(headers)
|
|
884
|
+
?? normalizeLogConversationId(req.headers.get("x-opencode-session"))
|
|
885
|
+
?? metadataGoLane
|
|
886
|
+
?? getOrAllocateRequestSessionLane(req);
|
|
894
887
|
let internalReq: Request;
|
|
895
888
|
try {
|
|
896
889
|
// The UTF-16 JSON string and the Request's UTF-8 body coexist until dispatch.
|
|
@@ -934,6 +927,8 @@ async function handleClaudeMessagesWithBudget(
|
|
|
934
927
|
// Without this the replay would look native and a Responses-scoped wire default
|
|
935
928
|
// would fire, disagreeing with the pre-flight decision above.
|
|
936
929
|
inboundWire: "anthropic",
|
|
930
|
+
claudeGoAffinity: { sessionLane: claudeGoSessionLane },
|
|
931
|
+
claudeNativeSessionId,
|
|
937
932
|
stripClaudeMainAuthForNoncanonicalForward: true,
|
|
938
933
|
...(trustedClaudeMainAuth ? { trustedClaudeMainAuth } : {}),
|
|
939
934
|
// Claude's internal stored-main enrichment is not an original caller credential.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/** Native history/notes JSON relay. No interpretation of encrypted tool arguments or retries. */
|
|
2
|
+
import { formatErrorResponse } from "../bridge";
|
|
3
|
+
import {
|
|
4
|
+
CodexAccountCooldownError, CodexAuthContextError, CodexMainProfileDrainingError, CodexDirectAuthenticationError,
|
|
5
|
+
CodexPoolAuthenticationError, CodexThreadAffinityExpiredError, CodexMainSubstitutionUnavailableError,
|
|
6
|
+
codexMainProfileDrainingResponse, cooldownErrorResponse,
|
|
7
|
+
materializeCodexUpstreamAuth, isCodexAuthContextUsable, resolveCodexAuthContext, releaseCodexAuthContextProbeLease,
|
|
8
|
+
} from "../codex/auth-context";
|
|
9
|
+
import { getContextSessionOwner, contextSessionOwnerMatches } from "../codex/context-owner";
|
|
10
|
+
import { contextEndpoint, contextRelayActivated } from "../codex/context-compat";
|
|
11
|
+
import { formatCodexProviderForLog } from "../codex/routing";
|
|
12
|
+
import { listOpenAiForwardSidecarCandidates } from "../providers/openai-sidecar";
|
|
13
|
+
import { clearableDeadline, type ClearableDeadline } from "../lib/abort";
|
|
14
|
+
import { readBoundedResponseBytes } from "../lib/bounded-body";
|
|
15
|
+
import type { AdmissionLease } from "../lib/admission";
|
|
16
|
+
import type { OcxConfig } from "../types";
|
|
17
|
+
import { resolveContextPrincipal, ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "./auth-cors";
|
|
18
|
+
import { readBoundedJsonRequestBody } from "./request-decompress";
|
|
19
|
+
import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
|
|
20
|
+
import { codexAccountSelectionForTurn } from "./lifecycle";
|
|
21
|
+
import type { RequestLogContext } from "./request-log";
|
|
22
|
+
|
|
23
|
+
const PROTOCOL_HEADERS = ["x-openai-encrypted-tool-arguments", "x-openai-tool-output-truncation-policy"];
|
|
24
|
+
const RESPONSE_HEADERS = ["content-type", "retry-after", "x-request-id", "openai-processing-ms", ...PROTOCOL_HEADERS];
|
|
25
|
+
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
26
|
+
const MAX_REQUEST_BYTES = 16 * 1024 * 1024;
|
|
27
|
+
const RELAY_DEADLINE_MS = 35_000;
|
|
28
|
+
|
|
29
|
+
export function contextSelectionHeaders(headers: Headers, sessionId: string): Headers {
|
|
30
|
+
const result = new Headers(headers);
|
|
31
|
+
// Codex history tools carry root session_id in JSON, unlike Responses' HTTP headers.
|
|
32
|
+
// Root model requests use (session-id=root, thread-id=root); don't fabricate a parent key.
|
|
33
|
+
if (!result.has("x-codex-parent-thread-id") && !result.has("session-id") && !result.has("thread-id")) {
|
|
34
|
+
result.set("session-id", sessionId);
|
|
35
|
+
result.set("thread-id", sessionId);
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One deadline covers the whole operation, starting before the body is read.
|
|
42
|
+
*
|
|
43
|
+
* Reading the body and selecting a credential are both waits a caller controls, and this route
|
|
44
|
+
* holds an admitted turn slot for their duration. A deadline that started at dispatch would let
|
|
45
|
+
* an unfinished body or a stalled refresh hold that slot with no bound at all.
|
|
46
|
+
*/
|
|
47
|
+
export async function handleContextHistory(
|
|
48
|
+
req: Request, config: OcxConfig, logCtx: RequestLogContext,
|
|
49
|
+
endpoint: string, turnAdmissionLease?: AdmissionLease, admission?: DataPlaneAdmission,
|
|
50
|
+
revalidateAdmission?: () => DataPlaneAdmission | null,
|
|
51
|
+
): Promise<Response> {
|
|
52
|
+
const deadline = clearableDeadline(RELAY_DEADLINE_MS, req.signal);
|
|
53
|
+
try {
|
|
54
|
+
return await relayContextHistory(req, config, logCtx, endpoint, deadline, turnAdmissionLease,
|
|
55
|
+
admission, revalidateAdmission);
|
|
56
|
+
} finally {
|
|
57
|
+
deadline.clear();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function relayContextHistory(
|
|
62
|
+
req: Request, config: OcxConfig, logCtx: RequestLogContext,
|
|
63
|
+
endpoint: string, deadline: ClearableDeadline,
|
|
64
|
+
turnAdmissionLease?: AdmissionLease, admission?: DataPlaneAdmission,
|
|
65
|
+
revalidateAdmission?: () => DataPlaneAdmission | null,
|
|
66
|
+
): Promise<Response> {
|
|
67
|
+
// The feature is opt-in, and the opt-in has to hold here rather than only where the injected
|
|
68
|
+
// base URL is rewritten: a caller that can reach the data plane can POST these paths directly.
|
|
69
|
+
// While it is off the endpoints do not exist, which is also what a disabled route should look
|
|
70
|
+
// like from outside.
|
|
71
|
+
if (!contextEndpoint("/v1/" + endpoint) || req.method !== "POST" || !contextRelayActivated()) {
|
|
72
|
+
return formatErrorResponse(404, "not_found", "Unknown context endpoint");
|
|
73
|
+
}
|
|
74
|
+
// Only a trusted listener admission authorizes replacing a proxy bearer with Codex auth.
|
|
75
|
+
const substituteMainCredential = admission?.source === "bearer";
|
|
76
|
+
try { if (!substituteMainCredential) validateForwardAdmissionCredential(req.headers, config); }
|
|
77
|
+
catch (err) {
|
|
78
|
+
if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message);
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
// A workspace is not a person and a local socket is not a caller. Ownership is keyed by the
|
|
82
|
+
// admission secret that was actually matched, so an admission that carries no principal —
|
|
83
|
+
// loopback — cannot own or reach a session.
|
|
84
|
+
const principalId = resolveContextPrincipal(req, config, admission);
|
|
85
|
+
if (!principalId) {
|
|
86
|
+
return formatErrorResponse(403, "context_principal_required",
|
|
87
|
+
"Context history requires an opencodex API key on the request; admission alone carries no caller identity");
|
|
88
|
+
}
|
|
89
|
+
let body: unknown;
|
|
90
|
+
try { body = await readBoundedJsonRequestBody(req, MAX_REQUEST_BYTES, undefined, { signal: deadline.signal }); }
|
|
91
|
+
catch (err) {
|
|
92
|
+
const cancelled = cancellationResponse(req, deadline);
|
|
93
|
+
if (cancelled) return cancelled;
|
|
94
|
+
return decodeRequestErrorResponse(err, "context_history");
|
|
95
|
+
}
|
|
96
|
+
const sessionId = (body as {context?: {session_id?: unknown}} | null)?.context?.session_id;
|
|
97
|
+
if (typeof sessionId !== "string" || !/^[A-Za-z0-9._:-]{1,512}$/.test(sessionId)) {
|
|
98
|
+
return formatErrorResponse(400, "invalid_request_error", "context.session_id must be a bounded nonempty string");
|
|
99
|
+
}
|
|
100
|
+
const candidate = listOpenAiForwardSidecarCandidates(config)[0];
|
|
101
|
+
if (!candidate) return formatErrorResponse(400, "invalid_request_error", "History and notes require the native ChatGPT forward provider");
|
|
102
|
+
const rootHeader = req.headers.get("x-codex-parent-thread-id")?.trim() || req.headers.get("session-id")?.trim();
|
|
103
|
+
if (rootHeader && rootHeader !== sessionId) {
|
|
104
|
+
return formatErrorResponse(409, "context_account_unavailable", "Context root does not match this request");
|
|
105
|
+
}
|
|
106
|
+
const owner = getContextSessionOwner(principalId, sessionId, candidate.provider.baseUrl);
|
|
107
|
+
if (!owner || owner.ambiguous || (owner.kind === "caller" && substituteMainCredential)) {
|
|
108
|
+
return formatErrorResponse(409, "context_account_unavailable",
|
|
109
|
+
"Context account ownership is unavailable; start a new session and preserve needed state before resetting context");
|
|
110
|
+
}
|
|
111
|
+
let authContext: Awaited<ReturnType<typeof resolveCodexAuthContext>>;
|
|
112
|
+
const headers = new Headers(candidate.provider.headers);
|
|
113
|
+
try {
|
|
114
|
+
authContext = await resolveCodexAuthContext(contextSelectionHeaders(req.headers, sessionId), config, owner.kind === "stored" ? "pool" : "direct", {
|
|
115
|
+
// The same deadline bounds credential selection and refresh waits.
|
|
116
|
+
signal: deadline.signal,
|
|
117
|
+
// Resolve the proven physical owner as an explicit account. Context operations
|
|
118
|
+
// never create affinity, rotate on quota, or inspect file-main for a caller owner.
|
|
119
|
+
modelId: "context_history",
|
|
120
|
+
...(owner.kind === "stored" ? { accountId: owner.accountId } : { requestScopedMainCredential: true }),
|
|
121
|
+
admission,
|
|
122
|
+
substituteMainCredentialForDirect: substituteMainCredential,
|
|
123
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
|
|
124
|
+
});
|
|
125
|
+
if (authContext.kind !== "main" && authContext.probeLeaseId) {
|
|
126
|
+
// History traffic must not occupy or settle the model's quota-recovery probe.
|
|
127
|
+
releaseCodexAuthContextProbeLease(authContext);
|
|
128
|
+
return formatErrorResponse(503, "upstream_error", "Model quota recovery is pending; retry context operation later");
|
|
129
|
+
}
|
|
130
|
+
if (!isCodexAuthContextUsable(authContext, config)) throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
|
|
131
|
+
logCtx.provider = formatCodexProviderForLog(candidate.providerName, codexLogAccountId(authContext), config);
|
|
132
|
+
// Materialization rechecks the current account policy after async selection.
|
|
133
|
+
// Synthetic lane IDs are local selection metadata, never upstream headers.
|
|
134
|
+
for (const [key, value] of materializeCodexUpstreamAuth(req.headers, authContext, {
|
|
135
|
+
config, modelId: "context_history", admission, substituteMainCredential,
|
|
136
|
+
})) {
|
|
137
|
+
headers.set(key, value);
|
|
138
|
+
}
|
|
139
|
+
// Check the assembled outbound headers, including configured provider headers.
|
|
140
|
+
validateForwardAdmissionCredential(headers, config);
|
|
141
|
+
// Recheck actual wire identity after async selection/materialization. A replaced
|
|
142
|
+
// account slot or login must not receive another physical account's history.
|
|
143
|
+
const currentOwner = getContextSessionOwner(principalId, sessionId, candidate.provider.baseUrl);
|
|
144
|
+
if (!currentOwner || currentOwner.kind !== owner.kind
|
|
145
|
+
|| !contextSessionOwnerMatches(owner, headers) || !contextSessionOwnerMatches(currentOwner, headers)) {
|
|
146
|
+
return formatErrorResponse(409, "context_account_unavailable", "Context account identity changed; start a new session");
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
const cancelled = cancellationResponse(req, deadline);
|
|
150
|
+
if (cancelled) return cancelled;
|
|
151
|
+
if (err instanceof CodexAccountCooldownError) return cooldownErrorResponse(err);
|
|
152
|
+
if (err instanceof CodexMainProfileDrainingError) return codexMainProfileDrainingResponse();
|
|
153
|
+
if (err instanceof CodexThreadAffinityExpiredError) return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
|
|
154
|
+
if (err instanceof CodexAuthContextError || err instanceof CodexPoolAuthenticationError || err instanceof CodexDirectAuthenticationError
|
|
155
|
+
|| err instanceof CodexMainSubstitutionUnavailableError || err instanceof ForwardAdmissionCredentialError) {
|
|
156
|
+
return formatErrorResponse(401, "authentication_error", "Selected Codex account is unavailable or needs reauthentication");
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
headers.set("content-type", "application/json");
|
|
161
|
+
for (const key of PROTOCOL_HEADERS) {
|
|
162
|
+
const value = req.headers.get(key); if (value !== null) headers.set(key, value);
|
|
163
|
+
}
|
|
164
|
+
// Nothing is dispatched after cancellation; a notes write is not replayable.
|
|
165
|
+
const cancelledBeforeDispatch = cancellationResponse(req, deadline);
|
|
166
|
+
if (cancelledBeforeDispatch) return cancelledBeforeDispatch;
|
|
167
|
+
// Body reading and credential selection are both waits, and a key can be revoked, rotated or
|
|
168
|
+
// replaced during them. Re-resolve admission against the receiving listener policy and require
|
|
169
|
+
// the same principal, so a withdrawn key cannot dispatch on a snapshot taken minutes earlier.
|
|
170
|
+
if (revalidateAdmission && resolveContextPrincipal(req, config, revalidateAdmission() ?? undefined) !== principalId) {
|
|
171
|
+
return formatErrorResponse(401, "authentication_error",
|
|
172
|
+
"opencodex API key changed during this request; retry with current credentials");
|
|
173
|
+
}
|
|
174
|
+
// The operator may disable the feature while body or credential IO is pending.
|
|
175
|
+
if (!contextRelayActivated()) return formatErrorResponse(404, "not_found", "Unknown context endpoint");
|
|
176
|
+
let response: Response | undefined;
|
|
177
|
+
try {
|
|
178
|
+
response = await fetch(`${candidate.provider.baseUrl}/${endpoint}`, {
|
|
179
|
+
method: "POST", headers, body: JSON.stringify(body), signal: deadline.signal, redirect: "manual",
|
|
180
|
+
});
|
|
181
|
+
const result = await readBoundedResponseBytes(response, {maxBytes: MAX_RESPONSE_BYTES, signal: deadline.signal});
|
|
182
|
+
if (result.oversized) return formatErrorResponse(502, "upstream_error", "Context response exceeded 16 MiB");
|
|
183
|
+
const outputHeaders = new Headers();
|
|
184
|
+
for (const key of RESPONSE_HEADERS) {
|
|
185
|
+
const value = response.headers.get(key); if (value !== null) outputHeaders.set(key, value);
|
|
186
|
+
}
|
|
187
|
+
// A context 403 is not evidence that the model credential is invalid. Don't mutate pool
|
|
188
|
+
// health/quota or retry writes; preserve the real upstream result for the caller.
|
|
189
|
+
return new Response([204,205,304].includes(response.status) ? null : result.bytes, {status:response.status, headers:outputHeaders});
|
|
190
|
+
} catch {
|
|
191
|
+
return cancellationResponse(req, deadline)
|
|
192
|
+
?? formatErrorResponse(502, "upstream_error", "Context upstream connection failed");
|
|
193
|
+
} finally {
|
|
194
|
+
if (response?.body && !response.body.locked) void response.body.cancel().catch(() => undefined);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Client cancellation and deadline expiry are different outcomes and must not share a status. */
|
|
199
|
+
function cancellationResponse(req: Request, deadline: ClearableDeadline): Response | undefined {
|
|
200
|
+
if (req.signal.aborted) {
|
|
201
|
+
return formatErrorResponse(499, "client_closed_request", "Context request canceled by client");
|
|
202
|
+
}
|
|
203
|
+
if (deadline.didExpire()) {
|
|
204
|
+
return formatErrorResponse(504, "upstream_error", "Context operation exceeded its deadline");
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
package/src/server/images.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { readBoundedResponseBytes, type BoundedBytesResult } from "../lib/bounde
|
|
|
28
28
|
import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
29
29
|
import type { OcxConfig } from "../types";
|
|
30
30
|
import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar";
|
|
31
|
+
import { selectProactiveApiKeyTransport } from "../providers/key-failover";
|
|
31
32
|
import { getProviderRegistryEntry } from "../providers/registry";
|
|
32
33
|
import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
|
|
33
34
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
|
|
@@ -698,7 +699,33 @@ export async function handleImages(
|
|
|
698
699
|
// Do not hide a broken/expired pool behind separately billed API-key image generation.
|
|
699
700
|
return forwardAuthError;
|
|
700
701
|
} else if (candidates.keyed) {
|
|
701
|
-
const {
|
|
702
|
+
const { providerName } = candidates.keyed;
|
|
703
|
+
// The keyed image path builds its own URL and Authorization header and never enters
|
|
704
|
+
// handleResponses, so the pre-dispatch key pick happens here.
|
|
705
|
+
//
|
|
706
|
+
// Two things about the placement. It stays INSIDE this branch because higher up it would
|
|
707
|
+
// also run for requests ChatGPT forward goes on to serve, spending a rotation on a path
|
|
708
|
+
// that never used the key. And the header is rebuilt from the returned route rather than
|
|
709
|
+
// from candidates.keyed.apiKey, which is a snapshot resolved earlier: reusing it would
|
|
710
|
+
// send the OLD key while the picker had already persisted the new one.
|
|
711
|
+
//
|
|
712
|
+
// Transport variant: this branch reads `provider.baseUrl` and `provider.headers` to build
|
|
713
|
+
// the URL and the request, and it comes back with the credential already resolved.
|
|
714
|
+
const warmKeyProvider = selectProactiveApiKeyTransport(config, providerName, candidates.keyed.provider);
|
|
715
|
+
const provider = warmKeyProvider ?? candidates.keyed.provider;
|
|
716
|
+
// No fall back to the earlier snapshot once a pick has happened. The picker COMMITS its
|
|
717
|
+
// choice before returning, so if the chosen reference will not resolve -- revoked keychain
|
|
718
|
+
// entry, unset env var -- sending the previous key would authenticate a non-idempotent
|
|
719
|
+
// POST with a credential the config no longer considers active, and the previous key is
|
|
720
|
+
// the one that was cooling. Fail loudly instead.
|
|
721
|
+
if (warmKeyProvider && !warmKeyProvider.apiKey?.trim()) {
|
|
722
|
+
return formatErrorResponse(
|
|
723
|
+
500,
|
|
724
|
+
"configuration_error",
|
|
725
|
+
`image generation selected an API key for "${providerName}" that cannot be resolved`,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
const apiKey = warmKeyProvider?.apiKey ?? candidates.keyed.apiKey;
|
|
702
729
|
if (provider.headers) Object.assign(headers, provider.headers);
|
|
703
730
|
headers["authorization"] = `Bearer ${apiKey}`;
|
|
704
731
|
logCtx.provider = providerName;
|