@xkei/openclaude 0.30.0-antigravity

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 (37) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +518 -0
  3. package/bin/import-specifier.mjs +13 -0
  4. package/bin/import-specifier.test.mjs +13 -0
  5. package/bin/node-compile-cache.mjs +17 -0
  6. package/bin/openclaude +126 -0
  7. package/dist/cli.mjs +11292 -0
  8. package/dist/sdk.mjs +284293 -0
  9. package/docs/antigravity-plugin-install.md +223 -0
  10. package/docs/windows-aliases-and-launchers.md +162 -0
  11. package/package.json +226 -0
  12. package/scripts/windows/openclaude-aliases.ps1 +206 -0
  13. package/src/entrypoints/sdk/coreTypes.generated.ts +2385 -0
  14. package/src/entrypoints/sdk.d.ts +601 -0
  15. package/vendor/node-domexception-shim/index.js +3 -0
  16. package/vendor/node-domexception-shim/package.json +8 -0
  17. package/vendor/openclaude-antigravity-provider/.claude-plugin/marketplace.json +17 -0
  18. package/vendor/openclaude-antigravity-provider/.claude-plugin/plugin.json +38 -0
  19. package/vendor/openclaude-antigravity-provider/bin/antigravity-proxy.exe +0 -0
  20. package/vendor/openclaude-antigravity-provider/hooks/SessionEnd.ps1 +22 -0
  21. package/vendor/openclaude-antigravity-provider/hooks/SessionStart.ps1 +111 -0
  22. package/vendor/openclaude-antigravity-provider/hooks/Watchdog-Stop.ps1 +82 -0
  23. package/vendor/openclaude-antigravity-provider/hooks/hooks.json +37 -0
  24. package/vendor/openclaude-antigravity-provider/hooks/inject-provider.js +110 -0
  25. package/vendor/openclaude-antigravity-provider/hooks/session-end.bat +22 -0
  26. package/vendor/openclaude-antigravity-provider/hooks/start.bat +7 -0
  27. package/vendor/openclaude-antigravity-provider/package.json +24 -0
  28. package/vendor/openclaude-antigravity-provider/src/accounts.ts +115 -0
  29. package/vendor/openclaude-antigravity-provider/src/auth-cli.ts +125 -0
  30. package/vendor/openclaude-antigravity-provider/src/auth.ts +221 -0
  31. package/vendor/openclaude-antigravity-provider/src/config.ts +68 -0
  32. package/vendor/openclaude-antigravity-provider/src/constants.ts +139 -0
  33. package/vendor/openclaude-antigravity-provider/src/gemini-fallback.ts +157 -0
  34. package/vendor/openclaude-antigravity-provider/src/server.ts +367 -0
  35. package/vendor/openclaude-antigravity-provider/src/storage.ts +56 -0
  36. package/vendor/openclaude-antigravity-provider/src/transform.ts +241 -0
  37. package/vendor/openclaude-antigravity-provider/tsconfig.json +15 -0
