@yansigit/opencodex 2.33.0 → 2.33.1

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 (62) hide show
  1. package/gui/dist/assets/index-CIDo4y4k.js +102 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +3 -1
  4. package/src/adapters/command-code.ts +101 -20
  5. package/src/adapters/cursor/envelope-echo.ts +162 -0
  6. package/src/adapters/cursor/live-transport.ts +3 -1
  7. package/src/adapters/cursor/native-exec-fs.ts +13 -12
  8. package/src/adapters/cursor/native-exec-network.ts +3 -5
  9. package/src/adapters/cursor/native-exec-policy.ts +47 -0
  10. package/src/adapters/cursor/native-exec-shell.ts +13 -25
  11. package/src/adapters/cursor/native-exec.ts +18 -10
  12. package/src/adapters/cursor/protobuf-events.ts +28 -2
  13. package/src/adapters/cursor/protobuf-request.ts +20 -3
  14. package/src/adapters/cursor/request-builder.ts +7 -0
  15. package/src/adapters/cursor/tool-definitions.ts +22 -1
  16. package/src/adapters/cursor/tool-result-normalize.ts +21 -8
  17. package/src/adapters/cursor/types.ts +7 -0
  18. package/src/adapters/cursor.ts +114 -0
  19. package/src/adapters/google-aistudio-parser.ts +49 -0
  20. package/src/adapters/google.ts +108 -16
  21. package/src/adapters/openai-responses.ts +1 -0
  22. package/src/chat/inbound.ts +15 -0
  23. package/src/cli/index.ts +1 -1
  24. package/src/codex/catalog/provider-fetch.ts +34 -0
  25. package/src/generated/compatibility-version.json +91 -47
  26. package/src/generated/model-metadata.ts +3 -0
  27. package/src/oauth/aistudio-native-daemon.ts +62 -0
  28. package/src/oauth/aistudio-session-sync.ts +95 -0
  29. package/src/oauth/google-aistudio-auth.ts +98 -0
  30. package/src/oauth/key-providers.ts +8 -0
  31. package/src/oauth/login-cli.ts +66 -1
  32. package/src/providers/derive.ts +1 -1
  33. package/src/providers/quota.ts +90 -38
  34. package/src/providers/registry.ts +24 -3
  35. package/src/router.ts +3 -0
  36. package/src/routing/account-pool/cooldown.ts +8 -0
  37. package/src/routing/account-pool/index.ts +1 -0
  38. package/src/server/aistudio-ws-hub.ts +295 -0
  39. package/src/server/auth-cors.ts +1 -0
  40. package/src/server/chat-completions.ts +2 -0
  41. package/src/server/index.ts +94 -0
  42. package/src/server/management/logs-usage-routes.ts +11 -5
  43. package/src/server/management/oauth-account-routes.ts +13 -3
  44. package/src/server/port-reclaim.ts +19 -1
  45. package/src/server/request-log-conversation.ts +12 -0
  46. package/src/server/request-log.ts +2 -1
  47. package/src/server/responses/core.ts +4 -3
  48. package/src/server/responses/policy-fallback.ts +1 -1
  49. package/src/server/ws-bridge.ts +2 -1
  50. package/src/smoke/fingerprint-cache.ts +133 -0
  51. package/src/smoke/live-scenarios.ts +33 -0
  52. package/src/smoke/runner.ts +119 -0
  53. package/src/types/provider.ts +2 -1
  54. package/src/types/request.ts +2 -0
  55. package/src/types/tools.ts +31 -7
  56. package/src/usage/command-code-manifest.ts +116 -0
  57. package/src/usage/cost.ts +2 -2
  58. package/src/usage/expected-prices.ts +83 -0
  59. package/src/usage/log.ts +2 -2
  60. package/src/usage/summary.ts +34 -12
  61. package/src/web-search/index.ts +16 -8
  62. package/gui/dist/assets/index-DKLr4LTE.js +0 -102
