@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.
- package/gui/dist/assets/index-CIDo4y4k.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +3 -1
- package/src/adapters/command-code.ts +101 -20
- package/src/adapters/cursor/envelope-echo.ts +162 -0
- package/src/adapters/cursor/live-transport.ts +3 -1
- package/src/adapters/cursor/native-exec-fs.ts +13 -12
- package/src/adapters/cursor/native-exec-network.ts +3 -5
- package/src/adapters/cursor/native-exec-policy.ts +47 -0
- package/src/adapters/cursor/native-exec-shell.ts +13 -25
- package/src/adapters/cursor/native-exec.ts +18 -10
- package/src/adapters/cursor/protobuf-events.ts +28 -2
- package/src/adapters/cursor/protobuf-request.ts +20 -3
- package/src/adapters/cursor/request-builder.ts +7 -0
- package/src/adapters/cursor/tool-definitions.ts +22 -1
- package/src/adapters/cursor/tool-result-normalize.ts +21 -8
- package/src/adapters/cursor/types.ts +7 -0
- package/src/adapters/cursor.ts +114 -0
- package/src/adapters/google-aistudio-parser.ts +49 -0
- package/src/adapters/google.ts +108 -16
- package/src/adapters/openai-responses.ts +1 -0
- package/src/chat/inbound.ts +15 -0
- package/src/cli/index.ts +1 -1
- package/src/codex/catalog/provider-fetch.ts +34 -0
- package/src/generated/compatibility-version.json +91 -47
- package/src/generated/model-metadata.ts +3 -0
- package/src/oauth/aistudio-native-daemon.ts +62 -0
- package/src/oauth/aistudio-session-sync.ts +95 -0
- package/src/oauth/google-aistudio-auth.ts +98 -0
- package/src/oauth/key-providers.ts +8 -0
- package/src/oauth/login-cli.ts +66 -1
- package/src/providers/derive.ts +1 -1
- package/src/providers/quota.ts +90 -38
- package/src/providers/registry.ts +24 -3
- package/src/router.ts +3 -0
- package/src/routing/account-pool/cooldown.ts +8 -0
- package/src/routing/account-pool/index.ts +1 -0
- package/src/server/aistudio-ws-hub.ts +295 -0
- package/src/server/auth-cors.ts +1 -0
- package/src/server/chat-completions.ts +2 -0
- package/src/server/index.ts +94 -0
- package/src/server/management/logs-usage-routes.ts +11 -5
- package/src/server/management/oauth-account-routes.ts +13 -3
- package/src/server/port-reclaim.ts +19 -1
- package/src/server/request-log-conversation.ts +12 -0
- package/src/server/request-log.ts +2 -1
- package/src/server/responses/core.ts +4 -3
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/server/ws-bridge.ts +2 -1
- package/src/smoke/fingerprint-cache.ts +133 -0
- package/src/smoke/live-scenarios.ts +33 -0
- package/src/smoke/runner.ts +119 -0
- package/src/types/provider.ts +2 -1
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +31 -7
- package/src/usage/command-code-manifest.ts +116 -0
- package/src/usage/cost.ts +2 -2
- package/src/usage/expected-prices.ts +83 -0
- package/src/usage/log.ts +2 -2
- package/src/usage/summary.ts +34 -12
- package/src/web-search/index.ts +16 -8
- 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
|
+
}
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -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
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
|
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
|
|
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;
|
package/src/server/ws-bridge.ts
CHANGED
|
@@ -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>;
|