@@ -0,0 +1,367 @@
1
+ /**
2
+ * server.ts
3
+ * OpenAI-compatible local proxy using Bun.serve().
4
+ * Endpoint: http://localhost:51122/v1/chat/completions
5
+ *
6
+ * Run: bun run src/server.ts
7
+ */
8
+
9
+ import {
10
+ PROXY_PORT,
11
+ AVAILABLE_MODELS,
12
+ getAntigravityUserAgent,
13
+ ACCOUNTS_FILE,
14
+ isClaudeModel,
15
+ } from "./constants.ts";
16
+ import {
17
+ getAvailableAccount,
18
+ getValidAccessToken,
19
+ markAccountRateLimited,
20
+ markAccountUsed,
21
+ getAccountCount,
22
+ } from "./accounts.ts";
23
+ import {
24
+ translateToGemini,
25
+ translateGeminiChunkToOpenAI,
26
+ translateGeminiResponseToOpenAI,
27
+ type OpenAIChatRequest,
28
+ } from "./transform.ts";
29
+ import { getGeminiCliProfile } from "./config.ts";
30
+ import { handleGeminiCliFallback } from "./gemini-fallback.ts";
31
+
32
+ const MAX_RETRIES = 3;
33
+
34
+ // Stable session id for the Antigravity agent request wrapper (reused across
35
+ // requests for the lifetime of the proxy process, mirroring the IDE behavior).
36
+ const PROXY_SESSION_ID = crypto.randomUUID();
37
+
38
+ // ── Helpers ───────────────────────────────────────────────────────────────────
39
+
40
+ function jsonResponse(body: unknown, status = 200): Response {
41
+ return new Response(JSON.stringify(body), {
42
+ status,
43
+ headers: {
44
+ "Content-Type": "application/json",
45
+ "Access-Control-Allow-Origin": "*",
46
+ },
47
+ });
48
+ }
49
+
50
+ function errorResponse(message: string, type: string, status: number): Response {
51
+ return jsonResponse({ error: { message, type } }, status);
52
+ }
53
+
54
+ function parseRetryAfterMs(
55
+ headers: Headers,
56
+ defaultMs = 60_000,
57
+ ): number {
58
+ const ms = headers.get("retry-after-ms");
59
+ if (ms) {
60
+ const v = parseInt(ms, 10);
61
+ if (!isNaN(v) && v > 0) return v;
62
+ }
63
+ const sec = headers.get("retry-after");
64
+ if (sec) {
65
+ const v = parseInt(sec, 10);
66
+ if (!isNaN(v) && v > 0) return v * 1000;
67
+ }
68
+ return defaultMs;
69
+ }
70
+
71
+ function randomId(): string {
72
+ const bytes = new Uint8Array(8);
73
+ crypto.getRandomValues(bytes);
74
+ return Array.from(bytes)
75
+ .map((b) => b.toString(16).padStart(2, "0"))
76
+ .join("");
77
+ }
78
+
79
+ // ── /v1/models ────────────────────────────────────────────────────────────────
80
+
81
+ function handleModels(): Response {
82
+ const now = Math.floor(Date.now() / 1000);
83
+ return jsonResponse({
84
+ object: "list",
85
+ data: AVAILABLE_MODELS.map((id) => ({
86
+ id,
87
+ object: "model",
88
+ created: now,
89
+ owned_by: "google-antigravity",
90
+ })),
91
+ });
92
+ }
93
+
94
+ // ── /health ───────────────────────────────────────────────────────────────────
95
+
96
+ async function handleHealth(): Promise<Response> {
97
+ const geminiProfile = await getGeminiCliProfile();
98
+ return jsonResponse({
99
+ status: "ok",
100
+ accounts: await getAccountCount(),
101
+ geminiCliFallback: geminiProfile !== null,
102
+ });
103
+ }
104
+
105
+ // ── /v1/chat/completions ─────────────────────────────────────────────────────
106
+
107
+ async function handleChatCompletions(req: Request): Promise<Response> {
108
+ // Parse request body
109
+ let body: OpenAIChatRequest;
110
+ try {
111
+ body = (await req.json()) as OpenAIChatRequest;
112
+ } catch {
113
+ return errorResponse("Invalid JSON body", "invalid_request_error", 400);
114
+ }
115
+
116
+ const count = await getAccountCount();
117
+ if (count === 0) {
118
+ return errorResponse(
119
+ "No Antigravity accounts configured. Run: bun run src/auth-cli.ts",
120
+ "authentication_error",
121
+ 401,
122
+ );
123
+ }
124
+
125
+ const isStream = body.stream !== false;
126
+ const chunkId = `chatcmpl-${randomId()}`;
127
+
128
+ const { body: geminiBody, endpoints } = translateToGemini(body, PROXY_SESSION_ID);
129
+
130
+ let lastError = "";
131
+
132
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
133
+ // Select account
134
+ let accountInfo: Awaited<ReturnType<typeof getAvailableAccount>>;
135
+ try {
136
+ accountInfo = await getAvailableAccount();
137
+ } catch (err: unknown) {
138
+ // All Antigravity accounts are rate-limited.
139
+ // For Gemini models: transparently fall over to the Gemini-CLI provider.
140
+ // For Claude models: return 429 so OpenClaude can handle it normally.
141
+ if (!isClaudeModel(body.model)) {
142
+ const geminiProfile = await getGeminiCliProfile();
143
+ if (geminiProfile) {
144
+ return handleGeminiCliFallback(body, geminiProfile);
145
+ }
146
+ }
147
+ return errorResponse(
148
+ String(err instanceof Error ? err.message : err),
149
+ "rate_limit_error",
150
+ 429,
151
+ );
152
+ }
153
+
154
+ if (!accountInfo) {
155
+ return errorResponse("No available accounts.", "authentication_error", 401);
156
+ }
157
+
158
+ const { account, index } = accountInfo;
159
+
160
+ // Get fresh access token
161
+ let accessToken: string;
162
+ try {
163
+ accessToken = await getValidAccessToken(account);
164
+ } catch (err: unknown) {
165
+ lastError = String(err instanceof Error ? err.message : err);
166
+ continue;
167
+ }
168
+
169
+ // Antigravity Manager-style headers: ONLY the User-Agent is sent on
170
+ // content requests — no X-Goog-Api-Client, no Client-Metadata, and no
171
+ // x-goog-user-project (the project rides in the request body). Anything
172
+ // more routes the request into the per-account free-tier quota.
173
+ const upstreamHeaders: Record<string, string> = {
174
+ "User-Agent": getAntigravityUserAgent(),
175
+ Authorization: `Bearer ${accessToken}`,
176
+ "Content-Type": "application/json",
177
+ ...(isStream ? { Accept: "text/event-stream" } : {}),
178
+ };
179
+
180
+ // Try each endpoint in order (daily → autopush → prod).
181
+ // A network-level failure advances to the next endpoint; a 429 rotates accounts.
182
+ let upstream: Response | null = null;
183
+ let endpointError = "";
184
+ for (const endpoint of endpoints) {
185
+ try {
186
+ upstream = await fetch(endpoint, {
187
+ method: "POST",
188
+ headers: upstreamHeaders,
189
+ body: JSON.stringify(geminiBody),
190
+ });
191
+ // If the endpoint itself is down (5xx gateway), try the next one
192
+ if (upstream.status >= 502 && upstream.status <= 504) {
193
+ endpointError = `Endpoint ${endpoint} returned ${upstream.status}`;
194
+ upstream = null;
195
+ continue;
196
+ }
197
+ break; // got a real response (success or app-level error)
198
+ } catch (err: unknown) {
199
+ endpointError = `Network error on ${endpoint}: ${String(err instanceof Error ? err.message : err)}`;
200
+ upstream = null;
201
+ }
202
+ }
203
+
204
+ if (!upstream) {
205
+ lastError = endpointError;
206
+ continue;
207
+ }
208
+
209
+ // Handle rate limit — rotate to next account
210
+ if (upstream.status === 429) {
211
+ const retryAfterMs = parseRetryAfterMs(upstream.headers);
212
+ await markAccountRateLimited(index, retryAfterMs);
213
+ lastError = `Account ${account.email ?? index} rate-limited for ${Math.ceil(retryAfterMs / 1000)}s`;
214
+ continue;
215
+ }
216
+
217
+ // Handle other upstream errors
218
+ if (!upstream.ok) {
219
+ const errText = await upstream.text();
220
+ await markAccountUsed(index);
221
+ return errorResponse(
222
+ `Upstream error (${upstream.status}): ${errText}`,
223
+ "upstream_error",
224
+ upstream.status,
225
+ );
226
+ }
227
+
228
+ await markAccountUsed(index);
229
+
230
+ // ── Streaming ──────────────────────────────────────────────────────────
231
+ if (isStream) {
232
+ const upstreamBody = upstream.body;
233
+ if (!upstreamBody) {
234
+ return errorResponse("Empty upstream body", "upstream_error", 502);
235
+ }
236
+
237
+ const stream = new ReadableStream({
238
+ async start(controller) {
239
+ const reader = upstreamBody.getReader();
240
+ const decoder = new TextDecoder();
241
+ let buffer = "";
242
+
243
+ try {
244
+ while (true) {
245
+ const { done, value } = await reader.read();
246
+ if (done) break;
247
+
248
+ buffer += decoder.decode(value, { stream: true });
249
+ const lines = buffer.split("\n");
250
+ buffer = lines.pop() ?? "";
251
+
252
+ for (const line of lines) {
253
+ const trimmed = line.trim();
254
+ if (!trimmed) continue;
255
+ const translated = translateGeminiChunkToOpenAI(
256
+ trimmed,
257
+ body.model,
258
+ chunkId,
259
+ );
260
+ if (translated) {
261
+ controller.enqueue(new TextEncoder().encode(translated));
262
+ }
263
+ }
264
+ }
265
+ } catch {
266
+ // Stream ended or client disconnected
267
+ }
268
+
269
+ controller.enqueue(
270
+ new TextEncoder().encode("data: [DONE]\n\n"),
271
+ );
272
+ controller.close();
273
+ },
274
+ });
275
+
276
+ return new Response(stream, {
277
+ status: 200,
278
+ headers: {
279
+ "Content-Type": "text/event-stream",
280
+ "Cache-Control": "no-cache",
281
+ Connection: "keep-alive",
282
+ "Access-Control-Allow-Origin": "*",
283
+ },
284
+ });
285
+ }
286
+
287
+ // ── Non-streaming ──────────────────────────────────────────────────────
288
+ let geminiResponseBody: unknown;
289
+ try {
290
+ geminiResponseBody = await upstream.json();
291
+ } catch {
292
+ return errorResponse(
293
+ "Upstream returned invalid JSON",
294
+ "upstream_error",
295
+ 502,
296
+ );
297
+ }
298
+
299
+ return jsonResponse(
300
+ translateGeminiResponseToOpenAI(geminiResponseBody, body.model, chunkId),
301
+ );
302
+ }
303
+
304
+ // All retries exhausted — attempt Gemini-CLI fallback for Gemini models.
305
+ if (!isClaudeModel(body.model)) {
306
+ const geminiProfile = await getGeminiCliProfile();
307
+ if (geminiProfile) {
308
+ return handleGeminiCliFallback(body, geminiProfile);
309
+ }
310
+ }
311
+
312
+ return errorResponse(
313
+ `All retries failed. Last error: ${lastError}`,
314
+ "service_unavailable",
315
+ 503,
316
+ );
317
+ }
318
+
319
+ // ── Bun.serve router ──────────────────────────────────────────────────────────
320
+
321
+ const server = Bun.serve({
322
+ port: PROXY_PORT,
323
+ hostname: "127.0.0.1",
324
+
325
+ async fetch(req) {
326
+ const url = new URL(req.url);
327
+
328
+ // CORS preflight
329
+ if (req.method === "OPTIONS") {
330
+ return new Response(null, {
331
+ status: 204,
332
+ headers: {
333
+ "Access-Control-Allow-Origin": "*",
334
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
335
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
336
+ },
337
+ });
338
+ }
339
+
340
+ if (url.pathname === "/health" || url.pathname === "/v1/health") {
341
+ return handleHealth();
342
+ }
343
+
344
+ if (url.pathname === "/v1/models" && req.method === "GET") {
345
+ return handleModels();
346
+ }
347
+
348
+ if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
349
+ return handleChatCompletions(req);
350
+ }
351
+
352
+ return errorResponse("Not found", "not_found", 404);
353
+ },
354
+
355
+ error(err) {
356
+ console.error("[antigravity-provider] Server error:", err.message);
357
+ return errorResponse("Internal server error", "internal_error", 500);
358
+ },
359
+ });
360
+
361
+ console.log(
362
+ `[antigravity-provider] Proxy running → http://${server.hostname}:${server.port}/v1`,
363
+ );
364
+ console.log(`[antigravity-provider] Accounts file → ${ACCOUNTS_FILE}`);
365
+ console.log(
366
+ `[antigravity-provider] Health check → http://localhost:${server.port}/health`,
367
+ );
@@ -0,0 +1,56 @@
1
+ /**
2
+ * storage.ts
3
+ * Reads/writes account data using Bun.file() and Bun.write().
4
+ * Storage path: ~/.openclaude/antigravity-accounts.json
5
+ */
6
+
7
+ import { mkdirSync, existsSync } from "node:fs";
8
+ import { OPENCLAUDE_CONFIG_DIR, ACCOUNTS_FILE } from "./constants.ts";
9
+
10
+ export interface StoredAccount {
11
+ email?: string;
12
+ refreshToken: string;
13
+ projectId?: string;
14
+ managedProjectId?: string;
15
+ addedAt: number;
16
+ lastUsed: number;
17
+ enabled: boolean;
18
+ rateLimitedUntil?: number;
19
+ cachedQuotaUpdatedAt?: number;
20
+ }
21
+
22
+ export interface AccountsFile {
23
+ version: number;
24
+ accounts: StoredAccount[];
25
+ activeIndex: number;
26
+ }
27
+
28
+ function ensureConfigDir(): void {
29
+ if (!existsSync(OPENCLAUDE_CONFIG_DIR)) {
30
+ mkdirSync(OPENCLAUDE_CONFIG_DIR, { recursive: true });
31
+ }
32
+ }
33
+
34
+ export async function loadAccounts(): Promise<AccountsFile | null> {
35
+ ensureConfigDir();
36
+ const file = Bun.file(ACCOUNTS_FILE);
37
+ const exists = await file.exists();
38
+ if (!exists) return null;
39
+ try {
40
+ return await file.json() as AccountsFile;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export async function saveAccounts(data: AccountsFile): Promise<void> {
47
+ ensureConfigDir();
48
+ await Bun.write(ACCOUNTS_FILE, JSON.stringify(data, null, 2));
49
+ }
50
+
51
+ export async function clearAccounts(): Promise<void> {
52
+ const file = Bun.file(ACCOUNTS_FILE);
53
+ if (await file.exists()) {
54
+ await Bun.write(ACCOUNTS_FILE, "");
55
+ }
56
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * transform.ts
3
+ * Translates OpenAI Chat Completions requests → Gemini GenerateContent format.
4
+ * Translates Gemini SSE chunks → OpenAI SSE chunks.
5
+ */
6
+
7
+ import {
8
+ resolveAntigravityModel,
9
+ generateSyntheticProjectId,
10
+ ANTIGRAVITY_ENDPOINT_FALLBACKS,
11
+ } from "./constants.ts";
12
+
13
+ // ── OpenAI input types ────────────────────────────────────────────────────────
14
+
15
+ export interface OpenAIMessage {
16
+ role: "system" | "user" | "assistant";
17
+ content: string | OpenAIContentPart[];
18
+ }
19
+
20
+ export interface OpenAIContentPart {
21
+ type: "text" | "image_url";
22
+ text?: string;
23
+ image_url?: { url: string };
24
+ }
25
+
26
+ export interface OpenAIChatRequest {
27
+ model: string;
28
+ messages: OpenAIMessage[];
29
+ stream?: boolean;
30
+ temperature?: number;
31
+ max_tokens?: number;
32
+ top_p?: number;
33
+ }
34
+
35
+ // ── Gemini internal types ─────────────────────────────────────────────────────
36
+
37
+ interface GeminiPart {
38
+ text?: string;
39
+ }
40
+
41
+ interface GeminiContent {
42
+ role: "user" | "model";
43
+ parts: GeminiPart[];
44
+ }
45
+
46
+ interface GeminiRequest {
47
+ project: string;
48
+ model: string;
49
+ requestType: "agent";
50
+ userAgent: "antigravity";
51
+ requestId: string;
52
+ request: {
53
+ contents: GeminiContent[];
54
+ sessionId: string;
55
+ systemInstruction?: { role: "user"; parts: GeminiPart[] };
56
+ generationConfig?: {
57
+ temperature?: number;
58
+ maxOutputTokens?: number;
59
+ topP?: number;
60
+ };
61
+ };
62
+ }
63
+
64
+ // ── Helpers ───────────────────────────────────────────────────────────────────
65
+
66
+ function contentToText(content: OpenAIMessage["content"]): string {
67
+ if (typeof content === "string") return content;
68
+ return content
69
+ .filter((p) => p.type === "text")
70
+ .map((p) => p.text ?? "")
71
+ .join("\n");
72
+ }
73
+
74
+ function toGeminiRole(role: OpenAIMessage["role"]): "user" | "model" {
75
+ return role === "assistant" ? "model" : "user";
76
+ }
77
+
78
+ // ── Main translation: OpenAI → Gemini ────────────────────────────────────────
79
+
80
+ export interface TranslatedRequest {
81
+ body: GeminiRequest;
82
+ /** Ordered list of endpoints to try (daily → autopush → prod). */
83
+ endpoints: readonly string[];
84
+ }
85
+
86
+ export function translateToGemini(
87
+ req: OpenAIChatRequest,
88
+ sessionId: string,
89
+ ): TranslatedRequest {
90
+ // Daily sandbox serves bare names with tier/thinking suffixes (see
91
+ // ANTIGRAVITY_MODEL_MAP notes) — NOT the -preview generativelanguage names.
92
+ const geminiModel = resolveAntigravityModel(req.model);
93
+
94
+ // Pull out system message
95
+ let systemInstruction: { role: "user"; parts: GeminiPart[] } | undefined;
96
+ const turns = req.messages.filter((m) => {
97
+ if (m.role === "system") {
98
+ systemInstruction = { role: "user", parts: [{ text: contentToText(m.content) }] };
99
+ return false;
100
+ }
101
+ return true;
102
+ });
103
+
104
+ // Build contents, merging consecutive same-role turns (Gemini requires alternating)
105
+ const contents: GeminiContent[] = [];
106
+ for (const msg of turns) {
107
+ const role = toGeminiRole(msg.role);
108
+ const text = contentToText(msg.content);
109
+ const last = contents[contents.length - 1];
110
+ if (last && last.role === role) {
111
+ last.parts.push({ text });
112
+ } else {
113
+ contents.push({ role, parts: [{ text }] });
114
+ }
115
+ }
116
+
117
+ // Gemini requires the first turn to be "user"
118
+ if (contents.length === 0 || contents[0]?.role !== "user") {
119
+ contents.unshift({ role: "user", parts: [{ text: "" }] });
120
+ }
121
+
122
+ // Antigravity Manager-style wrapped body. The synthetic project +
123
+ // requestType "agent" route the request into the Antigravity agent quota
124
+ // pool on the daily sandbox (unlimited), instead of the per-account
125
+ // free tier that the prod endpoint enforces.
126
+ const body: GeminiRequest = {
127
+ project: generateSyntheticProjectId(),
128
+ model: geminiModel,
129
+ requestType: "agent",
130
+ userAgent: "antigravity",
131
+ requestId: `agent-${crypto.randomUUID()}`,
132
+ request: {
133
+ contents,
134
+ sessionId,
135
+ ...(systemInstruction ? { systemInstruction } : {}),
136
+ generationConfig: {
137
+ ...(req.temperature !== undefined ? { temperature: req.temperature } : {}),
138
+ ...(req.max_tokens !== undefined ? { maxOutputTokens: req.max_tokens } : {}),
139
+ ...(req.top_p !== undefined ? { topP: req.top_p } : {}),
140
+ },
141
+ },
142
+ };
143
+
144
+ const endpoints = ANTIGRAVITY_ENDPOINT_FALLBACKS.map(
145
+ (base) => `${base}/v1internal:streamGenerateContent?alt=sse`,
146
+ );
147
+ return { body, endpoints };
148
+ }
149
+
150
+ // ── Gemini SSE chunk → OpenAI SSE chunk ──────────────────────────────────────
151
+
152
+ interface GeminiCandidate {
153
+ content?: { parts?: Array<{ text?: string }> };
154
+ finishReason?: string;
155
+ }
156
+
157
+ interface GeminiChunk {
158
+ response?: { candidates?: GeminiCandidate[] };
159
+ candidates?: GeminiCandidate[];
160
+ }
161
+
162
+ export function translateGeminiChunkToOpenAI(
163
+ rawLine: string,
164
+ model: string,
165
+ chunkId: string,
166
+ ): string | null {
167
+ if (!rawLine.startsWith("data:")) return null;
168
+ const payload = rawLine.slice(5).trim();
169
+ if (!payload || payload === "[DONE]") return null;
170
+
171
+ let parsed: GeminiChunk;
172
+ try {
173
+ parsed = JSON.parse(payload) as GeminiChunk;
174
+ } catch {
175
+ return null;
176
+ }
177
+
178
+ // Daily sandbox SSE chunks nest the payload under "response";
179
+ // some endpoints emit it flat — support both.
180
+ const candidates = parsed.response?.candidates ?? parsed.candidates;
181
+ if (!candidates || candidates.length === 0) return null;
182
+
183
+ const candidate = candidates[0]!;
184
+ const text =
185
+ candidate.content?.parts?.map((p) => p.text ?? "").join("") ?? "";
186
+ const finishReason =
187
+ candidate.finishReason === "STOP"
188
+ ? "stop"
189
+ : candidate.finishReason === "MAX_TOKENS"
190
+ ? "length"
191
+ : null;
192
+
193
+ const chunk = {
194
+ id: chunkId,
195
+ object: "chat.completion.chunk",
196
+ created: Math.floor(Date.now() / 1000),
197
+ model,
198
+ choices: [
199
+ {
200
+ index: 0,
201
+ delta: text ? { role: "assistant", content: text } : {},
202
+ finish_reason: finishReason,
203
+ },
204
+ ],
205
+ };
206
+
207
+ return `data: ${JSON.stringify(chunk)}\n\n`;
208
+ }
209
+
210
+ // ── Full non-streaming response translation ───────────────────────────────────
211
+
212
+ export function translateGeminiResponseToOpenAI(
213
+ geminiBody: unknown,
214
+ model: string,
215
+ chunkId: string,
216
+ ): Record<string, unknown> {
217
+ // Unwrap the "response" envelope when present (daily sandbox shape)
218
+ const unwrapped = (
219
+ (geminiBody as { response?: unknown }).response ?? geminiBody
220
+ ) as { candidates?: GeminiCandidate[] };
221
+ const candidates = unwrapped.candidates ?? [];
222
+ const text =
223
+ candidates[0]?.content?.parts?.map((p) => p.text ?? "").join("") ?? "";
224
+ const finishReason =
225
+ candidates[0]?.finishReason === "STOP" ? "stop" : "length";
226
+
227
+ return {
228
+ id: chunkId,
229
+ object: "chat.completion",
230
+ created: Math.floor(Date.now() / 1000),
231
+ model,
232
+ choices: [
233
+ {
234
+ index: 0,
235
+ message: { role: "assistant", content: text },
236
+ finish_reason: finishReason,
237
+ },
238
+ ],
239
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
240
+ };
241
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ESNext"],
7
+ "types": ["bun-types"],
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "resolveJsonModule": true,
11
+ "allowImportingTsExtensions": true,
12
+ "noEmit": true
13
+ },
14
+ "include": ["src/**/*"]
15
+ }