@yansigit/opencodex 2.33.0 → 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.
Files changed (196) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-BjCaHxdz.js +112 -0
  3. package/gui/dist/assets/index-DLkXOXLC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +141 -23
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +162 -0
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +17 -1
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-fs.ts +13 -12
  17. package/src/adapters/cursor/native-exec-network.ts +3 -5
  18. package/src/adapters/cursor/native-exec-policy.ts +47 -0
  19. package/src/adapters/cursor/native-exec-shell.ts +116 -31
  20. package/src/adapters/cursor/native-exec.ts +38 -10
  21. package/src/adapters/cursor/protobuf-events.ts +28 -2
  22. package/src/adapters/cursor/protobuf-request.ts +93 -41
  23. package/src/adapters/cursor/request-builder.ts +39 -10
  24. package/src/adapters/cursor/tool-definitions.ts +27 -3
  25. package/src/adapters/cursor/tool-result-normalize.ts +51 -6
  26. package/src/adapters/cursor/types.ts +23 -4
  27. package/src/adapters/cursor.ts +170 -29
  28. package/src/adapters/google-aistudio-parser.ts +49 -0
  29. package/src/adapters/google-antigravity-replay.ts +105 -25
  30. package/src/adapters/google-antigravity-wire.ts +5 -0
  31. package/src/adapters/google-errors.ts +41 -12
  32. package/src/adapters/google-http.ts +12 -11
  33. package/src/adapters/google.ts +219 -36
  34. package/src/adapters/image.ts +1 -1
  35. package/src/adapters/kiro-constants.ts +15 -0
  36. package/src/adapters/kiro-tools.ts +43 -15
  37. package/src/adapters/kiro.ts +54 -9
  38. package/src/adapters/openai-chat.ts +286 -242
  39. package/src/adapters/openai-responses.ts +335 -24
  40. package/src/adapters/run-turn-queue.ts +36 -1
  41. package/src/adapters/tool-catalog-nudge.ts +2 -2
  42. package/src/adapters/xai-tool-schema.ts +436 -0
  43. package/src/bridge.ts +67 -26
  44. package/src/chat/inbound.ts +29 -1
  45. package/src/chat/outbound.ts +15 -7
  46. package/src/claude/agents-inject.ts +8 -1
  47. package/src/claude/outbound.ts +10 -8
  48. package/src/cli/account-api.ts +27 -7
  49. package/src/cli/account-extended.ts +10 -3
  50. package/src/cli/account.ts +29 -5
  51. package/src/cli/alias.ts +66 -0
  52. package/src/cli/claude.ts +26 -1
  53. package/src/cli/dispatch.ts +13 -1
  54. package/src/cli/help.ts +1 -0
  55. package/src/cli/index.ts +6 -1
  56. package/src/cli/init.ts +1 -0
  57. package/src/cli/models-runtime.ts +95 -0
  58. package/src/cli/models.ts +13 -7
  59. package/src/cli/provider-runtime.ts +16 -2
  60. package/src/cli/registry.ts +6 -1
  61. package/src/cli/telemetry-commands.ts +25 -0
  62. package/src/cli/v2.ts +34 -10
  63. package/src/codex/account-pause.ts +2 -1
  64. package/src/codex/account-priority.ts +3 -2
  65. package/src/codex/app-server-processes.ts +80 -6
  66. package/src/codex/auth-api.ts +48 -8
  67. package/src/codex/auth-context.ts +21 -18
  68. package/src/codex/catalog/aggregation.ts +6 -0
  69. package/src/codex/catalog/model-metadata.ts +13 -1
  70. package/src/codex/catalog/native-models.ts +5 -2
  71. package/src/codex/catalog/parsing.ts +16 -0
  72. package/src/codex/catalog/provider-fetch.ts +20 -3
  73. package/src/codex/catalog/sync.ts +127 -2
  74. package/src/codex/catalog.ts +1 -1
  75. package/src/codex/codex-write-lock.ts +3 -1
  76. package/src/codex/convergence-types.ts +1 -1
  77. package/src/codex/convergence.ts +22 -2
  78. package/src/codex/desired-state.ts +2 -2
  79. package/src/codex/desktop-app-restart.ts +18 -5
  80. package/src/codex/inject-coordination.ts +83 -0
  81. package/src/codex/inject.ts +14 -1
  82. package/src/codex/log-guard/inspect.ts +22 -4
  83. package/src/codex/model-entitlements.ts +9 -2
  84. package/src/codex/prompt-layers.ts +371 -25
  85. package/src/codex/prompt-text-probe.ts +238 -0
  86. package/src/codex/quota.ts +123 -18
  87. package/src/codex/routing.ts +9 -0
  88. package/src/codex/subagent-model-fallback.ts +198 -27
  89. package/src/codex/transition-state.ts +107 -8
  90. package/src/combos/types.ts +10 -0
  91. package/src/compatibility/openai-responses.ts +33 -1
  92. package/src/config/autonomous-remediation.ts +21 -0
  93. package/src/config/provider-validation.ts +14 -0
  94. package/src/config/rebase-provenance.ts +68 -0
  95. package/src/config.ts +191 -17
  96. package/src/generated/compatibility-version.json +279 -159
  97. package/src/generated/model-metadata.ts +3 -0
  98. package/src/images/loop.ts +5 -4
  99. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  100. package/src/lab/fabric/producer-child.ts +1 -1
  101. package/src/lib/config-ownership.ts +20 -0
  102. package/src/lib/errors.ts +11 -2
  103. package/src/lib/package-tree-integrity.ts +101 -0
  104. package/src/oauth/aistudio-credentials.ts +65 -0
  105. package/src/oauth/aistudio-native-daemon.ts +116 -0
  106. package/src/oauth/aistudio-session-sync.ts +95 -0
  107. package/src/oauth/generic-account-failover.ts +231 -0
  108. package/src/oauth/google-aistudio-auth.ts +98 -0
  109. package/src/oauth/index.ts +57 -5
  110. package/src/oauth/key-providers.ts +18 -1
  111. package/src/oauth/kiro.ts +45 -0
  112. package/src/oauth/login-cli.ts +65 -1
  113. package/src/oauth/types.ts +15 -0
  114. package/src/providers/codex-capacity.ts +5 -2
  115. package/src/providers/command-code-efforts.ts +38 -6
  116. package/src/providers/context-cap.ts +4 -3
  117. package/src/providers/default-aliases.ts +65 -0
  118. package/src/providers/derive.ts +29 -1
  119. package/src/providers/fastwire.ts +7 -1
  120. package/src/providers/model-presets.ts +119 -0
  121. package/src/providers/new-model-policy.ts +146 -0
  122. package/src/providers/provider-id-rewrite.ts +2 -1
  123. package/src/providers/quota.ts +157 -46
  124. package/src/providers/registry.ts +184 -71
  125. package/src/providers/slug-codec.ts +52 -0
  126. package/src/responses/code-mode-helper-compat.ts +50 -0
  127. package/src/responses/custom-tool-compat.ts +34 -10
  128. package/src/responses/parser.ts +4 -0
  129. package/src/responses/schema.ts +5 -1
  130. package/src/responses/thought-signature-replay.ts +17 -0
  131. package/src/router.ts +43 -2
  132. package/src/routing/account-pool/cooldown.ts +8 -0
  133. package/src/routing/account-pool/index.ts +1 -0
  134. package/src/routing/analytics.ts +1 -0
  135. package/src/routing/quota.ts +10 -0
  136. package/src/server/auth-cors.ts +24 -0
  137. package/src/server/chat-completions.ts +26 -16
  138. package/src/server/chat-native-sse.ts +3 -3
  139. package/src/server/chat-native.ts +30 -11
  140. package/src/server/claude-messages.ts +1 -1
  141. package/src/server/effort-policy.ts +16 -0
  142. package/src/server/index.ts +180 -14
  143. package/src/server/lifecycle.ts +52 -1
  144. package/src/server/management/agent-settings-routes.ts +31 -15
  145. package/src/server/management/codex-prompt-routes.ts +570 -0
  146. package/src/server/management/combo-routes.ts +2 -1
  147. package/src/server/management/config-routes.ts +27 -9
  148. package/src/server/management/context.ts +9 -0
  149. package/src/server/management/logs-usage-routes.ts +11 -5
  150. package/src/server/management/model-routes.ts +266 -0
  151. package/src/server/management/oauth-account-routes.ts +13 -3
  152. package/src/server/management/provider-routes.ts +137 -3
  153. package/src/server/management/routing-profile-routes.ts +2 -2
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/port-reclaim.ts +19 -1
  156. package/src/server/relay-eager.ts +147 -20
  157. package/src/server/relay.ts +251 -19
  158. package/src/server/request-log-conversation.ts +33 -0
  159. package/src/server/request-log.ts +48 -21
  160. package/src/server/responses/collaboration.ts +42 -5
  161. package/src/server/responses/combo-stream-preflight.ts +10 -3
  162. package/src/server/responses/core.ts +575 -140
  163. package/src/server/responses/empty-completion-guard.ts +35 -0
  164. package/src/server/responses/fetch-helpers.ts +14 -6
  165. package/src/server/responses/input-admission.ts +3 -1
  166. package/src/server/responses/passthrough-error.ts +33 -9
  167. package/src/server/responses/policy-fallback.ts +1 -1
  168. package/src/server/responses/responses-field-backfill.ts +105 -13
  169. package/src/server/responses/ws-upstream.ts +35 -5
  170. package/src/server/responses-custom-tool-repair.ts +52 -7
  171. package/src/server/responses-terminal-repair.ts +25 -4
  172. package/src/server/sse-frame-buffer.ts +31 -4
  173. package/src/server/ws-bridge.ts +14 -2
  174. package/src/smoke/fingerprint-cache.ts +133 -0
  175. package/src/smoke/live-scenarios.ts +33 -0
  176. package/src/smoke/runner.ts +119 -0
  177. package/src/telemetry/dispatcher.ts +44 -0
  178. package/src/telemetry/fingerprint.ts +24 -0
  179. package/src/telemetry/hook.ts +43 -0
  180. package/src/telemetry/ledger.ts +54 -0
  181. package/src/telemetry/types.ts +23 -0
  182. package/src/types/config.ts +66 -14
  183. package/src/types/provider.ts +79 -1
  184. package/src/types/request.ts +18 -10
  185. package/src/types/tools.ts +30 -11
  186. package/src/types.ts +1 -0
  187. package/src/usage/command-code-manifest.ts +116 -0
  188. package/src/usage/cost.ts +2 -2
  189. package/src/usage/expected-prices.ts +126 -24
  190. package/src/usage/log.ts +18 -8
  191. package/src/usage/summary.ts +34 -12
  192. package/src/web-search/exa-executor.ts +40 -9
  193. package/src/web-search/index.ts +16 -8
  194. package/src/web-search/loop.ts +5 -4
  195. package/gui/dist/assets/index-DKLr4LTE.js +0 -102
  196. package/gui/dist/assets/index-DrSQdTRd.css +0 -1
