@yansigit/opencodex 2.33.1 → 2.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/gui/dist/assets/index-BjCaHxdz.js +112 -0
- package/gui/dist/assets/index-DLkXOXLC.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -3
- package/src/adapters/anthropic.ts +79 -2
- package/src/adapters/command-code.ts +40 -3
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/checkpoint-store.ts +15 -10
- package/src/adapters/cursor/discovery.ts +60 -2
- package/src/adapters/cursor/effort-map.ts +79 -1
- package/src/adapters/cursor/envelope-echo.ts +9 -9
- package/src/adapters/cursor/live-models.ts +7 -2
- package/src/adapters/cursor/live-transport.ts +14 -0
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-shell.ts +103 -6
- package/src/adapters/cursor/native-exec.ts +20 -0
- package/src/adapters/cursor/protobuf-request.ts +74 -39
- package/src/adapters/cursor/request-builder.ts +32 -10
- package/src/adapters/cursor/tool-definitions.ts +13 -10
- package/src/adapters/cursor/tool-result-normalize.ts +35 -3
- package/src/adapters/cursor/types.ts +16 -4
- package/src/adapters/cursor.ts +97 -70
- package/src/adapters/google-antigravity-replay.ts +105 -25
- package/src/adapters/google-antigravity-wire.ts +5 -0
- package/src/adapters/google-errors.ts +41 -12
- package/src/adapters/google-http.ts +12 -11
- package/src/adapters/google.ts +156 -65
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +15 -0
- package/src/adapters/kiro-tools.ts +43 -15
- package/src/adapters/kiro.ts +54 -9
- package/src/adapters/openai-chat.ts +286 -242
- package/src/adapters/openai-responses.ts +334 -24
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-catalog-nudge.ts +2 -2
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/bridge.ts +67 -26
- package/src/chat/inbound.ts +14 -1
- package/src/chat/outbound.ts +15 -7
- package/src/claude/agents-inject.ts +8 -1
- package/src/claude/outbound.ts +10 -8
- package/src/cli/account-api.ts +27 -7
- package/src/cli/account-extended.ts +10 -3
- package/src/cli/account.ts +29 -5
- package/src/cli/alias.ts +66 -0
- package/src/cli/claude.ts +26 -1
- package/src/cli/dispatch.ts +13 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/init.ts +1 -0
- package/src/cli/models-runtime.ts +95 -0
- package/src/cli/models.ts +13 -7
- package/src/cli/provider-runtime.ts +16 -2
- package/src/cli/registry.ts +6 -1
- package/src/cli/telemetry-commands.ts +25 -0
- package/src/cli/v2.ts +34 -10
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +3 -2
- package/src/codex/app-server-processes.ts +80 -6
- package/src/codex/auth-api.ts +48 -8
- package/src/codex/auth-context.ts +21 -18
- package/src/codex/catalog/aggregation.ts +6 -0
- package/src/codex/catalog/model-metadata.ts +13 -1
- package/src/codex/catalog/native-models.ts +5 -2
- package/src/codex/catalog/parsing.ts +16 -0
- package/src/codex/catalog/provider-fetch.ts +20 -37
- package/src/codex/catalog/sync.ts +127 -2
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-write-lock.ts +3 -1
- package/src/codex/convergence-types.ts +1 -1
- package/src/codex/convergence.ts +22 -2
- package/src/codex/desired-state.ts +2 -2
- package/src/codex/desktop-app-restart.ts +18 -5
- package/src/codex/inject-coordination.ts +83 -0
- package/src/codex/inject.ts +14 -1
- package/src/codex/log-guard/inspect.ts +22 -4
- package/src/codex/model-entitlements.ts +9 -2
- package/src/codex/prompt-layers.ts +371 -25
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota.ts +123 -18
- package/src/codex/routing.ts +9 -0
- package/src/codex/subagent-model-fallback.ts +198 -27
- package/src/codex/transition-state.ts +107 -8
- package/src/combos/types.ts +10 -0
- package/src/compatibility/openai-responses.ts +33 -1
- package/src/config/autonomous-remediation.ts +21 -0
- package/src/config/provider-validation.ts +14 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +191 -17
- package/src/generated/compatibility-version.json +229 -153
- package/src/images/loop.ts +5 -4
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
- package/src/lab/fabric/producer-child.ts +1 -1
- package/src/lib/config-ownership.ts +20 -0
- package/src/lib/errors.ts +11 -2
- package/src/lib/package-tree-integrity.ts +101 -0
- package/src/oauth/aistudio-credentials.ts +65 -0
- package/src/oauth/aistudio-native-daemon.ts +54 -0
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/index.ts +57 -5
- package/src/oauth/key-providers.ts +10 -1
- package/src/oauth/kiro.ts +45 -0
- package/src/oauth/login-cli.ts +19 -20
- package/src/oauth/types.ts +15 -0
- package/src/providers/codex-capacity.ts +5 -2
- package/src/providers/command-code-efforts.ts +38 -6
- package/src/providers/context-cap.ts +4 -3
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +28 -0
- package/src/providers/fastwire.ts +7 -1
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/new-model-policy.ts +146 -0
- package/src/providers/provider-id-rewrite.ts +2 -1
- package/src/providers/quota.ts +73 -14
- package/src/providers/registry.ts +160 -68
- package/src/providers/slug-codec.ts +52 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/custom-tool-compat.ts +34 -10
- package/src/responses/parser.ts +4 -0
- package/src/responses/schema.ts +5 -1
- package/src/responses/thought-signature-replay.ts +17 -0
- package/src/router.ts +40 -2
- package/src/routing/analytics.ts +1 -0
- package/src/routing/quota.ts +10 -0
- package/src/server/auth-cors.ts +23 -0
- package/src/server/chat-completions.ts +24 -16
- package/src/server/chat-native-sse.ts +3 -3
- package/src/server/chat-native.ts +30 -11
- package/src/server/claude-messages.ts +1 -1
- package/src/server/effort-policy.ts +16 -0
- package/src/server/index.ts +152 -80
- package/src/server/lifecycle.ts +52 -1
- package/src/server/management/agent-settings-routes.ts +31 -15
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +2 -1
- package/src/server/management/config-routes.ts +27 -9
- package/src/server/management/context.ts +9 -0
- package/src/server/management/logs-usage-routes.ts +1 -1
- package/src/server/management/model-routes.ts +266 -0
- package/src/server/management/provider-routes.ts +137 -3
- package/src/server/management/routing-profile-routes.ts +2 -2
- package/src/server/management-api.ts +2 -0
- package/src/server/relay-eager.ts +147 -20
- package/src/server/relay.ts +251 -19
- package/src/server/request-log-conversation.ts +21 -0
- package/src/server/request-log.ts +46 -20
- package/src/server/responses/collaboration.ts +42 -5
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/core.ts +571 -137
- package/src/server/responses/empty-completion-guard.ts +35 -0
- package/src/server/responses/fetch-helpers.ts +14 -6
- package/src/server/responses/input-admission.ts +3 -1
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/responses-field-backfill.ts +105 -13
- package/src/server/responses/ws-upstream.ts +35 -5
- package/src/server/responses-custom-tool-repair.ts +52 -7
- package/src/server/responses-terminal-repair.ts +25 -4
- package/src/server/sse-frame-buffer.ts +31 -4
- package/src/server/ws-bridge.ts +15 -4
- package/src/telemetry/dispatcher.ts +44 -0
- package/src/telemetry/fingerprint.ts +24 -0
- package/src/telemetry/hook.ts +43 -0
- package/src/telemetry/ledger.ts +54 -0
- package/src/telemetry/types.ts +23 -0
- package/src/types/config.ts +66 -14
- package/src/types/provider.ts +77 -0
- package/src/types/request.ts +16 -10
- package/src/types/tools.ts +20 -25
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +43 -24
- package/src/usage/log.ts +17 -7
- package/src/web-search/exa-executor.ts +40 -9
- package/src/web-search/loop.ts +5 -4
- package/gui/dist/assets/index-CIDo4y4k.js +0 -102
- package/gui/dist/assets/index-DrSQdTRd.css +0 -1
- package/src/server/aistudio-ws-hub.ts +0 -295
package/src/images/loop.ts
CHANGED
|
@@ -274,10 +274,11 @@ export interface ImageBridgeDeps {
|
|
|
274
274
|
/** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
|
|
275
275
|
onUsage?: (usage: OcxUsage | undefined) => void;
|
|
276
276
|
/**
|
|
277
|
-
* Optional 429
|
|
278
|
-
* rotated
|
|
277
|
+
* Optional 429 failover for the routed (non-xAI) model. Return a rebuilt adapter for the
|
|
278
|
+
* rotated credential, or null when the pool is exhausted. Async hooks support OAuth refresh;
|
|
279
|
+
* existing synchronous key-pool hooks remain valid.
|
|
279
280
|
*/
|
|
280
|
-
on429?: (retryAfterHeader: string | null) => ProviderAdapter | null
|
|
281
|
+
on429?: (retryAfterHeader: string | null) => ProviderAdapter | null | Promise<ProviderAdapter | null>;
|
|
281
282
|
/** Opt-in same-target 429 policy (key-auth providers). When present, 429 replays on the SAME key before on429 rotation. */
|
|
282
283
|
retryOn429Policy?: Required<RateLimitRetryPolicy> | null;
|
|
283
284
|
/** Called when the bridged Responses stream completes (parity with runTurn / routed paths). */
|
|
@@ -592,7 +593,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
592
593
|
}
|
|
593
594
|
// 429 key-failover parity with web-search / normal routed path.
|
|
594
595
|
while (prepared.response.status === 429 && deps.on429) {
|
|
595
|
-
const rotated = deps.on429(prepared.response.headers.get("retry-after"));
|
|
596
|
+
const rotated = await deps.on429(prepared.response.headers.get("retry-after"));
|
|
596
597
|
if (!rotated) break;
|
|
597
598
|
try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
598
599
|
adapter = rotated;
|
|
@@ -274,7 +274,7 @@
|
|
|
274
274
|
"assertions": [
|
|
275
275
|
{ "id": "text", "operator": "normalized_text_equals", "selector": "/client/response/normalizedText", "expected": "OK", "required": true },
|
|
276
276
|
{ "id": "terminal", "operator": "terminal_signal_equals", "selector": "/client/response/terminal", "expected": "completed", "required": true },
|
|
277
|
-
{ "id": "phase", "operator": "json_path_equals", "selector": "/client/response/events/
|
|
277
|
+
{ "id": "phase", "operator": "json_path_equals", "selector": "/client/response/events/7/data/item/phase", "expected": "final_answer", "required": true }
|
|
278
278
|
]
|
|
279
279
|
},
|
|
280
280
|
{
|
|
@@ -40,6 +40,7 @@ const INITIAL_OWNED_PATHS = [
|
|
|
40
40
|
"artifacts",
|
|
41
41
|
"auth.json",
|
|
42
42
|
"auth.store.lock",
|
|
43
|
+
"admin-api-token",
|
|
43
44
|
"catalog-backup.json",
|
|
44
45
|
"claude-env.sh",
|
|
45
46
|
"codex-accounts.json",
|
|
@@ -333,6 +334,25 @@ export function removeOwnedConfigState(configDir: string): ConfigRemovalResult {
|
|
|
333
334
|
}
|
|
334
335
|
}
|
|
335
336
|
|
|
337
|
+
// Per-catalog backups are named `catalog-backup-<16 hex>.json` (catalogBackupPathFor), one per
|
|
338
|
+
// CODEX_HOME, so they cannot be enumerated as literal manifest entries the way every other
|
|
339
|
+
// owned file can. Without this, `ocx uninstall` always reported "unowned files remain" and
|
|
340
|
+
// refused to remove a home OpenCodex created itself — the file is unambiguously ours, produced
|
|
341
|
+
// by our own writer, and the strict hex shape keeps the match from widening.
|
|
342
|
+
for (const name of readdirSync(configDir)) {
|
|
343
|
+
if (!/^catalog-backup-[0-9a-f]{16}\.json$/.test(name)) continue;
|
|
344
|
+
const path = join(configDir, name);
|
|
345
|
+
try {
|
|
346
|
+
removeOwnedEntry(rootPath, path);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
return {
|
|
349
|
+
status: "partial",
|
|
350
|
+
reason: `could not remove owned path ${name}: ${error instanceof Error ? error.message : String(error)}`,
|
|
351
|
+
residualPaths: [path],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
336
356
|
try {
|
|
337
357
|
unlinkSync(join(configDir, CONFIG_UNINSTALL_MANIFEST));
|
|
338
358
|
unlinkSync(join(configDir, CONFIG_OWNER_FILE));
|
package/src/lib/errors.ts
CHANGED
|
@@ -16,11 +16,18 @@ export class OcxRequestValidationError extends Error {
|
|
|
16
16
|
|
|
17
17
|
/** OpenAI / Codex hard block for high-risk cybersecurity activity (HTTP 400 or mid-stream). */
|
|
18
18
|
export const CYBER_POLICY_ERROR_CODE = "cyber_policy";
|
|
19
|
+
export const CYBER_POLICY_FALLBACK_MESSAGE = "Request blocked by the upstream cybersecurity policy.";
|
|
19
20
|
|
|
20
21
|
export function isCyberPolicyCode(code: string | null | undefined): boolean {
|
|
21
22
|
return code === CYBER_POLICY_ERROR_CODE;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** Preserve a structured upstream error type; otherwise use the dedicated policy identity. */
|
|
26
|
+
export function cyberPolicyErrorType(type: string | null | undefined): string {
|
|
27
|
+
const trimmed = typeof type === "string" ? type.trim() : "";
|
|
28
|
+
return trimmed || CYBER_POLICY_ERROR_CODE;
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
/**
|
|
25
32
|
* Detect OpenAI cyber-policy refusals from message text when structured `code` was stripped.
|
|
26
33
|
* Matches Codex fallback copy and Cursor/API agent wording (session evidence 2026-07-24).
|
|
@@ -149,9 +156,11 @@ export function classifyError(status: number, type: string, message: string): Oc
|
|
|
149
156
|
return { message, type: "invalid_request_error", code: "client_closed_request" };
|
|
150
157
|
}
|
|
151
158
|
// Codex only shows the dedicated cyber UI when error.code === "cyber_policy".
|
|
152
|
-
//
|
|
159
|
+
// The public wire does not establish invalid_request_error as the canonical type, so
|
|
160
|
+
// message-only classification keeps the dedicated identity instead of inventing one.
|
|
161
|
+
// Structured callers re-apply their real upstream type with cyberPolicyErrorType().
|
|
153
162
|
if (type === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(text)) {
|
|
154
|
-
return { message, type:
|
|
163
|
+
return { message, type: CYBER_POLICY_ERROR_CODE, code: CYBER_POLICY_ERROR_CODE };
|
|
155
164
|
}
|
|
156
165
|
// A LOCAL preflight refusal keeps its own code (#1524). The message necessarily says
|
|
157
166
|
// "context window" -- that is what it is refusing on -- so the generic remap below would
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface PackageTreeObservation {
|
|
4
|
+
readonly device: bigint;
|
|
5
|
+
readonly inode: bigint;
|
|
6
|
+
readonly contentTimeNs: bigint;
|
|
7
|
+
readonly size: bigint;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type PackageTreeIntegrityStatus =
|
|
11
|
+
| { readonly ok: true }
|
|
12
|
+
| { readonly ok: false; readonly reason: "package_tree_replaced" | "package_tree_unreadable" };
|
|
13
|
+
|
|
14
|
+
export interface PackageTreeIntegrityGuard {
|
|
15
|
+
status(): PackageTreeIntegrityStatus;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ObservePackageTree = () => PackageTreeObservation | null;
|
|
19
|
+
type PackageTreeRuntimeInstall = "bun" | "npm" | "source";
|
|
20
|
+
|
|
21
|
+
const packageManifestUrl = new URL("../../package.json", import.meta.url);
|
|
22
|
+
|
|
23
|
+
function observePackageManifest(): PackageTreeObservation | null {
|
|
24
|
+
try {
|
|
25
|
+
const stat = statSync(packageManifestUrl, { bigint: true });
|
|
26
|
+
return {
|
|
27
|
+
device: stat.dev,
|
|
28
|
+
inode: stat.ino,
|
|
29
|
+
// mtimeNs, NOT ctimeNs. An inode-change time moves for METADATA writes that
|
|
30
|
+
// replace nothing: chmod, chown, touch, an editor normalizing permissions, a
|
|
31
|
+
// backup tool restoring modes. Each of those left device, inode and size
|
|
32
|
+
// identical, so the comparison below called the manifest "replaced" and every
|
|
33
|
+
// /v1/* request answered 503 until the process was restarted. Measured on
|
|
34
|
+
// macOS: chmod alone moved ctimeNs and left mtimeNs untouched.
|
|
35
|
+
//
|
|
36
|
+
// mtimeNs still catches every real replacement. An in-place rewrite of the
|
|
37
|
+
// same byte length moves mtimeNs while inode and size hold; an atomic
|
|
38
|
+
// install (write-then-rename, which is what a package manager does) changes
|
|
39
|
+
// the inode as well. Both were measured before this change was made.
|
|
40
|
+
contentTimeNs: stat.mtimeNs,
|
|
41
|
+
size: stat.size,
|
|
42
|
+
};
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sameObservation(left: PackageTreeObservation, right: PackageTreeObservation): boolean {
|
|
49
|
+
return left.device === right.device
|
|
50
|
+
&& left.inode === right.inode
|
|
51
|
+
&& left.contentTimeNs === right.contentTimeNs
|
|
52
|
+
&& left.size === right.size;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How long an `ok` observation is reused before the manifest is stat'd again.
|
|
57
|
+
*
|
|
58
|
+
* `status()` runs on `/healthz`, `/readyz` and every `/v1/*` request, so an unthrottled guard
|
|
59
|
+
* adds a filesystem syscall to the proxy's hot path to detect an event that happens at most
|
|
60
|
+
* once per install. A replaced tree is not time-critical either: the process is already
|
|
61
|
+
* serving broken imports, and one more second of that is not worse than a syscall per turn
|
|
62
|
+
* forever.
|
|
63
|
+
*
|
|
64
|
+
* A NEGATIVE result is never cached — once the tree looks wrong, every later request re-checks,
|
|
65
|
+
* so a repaired install recovers on its own instead of staying refused for a window.
|
|
66
|
+
*/
|
|
67
|
+
const PACKAGE_TREE_RECHECK_MS = 1_000;
|
|
68
|
+
|
|
69
|
+
export function createPackageTreeIntegrityGuard(
|
|
70
|
+
observe: ObservePackageTree = observePackageManifest,
|
|
71
|
+
now: () => number = Date.now,
|
|
72
|
+
): PackageTreeIntegrityGuard {
|
|
73
|
+
const boot = observe();
|
|
74
|
+
let lastOkAt: number | null = null;
|
|
75
|
+
return {
|
|
76
|
+
status(): PackageTreeIntegrityStatus {
|
|
77
|
+
const at = now();
|
|
78
|
+
if (lastOkAt !== null && at - lastOkAt < PACKAGE_TREE_RECHECK_MS) return { ok: true };
|
|
79
|
+
const current = observe();
|
|
80
|
+
if (boot === null || current === null) return { ok: false, reason: "package_tree_unreadable" };
|
|
81
|
+
if (!sameObservation(boot, current)) return { ok: false, reason: "package_tree_replaced" };
|
|
82
|
+
lastOkAt = at;
|
|
83
|
+
return { ok: true };
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Installed packages fail closed when their manifest is replaced under a live process.
|
|
90
|
+
* A source checkout is different: editing package.json is ordinary development work, and
|
|
91
|
+
* Bun keeps serving the module snapshot already loaded by this process until the operator
|
|
92
|
+
* chooses to restart. Fencing every request there makes running dev directly impossible.
|
|
93
|
+
*/
|
|
94
|
+
export function createRuntimePackageTreeIntegrityGuard(
|
|
95
|
+
installer: PackageTreeRuntimeInstall,
|
|
96
|
+
observe: ObservePackageTree = observePackageManifest,
|
|
97
|
+
now: () => number = Date.now,
|
|
98
|
+
): PackageTreeIntegrityGuard {
|
|
99
|
+
if (installer === "source") return { status: () => ({ ok: true }) };
|
|
100
|
+
return createPackageTreeIntegrityGuard(observe, now);
|
|
101
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { OcxProviderConfig } from "../types";
|
|
3
|
+
import {
|
|
4
|
+
parseGoogleCookieJar,
|
|
5
|
+
validateAiStudioCookies,
|
|
6
|
+
} from "./google-aistudio-auth";
|
|
7
|
+
import {
|
|
8
|
+
cookieHeaderFromSession,
|
|
9
|
+
loadAiStudioSession,
|
|
10
|
+
type AiStudioSessionData,
|
|
11
|
+
} from "./aistudio-session-sync";
|
|
12
|
+
|
|
13
|
+
export type AiStudioCredentialResolution =
|
|
14
|
+
| { kind: "ready"; cookieHeader: string; source: "provider-api-key" | "provider-header" | "session"; fingerprint: string }
|
|
15
|
+
| { kind: "missing"; reason: string }
|
|
16
|
+
| { kind: "invalid"; reason: string };
|
|
17
|
+
|
|
18
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
|
19
|
+
|
|
20
|
+
function validCookieHeader(value: unknown): string | undefined {
|
|
21
|
+
if (typeof value !== "string") return undefined;
|
|
22
|
+
const cookieHeader = value.trim();
|
|
23
|
+
if (!cookieHeader || CONTROL_CHARACTERS.test(cookieHeader)) return undefined;
|
|
24
|
+
const jar = parseGoogleCookieJar(cookieHeader);
|
|
25
|
+
return validateAiStudioCookies(jar).valid ? jar.cookieHeader : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ready(cookieHeader: string, source: "provider-api-key" | "provider-header" | "session"): AiStudioCredentialResolution {
|
|
29
|
+
return {
|
|
30
|
+
kind: "ready",
|
|
31
|
+
cookieHeader,
|
|
32
|
+
source,
|
|
33
|
+
fingerprint: createHash("sha256").update(cookieHeader).digest("hex"),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveAiStudioCredentials(
|
|
38
|
+
provider: OcxProviderConfig,
|
|
39
|
+
session?: AiStudioSessionData | null,
|
|
40
|
+
): AiStudioCredentialResolution {
|
|
41
|
+
const invalidSources: string[] = [];
|
|
42
|
+
const apiKey = validCookieHeader(provider.apiKey);
|
|
43
|
+
if (apiKey) return ready(apiKey, "provider-api-key");
|
|
44
|
+
if ((typeof provider.apiKey === "string" && provider.apiKey.trim())
|
|
45
|
+
|| (provider.apiKey !== undefined && provider.apiKey !== null && typeof provider.apiKey !== "string")) {
|
|
46
|
+
invalidSources.push("provider apiKey");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cookieEntry = Object.entries(provider.headers ?? {}).find(([name]) => name.toLowerCase() === "cookie");
|
|
50
|
+
const headerCookie = validCookieHeader(cookieEntry?.[1]);
|
|
51
|
+
if (headerCookie) return ready(headerCookie, "provider-header");
|
|
52
|
+
if ((typeof cookieEntry?.[1] === "string" && cookieEntry[1].trim())
|
|
53
|
+
|| (cookieEntry !== undefined && typeof cookieEntry[1] !== "string")) {
|
|
54
|
+
invalidSources.push("provider Cookie header");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const savedSession = session === undefined ? loadAiStudioSession() : session;
|
|
58
|
+
const sessionCookie = validCookieHeader(cookieHeaderFromSession(savedSession));
|
|
59
|
+
if (sessionCookie) return ready(sessionCookie, "session");
|
|
60
|
+
if (savedSession) invalidSources.push("saved session");
|
|
61
|
+
|
|
62
|
+
return invalidSources.length > 0
|
|
63
|
+
? { kind: "invalid", reason: "AI Studio credentials are malformed or missing SAPISID." }
|
|
64
|
+
: { kind: "missing", reason: "AI Studio credentials are missing." };
|
|
65
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
4
|
+
import { loadAiStudioSession, getAiStudioSessionPath } from "./aistudio-session-sync";
|
|
5
|
+
import { resolveAiStudioCredentials } from "./aistudio-credentials";
|
|
6
|
+
import type { OcxProviderConfig } from "../types";
|
|
4
7
|
|
|
5
8
|
export function isNativeWebKitSupported(): boolean {
|
|
6
9
|
return process.platform === "darwin";
|
|
@@ -30,6 +33,57 @@ export async function buildAiStudioNativeDaemon(outputPath?: string): Promise<st
|
|
|
30
33
|
return dest;
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
type NativeLoginChild = { exited: Promise<number>; kill: () => void };
|
|
37
|
+
type NativeLoginSpawn = (command: string[], options: { stdout: "pipe"; stderr: "pipe" }) => NativeLoginChild;
|
|
38
|
+
|
|
39
|
+
export type AiStudioNativeLoginResult =
|
|
40
|
+
| { kind: "authenticated"; sessionPath: string }
|
|
41
|
+
| { kind: "cancelled" }
|
|
42
|
+
| { kind: "unsupported" }
|
|
43
|
+
| { kind: "failed"; error: string };
|
|
44
|
+
|
|
45
|
+
export interface AiStudioNativeLoginOptions {
|
|
46
|
+
platform?: NodeJS.Platform;
|
|
47
|
+
sessionPath?: string;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
/** Test-only process seam. Production always uses Bun.spawn. */
|
|
50
|
+
spawn?: NativeLoginSpawn;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Spawn one visible native login and report only after its session is durable and valid. */
|
|
54
|
+
export async function runAiStudioNativeLogin(options: AiStudioNativeLoginOptions = {}): Promise<AiStudioNativeLoginResult> {
|
|
55
|
+
if ((options.platform ?? process.platform) !== "darwin") return { kind: "unsupported" };
|
|
56
|
+
const sessionPath = options.sessionPath ?? getAiStudioSessionPath();
|
|
57
|
+
const spawnLogin = options.spawn ?? ((command, spawnOptions) => Bun.spawn(command, spawnOptions) as unknown as NativeLoginChild);
|
|
58
|
+
let child: NativeLoginChild;
|
|
59
|
+
try {
|
|
60
|
+
child = spawnLogin(["swift", getAiStudioNativeDaemonSourcePath(), "--login"], { stdout: "pipe", stderr: "pipe" });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return { kind: "failed", error: `Could not start native AI Studio login: ${error instanceof Error ? error.message : String(error)}` };
|
|
63
|
+
}
|
|
64
|
+
let aborted = options.signal?.aborted === true;
|
|
65
|
+
const terminate = () => {
|
|
66
|
+
aborted = true;
|
|
67
|
+
try { child.kill(); } catch { /* already exited */ }
|
|
68
|
+
};
|
|
69
|
+
options.signal?.addEventListener("abort", terminate, { once: true });
|
|
70
|
+
try {
|
|
71
|
+
const exitCode = await child.exited;
|
|
72
|
+
if (aborted || options.signal?.aborted) return { kind: "cancelled" };
|
|
73
|
+
if (exitCode === 2) return { kind: "cancelled" };
|
|
74
|
+
if (exitCode !== 0) return { kind: "failed", error: `Native AI Studio login failed (exit code ${exitCode})` };
|
|
75
|
+
const session = loadAiStudioSession(sessionPath);
|
|
76
|
+
const credentials = resolveAiStudioCredentials({} as OcxProviderConfig, session);
|
|
77
|
+
return credentials.kind === "ready"
|
|
78
|
+
? { kind: "authenticated", sessionPath }
|
|
79
|
+
: { kind: "failed", error: "Native login completed without a valid AI Studio session" };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return { kind: "failed", error: error instanceof Error ? error.message : String(error) };
|
|
82
|
+
} finally {
|
|
83
|
+
options.signal?.removeEventListener("abort", terminate);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
33
87
|
export interface NativeDaemonHandle {
|
|
34
88
|
process: ChildProcess;
|
|
35
89
|
stop: () => void;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic OAuth multi-account 429 failover (#2568).
|
|
3
|
+
*
|
|
4
|
+
* The API-key twin (`providers/key-failover.ts`) rotates by default for any key provider with a
|
|
5
|
+
* 2+ pool, but it returns false for `authMode === "oauth"`, and the only OAuth rotator that
|
|
6
|
+
* exists is Anthropic's — behind its own opt-in. So xAI, Cursor, Kimi, GitHub Copilot,
|
|
7
|
+
* Antigravity and Nous have no recovery path on a 429 even with several accounts logged in.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately narrower than the Anthropic pool: no session affinity, no quota-ranked selection,
|
|
10
|
+
* no probe leases. Those carry provider-specific meaning; this module only answers "the account
|
|
11
|
+
* that just 429'd is cooled, is there another one we may use".
|
|
12
|
+
*
|
|
13
|
+
* NOT a home for Codex (`codex/routing.ts` owns quota scopes and probe leases) or Anthropic
|
|
14
|
+
* (`oauth/anthropic-routing.ts` owns affinity and a fail-closed local-cli credential rule).
|
|
15
|
+
* Both are excluded by `isGenericFailoverProvider`.
|
|
16
|
+
*/
|
|
17
|
+
import { getAccountSet } from "./store";
|
|
18
|
+
import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index";
|
|
19
|
+
import { parseRetryAfterMs } from "../combos/failover";
|
|
20
|
+
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
21
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
22
|
+
|
|
23
|
+
/** Cap same-request rotations so a short Retry-After cannot spin. Mirrors the Anthropic bound. */
|
|
24
|
+
export const GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST = 3;
|
|
25
|
+
|
|
26
|
+
const DEFAULT_COOLDOWN_MS = 60_000;
|
|
27
|
+
const MAX_COOLDOWN_MS = 15 * 60_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How long a presence answer may be reused before the store is consulted again.
|
|
31
|
+
*
|
|
32
|
+
* `loadAuthStore` has no cache: every call chmods the config dir and the secret, reads the whole
|
|
33
|
+
* file, parses it and normalizes the store (store.ts:136-151). Since presence now decides
|
|
34
|
+
* activation, this predicate runs on paths that have not seen a 429 at all — the streaming and
|
|
35
|
+
* non-streaming runTurn entry points evaluate it once per request — so an uncached check would put
|
|
36
|
+
* a synchronous file read in front of every request for every OAuth provider.
|
|
37
|
+
*
|
|
38
|
+
* Two seconds is short enough that a login in another window is picked up before the operator can
|
|
39
|
+
* switch back and send a prompt, and long enough that a burst of requests shares one read. The
|
|
40
|
+
* cache holds a COUNT, never a credential.
|
|
41
|
+
*/
|
|
42
|
+
const PRESENCE_CACHE_TTL_MS = 2_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Providers whose rotation is owned elsewhere and must not be handled here.
|
|
46
|
+
*
|
|
47
|
+
* `openai` is the Codex pool: quota scopes, probe leases and affinity semantics that this
|
|
48
|
+
* module deliberately does not reimplement. `anthropic` has its own pool with a fail-closed
|
|
49
|
+
* rule about background local-cli credential slots.
|
|
50
|
+
*/
|
|
51
|
+
const EXCLUDED_PROVIDERS = new Set(["openai", "anthropic"]);
|
|
52
|
+
|
|
53
|
+
interface AccountHealth {
|
|
54
|
+
cooldownUntil: number;
|
|
55
|
+
cooldownSource: "retry-after" | "default";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface PresenceEntry {
|
|
59
|
+
eligible: number;
|
|
60
|
+
readAt: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Process-local, like the Anthropic pool's: a restart is allowed to forget a cooldown. */
|
|
64
|
+
const health = new Map<string, AccountHealth>();
|
|
65
|
+
|
|
66
|
+
/** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */
|
|
67
|
+
const presence = new Map<string, PresenceEntry>();
|
|
68
|
+
|
|
69
|
+
const healthKey = (provider: string, accountId: string) => `${provider}\u0000${accountId}`;
|
|
70
|
+
|
|
71
|
+
function isCooled(provider: string, accountId: string, now: number): boolean {
|
|
72
|
+
const entry = health.get(healthKey(provider, accountId));
|
|
73
|
+
if (!entry) return false;
|
|
74
|
+
if (entry.cooldownUntil <= now) {
|
|
75
|
+
health.delete(healthKey(provider, accountId));
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when this provider participates in generic rotation at all. */
|
|
82
|
+
export function isGenericFailoverProvider(providerName: string, provider: OcxProviderConfig): boolean {
|
|
83
|
+
return provider.authMode === "oauth" && !EXCLUDED_PROVIDERS.has(providerName);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Stored accounts that could serve traffic if asked, ignoring cooldowns.
|
|
88
|
+
*
|
|
89
|
+
* Cooldowns are excluded on purpose: they are transient and per-request, while this answers the
|
|
90
|
+
* durable question "did the operator log in more than one account". Treating a cooled account as
|
|
91
|
+
* absent would switch the feature off for the rest of the cooldown, which is exactly when it is
|
|
92
|
+
* needed.
|
|
93
|
+
*/
|
|
94
|
+
function eligibleAccountCount(providerName: string, now: number): number {
|
|
95
|
+
const cached = presence.get(providerName);
|
|
96
|
+
if (cached && now >= cached.readAt && now - cached.readAt < PRESENCE_CACHE_TTL_MS) return cached.eligible;
|
|
97
|
+
const set = getAccountSet(providerName);
|
|
98
|
+
const eligible = set ? set.accounts.filter(account => account.needsReauth !== true).length : 0;
|
|
99
|
+
presence.set(providerName, { eligible, readAt: now });
|
|
100
|
+
return eligible;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Presence IS consent (#2568d).
|
|
105
|
+
*
|
|
106
|
+
* `hasKeyPoolFailover` already reads a 2+ key pool as the operator asking for rotation, and a
|
|
107
|
+
* second OAuth login is the same statement. One account stays a strict no-op either way, so this
|
|
108
|
+
* only changes behaviour for someone who deliberately logged in twice.
|
|
109
|
+
*/
|
|
110
|
+
export function hasFailoverAccountQuorum(providerName: string, now = Date.now()): boolean {
|
|
111
|
+
return eligibleAccountCount(providerName, now) >= 2;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether generic rotation is active for this provider.
|
|
116
|
+
*
|
|
117
|
+
* Precedence, most specific first:
|
|
118
|
+
*
|
|
119
|
+
* 1. `providers.<name>.oauthAccountFailover.enabled` — an operator may accept rotation on one
|
|
120
|
+
* provider and refuse it on another, because provider terms differ.
|
|
121
|
+
* 2. `oauthAccountFailover.enabled` — the global switch. Anyone who already wrote `false` keeps
|
|
122
|
+
* strict single-account behaviour across this change.
|
|
123
|
+
* 3. Presence: 2 or more eligible stored accounts (#2568d, owner decision).
|
|
124
|
+
*
|
|
125
|
+
* Only an explicit boolean overrides presence. A malformed value falls through instead of
|
|
126
|
+
* throwing, because a typo in a knob must not take a provider out of service.
|
|
127
|
+
*/
|
|
128
|
+
export function isGenericOAuthFailoverEnabled(
|
|
129
|
+
config: OcxConfig,
|
|
130
|
+
providerName: string,
|
|
131
|
+
now = Date.now(),
|
|
132
|
+
): boolean {
|
|
133
|
+
const provider = config.providers?.[providerName];
|
|
134
|
+
if (!provider || !isGenericFailoverProvider(providerName, provider)) return false;
|
|
135
|
+
const perProvider = provider.oauthAccountFailover?.enabled;
|
|
136
|
+
if (typeof perProvider === "boolean") return perProvider;
|
|
137
|
+
const global = config.oauthAccountFailover?.enabled;
|
|
138
|
+
if (typeof global === "boolean") return global;
|
|
139
|
+
return hasFailoverAccountQuorum(providerName, now);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Accounts that may serve traffic right now: not cooled, not flagged for reauth. */
|
|
143
|
+
export function eligibleFailoverAccounts(providerName: string, now = Date.now()): string[] {
|
|
144
|
+
const set = getAccountSet(providerName);
|
|
145
|
+
if (!set) return [];
|
|
146
|
+
return set.accounts
|
|
147
|
+
.filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now))
|
|
148
|
+
.map(account => account.id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Cool the account that actually 429'd and name the next eligible one, or null.
|
|
153
|
+
*
|
|
154
|
+
* Returns the id only; the caller mints the credential so a failed refresh does not leave the
|
|
155
|
+
* cooldown applied to an account we then could not use.
|
|
156
|
+
*/
|
|
157
|
+
export function rotateGenericOAuthAccountOn429(
|
|
158
|
+
config: OcxConfig,
|
|
159
|
+
providerName: string,
|
|
160
|
+
failedAccountId: string,
|
|
161
|
+
retryAfterHeader: string | null | undefined,
|
|
162
|
+
now = Date.now(),
|
|
163
|
+
): string | null {
|
|
164
|
+
if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
|
|
165
|
+
const set = getAccountSet(providerName);
|
|
166
|
+
// A single stored account has nowhere to go; rotating to itself would just replay the 429.
|
|
167
|
+
if (!set || set.accounts.length < 2) return null;
|
|
168
|
+
|
|
169
|
+
const parsed = parseRetryAfterMs(retryAfterHeader, now);
|
|
170
|
+
const cooldownMs = Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
|
|
171
|
+
health.set(healthKey(providerName, failedAccountId), {
|
|
172
|
+
cooldownUntil: now + cooldownMs,
|
|
173
|
+
cooldownSource: parsed ? "retry-after" : "default",
|
|
174
|
+
});
|
|
175
|
+
sweepExpiredOnWrite(now);
|
|
176
|
+
|
|
177
|
+
const eligible = eligibleFailoverAccounts(providerName, now).filter(id => id !== failedAccountId);
|
|
178
|
+
if (eligible.length === 0) return null;
|
|
179
|
+
// A rotation means the roster in use just changed; do not answer the next activation question
|
|
180
|
+
// from a count read before the failure.
|
|
181
|
+
presence.delete(providerName);
|
|
182
|
+
// Deterministic: start after the failed account so repeated 429s walk the roster instead of
|
|
183
|
+
// hammering whichever id happens to sort first.
|
|
184
|
+
const order = set.accounts.map(account => account.id);
|
|
185
|
+
const start = order.indexOf(failedAccountId);
|
|
186
|
+
for (let i = 1; i <= order.length; i++) {
|
|
187
|
+
const candidate = order[(start + i) % order.length]!;
|
|
188
|
+
if (candidate !== failedAccountId && eligible.includes(candidate)) return candidate;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Full credential snapshot for a rotated account.
|
|
195
|
+
*
|
|
196
|
+
* Returns the snapshot rather than a bare bearer: Antigravity pairs an account-matched
|
|
197
|
+
* `projectId` with its token and Kiro carries routing metadata, so a token-only swap would mix
|
|
198
|
+
* one account's bearer with another's routing data.
|
|
199
|
+
*/
|
|
200
|
+
export async function failoverAccountSnapshot(
|
|
201
|
+
providerName: string,
|
|
202
|
+
accountId: string,
|
|
203
|
+
): Promise<OAuthAccessSnapshot> {
|
|
204
|
+
return getValidAccessSnapshotForAccount(providerName, accountId);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Earliest remaining cooldown, for a client-facing Retry-After when every account is cooled. */
|
|
208
|
+
export function genericFailoverRetryAfterSeconds(providerName: string, now = Date.now()): number | null {
|
|
209
|
+
const set = getAccountSet(providerName);
|
|
210
|
+
if (!set) return null;
|
|
211
|
+
let earliest: number | null = null;
|
|
212
|
+
for (const account of set.accounts) {
|
|
213
|
+
const entry = health.get(healthKey(providerName, account.id));
|
|
214
|
+
if (!entry || entry.cooldownUntil <= now) continue;
|
|
215
|
+
if (earliest === null || entry.cooldownUntil < earliest) earliest = entry.cooldownUntil;
|
|
216
|
+
}
|
|
217
|
+
return earliest === null ? null : Math.max(1, Math.ceil((earliest - now) / 1000));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Test seam and manual-recovery hook. */
|
|
221
|
+
export function clearGenericFailoverHealth(providerName?: string): void {
|
|
222
|
+
if (!providerName) {
|
|
223
|
+
health.clear();
|
|
224
|
+
presence.clear();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
presence.delete(providerName);
|
|
228
|
+
for (const key of [...health.keys()]) {
|
|
229
|
+
if (key.startsWith(`${providerName}\u0000`)) health.delete(key);
|
|
230
|
+
}
|
|
231
|
+
}
|