@@ -0,0 +1,295 @@
1
+ /**
2
+ * In-process WebSocket Relay Hub for Google AI Studio Web / Build sessions.
3
+ */
4
+
5
+ export interface WsRelayRequest {
6
+ url: string;
7
+ method: string;
8
+ headers?: Record<string, string>;
9
+ body?: string;
10
+ }
11
+
12
+ export interface WsRelayStreamResult {
13
+ chunks: AsyncIterable<string>;
14
+ status?: number;
15
+ headers?: Record<string, string>;
16
+ }
17
+
18
+ export interface WebSocketLike {
19
+ send(data: string): void;
20
+ close(code?: number, reason?: string): void;
21
+ }
22
+
23
+ interface PendingStream {
24
+ pushChunk(chunk: string): void;
25
+ finish(): void;
26
+ fail(err: Error): void;
27
+ }
28
+
29
+ export interface AiStudioRelayHub {
30
+ hasActiveSessions(): boolean;
31
+ getActiveSessionCount(): number;
32
+ registerSession(id: string, ws: WebSocketLike): void;
33
+ unregisterSession(id: string): void;
34
+ reset(): void;
35
+ dispatchStream(req: WsRelayRequest, signal?: AbortSignal): Promise<WsRelayStreamResult>;
36
+ handleClientMessage(sessionId: string, raw: string): void;
37
+ }
38
+
39
+ export function createAiStudioRelayHub(): AiStudioRelayHub {
40
+ const sessions = new Map<string, WebSocketLike>();
41
+ const pendingRequests = new Map<string, PendingStream>();
42
+ const pendingBySession = new Map<string, Set<string>>();
43
+ let sessionRoundRobin = 0;
44
+
45
+ function getNextSession(): { id: string; ws: WebSocketLike } | undefined {
46
+ for (const [id, ws] of sessions.entries()) {
47
+ if ((ws as any).readyState !== undefined && (ws as any).readyState > 1) {
48
+ sessions.delete(id);
49
+ }
50
+ }
51
+ const arr = Array.from(sessions.values());
52
+ if (arr.length === 0) return undefined;
53
+ const id = Array.from(sessions.keys())[sessionRoundRobin % arr.length]!;
54
+ const ws = sessions.get(id)!;
55
+ sessionRoundRobin = (sessionRoundRobin + 1) % arr.length;
56
+ return { id, ws };
57
+ }
58
+
59
+ return {
60
+ hasActiveSessions() {
61
+ return sessions.size > 0;
62
+ },
63
+
64
+ getActiveSessionCount() {
65
+ return sessions.size;
66
+ },
67
+
68
+ registerSession(id: string, ws: WebSocketLike) {
69
+ sessions.set(id, ws);
70
+ console.log(`[AIStudioHub] registered session: ${id}, total: ${sessions.size}`);
71
+ },
72
+
73
+ unregisterSession(id: string) {
74
+ sessions.delete(id);
75
+ console.log(`[AIStudioHub] unregistered session: ${id}, remaining: ${sessions.size}`);
76
+ const requestIds = pendingBySession.get(id);
77
+ if (!requestIds) return;
78
+ for (const requestId of requestIds) {
79
+ pendingRequests.get(requestId)?.fail(new Error("Google AI Studio browser session disconnected"));
80
+ }
81
+ pendingBySession.delete(id);
82
+ },
83
+
84
+ reset() {
85
+ for (const pending of pendingRequests.values()) {
86
+ pending.fail(new Error("AI Studio relay hub reset"));
87
+ }
88
+ sessions.clear();
89
+ pendingRequests.clear();
90
+ pendingBySession.clear();
91
+ sessionRoundRobin = 0;
92
+ },
93
+
94
+ async dispatchStream(req: WsRelayRequest, signal?: AbortSignal): Promise<WsRelayStreamResult> {
95
+ const session = getNextSession();
96
+ if (!session) {
97
+ throw new Error("No active Google AI Studio browser session connected. Open the AI Studio bridge in your browser to start.");
98
+ }
99
+ const { id: sessionId, ws } = session;
100
+
101
+ const reqId = `req_${crypto.randomUUID()}`;
102
+ const chunkQueue: string[] = [];
103
+ let resolveNextChunk: ((value: IteratorResult<string>) => void) | null = null;
104
+ let rejectNextChunk: ((err: Error) => void) | null = null;
105
+ let isFinished = false;
106
+ let finishError: Error | null = null;
107
+
108
+ const pending: PendingStream = {
109
+ pushChunk(chunk: string) {
110
+ if (isFinished) return;
111
+ if (resolveNextChunk) {
112
+ const r = resolveNextChunk;
113
+ resolveNextChunk = null;
114
+ rejectNextChunk = null;
115
+ r({ value: chunk, done: false });
116
+ } else {
117
+ chunkQueue.push(chunk);
118
+ }
119
+ },
120
+ finish() {
121
+ if (isFinished) return;
122
+ isFinished = true;
123
+ pendingRequests.delete(reqId);
124
+ pendingBySession.get(sessionId)?.delete(reqId);
125
+ if (resolveNextChunk) {
126
+ const r = resolveNextChunk;
127
+ resolveNextChunk = null;
128
+ rejectNextChunk = null;
129
+ r({ value: undefined as any, done: true });
130
+ }
131
+ },
132
+ fail(err: Error) {
133
+ if (isFinished) return;
134
+ isFinished = true;
135
+ finishError = err;
136
+ pendingRequests.delete(reqId);
137
+ pendingBySession.get(sessionId)?.delete(reqId);
138
+ if (rejectNextChunk) {
139
+ const r = rejectNextChunk;
140
+ resolveNextChunk = null;
141
+ rejectNextChunk = null;
142
+ r(err);
143
+ }
144
+ },
145
+ };
146
+
147
+ pendingRequests.set(reqId, pending);
148
+ const requestIds = pendingBySession.get(sessionId) ?? new Set<string>();
149
+ requestIds.add(reqId);
150
+ pendingBySession.set(sessionId, requestIds);
151
+
152
+ if (signal) {
153
+ signal.addEventListener("abort", () => {
154
+ try {
155
+ ws.send(JSON.stringify({ id: reqId, type: "abort" }));
156
+ } catch (err) {
157
+ void err;
158
+ }
159
+ pending.fail(new Error("Request aborted by client"));
160
+ });
161
+ }
162
+
163
+ const msg = {
164
+ id: reqId,
165
+ type: "http_request",
166
+ payload: {
167
+ url: req.url,
168
+ method: req.method,
169
+ headers: req.headers ?? {},
170
+ body: req.body ?? "",
171
+ },
172
+ };
173
+
174
+ ws.send(JSON.stringify(msg));
175
+
176
+ const asyncIterable: AsyncIterable<string> = {
177
+ [Symbol.asyncIterator]() {
178
+ return {
179
+ async next(): Promise<IteratorResult<string>> {
180
+ if (chunkQueue.length > 0) {
181
+ return { value: chunkQueue.shift()!, done: false };
182
+ }
183
+ if (isFinished) {
184
+ if (finishError) throw finishError;
185
+ return { value: undefined as any, done: true };
186
+ }
187
+ return new Promise<IteratorResult<string>>((resolve, reject) => {
188
+ resolveNextChunk = resolve;
189
+ rejectNextChunk = reject;
190
+ });
191
+ },
192
+ };
193
+ },
194
+ };
195
+
196
+ return { chunks: asyncIterable };
197
+ },
198
+
199
+ handleClientMessage(sessionId: string, raw: string) {
200
+ let data: any;
201
+ try {
202
+ data = JSON.parse(raw);
203
+ } catch (err) {
204
+ console.log("[AIStudioHub] failed to parse JSON message:", err);
205
+ void err;
206
+ return;
207
+ }
208
+
209
+ const { id, type, payload } = data || {};
210
+ if (!id) {
211
+ console.log("[AIStudioHub] message missing id");
212
+ return;
213
+ }
214
+
215
+ const pending = pendingRequests.get(id);
216
+ if (!pending || !pendingBySession.get(sessionId)?.has(id)) {
217
+ console.log("[AIStudioHub] no pending request for id:", id, "session:", sessionId);
218
+ return;
219
+ }
220
+
221
+ if (type === "stream_chunk") {
222
+ if (payload?.data) {
223
+ pending.pushChunk(payload.data);
224
+ }
225
+ } else if (type === "stream_end" || type === "http_response") {
226
+ if (payload?.body) {
227
+ pending.pushChunk(payload.body);
228
+ }
229
+ pending.finish();
230
+ } else if (type === "error") {
231
+ console.log(`[AIStudioHub] client error from ${sessionId}:`, payload?.error);
232
+ pending.fail(new Error(payload?.error || "Upstream AI Studio error"));
233
+ }
234
+ },
235
+ };
236
+ }
237
+
238
+ export const globalAiStudioRelayHub = createAiStudioRelayHub();
239
+
240
+ export function getAiStudioUserScript(listenPort: number): string {
241
+ return `// ==UserScript==
242
+ // @name OpenCodex AI Studio Relay Bridge
243
+ // @namespace https://opencodex.dev/
244
+ // @version 1.0.0
245
+ // @description Relays requests from opencodex to Google AI Studio session
246
+ // @match https://aistudio.google.com/*
247
+ // @grant none
248
+ // @run-at document-idle
249
+ // ==/UserScript==
250
+
251
+ (function() {
252
+ 'use strict';
253
+ const WS_URL = "ws://127.0.0.1:${listenPort}/v1/ws/aistudio";
254
+ let ws;
255
+ function connect() {
256
+ try { ws = new WebSocket(WS_URL); } catch { setTimeout(connect, 3000); return; }
257
+ ws.onmessage = async (e) => {
258
+ try {
259
+ const msg = JSON.parse(e.data);
260
+ if (msg.type === "http_request") {
261
+ const { id, payload } = msg;
262
+ try {
263
+ const res = await fetch(payload.url, { method: payload.method, headers: payload.headers, body: payload.body, credentials: "include" });
264
+ if (payload.url.includes("streamGenerateContent") && res.body) {
265
+ const reader = res.body.getReader();
266
+ const decoder = new TextDecoder();
267
+ while (true) {
268
+ const { done, value } = await reader.read();
269
+ if (done) break;
270
+ ws.send(JSON.stringify({ id, type: "stream_chunk", payload: { data: decoder.decode(value, { stream: true }) } }));
271
+ }
272
+ ws.send(JSON.stringify({ id, type: "stream_end", payload: {} }));
273
+ } else {
274
+ const text = await res.text();
275
+ ws.send(JSON.stringify({ id, type: "http_response", payload: { body: text, status: res.status } }));
276
+ }
277
+ } catch (err) {
278
+ ws.send(JSON.stringify({ id, type: "error", payload: { error: String(err) } }));
279
+ }
280
+ }
281
+ } catch (err) {
282
+ void err;
283
+ }
284
+ };
285
+ ws.onclose = () => setTimeout(connect, 3000);
286
+ }
287
+ connect();
288
+ })();
289
+ `;
290
+ }
291
+
292
+ export function getAiStudioBridgeHtml(listenPort: number): string {
293
+ const extensionPath = typeof process !== "undefined" ? process.cwd() + "/integrations/aistudio-extension" : "integrations/aistudio-extension";
294
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Google AI Studio Bridge</title><style>body{font-family:system-ui,-apple-system,sans-serif;background:#121316;color:#e1e3e6;padding:2rem;max-width:680px;margin:auto;line-height:1.5}.card{background:#1e1f23;border:1px solid #2d2f34;border-radius:8px;padding:1.5rem;margin-bottom:1.5rem}.status{display:inline-block;padding:0.25rem 0.75rem;border-radius:4px;font-weight:500}.connected{background:#163820;color:#4ade80}.disconnected{background:#3b1717;color:#f87171}code{background:#2a2c31;padding:0.2rem 0.4rem;border-radius:4px}.btn{display:inline-block;padding:0.5rem 1rem;background:#3b82f6;color:#fff;text-decoration:none;border-radius:6px;font-weight:500;margin-top:0.5rem}pre{background:#16171a;padding:1rem;border-radius:6px;overflow-x:auto;font-size:0.85rem}</style></head><body><div class="card"><h2>Google AI Studio Bridge</h2><p>Status: <span id="status" class="status disconnected">Connecting...</span></p><p>This bridge connects <code>opencodex</code> with your active Google AI Studio / Google AI Pro session.</p></div><div class="card"><h3>🧩 Option 1: Unpacked Extension for Brave / Chrome (Zero Tab Overhead)</h3><p>Run entirely in the background without needing an active browser tab:</p><ol style="padding-left:1.2rem;margin:0.5rem 0"><li>Open <code>brave://extensions</code> (or <code>chrome://extensions</code>) and toggle <strong>Developer mode</strong> in the top-right.</li><li>Click <strong>Load unpacked</strong> and select this directory:<br><code id="ext-path">${extensionPath}</code></li></ol><button class="btn" style="cursor:pointer" onclick="navigator.clipboard.writeText(document.getElementById('ext-path').textContent);this.textContent='Copied!'">📋 Copy Extension Directory Path</button></div><div class="card"><h3>🌐 Option 2: Userscript (Tampermonkey) or Console</h3><p>Install via Tampermonkey/Violentmonkey or paste in Developer Tools Console:</p><p><a class="btn" href="/aistudio/bridge.user.js">⚡ Install Userscript</a> <a class="btn" style="background:#374151;margin-left:0.5rem" href="https://aistudio.google.com" target="_blank">Open aistudio.google.com ↗</a></p><pre style="margin-top:1rem"><code>const ws=new WebSocket('ws://127.0.0.1:${listenPort}/v1/ws/aistudio');ws.onmessage=async(e)=>{const{id,payload,type}=JSON.parse(e.data);if(type==='http_request'){try{const res=await fetch(payload.url,{method:payload.method,headers:payload.headers,body:payload.body,credentials:'include'});if(payload.url.includes('streamGenerateContent')&&res.body){const r=res.body.getReader(),d=new TextDecoder();while(true){const{done,value}=await r.read();if(done)break;ws.send(JSON.stringify({id,type:'stream_chunk',payload:{data:d.decode(value,{stream:true})}}))}ws.send(JSON.stringify({id,type:'stream_end',payload:{}}))}else{const body=await res.text();ws.send(JSON.stringify({id,type:'http_response',payload:{body,status:res.status}}))}}catch(err){ws.send(JSON.stringify({id,type:'error',payload:{error:String(err)}}))}}};console.log('opencodex AI Studio relay active!');</code></pre></div><script>let ws;const statusEl=document.getElementById("status");function connect(){const proto=location.protocol==="https:"?"wss:":"ws:";ws=new WebSocket(proto+"//"+location.host+"/v1/ws/aistudio");ws.onopen=()=>{statusEl.textContent="🟢 Connected (Relay Active)";statusEl.className="status connected"};ws.onclose=()=>{statusEl.textContent="🔴 Disconnected (Retrying...)";statusEl.className="status disconnected";setTimeout(connect,2000)}};connect()</script></body></html>`;
295
+ }
@@ -721,6 +721,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
721
721
  "disabled",
722
722
  "allowPrivateNetwork",
723
723
  "authMode",
724
+ "googleMode",
724
725
  "apiKeyTransport",
725
726
  "keyOptional",
726
727
  "freeTier",
@@ -9,6 +9,7 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses";
9
9
  import {
10
10
  assertChatCompletionsRoutingBody,
11
11
  ChatCompletionsRequestError,
12
+ copyChatResponsesSessionHeaders,
12
13
  chatCompletionsToResponsesBody,
13
14
  } from "../chat/inbound";
14
15
  import {
@@ -201,6 +202,7 @@ async function handleChatCompletionsWithBudget(
201
202
  const value = req.headers.get(name);
202
203
  if (value) headers.set(name, value);
203
204
  }
205
+ copyChatResponsesSessionHeaders(req.headers, headers);
204
206
  // Prefer main ChatGPT auth so OpenAI-backed sidecars remain reachable on routed turns.
205
207
  if (!directRoute) {
206
208
  // This enrichment is optional for routed/non-main providers. If native main
@@ -40,6 +40,7 @@ import {
40
40
  enforceAppOwnedMemoryBudget,
41
41
  resolveAppOwnedMemoryBudgetBytes,
42
42
  } from "../lib/app-owned-memory";
43
+ import { getAiStudioBridgeHtml, getAiStudioUserScript, globalAiStudioRelayHub } from "./aistudio-ws-hub";
43
44
  import {
44
45
  registerAppOwnedMemorySweepFallback,
45
46
  registerDefaultAppOwnedMemoryStores,
@@ -668,6 +669,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
668
669
  if (path === "/v1/realtime" || path === "/v1/live") {
669
670
  return req.headers.get("upgrade")?.toLowerCase() === "websocket";
670
671
  }
672
+ if (path === "/v1/ws/aistudio" || path === "/aistudio/ws") {
673
+ return req.headers.get("upgrade")?.toLowerCase() === "websocket";
674
+ }
675
+ if (path === "/v1/ws/aistudio/status") return req.method === "GET";
676
+ if (path === "/api/aistudio/session") return req.method === "POST";
677
+ if (path === "/aistudio/bridge" || path === "/aistudio/bridge.user.js") return req.method === "GET";
671
678
  return false;
672
679
  }
673
680
 
@@ -903,6 +910,73 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
903
910
  return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy);
904
911
  }
905
912
 
913
+ if (url.pathname === "/v1/ws/aistudio" || url.pathname === "/aistudio/ws") {
914
+ if (isDraining()) return drainingResponse(req, policy);
915
+ const admission = resolveApiAuth(req, policy);
916
+ if (!admission) {
917
+ return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
918
+ }
919
+ const origin = req.headers.get("Origin");
920
+ const isExtensionOrigin = origin?.startsWith("chrome-extension://");
921
+ if (!isAllowedRequestOrigin(req, policy) && origin !== "https://aistudio.google.com" && !isExtensionOrigin) {
922
+ return withCors(formatErrorResponse(403, "origin_rejected", "AI Studio relay WebSocket blocked: non-local Origin"), req, policy);
923
+ }
924
+ const sessionId = "aistudio_" + crypto.randomUUID().slice(0, 8);
925
+ if (requestServer.upgrade(req, {
926
+ data: { kind: "aistudio-relay", aistudioSessionId: sessionId },
927
+ })) return undefined as unknown as Response;
928
+ return withCors(formatErrorResponse(426, "upgrade_required", "AI Studio WebSocket upgrade failed"), req, policy);
929
+ }
930
+
931
+ if (url.pathname === "/v1/ws/aistudio/status" && req.method === "GET") {
932
+ return jsonResponse({
933
+ activeSessions: globalAiStudioRelayHub.getActiveSessionCount(),
934
+ hasActiveSessions: globalAiStudioRelayHub.hasActiveSessions(),
935
+ });
936
+ }
937
+
938
+ if (url.pathname === "/api/aistudio/session" && req.method === "POST") {
939
+ const admission = resolveApiAuth(req, policy);
940
+ if (!admission) {
941
+ return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
942
+ }
943
+ const origin = req.headers.get("Origin");
944
+ const isExtensionOrigin = origin?.startsWith("chrome-extension://");
945
+ if (!isAllowedRequestOrigin(req, policy) && origin !== "https://aistudio.google.com" && !isExtensionOrigin) {
946
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin request blocked"), req, policy);
947
+ }
948
+ try {
949
+ const bodyJson = (await req.json()) as any;
950
+ const { saveAiStudioSession, saveAiStudioSessionFromToken } = await import("../oauth/aistudio-session-sync");
951
+ if (bodyJson.token && typeof bodyJson.token === "string") {
952
+ saveAiStudioSessionFromToken(bodyJson.token);
953
+ } else if (Array.isArray(bodyJson.cookies)) {
954
+ saveAiStudioSession({
955
+ selectedProject: bodyJson.selectedProject || "",
956
+ windowId: bodyJson.windowId || "",
957
+ cookies: bodyJson.cookies,
958
+ });
959
+ } else {
960
+ return withCors(jsonResponse({ error: "invalid session payload" }, 400), req, policy);
961
+ }
962
+ return withCors(jsonResponse({ ok: true, message: "AI Studio session updated successfully" }), req, policy);
963
+ } catch (err) {
964
+ return withCors(jsonResponse({ error: String(err) }, 400), req, policy);
965
+ }
966
+ }
967
+
968
+ if (url.pathname === "/aistudio/bridge" && req.method === "GET") {
969
+ const listenPort = (server.port ?? config.port) || 10100;
970
+ const bridgeHtml = getAiStudioBridgeHtml(listenPort);
971
+ return new Response(bridgeHtml, { headers: { "Content-Type": "text/html; charset=utf-8" } });
972
+ }
973
+
974
+ if (url.pathname === "/aistudio/bridge.user.js" && req.method === "GET") {
975
+ const listenPort = (server.port ?? config.port) || 10100;
976
+ const userScript = getAiStudioUserScript(listenPort);
977
+ return new Response(userScript, { headers: { "Content-Type": "application/javascript; charset=utf-8" } });
978
+ }
979
+
906
980
  if (url.pathname === "/healthz" && req.method === "GET") {
907
981
  // service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
908
982
  const healthPort = server.port ?? listenPort;
@@ -1564,6 +1638,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1564
1638
  // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
1565
1639
  // Live sideband sockets (kind=live-sideband) are a transparent bidirectional relay instead.
1566
1640
  open(ws: ServerWebSocket<WsData>) {
1641
+ if (ws.data.kind === "aistudio-relay" && ws.data.aistudioSessionId) {
1642
+ globalAiStudioRelayHub.registerSession(ws.data.aistudioSessionId, ws);
1643
+ return;
1644
+ }
1567
1645
  if (ws.data.kind === "live-sideband") {
1568
1646
  if (!ws.data.liveTurnAdmissionLease) {
1569
1647
  closeLiveSideband(ws, 1013, "server busy");
@@ -1580,6 +1658,18 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1580
1658
  registerCodexWebSocket(ws);
1581
1659
  },
1582
1660
  message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
1661
+ if (ws.data.kind === "aistudio-relay" && ws.data.aistudioSessionId) {
1662
+ const text = typeof raw === "string"
1663
+ ? raw
1664
+ : Buffer.isBuffer(raw)
1665
+ ? raw.toString("utf-8")
1666
+ : new TextDecoder().decode(raw as any);
1667
+ globalAiStudioRelayHub.handleClientMessage(
1668
+ ws.data.aistudioSessionId,
1669
+ text
1670
+ );
1671
+ return;
1672
+ }
1583
1673
  if (ws.data.kind === "live-sideband") {
1584
1674
  if (ws.data.liveClosing) return;
1585
1675
  const rawBytes = webSocketFrameBytes(raw);
@@ -1758,6 +1848,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1758
1848
  })();
1759
1849
  },
1760
1850
  close(ws: ServerWebSocket<WsData>) {
1851
+ if (ws.data.kind === "aistudio-relay" && ws.data.aistudioSessionId) {
1852
+ globalAiStudioRelayHub.unregisterSession(ws.data.aistudioSessionId);
1853
+ return;
1854
+ }
1761
1855
  if (ws.data.kind === "live-sideband") {
1762
1856
  closeLiveSideband(ws);
1763
1857
  return;
@@ -21,7 +21,7 @@ import {
21
21
  submitManualLoginCode,
22
22
  upsertOAuthProvider,
23
23
  } from "../../oauth";
24
- import { removeCredential } from "../../oauth/store";
24
+ import { getAccountSet, removeCredential } from "../../oauth/store";
25
25
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
26
26
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
27
27
  import { deriveProviderPresets } from "../../providers/derive";
@@ -204,10 +204,16 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
204
204
  const filter = {
205
205
  provider: url.searchParams.get("provider"),
206
206
  model: url.searchParams.get("model"),
207
+ account: url.searchParams.get("account"),
207
208
  };
209
+ const fallbackAccounts: Record<string, string> = {};
210
+ for (const p of listOAuthProviders()) {
211
+ const set = getAccountSet(p);
212
+ if (set?.activeAccountId) fallbackAccounts[p] = set.activeAccountId;
213
+ }
208
214
  const project = <T extends UsageSummary>(summary: T, entries?: PersistedUsageEntry[]) =>
209
- projectUsageSummary(summary, filter, entries);
210
- const filterRequested = Boolean(filter.provider ?? filter.model);
215
+ projectUsageSummary(summary, filter, entries, fallbackAccounts);
216
+ const filterRequested = Boolean(filter.provider ?? filter.model ?? filter.account);
211
217
  const now = Date.now();
212
218
  try {
213
219
  const cacheKey = `${range}:${surface}`;
@@ -242,7 +248,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
242
248
  const revisionReadAt = Date.now();
243
249
  const window = snapshotWindow(snapshot.entries);
244
250
  const summary = {
245
- ...summarizeUsage(snapshot.entries, range, now, surface),
251
+ ...summarizeUsage(snapshot.entries, range, now, surface, fallbackAccounts),
246
252
  historyTruncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated,
247
253
  truncatedPrefixBytes: snapshot.truncatedPrefixBytes,
248
254
  entriesTruncated: snapshot.entriesTruncated,
@@ -270,7 +276,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
270
276
  for (const nextRange of ranges) {
271
277
  for (const nextSurface of surfaces) {
272
278
  const nextSummary = nextRange === range && nextSurface === surface ? summary : {
273
- ...summarizeUsage(snapshot.entries, nextRange, now, nextSurface),
279
+ ...summarizeUsage(snapshot.entries, nextRange, now, nextSurface, fallbackAccounts),
274
280
  historyTruncated: summary.historyTruncated,
275
281
  truncatedPrefixBytes: summary.truncatedPrefixBytes,
276
282
  entriesTruncated: summary.entriesTruncated,
@@ -438,10 +438,20 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
438
438
  const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; accountId?: unknown };
439
439
  const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
440
440
  const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
441
- if (provider !== "anthropic") return jsonResponse({ error: "clear-cooldown is only supported for anthropic" }, 400);
441
+ const supported = provider === "anthropic" || provider === "google-antigravity" || provider === "cursor" || provider === "command-code";
442
+ if (!supported) return jsonResponse({ error: "clear-cooldown is not supported for this provider" }, 400);
442
443
  if (!accountId) return jsonResponse({ error: "missing accountId" }, 400);
443
- const { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing");
444
- const cleared = clearAnthropicAccountCooldown(accountId);
444
+ let cleared = false;
445
+ if (provider === "anthropic") {
446
+ const { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing");
447
+ cleared = clearAnthropicAccountCooldown(accountId);
448
+ } else if (provider === "google-antigravity") {
449
+ const { clearAntigravityAccountCooldown } = await import("../../oauth/antigravity-routing");
450
+ cleared = clearAntigravityAccountCooldown(accountId);
451
+ } else if (provider === "cursor" || provider === "command-code") {
452
+ const { clearPoolAccountCooldown } = await import("../../routing/account-pool");
453
+ cleared = clearPoolAccountCooldown(provider, accountId);
454
+ }
445
455
  return jsonResponse({ ok: true, cleared });
446
456
  }
447
457
 
@@ -144,6 +144,24 @@ export function scanListenPids(port: number): ListenPidScan {
144
144
  .filter(pid => Number.isSafeInteger(pid) && pid > 0),
145
145
  };
146
146
  } catch (lsofErr) {
147
+ if (lsofErr && typeof lsofErr === "object" && "status" in lsofErr && lsofErr.status === 1) {
148
+ return { ok: true, pids: [] };
149
+ }
150
+ try {
151
+ const output = execFileSync("ss", ["-tlnp", `sport = :${port}`], {
152
+ encoding: "utf-8",
153
+ stdio: ["ignore", "pipe", "ignore"],
154
+ timeout: 3000,
155
+ });
156
+ const pids = new Set<number>();
157
+ for (const match of output.matchAll(/pid=(\d+)/g)) {
158
+ const pid = Number(match[1]);
159
+ if (Number.isSafeInteger(pid) && pid > 0) pids.add(pid);
160
+ }
161
+ return { ok: true, pids: [...pids] };
162
+ } catch {
163
+ /* ss unavailable or unsupported on this platform; try netstat */
164
+ }
147
165
  try {
148
166
  const output = execFileSync("netstat", ["-anlp"], {
149
167
  encoding: "utf-8",
@@ -154,7 +172,7 @@ export function scanListenPids(port: number): ListenPidScan {
154
172
  } catch (netstatErr) {
155
173
  return {
156
174
  ok: false,
157
- error: `lsof/netstat unavailable: ${String(lsofErr)} / ${String(netstatErr)}`,
175
+ error: `lsof/ss/netstat unavailable: ${String(lsofErr)} / ${String(netstatErr)}`,
158
176
  };
159
177
  }
160
178
  }
@@ -61,6 +61,18 @@ export function sessionIdHeaderFromRequest(headers: Headers): string | null {
61
61
  return headers.get("session_id") ?? headers.get("session-id");
62
62
  }
63
63
 
64
+ /**
65
+ * Codex thread identity from inbound Responses headers when the parent-thread header is absent.
66
+ * Priority: x-codex-parent-thread-id > thread-id > session_id / session-id.
67
+ */
68
+ export function inboundClientThreadIdFromRequest(headers: Headers): string | undefined {
69
+ return firstSanitizedConversationId(
70
+ headers.get("x-codex-parent-thread-id"),
71
+ headers.get("thread-id"),
72
+ sessionIdHeaderFromRequest(headers),
73
+ );
74
+ }
75
+
64
76
  function firstSanitizedConversationId(
65
77
  ...values: Array<string | null | undefined>
66
78
  ): string | undefined {
@@ -1200,6 +1200,7 @@ export function finishRequestAttempt(
1200
1200
  status: number,
1201
1201
  durationMs: number,
1202
1202
  usage?: OcxUsage,
1203
+ upstreamError?: string,
1203
1204
  ): PersistedUsageAttempt {
1204
1205
  const finalized = finalizedUsage(
1205
1206
  attempt.adapter,
@@ -1214,7 +1215,7 @@ export function finishRequestAttempt(
1214
1215
  else delete attempt.usage;
1215
1216
  if (finalized.totalTokens !== undefined) attempt.totalTokens = finalized.totalTokens;
1216
1217
  else delete attempt.totalTokens;
1217
- const errorCode = requestLogErrorCode(status);
1218
+ const errorCode = requestLogErrorCode(status, upstreamError);
1218
1219
  if (errorCode) attempt.errorCode = errorCode;
1219
1220
  else delete attempt.errorCode;
1220
1221
  return attempt;
@@ -249,6 +249,7 @@ import {
249
249
  } from "../request-log";
250
250
  import {
251
251
  conversationIdFromResponsesRequest,
252
+ inboundClientThreadIdFromRequest,
252
253
  normalizeLogConversationId,
253
254
  reasoningReplayConversationIdFromResponsesRequest,
254
255
  sessionIdHeaderFromRequest,
@@ -1803,7 +1804,7 @@ export async function handleComboResponses(
1803
1804
  // Expand previous_response_id before image policy and child dispatch so a
1804
1805
  // continuation that only references prior images still fails closed when
1805
1806
  // imageInput is disabled (and so targets see the full replayed input).
1806
- const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
1807
+ const inboundClientThreadId = inboundClientThreadIdFromRequest(req.headers);
1807
1808
  const body = expandPreviousResponseInput(rawBody, inboundClientThreadId);
1808
1809
  const scopeMismatch = previousResponseScopeMismatch(body);
1809
1810
  if (scopeMismatch) {
@@ -2254,7 +2255,7 @@ async function handleResponsesInner(
2254
2255
  let unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
2255
2256
  (body as { input?: unknown } | undefined)?.input,
2256
2257
  );
2257
- const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
2258
+ const inboundClientThreadId = inboundClientThreadIdFromRequest(req.headers);
2258
2259
  const originalBody = body;
2259
2260
  if (options.comboReplaySnapshot) {
2260
2261
  copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body);
@@ -4519,7 +4520,7 @@ async function handleResponsesInner(
4519
4520
  }
4520
4521
  },
4521
4522
  recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
4522
- connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
4523
+ connectTimeoutMs: config.connectTimeoutMs ?? Math.max(200_000, wsPlan.routedModelStallTimeoutMs),
4523
4524
  routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
4524
4525
  stallTimeoutSec: wsPlan.stallTimeoutSec,
4525
4526
  streamRoutedModelOutput: wsPlan.streamRoutedModelOutput,
@@ -89,7 +89,7 @@ function finishFailedPolicyAttempt(logCtx: RequestLogContext, status: number): v
89
89
  const attempt = logCtx.activeAttempt;
90
90
  if (attempt) {
91
91
  const startedAt = logCtx.activeAttemptStartedAt ?? Date.now();
92
- finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage);
92
+ finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage, logCtx.upstreamError);
93
93
  }
94
94
  delete logCtx.activeAttempt;
95
95
  delete logCtx.activeAttemptStartedAt;
@@ -35,7 +35,8 @@ export interface WsData {
35
35
  cancel?: () => void; // cancels the in-flight stream reader/fetch
36
36
  turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns
37
37
  /** Discriminator: Responses reframing vs transparent live/realtime sideband relay. */
38
- kind?: "responses" | "live-sideband";
38
+ kind?: "responses" | "live-sideband" | "aistudio-relay";
39
+ aistudioSessionId?: string;
39
40
  liveUpstream?: WebSocket;
40
41
  liveUpstreamUrl?: string;
41
42
  liveUpstreamHeaders?: Record<string, string>;