@@ -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
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Google AI Studio Web-UI (SAPISIDHASH) authorization & header builder.
3
+ */
4
+
5
+ export interface GoogleCookieJar {
6
+ sapisid?: string;
7
+ psid?: string;
8
+ ssid?: string;
9
+ hsid?: string;
10
+ sid?: string;
11
+ cookieHeader: string;
12
+ }
13
+
14
+ const DEFAULT_ORIGIN = "https://aistudio.google.com";
15
+
16
+ /**
17
+ * Generate the Google internal SAPISIDHASH Authorization header value.
18
+ * Formula uses Unix seconds: "SAPISIDHASH <timestamp>_<sha1(timestamp + " " + SAPISID + " " + origin)>".
19
+ */
20
+ export async function generateSapisidHash(
21
+ sapisid: string,
22
+ origin: string = DEFAULT_ORIGIN,
23
+ timestamp: number = Date.now()
24
+ ): Promise<string> {
25
+ // Callers historically passed Date.now() (milliseconds); Google expects Unix seconds.
26
+ const seconds = Math.floor(timestamp > 10_000_000_000 ? timestamp / 1000 : timestamp);
27
+ const raw = `${seconds} ${sapisid} ${origin}`;
28
+ const buf = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(raw));
29
+ const hexHash = Array.from(new Uint8Array(buf))
30
+ .map((b) => b.toString(16).padStart(2, "0"))
31
+ .join("");
32
+ return `SAPISIDHASH ${seconds}_${hexHash}`;
33
+ }
34
+
35
+ /**
36
+ * Parse a raw cookie string (from browser export, header, or config) into tokens and a normalized string.
37
+ */
38
+ export function parseGoogleCookieJar(cookieInput: string): GoogleCookieJar {
39
+ const cleanInput = (cookieInput || "").trim();
40
+ const jar: GoogleCookieJar = { cookieHeader: cleanInput };
41
+ if (!cleanInput) return jar;
42
+ if (/[\r\n\u0000]/.test(cleanInput)) return { cookieHeader: "" };
43
+
44
+ const parts = cleanInput.split(";").map((p) => p.trim());
45
+ for (const part of parts) {
46
+ const eqIdx = part.indexOf("=");
47
+ if (eqIdx === -1) continue;
48
+ const name = part.slice(0, eqIdx).trim();
49
+ const value = part.slice(eqIdx + 1).trim();
50
+
51
+ if (name === "SAPISID" || name === "__Secure-3PAPISID") {
52
+ jar.sapisid = value;
53
+ } else if (name === "__Secure-1PSID" || name === "__Secure-3PSID") {
54
+ jar.psid = value;
55
+ } else if (name === "SSID") {
56
+ jar.ssid = value;
57
+ } else if (name === "HSID") {
58
+ jar.hsid = value;
59
+ } else if (name === "SID") {
60
+ jar.sid = value;
61
+ }
62
+ }
63
+
64
+ return jar;
65
+ }
66
+
67
+ /**
68
+ * Validate whether a cookie jar contains sufficient authentication credentials.
69
+ */
70
+ export function validateAiStudioCookies(jar: GoogleCookieJar): { valid: boolean; error?: string } {
71
+ if (!jar.sapisid || !jar.cookieHeader) {
72
+ return {
73
+ valid: false,
74
+ error: "Missing SAPISID cookie required for Google AI Studio authorization.",
75
+ };
76
+ }
77
+ return { valid: true };
78
+ }
79
+
80
+ /**
81
+ * Build the HTTP headers required for alkalimakersuite-pa.clients6.google.com calls.
82
+ */
83
+ export async function buildAiStudioHeaders(
84
+ jar: GoogleCookieJar,
85
+ origin: string = DEFAULT_ORIGIN
86
+ ): Promise<Record<string, string>> {
87
+ const sapisid = jar.sapisid || "";
88
+ const authHeader = await generateSapisidHash(sapisid, origin);
89
+
90
+ return {
91
+ "Authorization": authHeader,
92
+ "Cookie": jar.cookieHeader,
93
+ "X-Goog-AuthUser": "0",
94
+ "Origin": origin,
95
+ "Referer": origin.endsWith("/") ? origin : `${origin}/`,
96
+ "Content-Type": "application/json",
97
+ };
98
+ }
@@ -58,11 +58,20 @@ export interface OAuthAccessSnapshot {
58
58
  /** Cloud Code Assist project selected during Antigravity login. */
59
59
  projectId?: string;
60
60
  /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */
61
- kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">;
61
+ kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion" | "authType">;
62
+ /**
63
+ * Allowlisted GitHub Copilot API origin belonging to THIS account.
64
+ *
65
+ * Copilot pins its bearer to an account-scoped regional host, and the initial route already
66
+ * pairs the two (`core.ts` resolves transport with `getOAuthCredentialApiBaseUrl`). Account
67
+ * failover must carry the pairing across the rotation; without it, account B's token is sent to
68
+ * account A's origin (#2568d).
69
+ */
70
+ apiBaseUrl?: string;
62
71
  }
