@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-preview.20260707
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 +1 -0
- package/bin/ocx.mjs +4 -4
- package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
- package/gui/dist/assets/index-CkV5xFA8.js +15 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/anthropic-image-guard.ts +195 -0
- package/src/adapters/anthropic.ts +85 -14
- package/src/adapters/cursor/cursor-errors.ts +2 -2
- package/src/adapters/cursor/live-transport.ts +1 -1
- package/src/adapters/cursor/transport-retry.ts +2 -2
- package/src/adapters/google-errors.ts +1 -1
- package/src/adapters/google-http.ts +1 -1
- package/src/adapters/google-truncation.ts +1 -1
- package/src/adapters/google.ts +1 -1
- package/src/adapters/kiro-errors.ts +1 -1
- package/src/adapters/kiro-retry.ts +1 -1
- package/src/adapters/kiro-truncation.ts +1 -1
- package/src/adapters/kiro.ts +1 -1
- package/src/adapters/openai-chat.ts +12 -2
- package/src/adapters/openai-responses.ts +126 -3
- package/src/bridge.ts +164 -7
- package/src/{doctor.ts → cli/doctor.ts} +21 -4
- package/src/{cli-help.ts → cli/help.ts} +1 -1
- package/src/cli/index.ts +584 -0
- package/src/{init.ts → cli/init.ts} +6 -6
- package/src/{cli-models.ts → cli/models.ts} +2 -2
- package/src/{cli-provider.ts → cli/provider.ts} +12 -8
- package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
- package/src/{cli-status.ts → cli/status.ts} +7 -7
- package/src/cli.ts +9 -575
- package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
- package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
- package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
- package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
- package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
- package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
- package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
- package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
- package/src/codex/history-migration-guardian.ts +102 -0
- package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
- package/src/{codex-home.ts → codex/home.ts} +1 -1
- package/src/{codex-inject.ts → codex/inject.ts} +204 -26
- package/src/{codex-journal.ts → codex/journal.ts} +2 -2
- package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
- package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
- package/src/{codex-paths.ts → codex/paths.ts} +2 -2
- package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
- package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
- package/src/{codex-routing.ts → codex/routing.ts} +8 -8
- package/src/{codex-shim.ts → codex/shim.ts} +6 -5
- package/src/{codex-sync.ts → codex/sync.ts} +4 -4
- package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
- package/src/config.ts +2 -0
- package/src/generated/jawcode-model-metadata.ts +2 -0
- package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
- package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
- package/src/{process-control.ts → lib/process-control.ts} +1 -1
- package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
- package/src/oauth/callback-server.ts +1 -1
- package/src/oauth/google-antigravity.ts +7 -4
- package/src/oauth/index.ts +67 -16
- package/src/oauth/login-cli.ts +2 -2
- package/src/oauth/store.ts +236 -20
- package/src/oauth/token-guardian.ts +24 -20
- package/src/oauth/types.ts +16 -0
- package/src/providers/api-keys.ts +121 -0
- package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
- package/src/providers/derive.ts +2 -0
- package/src/providers/key-failover.ts +145 -0
- package/src/{provider-label.ts → providers/label.ts} +1 -1
- package/src/{provider-quota.ts → providers/quota.ts} +12 -7
- package/src/providers/registry.ts +66 -4
- package/src/responses/compaction.ts +117 -0
- package/src/responses/parser.ts +89 -13
- package/src/responses/reasoning-envelope.ts +52 -0
- package/src/responses/schema.ts +15 -3
- package/src/responses/state.ts +117 -2
- package/src/router.ts +2 -0
- package/src/server/auth-cors.ts +231 -0
- package/src/server/index.ts +523 -0
- package/src/server/lifecycle.ts +73 -0
- package/src/server/management-api.ts +628 -0
- package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
- package/src/server/relay.ts +534 -0
- package/src/server/request-decompress.ts +46 -0
- package/src/server/request-log.ts +310 -0
- package/src/server/responses.ts +775 -0
- package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
- package/src/service.ts +19 -11
- package/src/types.ts +27 -0
- package/src/{update.ts → update/index.ts} +5 -5
- package/src/{update-job.ts → update/job.ts} +7 -6
- package/src/{update-notify.ts → update/notify.ts} +3 -3
- package/src/{usage-debug.ts → usage/debug.ts} +3 -3
- package/src/{usage-log.ts → usage/log.ts} +3 -3
- package/src/{usage-summary.ts → usage/summary.ts} +3 -3
- package/src/{usage-totals.ts → usage/totals.ts} +1 -1
- package/src/vision/describe.ts +3 -3
- package/src/vision/index.ts +21 -1
- package/src/web-search/executor.ts +4 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +84 -24
- package/gui/dist/assets/index-BcHhxo1I.css +0 -1
- package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
- package/src/server.ts +0 -2501
- /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
- /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
- /package/src/{abort.ts → lib/abort.ts} +0 -0
- /package/src/{debug.ts → lib/debug.ts} +0 -0
- /package/src/{errors.ts → lib/errors.ts} +0 -0
- /package/src/{open-url.ts → lib/open-url.ts} +0 -0
- /package/src/{privacy.ts → lib/privacy.ts} +0 -0
- /package/src/{redact.ts → lib/redact.ts} +0 -0
- /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
- /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
- /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
- /package/src/{ports.ts → server/ports.ts} +0 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-key pool for key-auth providers (the API-key twin of OAuth multiauth).
|
|
3
|
+
*
|
|
4
|
+
* `provider.apiKey` stays the single source of truth for routing — it always mirrors the
|
|
5
|
+
* ACTIVE pool entry, so the router/adapters never learn about the pool. The pool itself
|
|
6
|
+
* lives in `provider.apiKeyPool` in config.json (same file that already holds apiKey).
|
|
7
|
+
* A provider with a legacy bare `apiKey` is seeded into a one-entry pool on first touch.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { saveConfig } from "../config";
|
|
11
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
12
|
+
|
|
13
|
+
export interface ProviderApiKeyInfo {
|
|
14
|
+
id: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
/** First/last 4 chars only; env references (`${VAR}`) are shown verbatim (not secrets). */
|
|
17
|
+
masked: string;
|
|
18
|
+
active: boolean;
|
|
19
|
+
addedAt?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isEnvReference(value: string): boolean {
|
|
23
|
+
return /^\$\{?\w+\}?$/.test(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function maskApiKey(value: string): string {
|
|
27
|
+
if (isEnvReference(value)) return value;
|
|
28
|
+
if (value.length <= 8) return "****";
|
|
29
|
+
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Content-derived id: re-adding the same key upserts instead of duplicating. */
|
|
33
|
+
function keyId(key: string): string {
|
|
34
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 8);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True for providers whose upstream auth is a configured API key (not oauth/forward). */
|
|
38
|
+
export function isKeyAuthProvider(provider: OcxProviderConfig): boolean {
|
|
39
|
+
return provider.authMode !== "oauth" && provider.authMode !== "forward";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Seed the pool from a legacy bare `apiKey`, and keep `apiKey` mirrored to the active entry. */
|
|
43
|
+
function ensurePool(provider: OcxProviderConfig): NonNullable<OcxProviderConfig["apiKeyPool"]> {
|
|
44
|
+
if (!provider.apiKeyPool) provider.apiKeyPool = [];
|
|
45
|
+
if (provider.apiKeyPool.length === 0 && provider.apiKey) {
|
|
46
|
+
provider.apiKeyPool.push({ id: keyId(provider.apiKey), key: provider.apiKey });
|
|
47
|
+
}
|
|
48
|
+
return provider.apiKeyPool;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function activeEntryId(provider: OcxProviderConfig): string | null {
|
|
52
|
+
const pool = provider.apiKeyPool ?? [];
|
|
53
|
+
if (pool.length === 0) return null;
|
|
54
|
+
return (pool.find(e => e.key === provider.apiKey) ?? pool[0]!).id;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function listProviderApiKeys(config: OcxConfig, name: string): { activeId: string | null; keys: ProviderApiKeyInfo[] } {
|
|
58
|
+
const provider = config.providers[name];
|
|
59
|
+
if (!provider || !isKeyAuthProvider(provider)) return { activeId: null, keys: [] };
|
|
60
|
+
const pool = ensurePool(provider);
|
|
61
|
+
const activeId = activeEntryId(provider);
|
|
62
|
+
return {
|
|
63
|
+
activeId,
|
|
64
|
+
keys: pool.map(entry => ({
|
|
65
|
+
id: entry.id,
|
|
66
|
+
...(entry.label ? { label: entry.label } : {}),
|
|
67
|
+
masked: maskApiKey(entry.key),
|
|
68
|
+
active: entry.id === activeId,
|
|
69
|
+
...(entry.addedAt !== undefined ? { addedAt: entry.addedAt } : {}),
|
|
70
|
+
})),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Add (or upsert) a key and make it ACTIVE. Persists config. */
|
|
75
|
+
export function addProviderApiKey(config: OcxConfig, name: string, key: string, label?: string): { id: string } | { error: string } {
|
|
76
|
+
const provider = config.providers[name];
|
|
77
|
+
if (!provider || !isKeyAuthProvider(provider)) return { error: "provider does not use API-key auth" };
|
|
78
|
+
const trimmed = key.trim();
|
|
79
|
+
if (!trimmed) return { error: "key is required" };
|
|
80
|
+
if (/[\r\n]/.test(trimmed)) return { error: "key must not include line breaks" };
|
|
81
|
+
const pool = ensurePool(provider);
|
|
82
|
+
const id = keyId(trimmed);
|
|
83
|
+
const existing = pool.find(e => e.id === id);
|
|
84
|
+
if (existing) {
|
|
85
|
+
if (label?.trim()) existing.label = label.trim();
|
|
86
|
+
} else {
|
|
87
|
+
pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
|
|
88
|
+
}
|
|
89
|
+
provider.apiKey = trimmed;
|
|
90
|
+
saveConfig(config);
|
|
91
|
+
return { id };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Switch the ACTIVE key (mirrors into `provider.apiKey`). Persists config. */
|
|
95
|
+
export function setActiveProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
|
|
96
|
+
const provider = config.providers[name];
|
|
97
|
+
if (!provider || !isKeyAuthProvider(provider)) return false;
|
|
98
|
+
const entry = ensurePool(provider).find(e => e.id === id);
|
|
99
|
+
if (!entry) return false;
|
|
100
|
+
provider.apiKey = entry.key;
|
|
101
|
+
saveConfig(config);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Remove one key; removing the active one promotes the first remaining. Persists config. */
|
|
106
|
+
export function removeProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
|
|
107
|
+
const provider = config.providers[name];
|
|
108
|
+
if (!provider || !isKeyAuthProvider(provider)) return false;
|
|
109
|
+
const pool = ensurePool(provider);
|
|
110
|
+
const entry = pool.find(e => e.id === id);
|
|
111
|
+
if (!entry) return false;
|
|
112
|
+
provider.apiKeyPool = pool.filter(e => e.id !== id);
|
|
113
|
+
if (provider.apiKey === entry.key) {
|
|
114
|
+
const next = provider.apiKeyPool[0];
|
|
115
|
+
if (next) provider.apiKey = next.key;
|
|
116
|
+
else delete provider.apiKey;
|
|
117
|
+
}
|
|
118
|
+
if (provider.apiKeyPool.length === 0) delete provider.apiKeyPool;
|
|
119
|
+
saveConfig(config);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
package/src/providers/derive.ts
CHANGED
|
@@ -82,6 +82,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
|
|
|
82
82
|
...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}),
|
|
83
83
|
...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
|
|
84
84
|
...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
|
|
85
|
+
...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
|
|
85
86
|
...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
|
|
86
87
|
...(entry.googleMode ? { googleMode: entry.googleMode } : {}),
|
|
87
88
|
...(entry.project ? { project: entry.project } : {}),
|
|
@@ -178,6 +179,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
|
|
|
178
179
|
if (!prov.noPenaltyModels && seed.noPenaltyModels) prov.noPenaltyModels = [...seed.noPenaltyModels];
|
|
179
180
|
if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels];
|
|
180
181
|
if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
|
|
182
|
+
if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels];
|
|
181
183
|
if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames;
|
|
182
184
|
}
|
|
183
185
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-key 429 failover for non-OpenAI providers.
|
|
3
|
+
*
|
|
4
|
+
* When a provider's upstream returns 429, this module picks the next available key
|
|
5
|
+
* from `apiKeyPool`, puts the exhausted key into cooldown (respecting Retry-After),
|
|
6
|
+
* and returns a fresh provider config with the swapped key. If all keys are in
|
|
7
|
+
* cooldown, returns null so the caller surfaces the 429 to the client.
|
|
8
|
+
*
|
|
9
|
+
* Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
|
|
10
|
+
*/
|
|
11
|
+
import { saveConfig } from "../config";
|
|
12
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
13
|
+
|
|
14
|
+
// ---- cooldown state (in-memory, same as codex/routing.ts) ----
|
|
15
|
+
|
|
16
|
+
interface KeyCooldown {
|
|
17
|
+
cooldownUntil: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_COOLDOWN_MS = 60_000;
|
|
21
|
+
const MAX_COOLDOWN_MS = 10 * 60_000; // cap at 10 min for api-key rotation
|
|
22
|
+
|
|
23
|
+
/** Map<`${providerName}\0${keyId}`, KeyCooldown> */
|
|
24
|
+
const keyCooldowns = new Map<string, KeyCooldown>();
|
|
25
|
+
|
|
26
|
+
function cooldownKey(providerName: string, keyId: string): string {
|
|
27
|
+
return `${providerName}\0${keyId}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseRetryAfterMs(value: string | null | undefined, now = Date.now()): number | undefined {
|
|
31
|
+
const text = value?.trim();
|
|
32
|
+
if (!text) return undefined;
|
|
33
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
|
34
|
+
const seconds = Number(text);
|
|
35
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
36
|
+
return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const timestamp = Date.parse(text);
|
|
40
|
+
if (!Number.isFinite(timestamp)) return undefined;
|
|
41
|
+
const delay = timestamp - now;
|
|
42
|
+
return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isKeyInCooldown(providerName: string, keyId: string, now = Date.now()): boolean {
|
|
46
|
+
const entry = keyCooldowns.get(cooldownKey(providerName, keyId));
|
|
47
|
+
if (!entry) return false;
|
|
48
|
+
if (entry.cooldownUntil <= now) {
|
|
49
|
+
keyCooldowns.delete(cooldownKey(providerName, keyId));
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- public API ----
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check whether a provider has multiple keys available for failover.
|
|
59
|
+
* Returns true only for key-auth providers with 2+ pool entries.
|
|
60
|
+
*/
|
|
61
|
+
export function hasKeyPoolFailover(provider: OcxProviderConfig): boolean {
|
|
62
|
+
if (provider.authMode === "oauth" || provider.authMode === "forward") return false;
|
|
63
|
+
return (provider.apiKeyPool?.length ?? 0) >= 2;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Record a 429 for the current key and attempt to switch to the next available one.
|
|
68
|
+
*
|
|
69
|
+
* @returns A new OcxProviderConfig with the swapped key (and mutated config on disk),
|
|
70
|
+
* or `null` when no alternative key is available (all in cooldown or pool < 2).
|
|
71
|
+
*/
|
|
72
|
+
export function rotateKeyOn429(
|
|
73
|
+
config: OcxConfig,
|
|
74
|
+
providerName: string,
|
|
75
|
+
retryAfterHeader: string | null | undefined,
|
|
76
|
+
now = Date.now(),
|
|
77
|
+
attemptedKey?: string,
|
|
78
|
+
): OcxProviderConfig | null {
|
|
79
|
+
const provider = config.providers[providerName];
|
|
80
|
+
if (!provider) return null;
|
|
81
|
+
if (provider.authMode === "oauth" || provider.authMode === "forward") return null;
|
|
82
|
+
|
|
83
|
+
const pool = provider.apiKeyPool;
|
|
84
|
+
if (!pool || pool.length < 2) return null;
|
|
85
|
+
|
|
86
|
+
// Cool the key that ACTUALLY failed. Under concurrent 429s another request may already have
|
|
87
|
+
// rotated provider.apiKey — cooling the live key would punish an innocent replacement and can
|
|
88
|
+
// exhaust a 2-key pool from a single bad key. CAS semantics: callers pass the key they used.
|
|
89
|
+
const failedKey = attemptedKey ?? provider.apiKey;
|
|
90
|
+
const currentEntry = pool.find(e => e.key === failedKey);
|
|
91
|
+
if (currentEntry) {
|
|
92
|
+
const cooldownMs = parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS;
|
|
93
|
+
keyCooldowns.set(cooldownKey(providerName, currentEntry.id), {
|
|
94
|
+
cooldownUntil: now + cooldownMs,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Lost the race: someone already rotated away from the failed key. If the live key is healthy,
|
|
99
|
+
// retry with it as-is instead of rotating a second time.
|
|
100
|
+
if (attemptedKey !== undefined && provider.apiKey !== attemptedKey) {
|
|
101
|
+
const liveEntry = pool.find(e => e.key === provider.apiKey);
|
|
102
|
+
if (liveEntry && !isKeyInCooldown(providerName, liveEntry.id, now)) {
|
|
103
|
+
return { ...provider };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Pick the next key that is NOT in cooldown
|
|
108
|
+
const currentIndex = currentEntry ? pool.indexOf(currentEntry) : -1;
|
|
109
|
+
for (let i = 1; i < pool.length; i++) {
|
|
110
|
+
const candidate = pool[(currentIndex + i) % pool.length]!;
|
|
111
|
+
if (!isKeyInCooldown(providerName, candidate.id, now)) {
|
|
112
|
+
// Swap active key
|
|
113
|
+
provider.apiKey = candidate.key;
|
|
114
|
+
saveConfig(config);
|
|
115
|
+
console.warn(
|
|
116
|
+
// Log ids only — labels are user-supplied free text and could carry secret material.
|
|
117
|
+
`[key-failover] ${providerName}: 429 on key ${currentEntry?.id ?? "?"}; rotating to key ${candidate.id}`,
|
|
118
|
+
);
|
|
119
|
+
return { ...provider };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// All keys in cooldown
|
|
124
|
+
console.warn(`[key-failover] ${providerName}: all ${pool.length} keys in cooldown; returning 429 to client`);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Clear cooldown state for a provider (e.g. after manual key management). */
|
|
129
|
+
export function clearKeyCooldowns(providerName?: string): void {
|
|
130
|
+
if (!providerName) {
|
|
131
|
+
keyCooldowns.clear();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const prefix = `${providerName}\0`;
|
|
135
|
+
for (const key of keyCooldowns.keys()) {
|
|
136
|
+
if (key.startsWith(prefix)) keyCooldowns.delete(key);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Visible-for-testing: get the cooldown-until timestamp for a key. */
|
|
141
|
+
export function getKeyCooldownUntil(providerName: string, keyId: string, now = Date.now()): number | null {
|
|
142
|
+
const entry = keyCooldowns.get(cooldownKey(providerName, keyId));
|
|
143
|
+
if (!entry) return null;
|
|
144
|
+
return entry.cooldownUntil > now ? entry.cooldownUntil : null;
|
|
145
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { listCodexAuthAccounts } from "
|
|
2
|
-
import { MAIN_CODEX_ACCOUNT_ID } from "
|
|
3
|
-
import { getValidAccessToken } from "
|
|
4
|
-
import { getCredential } from "
|
|
5
|
-
import { antigravityUserAgent } from "
|
|
6
|
-
import { getProviderRegistryEntry } from "./
|
|
7
|
-
import type { OcxConfig, OcxProviderConfig } from "
|
|
1
|
+
import { listCodexAuthAccounts } from "../codex/auth-api";
|
|
2
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
3
|
+
import { getValidAccessToken } from "../oauth";
|
|
4
|
+
import { getCredential } from "../oauth/store";
|
|
5
|
+
import { antigravityUserAgent } from "../adapters/client-fingerprint";
|
|
6
|
+
import { getProviderRegistryEntry } from "./registry";
|
|
7
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
8
8
|
|
|
9
9
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
10
10
|
const REQUEST_TIMEOUT_MS = 8_000;
|
|
@@ -43,6 +43,11 @@ export interface ProviderQuotaResponse {
|
|
|
43
43
|
|
|
44
44
|
let cache: { key: string; ts: number; response: ProviderQuotaResponse } | null = null;
|
|
45
45
|
|
|
46
|
+
/** Invalidate the report cache (e.g. after switching a provider's active account). */
|
|
47
|
+
export function clearProviderQuotaCache(): void {
|
|
48
|
+
cache = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
function cacheKey(config: OcxConfig): string {
|
|
47
52
|
const providers = Object.entries(config.providers)
|
|
48
53
|
.map(([name, provider]) => `${name}:${provider.authMode ?? "key"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}`)
|
|
@@ -39,6 +39,7 @@ export interface ProviderRegistryEntry {
|
|
|
39
39
|
noPenaltyModels?: string[];
|
|
40
40
|
autoToolChoiceOnlyModels?: string[];
|
|
41
41
|
preserveReasoningContentModels?: string[];
|
|
42
|
+
thinkingToggleModels?: string[];
|
|
42
43
|
escapeBuiltinToolNames?: boolean;
|
|
43
44
|
oauthId?: string;
|
|
44
45
|
jawcodeBundle?: string;
|
|
@@ -55,13 +56,18 @@ export type ProviderConfigSeed = Pick<
|
|
|
55
56
|
| "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities"
|
|
56
57
|
| "reasoningEfforts" | "modelReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
|
|
57
58
|
| "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
|
|
58
|
-
| "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "escapeBuiltinToolNames"
|
|
59
|
+
| "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "thinkingToggleModels" | "escapeBuiltinToolNames"
|
|
59
60
|
| "googleMode" | "project" | "location"
|
|
60
61
|
>;
|
|
61
62
|
|
|
62
63
|
|
|
63
64
|
const OLLAMA_REASONING_MAP: Record<string, string> = { xhigh: "max" };
|
|
64
65
|
|
|
66
|
+
// Shared between the OAuth (Claude account) and API-key Anthropic entries so both expose the
|
|
67
|
+
// same static model seed.
|
|
68
|
+
const ANTHROPIC_MODELS = ["claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
|
|
69
|
+
const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = { "claude-sonnet-5": 1_000_000 };
|
|
70
|
+
|
|
65
71
|
const ZAI_GLM_52_MODELS = ["glm-5.2", "glm-5.2[1m]"];
|
|
66
72
|
const ZAI_GLM_52_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"];
|
|
67
73
|
const ZAI_GLM_52_REASONING_MAP: Record<string, string> = {
|
|
@@ -73,6 +79,26 @@ const ZAI_GLM_52_REASONING_MAP: Record<string, string> = {
|
|
|
73
79
|
xhigh: "max",
|
|
74
80
|
max: "max",
|
|
75
81
|
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Vendor thinking-toggle models (MiMo v2.x, GLM 5/5.1 on Zen Go): the wire knob is
|
|
85
|
+
* `thinking: {type: enabled|disabled}` — a binary. Advertise a two-step Codex ladder
|
|
86
|
+
* (low = thinking off, high = thinking on) and map efforts onto the toggle. Zen Go
|
|
87
|
+
* pass-through probed live 2026-07-07 (glm-5.2 toggle verified; mimo/minimax accept shape).
|
|
88
|
+
*/
|
|
89
|
+
const THINKING_TOGGLE_EFFORTS = ["low", "high"];
|
|
90
|
+
const THINKING_TOGGLE_MAP: Record<string, string> = {
|
|
91
|
+
none: "disabled",
|
|
92
|
+
minimal: "disabled",
|
|
93
|
+
low: "disabled",
|
|
94
|
+
medium: "enabled",
|
|
95
|
+
high: "enabled",
|
|
96
|
+
xhigh: "enabled",
|
|
97
|
+
max: "enabled",
|
|
98
|
+
};
|
|
99
|
+
const OPENCODE_GO_THINKING_TOGGLE_MODELS = [
|
|
100
|
+
"mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-omni", "mimo-v2-pro", "glm-5", "glm-5.1",
|
|
101
|
+
];
|
|
76
102
|
const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"];
|
|
77
103
|
const DEEPSEEK_THINKING_EFFORTS = ["high", "xhigh"];
|
|
78
104
|
const DEEPSEEK_THINKING_REASONING_MAP: Record<string, string> = {
|
|
@@ -148,6 +174,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
148
174
|
modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS),
|
|
149
175
|
modelInputModalities: cursorModelInputModalities(CURSOR_STATIC_MODELS),
|
|
150
176
|
modelReasoningEfforts: cursorModelReasoningEfforts(CURSOR_STATIC_MODELS),
|
|
177
|
+
// Cursor's wire protocol never forwards image parts (request-builder emits an unsupported-
|
|
178
|
+
// content marker), so the vision sidecar covers ALL cursor models regardless of what the
|
|
179
|
+
// upstream model could natively do. Live-discovered models outside the static list fall back
|
|
180
|
+
// to the same marker until they appear here.
|
|
181
|
+
noVisionModels: cursorModelIds(CURSOR_STATIC_MODELS),
|
|
151
182
|
},
|
|
152
183
|
{
|
|
153
184
|
id: "xai",
|
|
@@ -174,8 +205,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
174
205
|
oauthId: "anthropic",
|
|
175
206
|
jawcodeBundle: "anthropic",
|
|
176
207
|
note: "Log in with your Claude account",
|
|
177
|
-
models: [
|
|
178
|
-
modelContextWindows: {
|
|
208
|
+
models: [...ANTHROPIC_MODELS],
|
|
209
|
+
modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS },
|
|
210
|
+
defaultModel: "claude-sonnet-4-6",
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: "anthropic-apikey",
|
|
214
|
+
label: "Anthropic (API key)",
|
|
215
|
+
adapter: "anthropic",
|
|
216
|
+
baseUrl: "https://api.anthropic.com",
|
|
217
|
+
authKind: "key",
|
|
218
|
+
featured: true,
|
|
219
|
+
dashboardUrl: "https://console.anthropic.com/settings/keys",
|
|
220
|
+
jawcodeBundle: "anthropic",
|
|
221
|
+
extraMetadataAliases: ["anthropic-key"],
|
|
222
|
+
note: "Direct Anthropic API billing — no Claude subscription",
|
|
223
|
+
models: [...ANTHROPIC_MODELS],
|
|
224
|
+
liveModels: true,
|
|
225
|
+
modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS },
|
|
179
226
|
defaultModel: "claude-sonnet-4-6",
|
|
180
227
|
},
|
|
181
228
|
{
|
|
@@ -251,9 +298,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
251
298
|
"glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
|
|
252
299
|
"kimi-k2.7-code": [],
|
|
253
300
|
"kimi-k2.7-code-highspeed": [],
|
|
301
|
+
...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS])),
|
|
254
302
|
},
|
|
255
|
-
modelReasoningEffortMap: {
|
|
303
|
+
modelReasoningEffortMap: {
|
|
304
|
+
"glm-5.2": ZAI_GLM_52_REASONING_MAP,
|
|
305
|
+
...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])),
|
|
306
|
+
},
|
|
307
|
+
thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS,
|
|
256
308
|
noReasoningModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
|
|
309
|
+
// Text-only Zen Go models (jawcode metadata) — the vision sidecar describes images for
|
|
310
|
+
// every model listed here (and the catalog advertises image input on their behalf).
|
|
311
|
+
// Kimi K2.7 Code accepts text+image+video: do NOT list it here.
|
|
312
|
+
noVisionModels: [
|
|
313
|
+
"glm-5.2", "glm-5", "glm-5.1",
|
|
314
|
+
"deepseek-v4-flash", "deepseek-v4-pro",
|
|
315
|
+
"mimo-v2-pro", "mimo-v2.5-pro",
|
|
316
|
+
"minimax-m2.5", "minimax-m2.7",
|
|
317
|
+
"qwen3.7-max",
|
|
318
|
+
],
|
|
257
319
|
noTemperatureModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
|
|
258
320
|
noTopPModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
|
|
259
321
|
noPenaltyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote compaction v2 support for ROUTED providers.
|
|
3
|
+
*
|
|
4
|
+
* Codex decides "this provider supports remote compaction" by provider name (built-in `OpenAI`),
|
|
5
|
+
* and Design B points that provider at this proxy — so Codex sends remote compaction v2 requests
|
|
6
|
+
* for EVERY routed model. The request is a normal /responses call whose input ends with
|
|
7
|
+
* `{"type":"compaction_trigger"}`; codex-rs `collect_compaction_output` then requires the stream
|
|
8
|
+
* to carry EXACTLY ONE `{"type":"compaction","encrypted_content":...}` output item
|
|
9
|
+
* (compact_remote_v2.rs) or it fatals with "expected exactly one compaction output item".
|
|
10
|
+
*
|
|
11
|
+
* Routed models cannot produce OpenAI's encrypted blob, so the proxy runs the model as a plain
|
|
12
|
+
* summarizer and wraps the summary text in a transparent envelope: `ocx1:` + base64(utf8 summary).
|
|
13
|
+
* Codex stores the item and replays it in later input; the parser decodes our envelope back into
|
|
14
|
+
* plain text for routed models. Real OpenAI-encrypted blobs (no `ocx1:` prefix) are opaque —
|
|
15
|
+
* routed models get a short "history was compacted" note instead.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const OCX_COMPACTION_PREFIX = "ocx1:";
|
|
19
|
+
|
|
20
|
+
/** Mirrors codex-rs core/templates/compact/prompt.md (the local-compaction instruction). */
|
|
21
|
+
export const COMPACT_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
|
|
22
|
+
|
|
23
|
+
Include:
|
|
24
|
+
- Current progress and key decisions made
|
|
25
|
+
- Important context, constraints, or user preferences
|
|
26
|
+
- What remains to be done (clear next steps)
|
|
27
|
+
- Any critical data, examples, or references needed to continue
|
|
28
|
+
|
|
29
|
+
Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
|
|
30
|
+
|
|
31
|
+
/** Mirrors codex-rs core/templates/compact/summary_prefix.md (framing for a replayed summary). */
|
|
32
|
+
export const SUMMARY_PREFIX = "Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:";
|
|
33
|
+
|
|
34
|
+
export const OPAQUE_COMPACTION_NOTE = "[earlier conversation was compacted; the summary is stored in a format this model cannot read]";
|
|
35
|
+
|
|
36
|
+
export function encodeCompactionSummary(summary: string): string {
|
|
37
|
+
return OCX_COMPACTION_PREFIX + Buffer.from(summary, "utf-8").toString("base64");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Decode an `ocx1:` envelope; returns null for real (OpenAI-encrypted) blobs or garbage. */
|
|
41
|
+
export function decodeCompactionSummary(encryptedContent: string): string | null {
|
|
42
|
+
if (!encryptedContent.startsWith(OCX_COMPACTION_PREFIX)) return null;
|
|
43
|
+
try {
|
|
44
|
+
return Buffer.from(encryptedContent.slice(OCX_COMPACTION_PREFIX.length), "base64").toString("utf-8");
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Render a replayed compaction item as plain user-visible text for a routed model. */
|
|
51
|
+
export function compactionItemToText(encryptedContent: string | undefined): string {
|
|
52
|
+
const decoded = typeof encryptedContent === "string" ? decodeCompactionSummary(encryptedContent) : null;
|
|
53
|
+
return decoded ? `${SUMMARY_PREFIX}\n\n${decoded}` : OPAQUE_COMPACTION_NOTE;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Remote compaction v1 (`POST /responses/compact`, unary) — codex-rs installs the returned
|
|
58
|
+
* `{"output":[ResponseItem...]}` as the REPLACEMENT history (compact_remote.rs
|
|
59
|
+
* process_compacted_history). Mirror codex-rs local `build_compacted_history`: recent real user
|
|
60
|
+
* messages within a token budget, then one user message `SUMMARY_PREFIX\n<summary>`. Plain user
|
|
61
|
+
* message items parse as real user messages on the codex side (event_mapping parse_user_message);
|
|
62
|
+
* contextual wrappers are filtered there, and v2-style `compaction` items are NOT expected here.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/** codex-rs compact.rs COMPACT_USER_MESSAGE_MAX_TOKENS = 20k tokens (~4 chars/token). */
|
|
66
|
+
const COMPACT_V1_RETAINED_CHAR_BUDGET = 20_000 * 4;
|
|
67
|
+
|
|
68
|
+
/** Extract plain-text user messages from a Responses `input` array (for v1 compact retention). */
|
|
69
|
+
export function extractCompactUserMessages(input: unknown): string[] {
|
|
70
|
+
if (!Array.isArray(input)) return [];
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
for (const item of input) {
|
|
73
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
74
|
+
const rec = item as { type?: string; role?: string; content?: unknown };
|
|
75
|
+
if (rec.type !== undefined && rec.type !== "message") continue;
|
|
76
|
+
if (rec.role !== "user") continue;
|
|
77
|
+
let text = "";
|
|
78
|
+
if (typeof rec.content === "string") text = rec.content;
|
|
79
|
+
else if (Array.isArray(rec.content)) {
|
|
80
|
+
text = rec.content
|
|
81
|
+
.map(b => {
|
|
82
|
+
if (!b || typeof b !== "object") return "";
|
|
83
|
+
const block = b as { type?: string; text?: string };
|
|
84
|
+
return (block.type === "input_text" || block.type === "text") && typeof block.text === "string" ? block.text : "";
|
|
85
|
+
})
|
|
86
|
+
.join("");
|
|
87
|
+
}
|
|
88
|
+
if (text.trim().length > 0) out.push(text);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function compactUserMessageItem(text: string): Record<string, unknown> {
|
|
94
|
+
return { type: "message", role: "user", content: [{ type: "input_text", text }] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Build the v1 compact `output` array: retained recent user messages + the summary message. */
|
|
98
|
+
export function buildCompactV1Output(userMessages: string[], summary: string): Record<string, unknown>[] {
|
|
99
|
+
const selected: string[] = [];
|
|
100
|
+
let remaining = COMPACT_V1_RETAINED_CHAR_BUDGET;
|
|
101
|
+
for (let i = userMessages.length - 1; i >= 0 && remaining > 0; i--) {
|
|
102
|
+
const msg = userMessages[i];
|
|
103
|
+
if (msg.length <= remaining) {
|
|
104
|
+
selected.push(msg);
|
|
105
|
+
remaining -= msg.length;
|
|
106
|
+
} else {
|
|
107
|
+
// Budget partially covers this older message: keep its tail (most recent context) and stop.
|
|
108
|
+
selected.push(msg.slice(msg.length - remaining));
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
selected.reverse();
|
|
113
|
+
// codex-rs compact.rs uses "{SUMMARY_PREFIX}\n{summary}" (single newline) and detects stored
|
|
114
|
+
// summaries by that exact prefix — keep the same shape.
|
|
115
|
+
const summaryText = summary.trim().length > 0 ? `${SUMMARY_PREFIX}\n${summary}` : "(no summary available)";
|
|
116
|
+
return [...selected.map(compactUserMessageItem), compactUserMessageItem(summaryText)];
|
|
117
|
+
}
|