@yansigit/opencodex 2.36.1-dev.20260829.47 → 2.36.1-dev.20260829.50
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/{ApiKeys-DAuYQsGJ.js → ApiKeys-BLPOvK4z.js} +1 -1
- package/gui/dist/assets/{Claude-bN_80RT8.js → Claude-BbHv3EU7.js} +1 -1
- package/gui/dist/assets/{CodexSet-D3dWX_ay.js → CodexSet-hgu5hES-.js} +1 -1
- package/gui/dist/assets/{FileIntegrationPage-BGyzkPs2.js → FileIntegrationPage-RCmDvvsx.js} +1 -1
- package/gui/dist/assets/{Grok-CuaKd1TT.js → Grok-DH-ggRqA.js} +1 -1
- package/gui/dist/assets/{Integrations-C7NGQoxR.js → Integrations-D6m5E2-T.js} +2 -2
- package/gui/dist/assets/{IntegrationsOverview-DjVorYO5.js → IntegrationsOverview-C1rbnXV7.js} +1 -1
- package/gui/dist/assets/{Logs-B21a-9xe.js → Logs-Bqb0vYRk.js} +1 -1
- package/gui/dist/assets/{Models-CN7Q3kuK.js → Models-gMnzHf0c.js} +1 -1
- package/gui/dist/assets/{NumberStepper-BWg90tEs.js → NumberStepper-awaoZNlj.js} +1 -1
- package/gui/dist/assets/{Providers-CP_i-7LS.js → Providers-Bo6wuQMa.js} +1 -1
- package/gui/dist/assets/{RestoreDialog-3M0NqCiD.js → RestoreDialog-DX7SGWIk.js} +1 -1
- package/gui/dist/assets/{Startup-BVPFoP2s.js → Startup-B1htiLU9.js} +1 -1
- package/gui/dist/assets/{Storage-BdsG9ypz.js → Storage-B3MfosmS.js} +1 -1
- package/gui/dist/assets/{Subagents-CguR_bGQ.js → Subagents-L6Q2V9PZ.js} +1 -1
- package/gui/dist/assets/{Usage-C0kKi9_H.js → Usage-xbZnkDoj.js} +1 -1
- package/gui/dist/assets/{codex-stale-banner-D4gvSWnO.js → codex-stale-banner-W4FhikN7.js} +1 -1
- package/gui/dist/assets/{data-surface-BtO3lwam.js → data-surface-B8PX8rjm.js} +1 -1
- package/gui/dist/assets/{data-surface-BCycbObp.js → data-surface-BbBMp0Wj.js} +1 -1
- package/gui/dist/assets/{index-CU1jE0st.js → index-JEC_6CUW.js} +3 -3
- package/gui/dist/assets/{model-display-D2elXkuE.js → model-display-BvLWy9D5.js} +1 -1
- package/gui/dist/assets/{provider-payload-DfkXWE7v.js → provider-payload-BRfSsonS.js} +1 -1
- package/gui/dist/assets/{section-tabs-BP7gEPK6.js → section-tabs-B8sLMiz6.js} +1 -1
- package/gui/dist/assets/shared-DI18-uF7.js +69 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/base.ts +26 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +34 -41
- package/src/adapters/cursor/envelope-echo.ts +128 -0
- package/src/adapters/cursor/request-builder.ts +19 -12
- package/src/adapters/cursor/tool-definitions.ts +2 -1
- package/src/adapters/cursor/tool-result-normalize.ts +23 -31
- package/src/adapters/cursor.ts +21 -2
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +71 -2
- package/src/adapters/google.ts +19 -4
- package/src/adapters/kiro-constants.ts +12 -0
- package/src/adapters/kiro.ts +128 -11
- package/src/adapters/openai-chat.ts +16 -2
- package/src/adapters/openai-responses.ts +15 -2
- package/src/adapters/tool-catalog-nudge.ts +2 -1
- package/src/adapters/xai-web-search.ts +10 -14
- package/src/claude/outbound.ts +14 -3
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +93 -16
- package/src/cli/account-extended.ts +262 -36
- package/src/cli/account-main.ts +12 -12
- package/src/cli/account.ts +40 -10
- package/src/cli/agent.ts +8 -1
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +535 -0
- package/src/cli/claude-desktop.ts +31 -11
- package/src/cli/config-command.ts +7 -3
- package/src/cli/dispatch.ts +195 -27
- package/src/cli/doctor.ts +100 -1
- package/src/cli/help.ts +11 -2
- package/src/cli/index.ts +19 -3
- package/src/cli/init.ts +2 -2
- package/src/cli/inspect.ts +230 -0
- package/src/cli/observe.ts +11 -3
- package/src/cli/provider.ts +58 -74
- package/src/cli/registry.ts +34 -2
- package/src/cli/runtime-api.ts +51 -7
- package/src/cli/status.ts +16 -0
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/usage-report.ts +52 -2
- package/src/cli/version-skew.ts +46 -0
- package/src/codex/account-label.ts +21 -0
- package/src/codex/catalog/provider-fetch.ts +4 -0
- package/src/codex/convergence.ts +110 -15
- package/src/codex/transition-state.ts +12 -3
- package/src/compatibility/openai-responses.ts +9 -1
- package/src/config.ts +209 -11
- package/src/generated/compatibility-version.json +115 -75
- package/src/integrations/ownership-policy.ts +24 -5
- package/src/integrations/ownership.ts +36 -2
- package/src/integrations/state.ts +40 -7
- package/src/integrations/writer.ts +21 -3
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/errors.ts +25 -1
- package/src/lib/service-secrets.ts +15 -0
- package/src/oauth/index.ts +143 -82
- package/src/oauth/login-cli.ts +53 -11
- package/src/oauth/store.ts +16 -6
- package/src/providers/alibaba-region-startup.ts +31 -11
- package/src/providers/api-keys.ts +58 -34
- package/src/providers/key-failover.ts +59 -46
- package/src/providers/label.ts +34 -1
- package/src/providers/model-rename-startup.ts +25 -7
- package/src/providers/openai-tier-startup.ts +38 -14
- package/src/responses/turn-termination.ts +107 -0
- package/src/server/index.ts +10 -0
- package/src/server/management/context.ts +10 -3
- package/src/server/management/logs-usage-routes.ts +24 -43
- package/src/server/management/model-routes.ts +284 -165
- package/src/server/management/oauth-account-routes.ts +93 -15
- package/src/server/management/provider-routes.ts +200 -87
- package/src/server/management/route-registry.ts +317 -0
- package/src/server/proxy-liveness.ts +27 -4
- package/src/server/request-log.ts +37 -17
- package/src/server/responses/core.ts +80 -4
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/service.ts +34 -0
- package/src/storage/policy-input.ts +166 -0
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +115 -221
- package/src/usage/log.ts +32 -6
- package/src/usage/summary.ts +22 -34
- package/gui/dist/assets/shared-BdYGV-yJ.js +0 -69
package/src/oauth/login-cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as readline from "node:readline";
|
|
2
2
|
import { openUrl } from "../lib/open-url";
|
|
3
|
-
import { loadConfig,
|
|
3
|
+
import { initializePersistedConfigIfMissing, loadConfig, mutatePersistedConfig } from "../config";
|
|
4
4
|
import { findLiveProxy } from "../server/proxy-liveness";
|
|
5
5
|
import {
|
|
6
6
|
requestBoundLocalProviderReload,
|
|
@@ -11,6 +11,7 @@ import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginP
|
|
|
11
11
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
12
12
|
import { configuredAdminToken } from "../lib/admin-secrets";
|
|
13
13
|
import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
|
|
14
|
+
import { apiKeyPoolEntryId } from "../providers/api-keys";
|
|
14
15
|
|
|
15
16
|
const LIVE_RELOAD_PROVIDERS = new Set<string>([
|
|
16
17
|
...listOAuthProviders(),
|
|
@@ -121,7 +122,7 @@ async function handleAiStudioLogin(): Promise<void> {
|
|
|
121
122
|
|
|
122
123
|
const config = loadConfig();
|
|
123
124
|
if (!config.providers["google-aistudio"]) {
|
|
124
|
-
config
|
|
125
|
+
await commitKeyLoginProvider(config, "google-aistudio", {
|
|
125
126
|
adapter: "google",
|
|
126
127
|
googleMode: "ai-studio-web",
|
|
127
128
|
baseUrl: "https://alkalimakersuite-pa.clients6.google.com",
|
|
@@ -129,8 +130,7 @@ async function handleAiStudioLogin(): Promise<void> {
|
|
|
129
130
|
liveModels: false,
|
|
130
131
|
defaultModel: "gemini-3.7-flash",
|
|
131
132
|
models: ["gemini-3.7-flash", "gemini-3.1-pro-preview", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-3.5-flash"],
|
|
132
|
-
};
|
|
133
|
-
saveConfig(config);
|
|
133
|
+
});
|
|
134
134
|
console.log("\n ✓ Configured 'google-aistudio' in ~/.opencodex/config.json");
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -200,10 +200,28 @@ export function mergeKeyLoginProviderRow(
|
|
|
200
200
|
provider: OcxProviderConfig,
|
|
201
201
|
existing: OcxProviderConfig | undefined,
|
|
202
202
|
): OcxProviderConfig {
|
|
203
|
-
return
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
203
|
+
if (!existing) return structuredClone(provider);
|
|
204
|
+
const merged = structuredClone(existing);
|
|
205
|
+
merged.adapter = provider.adapter;
|
|
206
|
+
merged.baseUrl = provider.baseUrl;
|
|
207
|
+
if (provider.authMode !== undefined) merged.authMode = provider.authMode;
|
|
208
|
+
else delete merged.authMode;
|
|
209
|
+
delete merged.azureCredential;
|
|
210
|
+
if (provider.apiKeyTransport !== undefined) merged.apiKeyTransport = provider.apiKeyTransport;
|
|
211
|
+
if (provider.apiKey) {
|
|
212
|
+
const pool = merged.apiKeyPool ?? (merged.apiKey
|
|
213
|
+
? [{ id: apiKeyPoolEntryId(merged.apiKey), key: merged.apiKey }]
|
|
214
|
+
: []);
|
|
215
|
+
const existingKey = pool.find(entry => entry.key === provider.apiKey);
|
|
216
|
+
if (!existingKey) {
|
|
217
|
+
const id = apiKeyPoolEntryId(provider.apiKey);
|
|
218
|
+
if (pool.some(entry => entry.id === id)) throw new Error("API-key pool ID collision");
|
|
219
|
+
pool.push({ id, key: provider.apiKey, addedAt: Date.now() });
|
|
220
|
+
}
|
|
221
|
+
if (pool.length > 0) merged.apiKeyPool = pool;
|
|
222
|
+
merged.apiKey = provider.apiKey;
|
|
223
|
+
}
|
|
224
|
+
return merged;
|
|
207
225
|
}
|
|
208
226
|
|
|
209
227
|
/**
|
|
@@ -219,9 +237,33 @@ export async function commitKeyLoginProvider(
|
|
|
219
237
|
provider: OcxProviderConfig,
|
|
220
238
|
onLiveReload?: (result: LocalProviderReloadResult | null) => void,
|
|
221
239
|
): Promise<OcxProviderConfig> {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
240
|
+
let mergedProvider = mergeKeyLoginProviderRow(provider, config.providers[name]);
|
|
241
|
+
const mutate = () => mutatePersistedConfig(fresh => {
|
|
242
|
+
const collision = codexAccountNamespaceProviderCollisionError(fresh.codexAccountNamespaces, name);
|
|
243
|
+
if (collision) throw new Error(collision);
|
|
244
|
+
mergedProvider = mergeKeyLoginProviderRow(provider, fresh.providers[name]);
|
|
245
|
+
fresh.providers[name] = mergedProvider;
|
|
246
|
+
return { changed: true, value: { config: structuredClone(fresh), provider: structuredClone(mergedProvider) } };
|
|
247
|
+
});
|
|
248
|
+
let outcome = mutate();
|
|
249
|
+
if (outcome.status === "unavailable" && outcome.reason === "missing") {
|
|
250
|
+
const initial = structuredClone(config);
|
|
251
|
+
const collision = codexAccountNamespaceProviderCollisionError(initial.codexAccountNamespaces, name);
|
|
252
|
+
if (collision) throw new Error(collision);
|
|
253
|
+
mergedProvider = mergeKeyLoginProviderRow(provider, initial.providers[name]);
|
|
254
|
+
initial.providers[name] = mergedProvider;
|
|
255
|
+
const initialized = initializePersistedConfigIfMissing(initial);
|
|
256
|
+
if (initialized === "invalid") throw new Error("config is invalid");
|
|
257
|
+
outcome = initialized === "created"
|
|
258
|
+
? { status: "committed", value: { config: initial, provider: structuredClone(mergedProvider) } }
|
|
259
|
+
: mutate();
|
|
260
|
+
}
|
|
261
|
+
if (outcome.status === "unavailable") throw new Error(outcome.reason === "conflict"
|
|
262
|
+
? "config changed while saving provider; retry"
|
|
263
|
+
: `config is ${outcome.reason}`);
|
|
264
|
+
for (const key of Object.keys(config)) delete (config as unknown as Record<string, unknown>)[key];
|
|
265
|
+
Object.assign(config, outcome.value.config);
|
|
266
|
+
mergedProvider = outcome.value.provider;
|
|
225
267
|
// Evaluate the reload BEFORE the optional call: `onLiveReload?.(await ...)` short-circuits
|
|
226
268
|
// the whole argument list when no callback is supplied, so the reload would never fire for
|
|
227
269
|
// callers that do not care about the outcome.
|
package/src/oauth/store.ts
CHANGED
|
@@ -591,24 +591,34 @@ export async function upsertCredentialByIdentity(
|
|
|
591
591
|
}, [provider, safe]);
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
-
/**
|
|
595
|
-
|
|
594
|
+
/**
|
|
595
|
+
* Remove the ACTIVE account; remaining accounts promote the first one.
|
|
596
|
+
*
|
|
597
|
+
* Returns what actually happened, which a caller cannot otherwise know. A read-then-remove
|
|
598
|
+
* preflight is not equivalent: `mutateStore` serializes mutations, so between a caller's
|
|
599
|
+
* `getAccountSet` check and its `removeCredential` call another process can remove the same
|
|
600
|
+
* account, and both callers would then report a removal that only one of them performed.
|
|
601
|
+
* Deciding inside the mutation is the only place the answer is true when it is returned.
|
|
602
|
+
*/
|
|
603
|
+
export async function removeCredential(provider: string): Promise<"removed" | "not-found"> {
|
|
596
604
|
let removedAccountId: string | undefined;
|
|
597
|
-
await mutateStore(store => {
|
|
605
|
+
const result = await mutateStore(store => {
|
|
598
606
|
const set = store[provider];
|
|
599
|
-
if (!set) return;
|
|
607
|
+
if (!set) return "not-found" as const;
|
|
600
608
|
removedAccountId = set.activeAccountId;
|
|
601
609
|
set.accounts = set.accounts.filter(a => a.id !== set.activeAccountId);
|
|
602
610
|
if (set.accounts.length === 0) {
|
|
603
611
|
delete store[provider];
|
|
604
|
-
return;
|
|
612
|
+
return "removed" as const;
|
|
605
613
|
}
|
|
606
614
|
set.activeAccountId = set.accounts[0]!.id;
|
|
615
|
+
return "removed" as const;
|
|
607
616
|
}, [provider]);
|
|
608
|
-
if (provider === "google-antigravity" && removedAccountId) {
|
|
617
|
+
if (result === "removed" && provider === "google-antigravity" && removedAccountId) {
|
|
609
618
|
const { clearAntigravityRoutingStateForAccount } = await import("./antigravity-routing");
|
|
610
619
|
clearAntigravityRoutingStateForAccount(removedAccountId);
|
|
611
620
|
}
|
|
621
|
+
return result;
|
|
612
622
|
}
|
|
613
623
|
|
|
614
624
|
// ---------------------------------------------------------------------------
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mutatePersistedConfig } from "../config";
|
|
2
2
|
import { backupConfigBeforeAlibabaRegionMigration } from "./alibaba-region-backup";
|
|
3
3
|
import { projectAlibabaRegionMigration } from "./alibaba-region-migration";
|
|
4
4
|
import type { OcxConfig } from "../types";
|
|
@@ -6,7 +6,7 @@ import type { OcxConfig } from "../types";
|
|
|
6
6
|
export interface AlibabaRegionStartupDeps {
|
|
7
7
|
project: typeof projectAlibabaRegionMigration;
|
|
8
8
|
backup: () => void;
|
|
9
|
-
save
|
|
9
|
+
save?: (config: OcxConfig) => void;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -22,15 +22,35 @@ export function runAlibabaRegionStartupMigration(
|
|
|
22
22
|
deps: AlibabaRegionStartupDeps = {
|
|
23
23
|
project: projectAlibabaRegionMigration,
|
|
24
24
|
backup: () => { backupConfigBeforeAlibabaRegionMigration(); },
|
|
25
|
-
save: saveConfig,
|
|
26
25
|
},
|
|
27
26
|
): OcxConfig {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
if (deps.save) {
|
|
28
|
+
const projection = deps.project(config);
|
|
29
|
+
if (!projection.changed) {
|
|
30
|
+
for (const warning of projection.warnings) console.warn(`[alibaba-region-migration] ${warning}`);
|
|
31
|
+
return projection.config;
|
|
32
|
+
}
|
|
33
|
+
deps.backup();
|
|
34
|
+
deps.save(projection.config);
|
|
35
|
+
for (const warning of projection.warnings) console.warn(`[alibaba-region-migration] ${warning}`);
|
|
36
|
+
return projection.config;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let previousSnapshot: string | undefined;
|
|
40
|
+
const outcome = mutatePersistedConfig(fresh => {
|
|
41
|
+
const snapshot = JSON.stringify(fresh);
|
|
42
|
+
const next = deps.project(fresh);
|
|
43
|
+
// The callback is repeated after freshness validation. Back up only the
|
|
44
|
+
// confirmed preimage that is about to be committed.
|
|
45
|
+
if (next.changed && snapshot === previousSnapshot) deps.backup();
|
|
46
|
+
previousSnapshot = snapshot;
|
|
47
|
+
if (next.changed) {
|
|
48
|
+
for (const key of Object.keys(fresh)) delete (fresh as unknown as Record<string, unknown>)[key];
|
|
49
|
+
Object.assign(fresh, next.config);
|
|
50
|
+
}
|
|
51
|
+
return { changed: next.changed, value: next };
|
|
52
|
+
});
|
|
53
|
+
if (outcome.status === "unavailable") return config;
|
|
54
|
+
for (const warning of outcome.value.warnings) console.warn(`[alibaba-region-migration] ${warning}`);
|
|
55
|
+
return outcome.value.config;
|
|
36
56
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* A provider with a legacy bare `apiKey` is seeded into a one-entry pool on first touch.
|
|
8
8
|
*/
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
|
-
import {
|
|
10
|
+
import { mutatePersistedConfig } from "../config";
|
|
11
11
|
import { isAzureIdentityProvider } from "../config/provider-validation";
|
|
12
12
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
13
13
|
|
|
@@ -62,6 +62,23 @@ function activeEntryId(provider: OcxProviderConfig): string | null {
|
|
|
62
62
|
return (pool.find(e => e.key === provider.apiKey) ?? pool[0]!).id;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function mutateProvider<T>(
|
|
66
|
+
config: OcxConfig,
|
|
67
|
+
name: string,
|
|
68
|
+
mutate: (provider: OcxProviderConfig) => T | null,
|
|
69
|
+
): T | null {
|
|
70
|
+
const outcome = mutatePersistedConfig(fresh => {
|
|
71
|
+
const provider = fresh.providers[name];
|
|
72
|
+
if (!provider || !isKeyAuthProvider(provider)) return { changed: false, value: null };
|
|
73
|
+
const before = JSON.stringify(provider);
|
|
74
|
+
const value = mutate(provider);
|
|
75
|
+
return { changed: value !== null && JSON.stringify(provider) !== before, value: value === null ? null : { provider, value } };
|
|
76
|
+
});
|
|
77
|
+
if (outcome.status === "unavailable" || outcome.value === null) return null;
|
|
78
|
+
config.providers[name] = structuredClone(outcome.value.provider);
|
|
79
|
+
return outcome.value.value;
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
export function listProviderApiKeys(config: OcxConfig, name: string): { activeId: string | null; keys: ProviderApiKeyInfo[] } {
|
|
66
83
|
const provider = config.providers[name];
|
|
67
84
|
if (!provider || !isKeyAuthProvider(provider)) return { activeId: null, keys: [] };
|
|
@@ -86,56 +103,63 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string,
|
|
|
86
103
|
if (typeof key !== "string" || !key.trim()) return { error: "key is required" };
|
|
87
104
|
const trimmed = sanitizeApiKeyValue(key);
|
|
88
105
|
if (!trimmed) return { error: "key must not include line breaks" };
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
const saved = mutateProvider(config, name, fresh => {
|
|
107
|
+
const pool = ensurePool(fresh);
|
|
108
|
+
const existing = pool.find(entry => entry.key === trimmed);
|
|
109
|
+
if (existing) {
|
|
110
|
+
if (label?.trim()) existing.label = label.trim();
|
|
111
|
+
fresh.apiKey = trimmed;
|
|
112
|
+
return { id: existing.id };
|
|
113
|
+
}
|
|
114
|
+
const id = apiKeyPoolEntryId(trimmed);
|
|
115
|
+
if (pool.some(entry => entry.id === id)) return { error: "key id collision" };
|
|
95
116
|
pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return {
|
|
117
|
+
fresh.apiKey = trimmed;
|
|
118
|
+
return { id };
|
|
119
|
+
});
|
|
120
|
+
return saved === null ? { error: "config is unavailable" } : saved;
|
|
100
121
|
}
|
|
101
122
|
|
|
102
123
|
/** Switch the ACTIVE key (mirrors into `provider.apiKey`). Persists config. */
|
|
103
124
|
export function setActiveProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
|
|
104
125
|
const provider = config.providers[name];
|
|
105
126
|
if (!provider || !isKeyAuthProvider(provider)) return false;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
127
|
+
return mutateProvider(config, name, fresh => {
|
|
128
|
+
const entry = ensurePool(fresh).find(candidate => candidate.id === id);
|
|
129
|
+
if (!entry) return null;
|
|
130
|
+
fresh.apiKey = entry.key;
|
|
131
|
+
return true;
|
|
132
|
+
}) === true;
|
|
111
133
|
}
|
|
112
134
|
|
|
113
135
|
/** Rename a key slot without changing its id, secret, or active routing state. */
|
|
114
136
|
export function setProviderApiKeyLabel(config: OcxConfig, name: string, id: string, label: string | undefined): boolean {
|
|
115
137
|
const provider = config.providers[name];
|
|
116
138
|
if (!provider || !isKeyAuthProvider(provider)) return false;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
return mutateProvider(config, name, fresh => {
|
|
140
|
+
const entry = ensurePool(fresh).find(candidate => candidate.id === id);
|
|
141
|
+
if (!entry) return null;
|
|
142
|
+
if (label) entry.label = label;
|
|
143
|
+
else delete entry.label;
|
|
144
|
+
return true;
|
|
145
|
+
}) === true;
|
|
123
146
|
}
|
|
124
147
|
|
|
125
148
|
/** Remove one key; removing the active one promotes the first remaining. Persists config. */
|
|
126
149
|
export function removeProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
|
|
127
150
|
const provider = config.providers[name];
|
|
128
151
|
if (!provider || !isKeyAuthProvider(provider)) return false;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
152
|
+
return mutateProvider(config, name, fresh => {
|
|
153
|
+
const pool = ensurePool(fresh);
|
|
154
|
+
const entry = pool.find(candidate => candidate.id === id);
|
|
155
|
+
if (!entry) return null;
|
|
156
|
+
fresh.apiKeyPool = pool.filter(candidate => candidate.id !== id);
|
|
157
|
+
if (fresh.apiKey === entry.key) {
|
|
158
|
+
const next = fresh.apiKeyPool[0];
|
|
159
|
+
if (next) fresh.apiKey = next.key;
|
|
160
|
+
else delete fresh.apiKey;
|
|
161
|
+
}
|
|
162
|
+
if (fresh.apiKeyPool.length === 0) delete fresh.apiKeyPool;
|
|
163
|
+
return true;
|
|
164
|
+
}) === true;
|
|
141
165
|
}
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { mutatePersistedConfig } from "../config";
|
|
12
12
|
import { isAzureIdentityProvider } from "../config/provider-validation";
|
|
13
|
+
import { routedProviderConfig } from "../router";
|
|
13
14
|
import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy } from "../types";
|
|
14
15
|
import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
|
|
15
16
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
@@ -159,50 +160,66 @@ export function rotateKeyOn429(
|
|
|
159
160
|
if (isAzureIdentityProvider(provider)) return null;
|
|
160
161
|
if (provider.authMode === "oauth" || provider.authMode === "forward") return null;
|
|
161
162
|
|
|
162
|
-
const pool = provider.apiKeyPool;
|
|
163
|
-
if (!pool || pool.length < 2) return null;
|
|
164
|
-
|
|
165
|
-
// Cool the key that ACTUALLY failed. Under concurrent 429s another request may already have
|
|
166
|
-
// rotated provider.apiKey — cooling the live key would punish an innocent replacement and can
|
|
167
|
-
// exhaust a 2-key pool from a single bad key. CAS semantics: callers pass the key they used.
|
|
168
163
|
const failedKey = attemptedKey ?? provider.apiKey;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
164
|
+
type Rotation =
|
|
165
|
+
| { provider: OcxProviderConfig; failedId?: string; candidateId?: string }
|
|
166
|
+
| { exhaustedCount: number };
|
|
167
|
+
const outcome = mutatePersistedConfig<Rotation | null>(fresh => {
|
|
168
|
+
const freshProvider = fresh.providers[providerName];
|
|
169
|
+
if (!freshProvider || isAzureIdentityProvider(freshProvider)
|
|
170
|
+
|| freshProvider.authMode === "oauth" || freshProvider.authMode === "forward") {
|
|
171
|
+
return { changed: false, value: null };
|
|
172
|
+
}
|
|
173
|
+
const pool = freshProvider.apiKeyPool;
|
|
174
|
+
if (!pool || pool.length < 2) return { changed: false, value: null };
|
|
177
175
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
176
|
+
// Cool the key that actually failed. The callback is rerun after rebasing,
|
|
177
|
+
// so both the id and the active-key comparison come from the commit preimage.
|
|
178
|
+
const failedEntry = pool.find(entry => entry.key === failedKey);
|
|
179
|
+
if (failedEntry) {
|
|
180
|
+
const cooldownMs = parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS;
|
|
181
|
+
keyCooldowns.set(cooldownKey(providerName, failedEntry.id), { cooldownUntil: now + cooldownMs });
|
|
182
|
+
sweepExpiredOnWrite(now);
|
|
184
183
|
}
|
|
185
|
-
}
|
|
186
184
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
console.warn(
|
|
196
|
-
// Log ids only — labels are user-supplied free text and could carry secret material.
|
|
197
|
-
`[key-failover] ${providerName}: 429 on key ${currentEntry?.id ?? "?"}; rotating to key ${candidate.id}`,
|
|
198
|
-
);
|
|
199
|
-
return { ...provider };
|
|
185
|
+
if (freshProvider.apiKey !== failedKey) {
|
|
186
|
+
const activeEntry = pool.find(entry => entry.key === freshProvider.apiKey);
|
|
187
|
+
if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) {
|
|
188
|
+
return {
|
|
189
|
+
changed: false,
|
|
190
|
+
value: { provider: structuredClone(freshProvider), failedId: failedEntry?.id, candidateId: undefined },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
200
193
|
}
|
|
194
|
+
|
|
195
|
+
const currentIndex = failedEntry ? pool.indexOf(failedEntry) : -1;
|
|
196
|
+
const candidateCount = failedEntry ? pool.length - 1 : pool.length;
|
|
197
|
+
for (let offset = 1; offset <= candidateCount; offset += 1) {
|
|
198
|
+
const candidate = pool[(currentIndex + offset) % pool.length]!;
|
|
199
|
+
if (isKeyInCooldown(providerName, candidate.id, now)) continue;
|
|
200
|
+
freshProvider.apiKey = candidate.key;
|
|
201
|
+
return {
|
|
202
|
+
changed: true,
|
|
203
|
+
value: { provider: structuredClone(freshProvider), failedId: failedEntry?.id, candidateId: candidate.id },
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return { changed: false, value: { exhaustedCount: pool.length } };
|
|
207
|
+
});
|
|
208
|
+
if (outcome.status === "unavailable" || outcome.value === null) return null;
|
|
209
|
+
if ("exhaustedCount" in outcome.value) {
|
|
210
|
+
console.warn(`[key-failover] ${providerName}: all ${outcome.value.exhaustedCount} keys in cooldown; returning 429 to client`);
|
|
211
|
+
return null;
|
|
201
212
|
}
|
|
202
213
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
214
|
+
const committed = structuredClone(outcome.value.provider);
|
|
215
|
+
config.providers[providerName] = committed;
|
|
216
|
+
if (outcome.value.candidateId) {
|
|
217
|
+
console.warn(
|
|
218
|
+
// Log ids only — labels are user-supplied free text and could carry secret material.
|
|
219
|
+
`[key-failover] ${providerName}: 429 on key ${outcome.value.failedId ?? "?"}; rotating to key ${outcome.value.candidateId}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return structuredClone(committed);
|
|
206
223
|
}
|
|
207
224
|
|
|
208
225
|
export function sweepExpiredApiKeyCooldowns(now = Date.now()): number {
|
|
@@ -225,13 +242,9 @@ interface RotateProviderTransportOptions {
|
|
|
225
242
|
/**
|
|
226
243
|
* Rotate a failed key and re-apply provider-specific transport metadata to the replacement.
|
|
227
244
|
*
|
|
228
|
-
*
|
|
229
|
-
* route
|
|
230
|
-
*
|
|
231
|
-
* provider from that snapshot would silently drop every field the registry merged in at
|
|
232
|
-
* routing time (scalar flags like `promptCacheKey`/`parallelToolCalls`, merged model
|
|
233
|
-
* metadata such as `noTemperatureModels`, a pinned baseUrl). Mirrors the OAuth-401 replay
|
|
234
|
-
* path in src/server/responses/core.ts, which spreads `route.provider` for the same reason.
|
|
245
|
+
* Put the authoritative committed row over request-only fields and registry backfills, then
|
|
246
|
+
* route it again so concurrent provider edits take effect without losing either kind of runtime
|
|
247
|
+
* metadata.
|
|
235
248
|
*/
|
|
236
249
|
export function rotateProviderTransportOn429(
|
|
237
250
|
config: OcxConfig,
|
|
@@ -249,7 +262,7 @@ export function rotateProviderTransportOn429(
|
|
|
249
262
|
return rotated
|
|
250
263
|
? resolveProviderTransport(
|
|
251
264
|
providerName,
|
|
252
|
-
{ ...routedProvider,
|
|
265
|
+
routedProviderConfig(providerName, { ...routedProvider, ...rotated }),
|
|
253
266
|
options.promptCacheKey,
|
|
254
267
|
)
|
|
255
268
|
: null;
|
package/src/providers/label.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
|
|
1
|
+
import { CODEX_ACCOUNT_LOG_LABEL_RE, oauthAccountLogLabel } from "../codex/account-label";
|
|
2
|
+
import type { OcxProviderConfig } from "../types";
|
|
2
3
|
|
|
3
4
|
export function canonicalUsageProviderLabel(provider: string): string {
|
|
4
5
|
return provider === "chatgpt" || provider === "openai-multi" ? "openai" : provider;
|
|
@@ -17,3 +18,35 @@ export function baseProviderLabel(provider: string): string {
|
|
|
17
18
|
if (suffix === "main") return canonicalUsageProviderLabel(provider.slice(0, cut));
|
|
18
19
|
return CODEX_ACCOUNT_LOG_LABEL_RE.test(suffix) ? canonicalUsageProviderLabel(provider.slice(0, cut)) : provider;
|
|
19
20
|
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stamp the per-account usage label for a non-Codex OAuth provider (#2699).
|
|
24
|
+
*
|
|
25
|
+
* Call this where the resolved credential is known and NOT inside a failover gate. The obvious
|
|
26
|
+
* place -- next to the `genericFailoverAccountId` assignment in `core.ts` -- sits inside
|
|
27
|
+
* `isGenericFailoverProvider`, and the rotation paths additionally require two or more stored
|
|
28
|
+
* accounts. Stamping there would leave the ordinary case (one xai account, failover off) with no
|
|
29
|
+
* label at all while every test still passed, which is the bug this fixes rather than a variant
|
|
30
|
+
* of it.
|
|
31
|
+
*
|
|
32
|
+
* Two providers are skipped because they already have attribution:
|
|
33
|
+
* - `openai` produces its own `p`-labels through `codexAuthContextLogLabel`.
|
|
34
|
+
* - `anthropic` folds the account into the provider label (`formatAnthropicProviderForLog`).
|
|
35
|
+
*
|
|
36
|
+
* It lives here rather than in `account-label.ts` because it needs `baseProviderLabel`, and this
|
|
37
|
+
* module already imports from that one -- the reverse direction would be an import cycle. This
|
|
38
|
+
* file stays Lab-clean, which matters because `core.ts` is one of the three files
|
|
39
|
+
* `tests/core-lab-boundary.test.ts` guards.
|
|
40
|
+
*/
|
|
41
|
+
export function stampOAuthAccountLabel(
|
|
42
|
+
logCtx: { accountLogLabel?: string },
|
|
43
|
+
providerName: string,
|
|
44
|
+
provider: Pick<OcxProviderConfig, "authMode">,
|
|
45
|
+
accountId: string | undefined,
|
|
46
|
+
): void {
|
|
47
|
+
if (!accountId) return;
|
|
48
|
+
if (provider.authMode !== "oauth") return;
|
|
49
|
+
const base = baseProviderLabel(providerName);
|
|
50
|
+
if (base === "openai" || base === "anthropic") return;
|
|
51
|
+
logCtx.accountLogLabel = oauthAccountLogLabel(accountId, base);
|
|
52
|
+
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mutatePersistedConfig } from "../config";
|
|
2
2
|
import { projectModelRenames } from "./model-rename-migration";
|
|
3
3
|
import type { OcxConfig } from "../types";
|
|
4
4
|
|
|
5
5
|
export interface ModelRenameStartupDeps {
|
|
6
6
|
project: typeof projectModelRenames;
|
|
7
|
-
save
|
|
7
|
+
save?: (config: OcxConfig) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function adoptConfig(target: OcxConfig, source: OcxConfig): void {
|
|
11
|
+
if (target === source) return;
|
|
12
|
+
for (const key of Object.keys(target)) delete (target as unknown as Record<string, unknown>)[key];
|
|
13
|
+
Object.assign(target, structuredClone(source));
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
/**
|
|
@@ -18,11 +24,23 @@ export interface ModelRenameStartupDeps {
|
|
|
18
24
|
*/
|
|
19
25
|
export function runModelRenameStartupMigration(
|
|
20
26
|
config: OcxConfig,
|
|
21
|
-
deps: ModelRenameStartupDeps = { project: projectModelRenames
|
|
27
|
+
deps: ModelRenameStartupDeps = { project: projectModelRenames },
|
|
22
28
|
): OcxConfig {
|
|
23
|
-
const projection = deps.project(config);
|
|
24
|
-
for (const warning of projection.warnings) console.warn(`[model-rename-migration] ${warning}`);
|
|
29
|
+
const projection = deps.project(structuredClone(config));
|
|
25
30
|
if (!projection.changed) return projection.config;
|
|
26
|
-
deps.save
|
|
27
|
-
|
|
31
|
+
if (deps.save) {
|
|
32
|
+
deps.save(projection.config);
|
|
33
|
+
adoptConfig(config, projection.config);
|
|
34
|
+
for (const warning of projection.warnings) console.warn(`[model-rename-migration] ${warning}`);
|
|
35
|
+
return config;
|
|
36
|
+
}
|
|
37
|
+
const outcome = mutatePersistedConfig(fresh => {
|
|
38
|
+
const next = deps.project(fresh);
|
|
39
|
+
if (next.changed) adoptConfig(fresh, next.config);
|
|
40
|
+
return { changed: next.changed, value: next };
|
|
41
|
+
});
|
|
42
|
+
if (outcome.status === "unavailable") return config;
|
|
43
|
+
adoptConfig(config, outcome.value.config);
|
|
44
|
+
for (const warning of outcome.value.warnings) console.warn(`[model-rename-migration] ${warning}`);
|
|
45
|
+
return config;
|
|
28
46
|
}
|
|
@@ -2,8 +2,8 @@ import {
|
|
|
2
2
|
backupConfigBeforeOpenAiTierMigration,
|
|
3
3
|
OpenAiTierBackupCollisionError,
|
|
4
4
|
OpenAiTierRollbackPreserveError,
|
|
5
|
+
mutatePersistedConfig,
|
|
5
6
|
preserveOpenAiTierRollbackSnapshot,
|
|
6
|
-
saveConfig,
|
|
7
7
|
} from "../config";
|
|
8
8
|
import type { OcxConfig } from "../types";
|
|
9
9
|
import { projectOpenAiTierMigration } from "./openai-tiers";
|
|
@@ -11,7 +11,7 @@ import { projectOpenAiTierMigration } from "./openai-tiers";
|
|
|
11
11
|
export interface OpenAiTierStartupDeps {
|
|
12
12
|
project: typeof projectOpenAiTierMigration;
|
|
13
13
|
backup: () => void;
|
|
14
|
-
save
|
|
14
|
+
save?: (config: OcxConfig) => void;
|
|
15
15
|
preserveRollback?: (error: OpenAiTierBackupCollisionError) => void;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -34,23 +34,47 @@ function defaultPreserveRollback(error: OpenAiTierBackupCollisionError): void {
|
|
|
34
34
|
const DEFAULT_DEPS: OpenAiTierStartupDeps = {
|
|
35
35
|
project: projectOpenAiTierMigration,
|
|
36
36
|
backup: backupConfigBeforeOpenAiTierMigration,
|
|
37
|
-
save: saveConfig,
|
|
38
37
|
};
|
|
39
38
|
|
|
40
39
|
export function runOpenAiTierStartupMigration(
|
|
41
40
|
config: OcxConfig,
|
|
42
41
|
deps: OpenAiTierStartupDeps = DEFAULT_DEPS,
|
|
43
42
|
): OcxConfig {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
43
|
+
const backup = (): void => {
|
|
44
|
+
try {
|
|
45
|
+
deps.backup();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (!(error instanceof OpenAiTierBackupCollisionError)) throw error;
|
|
48
|
+
(deps.preserveRollback ?? defaultPreserveRollback)(error);
|
|
49
|
+
deps.backup();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
if (deps.save) {
|
|
53
|
+
const projection = deps.project(config);
|
|
54
|
+
if (!projection.changed) return projection.config;
|
|
55
|
+
backup();
|
|
56
|
+
deps.save(projection.config);
|
|
57
|
+
for (const warning of projection.warnings) console.warn(`[openai-provider-migration] ${warning}`);
|
|
58
|
+
return projection.config;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let previousSnapshot: string | undefined;
|
|
62
|
+
const outcome = mutatePersistedConfig(fresh => {
|
|
63
|
+
const snapshot = JSON.stringify(fresh);
|
|
64
|
+
const next = deps.project(fresh);
|
|
65
|
+
// mutatePersistedConfig confirms a candidate by invoking us again with the same
|
|
66
|
+
// snapshot after rebasing. Back up only that confirmed preimage.
|
|
67
|
+
if (next.changed && snapshot === previousSnapshot) backup();
|
|
68
|
+
previousSnapshot = snapshot;
|
|
69
|
+
if (next.changed) {
|
|
70
|
+
for (const key of Object.keys(fresh)) delete (fresh as unknown as Record<string, unknown>)[key];
|
|
71
|
+
Object.assign(fresh, next.config);
|
|
72
|
+
}
|
|
73
|
+
return { changed: next.changed, value: next };
|
|
74
|
+
});
|
|
75
|
+
if (outcome.status === "unavailable") return config;
|
|
76
|
+
if (outcome.status === "committed") {
|
|
77
|
+
for (const warning of outcome.value.warnings) console.warn(`[openai-provider-migration] ${warning}`);
|
|
52
78
|
}
|
|
53
|
-
|
|
54
|
-
for (const warning of projection.warnings) console.warn(`[openai-provider-migration] ${warning}`);
|
|
55
|
-
return projection.config;
|
|
79
|
+
return outcome.value.config;
|
|
56
80
|
}
|