63
72
 
64
73
  export interface ObservedOAuthAccessSnapshot extends OAuthAccessSnapshot {
65
- /** Allowlisted provider API origin consumed by GitHub Copilot model discovery. */
74
+ /** Retained for callers that predate `apiBaseUrl` moving onto the base snapshot. */
66
75
  apiBaseUrl?: string;
67
76
  }
68
77
 
@@ -351,24 +360,43 @@ export function publicOAuthAuthenticationErrorMessage(error: unknown): string {
351
360
  }
352
361
 
353
362
  function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot {
363
+ // Derived, not read back: a stored `authType` is trusted when present, but a credential imported
364
+ // before the field existed still routes correctly because the client pair implies SSO OIDC.
365
+ const kiroAuthType = cred.kiro?.authType
366
+ ?? (cred.kiro?.clientId && cred.kiro?.clientSecret ? "aws_sso_oidc" as const : undefined);
354
367
  const storedKiroRouting = {
355
368
  ...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}),
356
369
  ...(cred.kiro?.apiRegion ? { apiRegion: cred.kiro.apiRegion } : {}),
357
370
  ...(cred.kiro?.ssoRegion ? { ssoRegion: cred.kiro.ssoRegion } : {}),
358
371
  };
372
+ // `authType` is a property OF the account, not routing the environment can substitute for, so it
373
+ // is merged after the environment fallback decision rather than counting as stored routing.
374
+ // Folding it into `storedKiroRouting` would make a client-pair-only credential look non-empty
375
+ // and silently disable `environmentKiroRoutingMetadata()` for it.
376
+ const kiroAuthTypeRouting = kiroAuthType ? { authType: kiroAuthType } : {};
377
+ // Validated here, not at the call site: an unvalidated origin from a legacy or crafted
378
+ // credential must never travel with a bearer, and dropping it makes the transport fall back to
379
+ // the canonical host rather than to whatever the previous account was using.
380
+ const copilotApiBaseUrl = provider === "github-copilot"
381
+ ? validateCopilotApiBaseUrl(cred.apiBaseUrl)
382
+ : undefined;
359
383
  return {
360
384
  provider,
361
385
  accountId,
362
386
  generation: credentialGeneration(cred),
363
387
  accessToken: cred.access,
364
388
  ...(cred.projectId ? { projectId: cred.projectId } : {}),
389
+ ...(copilotApiBaseUrl ? { apiBaseUrl: copilotApiBaseUrl } : {}),
365
390
  // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials
366
391
  // may use explicit environment routing, but never borrow the currently signed-in local CLI account.
367
392
  ...(provider === "kiro"
368
393
  ? {
369
- kiro: Object.keys(storedKiroRouting).length > 0
370
- ? storedKiroRouting
371
- : environmentKiroRoutingMetadata() ?? {},
394
+ kiro: {
395
+ ...(Object.keys(storedKiroRouting).length > 0
396
+ ? storedKiroRouting
397
+ : environmentKiroRoutingMetadata() ?? {}),
398
+ ...kiroAuthTypeRouting,
399
+ },
372
400
  }
373
401
  : {}),
374
402
  };
