@bitkyc08/opencodex 2.58.0 → 2.60.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/README.md +28 -10
- package/gui/dist/assets/index-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +34 -1
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +21 -4
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +45 -2
- package/src/adapters/devin/cloud-direct/chat.ts +14 -3
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
- package/src/adapters/devin.ts +125 -26
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +55 -4
- package/src/adapters/google-http.ts +57 -11
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +60 -10
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +10 -11
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +25 -4
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +28 -35
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +26 -6
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/dispatch.ts +57 -3
- package/src/cli/doctor.ts +218 -4
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +95 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +2 -0
- package/src/client/machine-api.ts +6 -5
- package/src/client/machine-listener.ts +16 -3
- package/src/client/runtime.ts +26 -2
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +146 -5
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +44 -10
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +22 -2
- package/src/codex/catalog/model-hints.ts +66 -33
- package/src/codex/catalog/parsing.ts +90 -5
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +41 -26
- package/src/codex/catalog/routed-gather.ts +61 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +24 -167
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +80 -2
- package/src/codex/observed-model-denials.ts +230 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +289 -16
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +20 -16
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +114 -2
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +125 -5
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +43 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +33 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +31 -7
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +25 -0
- package/src/config/schema/leaf-validators.ts +76 -17
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +410 -258
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +82 -15
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +53 -5
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +741 -0
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +332 -35
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +41 -27
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +327 -25
- package/src/providers/model-rename-migration.ts +56 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +131 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +87 -21
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +32 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +9 -2
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +13 -5
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +30 -5
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +24 -25
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +48 -7
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +136 -34
- package/src/server/request-log.ts +80 -3
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +74 -30
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +63 -30
- package/src/server/responses/compact.ts +89 -18
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +90 -24
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +16 -15
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +41 -15
- package/src/server/responses/passthrough-dispatch.ts +145 -53
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +96 -17
- package/src/server/responses/request-send-budget.ts +89 -8
- package/src/server/responses/request-sidecar-auth.ts +17 -9
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +15 -12
- package/src/server/responses/run-turn-execution.ts +45 -8
- package/src/server/responses/sidecar-execution.ts +19 -2
- package/src/server/responses/ws-upstream.ts +16 -28
- package/src/server/responses-custom-tool-repair.ts +29 -56
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +37 -10
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +111 -6
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +9 -1
- package/src/types/tools.ts +14 -0
- package/src/types/wire.ts +9 -1
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
- package/gui/dist/assets/index-C5-RdDmD.css +0 -1
|
@@ -41,7 +41,7 @@ export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProv
|
|
|
41
41
|
export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast";
|
|
42
42
|
|
|
43
43
|
import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts";
|
|
44
|
-
import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF,
|
|
44
|
+
import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants";
|
|
45
45
|
import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, opencodeModelCapabilities, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata";
|
|
46
46
|
import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp";
|
|
47
47
|
import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh";
|
|
@@ -867,7 +867,7 @@ export interface GajaeModelEntry {
|
|
|
867
867
|
/** Gajae validates strictly: an unknown field fails the whole config. */
|
|
868
868
|
export interface GajaeProviderBlock {
|
|
869
869
|
baseUrl: string;
|
|
870
|
-
|
|
870
|
+
apiKey: string;
|
|
871
871
|
api: "openai-completions";
|
|
872
872
|
models: GajaeModelEntry[];
|
|
873
873
|
}
|
|
@@ -1056,7 +1056,7 @@ function buildGajaeClientConfig(ctx: ExportContext): GajaeGeneratedConfig {
|
|
|
1056
1056
|
providers: {
|
|
1057
1057
|
[OPENCODE_PROVIDER_ID]: {
|
|
1058
1058
|
baseUrl: ctx.baseUrl,
|
|
1059
|
-
|
|
1059
|
+
apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
|
|
1060
1060
|
api: "openai-completions",
|
|
1061
1061
|
models,
|
|
1062
1062
|
},
|
|
@@ -1320,8 +1320,8 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
|
|
|
1320
1320
|
id: "gajae",
|
|
1321
1321
|
filename: "gajae-models.yaml",
|
|
1322
1322
|
destination: env => gajaeConfigPath(env),
|
|
1323
|
-
apiKeyEnv:
|
|
1324
|
-
exportHint:
|
|
1323
|
+
apiKeyEnv: "",
|
|
1324
|
+
exportHint: "No environment variable is needed for the loopback provider.",
|
|
1325
1325
|
build: buildGajaeClientConfig,
|
|
1326
1326
|
format: "yaml",
|
|
1327
1327
|
summarize: summarizeGajae,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOrcaAuthSource } from "./orca-auth-source";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -52,11 +53,20 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
function isCredential(value: unknown): value is CodexAccountCredentials {
|
|
56
|
+
const hasNoSource = isObject(value)
|
|
57
|
+
&& value.sourceAuthPath === undefined
|
|
58
|
+
&& value.sourceSubject === undefined;
|
|
59
|
+
const hasCompleteSource = isObject(value)
|
|
60
|
+
&& typeof value.sourceAuthPath === "string"
|
|
61
|
+
&& value.sourceAuthPath.length > 0
|
|
62
|
+
&& typeof value.sourceSubject === "string"
|
|
63
|
+
&& value.sourceSubject.length > 0;
|
|
55
64
|
return isObject(value)
|
|
56
65
|
&& typeof value.accessToken === "string"
|
|
57
66
|
&& typeof value.refreshToken === "string"
|
|
58
67
|
&& typeof value.expiresAt === "number"
|
|
59
|
-
&& typeof value.chatgptAccountId === "string"
|
|
68
|
+
&& typeof value.chatgptAccountId === "string"
|
|
69
|
+
&& (hasNoSource || hasCompleteSource);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
function isCredentialRecord(value: unknown): value is CodexAccountCredentialRecord {
|
|
@@ -78,6 +88,7 @@ export function refreshGrantFingerprintForToken(refreshToken: string): string {
|
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
function recordGrantFingerprint(record: CodexAccountCredentialRecord): string | undefined {
|
|
91
|
+
if (record.credential?.sourceAuthPath) return undefined;
|
|
81
92
|
return record.refreshGrantFingerprint ?? (
|
|
82
93
|
record.credential ? refreshGrantFingerprintForToken(record.credential.refreshToken) : undefined
|
|
83
94
|
);
|
|
@@ -359,6 +370,23 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
|
|
|
359
370
|
return !!record?.credential && record.deletedAt == null && record.generation === generation;
|
|
360
371
|
}
|
|
361
372
|
|
|
373
|
+
/**
|
|
374
|
+
* The same verdict as {@link isCodexAccountGenerationLive}, over ONE store load.
|
|
375
|
+
*
|
|
376
|
+
* `readCodexAccountRecord` is `loadCodexAccountRecordStore()[id]`, so asking it per row
|
|
377
|
+
* reloads, reparses and renormalizes the whole file per row — the exact shape
|
|
378
|
+
* {@link loadCodexAccountRecordSnapshot} was added to avoid. The denial reader resolves
|
|
379
|
+
* several accounts in one synchronous pass on the request path, so it opens one checker and
|
|
380
|
+
* closes over the snapshot instead.
|
|
381
|
+
*/
|
|
382
|
+
export function beginCodexAccountGenerationLiveCheck(): (id: string, generation: number) => boolean {
|
|
383
|
+
const snapshot = loadCodexAccountRecordSnapshot();
|
|
384
|
+
return (id, generation) => {
|
|
385
|
+
const record = snapshot[id];
|
|
386
|
+
return !!record?.credential && record.deletedAt == null && record.generation === generation;
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
362
390
|
export function saveCodexAccountCredentialIfGeneration(
|
|
363
391
|
id: string,
|
|
364
392
|
generation: number,
|
|
@@ -426,7 +454,7 @@ export function commitRefreshedCodexCredentialWithAliases(
|
|
|
426
454
|
return withCredentialMutationLockSync(() => {
|
|
427
455
|
const store = loadCodexAccountRecordStore();
|
|
428
456
|
const current = store[id];
|
|
429
|
-
if (!current || current.generation !== generation || current.deletedAt != null || !current.credential) {
|
|
457
|
+
if (!current || current.generation !== generation || current.deletedAt != null || !current.credential || current.credential.sourceAuthPath) {
|
|
430
458
|
return { committed: false, propagatedAliases: [] };
|
|
431
459
|
}
|
|
432
460
|
const priorCredential = current.credential;
|
|
@@ -458,7 +486,7 @@ export function commitRefreshedCodexCredentialWithAliases(
|
|
|
458
486
|
&& priorCredential.chatgptAccountId === cred.chatgptAccountId
|
|
459
487
|
) {
|
|
460
488
|
for (const [aliasId, alias] of Object.entries(store)) {
|
|
461
|
-
if (aliasId === id || alias.deletedAt != null || !alias.credential) continue;
|
|
489
|
+
if (aliasId === id || alias.deletedAt != null || !alias.credential || alias.credential.sourceAuthPath) continue;
|
|
462
490
|
if (recordGrantFingerprint(alias) !== priorFingerprint) continue;
|
|
463
491
|
if (alias.credential.accessToken !== priorCredential.accessToken) continue;
|
|
464
492
|
if (alias.credential.expiresAt !== priorCredential.expiresAt) continue;
|
|
@@ -585,6 +613,41 @@ function withCredentialMutationLockSync<T>(fn: () => T): T {
|
|
|
585
613
|
}
|
|
586
614
|
|
|
587
615
|
type CodexTokenResult = { accessToken: string; chatgptAccountId: string; generation: number };
|
|
616
|
+
type CodexRefreshGenerationHandoff = (accountId: string, fromGeneration: number, toGeneration: number) => void;
|
|
617
|
+
// var (hoisted, initialized to undefined) rather than const: a registrar reached
|
|
618
|
+
// through an import cycle can register while this module body is still evaluating,
|
|
619
|
+
// and the lazily created Set must be reachable rather than in the temporal dead zone.
|
|
620
|
+
var refreshGenerationHandoffs: Set<CodexRefreshGenerationHandoff> | undefined;
|
|
621
|
+
function refreshHandoffs(): Set<CodexRefreshGenerationHandoff> {
|
|
622
|
+
return refreshGenerationHandoffs ??= new Set();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Register process-local state that must follow a credential refresh generation. */
|
|
626
|
+
export function registerCodexRefreshGenerationHandoff(handoff: CodexRefreshGenerationHandoff): () => void {
|
|
627
|
+
const set = refreshHandoffs();
|
|
628
|
+
set.add(handoff);
|
|
629
|
+
return () => set.delete(handoff);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Invoke every registered handoff for one committed `fromGeneration` to `toGeneration` move.
|
|
634
|
+
*
|
|
635
|
+
* Each listener runs independently and a throw is contained: by the time handoffs run the
|
|
636
|
+
* rotated credential is already persisted, so a failing listener must not reject the shared
|
|
637
|
+
* refresh promise (surviving waiters would see a refresh failure that never happened), must
|
|
638
|
+
* not starve the remaining listeners, and must not block plan reconciliation. The warning
|
|
639
|
+
* carries no account id or token material — the same scrub refresh error messages get.
|
|
640
|
+
*/
|
|
641
|
+
function dispatchRefreshGenerationHandoffs(accountId: string, fromGeneration: number, toGeneration: number): void {
|
|
642
|
+
for (const handoff of refreshHandoffs()) {
|
|
643
|
+
try {
|
|
644
|
+
handoff(accountId, fromGeneration, toGeneration);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
console.warn("[codex-auth] a refresh generation handoff listener failed", error);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
588
651
|
type CodexRefreshResult = CodexTokenResult & {
|
|
589
652
|
credential?: CodexAccountCredentials;
|
|
590
653
|
/**
|
|
@@ -630,7 +693,14 @@ export type CodexRefreshProvenance = "self-refresh" | "joined-lineage" | "extern
|
|
|
630
693
|
|
|
631
694
|
/** Terminal outcome of one forced refresh, as seen by the caller that requested it. */
|
|
632
695
|
export type ForcedRefreshOutcome =
|
|
633
|
-
| {
|
|
696
|
+
| {
|
|
697
|
+
kind: "resolved";
|
|
698
|
+
provenance: CodexRefreshProvenance;
|
|
699
|
+
generation: number;
|
|
700
|
+
rotated: boolean;
|
|
701
|
+
/** Same-grant records advanced by this refresh, at their committed generations. */
|
|
702
|
+
propagatedAliases?: { id: string; generation: number }[];
|
|
703
|
+
}
|
|
634
704
|
| { kind: "failed"; error: unknown };
|
|
635
705
|
const MAX_CODEX_REFRESH_FLIGHTS = 32;
|
|
636
706
|
const CODEX_REFRESH_FLIGHT_STALE_MS = 120_000;
|
|
@@ -776,7 +846,7 @@ function findFreshCredentialForGrant(
|
|
|
776
846
|
// require both ids to be present and exactly equal rather than inferring identity from the grant.
|
|
777
847
|
if (!expectedChatgptAccountId) return null;
|
|
778
848
|
for (const [candidateId, candidate] of Object.entries(records)) {
|
|
779
|
-
if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential) continue;
|
|
849
|
+
if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential || candidate.credential.sourceAuthPath) continue;
|
|
780
850
|
if (recordGrantFingerprint(candidate) !== refreshGrantFingerprint) continue;
|
|
781
851
|
if (!candidate.credential.chatgptAccountId) continue;
|
|
782
852
|
if (candidate.credential.chatgptAccountId !== expectedChatgptAccountId) continue;
|
|
@@ -926,6 +996,9 @@ export async function forceRefreshCodexPoolToken(
|
|
|
926
996
|
provenance: classify(resolved),
|
|
927
997
|
generation: resolved.generation,
|
|
928
998
|
rotated: resolved.accessToken !== options.rejectedAccessToken,
|
|
999
|
+
...(resolved.propagatedAliases?.length
|
|
1000
|
+
? { propagatedAliases: resolved.propagatedAliases }
|
|
1001
|
+
: {}),
|
|
929
1002
|
});
|
|
930
1003
|
},
|
|
931
1004
|
error => {
|
|
@@ -967,6 +1040,57 @@ export async function getValidCodexToken(
|
|
|
967
1040
|
};
|
|
968
1041
|
}
|
|
969
1042
|
|
|
1043
|
+
/** Source credentials never join refresh flights or spend a refresh grant, including after 401. */
|
|
1044
|
+
function resolveOrcaSourceToken(id: string, forced?: ForcedRefreshFence): CodexRefreshResult {
|
|
1045
|
+
const initial = readCodexAccountRecord(id);
|
|
1046
|
+
const initialCredential = initial?.credential;
|
|
1047
|
+
if (!initial || initial.deletedAt != null || !initialCredential?.sourceAuthPath || !initialCredential.sourceSubject) {
|
|
1048
|
+
throw new CodexCredentialGenerationConflictError();
|
|
1049
|
+
}
|
|
1050
|
+
const sourceCredential = readOrcaAuthSource(initialCredential.sourceAuthPath);
|
|
1051
|
+
if (sourceCredential.chatgptAccountId !== initialCredential.chatgptAccountId
|
|
1052
|
+
|| sourceCredential.sourceSubject !== initialCredential.sourceSubject) {
|
|
1053
|
+
throw new Error("Orca credential identity changed; reimport the account explicitly.");
|
|
1054
|
+
}
|
|
1055
|
+
if (sourceCredential.accessToken === initialCredential.accessToken
|
|
1056
|
+
&& sourceCredential.expiresAt === initialCredential.expiresAt) {
|
|
1057
|
+
if (forced?.rejectedAccessToken === sourceCredential.accessToken) {
|
|
1058
|
+
throw new Error("Orca bearer was rejected; update the account in Orca and retry.");
|
|
1059
|
+
}
|
|
1060
|
+
return { accessToken: sourceCredential.accessToken, chatgptAccountId: sourceCredential.chatgptAccountId,
|
|
1061
|
+
generation: initial.generation, provenance: "external-replacement" };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
return withCredentialMutationLockSync(() => {
|
|
1065
|
+
const store = loadCodexAccountRecordStore();
|
|
1066
|
+
const record = store[id];
|
|
1067
|
+
const prior = record?.credential;
|
|
1068
|
+
if (!record || record.deletedAt != null || !prior?.sourceAuthPath || !prior.sourceSubject) {
|
|
1069
|
+
throw new CodexCredentialGenerationConflictError();
|
|
1070
|
+
}
|
|
1071
|
+
if (record.generation !== initial.generation) throw new CodexCredentialGenerationConflictError();
|
|
1072
|
+
const credential = readOrcaAuthSource(prior.sourceAuthPath);
|
|
1073
|
+
if (credential.chatgptAccountId !== prior.chatgptAccountId || credential.sourceSubject !== prior.sourceSubject) {
|
|
1074
|
+
throw new Error("Orca credential identity changed; reimport the account explicitly.");
|
|
1075
|
+
}
|
|
1076
|
+
if (credential.accessToken !== prior.accessToken || credential.expiresAt !== prior.expiresAt) {
|
|
1077
|
+
store[id] = {
|
|
1078
|
+
credential, generation: record.generation + 1, replacedAt: Date.now(),
|
|
1079
|
+
// The source identity was checked above. Retire old-generation writers without
|
|
1080
|
+
// splitting this same account's retained quota history on every source rotation.
|
|
1081
|
+
quotaHistoryIdentity: record.quotaHistoryIdentity,
|
|
1082
|
+
...preservedValidationMetadata(record),
|
|
1083
|
+
};
|
|
1084
|
+
persistCredentialMutation(store);
|
|
1085
|
+
}
|
|
1086
|
+
if (forced?.rejectedAccessToken === credential.accessToken) {
|
|
1087
|
+
throw new Error("Orca bearer was rejected; update the account in Orca and retry.");
|
|
1088
|
+
}
|
|
1089
|
+
return { accessToken: credential.accessToken, chatgptAccountId: credential.chatgptAccountId,
|
|
1090
|
+
generation: store[id]!.generation, provenance: "external-replacement" };
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
970
1094
|
async function resolveCodexToken(
|
|
971
1095
|
id: string,
|
|
972
1096
|
forced?: ForcedRefreshFence,
|
|
@@ -976,6 +1100,7 @@ async function resolveCodexToken(
|
|
|
976
1100
|
const record = readCodexAccountRecord(id);
|
|
977
1101
|
const cred = record?.deletedAt == null ? record?.credential : undefined;
|
|
978
1102
|
if (!record || !cred) throw new CodexCredentialUnavailableError();
|
|
1103
|
+
if (cred.sourceAuthPath) return resolveOrcaSourceToken(id, forced);
|
|
979
1104
|
const refreshGrantFingerprint = recordGrantFingerprint(record);
|
|
980
1105
|
if (!refreshGrantFingerprint) throw new CodexCredentialUnavailableError();
|
|
981
1106
|
|
|
@@ -1005,6 +1130,7 @@ async function resolveCodexToken(
|
|
|
1005
1130
|
const refreshed = await awaitOwnCancellation(existing.promise, callerSignal);
|
|
1006
1131
|
const current = readCodexAccountRecord(id);
|
|
1007
1132
|
const currentCred = current?.deletedAt == null ? current?.credential : undefined;
|
|
1133
|
+
if (currentCred?.sourceAuthPath) return resolveOrcaSourceToken(id, forced);
|
|
1008
1134
|
// The flight owner already committed this credential, and it is the one stored
|
|
1009
1135
|
// for this account: adopt the stored state instead of CAS-writing the identical
|
|
1010
1136
|
// bytes, which would bump the generation a second time and invalidate the
|
|
@@ -1126,6 +1252,7 @@ async function resolveCodexToken(
|
|
|
1126
1252
|
const lockedRecord = readCodexAccountRecord(id);
|
|
1127
1253
|
const lockedCred = lockedRecord?.deletedAt == null ? lockedRecord?.credential : undefined;
|
|
1128
1254
|
if (!lockedRecord || !lockedCred) throw new CodexCredentialGenerationConflictError();
|
|
1255
|
+
if (lockedCred.sourceAuthPath) return resolveOrcaSourceToken(id, forced);
|
|
1129
1256
|
const startGeneration = lockedRecord.generation;
|
|
1130
1257
|
const lockedRefreshGrantFingerprint = recordGrantFingerprint(lockedRecord);
|
|
1131
1258
|
if (lockedRefreshGrantFingerprint !== refreshGrantFingerprint) {
|
|
@@ -1302,6 +1429,19 @@ async function resolveCodexToken(
|
|
|
1302
1429
|
* committed result, for every waiter, including none.
|
|
1303
1430
|
*/
|
|
1304
1431
|
const refreshPromise = fetchPromise.then(async (result): Promise<CodexRefreshResult> => {
|
|
1432
|
+
// Generation-dependent completion belongs to the flight, not to its initiating
|
|
1433
|
+
// request. The owner may stop waiting after a disconnect while this detached work
|
|
1434
|
+
// still commits G+1; advance process-local affinities before any waiter observes
|
|
1435
|
+
// the result (and even when there are no surviving waiters).
|
|
1436
|
+
if (result.selfRefreshed) {
|
|
1437
|
+
dispatchRefreshGenerationHandoffs(id, result.generation - 1, result.generation);
|
|
1438
|
+
// A propagated alias committed at its OWN generation, so its affinities sit at
|
|
1439
|
+
// alias.generation - 1: without this handoff they fail the exact-generation
|
|
1440
|
+
// liveness check on the very next request that reads them.
|
|
1441
|
+
for (const alias of result.propagatedAliases ?? []) {
|
|
1442
|
+
dispatchRefreshGenerationHandoffs(alias.id, alias.generation - 1, alias.generation);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1305
1445
|
await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation);
|
|
1306
1446
|
// One settlement path for the whole flight: the refreshing account, then any dormant alias that
|
|
1307
1447
|
// adopted the same rotated JWT. An alias holds the identical access token, so a changed
|
|
@@ -1331,6 +1471,7 @@ async function resolveCodexToken(
|
|
|
1331
1471
|
// Provenance rides out with the rest: a joiner that adopts this result needs the
|
|
1332
1472
|
// flight's own classification, not a guess made at the adoption site (#3019).
|
|
1333
1473
|
...(result.provenance !== undefined ? { provenance: result.provenance } : {}),
|
|
1474
|
+
...(result.propagatedAliases?.length ? { propagatedAliases: result.propagatedAliases } : {}),
|
|
1334
1475
|
...(result.resolvedGrantFingerprint !== undefined
|
|
1335
1476
|
? { resolvedGrantFingerprint: result.resolvedGrantFingerprint }
|
|
1336
1477
|
: {}),
|
|
@@ -82,9 +82,9 @@ export function mainQuotaWithCarriedResetCredits(
|
|
|
82
82
|
/**
|
|
83
83
|
* Why an account needs the operator. `missing_credential`, `refresh_failed`, and
|
|
84
84
|
* `quota_unauthorized` are the three causes this surface tells apart on its own. `unauthorized`
|
|
85
|
-
* and `forbidden`
|
|
86
|
-
* `
|
|
87
|
-
*
|
|
85
|
+
* and `forbidden` come from the shared health projection: `projectCodexAccountHealth` maps a
|
|
86
|
+
* stored verification failure's `http_status:401`/`http_status:403` to them, so the union has
|
|
87
|
+
* to accept every reason the projection can emit rather than silently dropping one.
|
|
88
88
|
*/
|
|
89
89
|
export type CodexAccountReauthReason =
|
|
90
90
|
| "missing_credential"
|
|
@@ -105,18 +105,26 @@ export function poolAccountDto(
|
|
|
105
105
|
const plan = codexPlanValue(account.plan);
|
|
106
106
|
const quota = quotaForPlan(quotaResult.quota, plan);
|
|
107
107
|
const runtimeReauth = isAccountNeedsReauth(account.id);
|
|
108
|
+
const rawReauthReason: CodexAccountReauthReason | undefined = !hasCredential
|
|
109
|
+
? "missing_credential"
|
|
110
|
+
: quotaResult.reauthReason;
|
|
108
111
|
const needsReauth = !hasCredential || quotaResult.needsReauth || runtimeReauth;
|
|
109
|
-
|
|
112
|
+
// An in-memory reauth mark carries no cause of its own, so it must not name one: passing
|
|
113
|
+
// a caller reason here would outrank the stored verdict's http_status inside the
|
|
114
|
+
// projection and hide unauthorized/forbidden until the mark is gone. With no caller
|
|
115
|
+
// reason the projection falls back to the persisted cause, then to refresh_failed.
|
|
116
|
+
const healthReason = rawReauthReason === "quota_unauthorized" || rawReauthReason === "missing_credential"
|
|
117
|
+
? "unauthorized"
|
|
118
|
+
: rawReauthReason;
|
|
119
|
+
const health = projectCodexAccountHealth({
|
|
120
|
+
accountId: account.id,
|
|
121
|
+
needsReauth,
|
|
122
|
+
reauthReason: needsReauth ? healthReason : undefined,
|
|
123
|
+
});
|
|
110
124
|
// `needsReauth` is an OR of three independent causes plus a persisted verdict resolved inside the
|
|
111
125
|
// health projection. Emitting only the boolean is what left #4212's reporter guessing which
|
|
112
126
|
// account took their model away and why, so name the cause they actually have to act on.
|
|
113
|
-
const reauthReason: CodexAccountReauthReason | undefined =
|
|
114
|
-
? "missing_credential"
|
|
115
|
-
: runtimeReauth
|
|
116
|
-
? "refresh_failed"
|
|
117
|
-
: quotaResult.needsReauth
|
|
118
|
-
? "quota_unauthorized"
|
|
119
|
-
: health.status === "reauth_required" ? health.reason : undefined;
|
|
127
|
+
const reauthReason: CodexAccountReauthReason | undefined = rawReauthReason ?? (health.status === "reauth_required" ? health.reason : undefined);
|
|
120
128
|
return {
|
|
121
129
|
id: account.id,
|
|
122
130
|
email: projectEmail(account.email, maskEmails) ?? account.email,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store";
|
|
1
|
+
import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, isTerminalCodexPoolRefreshFailure, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store";
|
|
2
2
|
import type { PoolQuotaWriter } from "../quota-types";
|
|
3
3
|
import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
|
|
4
4
|
import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
|
|
@@ -9,7 +9,7 @@ import { codexWarmupFailureReason, warmCodexAccount } from "../warmup";
|
|
|
9
9
|
import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
10
10
|
import { ResourceAdmissionError } from "../../lib/admission";
|
|
11
11
|
import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing";
|
|
12
|
-
import { claimQuotaRecovery, quotaRecoveryTerminalFor, releaseQuotaRecovery, settleQuotaRecovery, settleQuotaRecoveryTerminal } from "../quota-401-recovery";
|
|
12
|
+
import { claimQuotaRecovery, fencePropagatedQuotaRecovery, quotaRecoveryTerminalFor, releaseQuotaRecovery, settleQuotaRecovery, settleQuotaRecoveryTerminal } from "../quota-401-recovery";
|
|
13
13
|
import { seedLoginRowsForTests } from "./login-state";
|
|
14
14
|
import { nonEmptyPlan } from "./runtime-config";
|
|
15
15
|
|
|
@@ -46,6 +46,8 @@ export interface PoolQuotaResult {
|
|
|
46
46
|
resetRefreshLineage?: ManualResetRefreshLineage;
|
|
47
47
|
quota: StoredAccountQuota | null;
|
|
48
48
|
needsReauth: boolean;
|
|
49
|
+
/** Failure source observed while obtaining the quota, when reauthentication is required. */
|
|
50
|
+
reauthReason?: "refresh_failed" | "quota_unauthorized";
|
|
49
51
|
/** Credential generation whose cache or network result this DTO state belongs to. */
|
|
50
52
|
credentialGeneration?: number;
|
|
51
53
|
/** Present only when this call freshly parsed a WHAM usage response. */
|
|
@@ -178,7 +180,7 @@ export async function recoverPoolQuotaFrom401(ctx: {
|
|
|
178
180
|
// the credential it condemned, so a late terminal response arriving after the operator
|
|
179
181
|
// re-authenticated would quarantine the replacement.
|
|
180
182
|
markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
|
|
181
|
-
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
183
|
+
return { quota: existing ?? null, needsReauth: true, reauthReason: "quota_unauthorized", credentialGeneration: rejectedGeneration };
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
const claim = claimQuotaRecovery(accountId, rejectedGeneration);
|
|
@@ -186,7 +188,7 @@ export async function recoverPoolQuotaFrom401(ctx: {
|
|
|
186
188
|
// A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the
|
|
187
189
|
// budget being used would make the next bare 401 report a dead credential as healthy.
|
|
188
190
|
if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) {
|
|
189
|
-
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
191
|
+
return { quota: existing ?? null, needsReauth: true, reauthReason: "refresh_failed", credentialGeneration: rejectedGeneration };
|
|
190
192
|
}
|
|
191
193
|
// Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a
|
|
192
194
|
// transient failure is backing off. Report transient and let the next poll try —
|
|
@@ -205,6 +207,12 @@ export async function recoverPoolQuotaFrom401(ctx: {
|
|
|
205
207
|
onSettled: outcome => {
|
|
206
208
|
if (outcome.kind === "resolved") {
|
|
207
209
|
settleQuotaRecovery(accountId, claim.claimId, outcome);
|
|
210
|
+
// A propagated alias adopted the rotated grant without spending its own claim. Fence
|
|
211
|
+
// each committed alias generation so a later 401 on it cannot claim a second refresh
|
|
212
|
+
// of the same underlying grant.
|
|
213
|
+
for (const alias of outcome.propagatedAliases ?? []) {
|
|
214
|
+
fencePropagatedQuotaRecovery(alias.id, alias.generation);
|
|
215
|
+
}
|
|
208
216
|
} else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) {
|
|
209
217
|
// A revoked or expired grant does not become valid on the next poll. Releasing it
|
|
210
218
|
// into backoff would let the following bare 401 find a non-terminal record and
|
|
@@ -219,8 +227,15 @@ export async function recoverPoolQuotaFrom401(ctx: {
|
|
|
219
227
|
// A refresh that failed terminally is the one case where the credential really is gone.
|
|
220
228
|
// Everything else is unknown, and unknown is not proof.
|
|
221
229
|
if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
|
|
230
|
+
// Persist the same verdict the token guardian writes: the in-memory mark dies with
|
|
231
|
+
// this process, and only the stored terminal failure keeps a cached listing from
|
|
232
|
+
// calling the dead grant healthy after a restart.
|
|
233
|
+
markCodexAccountValidationFailed(accountId, `refresh_${e.reason}`, {
|
|
234
|
+
expectedGeneration: rejectedGeneration,
|
|
235
|
+
terminal: true,
|
|
236
|
+
});
|
|
222
237
|
markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
|
|
223
|
-
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
238
|
+
return { quota: existing ?? null, needsReauth: true, reauthReason: "refresh_failed", credentialGeneration: rejectedGeneration };
|
|
224
239
|
}
|
|
225
240
|
return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
|
|
226
241
|
}
|
|
@@ -252,7 +267,7 @@ export async function recoverPoolQuotaFrom401(ctx: {
|
|
|
252
267
|
// let the next poll call a dead credential healthy. The evidence is about the
|
|
253
268
|
// REFRESHED credential, which is what the replay used.
|
|
254
269
|
markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation);
|
|
255
|
-
return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation };
|
|
270
|
+
return { quota: existing ?? null, needsReauth: true, reauthReason: "quota_unauthorized", credentialGeneration: refreshed.generation };
|
|
256
271
|
}
|
|
257
272
|
return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
|
|
258
273
|
}
|
|
@@ -393,9 +408,23 @@ export async function fetchFreshPoolAccountQuota(
|
|
|
393
408
|
quotaProbeSkipped: true,
|
|
394
409
|
}, quotaProbeEvidence);
|
|
395
410
|
}
|
|
396
|
-
|
|
411
|
+
// Terminal means the grant itself is dead or missing; an `unknown` refresh failure (a
|
|
412
|
+
// token-endpoint 5xx, a transport blip) may clear, so it reports transient like the
|
|
413
|
+
// 401-recovery path instead of quarantining a healthy account (#2887). A revoked or
|
|
414
|
+
// expired grant is also written to the record as a terminal validation failure, the same
|
|
415
|
+
// verdict the token guardian persists: the in-memory mark dies with this process, and
|
|
416
|
+
// the stored verdict is what keeps a cached listing from calling the dead grant healthy
|
|
417
|
+
// after a restart.
|
|
418
|
+
if (isTerminalCodexPoolRefreshFailure(e)) {
|
|
419
|
+
if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
|
|
420
|
+
markCodexAccountValidationFailed(accountId, `refresh_${e.reason}`, {
|
|
421
|
+
expectedGeneration: requestCredentialGeneration,
|
|
422
|
+
terminal: true,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
markAccountNeedsReauth(accountId, captureConfigGeneration(), requestCredentialGeneration);
|
|
397
426
|
return withQuotaProbeEvidence(
|
|
398
|
-
{ quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration },
|
|
427
|
+
{ quota: existing ?? null, needsReauth: true, reauthReason: "refresh_failed", credentialGeneration: requestCredentialGeneration },
|
|
399
428
|
quotaProbeEvidence,
|
|
400
429
|
);
|
|
401
430
|
}
|
|
@@ -476,9 +505,14 @@ export async function fetchPoolAccountQuota(
|
|
|
476
505
|
&& generation !== undefined && record.generation === generation
|
|
477
506
|
&& isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) {
|
|
478
507
|
try {
|
|
508
|
+
// Quota I/O may outlive the source capture. Never validate a rotated or revoked link
|
|
509
|
+
// using an older generation's observation. Explicit validation consent still applies.
|
|
510
|
+
const sourceToken = record.credential.sourceAuthPath ? await getValidToken(accountId) : null;
|
|
511
|
+
if (sourceToken && (sourceToken.generation !== generation
|
|
512
|
+
|| !isCodexAccountGenerationLive(accountId, generation))) return result;
|
|
479
513
|
await warmCodexAccount({
|
|
480
|
-
accessToken: record.credential.accessToken,
|
|
481
|
-
chatgptAccountId: record.credential.chatgptAccountId,
|
|
514
|
+
accessToken: sourceToken?.accessToken ?? record.credential.accessToken,
|
|
515
|
+
chatgptAccountId: sourceToken?.chatgptAccountId ?? record.credential.chatgptAccountId,
|
|
482
516
|
});
|
|
483
517
|
markCodexAccountValidated(accountId, Date.now(), generation);
|
|
484
518
|
clearAccountNeedsReauth(accountId, generation);
|
|
@@ -7,7 +7,7 @@ import { getAccountQuotaHistory, listAccountQuotas } from "../quota";
|
|
|
7
7
|
import { deleteCodexAccount } from "../account-lifecycle";
|
|
8
8
|
import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
|
|
9
9
|
import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPin, setCodexAccountPriority } from "../account-priority";
|
|
10
|
-
import { codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing";
|
|
10
|
+
import { codexAccountPinDrainReason, codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing";
|
|
11
11
|
import { DEFAULT_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, parseCodexAccountPoolStrategy, parseAccountPriority } from "../pool-rotation";
|
|
12
12
|
import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
|
|
13
13
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
@@ -235,7 +235,22 @@ export async function handleCodexAuthAPI(
|
|
|
235
235
|
else setCodexAccountPin(runtimeConfig, targetAccountId);
|
|
236
236
|
resetCodexRoutingForManualSelection(targetAccountId);
|
|
237
237
|
saveRuntimeConfig(config, runtimeConfig);
|
|
238
|
-
|
|
238
|
+
// A pin this route accepts can still be dropped by the very next resolve, and saying
|
|
239
|
+
// nothing about that is what made the setting look ignored (#4521). The checks above
|
|
240
|
+
// refuse an account that cannot be selected at all; this reports the one remaining
|
|
241
|
+
// outcome they do not cover, from the same predicate routing releases on, so the two
|
|
242
|
+
// cannot drift. Absent means the pin survives — additive for existing clients.
|
|
243
|
+
// `appliesImmediately` is unchanged: it answers whether thread affinity was cleared,
|
|
244
|
+
// not whether the pin is durable.
|
|
245
|
+
const pinDrainReason = body.accountId == null
|
|
246
|
+
? undefined
|
|
247
|
+
: codexAccountPinDrainReason(runtimeConfig, targetAccountId);
|
|
248
|
+
return jsonResponse({
|
|
249
|
+
ok: true,
|
|
250
|
+
activeCodexAccountId: body.accountId,
|
|
251
|
+
appliesImmediately: true,
|
|
252
|
+
...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}),
|
|
253
|
+
});
|
|
239
254
|
}
|
|
240
255
|
|
|
241
256
|
if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
|
|
@@ -135,20 +135,22 @@ export function requestOwnedMainPinState(
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
|
-
*
|
|
138
|
+
* One conversation tree keys as ONE cohort (#4780).
|
|
139
139
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
140
|
+
* Upstream keys its prompt cache on something the whole tree shares -- `prompt_cache_key` is the
|
|
141
|
+
* session id, or `{source}:{parent_thread_id}` for an internal session -- and one `AgentControl`
|
|
142
|
+
* whose `session_id` is the root thread's id is shared with every sub-agent. While the proxy
|
|
143
|
+
* keyed per thread, two requests could carry an identical `prompt_cache_key` and be served by
|
|
144
|
+
* different accounts, so the split member asserted a warm prefix that was deterministically cold
|
|
145
|
+
* on its account. Nothing failed; the prompt was replayed in full and the tokens burned.
|
|
145
146
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
147
|
+
* THIS IS NOT A REVERT OF #4546 wp8. wp8 fixed a different defect -- a child bound under the RAW
|
|
148
|
+
* parent id, an identity unrelated to the root's own binding, so siblings shared an entry the
|
|
149
|
+
* root was not on and a grandchild landed on a key nobody had bound. A cohort key has no such
|
|
150
|
+
* incoherence, because the root's own binding IS the cohort key. What wp8 additionally gave each
|
|
151
|
+
* thread, a binding of its own, is what this deliberately gives up.
|
|
152
|
+
*
|
|
153
|
+
* Which requests produce no key at all is unchanged, through both units.
|
|
152
154
|
*
|
|
153
155
|
* The derivation itself lives in ./lineage so a lineage record's conversation key and the key
|
|
154
156
|
* the thread actually binds under can never drift apart.
|
|
@@ -1419,6 +1421,7 @@ export function materializeCodexUpstreamAuth(
|
|
|
1419
1421
|
if (!stored?.accessToken || !isMainAccountTokenLive()) {
|
|
1420
1422
|
throw new CodexMainSubstitutionUnavailableError();
|
|
1421
1423
|
}
|
|
1424
|
+
selected.delete("chatgpt-account-id");
|
|
1422
1425
|
selected.set("authorization", `Bearer ${stored.accessToken}`);
|
|
1423
1426
|
if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
|
|
1424
1427
|
observeSelectedMainCredential(stored, writer);
|
|
@@ -1496,6 +1499,7 @@ export async function materializeCodexUpstreamAuthAsync(
|
|
|
1496
1499
|
...(options.nativeMainRefreshDependencies ?? {}),
|
|
1497
1500
|
});
|
|
1498
1501
|
if (!stored?.accessToken) throw new CodexMainSubstitutionUnavailableError();
|
|
1502
|
+
selected.delete("chatgpt-account-id");
|
|
1499
1503
|
selected.set("authorization", `Bearer ${stored.accessToken}`);
|
|
1500
1504
|
if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
|
|
1501
1505
|
observeSelectedMainCredential(stored, writer);
|