@bitkyc08/opencodex 2.59.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/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/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 +23 -0
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/request-builder.ts +7 -1
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +3 -1
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
- package/src/adapters/devin.ts +50 -13
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +29 -5
- package/src/adapters/google-http.ts +49 -10
- 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 +37 -6
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +5 -1
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +15 -3
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/bridge/sse.ts +3 -151
- package/src/cli/account-extended.ts +4 -4
- package/src/cli/dispatch.ts +3 -3
- package/src/cli/doctor.ts +28 -9
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +7 -2
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/client/machine-api.ts +2 -2
- package/src/client/machine-listener.ts +2 -2
- package/src/client/runtime.ts +26 -2
- package/src/codex/account-store.ts +65 -0
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +30 -7
- package/src/codex/catalog/gather-capture.ts +21 -2
- package/src/codex/catalog/model-hints.ts +29 -28
- package/src/codex/catalog/parsing.ts +7 -0
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/retained-sync.ts +22 -26
- package/src/codex/catalog/routed-gather.ts +19 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +20 -166
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +29 -10
- package/src/codex/observed-model-denials.ts +101 -8
- package/src/codex/prompt-text-probe.ts +9 -6
- package/src/codex/routing.ts +7 -1
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +22 -4
- package/src/combos/failover.ts +3 -0
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +22 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +18 -0
- package/src/config/proxy-env.ts +8 -2
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +4 -0
- package/src/config/schema/leaf-validators.ts +12 -0
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +249 -169
- 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/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/errors.ts +79 -0
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- 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 +50 -2
- package/src/lib/socks5-fetch.ts +136 -26
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +218 -27
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +1 -0
- 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/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/key-failover.ts +66 -18
- package/src/providers/model-rename-migration.ts +55 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/quota/antigravity.ts +22 -2
- 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 +35 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +9 -0
- 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 +0 -12
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- 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/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 +3 -0
- package/src/server/chat-completions.ts +5 -2
- package/src/server/claude-messages.ts +6 -3
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- 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 +16 -13
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +18 -2
- 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/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/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +119 -10
- package/src/server/request-log.ts +55 -2
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +11 -6
- package/src/server/responses/compact.ts +32 -10
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +8 -3
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +6 -12
- 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/passthrough-delivery.ts +31 -14
- package/src/server/responses/passthrough-dispatch.ts +36 -9
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +67 -17
- package/src/server/responses/request-send-budget.ts +5 -1
- package/src/server/responses/request-sidecar-auth.ts +1 -1
- package/src/server/responses/request-transport.ts +2 -2
- package/src/server/responses/run-turn-execution.ts +25 -3
- package/src/server/responses/sidecar-execution.ts +17 -2
- package/src/server/responses/ws-upstream.ts +14 -27
- package/src/server/responses-custom-tool-repair.ts +27 -54
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-payload-rewrite.ts +1 -1
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/config.ts +11 -3
- package/src/types/provider.ts +18 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +14 -0
- package/src/types.ts +1 -0
- 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/gui/dist/assets/index-C5IebErG.js +0 -136
- package/gui/dist/assets/index-OESInAjC.css +0 -1
package/src/cli/provider.ts
CHANGED
|
@@ -139,7 +139,7 @@ function handleList(args: string[]): void {
|
|
|
139
139
|
// provider add
|
|
140
140
|
// ---------------------------------------------------------------------------
|
|
141
141
|
|
|
142
|
-
const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--api-key-transport <x-api-key|bearer>] [--default-model <model>] [--model <id> --text-only] [--allow-private-network] [--set-default] [--force] [--json] [--sync]";
|
|
142
|
+
const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--api-key-transport <x-api-key|bearer>] [--default-model <model>] [--model <id> --text-only] [--google-tool-schema-policy <compatible|reject-lossy>] [--allow-private-network] [--set-default] [--force] [--json] [--sync]";
|
|
143
143
|
|
|
144
144
|
async function handleAdd(args: string[]): Promise<void> {
|
|
145
145
|
const name = args[0];
|
|
@@ -164,6 +164,7 @@ async function handleAdd(args: string[]): Promise<void> {
|
|
|
164
164
|
const adapter = consumeFlagValue(restArgs, "--adapter");
|
|
165
165
|
const baseUrl = consumeFlagValue(restArgs, "--base-url");
|
|
166
166
|
const defaultModel = consumeFlagValue(restArgs, "--default-model");
|
|
167
|
+
const googleToolSchemaPolicy = consumeFlagValue(restArgs, "--google-tool-schema-policy");
|
|
167
168
|
const textOnly = consumeFlag(restArgs, "--text-only");
|
|
168
169
|
const capabilityModel = consumeFlagValue(restArgs, "--model");
|
|
169
170
|
rejectUnknownArgs(restArgs, ADD_USAGE);
|
|
@@ -228,6 +229,17 @@ async function handleAdd(args: string[]): Promise<void> {
|
|
|
228
229
|
}
|
|
229
230
|
provConfig.apiKeyTransport = apiKeyTransport;
|
|
230
231
|
}
|
|
232
|
+
if (googleToolSchemaPolicy !== undefined) {
|
|
233
|
+
if (googleToolSchemaPolicy !== "compatible" && googleToolSchemaPolicy !== "reject-lossy") {
|
|
234
|
+
console.error('Error: --google-tool-schema-policy must be "compatible" or "reject-lossy".');
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
if (provConfig.adapter !== "google") {
|
|
238
|
+
console.error("Error: --google-tool-schema-policy requires the google adapter.");
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
provConfig.googleToolSchemaPolicy = googleToolSchemaPolicy;
|
|
242
|
+
}
|
|
231
243
|
|
|
232
244
|
const existingProvider = config.providers[name];
|
|
233
245
|
if (existingProvider?.modelCapabilities !== undefined && provConfig.modelCapabilities === undefined) {
|
|
@@ -23,7 +23,7 @@ export interface MachineStatusV1 {
|
|
|
23
23
|
export interface MachineApiDeps {
|
|
24
24
|
sync: typeof syncConnectedClient;
|
|
25
25
|
disconnect: typeof disconnectClient;
|
|
26
|
-
scheduleStandaloneRecycle: () => void;
|
|
26
|
+
scheduleStandaloneRecycle: (disconnectedTokenFingerprint: string) => void;
|
|
27
27
|
hubReachability?: () => HubReachability;
|
|
28
28
|
setHubReachability?: (value: HubReachability) => void;
|
|
29
29
|
}
|
|
@@ -130,7 +130,7 @@ export async function handleMachineApi(
|
|
|
130
130
|
}
|
|
131
131
|
try {
|
|
132
132
|
const result = await deps.disconnect(input.keepCatalog === undefined ? {} : { keepCatalog: input.keepCatalog });
|
|
133
|
-
deps.scheduleStandaloneRecycle();
|
|
133
|
+
deps.scheduleStandaloneRecycle(state.tokenFingerprint);
|
|
134
134
|
return Response.json({ success: true, ...result }, { status: 202 });
|
|
135
135
|
} catch (error) {
|
|
136
136
|
return Response.json({ success: false, error: error instanceof Error ? error.message : "disconnect failed" }, { status: 409 });
|
|
@@ -75,8 +75,8 @@ export function startMachineListener(
|
|
|
75
75
|
const machineApiDeps: MachineApiDeps = {
|
|
76
76
|
sync: deps.machineApi?.sync ?? syncConnectedClient,
|
|
77
77
|
disconnect: deps.machineApi?.disconnect ?? disconnectClient,
|
|
78
|
-
scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (
|
|
79
|
-
void import("./runtime").then(module => module.scheduleStandaloneRecycle());
|
|
78
|
+
scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (tokenFingerprint => {
|
|
79
|
+
void import("./runtime").then(module => module.scheduleStandaloneRecycle(tokenFingerprint));
|
|
80
80
|
}),
|
|
81
81
|
hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability),
|
|
82
82
|
setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }),
|
package/src/client/runtime.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { loadConfig } from "../config";
|
|
|
4
4
|
import { removePid, removeRuntimePort, writePid, writeRuntimePort } from "../config/process-state";
|
|
5
5
|
import { installCrashGuards } from "../lib/crash-guard";
|
|
6
6
|
import { selfLaunchArgv } from "../lib/self-launch-argv";
|
|
7
|
+
import { loadServiceTokenFromFile, serviceApiTokenFingerprint } from "../lib/service-secrets";
|
|
7
8
|
import { findAvailablePort } from "../server/ports";
|
|
8
9
|
import { startMachineListener } from "./machine-listener";
|
|
9
10
|
import { readClientConnectionState } from "./state";
|
|
@@ -17,7 +18,30 @@ function cleanup(): void {
|
|
|
17
18
|
removeRuntimePort(process.pid);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export function
|
|
21
|
+
export function standaloneRecycleEnv(
|
|
22
|
+
env: NodeJS.ProcessEnv,
|
|
23
|
+
disconnectedTokenFingerprint: string,
|
|
24
|
+
): NodeJS.ProcessEnv {
|
|
25
|
+
const childEnv = { ...env };
|
|
26
|
+
const admissionToken = childEnv.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
27
|
+
if (admissionToken && serviceApiTokenFingerprint(admissionToken) !== disconnectedTokenFingerprint) {
|
|
28
|
+
// A surviving env token shadows OCX_API_TOKEN_FILE entirely, so nothing below can
|
|
29
|
+
// reintroduce the disconnected token.
|
|
30
|
+
return childEnv;
|
|
31
|
+
}
|
|
32
|
+
if (admissionToken) delete childEnv.OPENCODEX_API_AUTH_TOKEN;
|
|
33
|
+
// The respawned `ocx start` loads OCX_API_TOKEN_FILE into OPENCODEX_API_AUTH_TOKEN when the
|
|
34
|
+
// env token is absent, so vet the file by content: drop the pointer when it is unreadable or
|
|
35
|
+
// holds the disconnected token, keep it when it holds a different operator credential.
|
|
36
|
+
if (!childEnv.OCX_API_TOKEN_FILE?.trim()) return childEnv;
|
|
37
|
+
const fileToken = loadServiceTokenFromFile(childEnv);
|
|
38
|
+
if (fileToken === null || serviceApiTokenFingerprint(fileToken) === disconnectedTokenFingerprint) {
|
|
39
|
+
delete childEnv.OCX_API_TOKEN_FILE;
|
|
40
|
+
}
|
|
41
|
+
return childEnv;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function scheduleStandaloneRecycle(disconnectedTokenFingerprint: string): void {
|
|
21
45
|
if (recycleScheduled) return;
|
|
22
46
|
recycleScheduled = true;
|
|
23
47
|
const timer = setTimeout(() => {
|
|
@@ -46,7 +70,7 @@ export function scheduleStandaloneRecycle(): void {
|
|
|
46
70
|
detached: true,
|
|
47
71
|
stdio: "ignore",
|
|
48
72
|
windowsHide: true,
|
|
49
|
-
env:
|
|
73
|
+
env: standaloneRecycleEnv(process.env, disconnectedTokenFingerprint),
|
|
50
74
|
});
|
|
51
75
|
child.unref();
|
|
52
76
|
}
|
|
@@ -370,6 +370,23 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
|
|
|
370
370
|
return !!record?.credential && record.deletedAt == null && record.generation === generation;
|
|
371
371
|
}
|
|
372
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
|
+
|
|
373
390
|
export function saveCodexAccountCredentialIfGeneration(
|
|
374
391
|
id: string,
|
|
375
392
|
generation: number,
|
|
@@ -596,6 +613,41 @@ function withCredentialMutationLockSync<T>(fn: () => T): T {
|
|
|
596
613
|
}
|
|
597
614
|
|
|
598
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
|
+
|
|
599
651
|
type CodexRefreshResult = CodexTokenResult & {
|
|
600
652
|
credential?: CodexAccountCredentials;
|
|
601
653
|
/**
|
|
@@ -1377,6 +1429,19 @@ async function resolveCodexToken(
|
|
|
1377
1429
|
* committed result, for every waiter, including none.
|
|
1378
1430
|
*/
|
|
1379
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
|
+
}
|
|
1380
1445
|
await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation);
|
|
1381
1446
|
// One settlement path for the whole flight: the refreshing account, then any dormant alias that
|
|
1382
1447
|
// adopted the same rotated JWT. An alias holds the identical access token, so a changed
|
|
@@ -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";
|
|
@@ -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 —
|
|
@@ -225,8 +227,15 @@ export async function recoverPoolQuotaFrom401(ctx: {
|
|
|
225
227
|
// A refresh that failed terminally is the one case where the credential really is gone.
|
|
226
228
|
// Everything else is unknown, and unknown is not proof.
|
|
227
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
|
+
});
|
|
228
237
|
markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
|
|
229
|
-
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
238
|
+
return { quota: existing ?? null, needsReauth: true, reauthReason: "refresh_failed", credentialGeneration: rejectedGeneration };
|
|
230
239
|
}
|
|
231
240
|
return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
|
|
232
241
|
}
|
|
@@ -258,7 +267,7 @@ export async function recoverPoolQuotaFrom401(ctx: {
|
|
|
258
267
|
// let the next poll call a dead credential healthy. The evidence is about the
|
|
259
268
|
// REFRESHED credential, which is what the replay used.
|
|
260
269
|
markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation);
|
|
261
|
-
return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation };
|
|
270
|
+
return { quota: existing ?? null, needsReauth: true, reauthReason: "quota_unauthorized", credentialGeneration: refreshed.generation };
|
|
262
271
|
}
|
|
263
272
|
return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
|
|
264
273
|
}
|
|
@@ -399,9 +408,23 @@ export async function fetchFreshPoolAccountQuota(
|
|
|
399
408
|
quotaProbeSkipped: true,
|
|
400
409
|
}, quotaProbeEvidence);
|
|
401
410
|
}
|
|
402
|
-
|
|
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);
|
|
403
426
|
return withQuotaProbeEvidence(
|
|
404
|
-
{ quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration },
|
|
427
|
+
{ quota: existing ?? null, needsReauth: true, reauthReason: "refresh_failed", credentialGeneration: requestCredentialGeneration },
|
|
405
428
|
quotaProbeEvidence,
|
|
406
429
|
);
|
|
407
430
|
}
|
|
@@ -100,8 +100,9 @@ import type {
|
|
|
100
100
|
CatalogSourceEvidence,
|
|
101
101
|
CatalogTrustedOpenAiApiPolicySnapshot,
|
|
102
102
|
} from "../convergence-types";
|
|
103
|
-
import {
|
|
103
|
+
import { modelCapabilities, modelInputModalities } from "./model-hints";
|
|
104
104
|
import { configuredComboTargetModelsByProvider } from "./combo-member";
|
|
105
|
+
import { resolveModelPolicy } from "../../providers/resolved-model-policy";
|
|
105
106
|
|
|
106
107
|
/** Concurrent gatherRoutedModels callers with the same catalog identity share one live discovery.
|
|
107
108
|
* Keyed by gatherFlightKey so a different config cannot join or evict the wrong flight. */
|
|
@@ -344,8 +345,26 @@ export function captureProviderGather(
|
|
|
344
345
|
): CapturedProviderGather {
|
|
345
346
|
const enriched = detachedClone(withCanonicalOpenAiForwardAuthDefault(name, configured));
|
|
346
347
|
enrichProviderFromRegistry(name, enriched);
|
|
347
|
-
applyRegistryCapabilitySeedFill(name, enriched);
|
|
348
348
|
const registryTransportMatch = providerMatchesRegistryTransport(name, enriched);
|
|
349
|
+
const registryEntry = registryTransportMatch ? getProviderRegistryEntry(name) : undefined;
|
|
350
|
+
const staticProvider = resolveModelPolicy({
|
|
351
|
+
providerName: name,
|
|
352
|
+
modelId: enriched.defaultModel ?? "__catalog_capture__",
|
|
353
|
+
provider: enriched,
|
|
354
|
+
registryEntry,
|
|
355
|
+
transportMatchedRegistry: registryTransportMatch,
|
|
356
|
+
...(enriched.authMode ? { effectiveAuth: { authMode: enriched.authMode } } : {}),
|
|
357
|
+
}).provider;
|
|
358
|
+
for (const key of [
|
|
359
|
+
"modelContextWindows", "modelInputModalities", "modelMaxInputTokens", "modelMaxOutputTokens",
|
|
360
|
+
"modelReasoningEfforts", "modelSupportsReasoningSummaries",
|
|
361
|
+
"modelSupportsVerbosity", "modelSupportsServiceTier",
|
|
362
|
+
] as const) {
|
|
363
|
+
if (staticProvider[key] !== undefined) enriched[key] = detachedClone(staticProvider[key]) as never;
|
|
364
|
+
}
|
|
365
|
+
// A present modelDefaultReasoningEfforts object is a whole-map catalog authority, including
|
|
366
|
+
// explicit {}. enrichProviderFromRegistry already preserves/fills that contract; replacing it
|
|
367
|
+
// with the resolver's per-key registry fill would turn "no configured default" into a seed default.
|
|
349
368
|
const provider = recursivelyFreeze(enriched);
|
|
350
369
|
const fastPolicyAuthority = captureFastPolicyAuthority(
|
|
351
370
|
name,
|
|
@@ -90,6 +90,7 @@ import type { CatalogModel } from "./parsing";
|
|
|
90
90
|
import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
|
|
91
91
|
import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
|
|
92
92
|
import type { ComboCatalogOmission } from "./aggregation";
|
|
93
|
+
import { clampObservedModelLimits, resolveModelPolicy } from "../../providers/resolved-model-policy";
|
|
93
94
|
import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
|
|
94
95
|
import type {
|
|
95
96
|
CatalogAdmissionSnapshot,
|
|
@@ -246,22 +247,6 @@ export function configuredReasoningSummarySupport(prov: OcxProviderConfig | unde
|
|
|
246
247
|
return modelRecordValue(prov.modelReasoningSummaryDelivery, id) !== undefined ? true : undefined;
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
function configuredVerbositySupport(name: string, prov: OcxProviderConfig | undefined, id: string): boolean | undefined {
|
|
250
|
-
const explicit = prov ? modelRecordValue(prov.modelSupportsVerbosity, id) : undefined;
|
|
251
|
-
if (explicit !== undefined) return explicit;
|
|
252
|
-
if (!prov) return undefined;
|
|
253
|
-
void name;
|
|
254
|
-
// Provider-wide fallback for ids the per-model map does not enumerate — a live-discovered
|
|
255
|
-
// model would otherwise re-advertise a control the upstream accepts and ignores.
|
|
256
|
-
//
|
|
257
|
-
// Read from the PROVIDER CONFIG, never from PROVIDER_REGISTRY. A gather flight captures its
|
|
258
|
-
// registry authority up front and forbids any later registry read, so consulting the registry
|
|
259
|
-
// here made a custom-destination flight fall back to "configured" instead of serving its own
|
|
260
|
-
// discovery result (tests/codex-integration/codex-gather-authority.test.ts). `applyVerbosityDefaults` in
|
|
261
|
-
// providers/derive.ts materializes the registry default into the config at seed/enrich time.
|
|
262
|
-
return prov.supportsVerbosity;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
250
|
export function applyProviderConfigHints(
|
|
266
251
|
name: string,
|
|
267
252
|
prov: OcxProviderConfig,
|
|
@@ -270,6 +255,15 @@ export function applyProviderConfigHints(
|
|
|
270
255
|
metadataModelIdCaseFold?: boolean,
|
|
271
256
|
effectiveAlias?: string | null,
|
|
272
257
|
): CatalogModel {
|
|
258
|
+
const staticPolicy = resolveModelPolicy({
|
|
259
|
+
providerName: name,
|
|
260
|
+
modelId: model.id,
|
|
261
|
+
provider: prov,
|
|
262
|
+
transportMatchedRegistry: false,
|
|
263
|
+
modelCapabilities: prov.modelCapabilities?.[model.id],
|
|
264
|
+
...(prov.authMode ? { effectiveAuth: { authMode: prov.authMode } } : {}),
|
|
265
|
+
...(effectiveAlias !== undefined ? { effectiveAlias } : {}),
|
|
266
|
+
});
|
|
273
267
|
const displayName = configuredModelDisplayName(prov, model.id);
|
|
274
268
|
// The alias decision is resolved once at flight admission (captureProviderGather) and threaded
|
|
275
269
|
// through as `effectiveAlias`. Re-deriving it here would read PROVIDER_REGISTRY after admission,
|
|
@@ -279,11 +273,15 @@ export function applyProviderConfigHints(
|
|
|
279
273
|
const providerAlias = typeof effectiveAlias === "string" || effectiveAlias === null
|
|
280
274
|
? effectiveAlias
|
|
281
275
|
: model.providerAlias;
|
|
282
|
-
const configuredCap = configuredContextWindow(prov, model.id);
|
|
283
|
-
const configuredMaxInput =
|
|
276
|
+
const configuredCap = staticPolicy.model.contextWindow ?? configuredContextWindow(prov, model.id);
|
|
277
|
+
const configuredMaxInput = staticPolicy.model.maxInputTokens;
|
|
284
278
|
const maxOutputTokens = routedMaxOutputTokens(name, prov, model, model.id, metadataModelIdCaseFold);
|
|
285
279
|
const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id);
|
|
286
|
-
|
|
280
|
+
// The resolver owns exact capability precedence and legacy exact/colon-family/case-fold fallback.
|
|
281
|
+
// Removing an exact capability row therefore returns this projection to legacy-map inference.
|
|
282
|
+
let inputModalities = staticPolicy.model.inputModalities
|
|
283
|
+
? [...staticPolicy.model.inputModalities]
|
|
284
|
+
: undefined;
|
|
287
285
|
// The shared vision-sidecar consumer predicate keeps catalog advertisement and request-time
|
|
288
286
|
// planning aligned. The catalog must still advertise image input — the Codex app
|
|
289
287
|
// gates attachments client-side on input_modalities, and a text-only entry would block images
|
|
@@ -297,10 +295,12 @@ export function applyProviderConfigHints(
|
|
|
297
295
|
}
|
|
298
296
|
const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
|
|
299
297
|
const suppressSyntheticMax = modelRecordValue(prov.modelSuppressSyntheticMax, model.id) === true;
|
|
300
|
-
const defaultReasoningEffort =
|
|
301
|
-
const supportsReasoningSummaries =
|
|
302
|
-
const supportsVerbosity =
|
|
298
|
+
const defaultReasoningEffort = staticPolicy.model.defaultReasoningEffort ?? model.defaultReasoningEffort;
|
|
299
|
+
const supportsReasoningSummaries = staticPolicy.model.supportsReasoningSummaries;
|
|
300
|
+
const supportsVerbosity = staticPolicy.model.supportsVerbosity;
|
|
303
301
|
const fastPolicy = fastPolicyForModel(prov, model.id, name);
|
|
302
|
+
// Frozen gather providers retain the captured Fast authority. That late/captured eligibility
|
|
303
|
+
// owns catalog publication, including an explicit provider-level false.
|
|
304
304
|
const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy);
|
|
305
305
|
const {
|
|
306
306
|
supportsServiceTier: _staleServiceTier,
|
|
@@ -313,9 +313,12 @@ export function applyProviderConfigHints(
|
|
|
313
313
|
const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0
|
|
314
314
|
? model.contextWindow
|
|
315
315
|
: undefined;
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
316
|
+
const projectedLimits = clampObservedModelLimits(staticPolicy.model, {
|
|
317
|
+
...(discoveredWindow !== undefined ? { contextWindow: discoveredWindow } : {}),
|
|
318
|
+
...(typeof model.maxInputTokens === "number" && model.maxInputTokens > 0 ? { maxInputTokens: model.maxInputTokens } : {}),
|
|
319
|
+
});
|
|
320
|
+
const hintedWindow = projectedLimits.contextWindow
|
|
321
|
+
?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined);
|
|
319
322
|
const hinted = {
|
|
320
323
|
...modelWithoutServiceTier,
|
|
321
324
|
...(displayName !== undefined ? { displayName } : {}),
|
|
@@ -326,9 +329,7 @@ export function applyProviderConfigHints(
|
|
|
326
329
|
...(suppressSyntheticMax ? { suppressSyntheticMax: true } : {}),
|
|
327
330
|
...(configuredMaxInput !== undefined
|
|
328
331
|
? {
|
|
329
|
-
maxInputTokens:
|
|
330
|
-
? Math.min(model.maxInputTokens, configuredMaxInput)
|
|
331
|
-
: configuredMaxInput,
|
|
332
|
+
maxInputTokens: projectedLimits.maxInputTokens ?? configuredMaxInput,
|
|
332
333
|
}
|
|
333
334
|
: {}),
|
|
334
335
|
...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
|
|
@@ -803,6 +803,13 @@ export function normalizeRoutedCatalogEntry(
|
|
|
803
803
|
delete entry.multi_agent_reasoning_effort;
|
|
804
804
|
delete entry.use_responses_lite;
|
|
805
805
|
delete entry.supports_websockets;
|
|
806
|
+
// Routed rows cloned from native templates must not inherit OpenAI-only experimental context
|
|
807
|
+
// delivery. Codex reads the flag as "this model accepts experimental context history" and drives
|
|
808
|
+
// its context-management cadence from it, so a third-party provider that never negotiated it gets
|
|
809
|
+
// a compact-after-every-step loop instead (observed on a routed DeepSeek row: 1,600+ compactions
|
|
810
|
+
// in a single thread). Nothing re-applies the field from provider metadata during this step, so
|
|
811
|
+
// the row leaves this normalization without it.
|
|
812
|
+
delete entry.supports_experimental_context;
|
|
806
813
|
/*
|
|
807
814
|
* Tier metadata is stripped from routed rows because a row cloned from a native template
|
|
808
815
|
* would otherwise hand a third-party provider OpenAI's tiers.
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
clearModelCache,
|
|
12
12
|
clearProviderDiscoveryStatus,
|
|
13
13
|
captureModelCacheGeneration,
|
|
14
|
+
observeModelCacheRevision,
|
|
14
15
|
DEFAULT_MODEL_CACHE_TTL_MS,
|
|
15
16
|
getFreshCached,
|
|
16
17
|
getStaleCached,
|
|
@@ -108,6 +109,14 @@ import { captureProviderGather, materializeCapturedHeaders } from "./gather-capt
|
|
|
108
109
|
export interface ProviderModelsResult {
|
|
109
110
|
readonly models: CatalogModel[];
|
|
110
111
|
readonly outcome: CatalogGatherProviderModelOutcome;
|
|
112
|
+
/**
|
|
113
|
+
* The provider's cache content revision as it stood when these rows were chosen.
|
|
114
|
+
*
|
|
115
|
+
* Sampled here rather than by the caller because only this point is synchronous with the
|
|
116
|
+
* choice. A caller reading it after its own await can observe a revision another flight
|
|
117
|
+
* published in between, and would then record these rows under that flight's identity.
|
|
118
|
+
*/
|
|
119
|
+
readonly contentRevision: string;
|
|
111
120
|
}
|
|
112
121
|
export const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" };
|
|
113
122
|
|
|
@@ -145,7 +154,11 @@ export async function fetchProviderModelsWithAuth(
|
|
|
145
154
|
const observed = (
|
|
146
155
|
models: CatalogModel[],
|
|
147
156
|
state: CatalogGatherProviderModelOutcome["state"],
|
|
148
|
-
): ProviderModelsResult => ({
|
|
157
|
+
): ProviderModelsResult => ({
|
|
158
|
+
models,
|
|
159
|
+
outcome: { provider: name, state },
|
|
160
|
+
contentRevision: observeModelCacheRevision(name),
|
|
161
|
+
});
|
|
149
162
|
// Capture before any credential refresh or outbound await. OAuth account changes clear this
|
|
150
163
|
// generation, so a request started with the former account cannot later publish its result.
|
|
151
164
|
const cacheGeneration = captureModelCacheGeneration(name);
|
|
@@ -372,7 +385,11 @@ export async function fetchProviderModelsWithAuth(
|
|
|
372
385
|
...(cursorFetch ? { fetch: cursorFetch } : {}),
|
|
373
386
|
});
|
|
374
387
|
if (liveResult.ok) {
|
|
375
|
-
const available = filterCursorConfiguredModelsByLiveDiscovery(
|
|
388
|
+
const available = filterCursorConfiguredModelsByLiveDiscovery(
|
|
389
|
+
configured,
|
|
390
|
+
liveResult.models,
|
|
391
|
+
liveResult.maxModeModels ?? [],
|
|
392
|
+
);
|
|
376
393
|
const result = available.length > 0 ? available : configured;
|
|
377
394
|
// Cache the discovery-filtered roster without combo retention so a later
|
|
378
395
|
// gather can re-apply the current capture's retain set on read.
|
|
@@ -59,10 +59,12 @@ import {
|
|
|
59
59
|
type CatalogWritePermit,
|
|
60
60
|
} from "../catalog-write-serialization";
|
|
61
61
|
import {
|
|
62
|
+
preparedBytesDifferFromDisk,
|
|
62
63
|
publishHashedCodexCatalogBackup,
|
|
63
64
|
publishLegacyCodexCatalogBackup,
|
|
64
65
|
replaceActiveCodexCatalog,
|
|
65
66
|
replaceCodexModelsCache,
|
|
67
|
+
type PreparedCatalogFileWrite,
|
|
66
68
|
} from "../internal/catalog-writer";
|
|
67
69
|
import { visibleCodexAccountSelectors } from "./account-models";
|
|
68
70
|
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models";
|
|
@@ -234,24 +236,6 @@ function revalidateRetainedCatalogSync(
|
|
|
234
236
|
};
|
|
235
237
|
}
|
|
236
238
|
|
|
237
|
-
/**
|
|
238
|
-
* Exact bytes currently on disk at `path`, or null when unreadable/absent.
|
|
239
|
-
*
|
|
240
|
-
* Deliberately a Buffer rather than a decoded string: `readFileSync(path, "utf8")`
|
|
241
|
-
* substitutes U+FFFD for every invalid byte, so a file holding a raw 0x80 decodes
|
|
242
|
-
* equal to prepared content holding a legitimately encoded U+FFFD. Comparing the
|
|
243
|
-
* decoded strings would then classify a malformed catalog as identical, skip the
|
|
244
|
-
* atomic repair write, and leave the corruption on disk while reporting
|
|
245
|
-
* `catalogWritten: false`.
|
|
246
|
-
*/
|
|
247
|
-
function currentCatalogFileContent(path: string): Buffer | null {
|
|
248
|
-
try {
|
|
249
|
-
return readFileSync(path);
|
|
250
|
-
} catch {
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
239
|
function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null {
|
|
256
240
|
if (read.onDiskCatalog && !catalogHasRoutedEntries(read.onDiskCatalog)) {
|
|
257
241
|
try {
|
|
@@ -558,15 +542,12 @@ function writeRetainedCatalogSync({
|
|
|
558
542
|
// nothing about the catalog changed. Skipping the no-op write keeps both the mtime
|
|
559
543
|
// and `catalogWritten` honest; `added` still reports the routed rows the catalog
|
|
560
544
|
// carries, because they are on disk either way.
|
|
561
|
-
const
|
|
562
|
-
if (
|
|
545
|
+
const preparedCatalog: PreparedCatalogFileWrite = { path: catalogPath, content };
|
|
546
|
+
if (!preparedBytesDifferFromDisk(preparedCatalog)) {
|
|
563
547
|
return { added, path: catalogPath, catalogWritten: false, comboOmissions };
|
|
564
548
|
}
|
|
565
549
|
|
|
566
|
-
replaceActiveCodexCatalog(permit, owningCodexHome,
|
|
567
|
-
path: catalogPath,
|
|
568
|
-
content,
|
|
569
|
-
});
|
|
550
|
+
replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
|
|
570
551
|
return {
|
|
571
552
|
added,
|
|
572
553
|
path: catalogPath,
|
|
@@ -713,10 +694,25 @@ export function invalidateCodexModelsCacheWithPermit(
|
|
|
713
694
|
client_version: "0.0.0",
|
|
714
695
|
models: [...models, ...observedAccountModels],
|
|
715
696
|
};
|
|
716
|
-
|
|
697
|
+
const preparedCache: PreparedCatalogFileWrite = {
|
|
717
698
|
path: cachePath,
|
|
718
699
|
content: `${JSON.stringify(wrapper, null, 2)}\n`,
|
|
719
|
-
}
|
|
700
|
+
};
|
|
701
|
+
// The same no-op rule the active catalog already applies (#1459), for the same
|
|
702
|
+
// reason and at the second writer that has to obey it.
|
|
703
|
+
//
|
|
704
|
+
// This function is what `refreshCodexModelCatalog` reports as `cacheSynced`, and
|
|
705
|
+
// `handleStart` ORs that into the stale-app-server warning. Rewriting identical
|
|
706
|
+
// bytes bumped this file's mtime and returned `true`, so on a start where the
|
|
707
|
+
// catalog reproduced byte-identically — the settled case — the warning still
|
|
708
|
+
// claimed "Disk catalog/cache were updated" and told the operator their Codex
|
|
709
|
+
// model list might be stale, when nothing on disk had changed and Codex held the
|
|
710
|
+
// same model set the file already described. Returning `false` here makes
|
|
711
|
+
// `cacheSynced` mean what its name and its consumers already assume, and what
|
|
712
|
+
// `pullRemoteCatalog` and the early returns in `refreshCodexModelCatalog`
|
|
713
|
+
// already assert: a write happened.
|
|
714
|
+
if (!preparedBytesDifferFromDisk(preparedCache)) return false;
|
|
715
|
+
replaceCodexModelsCache(permit, owningCodexHome, preparedCache);
|
|
720
716
|
return true;
|
|
721
717
|
} catch {
|
|
722
718
|
return false;
|