@@ -499,6 +527,22 @@ export async function getValidAccessTokenForAccount(provider: string, accountId:
499
527
  return (await resolveAccessSnapshotForAccount(provider, accountId)).accessToken;
500
528
  }
501
529
 
530
+ /**
531
+ * Account-scoped resolver returning the FULL snapshot, not just the bearer.
532
+ *
533
+ * A rotator that swaps only the token silently mixes credential generations: Antigravity pairs
534
+ * an account-matched `projectId` with its token (see the pairing comment in
535
+ * server/responses/core.ts), Kiro carries routing metadata, and Copilot's observed snapshot
536
+ * carries an account-specific API origin. Reading those back from "whichever account is active"
537
+ * after a rotation is exactly the mixing this returns in one piece to prevent (#2568).
538
+ */
539
+ export async function getValidAccessSnapshotForAccount(
540
+ provider: string,
541
+ accountId: string,
542
+ ): Promise<OAuthAccessSnapshot> {
543
+ return resolveAccessSnapshotForAccount(provider, accountId);
544
+ }
545
+
502
546
  /** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */
503
547
  function isTerminalRefreshError(err: unknown): boolean {
504
548
  const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
@@ -1094,6 +1138,14 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1094
1138
  if (existing?.modelCosts !== undefined) {
1095
1139
  next.modelCosts = existing.modelCosts;
1096
1140
  }
1141
+ // The per-provider account-failover opt-out is operator intent about SPENDING, and the login
1142
+ // path is exactly where losing it does damage: adding a second account both rebuilds this row
1143
+ // from the preset and creates the 2-account quorum that turns presence-driven rotation on
1144
+ // (#2568d). Dropping the opt-out here would enable the thing the operator switched off, at the
1145
+ // moment they were doing something unrelated.
1146
+ if (existing?.oauthAccountFailover !== undefined) {
1147
+ next.oauthAccountFailover = existing.oauthAccountFailover;
1148
+ }
1097
1149
  if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) {
1098
1150
  // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
1099
1151
  let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
@@ -1,6 +1,7 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
2
  import { deriveKeyLoginMap, enrichProviderFromRegistry, type DerivedKeyLoginProvider } from "../providers/derive";
3
3
  import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
4
+ import { parseGoogleCookieJar, validateAiStudioCookies } from "./google-aistudio-auth";
4
5
 
5
6
  /**
6
7
  * API-key "login" providers: not OAuth — the flow opens the provider's dashboard so the user can
@@ -19,7 +20,8 @@ export const KEY_LOGIN_PROVIDERS: Record<string, KeyLoginProvider> = deriveKeyLo
19
20
  * caller didn't already supply. Lets the vision/reasoning classification actually reach the saved
20
21
  * config (the GUI/API only send adapter/baseUrl/apiKey/defaultModel). No-op for unknown names.
21
22
  *
22
- * `modelSupportsReasoningSummaries` is deliberately excluded from what gets persisted. It is
23
+ * `modelSupportsReasoningSummaries` and the verbosity capability are deliberately excluded from
24
+ * what gets persisted. They are
23
25
  * registry-only metadata resolved at runtime, and this function feeds a config that is about to
24
26
  * be written to disk. Persisting today's registry defaults would freeze them as the user's own
25
27
  * overrides: a later registry correction — say we learn a model's backend rejects summary
@@ -30,9 +32,17 @@ export const KEY_LOGIN_PROVIDERS: Record<string, KeyLoginProvider> = deriveKeyLo
30
32
  export function enrichProviderFromCatalog(name: string, prov: OcxProviderConfig): void {
31
33
  const hadOwnSummaries = Object.hasOwn(prov, "modelSupportsReasoningSummaries");
32
34
  const submittedSummaries = prov.modelSupportsReasoningSummaries;
35
+ const hadOwnVerbosity = Object.hasOwn(prov, "modelSupportsVerbosity");
36
+ const submittedVerbosity = prov.modelSupportsVerbosity;
37
+ const hadOwnProviderVerbosity = Object.hasOwn(prov, "supportsVerbosity");
38
+ const submittedProviderVerbosity = prov.supportsVerbosity;
33
39
  enrichProviderFromRegistry(name, prov);
34
40
  if (hadOwnSummaries) prov.modelSupportsReasoningSummaries = submittedSummaries;
35
41
  else delete prov.modelSupportsReasoningSummaries;
42
+ if (hadOwnVerbosity) prov.modelSupportsVerbosity = submittedVerbosity;
43
+ else delete prov.modelSupportsVerbosity;
44
+ if (hadOwnProviderVerbosity) prov.supportsVerbosity = submittedProviderVerbosity;
45
+ else delete prov.supportsVerbosity;
36
46
  }
37
47
 
38
48
  export function isKeyLoginProvider(name: string): boolean {
@@ -86,6 +96,13 @@ export async function validateApiKey(
86
96
  return "unknown";
87
97
  }
88
98
 
99
+ if (provider.adapter === "google" && provider.googleMode === "ai-studio-web") {
100
+ const jar = parseGoogleCookieJar(key);
101
+ const val = validateAiStudioCookies(jar);
102
+ if (!val.valid) return false;
103
+ return true;
104
+ }
105
+
89
106
  if (provider.adapter === "google" && (provider.googleMode ?? "ai-studio") === "ai-studio") {
90
107
  // Generative Language API rejects Bearer-wrapped API keys; probe models.list with the
91
108
  // documented x-goog-api-key header instead (pageSize=1 — validation only needs a 200).
package/src/oauth/kiro.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  } from "./kiro-credentials";
29
29
  import { homedir } from "node:os";
30
30
  import { getAccountSet, saveAccountCredential } from "./store";
31
+ import { KIRO_BUILDER_ID_SERVICE_PROFILE_ARN } from "../adapters/kiro-constants";
31
32
 
32
33
  const DEFAULT_REGION = "us-east-1";
33
34
  const REFRESH_URL = "https://prod.{region}.auth.desktop.kiro.dev/refreshToken";
@@ -473,6 +474,50 @@ export function resolveKiroProfileArn(account?: Pick<KiroOAuthMetadata, "profile
473
474
  return readImportedKiroCredential()?.profileArn;
474
475
  }
475
476
 
477
+ /**
478
+ * Resolve the profileArn actually SENT upstream, which is not always the account's own.
479
+ *
480
+ * An AWS Builder ID account authenticates through SSO OIDC and never receives an account-scoped
481
+ * profile ARN, so gated models reject its requests with a `profileArn`-demanding
482
+ * `ValidationException`. The Kiro CLI handles this by carrying a fixed service profile on Builder
483
+ * ID requests, and this mirrors that.
484
+ *
485
+ * Deliberately separate from `resolveKiroProfileArn`: that resolver answers "what is this
486
+ * account's profile", and callers that ask it — region inference, account matching, continuation
487
+ * scoping — must keep receiving `undefined` here. Only request construction uses this function.
488
+ *
489
+ * The fallback is gated on `authType === "aws_sso_oidc"` rather than on a missing ARN, so a
490
+ * `kiro_desktop` account whose profile import failed keeps producing its actionable error instead
491
+ * of silently borrowing a service profile that does not describe it.
492
+ */
493
+ export function resolveKiroRequestProfileArn(
494
+ account?: Pick<KiroOAuthMetadata, "profileArn" | "authType">,
495
+ ): string | undefined {
496
+ return resolveKiroRequestProfile(account).profileArn;
497
+ }
498
+
499
+ /**
500
+ * The profileArn to send, together with WHY it was chosen.
501
+ *
502
+ * The request builder must decide the wire envelope from the same evaluation that produced the
503
+ * ARN. Re-deriving "is this Builder ID" from the account context alone would miss the accountless
504
+ * path, where the auth type comes from the locally imported credential instead: the fallback would
505
+ * be sent while the request was shaped as an enterprise IDE call, which is not a combination the
506
+ * vendor client ever produces.
507
+ */
508
+ export function resolveKiroRequestProfile(
509
+ account?: Pick<KiroOAuthMetadata, "profileArn" | "authType">,
510
+ ): { profileArn: string | undefined; builderIdFallback: boolean } {
511
+ const own = resolveKiroProfileArn(account);
512
+ if (own) return { profileArn: own, builderIdFallback: false };
513
+ const authType = account !== undefined
514
+ ? account.authType
515
+ : readImportedKiroCredential()?.authType;
516
+ return authType === "aws_sso_oidc"
517
+ ? { profileArn: KIRO_BUILDER_ID_SERVICE_PROFILE_ARN, builderIdFallback: true }
518
+ : { profileArn: undefined, builderIdFallback: false };
519
+ }
520
+
476
521
  async function kiroTokenRefreshError(response: Response): Promise<KiroTokenRefreshError> {
477
522
  let oauthError: string | undefined;
478
523
  try {
@@ -15,6 +15,7 @@ import { codexAccountNamespaceProviderCollisionError } from "../codex/account-na
15
15
  const LIVE_RELOAD_PROVIDERS = new Set<string>([
16
16
  ...listOAuthProviders(),
17
17
  ...Object.keys(KEY_LOGIN_PROVIDERS),
18
+ "google-aistudio",
18
19
  ]);
19
20
 
20
21
  export function runningProxyUpdateHeaders(): Headers {
@@ -65,16 +66,79 @@ export function warnIfLiveReloadSkipped(result: LocalProviderReloadResult | null
65
66
 
66
67
  export async function handleLogin(provider?: string): Promise<void> {
67
68
  const name = (provider ?? "").trim().toLowerCase();
69
+ if (name === "google-aistudio" || name === "aistudio" || name === "gemini-aistudio") {
70
+ return handleAiStudioLogin();
71
+ }
68
72
  if (isPublicOAuthProvider(name)) return handleOAuthLogin(name);
69
73
  if (isKeyLoginProvider(name)) return handleKeyLogin(name);
70
74
  console.error(
71
75
  `Usage: ocx login <provider>\n` +
72
- ` OAuth login: ${listOAuthProviders().join(", ")}\n` +
76
+ ` OAuth / Web: ${[...listOAuthProviders(), "google-aistudio"].join(", ")}\n` +
73
77
  ` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`,
74
78
  );
75
79
  process.exit(1);
76
80
  }
77
81
 
82
+ async function handleAiStudioLogin(): Promise<void> {
83
+ console.log("\n🌐 Google AI Studio Sign-In & Session Setup:");
84
+ console.log(" Option 1: Paste Session Token from the Brave/Chrome extension popup (Passkey-friendly)");
85
+ console.log(" Option 2: Open native macOS sign-in window\n");
86
+
87
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
88
+ try {
89
+ const choice = await new Promise<string>((res) => {
90
+ rl.question("Paste Session Token (or press Enter for native window): ", (ans) => res(ans.trim()));
91
+ });
92
+
93
+ if (choice.length > 20) {
94
+ const { saveAiStudioSessionFromToken } = await import("./aistudio-session-sync");
95
+ saveAiStudioSessionFromToken(choice);
96
+ console.log("\nāœ… Session token imported successfully! Saved to ~/.opencodex/aistudio-session.json");
97
+ } else if (process.platform === "darwin") {
98
+ const { runAiStudioNativeLogin } = await import("./aistudio-native-daemon");
99
+ console.log("\nšŸš€ Opening native Google AI Studio login window...");
100
+ const result = await runAiStudioNativeLogin();
101
+ if (result.kind === "cancelled") {
102
+ console.log("\nNative Google AI Studio login cancelled.");
103
+ return;
104
+ }
105
+ if (result.kind === "unsupported") {
106
+ console.error("\nGoogle AI Studio native login is only available on macOS.");
107
+ return;
108
+ }
109
+ if (result.kind === "failed") {
110
+ console.error(`\n${result.error}`);
111
+ return;
112
+ }
113
+ console.log("\nāœ… Google AI Studio authenticated successfully! Session saved to ~/.opencodex/aistudio-session.json");
114
+ } else {
115
+ console.error("\nGoogle AI Studio native login is only available on macOS. Paste a session token from the extension instead.");
116
+ return;
117
+ }
118
+ } finally {
119
+ rl.close();
120
+ }
121
+
122
+ const config = loadConfig();
123
+ if (!config.providers["google-aistudio"]) {
124
+ config.providers["google-aistudio"] = {
125
+ adapter: "google",
126
+ googleMode: "ai-studio-web",
127
+ baseUrl: "https://alkalimakersuite-pa.clients6.google.com",
128
+ authMode: "local",
129
+ liveModels: false,
130
+ defaultModel: "gemini-3.7-flash",
131
+ 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);
134
+ console.log("\n āœ“ Configured 'google-aistudio' in ~/.opencodex/config.json");
135
+ }
136
+
137
+ const reload = await notifyRunningProxy("google-aistudio");
138
+ console.log("\nāœ… Ready! Use models with 'google-aistudio' provider in your coding agents.");
139
+ warnIfLiveReloadSkipped(reload);
140
+ }
141
+
78
142
  async function handleOAuthLogin(name: string): Promise<void> {
79
143
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
80
144
  try {
@@ -1,6 +1,16 @@
1
1
  /** Minimal OAuth types, ported from jawcode packages/ai/src/utils/oauth/types.ts. */
2
2
  export type OAuthCredentialSource = "oauth" | "local-cli" | "credential-file" | "environment" | "manual";
3
3
 
4
+ /**
5
+ * How the account authenticated. Mirrors `KiroAuthType` in `./kiro-credentials`, restated here so
6
+ * the credential-store types do not depend on the SQLite import module.
7
+ *
8
+ * `aws_sso_oidc` covers AWS Builder ID, which never issues an account-scoped CodeWhisperer
9
+ * profile ARN; the adapter needs that distinction to tell a Builder ID account apart from a
10
+ * `kiro_desktop` account whose profile import merely failed.
11
+ */
12
+ export type KiroCredentialAuthType = "kiro_desktop" | "aws_sso_oidc";
13
+
4
14
  /** Account-scoped Kiro data required for refresh and request routing. */
5
15
  export interface KiroOAuthMetadata {
6
16
  profileArn?: string;
@@ -8,6 +18,11 @@ export interface KiroOAuthMetadata {
8
18
  apiRegion?: string;
9
19
  clientId?: string;
10
20
  clientSecret?: string;
21
+ /**
22
+ * Non-secret routing signal. Derived from the presence of a device-registration client pair, so
23
+ * it stays accurate even though `clientId`/`clientSecret` never leave the credential store.
24
+ */
25
+ authType?: KiroCredentialAuthType;
11
26
  }
12
27
 
13
28
  export type OAuthCredentials = {