pi-antigravity-rotator 2.1.6 → 2.2.2

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/src/compat.ts CHANGED
@@ -1,2410 +1,1590 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { Readable } from "node:stream";
3
3
  import { PayloadTooLargeError, readLimitedBody } from "./body-limit.js";
4
- import { logger } from "./logger.js";
4
+ import { logger, redactSensitive } from "./logger.js";
5
5
  import type { AccountRotator } from "./rotator.js";
6
- import { resolveQuotaModelKey } from "./types.js";
7
6
  import { withRotation, flattenHeaders, type RequestBody } from "./proxy.js";
8
-
9
-
10
- const compatLogger = logger.child("compat");
11
-
12
- export interface ChatMessage {
13
- role: "system" | "developer" | "user" | "assistant" | "model" | "tool";
14
- content: string | Array<{ type: string; text?: string;[key: string]: unknown }> | null;
15
- tool_calls?: OpenAIToolCall[];
16
- tool_call_id?: string;
17
- name?: string;
18
- }
19
-
20
- export interface OpenAITool {
21
- type: "function";
22
- function: {
23
- name: string;
24
- description?: string;
25
- parameters?: Record<string, unknown>;
26
- };
27
- }
28
-
29
- export interface OpenAIToolCall {
30
- id: string;
31
- type: "function";
32
- function: {
33
- name: string;
34
- arguments: string;
35
- };
36
- }
37
-
38
- export interface OpenAIToolChoice {
39
- type: "function";
40
- function: { name: string };
41
- }
42
-
43
- // Gemini function calling types
44
- interface GeminiFunctionDeclaration {
45
- name: string;
46
- description?: string;
47
- parameters?: Record<string, unknown>;
48
- }
49
-
50
- interface GeminiToolConfig {
51
- functionCallingConfig: {
52
- mode: "AUTO" | "NONE" | "ANY";
53
- allowedFunctionNames?: string[];
54
- };
55
- }
56
-
57
- export interface OpenAIChatCompletionRequest {
58
- model: string;
59
- messages: ChatMessage[];
60
- stream?: boolean;
61
- temperature?: number;
62
- max_tokens?: number;
63
- prompt?: string | string[];
64
- input?: unknown;
65
- tools?: OpenAITool[];
66
- tool_choice?: unknown;
67
- /** OpenAI-style reasoning effort. Mapped to Gemini thinkingLevel. */
68
- reasoning_effort?: string;
69
- [key: string]: unknown;
70
- }
71
-
72
- export interface OpenAIResponsesRequest {
73
- model: string;
74
- input?: unknown;
75
- instructions?: string | Array<{ type: string; text?: string;[key: string]: unknown }> | null;
76
- stream?: boolean;
77
- temperature?: number;
78
- max_output_tokens?: number;
79
- tools?: Array<Record<string, unknown>>;
80
- tool_choice?: unknown;
81
- reasoning?: { effort?: string | null;[key: string]: unknown } | null;
82
- metadata?: Record<string, string>;
83
- store?: boolean;
84
- previous_response_id?: string | null;
85
- conversation?: unknown;
86
- parallel_tool_calls?: boolean;
87
- [key: string]: unknown;
88
- }
89
-
90
- export interface AnthropicMessagesRequest {
91
- model: string;
92
- messages: ChatMessage[];
93
- system?: string | Array<{ type: string; text?: string;[key: string]: unknown }>;
94
- stream?: boolean;
95
- max_tokens?: number;
96
- temperature?: number;
97
- [key: string]: unknown;
98
- }
99
-
100
- export interface CompatCompletion {
101
- text: string;
102
- thinkingText?: string; // Gemini thought blocks (thought: true), emitted as reasoning_content
103
- inputTokens: number;
104
- outputTokens: number;
105
- responseId?: string;
106
- toolCalls?: OpenAIToolCall[];
107
- }
108
-
109
- interface ResponseOutputText {
110
- type: "output_text";
111
- text: string;
112
- annotations: unknown[];
113
- }
114
-
115
- interface ResponseMessageOutputItem {
116
- id: string;
117
- type: "message";
118
- status: "completed";
119
- role: "assistant";
120
- content: ResponseOutputText[];
121
- }
122
-
123
- interface ResponseFunctionCallOutputItem {
124
- id: string;
125
- type: "function_call";
126
- call_id: string;
127
- name: string;
128
- arguments: string;
129
- status: "completed";
130
- }
131
-
132
- type ResponseOutputItem = ResponseMessageOutputItem | ResponseFunctionCallOutputItem;
133
-
134
- interface StoredResponseEntry {
135
- response: Record<string, unknown>;
136
- inputItems: Array<Record<string, unknown>>;
137
- conversationMessages: ChatMessage[];
138
- callIdToName: Map<string, string>;
139
- expiresAt: number;
140
- }
141
-
142
- // ---------------------------------------------------------------------------
143
- // Model-specific specs — mirrors Antigravity-Manager model_specs.json
144
- // ---------------------------------------------------------------------------
145
- interface ModelSpec {
146
- maxOutputTokens: number;
147
- thinkingBudget: number; // -1 = adaptive (model decides), >=0 = fixed
148
- isThinking: boolean;
149
- }
150
- const MODEL_SPECS: Record<string, ModelSpec> = {
151
- "gemini-pro-agent": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
152
- "gemini-3-flash-agent": { maxOutputTokens: 65536, thinkingBudget: 10000, isThinking: true },
153
- "gemini-3-pro-high": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
154
- "gemini-3-pro-low": { maxOutputTokens: 65535, thinkingBudget: 1001, isThinking: true },
155
- "gemini-3.1-pro": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
156
- "gemini-3.1-pro-high": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
157
- "gemini-3.1-pro-low": { maxOutputTokens: 65535, thinkingBudget: 1001, isThinking: true },
158
- "gemini-3.1-pro-preview": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
159
- "gemini-3.5-flash": { maxOutputTokens: 65536, thinkingBudget: 10000, isThinking: true },
160
- "gemini-3.5-flash-medium": { maxOutputTokens: 65536, thinkingBudget: 4000, isThinking: true },
161
- "gemini-3.5-flash-low": { maxOutputTokens: 65536, thinkingBudget: 4000, isThinking: true },
162
- "gemini-3.5-flash-high": { maxOutputTokens: 65536, thinkingBudget: 10000, isThinking: true },
163
- "gemini-3-flash": { maxOutputTokens: 65536, thinkingBudget: 4000, isThinking: true },
164
- "gemini-2.5-flash": { maxOutputTokens: 65535, thinkingBudget: 24576, isThinking: true },
165
- "gemini-2.5-pro": { maxOutputTokens: 65535, thinkingBudget: 1024, isThinking: true },
166
- "claude-sonnet-4-6": { maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
167
- "claude-sonnet-4-6-thinking":{ maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
168
- "claude-opus-4-6-thinking": { maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
169
- "gpt-oss-120b-medium": { maxOutputTokens: 32768, thinkingBudget: 8192, isThinking: true },
170
- "gpt-oss-120b": { maxOutputTokens: 32768, thinkingBudget: 8192, isThinking: true },
7
+ import {
8
+ isRecord,
9
+ sanitizeGeminiSchema,
10
+ sanitizeClaudeViaGeminiSchema,
11
+ } from "./compat/schema-sanitizer.js";
12
+ import {
13
+ DEFAULT_MODEL_SPECS,
14
+ setModelSpecsOverride,
15
+ getActiveModelSpecs,
16
+ getModelFamily,
17
+ getModelSpec,
18
+ isThinkingModel,
19
+ } from "./compat/model-specs.js";
20
+ import type { ModelSpec } from "./compat/model-specs.js";
21
+ import {
22
+ responsesStore,
23
+ makeCompatId,
24
+ getStoredResponse,
25
+ setStoredResponse,
26
+ resetResponsesStoreForTests,
27
+ loadResponsesStore,
28
+ flushResponsesStore,
29
+ flushResponsesStoreSync,
30
+ cacheThoughtSignature,
31
+ } from "./compat/cache.js";
32
+ import {
33
+ normalizeOpenAIChatCompletionRequest,
34
+ normalizeOpenAIResponsesRequest,
35
+ normalizeAnthropicMessagesRequest,
36
+ convertResponsesToChatRequest,
37
+ openAIToAntigravityBody,
38
+ anthropicToAntigravityBody,
39
+ validateOpenAIChatCompletionRequest,
40
+ validateOpenAIResponsesRequest,
41
+ validateAnthropicMessagesRequest,
42
+ buildResponsesResponse,
43
+ saveResponsesEntry,
44
+ } from "./compat/translators.js";
45
+ import type {
46
+ ChatMessage,
47
+ OpenAITool,
48
+ OpenAIToolCall,
49
+ OpenAIToolChoice,
50
+ OpenAIChatCompletionRequest,
51
+ OpenAIResponsesRequest,
52
+ AnthropicMessagesRequest,
53
+ CompatCompletion,
54
+ ResponsesConversionResult,
55
+ } from "./compat/translators.js";
56
+
57
+ export {
58
+ isRecord,
59
+ sanitizeGeminiSchema,
60
+ sanitizeClaudeViaGeminiSchema,
61
+ DEFAULT_MODEL_SPECS,
62
+ setModelSpecsOverride,
63
+ getActiveModelSpecs,
64
+ getModelFamily,
65
+ getModelSpec,
66
+ isThinkingModel,
67
+ resetResponsesStoreForTests,
68
+ loadResponsesStore,
69
+ flushResponsesStore,
70
+ flushResponsesStoreSync,
71
+ normalizeOpenAIChatCompletionRequest,
72
+ normalizeOpenAIResponsesRequest,
73
+ normalizeAnthropicMessagesRequest,
74
+ openAIToAntigravityBody,
75
+ anthropicToAntigravityBody,
76
+ validateOpenAIChatCompletionRequest,
77
+ validateOpenAIResponsesRequest,
78
+ validateAnthropicMessagesRequest,
171
79
  };
172
- const GEMINI_MAX_OUTPUT_TOKENS = 65536;
173
- const CLAUDE_MAX_OUTPUT_TOKENS = 64000;
174
- const FALLBACK_THINKING_BUDGET = 24576;
175
- const CLAUDE_DEFAULT_THINKING_BUDGET = 32768;
176
-
177
- function getModelFamily(model: string): "claude" | "gemini" | "unknown" {
178
- const l = model.toLowerCase();
179
- if (l.includes("claude")) return "claude";
180
- if (l.includes("gemini")) return "gemini";
181
- return "unknown";
182
- }
183
-
184
- function getModelSpec(model: string): ModelSpec {
185
- const lower = model.toLowerCase();
186
- if (MODEL_SPECS[lower]) return MODEL_SPECS[lower];
187
- for (const [key, spec] of Object.entries(MODEL_SPECS)) {
188
- if (lower.includes(key)) return spec;
189
- }
190
- const family = getModelFamily(model);
191
- if (family === "claude") return { maxOutputTokens: CLAUDE_MAX_OUTPUT_TOKENS, thinkingBudget: CLAUDE_DEFAULT_THINKING_BUDGET, isThinking: true };
192
- if (family === "gemini") return { maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS, thinkingBudget: FALLBACK_THINKING_BUDGET, isThinking: true };
193
- return { maxOutputTokens: 65536, thinkingBudget: FALLBACK_THINKING_BUDGET, isThinking: false };
194
- }
195
-
196
- function isThinkingModel(model: string): boolean {
197
- const spec = getModelSpec(model);
198
- if (spec.isThinking) return true;
199
- const l = model.toLowerCase();
200
- if (l.includes("gemini")) {
201
- const m = l.match(/gemini-(\d+)/);
202
- if (m && parseInt(m[1], 10) >= 3) return true;
203
- }
204
- return false;
205
- }
206
-
207
- type AntigravityPart = { text: string } | { inlineData: { mimeType: string; data: string } };
208
-
209
- function isRecord(value: unknown): value is Record<string, unknown> {
210
- return typeof value === "object" && value !== null && !Array.isArray(value);
211
- }
212
-
213
- function isNonEmptyString(value: unknown): value is string {
214
- return typeof value === "string" && value.trim().length > 0;
215
- }
216
-
217
- /**
218
- * In-memory cache for Gemini `thoughtSignature` values keyed by the OpenAI
219
- * tool-call ID we assign. Gemini 3 models require this signature to be
220
- * re-submitted with any `functionCall` part that appears in the *current turn*
221
- * of a multi-turn conversation. Because the OpenAI wire format has no field
222
- * for this, we cache it server-side and transparently re-inject it when the
223
- * client replays its history.
224
- *
225
- * Keys are unique per call (timestamp + counter) so there are no cross-session
226
- * collisions even under heavy concurrent load. Entries older than the rolling
227
- * window (max 500) are evicted automatically.
228
- */
229
- const thoughtSignatureCache = new Map<string, string>();
230
- const THOUGHT_SIGNATURE_CACHE_MAX = 500;
231
- const RESPONSES_STORE_TTL_MS = 6 * 60 * 60 * 1000;
232
- const RESPONSES_STORE_MAX = 500;
233
- const responsesStore = new Map<string, StoredResponseEntry>();
234
-
235
- function makeCompatId(prefix: string): string {
236
- return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
237
- }
238
-
239
- function pruneResponsesStore(now = Date.now()): void {
240
- for (const [id, entry] of responsesStore) {
241
- if (entry.expiresAt <= now) responsesStore.delete(id);
242
- }
243
- while (responsesStore.size > RESPONSES_STORE_MAX) {
244
- const oldest = responsesStore.keys().next();
245
- if (oldest.done) break;
246
- responsesStore.delete(oldest.value);
247
- }
248
- }
249
-
250
- function getStoredResponse(id: string): StoredResponseEntry | null {
251
- pruneResponsesStore();
252
- return responsesStore.get(id) || null;
253
- }
254
-
255
- function setStoredResponse(id: string, entry: StoredResponseEntry): void {
256
- pruneResponsesStore();
257
- responsesStore.set(id, entry);
258
- pruneResponsesStore();
259
- }
260
-
261
- export function resetResponsesStoreForTests(): void {
262
- responsesStore.clear();
263
- }
264
-
265
- function cacheThoughtSignature(callId: string, signature: string): void {
266
- if (thoughtSignatureCache.size >= THOUGHT_SIGNATURE_CACHE_MAX) {
267
- // Evict the oldest entry
268
- const firstKey = thoughtSignatureCache.keys().next().value;
269
- if (firstKey !== undefined) thoughtSignatureCache.delete(firstKey);
270
- }
271
- thoughtSignatureCache.set(callId, signature);
272
- }
273
-
274
- /**
275
- * Strip cache_control fields from content blocks.
276
- * Cloud Code API rejects cache_control with "Extra inputs are not permitted".
277
- */
278
- function cleanCacheControl<T>(content: T): T {
279
- if (!Array.isArray(content)) return content;
280
- return content.map((block: Record<string, unknown>) => {
281
- if (!block || typeof block !== "object") return block;
282
- if ("cache_control" in block) {
283
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
284
- const { cache_control: _cc, ...rest } = block;
285
- return rest;
286
- }
287
- return block;
288
- }) as T;
289
- }
290
-
291
- function extractText(content: ChatMessage["content"]): string {
292
- if (typeof content === "string") return content;
293
- if (!Array.isArray(content)) return "";
294
- return cleanCacheControl(content)
295
- .filter((p: { type?: string; text?: string; thinking?: string }) => (p.type === "text" && typeof p.text === "string") || (p.type === "thinking" && typeof p.thinking === "string"))
296
- .map((p: { type?: string; text?: string; thinking?: string }) => p.type === "thinking" ? `[Thinking]\n${p.thinking}\n[/Thinking]` : (p.text as string))
297
- .join("\n");
298
- }
299
-
300
- function dataUrlToInlineData(url: string): AntigravityPart | null {
301
- const match = url.match(/^data:([^;,]+);base64,(.+)$/s);
302
- if (!match) return null;
303
- return { inlineData: { mimeType: match[1], data: match[2] } };
304
- }
305
-
306
- function extractParts(content: ChatMessage["content"]): AntigravityPart[] {
307
- if (content === null) return [];
308
- if (typeof content === "string") return content ? [{ text: content }] : [];
309
- if (!Array.isArray(content)) return [];
310
- const parts: AntigravityPart[] = [];
311
- for (const part of content) {
312
- if (part.type === "text" && typeof part.text === "string" && part.text) {
313
- parts.push({ text: part.text });
314
- continue;
315
- }
316
- if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking) {
317
- parts.push({ text: `[Thinking]\n${part.thinking}\n[/Thinking]` });
318
- continue;
319
- }
320
- if (part.type === "image_url" && isRecord(part.image_url) && typeof part.image_url.url === "string") {
321
- const inline = dataUrlToInlineData(part.image_url.url);
322
- if (inline) parts.push(inline);
323
- continue;
324
- }
325
- if (part.type === "image" && isRecord(part.source) && part.source.type === "base64" && typeof part.source.media_type === "string" && typeof part.source.data === "string") {
326
- parts.push({ inlineData: { mimeType: part.source.media_type, data: part.source.data } });
327
- }
328
- }
329
- return parts;
330
- }
331
-
332
- /**
333
- * Gemini's function_declarations accept a restricted subset of JSON Schema.
334
- * Keywords like `const`, `$schema`, `$ref`, `$defs`, `if/then/else`, `not`,
335
- * `patternProperties`, etc. are not supported and will cause a 400.
336
- * This function recursively strips those unsupported keywords.
337
- */
338
- function sanitizeGeminiSchema(schema: unknown): unknown {
339
- if (!isRecord(schema)) return schema;
340
-
341
- // Keywords Gemini does not support
342
- const UNSUPPORTED = new Set([
343
- "const", "$schema", "$id", "$ref", "$defs", "definitions",
344
- "if", "then", "else", "not",
345
- "patternProperties", "unevaluatedProperties", "unevaluatedItems",
346
- "contentEncoding", "contentMediaType", "examples",
347
- "exclusiveMinimum", "exclusiveMaximum", "minimum", "maximum",
348
- "multipleOf", "minLength", "maxLength", "pattern",
349
- "minItems", "maxItems", "uniqueItems",
350
- "minProperties", "maxProperties", "title", "default",
351
- ]);
352
-
353
- const out: Record<string, unknown> = {};
354
- for (const [key, value] of Object.entries(schema)) {
355
- if (UNSUPPORTED.has(key)) continue;
356
-
357
- if (key === "anyOf" || key === "oneOf" || key === "allOf") {
358
- if (Array.isArray(value)) {
359
- // Special case: all items are pure {const: value} — this is the
360
- // JSON Schema way of writing an enum. Convert to Gemini's `enum` array.
361
- const allConst = value.every(
362
- (item) => isRecord(item) && Object.keys(item).length === 1 && "const" in item,
363
- );
364
- if (allConst) {
365
- out["enum"] = value.map((item) => (item as Record<string, unknown>)["const"]);
366
- // Infer type:string when all const values are strings (covers most tool params)
367
- if (value.every((item) => typeof (item as Record<string, unknown>)["const"] === "string")) {
368
- if (!out["type"]) out["type"] = "string";
369
- }
370
- } else {
371
- const cleaned = value.map(sanitizeGeminiSchema).filter(
372
- // Drop entries that become empty objects after sanitisation
373
- (v) => isRecord(v) && Object.keys(v).length > 0,
374
- );
375
- // If only one variant remains, unwrap it (Gemini prefers flat schemas)
376
- if (cleaned.length === 1) {
377
- Object.assign(out, cleaned[0]);
378
- } else if (cleaned.length > 1) {
379
- out[key] = cleaned;
380
- }
381
- // cleaned.length === 0: skip entirely
382
- }
383
- }
384
- continue;
385
- }
386
-
387
-
388
- if (key === "properties" && isRecord(value)) {
389
- out[key] = Object.fromEntries(
390
- Object.entries(value).map(([k, v]) => [k, sanitizeGeminiSchema(v)]),
391
- );
392
- continue;
393
- }
394
-
395
- if (key === "items") {
396
- out[key] = sanitizeGeminiSchema(value);
397
- continue;
398
- }
399
-
400
- out[key] = isRecord(value) ? sanitizeGeminiSchema(value) : value;
401
- }
402
- return out;
403
- }
404
-
405
- /**
406
- * Lighter sanitization for Claude models routed through Gemini's API.
407
- * Gemini's outer API still validates schemas before routing to Claude, so
408
- * we must remove fields Gemini's protobuf doesn't know about (like `const`,
409
- * `$ref`, etc.). However, unlike the Gemini-native sanitizer, we KEEP
410
- * standard JSON Schema Draft 2020-12 keywords (minimum, maximum, pattern,
411
- * etc.) that Claude requires and that Gemini's API does pass through.
412
- */
413
- function sanitizeClaudeViaGeminiSchema(schema: unknown): unknown {
414
- if (!isRecord(schema)) return schema;
415
-
416
- // Only remove fields that Gemini's API layer truly rejects at the network level.
417
- // We keep standard Draft 2020-12 keywords but must strip exclusiveMinimum/exclusiveMaximum
418
- // as boolean values (Draft 4) — the API layer rejects them even for Claude-bound requests.
419
- const UNSUPPORTED = new Set([
420
- "$schema", "$id", "$ref", "$defs", "definitions",
421
- "if", "then", "else", "not",
422
- "patternProperties", "unevaluatedProperties", "unevaluatedItems",
423
- "contentEncoding", "contentMediaType",
424
- // Gemini's protobuf layer rejects these regardless of target model
425
- "exclusiveMinimum", "exclusiveMaximum",
426
- ]);
427
-
428
- const out: Record<string, unknown> = {};
429
- for (const [key, value] of Object.entries(schema)) {
430
- if (UNSUPPORTED.has(key)) continue;
431
-
432
- // `const` is not supported by Gemini's API — convert to a single-value enum
433
- if (key === "const") {
434
- out["enum"] = [value];
435
- continue;
436
- }
437
-
438
- if (key === "anyOf" || key === "oneOf" || key === "allOf") {
439
- if (Array.isArray(value)) {
440
- // Case 1: all items are pure {const: value} — convert to flat enum.
441
- const allPureConst = value.every(
442
- (item) => isRecord(item) && Object.keys(item).length === 1 && "const" in item,
443
- );
444
- if (allPureConst) {
445
- out["enum"] = value.map((item) => (item as Record<string, unknown>)["const"]);
446
- if (value.every((item) => typeof (item as Record<string, unknown>)["const"] === "string")) {
447
- if (!out["type"]) out["type"] = "string";
448
- }
449
- continue;
450
- }
451
-
452
- // Case 2: all items are {type: T, const: V} (same type, each with a const).
453
- // e.g. [{type:"string",const:"fact"},{type:"string",const:"lesson"}]
454
- // Merge into a single flat {type: T, enum: [V1, V2, ...]} — avoids
455
- // the redundant anyOf-with-single-enum pattern that Claude rejects.
456
- const allTypeConst = value.every(
457
- (item) =>
458
- isRecord(item) &&
459
- Object.keys(item).length === 2 &&
460
- "type" in item &&
461
- "const" in item,
462
- );
463
- if (allTypeConst) {
464
- const firstType = (value[0] as Record<string, unknown>)["type"];
465
- const allSameType = value.every((item) => (item as Record<string, unknown>)["type"] === firstType);
466
- if (allSameType) {
467
- if (!out["type"]) out["type"] = firstType;
468
- out["enum"] = value.map((item) => (item as Record<string, unknown>)["const"]);
469
- continue;
470
- }
471
- }
472
-
473
- // Sanitize all variants first.
474
- const cleaned = value.map(sanitizeClaudeViaGeminiSchema).filter(
475
- (v) => isRecord(v) && Object.keys(v).length > 0,
476
- ) as Record<string, unknown>[];
477
-
478
- if (cleaned.length === 0) {
479
- // All variants collapsed to nothing — skip entirely.
480
- continue;
481
- }
482
-
483
- // Case 3: nullable pattern — anyOf/oneOf with exactly one {type:"null"}
484
- // variant and one or more real variants. Convert to the real variant
485
- // with nullable:true. This is lossless — Gemini's proto supports nullable.
486
- // e.g. anyOf:[{type:"string"},{type:"null"}] → {type:"string",nullable:true}
487
- if (key !== "allOf") {
488
- const nullIdx = cleaned.findIndex((v) => v.type === "null" && Object.keys(v).length === 1);
489
- if (nullIdx !== -1) {
490
- const nonNull = cleaned.filter((_, i) => i !== nullIdx);
491
- if (nonNull.length === 1) {
492
- Object.assign(out, nonNull[0], { nullable: true });
493
- continue;
494
- }
495
- if (nonNull.length > 1) {
496
- // Multiple non-null variants + null → collapse non-null variants,
497
- // then mark nullable. Still lossy but preserves nullability.
498
- Object.assign(out, nonNull[0], { nullable: true });
499
- continue;
500
- }
501
- }
502
- }
503
-
504
- // Case 4: allOf — deep merge all variants (allOf = intersection).
505
- // Merging properties from all variants is semantically correct.
506
- if (key === "allOf") {
507
- const merged: Record<string, unknown> = {};
508
- let mergedProperties: Record<string, unknown> = {};
509
- let mergedRequired: string[] = [];
510
- for (const variant of cleaned) {
511
- for (const [vk, vv] of Object.entries(variant)) {
512
- if (vk === "properties" && isRecord(vv)) {
513
- mergedProperties = { ...mergedProperties, ...vv };
514
- } else if (vk === "required" && Array.isArray(vv)) {
515
- mergedRequired = [...new Set([...mergedRequired, ...vv])];
516
- } else {
517
- merged[vk] = vv;
518
- }
519
- }
520
- }
521
- if (Object.keys(mergedProperties).length > 0) merged["properties"] = mergedProperties;
522
- if (mergedRequired.length > 0) merged["required"] = mergedRequired;
523
- Object.assign(out, merged);
524
- continue;
525
- }
526
-
527
- // Case 5: anyOf/oneOf where all variants are objects with properties —
528
- // merge all properties together, making all optional (union of shapes).
529
- // This is mildly lossy (accepts wider input) but doesn't reject valid inputs.
530
- const allObjects = cleaned.every(
531
- (v) => v.type === "object" && isRecord(v.properties),
532
- );
533
- if (allObjects && cleaned.length > 1) {
534
- const unionProperties: Record<string, unknown> = {};
535
- for (const variant of cleaned) {
536
- const props = variant.properties as Record<string, unknown>;
537
- for (const [pk, pv] of Object.entries(props)) {
538
- if (!(pk in unionProperties)) unionProperties[pk] = pv;
539
- }
540
- }
541
- // Only keep required fields that exist in ALL variants
542
- const allRequired = cleaned.map((v) =>
543
- Array.isArray(v.required) ? new Set(v.required as string[]) : new Set<string>(),
544
- );
545
- const commonRequired = [...allRequired[0]].filter((r) =>
546
- allRequired.every((s) => s.has(r)),
547
- );
548
- const base = { ...cleaned[0] };
549
- base["properties"] = unionProperties;
550
- if (commonRequired.length > 0) {
551
- base["required"] = commonRequired;
552
- } else {
553
- delete base["required"];
554
- }
555
- Object.assign(out, base);
556
- continue;
557
- }
558
-
559
- // Fallback: collapse to the first valid variant.
560
- // Gemini's Schema proto serialization corrupts complex anyOf/oneOf
561
- // during the round-trip to Claude, causing JSON Schema draft 2020-12
562
- // validation failures. Collapsing is lossy but functional — the tool
563
- // still works, just with a narrower accepted input type.
564
- Object.assign(out, cleaned[0]);
565
- }
566
- continue;
567
- }
568
-
569
- if (key === "properties" && isRecord(value)) {
570
- out[key] = Object.fromEntries(
571
- Object.entries(value).map(([k, v]) => [k, sanitizeClaudeViaGeminiSchema(v)]),
572
- );
573
- continue;
574
- }
575
-
576
- if (key === "items") {
577
- out[key] = sanitizeClaudeViaGeminiSchema(value);
578
- continue;
579
- }
580
-
581
- out[key] = isRecord(value) ? sanitizeClaudeViaGeminiSchema(value) : value;
582
- }
583
- return out;
584
- }
585
-
586
- /** Convert OpenAI tools array to Gemini functionDeclarations */
587
- function convertOpenAIToolsToGemini(tools: OpenAITool[], isClaude: boolean = false): { functionDeclarations: GeminiFunctionDeclaration[] }[] {
588
- const decls: GeminiFunctionDeclaration[] = tools
589
- .filter((t) => t.type === "function" && isNonEmptyString(t.function?.name))
590
- .map((t) => {
591
- const sanitized = t.function.parameters
592
- ? (isClaude ? sanitizeClaudeViaGeminiSchema(t.function.parameters) : sanitizeGeminiSchema(t.function.parameters)) as Record<string, unknown>
593
- : undefined;
594
- return {
595
- name: t.function.name,
596
- ...(t.function.description ? { description: t.function.description } : {}),
597
- ...(sanitized ? { parameters: sanitized } : {}),
598
- };
599
- });
600
- return decls.length > 0 ? [{ functionDeclarations: decls }] : [];
601
- }
602
-
603
- /** Convert OpenAI tool_choice to Gemini toolConfig */
604
- function convertToolChoiceToGemini(toolChoice: unknown): GeminiToolConfig | undefined {
605
- if (!toolChoice || toolChoice === "none") return { functionCallingConfig: { mode: "NONE" } };
606
- if (toolChoice === "auto" || toolChoice === "required") return { functionCallingConfig: { mode: "AUTO" } };
607
- if (isRecord(toolChoice) && toolChoice.type === "function" && isRecord(toolChoice.function) && isNonEmptyString(toolChoice.function.name)) {
608
- return { functionCallingConfig: { mode: "ANY", allowedFunctionNames: [toolChoice.function.name] } };
609
- }
610
- return { functionCallingConfig: { mode: "AUTO" } };
611
- }
612
-
613
- function validateMessages(value: unknown): value is ChatMessage[] {
614
- return Array.isArray(value) && value.every((msg) => {
615
- if (!isRecord(msg)) return false;
616
- if (!["system", "developer", "user", "assistant", "model", "tool"].includes(String(msg.role))) return false;
617
- return typeof msg.content === "string" || msg.content === null || Array.isArray(msg.content);
618
- });
619
- }
620
-
621
- function extractTextFromUnknownContent(content: unknown): string {
622
- if (typeof content === "string") return content;
623
- if (!Array.isArray(content)) return "";
624
- return content
625
- .map((part) => {
626
- if (typeof part === "string") return part;
627
- if (!isRecord(part)) return "";
628
- if (typeof part.text === "string") return part.text;
629
- if (typeof part.input_text === "string") return part.input_text;
630
- if (typeof part.output_text === "string") return part.output_text;
631
- return "";
632
- })
633
- .filter(Boolean)
634
- .join("\n");
635
- }
636
-
637
- function normalizeContentBlocks(content: unknown): ChatMessage["content"] {
638
- if (typeof content === "string" || content === null) return content;
639
- if (!Array.isArray(content)) return extractTextFromUnknownContent(content);
640
- const blocks = content.flatMap((part) => {
641
- if (typeof part === "string") return [{ type: "text", text: part }];
642
- if (!isRecord(part)) return [];
643
- if (part.type === "input_text" && typeof part.text === "string") return [{ type: "text", text: part.text }];
644
- if (part.type === "output_text" && typeof part.text === "string") return [{ type: "text", text: part.text }];
645
- if (typeof part.text === "string") return [{ ...part, type: typeof part.type === "string" ? part.type : "text", text: part.text }];
646
- if (typeof part.input_text === "string") return [{ type: "text", text: part.input_text }];
647
- if (typeof part.output_text === "string") return [{ type: "text", text: part.output_text }];
648
- if (part.type === "input_image" && typeof part.image_url === "string") {
649
- return [{ type: "image_url", image_url: { url: part.image_url } }];
650
- }
651
- return [part as { type: string; text?: string;[key: string]: unknown }];
652
- });
653
- return blocks.length > 0 ? blocks : "";
654
- }
655
-
656
- function normalizeInstructionsContent(content: OpenAIResponsesRequest["instructions"]): ChatMessage["content"] {
657
- if (typeof content === "string" || content === null || content === undefined) return content ?? "";
658
- return normalizeContentBlocks(content);
659
- }
660
-
661
- function contentToResponseInputBlocks(content: ChatMessage["content"], role: string): Array<Record<string, unknown>> {
662
- if (typeof content === "string") {
663
- if (!content) return [];
664
- return [{ type: role === "assistant" || role === "model" ? "output_text" : "input_text", text: content }];
665
- }
666
- if (!Array.isArray(content)) return [];
667
- return cleanCacheControl(content).flatMap((part) => {
668
- if (!isRecord(part)) return [];
669
- if (typeof part.text === "string") {
670
- return [{ type: role === "assistant" || role === "model" ? "output_text" : "input_text", text: part.text }];
671
- }
672
- if (part.type === "image_url" && isRecord(part.image_url) && typeof part.image_url.url === "string") {
673
- return [{ type: "input_image", image_url: part.image_url.url }];
674
- }
675
- return [part];
676
- });
677
- }
678
-
679
- type ParsedResponsesInput = {
680
- inputItems: Array<Record<string, unknown>>;
681
- messages: ChatMessage[];
80
+ export type {
81
+ ModelSpec,
82
+ ChatMessage,
83
+ OpenAITool,
84
+ OpenAIToolCall,
85
+ OpenAIToolChoice,
86
+ OpenAIChatCompletionRequest,
87
+ OpenAIResponsesRequest,
88
+ AnthropicMessagesRequest,
89
+ CompatCompletion,
682
90
  };
683
91
 
684
- function parseResponsesInput(input: unknown, callIdToName: Map<string, string> = new Map()): ParsedResponsesInput {
685
- if (typeof input === "string") {
686
- return {
687
- inputItems: [{ id: makeCompatId("in"), type: "message", role: "user", content: [{ type: "input_text", text: input }] }],
688
- messages: [{ role: "user", content: input }],
689
- };
690
- }
691
- if (!Array.isArray(input)) return { inputItems: [], messages: [] };
692
-
693
- const inputItems: Array<Record<string, unknown>> = [];
694
- const messages: ChatMessage[] = [];
695
-
696
- for (const rawItem of input) {
697
- if (typeof rawItem === "string") {
698
- inputItems.push({ id: makeCompatId("in"), type: "message", role: "user", content: [{ type: "input_text", text: rawItem }] });
699
- messages.push({ role: "user", content: rawItem });
700
- continue;
701
- }
702
- if (!isRecord(rawItem)) continue;
703
-
704
- if (rawItem.type === "function_call_output" && typeof rawItem.call_id === "string") {
705
- const outputText = typeof rawItem.output === "string" ? rawItem.output : JSON.stringify(rawItem.output ?? "");
706
- const toolName = typeof rawItem.name === "string" ? rawItem.name : (callIdToName.get(rawItem.call_id) || "unknown");
707
- inputItems.push({
708
- id: makeCompatId("in"),
709
- type: "function_call_output",
710
- call_id: rawItem.call_id,
711
- output: outputText,
712
- });
713
- messages.push({ role: "tool", content: outputText, name: toolName, tool_call_id: rawItem.call_id });
714
- continue;
715
- }
716
-
717
- if (rawItem.type === "function_call" && typeof rawItem.name === "string") {
718
- const callId = typeof rawItem.call_id === "string" ? rawItem.call_id : makeCompatId("call");
719
- const args = typeof rawItem.arguments === "string" ? rawItem.arguments : JSON.stringify(rawItem.arguments ?? {});
720
- inputItems.push({
721
- id: makeCompatId("in"),
722
- type: "function_call",
723
- call_id: callId,
724
- name: rawItem.name,
725
- arguments: args,
726
- });
727
- // Merge consecutive function_call items into a single assistant message
728
- // so Claude sees one assistant turn with multiple tool_calls rather than
729
- // separate assistant turns (which would each require their own tool_result).
730
- const lastMsg = messages[messages.length - 1];
731
- if (lastMsg && lastMsg.role === "assistant" && Array.isArray(lastMsg.tool_calls)) {
732
- lastMsg.tool_calls.push({ id: callId, type: "function", function: { name: rawItem.name, arguments: args } });
733
- } else {
734
- messages.push({
735
- role: "assistant",
736
- content: null,
737
- tool_calls: [{ id: callId, type: "function", function: { name: rawItem.name, arguments: args } }],
738
- });
739
- }
740
- continue;
741
- }
742
-
743
- const isMessage = rawItem.type === "message" || typeof rawItem.role === "string" || "content" in rawItem;
744
- if (!isMessage) continue;
745
- const rawRole = typeof rawItem.role === "string" ? rawItem.role : "user";
746
- const role = rawRole === "developer" ? "system" : rawRole;
747
- if (!["system", "user", "assistant", "model", "tool"].includes(role)) continue;
748
- const content = "content" in rawItem ? normalizeContentBlocks(rawItem.content) : extractTextFromUnknownContent(rawItem);
749
- inputItems.push({
750
- id: makeCompatId("in"),
751
- type: "message",
752
- role: rawRole,
753
- content: contentToResponseInputBlocks(content, role),
754
- });
755
- messages.push({ role: role as ChatMessage["role"], content });
756
- }
757
-
758
- return { inputItems, messages };
759
- }
760
-
761
- function messagesFromResponsesInput(input: unknown): ChatMessage[] | null {
762
- if (typeof input === "string") return [{ role: "user", content: input }];
763
- if (!Array.isArray(input)) return null;
764
-
765
- const messages: ChatMessage[] = [];
766
- for (const item of input) {
767
- if (typeof item === "string") {
768
- messages.push({ role: "user", content: item });
769
- continue;
770
- }
771
- if (!isRecord(item)) continue;
772
- const role = typeof item.role === "string" ? item.role : "user";
773
- if (!["system", "user", "assistant", "model", "tool"].includes(role)) continue;
774
- const content = "content" in item ? normalizeContentBlocks(item.content) : extractTextFromUnknownContent(item);
775
- messages.push({ role: role as ChatMessage["role"], content });
776
- }
777
- return messages.length > 0 ? messages : null;
778
- }
779
-
780
- function messagesFromLooseMessages(value: unknown): ChatMessage[] | null {
781
- if (typeof value === "string") return [{ role: "user", content: value }];
782
- if (isRecord(value)) return messagesFromResponsesInput([value]);
783
- return null;
784
- }
785
-
786
- function messagesFromAntigravityRequest(value: Record<string, unknown>): ChatMessage[] | null {
787
- const request = isRecord(value.request) ? value.request : null;
788
- if (!request || !Array.isArray(request.contents)) return null;
789
- const messages: ChatMessage[] = [];
790
- if (isRecord(request.systemInstruction) && Array.isArray(request.systemInstruction.parts)) {
791
- const systemText = request.systemInstruction.parts
792
- .map((part) => isRecord(part) && typeof part.text === "string" ? part.text : "")
793
- .filter(Boolean)
794
- .join("\n");
795
- if (systemText) messages.push({ role: "system", content: systemText });
796
- }
797
- for (const turn of request.contents) {
798
- if (!isRecord(turn) || !Array.isArray(turn.parts)) continue;
799
- const role = turn.role === "model" || turn.role === "assistant" ? "assistant" : "user";
800
- const content = turn.parts
801
- .map((part) => isRecord(part) && typeof part.text === "string" ? part.text : "")
802
- .filter(Boolean)
803
- .join("\n");
804
- messages.push({ role, content });
805
- }
806
- return messages.length > 0 ? messages : null;
807
- }
808
-
809
- export function normalizeOpenAIChatCompletionRequest(value: unknown): unknown {
810
- if (!isRecord(value) || Array.isArray(value.messages)) return value;
811
- let messages: ChatMessage[] | null = null;
812
- if ("messages" in value) messages = messagesFromLooseMessages(value.messages);
813
- else if (typeof value.prompt === "string") messages = [{ role: "user", content: value.prompt }];
814
- else if (Array.isArray(value.prompt)) messages = messagesFromResponsesInput(value.prompt) ?? value.prompt.map((prompt) => ({ role: "user", content: String(prompt) }));
815
- else if ("input" in value) messages = messagesFromResponsesInput(value.input);
816
- else messages = messagesFromAntigravityRequest(value);
817
- return messages ? { ...value, messages } : value;
818
- }
819
-
820
- export function normalizeOpenAIResponsesRequest(value: unknown): unknown {
821
- if (!isRecord(value)) return value;
822
-
823
- // Normalize and filter tools array.
824
- // Codex / VS Code Responses API sends tools in two layouts:
825
- // 1. Standard: { type: "function", function: { name, description, parameters } }
826
- // 2. Flat (v2): { type: "function", name, description, parameters } ← Codex uses this
827
- // We normalize flat entries to standard layout, drop non-function types, and drop
828
- // any function entries still missing a name after normalization.
829
- let normalized: Record<string, unknown> = { ...value };
830
- if (Array.isArray(value.tools)) {
831
- const before = value.tools.length;
832
- const filtered: unknown[] = [];
833
- for (const t of value.tools) {
834
- if (!isRecord(t) || typeof t.type !== "string") continue;
835
- if (t.type !== "function") continue;
836
-
837
- // Flat format: name at root level → lift into .function wrapper
838
- if (isNonEmptyString(t.name) && !isRecord(t.function)) {
839
- filtered.push({
840
- type: "function",
841
- function: {
842
- name: t.name,
843
- ...(typeof t.description === "string" ? { description: t.description } : {}),
844
- ...(isRecord(t.parameters) ? { parameters: t.parameters } : {}),
845
- },
846
- });
847
- continue;
848
- }
849
-
850
- // Standard format: must have .function.name
851
- if (isRecord(t.function) && isNonEmptyString(t.function.name)) {
852
- filtered.push(t);
853
- }
854
- // else: drop (function entry without a usable name)
855
- }
856
- const dropped = before - filtered.length;
857
- if (dropped > 0) {
858
- compatLogger.warn(`Filtered ${dropped} unsupported/unnamed tool(s) from Responses request (kept ${filtered.length} function tools)`);
859
- }
860
- normalized = { ...normalized, tools: filtered.length > 0 ? filtered : undefined };
861
- }
862
-
863
- if ("input" in normalized) return normalized;
864
- if ("messages" in normalized) return { ...normalized, input: normalized.messages };
865
- if ("prompt" in normalized) return { ...normalized, input: normalized.prompt };
866
- return normalized;
867
- }
868
-
869
- export function normalizeAnthropicMessagesRequest(value: unknown): unknown {
870
- if (!isRecord(value) || Array.isArray(value.messages)) return value;
871
- const messages = "messages" in value
872
- ? messagesFromLooseMessages(value.messages)
873
- : "input" in value
874
- ? messagesFromResponsesInput(value.input)
875
- : messagesFromAntigravityRequest(value);
876
- return messages ? { ...value, messages } : value;
877
- }
878
-
879
- function validateResponsesTools(value: unknown): string[] {
880
- if (value === undefined) return [];
881
- if (!Array.isArray(value)) return ["body.tools must be an array when provided"];
882
- const errors: string[] = [];
883
- for (const tool of value) {
884
- if (!isRecord(tool)) {
885
- errors.push("each tool must be an object");
886
- continue;
887
- }
888
- if (tool.type !== "function") {
889
- errors.push(`only function tools are supported (got: ${tool.type})`);
890
- }
891
- }
892
- return errors;
893
- }
894
-
895
- export function validateOpenAIChatCompletionRequest(value: unknown): { ok: true; value: OpenAIChatCompletionRequest } | { ok: false; errors: string[] } {
896
- if (!isRecord(value)) return { ok: false, errors: ["body must be a JSON object"] };
897
- const errors: string[] = [];
898
- if (!isNonEmptyString(value.model)) errors.push("body.model must be a non-empty string");
899
- if (!validateMessages(value.messages)) {
900
- compatLogger.warn(`OpenAI messages validation failed: ${JSON.stringify(value.messages)}`);
901
- errors.push("body.messages must be an array of chat messages");
902
- }
903
- if (value.stream !== undefined && typeof value.stream !== "boolean") errors.push("body.stream must be boolean when provided");
904
- if (value.temperature !== undefined && typeof value.temperature !== "number") errors.push("body.temperature must be number when provided");
905
- if (value.max_tokens !== undefined && typeof value.max_tokens !== "number") errors.push("body.max_tokens must be number when provided");
906
- return errors.length > 0 ? { ok: false, errors } : { ok: true, value: value as unknown as OpenAIChatCompletionRequest };
907
- }
908
-
909
- export function validateOpenAIResponsesRequest(value: unknown): { ok: true; value: OpenAIResponsesRequest } | { ok: false; errors: string[] } {
910
- if (!isRecord(value)) return { ok: false, errors: ["body must be a JSON object"] };
911
- const errors: string[] = [];
912
- if (!isNonEmptyString(value.model)) errors.push("body.model must be a non-empty string");
913
- if (value.stream !== undefined && typeof value.stream !== "boolean") errors.push("body.stream must be boolean when provided");
914
- if (value.temperature !== undefined && typeof value.temperature !== "number") errors.push("body.temperature must be number when provided");
915
- if (value.max_output_tokens !== undefined && typeof value.max_output_tokens !== "number") errors.push("body.max_output_tokens must be number when provided");
916
- if (value.store !== undefined && typeof value.store !== "boolean") errors.push("body.store must be boolean when provided");
917
- if (value.previous_response_id !== undefined && value.previous_response_id !== null && !isNonEmptyString(value.previous_response_id)) {
918
- errors.push("body.previous_response_id must be a non-empty string or null");
919
- }
920
- if (value.conversation !== undefined && value.conversation !== null) {
921
- errors.push("body.conversation is not supported; use previous_response_id instead");
922
- }
923
- if (value.metadata !== undefined && !isRecord(value.metadata)) errors.push("body.metadata must be an object when provided");
924
- if (value.reasoning !== undefined && value.reasoning !== null && !isRecord(value.reasoning)) {
925
- errors.push("body.reasoning must be an object when provided");
926
- } else if (isRecord(value.reasoning) && value.reasoning.effort !== undefined && value.reasoning.effort !== null && typeof value.reasoning.effort !== "string") {
927
- errors.push("body.reasoning.effort must be a string when provided");
928
- }
929
- if (value.instructions !== undefined && value.instructions !== null && typeof value.instructions !== "string" && !Array.isArray(value.instructions)) {
930
- errors.push("body.instructions must be a string or content array when provided");
931
- }
932
- errors.push(...validateResponsesTools(value.tools));
933
- return errors.length > 0 ? { ok: false, errors } : { ok: true, value: value as unknown as OpenAIResponsesRequest };
934
- }
935
-
936
- export function validateAnthropicMessagesRequest(value: unknown): { ok: true; value: AnthropicMessagesRequest } | { ok: false; errors: string[] } {
937
- if (!isRecord(value)) return { ok: false, errors: ["body must be a JSON object"] };
938
- const errors: string[] = [];
939
- if (!isNonEmptyString(value.model)) errors.push("body.model must be a non-empty string");
940
- if (!validateMessages(value.messages)) errors.push("body.messages must be an array of chat messages");
941
- if (value.system !== undefined && typeof value.system !== "string" && !Array.isArray(value.system)) errors.push("body.system must be string or content array when provided");
942
- if (value.stream !== undefined && typeof value.stream !== "boolean") errors.push("body.stream must be boolean when provided");
943
- if (value.temperature !== undefined && typeof value.temperature !== "number") errors.push("body.temperature must be number when provided");
944
- if (value.max_tokens !== undefined && typeof value.max_tokens !== "number") errors.push("body.max_tokens must be number when provided");
945
- return errors.length > 0 ? { ok: false, errors } : { ok: true, value: value as unknown as AnthropicMessagesRequest };
946
- }
947
-
948
- type GeminiContent = { role: "user" | "model"; parts: unknown[] };
949
-
950
- type ResponsesConversionResult = {
951
- chatRequest: OpenAIChatCompletionRequest;
952
- inputItems: Array<Record<string, unknown>>;
953
- conversationMessages: ChatMessage[];
954
- previousResponseId: string | null;
955
- };
956
-
957
- function convertResponsesToChatRequest(input: OpenAIResponsesRequest): ResponsesConversionResult {
958
- const previousResponseId = input.previous_response_id ?? null;
959
- const previous = previousResponseId ? getStoredResponse(previousResponseId) : null;
960
- if (previousResponseId && !previous) {
961
- throw new Error(`previous_response_id not found: ${previousResponseId}`);
962
- }
963
-
964
- const parsed = parseResponsesInput(input.input, previous?.callIdToName);
965
- const conversationMessages = [
966
- ...(previous?.conversationMessages ?? []),
967
- ...parsed.messages,
968
- ];
969
- const chatMessages = [
970
- ...(input.instructions ? [{ role: "system" as const, content: normalizeInstructionsContent(input.instructions) }] : []),
971
- ...conversationMessages,
972
- ];
973
-
974
- return {
975
- chatRequest: {
976
- model: input.model,
977
- messages: chatMessages,
978
- stream: input.stream,
979
- temperature: input.temperature,
980
- max_tokens: input.max_output_tokens,
981
- tools: input.tools as OpenAITool[] | undefined,
982
- tool_choice: input.tool_choice,
983
- reasoning_effort: typeof input.reasoning?.effort === "string" ? input.reasoning.effort : undefined,
984
- parallel_tool_calls: input.parallel_tool_calls,
985
- },
986
- inputItems: parsed.inputItems,
987
- conversationMessages,
988
- previousResponseId,
989
- };
990
- }
991
-
992
- function responseUsageFromCompletion(completion: CompatCompletion): Record<string, unknown> {
993
- return {
994
- input_tokens: completion.inputTokens,
995
- input_tokens_details: { cached_tokens: 0 },
996
- output_tokens: completion.outputTokens,
997
- output_tokens_details: { reasoning_tokens: 0 },
998
- total_tokens: completion.inputTokens + completion.outputTokens,
999
- };
1000
- }
1001
-
1002
- function buildResponsesOutput(completion: CompatCompletion): { output: ResponseOutputItem[]; outputText: string; callIdToName: Map<string, string> } {
1003
- const output: ResponseOutputItem[] = [];
1004
- const callIdToName = new Map<string, string>();
1005
- // Emit reasoning item first (before message text) when thinking content is present
1006
- if (completion.thinkingText) {
1007
- output.push({
1008
- id: makeCompatId("rs"),
1009
- type: "reasoning",
1010
- status: "completed",
1011
- summary: [{ type: "summary_text", text: completion.thinkingText }],
1012
- } as unknown as ResponseOutputItem);
1013
- }
1014
- if (completion.text) {
1015
- output.push({
1016
- id: makeCompatId("msg"),
1017
- type: "message",
1018
- status: "completed",
1019
- role: "assistant",
1020
- content: [{ type: "output_text", text: completion.text, annotations: [] }],
1021
- });
1022
- }
1023
- for (const toolCall of completion.toolCalls ?? []) {
1024
- callIdToName.set(toolCall.id, toolCall.function.name);
1025
- output.push({
1026
- id: makeCompatId("fc"),
1027
- type: "function_call",
1028
- call_id: toolCall.id,
1029
- name: toolCall.function.name,
1030
- arguments: toolCall.function.arguments,
1031
- status: "completed",
1032
- });
1033
- }
1034
- return { output, outputText: completion.text, callIdToName };
1035
- }
1036
-
1037
- function buildAssistantMessageFromCompletion(completion: CompatCompletion): ChatMessage {
1038
- return completion.toolCalls && completion.toolCalls.length > 0
1039
- ? { role: "assistant", content: completion.text || null, tool_calls: completion.toolCalls }
1040
- : { role: "assistant", content: completion.text };
1041
- }
1042
-
1043
- function buildResponsesResponse(
1044
- request: OpenAIResponsesRequest,
1045
- responseId: string,
1046
- createdAt: number,
1047
- completion: CompatCompletion,
1048
- status: "in_progress" | "completed" | "cancelled",
1049
- previousResponseId: string | null,
1050
- ): Record<string, unknown> {
1051
- const { output, outputText } = buildResponsesOutput(completion);
1052
- return {
1053
- id: responseId,
1054
- object: "response",
1055
- created_at: createdAt,
1056
- status,
1057
- error: null,
1058
- incomplete_details: null,
1059
- instructions: request.instructions ?? null,
1060
- max_output_tokens: request.max_output_tokens ?? null,
1061
- model: request.model,
1062
- output,
1063
- output_text: outputText,
1064
- parallel_tool_calls: request.parallel_tool_calls ?? true,
1065
- previous_response_id: previousResponseId,
1066
- reasoning: { effort: request.reasoning?.effort ?? null },
1067
- store: request.store !== false,
1068
- temperature: request.temperature ?? null,
1069
- text: { format: { type: "text" } },
1070
- tool_choice: request.tool_choice ?? "auto",
1071
- tools: Array.isArray(request.tools) ? request.tools : [],
1072
- top_p: null,
1073
- truncation: "disabled",
1074
- usage: responseUsageFromCompletion(completion),
1075
- metadata: isRecord(request.metadata) ? request.metadata : {},
1076
- };
1077
- }
1078
-
1079
- function saveResponsesEntry(
1080
- response: Record<string, unknown>,
1081
- inputItems: Array<Record<string, unknown>>,
1082
- conversationMessages: ChatMessage[],
1083
- completion: CompatCompletion,
1084
- ): void {
1085
- const responseId = typeof response.id === "string" ? response.id : null;
1086
- if (!responseId) return;
1087
- const { callIdToName } = buildResponsesOutput(completion);
1088
- const mergedConversation = [...conversationMessages, buildAssistantMessageFromCompletion(completion)];
1089
- setStoredResponse(responseId, {
1090
- response,
1091
- inputItems,
1092
- conversationMessages: mergedConversation,
1093
- callIdToName,
1094
- expiresAt: Date.now() + RESPONSES_STORE_TTL_MS,
1095
- });
1096
- }
1097
-
1098
- export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): RequestBody {
1099
- // Separate system messages from conversation turns
1100
- const systemParts: string[] = [];
1101
- const conversationMessages = input.messages.filter((msg) => {
1102
- if (msg.role === "system" || msg.role === "developer") {
1103
- const text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
1104
- if (text) systemParts.push(text);
1105
- return false;
1106
- }
1107
- return true;
1108
- });
1109
-
1110
- // Build multi-turn contents array.
1111
- //
1112
- // Gemini thinking models require a `thought_signature` on every functionCall
1113
- // part when replaying multi-turn tool conversations. Since we receive
1114
- // We always use native Gemini functionCall parts for all tool calls in the history.
1115
-
1116
- // Determine if model is Claude — affects schema sanitization and tool call ID handling
1117
- const isClaude = /^claude-/i.test(input.model);
1118
-
1119
- // Use model specs to determine thinking support
1120
- const isThinking = isThinkingModel(input.model);
1121
- const isGeminiThinking = !isClaude && isThinking;
1122
-
1123
- const contents: GeminiContent[] = [];
1124
- for (let i = 0; i < conversationMessages.length; i++) {
1125
- const msg = conversationMessages[i];
1126
- if (msg.role === "assistant" || msg.role === "model") {
1127
- // Check if this is a thinking model turn with tool calls that have no cached signatures.
1128
- // If so, we collapse the tool exchange into a neutral user summary instead of
1129
- // injecting [Tool call: ...] text that the model will learn to mimic.
1130
- const hasMissingSig =
1131
- isGeminiThinking &&
1132
- Array.isArray(msg.tool_calls) &&
1133
- msg.tool_calls.length > 0 &&
1134
- !thoughtSignatureCache.has(msg.tool_calls[0].id);
1135
-
1136
- if (hasMissingSig) {
1137
- // Build a summary of what the model did and what results came back.
1138
- // We collect the paired tool result(s) from the immediately following messages.
1139
- const toolNames = msg.tool_calls!.map((tc) => tc.function.name).join(", ");
1140
- const resultParts: string[] = [];
1141
- while (i + 1 < conversationMessages.length && conversationMessages[i + 1].role === "tool") {
1142
- i++;
1143
- const toolMsg = conversationMessages[i];
1144
- const toolText = typeof toolMsg.content === "string" ? toolMsg.content : extractText(toolMsg.content);
1145
- resultParts.push(`${toolMsg.name || "tool"}: ${toolText.slice(0, 500)}`);
1146
- }
1147
- const summaryText = `[Context: The assistant used tools (${toolNames}) and received results:\n${resultParts.join("\n")}]`;
1148
- contents.push({ role: "user", parts: [{ text: summaryText }] });
1149
- // Add a minimal model acknowledgement to avoid consecutive user turns
1150
- contents.push({ role: "model", parts: [{ text: "Understood, I have the tool results." }] });
1151
- continue;
1152
- }
1153
-
1154
- const parts: unknown[] = [];
1155
- if (msg.content) {
1156
- const textContent = typeof msg.content === "string" ? msg.content : extractText(msg.content);
1157
- if (textContent) parts.push({ text: textContent });
1158
- }
1159
- if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
1160
- // Use native Gemini functionCall parts. Re-inject thought_signature from
1161
- // the server-side cache if available. Google only validates signatures on
1162
- // the *current turn* (after the last real user text message), so missing
1163
- // signatures on older historical turns are silently ignored.
1164
- let isFirstInMessage = true;
1165
- for (const tc of msg.tool_calls) {
1166
- let args: unknown;
1167
- try {
1168
- args = typeof tc.function.arguments === "string" ? JSON.parse(tc.function.arguments) : tc.function.arguments;
1169
- } catch {
1170
- args = {};
1171
- }
1172
- // Only the first functionCall part in a model turn needs the signature
1173
- const cachedSig = isFirstInMessage ? thoughtSignatureCache.get(tc.id) : undefined;
1174
- parts.push({
1175
- ...(cachedSig ? { thoughtSignature: cachedSig } : {}),
1176
- // Include id only for Claude — Gemini native models reject the id field
1177
- functionCall: { ...(isClaude ? { id: tc.id } : {}), name: tc.function.name, args },
1178
- });
1179
- isFirstInMessage = false;
1180
- }
1181
- }
1182
- if (parts.length > 0) {
1183
- // For Claude: handle two scenarios that break tool_use/tool_result ordering.
1184
- // 1. Text-only model turn after a functionCall model turn: Codex sends
1185
- // assistant text and function_calls as separate items. The text-only turn
1186
- // would split functionCall from functionResponse — skip it entirely.
1187
- // 2. Model turn with functionCalls: strip any text parts since Google's
1188
- // v1internal translator may split mixed parts into separate Claude messages.
1189
- if (isClaude) {
1190
- const lastContent = contents[contents.length - 1];
1191
- const prevHasFunctionCall = lastContent && lastContent.role === "model" && lastContent.parts.some((p: any) => p.functionCall);
1192
- const hasFunctionCall = parts.some((p: any) => p.functionCall);
1193
- if (prevHasFunctionCall && !hasFunctionCall) {
1194
- // Skip text-only model turn after functionCall turn
1195
- } else if (hasFunctionCall) {
1196
- // Strip text parts, keep only functionCall parts
1197
- const fcOnly = parts.filter((p: any) => p.functionCall);
1198
- if (prevHasFunctionCall) {
1199
- lastContent.parts.push(...fcOnly);
1200
- } else {
1201
- contents.push({ role: "model", parts: fcOnly });
1202
- }
1203
- } else {
1204
- contents.push({ role: "model", parts });
1205
- }
1206
- } else {
1207
- contents.push({ role: "model", parts });
1208
- }
1209
- }
1210
- } else if (msg.role === "tool") {
1211
- const responseText = typeof msg.content === "string" ? msg.content : extractText(msg.content);
1212
- const fnName = msg.name || "unknown";
1213
- // Include tool_call_id so Gemini can pass it as tool_use_id to Claude
1214
- const toolCallId = msg.tool_call_id;
1215
- let responseData: unknown;
1216
- try {
1217
- const parsed = JSON.parse(responseText);
1218
- // Cloud Code proto requires functionResponse.response to be an object, not an array.
1219
- // Wrap arrays (and other non-object primitives) so the field is always a plain object.
1220
- responseData = (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed))
1221
- ? parsed
1222
- : { output: parsed };
1223
- } catch { responseData = { output: responseText }; }
1224
- // Include id only for Claude — Gemini native models reject the id field in functionResponse
1225
- const fnResponsePart = { functionResponse: { ...(isClaude && toolCallId ? { id: toolCallId } : {}), name: fnName, response: responseData } };
1226
- // Merge consecutive tool results into a single user turn.
1227
- // Claude (via Vertex) requires ALL tool_result blocks in one message
1228
- // directly after the assistant message with tool_use blocks.
1229
- const lastContent = contents[contents.length - 1];
1230
- if (lastContent && lastContent.role === "user" && Array.isArray(lastContent.parts) && lastContent.parts.length > 0 && isRecord(lastContent.parts[0] as any) && (lastContent.parts[0] as any).functionResponse !== undefined) {
1231
- lastContent.parts.push(fnResponsePart);
1232
- } else {
1233
- contents.push({ role: "user", parts: [fnResponsePart] });
1234
- }
1235
- } else {
1236
- // user message
1237
- const msgParts = extractParts(msg.content);
1238
-
1239
- if (msgParts.length > 0) contents.push({ role: "user", parts: msgParts });
1240
- }
1241
- }
1242
-
1243
-
1244
- if (contents.length === 0) contents.push({ role: "user", parts: [{ text: "Hello" }] });
1245
-
1246
- // Build tools / toolConfig if present
1247
- const inputTools = Array.isArray(input.tools) ? (input.tools as OpenAITool[]) : [];
1248
- const geminiTools = convertOpenAIToolsToGemini(inputTools, isClaude);
1249
- const geminiToolConfig = input.tool_choice !== undefined ? convertToolChoiceToGemini(input.tool_choice) : undefined;
1250
-
1251
- // Cap maxOutputTokens to model limits and build thinkingConfig
1252
- const modelSpec = getModelSpec(input.model);
1253
- const modelFamily = getModelFamily(input.model);
1254
- let maxOutputTokens = typeof input.max_tokens === "number" ? input.max_tokens : undefined;
1255
- if (maxOutputTokens && maxOutputTokens > modelSpec.maxOutputTokens) {
1256
- compatLogger.debug(`Capping ${input.model} maxOutputTokens ${maxOutputTokens} → ${modelSpec.maxOutputTokens}`);
1257
- maxOutputTokens = modelSpec.maxOutputTokens;
1258
- }
1259
-
1260
- let thinkingConfigObj: Record<string, unknown> | undefined;
1261
- if (modelFamily === "claude" && isThinking) {
1262
- // Claude: snake_case keys required by v1internal
1263
- const tb = modelSpec.thinkingBudget;
1264
- thinkingConfigObj = { include_thoughts: true, thinking_budget: tb };
1265
- if (!maxOutputTokens || maxOutputTokens <= tb) {
1266
- maxOutputTokens = Math.min(tb + 8192, modelSpec.maxOutputTokens);
1267
- compatLogger.debug(`Adjusted Claude maxOutputTokens → ${maxOutputTokens}`);
1268
- }
1269
- } else if (isThinking) {
1270
- // Gemini: camelCase keys; thinkingBudget=-1 means adaptive (omit the field)
1271
- const tb = modelSpec.thinkingBudget;
1272
- thinkingConfigObj = tb === -1
1273
- ? { includeThoughts: true }
1274
- : { includeThoughts: true, thinkingBudget: tb };
1275
- if (tb !== -1 && (!maxOutputTokens || maxOutputTokens <= tb)) {
1276
- maxOutputTokens = Math.min(tb + 8192, modelSpec.maxOutputTokens);
1277
- compatLogger.debug(`Adjusted Gemini maxOutputTokens → ${maxOutputTokens}`);
1278
- }
1279
- } else if (input.reasoning_effort) {
1280
- // Non-thinking models with explicit reasoning_effort hint
1281
- const budgets: Record<string, number> = { low: Math.round(modelSpec.thinkingBudget / 4), medium: Math.round(modelSpec.thinkingBudget / 2), high: modelSpec.thinkingBudget };
1282
- const b = budgets[input.reasoning_effort.toLowerCase()];
1283
- if (b) thinkingConfigObj = { includeThoughts: true, thinkingBudget: b };
1284
- }
1285
-
1286
- const generationConfig: Record<string, unknown> = {
1287
- ...(typeof input.temperature === "number" ? { temperature: input.temperature } : {}),
1288
- ...(maxOutputTokens ? { maxOutputTokens } : {}),
1289
- ...(thinkingConfigObj ? { thinkingConfig: thinkingConfigObj } : {}),
1290
- };
1291
-
1292
- const request: Record<string, unknown> = {
1293
- contents,
1294
- generationConfig,
1295
- };
1296
-
1297
- if (systemParts.length > 0) {
1298
- if (!isClaude && isThinking) {
1299
- // Gemini thinking models (gemini-3.1-pro-high/low) reject the systemInstruction
1300
- // field entirely — prepend system prompt to the first user content turn instead.
1301
- const firstTurn = contents[0];
1302
- if (firstTurn && firstTurn.role === "user" && (firstTurn.parts[0] as any)?.text !== undefined) {
1303
- (firstTurn.parts[0] as any).text = systemParts.join("\n\n") + "\n\n" + (firstTurn.parts[0] as any).text;
1304
- } else if (firstTurn && firstTurn.role === "user") {
1305
- firstTurn.parts.unshift({ text: systemParts.join("\n\n") + "\n\n" });
1306
- } else {
1307
- contents.unshift({
1308
- role: "user",
1309
- parts: [{ text: systemParts.join("\n\n") }],
1310
- });
1311
- }
1312
- } else {
1313
- request.systemInstruction = {
1314
- role: "system",
1315
- parts: [{ text: systemParts.join("\n\n") }],
1316
- };
1317
- }
1318
- }
1319
-
1320
- if (geminiTools.length > 0) request.tools = geminiTools;
1321
- if (geminiToolConfig) request.toolConfig = geminiToolConfig;
1322
-
1323
- let mappedModel = input.model;
1324
- if (mappedModel === "gemini-3.1-pro-high") mappedModel = "gemini-pro-agent";
1325
- if (mappedModel === "gemini-3.5-flash-high" || mappedModel === "gemini-3.5-flash" || mappedModel === "gemini-3.5-flash-medium") mappedModel = "gemini-3-flash-agent";
1326
- if (mappedModel === "gpt-oss-120b") mappedModel = "gpt-oss-120b-medium";
1327
-
1328
- return {
1329
- project: "compat-placeholder",
1330
- model: mappedModel,
1331
- displayModel: input.model,
1332
- userAgent: "antigravity",
1333
- requestType: "agent",
1334
- request,
1335
- };
1336
- }
1337
-
1338
- /** Convert Anthropic tools [{name, description, input_schema}] → OpenAI format [{type:"function", function:{name, description, parameters}}] */
1339
- function convertAnthropicToolsToOpenAI(tools: unknown): OpenAITool[] | undefined {
1340
- if (!Array.isArray(tools) || tools.length === 0) return undefined;
1341
- const result: OpenAITool[] = [];
1342
- for (const t of tools) {
1343
- if (!isRecord(t) || !isNonEmptyString(t.name)) continue;
1344
- result.push({
1345
- type: "function",
1346
- function: {
1347
- name: t.name as string,
1348
- ...(typeof t.description === "string" ? { description: t.description } : {}),
1349
- ...(isRecord(t.input_schema) ? { parameters: t.input_schema as Record<string, unknown> } : {}),
1350
- },
1351
- });
1352
- }
1353
- return result.length > 0 ? result : undefined;
1354
- }
1355
-
1356
- /** Convert Anthropic tool_choice → OpenAI tool_choice */
1357
- function convertAnthropicToolChoice(toolChoice: unknown): unknown {
1358
- if (!isRecord(toolChoice)) return toolChoice;
1359
- if (toolChoice.type === "auto") return "auto";
1360
- if (toolChoice.type === "any") return "required";
1361
- if (toolChoice.type === "tool" && isNonEmptyString(toolChoice.name)) {
1362
- return { type: "function", function: { name: toolChoice.name } };
1363
- }
1364
- return "auto";
1365
- }
92
+ const compatLogger = logger.child("compat");
1366
93
 
1367
- /**
1368
- * Convert Anthropic-format messages (tool_use / tool_result content blocks)
1369
- * to OpenAI-format messages (tool_calls array / role:"tool" messages).
1370
- */
1371
- function convertAnthropicMessagesToOpenAI(messages: ChatMessage[]): ChatMessage[] {
1372
- const result: ChatMessage[] = [];
1373
- for (const msg of messages) {
1374
- // Assistant messages with tool_use content blocks → tool_calls
1375
- if (msg.role === "assistant" && Array.isArray(msg.content)) {
1376
- const blocks = msg.content as Array<Record<string, unknown>>;
1377
- const toolUseBlocks = blocks.filter(
1378
- (b) => isRecord(b) && b.type === "tool_use" && isNonEmptyString(b.name),
1379
- );
1380
- if (toolUseBlocks.length > 0) {
1381
- const textParts = blocks
1382
- .filter((b) => isRecord(b) && b.type === "text" && typeof b.text === "string")
1383
- .map((b) => b.text as string)
1384
- .join("");
1385
- const toolCalls: OpenAIToolCall[] = toolUseBlocks.map((b) => ({
1386
- id: (b.id as string) || `call_${Date.now().toString(36)}`,
1387
- type: "function" as const,
1388
- function: {
1389
- name: b.name as string,
1390
- arguments: typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? {}),
1391
- },
1392
- }));
1393
- result.push({ role: "assistant", content: textParts || null, tool_calls: toolCalls });
1394
- continue;
1395
- }
1396
- }
1397
- // User messages with tool_result content blocks → role:"tool" messages
1398
- if (msg.role === "user" && Array.isArray(msg.content)) {
1399
- const blocks = msg.content as Array<Record<string, unknown>>;
1400
- const toolResults = blocks.filter((b) => isRecord(b) && b.type === "tool_result");
1401
- if (toolResults.length > 0) {
1402
- const otherBlocks = blocks.filter((b) => !isRecord(b) || b.type !== "tool_result");
1403
- if (otherBlocks.length > 0) {
1404
- result.push({ role: "user", content: otherBlocks as ChatMessage["content"] });
1405
- }
1406
- for (const tr of toolResults) {
1407
- const content = typeof tr.content === "string"
1408
- ? tr.content
1409
- : Array.isArray(tr.content)
1410
- ? extractTextFromUnknownContent(tr.content)
1411
- : JSON.stringify(tr.content ?? "");
1412
- result.push({
1413
- role: "tool",
1414
- content,
1415
- tool_call_id: tr.tool_use_id as string,
1416
- });
1417
- }
1418
- continue;
1419
- }
1420
- }
1421
- result.push(msg);
1422
- }
1423
- return result;
1424
- }
94
+ const VALIDATION_LOG_MAX_CHARS = 200;
1425
95
 
1426
- export function anthropicToAntigravityBody(input: AnthropicMessagesRequest): RequestBody {
1427
- const systemText = typeof input.system === "string" ? input.system : Array.isArray(input.system) ? extractText(input.system as ChatMessage["content"]) : "";
1428
- const tools = convertAnthropicToolsToOpenAI(input.tools);
1429
- const toolChoice = convertAnthropicToolChoice(input.tool_choice);
1430
- const convertedMessages = convertAnthropicMessagesToOpenAI(input.messages);
1431
- return openAIToAntigravityBody({
1432
- model: input.model,
1433
- stream: input.stream,
1434
- temperature: input.temperature,
1435
- max_tokens: input.max_tokens,
1436
- tools,
1437
- tool_choice: toolChoice,
1438
- messages: [
1439
- ...(systemText ? [{ role: "system" as const, content: systemText }] : []),
1440
- ...convertedMessages,
1441
- ],
1442
- });
96
+ export function logValidationFailure(scope: string, payload: unknown): void {
97
+ const truncated = redactSensitive(JSON.stringify(payload));
98
+ const clipped =
99
+ truncated.length > VALIDATION_LOG_MAX_CHARS
100
+ ? `${truncated.slice(0, VALIDATION_LOG_MAX_CHARS)}…[+${truncated.length - VALIDATION_LOG_MAX_CHARS} chars]`
101
+ : truncated;
102
+ compatLogger.warn(`${scope}: ${clipped}`);
1443
103
  }
1444
104
 
1445
- /**
1446
- * Maps an OpenAI reasoning_effort / model name suffix to a Gemini thinkingBudget integer.
1447
- * Cloud Code Assist uses thinkingBudget (integer token count), not thinkingLevel (string).
1448
- * Values match models.json: -high=10001, -low=1001, flash=dynamic(-1 means dynamic).
1449
- * Returns undefined for models that don't need an explicit budget (e.g. Claude, plain flash).
1450
- */
1451
- function mapReasoningEffortToThinkingLevel(effort: string | undefined, modelId: string): number | undefined {
1452
- const lowerModel = modelId.toLowerCase();
1453
- const isGemini31Pro = /gemini-3\.1-pro/i.test(modelId);
1454
- const isGemini3Flash = lowerModel.includes("gemini-3-flash") || lowerModel.includes("gemini-3.5-flash");
1455
-
1456
- let effectiveEffort = effort;
1457
- if (!effectiveEffort) {
1458
- if (lowerModel.endsWith("-high") || lowerModel.includes("gemini-pro-agent")) effectiveEffort = "high";
1459
- else if (lowerModel.endsWith("-low")) effectiveEffort = "low";
1460
- else if (isGemini3Flash) effectiveEffort = "high";
1461
- // Claude models: skip — thinking is handled by the anthropic-beta header
1462
- }
105
+ // Interfaces and types have been moved to src/compat/translators.ts
1463
106
 
1464
- if (!effectiveEffort) return undefined;
107
+ // Response Output types
1465
108
 
1466
- // Gemini 3.1 Pro uses fixed budgets matching models.json
1467
- if (isGemini31Pro) {
1468
- switch (effectiveEffort.toLowerCase()) {
1469
- case "high": return 10001;
1470
- case "medium": return 5000;
1471
- case "low": return 1001;
1472
- default: return undefined;
1473
- }
1474
- }
109
+ // Cache and stores have been moved to src/compat/cache.ts
1475
110
 
1476
- // Flash uses dynamic budget (-1 means let the model decide)
1477
- if (isGemini3Flash) {
1478
- switch (effectiveEffort.toLowerCase()) {
1479
- case "high": return -1;
1480
- case "medium": return 4096;
1481
- case "low": return 1024;
1482
- default: return undefined;
1483
- }
1484
- }
1485
-
1486
- return undefined;
1487
- }
111
+ // Helper and translation functions have been moved to src/compat/translators.ts
1488
112
 
1489
113
  export function parseAntigravitySse(raw: string): CompatCompletion {
1490
- let text = "";
1491
- let thinkingText = "";
1492
- let inputTokens = 0;
1493
- let outputTokens = 0;
1494
- let responseId: string | undefined;
1495
- const toolCallsMap = new Map<string, OpenAIToolCall>();
1496
- let toolCallIndex = 0;
1497
-
1498
- for (const line of raw.split(/\r?\n/)) {
1499
- if (!line.startsWith("data:")) continue;
1500
- const payload = line.slice(5).trim();
1501
- if (!payload || payload === "[DONE]") continue;
1502
- try {
1503
- const parsed = JSON.parse(payload) as Record<string, unknown>;
1504
- const response = isRecord(parsed.response) ? parsed.response : parsed;
1505
- if (!responseId && typeof response.responseId === "string") responseId = response.responseId;
1506
- const candidates = Array.isArray(response.candidates) ? response.candidates : [];
1507
- for (const candidate of candidates) {
1508
- if (!isRecord(candidate) || !isRecord(candidate.content) || !Array.isArray(candidate.content.parts)) continue;
1509
- for (const part of candidate.content.parts) {
1510
- if (!isRecord(part)) continue;
1511
- if (typeof part.text === "string") {
1512
- // Route thought blocks separately from normal text
1513
- if (part.thought === true) {
1514
- thinkingText += part.text;
1515
- } else {
1516
- text += part.text;
1517
- }
1518
- } else if (isRecord(part.functionCall)) {
1519
- // Gemini functionCall OpenAI tool_call
1520
- const fc = part.functionCall;
1521
- const name = typeof fc.name === "string" ? fc.name : "unknown";
1522
- const args = fc.args !== undefined ? JSON.stringify(fc.args) : "{}";
1523
- const callId = `call_${Date.now().toString(36)}_${toolCallIndex++}`;
1524
- // Cache thought_signature so we can re-inject it on the next turn
1525
- if (typeof part.thoughtSignature === "string" && part.thoughtSignature) {
1526
- cacheThoughtSignature(callId, part.thoughtSignature);
1527
- }
1528
- toolCallsMap.set(name + callId, { id: callId, type: "function", function: { name, arguments: args } });
1529
- }
1530
- }
1531
- }
1532
- const usage = isRecord(response.usageMetadata) ? response.usageMetadata : isRecord(response.usage) ? response.usage : null;
1533
- if (usage) {
1534
- if (typeof usage.promptTokenCount === "number") inputTokens = usage.promptTokenCount;
1535
- if (typeof usage.candidatesTokenCount === "number") outputTokens = usage.candidatesTokenCount;
1536
- if (typeof usage.input_tokens === "number") inputTokens = usage.input_tokens;
1537
- if (typeof usage.output_tokens === "number") outputTokens = usage.output_tokens;
1538
- }
1539
- } catch {
1540
- // Ignore malformed SSE lines from upstream; other chunks may still be valid.
1541
- }
1542
- }
1543
-
1544
- let parsedText = text;
1545
-
1546
- // Intercept legacy hallucinated format: [Tool call: name(args)]
1547
- const legacyRegex = /\[Tool call:\s*([a-zA-Z0-9_-]+)\(([\s\S]*?)\)\]/g;
1548
- let match;
1549
- while ((match = legacyRegex.exec(parsedText)) !== null) {
1550
- const name = match[1];
1551
- const args = match[2].trim();
1552
- const callId = `call_${Date.now().toString(36)}_${toolCallIndex++}`;
1553
- toolCallsMap.set(name + callId, { id: callId, type: "function", function: { name, arguments: args } });
1554
- }
1555
- parsedText = parsedText.replace(legacyRegex, "");
1556
-
1557
- // Intercept new hallucinated XML format: <tool_call name="name">args</tool_call>
1558
- const xmlRegex = /<tool_call name="([^"]+)">([\s\S]*?)<\/tool_call>/g;
1559
- while ((match = xmlRegex.exec(parsedText)) !== null) {
1560
- const name = match[1];
1561
- const args = match[2].trim();
1562
- const callId = `call_${Date.now().toString(36)}_${toolCallIndex++}`;
1563
- toolCallsMap.set(name + callId, { id: callId, type: "function", function: { name, arguments: args } });
1564
- }
1565
- parsedText = parsedText.replace(xmlRegex, "");
1566
-
1567
- parsedText = parsedText.trim();
1568
-
1569
- const toolCalls = toolCallsMap.size > 0 ? [...toolCallsMap.values()] : undefined;
1570
- return { text: parsedText, thinkingText: thinkingText || undefined, inputTokens, outputTokens, responseId, toolCalls };
1571
- }
1572
-
1573
- function writeJson(res: ServerResponse, status: number, payload: unknown, headers: Record<string, string> = {}): void {
1574
- res.writeHead(status, { "Content-Type": "application/json", ...headers });
1575
- res.end(JSON.stringify(payload));
114
+ let text = "";
115
+ let thinkingText = "";
116
+ let inputTokens = 0;
117
+ let outputTokens = 0;
118
+ let responseId: string | undefined;
119
+ const toolCallsMap = new Map<string, OpenAIToolCall>();
120
+ let toolCallIndex = 0;
121
+
122
+ for (const line of raw.split(/\r?\n/)) {
123
+ if (!line.startsWith("data:")) continue;
124
+ const payload = line.slice(5).trim();
125
+ if (!payload || payload === "[DONE]") continue;
126
+ try {
127
+ const parsed = JSON.parse(payload) as Record<string, unknown>;
128
+ const response = isRecord(parsed.response) ? parsed.response : parsed;
129
+ if (!responseId && typeof response.responseId === "string")
130
+ responseId = response.responseId;
131
+ const candidates = Array.isArray(response.candidates)
132
+ ? response.candidates
133
+ : [];
134
+ for (const candidate of candidates) {
135
+ if (
136
+ !isRecord(candidate) ||
137
+ !isRecord(candidate.content) ||
138
+ !Array.isArray(candidate.content.parts)
139
+ )
140
+ continue;
141
+ for (const part of candidate.content.parts) {
142
+ if (!isRecord(part)) continue;
143
+ if (typeof part.text === "string") {
144
+ // Route thought blocks separately from normal text
145
+ if (part.thought === true) {
146
+ thinkingText += part.text;
147
+ } else {
148
+ text += part.text;
149
+ }
150
+ } else if (isRecord(part.functionCall)) {
151
+ // Gemini functionCall → OpenAI tool_call
152
+ const fc = part.functionCall;
153
+ const name = typeof fc.name === "string" ? fc.name : "unknown";
154
+ const args = fc.args !== undefined ? JSON.stringify(fc.args) : "{}";
155
+ const callId = `call_${Date.now().toString(36)}_${toolCallIndex++}`;
156
+ // Cache thought_signature so we can re-inject it on the next turn
157
+ if (
158
+ typeof part.thoughtSignature === "string" &&
159
+ part.thoughtSignature
160
+ ) {
161
+ cacheThoughtSignature(callId, part.thoughtSignature);
162
+ }
163
+ toolCallsMap.set(name + callId, {
164
+ id: callId,
165
+ type: "function",
166
+ function: { name, arguments: args },
167
+ });
168
+ }
169
+ }
170
+ }
171
+ const usage = isRecord(response.usageMetadata)
172
+ ? response.usageMetadata
173
+ : isRecord(response.usage)
174
+ ? response.usage
175
+ : null;
176
+ if (usage) {
177
+ if (typeof usage.promptTokenCount === "number")
178
+ inputTokens = usage.promptTokenCount;
179
+ if (typeof usage.candidatesTokenCount === "number")
180
+ outputTokens = usage.candidatesTokenCount;
181
+ if (typeof usage.input_tokens === "number")
182
+ inputTokens = usage.input_tokens;
183
+ if (typeof usage.output_tokens === "number")
184
+ outputTokens = usage.output_tokens;
185
+ }
186
+ } catch {
187
+ // Ignore malformed SSE lines from upstream; other chunks may still be valid.
188
+ }
189
+ }
190
+
191
+ let parsedText = text;
192
+
193
+ // Intercept legacy hallucinated format: [Tool call: name(args)]
194
+ const legacyRegex = /\[Tool call:\s*([a-zA-Z0-9_-]+)\(([\s\S]*?)\)\]/g;
195
+ let match;
196
+ while ((match = legacyRegex.exec(parsedText)) !== null) {
197
+ const name = match[1];
198
+ const args = match[2].trim();
199
+ const callId = `call_${Date.now().toString(36)}_${toolCallIndex++}`;
200
+ toolCallsMap.set(name + callId, {
201
+ id: callId,
202
+ type: "function",
203
+ function: { name, arguments: args },
204
+ });
205
+ }
206
+ parsedText = parsedText.replace(legacyRegex, "");
207
+
208
+ // Intercept new hallucinated XML format: <tool_call name="name">args</tool_call>
209
+ const xmlRegex = /<tool_call name="([^"]+)">([\s\S]*?)<\/tool_call>/g;
210
+ while ((match = xmlRegex.exec(parsedText)) !== null) {
211
+ const name = match[1];
212
+ const args = match[2].trim();
213
+ const callId = `call_${Date.now().toString(36)}_${toolCallIndex++}`;
214
+ toolCallsMap.set(name + callId, {
215
+ id: callId,
216
+ type: "function",
217
+ function: { name, arguments: args },
218
+ });
219
+ }
220
+ parsedText = parsedText.replace(xmlRegex, "");
221
+
222
+ parsedText = parsedText.trim();
223
+
224
+ const toolCalls =
225
+ toolCallsMap.size > 0 ? [...toolCallsMap.values()] : undefined;
226
+ return {
227
+ text: parsedText,
228
+ thinkingText: thinkingText || undefined,
229
+ inputTokens,
230
+ outputTokens,
231
+ responseId,
232
+ toolCalls,
233
+ };
234
+ }
235
+
236
+ function writeJson(
237
+ res: ServerResponse,
238
+ status: number,
239
+ payload: unknown,
240
+ headers: Record<string, string> = {},
241
+ ): void {
242
+ res.writeHead(status, { "Content-Type": "application/json", ...headers });
243
+ res.end(JSON.stringify(payload));
1576
244
  }
1577
245
 
1578
- function writeResponsesEvent(res: ServerResponse, payload: Record<string, unknown>): void {
1579
- res.write(`data: ${JSON.stringify(payload)}\n\n`);
246
+ function writeResponsesEvent(
247
+ res: ServerResponse,
248
+ payload: Record<string, unknown>,
249
+ ): void {
250
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
1580
251
  }
1581
252
 
1582
253
  function summarizeCompatRequest(body: RequestBody): string {
1583
- const request = isRecord(body.request) ? body.request : {};
1584
- const contents = Array.isArray(request.contents) ? request.contents : [];
1585
- const tools = Array.isArray(request.tools) ? request.tools.length : 0;
1586
- const systemInstruction = isRecord(request.systemInstruction) ? "yes" : "no";
1587
- return `model=${body.model} userAgent=${body.userAgent || "none"} turns=${contents.length} tools=${tools} systemInstruction=${systemInstruction}`;
254
+ const request = isRecord(body.request) ? body.request : {};
255
+ const contents = Array.isArray(request.contents) ? request.contents : [];
256
+ const tools = Array.isArray(request.tools) ? request.tools.length : 0;
257
+ const systemInstruction = isRecord(request.systemInstruction) ? "yes" : "no";
258
+ return `model=${body.model} userAgent=${body.userAgent || "none"} turns=${contents.length} tools=${tools} systemInstruction=${systemInstruction}`;
1588
259
  }
1589
260
 
1590
- function writeOpenAIStream(res: ServerResponse, model: string, completion: CompatCompletion): void {
1591
- const created = Math.floor(Date.now() / 1000);
1592
- const id = `chatcmpl-${Date.now().toString(36)}`;
1593
- res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" });
1594
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] })}\n\n`);
1595
- // Emit reasoning/thinking content first if present
1596
- if (completion.thinkingText) {
1597
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { reasoning_content: completion.thinkingText }, finish_reason: null }] })}\n\n`);
1598
- }
1599
- if (completion.toolCalls && completion.toolCalls.length > 0) {
1600
- // Emit tool_call deltas
1601
- for (let i = 0; i < completion.toolCalls.length; i++) {
1602
- const tc = completion.toolCalls[i];
1603
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { tool_calls: [{ index: i, id: tc.id, type: "function", function: { name: tc.function.name, arguments: tc.function.arguments } }] }, finish_reason: null }] })}\n\n`);
1604
- }
1605
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] })}\n\n`);
1606
- } else {
1607
- if (completion.text) {
1608
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: completion.text }, finish_reason: null }] })}\n\n`);
1609
- }
1610
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`);
1611
- }
1612
- // Emit usage chunk so agents (hermes, openwebui) can display token statistics
1613
- if (completion.inputTokens > 0 || completion.outputTokens > 0) {
1614
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [], usage: { prompt_tokens: completion.inputTokens, completion_tokens: completion.outputTokens, total_tokens: completion.inputTokens + completion.outputTokens } })}\n\n`);
1615
- }
1616
- res.write("data: [DONE]\n\n");
1617
- res.end();
1618
- }
1619
261
 
1620
- function writeAnthropicStream(res: ServerResponse, model: string, completion: CompatCompletion): void {
1621
- const id = `msg_${Date.now().toString(36)}`;
1622
- res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" });
1623
- res.write(`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: completion.inputTokens, output_tokens: 0 } } })}\n\n`);
1624
- let contentIndex = 0;
1625
- // Emit thinking block first if present (Anthropic format)
1626
- if (completion.thinkingText) {
1627
- res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: contentIndex, content_block: { type: "thinking", thinking: "" } })}\n\n`);
1628
- res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: contentIndex, delta: { type: "thinking_delta", thinking: completion.thinkingText } })}\n\n`);
1629
- res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: contentIndex })}\n\n`);
1630
- contentIndex++;
1631
- }
1632
- if (completion.text) {
1633
- res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: contentIndex, content_block: { type: "text", text: "" } })}\n\n`);
1634
- res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: contentIndex, delta: { type: "text_delta", text: completion.text } })}\n\n`);
1635
- res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: contentIndex })}\n\n`);
1636
- contentIndex++;
1637
- }
1638
- // Emit tool_use content blocks if present
1639
- let hasToolUse = false;
1640
- if (completion.toolCalls && completion.toolCalls.length > 0) {
1641
- hasToolUse = true;
1642
- for (const tc of completion.toolCalls) {
1643
- let parsedInput: unknown;
1644
- try { parsedInput = JSON.parse(tc.function.arguments || "{}"); } catch { parsedInput = {}; }
1645
- res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: contentIndex, content_block: { type: "tool_use", id: tc.id, name: tc.function.name, input: {} } })}\n\n`);
1646
- res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: contentIndex, delta: { type: "input_json_delta", partial_json: JSON.stringify(parsedInput) } })}\n\n`);
1647
- res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: contentIndex })}\n\n`);
1648
- contentIndex++;
1649
- }
1650
- }
1651
- const stopReason = hasToolUse ? "tool_use" : "end_turn";
1652
- // message_delta: include both input_tokens and output_tokens so hermes shows full context count
1653
- res.write(`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null }, usage: { input_tokens: completion.inputTokens, output_tokens: completion.outputTokens } })}\n\n`);
1654
- res.write(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`);
1655
- res.end();
1656
- }
1657
262
 
1658
263
  async function readJsonBody(req: IncomingMessage): Promise<unknown> {
1659
- try {
1660
- const body = await readLimitedBody(req);
1661
- return JSON.parse(body.toString("utf-8"));
1662
- } catch (err) {
1663
- if (err instanceof PayloadTooLargeError) throw err;
1664
- throw new Error("Invalid JSON body");
1665
- }
264
+ try {
265
+ const body = await readLimitedBody(req);
266
+ return JSON.parse(body.toString("utf-8"));
267
+ } catch (err) {
268
+ if (err instanceof PayloadTooLargeError) throw err;
269
+ throw new Error("Invalid JSON body", { cause: err });
270
+ }
1666
271
  }
1667
272
 
1668
273
  async function streamCompatSse(
1669
- body: unknown,
1670
- req: IncomingMessage,
1671
- res: ServerResponse,
1672
- model: string,
1673
- format: "openai" | "anthropic",
274
+ body: unknown,
275
+ req: IncomingMessage,
276
+ res: ServerResponse,
277
+ model: string,
278
+ format: "openai" | "anthropic",
1674
279
  ): Promise<CompatCompletion> {
1675
- const nodeStream = Readable.fromWeb(body as import("node:stream/web").ReadableStream);
1676
- let text = "";
1677
- let inputTokens = 0;
1678
- let outputTokens = 0;
1679
- let responseId: string | undefined;
1680
- let toolCallIndex = 0;
1681
-
1682
- const created = Math.floor(Date.now() / 1000);
1683
- const id = format === "openai" ? `chatcmpl-${Date.now().toString(36)}` : `msg_${Date.now().toString(36)}`;
1684
-
1685
- const openaiToolCalls: OpenAIToolCall[] = [];
1686
- let anthropicActiveBlockIndex = -1;
1687
- let anthropicActiveBlockType: "thinking" | "text" | null = null;
1688
- let anthropicHasToolUse = false;
1689
- const anthropicToolCalls: OpenAIToolCall[] = [];
1690
-
1691
- res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
1692
-
1693
- if (format === "openai") {
1694
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] })}\n\n`);
1695
- } else if (format === "anthropic") {
1696
- res.write(`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } })}\n\n`);
1697
- }
1698
-
1699
- let tailBuffer = "";
1700
- let reqClosed = false;
1701
- req.once("close", () => { reqClosed = true; });
1702
-
1703
- try {
1704
- for await (const chunk of nodeStream) {
1705
- if (reqClosed) {
1706
- nodeStream.destroy();
1707
- break;
1708
- }
1709
- const str = chunk.toString();
1710
- tailBuffer += str;
1711
- let newlineIdx;
1712
- while ((newlineIdx = tailBuffer.indexOf('\n')) >= 0) {
1713
- const line = tailBuffer.slice(0, newlineIdx).trim();
1714
- tailBuffer = tailBuffer.slice(newlineIdx + 1);
1715
-
1716
- if (!line.startsWith("data:")) continue;
1717
- const payload = line.slice(5).trim();
1718
- if (!payload || payload === "[DONE]") continue;
1719
-
1720
- try {
1721
- const parsed = JSON.parse(payload) as Record<string, unknown>;
1722
- const response = isRecord(parsed.response) ? parsed.response : parsed;
1723
- if (!responseId && typeof response.responseId === "string") responseId = response.responseId;
1724
-
1725
- const candidates = Array.isArray(response.candidates) ? response.candidates : [];
1726
- if (candidates.length > 0 && candidates[0]?.content?.parts) {
1727
- // DEBUG LOGGING to see what Google is actually sending for thinking
1728
- if (candidates[0].content.parts.some((p: any) => p.thought === true || p.text)) {
1729
- console.log(`[DEBUG] Received parts:`, JSON.stringify(candidates[0].content.parts));
1730
- }
1731
- }
1732
- for (const candidate of candidates) {
1733
- if (!isRecord(candidate) || !isRecord(candidate.content) || !Array.isArray(candidate.content.parts)) continue;
1734
- for (const part of candidate.content.parts) {
1735
- if (!isRecord(part)) continue;
1736
- if (typeof part.text === "string" && part.text) {
1737
- if (part.thought === true) {
1738
- // Thought block → reasoning_content (OpenAI) or thinking_delta (Anthropic)
1739
- if (format === "openai") {
1740
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { reasoning_content: part.text }, finish_reason: null }] })}\n\n`);
1741
- } else {
1742
- if (anthropicActiveBlockType !== "thinking") {
1743
- if (anthropicActiveBlockType === "text") {
1744
- res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`);
1745
- }
1746
- anthropicActiveBlockIndex = 0;
1747
- anthropicActiveBlockType = "thinking";
1748
- res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: anthropicActiveBlockIndex, content_block: { type: "thinking", thinking: "" } })}\n\n`);
1749
- }
1750
- res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: anthropicActiveBlockIndex, delta: { type: "thinking_delta", thinking: part.text } })}\n\n`);
1751
- }
1752
- } else {
1753
- text += part.text;
1754
- if (format === "openai") {
1755
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: part.text }, finish_reason: null }] })}\n\n`);
1756
- } else {
1757
- if (anthropicActiveBlockType !== "text") {
1758
- if (anthropicActiveBlockType === "thinking") {
1759
- res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`);
1760
- anthropicActiveBlockIndex = 1;
1761
- } else {
1762
- anthropicActiveBlockIndex = 0;
1763
- }
1764
- anthropicActiveBlockType = "text";
1765
- res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: anthropicActiveBlockIndex, content_block: { type: "text", text: "" } })}\n\n`);
1766
- }
1767
- res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: anthropicActiveBlockIndex, delta: { type: "text_delta", text: part.text } })}\n\n`);
1768
- }
1769
- }
1770
- } else if (isRecord(part.functionCall)) {
1771
- const fc = part.functionCall;
1772
- const name = typeof fc.name === "string" ? fc.name : "unknown";
1773
- const args = fc.args !== undefined ? JSON.stringify(fc.args) : "{}";
1774
- const callId = `call_${Date.now().toString(36)}_${toolCallIndex++}`;
1775
- // Cache thought_signature so we can re-inject it on the next turn
1776
- if (typeof part.thoughtSignature === "string" && part.thoughtSignature) {
1777
- cacheThoughtSignature(callId, part.thoughtSignature);
1778
- }
1779
- if (format === "openai") {
1780
- openaiToolCalls.push({ id: callId, type: "function", function: { name, arguments: args } });
1781
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { tool_calls: [{ index: toolCallIndex - 1, id: callId, type: "function", function: { name, arguments: args } }] }, finish_reason: null }] })}\n\n`);
1782
- } else {
1783
- // Close any active text/thinking block before emitting tool_use
1784
- if (anthropicActiveBlockType !== null) {
1785
- res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`);
1786
- anthropicActiveBlockType = null;
1787
- }
1788
- anthropicActiveBlockIndex++;
1789
- anthropicHasToolUse = true;
1790
- anthropicToolCalls.push({ id: callId, type: "function", function: { name, arguments: args } });
1791
- let parsedInput: unknown;
1792
- try { parsedInput = JSON.parse(args); } catch { parsedInput = {}; }
1793
- res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: anthropicActiveBlockIndex, content_block: { type: "tool_use", id: callId, name, input: {} } })}\n\n`);
1794
- res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: anthropicActiveBlockIndex, delta: { type: "input_json_delta", partial_json: JSON.stringify(parsedInput) } })}\n\n`);
1795
- res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`);
1796
- }
1797
- }
1798
- }
1799
- }
1800
- const usage = isRecord(response.usageMetadata) ? response.usageMetadata : isRecord(response.usage) ? response.usage : null;
1801
- if (usage) {
1802
- if (typeof usage.promptTokenCount === "number") inputTokens = usage.promptTokenCount;
1803
- if (typeof usage.candidatesTokenCount === "number") outputTokens = usage.candidatesTokenCount;
1804
- if (typeof usage.input_tokens === "number") inputTokens = usage.input_tokens;
1805
- if (typeof usage.output_tokens === "number") outputTokens = usage.output_tokens;
1806
- }
1807
- } catch {
1808
- // Ignore malformed JSON chunks
1809
- }
1810
- }
1811
- }
1812
- } catch (err) {
1813
- compatLogger.warn(`Stream read error: ${err}`);
1814
- }
1815
-
1816
- if (!reqClosed && !res.writableEnded) {
1817
- if (format === "openai") {
1818
- const openaiFinishReason = toolCallIndex > 0 ? "tool_calls" : "stop";
1819
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: openaiFinishReason }] })}\n\n`);
1820
- // Emit a usage chunk so agents (hermes, openwebui, etc.) can display token statistics
1821
- if (inputTokens > 0 || outputTokens > 0) {
1822
- res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [], usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens } })}\n\n`);
1823
- }
1824
- res.write("data: [DONE]\n\n");
1825
- } else {
1826
- if (anthropicActiveBlockType !== null) {
1827
- res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`);
1828
- }
1829
- const anthropicStopReason = anthropicHasToolUse ? "tool_use" : "end_turn";
1830
- // message_delta carries output_tokens; also include input_tokens so Hermes shows full context count
1831
- res.write(`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: anthropicStopReason, stop_sequence: null }, usage: { input_tokens: inputTokens, output_tokens: outputTokens } })}\n\n`);
1832
- res.write(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`);
1833
- }
1834
- res.end();
1835
- }
1836
-
1837
- const collectedToolCalls = openaiToolCalls.length > 0 ? openaiToolCalls : anthropicToolCalls.length > 0 ? anthropicToolCalls : undefined;
1838
- return { text, inputTokens, outputTokens, responseId, toolCalls: collectedToolCalls };
280
+ const nodeStream = Readable.fromWeb(
281
+ body as import("node:stream/web").ReadableStream,
282
+ );
283
+ let text = "";
284
+ let inputTokens = 0;
285
+ let outputTokens = 0;
286
+ let responseId: string | undefined;
287
+ let toolCallIndex = 0;
288
+
289
+ const created = Math.floor(Date.now() / 1000);
290
+ const id =
291
+ format === "openai"
292
+ ? `chatcmpl-${Date.now().toString(36)}`
293
+ : `msg_${Date.now().toString(36)}`;
294
+
295
+ const openaiToolCalls: OpenAIToolCall[] = [];
296
+ let anthropicActiveBlockIndex = -1;
297
+ let anthropicActiveBlockType: "thinking" | "text" | null = null;
298
+ let anthropicHasToolUse = false;
299
+ const anthropicToolCalls: OpenAIToolCall[] = [];
300
+
301
+ res.writeHead(200, {
302
+ "Content-Type": "text/event-stream",
303
+ "Cache-Control": "no-cache",
304
+ Connection: "keep-alive",
305
+ "X-Accel-Buffering": "no",
306
+ });
307
+
308
+ if (format === "openai") {
309
+ res.write(
310
+ `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] })}\n\n`,
311
+ );
312
+ } else if (format === "anthropic") {
313
+ res.write(
314
+ `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } })}\n\n`,
315
+ );
316
+ }
317
+
318
+ let tailBuffer = "";
319
+ let reqClosed = false;
320
+ req.once("close", () => {
321
+ reqClosed = true;
322
+ });
323
+
324
+ try {
325
+ for await (const chunk of nodeStream) {
326
+ if (reqClosed) {
327
+ nodeStream.destroy();
328
+ break;
329
+ }
330
+ const str = chunk.toString();
331
+ tailBuffer += str;
332
+ let newlineIdx;
333
+ while ((newlineIdx = tailBuffer.indexOf("\n")) >= 0) {
334
+ const line = tailBuffer.slice(0, newlineIdx).trim();
335
+ tailBuffer = tailBuffer.slice(newlineIdx + 1);
336
+
337
+ if (!line.startsWith("data:")) continue;
338
+ const payload = line.slice(5).trim();
339
+ if (!payload || payload === "[DONE]") continue;
340
+
341
+ try {
342
+ const parsed = JSON.parse(payload) as Record<string, unknown>;
343
+ const response = isRecord(parsed.response) ? parsed.response : parsed;
344
+ if (!responseId && typeof response.responseId === "string")
345
+ responseId = response.responseId;
346
+
347
+ const candidates = Array.isArray(response.candidates)
348
+ ? response.candidates
349
+ : [];
350
+ if (candidates.length > 0 && candidates[0]?.content?.parts) {
351
+ // DEBUG LOGGING to see what Google is actually sending for thinking
352
+ if (
353
+ candidates[0].content.parts.some(
354
+ (p: any) => p.thought === true || p.text,
355
+ )
356
+ ) {
357
+ console.log(
358
+ `[DEBUG] Received parts:`,
359
+ JSON.stringify(candidates[0].content.parts),
360
+ );
361
+ }
362
+ }
363
+ for (const candidate of candidates) {
364
+ if (
365
+ !isRecord(candidate) ||
366
+ !isRecord(candidate.content) ||
367
+ !Array.isArray(candidate.content.parts)
368
+ )
369
+ continue;
370
+ for (const part of candidate.content.parts) {
371
+ if (!isRecord(part)) continue;
372
+ if (typeof part.text === "string" && part.text) {
373
+ if (part.thought === true) {
374
+ // Thought block → reasoning_content (OpenAI) or thinking_delta (Anthropic)
375
+ if (format === "openai") {
376
+ res.write(
377
+ `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { reasoning_content: part.text }, finish_reason: null }] })}\n\n`,
378
+ );
379
+ } else {
380
+ if (anthropicActiveBlockType !== "thinking") {
381
+ if (anthropicActiveBlockType === "text") {
382
+ res.write(
383
+ `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`,
384
+ );
385
+ }
386
+ anthropicActiveBlockIndex = 0;
387
+ anthropicActiveBlockType = "thinking";
388
+ res.write(
389
+ `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: anthropicActiveBlockIndex, content_block: { type: "thinking", thinking: "" } })}\n\n`,
390
+ );
391
+ }
392
+ res.write(
393
+ `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: anthropicActiveBlockIndex, delta: { type: "thinking_delta", thinking: part.text } })}\n\n`,
394
+ );
395
+ }
396
+ } else {
397
+ text += part.text;
398
+ if (format === "openai") {
399
+ res.write(
400
+ `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: part.text }, finish_reason: null }] })}\n\n`,
401
+ );
402
+ } else {
403
+ if (anthropicActiveBlockType !== "text") {
404
+ if (anthropicActiveBlockType === "thinking") {
405
+ res.write(
406
+ `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`,
407
+ );
408
+ anthropicActiveBlockIndex = 1;
409
+ } else {
410
+ anthropicActiveBlockIndex = 0;
411
+ }
412
+ anthropicActiveBlockType = "text";
413
+ res.write(
414
+ `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: anthropicActiveBlockIndex, content_block: { type: "text", text: "" } })}\n\n`,
415
+ );
416
+ }
417
+ res.write(
418
+ `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: anthropicActiveBlockIndex, delta: { type: "text_delta", text: part.text } })}\n\n`,
419
+ );
420
+ }
421
+ }
422
+ } else if (isRecord(part.functionCall)) {
423
+ const fc = part.functionCall;
424
+ const name = typeof fc.name === "string" ? fc.name : "unknown";
425
+ const args =
426
+ fc.args !== undefined ? JSON.stringify(fc.args) : "{}";
427
+ const callId = `call_${Date.now().toString(36)}_${toolCallIndex++}`;
428
+ // Cache thought_signature so we can re-inject it on the next turn
429
+ if (
430
+ typeof part.thoughtSignature === "string" &&
431
+ part.thoughtSignature
432
+ ) {
433
+ cacheThoughtSignature(callId, part.thoughtSignature);
434
+ }
435
+ if (format === "openai") {
436
+ openaiToolCalls.push({
437
+ id: callId,
438
+ type: "function",
439
+ function: { name, arguments: args },
440
+ });
441
+ res.write(
442
+ `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { tool_calls: [{ index: toolCallIndex - 1, id: callId, type: "function", function: { name, arguments: args } }] }, finish_reason: null }] })}\n\n`,
443
+ );
444
+ } else {
445
+ // Close any active text/thinking block before emitting tool_use
446
+ if (anthropicActiveBlockType !== null) {
447
+ res.write(
448
+ `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`,
449
+ );
450
+ anthropicActiveBlockType = null;
451
+ }
452
+ anthropicActiveBlockIndex++;
453
+ anthropicHasToolUse = true;
454
+ anthropicToolCalls.push({
455
+ id: callId,
456
+ type: "function",
457
+ function: { name, arguments: args },
458
+ });
459
+ let parsedInput: unknown;
460
+ try {
461
+ parsedInput = JSON.parse(args);
462
+ } catch {
463
+ parsedInput = {};
464
+ }
465
+ res.write(
466
+ `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: anthropicActiveBlockIndex, content_block: { type: "tool_use", id: callId, name, input: {} } })}\n\n`,
467
+ );
468
+ res.write(
469
+ `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: anthropicActiveBlockIndex, delta: { type: "input_json_delta", partial_json: JSON.stringify(parsedInput) } })}\n\n`,
470
+ );
471
+ res.write(
472
+ `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`,
473
+ );
474
+ }
475
+ }
476
+ }
477
+ }
478
+ const usage = isRecord(response.usageMetadata)
479
+ ? response.usageMetadata
480
+ : isRecord(response.usage)
481
+ ? response.usage
482
+ : null;
483
+ if (usage) {
484
+ if (typeof usage.promptTokenCount === "number")
485
+ inputTokens = usage.promptTokenCount;
486
+ if (typeof usage.candidatesTokenCount === "number")
487
+ outputTokens = usage.candidatesTokenCount;
488
+ if (typeof usage.input_tokens === "number")
489
+ inputTokens = usage.input_tokens;
490
+ if (typeof usage.output_tokens === "number")
491
+ outputTokens = usage.output_tokens;
492
+ }
493
+ } catch {
494
+ // Ignore malformed JSON chunks
495
+ }
496
+ }
497
+ }
498
+ } catch (err) {
499
+ compatLogger.warn(
500
+ `Stream read error: ${redactSensitive(String(err)).slice(0, 200)}`,
501
+ );
502
+ }
503
+
504
+ if (!reqClosed && !res.writableEnded) {
505
+ if (format === "openai") {
506
+ const openaiFinishReason = toolCallIndex > 0 ? "tool_calls" : "stop";
507
+ res.write(
508
+ `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: openaiFinishReason }] })}\n\n`,
509
+ );
510
+ // Emit a usage chunk so agents (hermes, openwebui, etc.) can display token statistics
511
+ if (inputTokens > 0 || outputTokens > 0) {
512
+ res.write(
513
+ `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [], usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens } })}\n\n`,
514
+ );
515
+ }
516
+ res.write("data: [DONE]\n\n");
517
+ } else {
518
+ if (anthropicActiveBlockType !== null) {
519
+ res.write(
520
+ `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: anthropicActiveBlockIndex })}\n\n`,
521
+ );
522
+ }
523
+ const anthropicStopReason = anthropicHasToolUse ? "tool_use" : "end_turn";
524
+ // message_delta carries output_tokens; also include input_tokens so Hermes shows full context count
525
+ res.write(
526
+ `event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: anthropicStopReason, stop_sequence: null }, usage: { input_tokens: inputTokens, output_tokens: outputTokens } })}\n\n`,
527
+ );
528
+ res.write(
529
+ `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
530
+ );
531
+ }
532
+ res.end();
533
+ }
534
+
535
+ const collectedToolCalls =
536
+ openaiToolCalls.length > 0
537
+ ? openaiToolCalls
538
+ : anthropicToolCalls.length > 0
539
+ ? anthropicToolCalls
540
+ : undefined;
541
+ return {
542
+ text,
543
+ inputTokens,
544
+ outputTokens,
545
+ responseId,
546
+ toolCalls: collectedToolCalls,
547
+ };
1839
548
  }
1840
549
 
1841
550
  async function streamResponsesSse(
1842
- body: unknown,
1843
- req: IncomingMessage,
1844
- res: ServerResponse,
1845
- request: OpenAIResponsesRequest,
1846
- responseId: string,
1847
- previousResponseId: string | null,
1848
- createdAt: number,
551
+ body: unknown,
552
+ req: IncomingMessage,
553
+ res: ServerResponse,
554
+ request: OpenAIResponsesRequest,
555
+ responseId: string,
556
+ previousResponseId: string | null,
557
+ createdAt: number,
1849
558
  ): Promise<CompatCompletion> {
1850
- const nodeStream = Readable.fromWeb(body as import("node:stream/web").ReadableStream);
1851
- let text = "";
1852
- let thinkingText = "";
1853
- let inputTokens = 0;
1854
- let outputTokens = 0;
1855
- const toolCalls: OpenAIToolCall[] = [];
1856
- let toolCallIndex = 0;
1857
- let nextOutputIndex = 0;
1858
- let messageOutputIndex = -1;
1859
- let messageItemId = "";
1860
- let reasoningOutputIndex = -1;
1861
- let reasoningItemId = "";
1862
- let reasoningDone = false;
1863
- let reqClosed = false;
1864
- req.once("close", () => { reqClosed = true; });
1865
-
1866
- res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
1867
- const emptyCompletion: CompatCompletion = { text: "", thinkingText: undefined, inputTokens: 0, outputTokens: 0, toolCalls: [] };
1868
- writeResponsesEvent(res, { type: "response.created", response: buildResponsesResponse(request, responseId, createdAt, emptyCompletion, "in_progress", previousResponseId) });
1869
- writeResponsesEvent(res, { type: "response.in_progress", response: buildResponsesResponse(request, responseId, createdAt, emptyCompletion, "in_progress", previousResponseId) });
1870
-
1871
- let tailBuffer = "";
1872
- try {
1873
- for await (const chunk of nodeStream) {
1874
- if (reqClosed) {
1875
- nodeStream.destroy();
1876
- break;
1877
- }
1878
- tailBuffer += chunk.toString();
1879
- let newlineIdx;
1880
- while ((newlineIdx = tailBuffer.indexOf("\n")) >= 0) {
1881
- const line = tailBuffer.slice(0, newlineIdx).trim();
1882
- tailBuffer = tailBuffer.slice(newlineIdx + 1);
1883
- if (!line.startsWith("data:")) continue;
1884
- const payload = line.slice(5).trim();
1885
- if (!payload || payload === "[DONE]") continue;
1886
- try {
1887
- const parsed = JSON.parse(payload) as Record<string, unknown>;
1888
- const response = isRecord(parsed.response) ? parsed.response : parsed;
1889
- const candidates = Array.isArray(response.candidates) ? response.candidates : [];
1890
- for (const candidate of candidates) {
1891
- if (!isRecord(candidate) || !isRecord(candidate.content) || !Array.isArray(candidate.content.parts)) continue;
1892
- for (const part of candidate.content.parts) {
1893
- if (!isRecord(part)) continue;
1894
- if (typeof part.text === "string" && part.text) {
1895
- if (part.thought === true) {
1896
- // Stream reasoning content via Responses API reasoning events.
1897
- // First thought chunk: open the reasoning output item.
1898
- if (reasoningOutputIndex === -1) {
1899
- reasoningOutputIndex = nextOutputIndex++;
1900
- reasoningItemId = makeCompatId("rs");
1901
- writeResponsesEvent(res, {
1902
- type: "response.output_item.added",
1903
- output_index: reasoningOutputIndex,
1904
- item: { id: reasoningItemId, type: "reasoning", status: "in_progress", summary: [] },
1905
- });
1906
- }
1907
- writeResponsesEvent(res, {
1908
- type: "response.reasoning_summary_text.delta",
1909
- item_id: reasoningItemId,
1910
- output_index: reasoningOutputIndex,
1911
- summary_index: 0,
1912
- delta: part.text,
1913
- });
1914
- thinkingText += part.text;
1915
- continue;
1916
- }
1917
- // Non-thought text arriving: close reasoning item immediately so Codex
1918
- // sees a completed reasoning block before any content/tool items.
1919
- if (reasoningOutputIndex !== -1 && !reasoningDone) {
1920
- reasoningDone = true;
1921
- writeResponsesEvent(res, { type: "response.reasoning_summary_text.done", item_id: reasoningItemId, output_index: reasoningOutputIndex, summary_index: 0, text: thinkingText });
1922
- writeResponsesEvent(res, { type: "response.output_item.done", output_index: reasoningOutputIndex, item: { id: reasoningItemId, type: "reasoning", status: "completed", summary: [{ type: "summary_text", text: thinkingText }] } });
1923
- }
1924
- if (messageOutputIndex === -1) {
1925
- messageOutputIndex = nextOutputIndex++;
1926
- messageItemId = makeCompatId("msg");
1927
- writeResponsesEvent(res, {
1928
- type: "response.output_item.added",
1929
- output_index: messageOutputIndex,
1930
- item: {
1931
- id: messageItemId,
1932
- type: "message",
1933
- status: "completed",
1934
- role: "assistant",
1935
- content: [{ type: "output_text", text: "", annotations: [] }],
1936
- },
1937
- });
1938
- }
1939
- text += part.text;
1940
- writeResponsesEvent(res, {
1941
- type: "response.output_text.delta",
1942
- item_id: messageItemId,
1943
- output_index: messageOutputIndex,
1944
- content_index: 0,
1945
- delta: part.text,
1946
- });
1947
- } else if (isRecord(part.functionCall)) {
1948
- // functionCall arriving: close reasoning item immediately if still open
1949
- if (reasoningOutputIndex !== -1 && !reasoningDone) {
1950
- reasoningDone = true;
1951
- writeResponsesEvent(res, { type: "response.reasoning_summary_text.done", item_id: reasoningItemId, output_index: reasoningOutputIndex, summary_index: 0, text: thinkingText });
1952
- writeResponsesEvent(res, { type: "response.output_item.done", output_index: reasoningOutputIndex, item: { id: reasoningItemId, type: "reasoning", status: "completed", summary: [{ type: "summary_text", text: thinkingText }] } });
1953
- }
1954
- const fc = part.functionCall;
1955
- const name = typeof fc.name === "string" ? fc.name : "unknown";
1956
- const args = fc.args !== undefined ? JSON.stringify(fc.args) : "{}";
1957
- const callId = `call_${Date.now().toString(36)}_${toolCallIndex++}`;
1958
- if (typeof part.thoughtSignature === "string" && part.thoughtSignature) {
1959
- cacheThoughtSignature(callId, part.thoughtSignature);
1960
- }
1961
- toolCalls.push({ id: callId, type: "function", function: { name, arguments: args } });
1962
- const item = {
1963
- id: makeCompatId("fc"),
1964
- type: "function_call",
1965
- call_id: callId,
1966
- name,
1967
- arguments: args,
1968
- status: "completed",
1969
- };
1970
- const outputIndex = nextOutputIndex++;
1971
- writeResponsesEvent(res, { type: "response.output_item.added", output_index: outputIndex, item });
1972
- writeResponsesEvent(res, { type: "response.function_call_arguments.delta", item_id: item.id, output_index: outputIndex, delta: args });
1973
- writeResponsesEvent(res, { type: "response.function_call_arguments.done", item_id: item.id, output_index: outputIndex, arguments: args });
1974
- writeResponsesEvent(res, { type: "response.output_item.done", output_index: outputIndex, item });
1975
- }
1976
- }
1977
- }
1978
- const usage = isRecord(response.usageMetadata) ? response.usageMetadata : isRecord(response.usage) ? response.usage : null;
1979
- if (usage) {
1980
- if (typeof usage.promptTokenCount === "number") inputTokens = usage.promptTokenCount;
1981
- if (typeof usage.candidatesTokenCount === "number") outputTokens = usage.candidatesTokenCount;
1982
- if (typeof usage.input_tokens === "number") inputTokens = usage.input_tokens;
1983
- if (typeof usage.output_tokens === "number") outputTokens = usage.output_tokens;
1984
- }
1985
- } catch {
1986
- // Ignore malformed JSON chunks
1987
- }
1988
- }
1989
- }
1990
- } catch (err) {
1991
- compatLogger.warn(`Responses stream read error: ${err}`);
1992
- }
1993
-
1994
- const completion: CompatCompletion = {
1995
- text,
1996
- thinkingText: thinkingText || undefined,
1997
- inputTokens,
1998
- outputTokens,
1999
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
2000
- };
2001
- if (!reqClosed && !res.writableEnded) {
2002
- // Close reasoning item if it was never closed mid-stream
2003
- if (reasoningOutputIndex !== -1 && !reasoningDone) {
2004
- writeResponsesEvent(res, {
2005
- type: "response.reasoning_summary_text.done",
2006
- item_id: reasoningItemId,
2007
- output_index: reasoningOutputIndex,
2008
- summary_index: 0,
2009
- text: thinkingText,
2010
- });
2011
- writeResponsesEvent(res, {
2012
- type: "response.output_item.done",
2013
- output_index: reasoningOutputIndex,
2014
- item: {
2015
- id: reasoningItemId,
2016
- type: "reasoning",
2017
- status: "completed",
2018
- summary: [{ type: "summary_text", text: thinkingText }],
2019
- },
2020
- });
2021
- }
2022
- if (messageOutputIndex !== -1) {
2023
- writeResponsesEvent(res, {
2024
- type: "response.output_text.done",
2025
- item_id: messageItemId,
2026
- output_index: messageOutputIndex,
2027
- content_index: 0,
2028
- text,
2029
- });
2030
- writeResponsesEvent(res, {
2031
- type: "response.output_item.done",
2032
- output_index: messageOutputIndex,
2033
- item: {
2034
- id: messageItemId,
2035
- type: "message",
2036
- status: "completed",
2037
- role: "assistant",
2038
- content: [{ type: "output_text", text, annotations: [] }],
2039
- },
2040
- });
2041
- }
2042
- writeResponsesEvent(res, {
2043
- type: "response.completed",
2044
- response: buildResponsesResponse(request, responseId, createdAt, completion, "completed", previousResponseId),
2045
- });
2046
- res.end();
2047
- }
2048
- return completion;
559
+ const nodeStream = Readable.fromWeb(
560
+ body as import("node:stream/web").ReadableStream,
561
+ );
562
+ let text = "";
563
+ let thinkingText = "";
564
+ let inputTokens = 0;
565
+ let outputTokens = 0;
566
+ const toolCalls: OpenAIToolCall[] = [];
567
+ let toolCallIndex = 0;
568
+ let nextOutputIndex = 0;
569
+ let messageOutputIndex = -1;
570
+ let messageItemId = "";
571
+ let reasoningOutputIndex = -1;
572
+ let reasoningItemId = "";
573
+ let reasoningDone = false;
574
+ let reqClosed = false;
575
+ req.once("close", () => {
576
+ reqClosed = true;
577
+ });
578
+
579
+ res.writeHead(200, {
580
+ "Content-Type": "text/event-stream",
581
+ "Cache-Control": "no-cache",
582
+ Connection: "keep-alive",
583
+ "X-Accel-Buffering": "no",
584
+ });
585
+ const emptyCompletion: CompatCompletion = {
586
+ text: "",
587
+ thinkingText: undefined,
588
+ inputTokens: 0,
589
+ outputTokens: 0,
590
+ toolCalls: [],
591
+ };
592
+ writeResponsesEvent(res, {
593
+ type: "response.created",
594
+ response: buildResponsesResponse(
595
+ request,
596
+ responseId,
597
+ createdAt,
598
+ emptyCompletion,
599
+ "in_progress",
600
+ previousResponseId,
601
+ ),
602
+ });
603
+ writeResponsesEvent(res, {
604
+ type: "response.in_progress",
605
+ response: buildResponsesResponse(
606
+ request,
607
+ responseId,
608
+ createdAt,
609
+ emptyCompletion,
610
+ "in_progress",
611
+ previousResponseId,
612
+ ),
613
+ });
614
+
615
+ let tailBuffer = "";
616
+ try {
617
+ for await (const chunk of nodeStream) {
618
+ if (reqClosed) {
619
+ nodeStream.destroy();
620
+ break;
621
+ }
622
+ tailBuffer += chunk.toString();
623
+ let newlineIdx;
624
+ while ((newlineIdx = tailBuffer.indexOf("\n")) >= 0) {
625
+ const line = tailBuffer.slice(0, newlineIdx).trim();
626
+ tailBuffer = tailBuffer.slice(newlineIdx + 1);
627
+ if (!line.startsWith("data:")) continue;
628
+ const payload = line.slice(5).trim();
629
+ if (!payload || payload === "[DONE]") continue;
630
+ try {
631
+ const parsed = JSON.parse(payload) as Record<string, unknown>;
632
+ const response = isRecord(parsed.response) ? parsed.response : parsed;
633
+ const candidates = Array.isArray(response.candidates)
634
+ ? response.candidates
635
+ : [];
636
+ for (const candidate of candidates) {
637
+ if (
638
+ !isRecord(candidate) ||
639
+ !isRecord(candidate.content) ||
640
+ !Array.isArray(candidate.content.parts)
641
+ )
642
+ continue;
643
+ for (const part of candidate.content.parts) {
644
+ if (!isRecord(part)) continue;
645
+ if (typeof part.text === "string" && part.text) {
646
+ if (part.thought === true) {
647
+ // Stream reasoning content via Responses API reasoning events.
648
+ // First thought chunk: open the reasoning output item.
649
+ if (reasoningOutputIndex === -1) {
650
+ reasoningOutputIndex = nextOutputIndex++;
651
+ reasoningItemId = makeCompatId("rs");
652
+ writeResponsesEvent(res, {
653
+ type: "response.output_item.added",
654
+ output_index: reasoningOutputIndex,
655
+ item: {
656
+ id: reasoningItemId,
657
+ type: "reasoning",
658
+ status: "in_progress",
659
+ summary: [],
660
+ },
661
+ });
662
+ }
663
+ writeResponsesEvent(res, {
664
+ type: "response.reasoning_summary_text.delta",
665
+ item_id: reasoningItemId,
666
+ output_index: reasoningOutputIndex,
667
+ summary_index: 0,
668
+ delta: part.text,
669
+ });
670
+ thinkingText += part.text;
671
+ continue;
672
+ }
673
+ // Non-thought text arriving: close reasoning item immediately so Codex
674
+ // sees a completed reasoning block before any content/tool items.
675
+ if (reasoningOutputIndex !== -1 && !reasoningDone) {
676
+ reasoningDone = true;
677
+ writeResponsesEvent(res, {
678
+ type: "response.reasoning_summary_text.done",
679
+ item_id: reasoningItemId,
680
+ output_index: reasoningOutputIndex,
681
+ summary_index: 0,
682
+ text: thinkingText,
683
+ });
684
+ writeResponsesEvent(res, {
685
+ type: "response.output_item.done",
686
+ output_index: reasoningOutputIndex,
687
+ item: {
688
+ id: reasoningItemId,
689
+ type: "reasoning",
690
+ status: "completed",
691
+ summary: [{ type: "summary_text", text: thinkingText }],
692
+ },
693
+ });
694
+ }
695
+ if (messageOutputIndex === -1) {
696
+ messageOutputIndex = nextOutputIndex++;
697
+ messageItemId = makeCompatId("msg");
698
+ writeResponsesEvent(res, {
699
+ type: "response.output_item.added",
700
+ output_index: messageOutputIndex,
701
+ item: {
702
+ id: messageItemId,
703
+ type: "message",
704
+ status: "completed",
705
+ role: "assistant",
706
+ content: [
707
+ { type: "output_text", text: "", annotations: [] },
708
+ ],
709
+ },
710
+ });
711
+ }
712
+ text += part.text;
713
+ writeResponsesEvent(res, {
714
+ type: "response.output_text.delta",
715
+ item_id: messageItemId,
716
+ output_index: messageOutputIndex,
717
+ content_index: 0,
718
+ delta: part.text,
719
+ });
720
+ } else if (isRecord(part.functionCall)) {
721
+ // functionCall arriving: close reasoning item immediately if still open
722
+ if (reasoningOutputIndex !== -1 && !reasoningDone) {
723
+ reasoningDone = true;
724
+ writeResponsesEvent(res, {
725
+ type: "response.reasoning_summary_text.done",
726
+ item_id: reasoningItemId,
727
+ output_index: reasoningOutputIndex,
728
+ summary_index: 0,
729
+ text: thinkingText,
730
+ });
731
+ writeResponsesEvent(res, {
732
+ type: "response.output_item.done",
733
+ output_index: reasoningOutputIndex,
734
+ item: {
735
+ id: reasoningItemId,
736
+ type: "reasoning",
737
+ status: "completed",
738
+ summary: [{ type: "summary_text", text: thinkingText }],
739
+ },
740
+ });
741
+ }
742
+ const fc = part.functionCall;
743
+ const name = typeof fc.name === "string" ? fc.name : "unknown";
744
+ const args =
745
+ fc.args !== undefined ? JSON.stringify(fc.args) : "{}";
746
+ const callId = `call_${Date.now().toString(36)}_${toolCallIndex++}`;
747
+ if (
748
+ typeof part.thoughtSignature === "string" &&
749
+ part.thoughtSignature
750
+ ) {
751
+ cacheThoughtSignature(callId, part.thoughtSignature);
752
+ }
753
+ toolCalls.push({
754
+ id: callId,
755
+ type: "function",
756
+ function: { name, arguments: args },
757
+ });
758
+ const item = {
759
+ id: makeCompatId("fc"),
760
+ type: "function_call",
761
+ call_id: callId,
762
+ name,
763
+ arguments: args,
764
+ status: "completed",
765
+ };
766
+ const outputIndex = nextOutputIndex++;
767
+ writeResponsesEvent(res, {
768
+ type: "response.output_item.added",
769
+ output_index: outputIndex,
770
+ item,
771
+ });
772
+ writeResponsesEvent(res, {
773
+ type: "response.function_call_arguments.delta",
774
+ item_id: item.id,
775
+ output_index: outputIndex,
776
+ delta: args,
777
+ });
778
+ writeResponsesEvent(res, {
779
+ type: "response.function_call_arguments.done",
780
+ item_id: item.id,
781
+ output_index: outputIndex,
782
+ arguments: args,
783
+ });
784
+ writeResponsesEvent(res, {
785
+ type: "response.output_item.done",
786
+ output_index: outputIndex,
787
+ item,
788
+ });
789
+ }
790
+ }
791
+ }
792
+ const usage = isRecord(response.usageMetadata)
793
+ ? response.usageMetadata
794
+ : isRecord(response.usage)
795
+ ? response.usage
796
+ : null;
797
+ if (usage) {
798
+ if (typeof usage.promptTokenCount === "number")
799
+ inputTokens = usage.promptTokenCount;
800
+ if (typeof usage.candidatesTokenCount === "number")
801
+ outputTokens = usage.candidatesTokenCount;
802
+ if (typeof usage.input_tokens === "number")
803
+ inputTokens = usage.input_tokens;
804
+ if (typeof usage.output_tokens === "number")
805
+ outputTokens = usage.output_tokens;
806
+ }
807
+ } catch {
808
+ // Ignore malformed JSON chunks
809
+ }
810
+ }
811
+ }
812
+ } catch (err) {
813
+ compatLogger.warn(
814
+ `Responses stream read error: ${redactSensitive(String(err)).slice(0, 200)}`,
815
+ );
816
+ }
817
+
818
+ const completion: CompatCompletion = {
819
+ text,
820
+ thinkingText: thinkingText || undefined,
821
+ inputTokens,
822
+ outputTokens,
823
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
824
+ };
825
+ if (!reqClosed && !res.writableEnded) {
826
+ // Close reasoning item if it was never closed mid-stream
827
+ if (reasoningOutputIndex !== -1 && !reasoningDone) {
828
+ writeResponsesEvent(res, {
829
+ type: "response.reasoning_summary_text.done",
830
+ item_id: reasoningItemId,
831
+ output_index: reasoningOutputIndex,
832
+ summary_index: 0,
833
+ text: thinkingText,
834
+ });
835
+ writeResponsesEvent(res, {
836
+ type: "response.output_item.done",
837
+ output_index: reasoningOutputIndex,
838
+ item: {
839
+ id: reasoningItemId,
840
+ type: "reasoning",
841
+ status: "completed",
842
+ summary: [{ type: "summary_text", text: thinkingText }],
843
+ },
844
+ });
845
+ }
846
+ if (messageOutputIndex !== -1) {
847
+ writeResponsesEvent(res, {
848
+ type: "response.output_text.done",
849
+ item_id: messageItemId,
850
+ output_index: messageOutputIndex,
851
+ content_index: 0,
852
+ text,
853
+ });
854
+ writeResponsesEvent(res, {
855
+ type: "response.output_item.done",
856
+ output_index: messageOutputIndex,
857
+ item: {
858
+ id: messageItemId,
859
+ type: "message",
860
+ status: "completed",
861
+ role: "assistant",
862
+ content: [{ type: "output_text", text, annotations: [] }],
863
+ },
864
+ });
865
+ }
866
+ writeResponsesEvent(res, {
867
+ type: "response.completed",
868
+ response: buildResponsesResponse(
869
+ request,
870
+ responseId,
871
+ createdAt,
872
+ completion,
873
+ "completed",
874
+ previousResponseId,
875
+ ),
876
+ });
877
+ res.end();
878
+ }
879
+ return completion;
2049
880
  }
2050
881
 
2051
882
  async function completeResponsesViaRotator(
2052
- req: IncomingMessage,
2053
- res: ServerResponse,
2054
- rotator: AccountRotator,
2055
- request: OpenAIResponsesRequest,
2056
- body: RequestBody,
2057
- responseId: string,
2058
- previousResponseId: string | null,
2059
- ): Promise<{ completion: CompatCompletion; status: number; errorText?: string; streamed: boolean }> {
2060
- const createdAt = Math.floor(Date.now() / 1000);
2061
- const outcome = await withRotation(rotator, body.model, flattenHeaders(req.headers), body,
2062
- async (response) => {
2063
- const completion = await streamResponsesSse(response.body, req, res, request, responseId, previousResponseId, createdAt);
2064
- if (completion.inputTokens > 0 || completion.outputTokens > 0) {
2065
- rotator.recordTokenUsage(body.displayModel || body.model, completion.inputTokens, completion.outputTokens);
2066
- }
2067
- return completion;
2068
- },
2069
- );
2070
- if (!outcome.ok) {
2071
- return {
2072
- completion: { text: "", inputTokens: 0, outputTokens: 0 },
2073
- status: outcome.status,
2074
- errorText: outcome.retryAfterMs ? `${outcome.errorText}; retryAfterMs=${outcome.retryAfterMs}` : outcome.errorText,
2075
- streamed: false,
2076
- };
2077
- }
2078
- return { completion: outcome.result, status: 200, streamed: true };
883
+ req: IncomingMessage,
884
+ res: ServerResponse,
885
+ rotator: AccountRotator,
886
+ request: OpenAIResponsesRequest,
887
+ body: RequestBody,
888
+ responseId: string,
889
+ previousResponseId: string | null,
890
+ ): Promise<{
891
+ completion: CompatCompletion;
892
+ status: number;
893
+ errorText?: string;
894
+ streamed: boolean;
895
+ }> {
896
+ const createdAt = Math.floor(Date.now() / 1000);
897
+ const outcome = await withRotation(
898
+ rotator,
899
+ body.model,
900
+ flattenHeaders(req.headers),
901
+ body,
902
+ async (response) => {
903
+ const completion = await streamResponsesSse(
904
+ response.body,
905
+ req,
906
+ res,
907
+ request,
908
+ responseId,
909
+ previousResponseId,
910
+ createdAt,
911
+ );
912
+ if (completion.inputTokens > 0 || completion.outputTokens > 0) {
913
+ rotator.recordTokenUsage(
914
+ body.displayModel || body.model,
915
+ completion.inputTokens,
916
+ completion.outputTokens,
917
+ );
918
+ }
919
+ return completion;
920
+ },
921
+ );
922
+ if (!outcome.ok) {
923
+ return {
924
+ completion: { text: "", inputTokens: 0, outputTokens: 0 },
925
+ status: outcome.status,
926
+ errorText: outcome.retryAfterMs
927
+ ? `${outcome.errorText}; retryAfterMs=${outcome.retryAfterMs}`
928
+ : outcome.errorText,
929
+ streamed: false,
930
+ };
931
+ }
932
+ return { completion: outcome.result, status: 200, streamed: true };
2079
933
  }
2080
934
 
2081
935
  async function completeViaRotator(
2082
- req: IncomingMessage,
2083
- res: ServerResponse,
2084
- rotator: AccountRotator,
2085
- body: RequestBody,
2086
- streamMode: "none" | "openai" | "anthropic",
2087
- ): Promise<{ completion: CompatCompletion; status: number; errorText?: string; streamed: boolean }> {
2088
- const outcome = await withRotation(rotator, body.model, flattenHeaders(req.headers), body,
2089
- async (response) => {
2090
- if (streamMode === "none") {
2091
- const raw = await response.text();
2092
- const completion = parseAntigravitySse(raw);
2093
- if (completion.inputTokens > 0 || completion.outputTokens > 0) {
2094
- rotator.recordTokenUsage(body.displayModel || body.model, completion.inputTokens, completion.outputTokens);
2095
- }
2096
- return completion;
2097
- } else {
2098
- const completion = await streamCompatSse(response.body, req, res, body.displayModel || body.model, streamMode);
2099
- if (completion.inputTokens > 0 || completion.outputTokens > 0) {
2100
- rotator.recordTokenUsage(body.displayModel || body.model, completion.inputTokens, completion.outputTokens);
2101
- }
2102
- return completion;
2103
- }
2104
- },
2105
- );
2106
- if (!outcome.ok) {
2107
- if (outcome.status === 404) {
2108
- compatLogger.warn(
2109
- `Compat upstream 404 endpoint=${outcome.endpoint || "unknown"} ${summarizeCompatRequest(body)} error=${(outcome.errorText || "").slice(0, 300)}`,
2110
- );
2111
- }
2112
- return {
2113
- completion: { text: "", inputTokens: 0, outputTokens: 0 },
2114
- status: outcome.status,
2115
- errorText: outcome.retryAfterMs ? `${outcome.errorText}; retryAfterMs=${outcome.retryAfterMs}` : outcome.errorText,
2116
- streamed: false,
2117
- };
2118
- }
2119
- return { completion: outcome.result, status: 200, streamed: streamMode !== "none" };
936
+ req: IncomingMessage,
937
+ res: ServerResponse,
938
+ rotator: AccountRotator,
939
+ body: RequestBody,
940
+ streamMode: "none" | "openai" | "anthropic",
941
+ ): Promise<{
942
+ completion: CompatCompletion;
943
+ status: number;
944
+ errorText?: string;
945
+ streamed: boolean;
946
+ }> {
947
+ const outcome = await withRotation(
948
+ rotator,
949
+ body.model,
950
+ flattenHeaders(req.headers),
951
+ body,
952
+ async (response) => {
953
+ if (streamMode === "none") {
954
+ const raw = await response.text();
955
+ const completion = parseAntigravitySse(raw);
956
+ if (completion.inputTokens > 0 || completion.outputTokens > 0) {
957
+ rotator.recordTokenUsage(
958
+ body.displayModel || body.model,
959
+ completion.inputTokens,
960
+ completion.outputTokens,
961
+ );
962
+ }
963
+ return completion;
964
+ } else {
965
+ const completion = await streamCompatSse(
966
+ response.body,
967
+ req,
968
+ res,
969
+ body.displayModel || body.model,
970
+ streamMode,
971
+ );
972
+ if (completion.inputTokens > 0 || completion.outputTokens > 0) {
973
+ rotator.recordTokenUsage(
974
+ body.displayModel || body.model,
975
+ completion.inputTokens,
976
+ completion.outputTokens,
977
+ );
978
+ }
979
+ return completion;
980
+ }
981
+ },
982
+ );
983
+ if (!outcome.ok) {
984
+ if (outcome.status === 404) {
985
+ compatLogger.warn(
986
+ `Compat upstream 404 endpoint=${outcome.endpoint || "unknown"} ${summarizeCompatRequest(body)} error=${(outcome.errorText || "").slice(0, 300)}`,
987
+ );
988
+ }
989
+ return {
990
+ completion: { text: "", inputTokens: 0, outputTokens: 0 },
991
+ status: outcome.status,
992
+ errorText: outcome.retryAfterMs
993
+ ? `${outcome.errorText}; retryAfterMs=${outcome.retryAfterMs}`
994
+ : outcome.errorText,
995
+ streamed: false,
996
+ };
997
+ }
998
+ return {
999
+ completion: outcome.result,
1000
+ status: 200,
1001
+ streamed: streamMode !== "none",
1002
+ };
2120
1003
  }
2121
1004
 
2122
-
2123
1005
  const MODEL_CATALOG = [
2124
- { id: "gemini-3.5-flash-medium", family: "gemini-3.5-flash", ctx: 1048576, quotaPool: "gemini-3.5-flash", multimodal: true, tools: true },
2125
- { id: "gemini-3.5-flash-high", family: "gemini-3.5-flash", ctx: 1048576, quotaPool: "gemini-3.5-flash", multimodal: true, tools: true },
2126
- { id: "gemini-3-flash", family: "gemini-3.5-flash", ctx: 1048576, quotaPool: "gemini-3.5-flash", multimodal: true, tools: true },
2127
- { id: "gemini-3.1-pro-low", family: "gemini-3.1-pro", ctx: 1048576, quotaPool: "gemini-3.1-pro", multimodal: true, tools: true },
2128
- { id: "gemini-3.1-pro-high", family: "gemini-3.1-pro", ctx: 1048576, quotaPool: "gemini-3.1-pro", multimodal: true, tools: true },
2129
- { id: "claude-sonnet-4-6", family: "claude", ctx: 500000, quotaPool: "claude-opus-4-6-thinking", multimodal: true, tools: true },
2130
- { id: "claude-opus-4-6-thinking", family: "claude", ctx: 500000, quotaPool: "claude-opus-4-6-thinking", multimodal: true, tools: true },
2131
- { id: "gpt-oss-120b-medium", family: "gpt-oss", ctx: 131072, quotaPool: "claude-opus-4-6-thinking", multimodal: false, tools: true },
1006
+ {
1007
+ id: "gemini-3.5-flash-medium",
1008
+ family: "gemini-3.5-flash",
1009
+ ctx: 1048576,
1010
+ quotaPool: "gemini-3.5-flash",
1011
+ multimodal: true,
1012
+ tools: true,
1013
+ },
1014
+ {
1015
+ id: "gemini-3.5-flash-high",
1016
+ family: "gemini-3.5-flash",
1017
+ ctx: 1048576,
1018
+ quotaPool: "gemini-3.5-flash",
1019
+ multimodal: true,
1020
+ tools: true,
1021
+ },
1022
+ {
1023
+ id: "gemini-3-flash",
1024
+ family: "gemini-3.5-flash",
1025
+ ctx: 1048576,
1026
+ quotaPool: "gemini-3.5-flash",
1027
+ multimodal: true,
1028
+ tools: true,
1029
+ },
1030
+ {
1031
+ id: "gemini-3.1-pro-low",
1032
+ family: "gemini-3.1-pro",
1033
+ ctx: 1048576,
1034
+ quotaPool: "gemini-3.1-pro",
1035
+ multimodal: true,
1036
+ tools: true,
1037
+ },
1038
+ {
1039
+ id: "gemini-3.1-pro-high",
1040
+ family: "gemini-3.1-pro",
1041
+ ctx: 1048576,
1042
+ quotaPool: "gemini-3.1-pro",
1043
+ multimodal: true,
1044
+ tools: true,
1045
+ },
1046
+ {
1047
+ id: "claude-sonnet-4-6",
1048
+ family: "claude",
1049
+ ctx: 500000,
1050
+ quotaPool: "claude-opus-4-6-thinking",
1051
+ multimodal: true,
1052
+ tools: true,
1053
+ },
1054
+ {
1055
+ id: "claude-opus-4-6-thinking",
1056
+ family: "claude",
1057
+ ctx: 500000,
1058
+ quotaPool: "claude-opus-4-6-thinking",
1059
+ multimodal: true,
1060
+ tools: true,
1061
+ },
1062
+ {
1063
+ id: "gpt-oss-120b-medium",
1064
+ family: "gpt-oss",
1065
+ ctx: 131072,
1066
+ quotaPool: "claude-opus-4-6-thinking",
1067
+ multimodal: false,
1068
+ tools: true,
1069
+ },
2132
1070
  ] as const;
2133
1071
 
2134
1072
  export function serveOpenAIModels(res: ServerResponse): void {
2135
- writeJson(res, 200, {
2136
- object: "list",
2137
- data: MODEL_CATALOG.map(({ id, ctx, family, quotaPool, multimodal, tools }) => ({
2138
- id,
2139
- object: "model",
2140
- created: 0,
2141
- owned_by: "pi-antigravity-rotator",
2142
- context_window: ctx,
2143
- max_model_len: ctx,
2144
- meta: {
2145
- context_length: ctx,
2146
- family,
2147
- quota_pool: quotaPool,
2148
- multimodal,
2149
- tool_calling: tools,
2150
- }
2151
- })),
2152
- });
1073
+ writeJson(res, 200, {
1074
+ object: "list",
1075
+ data: MODEL_CATALOG.map(
1076
+ ({ id, ctx, family, quotaPool, multimodal, tools }) => ({
1077
+ id,
1078
+ object: "model",
1079
+ created: 0,
1080
+ owned_by: "pi-antigravity-rotator",
1081
+ context_window: ctx,
1082
+ max_model_len: ctx,
1083
+ meta: {
1084
+ context_length: ctx,
1085
+ family,
1086
+ quota_pool: quotaPool,
1087
+ multimodal,
1088
+ tool_calling: tools,
1089
+ },
1090
+ }),
1091
+ ),
1092
+ });
2153
1093
  }
2154
1094
 
2155
1095
  export function serveGeminiModels(res: ServerResponse): void {
2156
- writeJson(res, 200, {
2157
- models: MODEL_CATALOG.map(({ id, ctx, family, quotaPool, multimodal, tools }) => ({
2158
- name: `models/${id}`,
2159
- baseModelId: family,
2160
- version: "v2.0",
2161
- displayName: id,
2162
- description: `Pi Antigravity Rotator Gemini-compatible model entry for ${id}`,
2163
- inputTokenLimit: ctx,
2164
- outputTokenLimit: ctx,
2165
- supportedGenerationMethods: ["generateContent", "streamGenerateContent"],
2166
- capabilities: {
2167
- tools,
2168
- multimodal,
2169
- quotaPool,
2170
- },
2171
- })),
2172
- });
2173
- }
2174
-
2175
- export async function handleGeminiGenerateContent(req: IncomingMessage, res: ServerResponse, rotator: AccountRotator): Promise<void> {
2176
- let parsed: unknown;
2177
- try {
2178
- parsed = await readJsonBody(req);
2179
- } catch (err) {
2180
- if (err instanceof PayloadTooLargeError) return writeJson(res, 413, { error: { message: "Payload too large", status: "INVALID_ARGUMENT" } });
2181
- return writeJson(res, 400, { error: { message: "Invalid JSON body", status: "INVALID_ARGUMENT" } });
2182
- }
2183
- if (!isRecord(parsed)) return writeJson(res, 400, { error: { message: "Body must be an object", status: "INVALID_ARGUMENT" } });
2184
-
2185
- const pathname = new URL(req.url || "/", "http://localhost").pathname;
2186
- const modelToken = pathname.match(/\/v1beta\/models\/(.+):(generateContent|streamGenerateContent)$/)?.[1];
2187
- const model = modelToken ? decodeURIComponent(modelToken).replace(/^models\//, "") : null;
2188
- if (!model) return writeJson(res, 400, { error: { message: "Model path is required", status: "INVALID_ARGUMENT" } });
2189
-
2190
- const body: RequestBody = {
2191
- model,
2192
- project: "",
2193
- request: {
2194
- contents: Array.isArray(parsed.contents) ? parsed.contents : [],
2195
- systemInstruction: parsed.systemInstruction,
2196
- generationConfig: parsed.generationConfig,
2197
- tools: parsed.tools,
2198
- },
2199
- };
2200
- const result = await completeViaRotator(req, res, rotator, body, "none");
2201
- if (result.status !== 200) {
2202
- return writeJson(res, result.status, { error: { message: result.errorText || "Upstream error", status: "UPSTREAM_ERROR" } });
2203
- }
2204
- if (result.streamed) return;
2205
- writeJson(res, 200, {
2206
- candidates: [{
2207
- content: {
2208
- role: "model",
2209
- parts: [{ text: result.completion.text }],
2210
- },
2211
- finishReason: "STOP",
2212
- }],
2213
- usageMetadata: {
2214
- promptTokenCount: result.completion.inputTokens,
2215
- candidatesTokenCount: result.completion.outputTokens,
2216
- totalTokenCount: result.completion.inputTokens + result.completion.outputTokens,
2217
- },
2218
- });
2219
- }
2220
-
2221
- export async function handleOpenAIChatCompletions(req: IncomingMessage, res: ServerResponse, rotator: AccountRotator): Promise<void> {
2222
- let parsed: unknown;
2223
- try {
2224
- parsed = await readJsonBody(req);
2225
- } catch (err) {
2226
- if (err instanceof PayloadTooLargeError) return writeJson(res, 413, { error: { message: "Payload too large", type: "invalid_request_error" } });
2227
- return writeJson(res, 400, { error: { message: "Invalid JSON body", type: "invalid_request_error" } });
2228
- }
2229
- const validation = validateOpenAIChatCompletionRequest(normalizeOpenAIChatCompletionRequest(parsed));
2230
- if (!validation.ok) return writeJson(res, 400, { error: { message: validation.errors.join("; "), type: "invalid_request_error" } });
2231
-
2232
- const started = Date.now();
2233
- const streamMode = validation.value.stream ? "openai" : "none";
2234
- const result = await completeViaRotator(req, res, rotator, openAIToAntigravityBody(validation.value), streamMode);
2235
- if (result.status !== 200) {
2236
- compatLogger.warn(`OpenAI compat upstream failed status=${result.status} model=${validation.value.model}`);
2237
- if (!res.headersSent) {
2238
- return writeJson(res, result.status, { error: { message: result.errorText || "Upstream error", type: "upstream_error" } });
2239
- }
2240
- return;
2241
- }
2242
- if (result.streamed) {
2243
- return;
2244
- }
2245
- const hasToolCalls = result.completion.toolCalls && result.completion.toolCalls.length > 0;
2246
- writeJson(res, 200, {
2247
- id: `chatcmpl-${started.toString(36)}`,
2248
- object: "chat.completion",
2249
- created: Math.floor(started / 1000),
2250
- model: validation.value.model,
2251
- choices: [{
2252
- index: 0,
2253
- message: {
2254
- role: "assistant",
2255
- ...(hasToolCalls
2256
- ? { content: null, tool_calls: result.completion.toolCalls }
2257
- : { content: result.completion.text }
2258
- ),
2259
- ...(result.completion.thinkingText ? { reasoning_content: result.completion.thinkingText } : {})
2260
- },
2261
- finish_reason: hasToolCalls ? "tool_calls" : "stop",
2262
- }],
2263
- usage: {
2264
- prompt_tokens: result.completion.inputTokens,
2265
- completion_tokens: result.completion.outputTokens,
2266
- total_tokens: result.completion.inputTokens + result.completion.outputTokens,
2267
- },
2268
- });
2269
- }
2270
-
2271
- export async function handleOpenAIResponsesCreate(req: IncomingMessage, res: ServerResponse, rotator: AccountRotator): Promise<void> {
2272
- let parsed: unknown;
2273
- try {
2274
- parsed = await readJsonBody(req);
2275
- } catch (err) {
2276
- if (err instanceof PayloadTooLargeError) return writeJson(res, 413, { error: { message: "Payload too large", type: "invalid_request_error" } });
2277
- return writeJson(res, 400, { error: { message: "Invalid JSON body", type: "invalid_request_error" } });
2278
- }
2279
-
2280
- const normalized = normalizeOpenAIResponsesRequest(parsed);
2281
- const validation = validateOpenAIResponsesRequest(normalized);
2282
- if (!validation.ok) return writeJson(res, 400, { error: { message: validation.errors.join("; "), type: "invalid_request_error" } });
2283
-
2284
- let converted: ResponsesConversionResult;
2285
- try {
2286
- converted = convertResponsesToChatRequest(validation.value);
2287
- } catch (err) {
2288
- return writeJson(res, 400, { error: { message: err instanceof Error ? err.message : String(err), type: "invalid_request_error" } });
2289
- }
2290
-
2291
- const responseId = makeCompatId("resp");
2292
- const createdAt = Math.floor(Date.now() / 1000);
2293
- const requestBody = openAIToAntigravityBody(converted.chatRequest);
2294
- requestBody.requestId = responseId;
2295
-
2296
- if (validation.value.store !== false) {
2297
- setStoredResponse(responseId, {
2298
- response: buildResponsesResponse(validation.value, responseId, createdAt, { text: "", inputTokens: 0, outputTokens: 0, toolCalls: [] }, "in_progress", converted.previousResponseId),
2299
- inputItems: converted.inputItems,
2300
- conversationMessages: converted.conversationMessages,
2301
- callIdToName: new Map(),
2302
- expiresAt: Date.now() + RESPONSES_STORE_TTL_MS,
2303
- });
2304
- }
2305
-
2306
- if (validation.value.stream) {
2307
- const result = await completeResponsesViaRotator(req, res, rotator, validation.value, requestBody, responseId, converted.previousResponseId);
2308
- if (result.status !== 200) {
2309
- responsesStore.delete(responseId);
2310
- if (!res.headersSent) return writeJson(res, result.status, { error: { message: result.errorText || "Upstream error", type: "upstream_error" } });
2311
- return;
2312
- }
2313
- if (validation.value.store !== false) {
2314
- const responseObject = buildResponsesResponse(validation.value, responseId, createdAt, result.completion, "completed", converted.previousResponseId);
2315
- saveResponsesEntry(responseObject, converted.inputItems, converted.conversationMessages, result.completion);
2316
- }
2317
- return;
2318
- }
2319
-
2320
- const result = await completeViaRotator(req, res, rotator, requestBody, "none");
2321
- if (result.status !== 200) {
2322
- responsesStore.delete(responseId);
2323
- return writeJson(res, result.status, { error: { message: result.errorText || "Upstream error", type: "upstream_error" } });
2324
- }
2325
-
2326
- const responseObject = buildResponsesResponse(validation.value, responseId, createdAt, result.completion, "completed", converted.previousResponseId);
2327
- if (validation.value.store !== false) {
2328
- saveResponsesEntry(responseObject, converted.inputItems, converted.conversationMessages, result.completion);
2329
- } else {
2330
- responsesStore.delete(responseId);
2331
- }
2332
- writeJson(res, 200, responseObject);
2333
- }
2334
-
2335
- export function handleOpenAIResponsesRetrieve(_req: IncomingMessage, res: ServerResponse, responseId: string): void {
2336
- const entry = getStoredResponse(responseId);
2337
- if (!entry) return writeJson(res, 404, { error: { message: `Response not found: ${responseId}`, type: "invalid_request_error" } });
2338
- writeJson(res, 200, entry.response);
2339
- }
2340
-
2341
- export function handleOpenAIResponsesDelete(_req: IncomingMessage, res: ServerResponse, responseId: string): void {
2342
- writeJson(res, 200, { id: responseId, object: "response.deleted", deleted: responsesStore.delete(responseId) });
2343
- }
2344
-
2345
- export function handleOpenAIResponsesCancel(_req: IncomingMessage, res: ServerResponse, responseId: string): void {
2346
- const entry = getStoredResponse(responseId);
2347
- if (!entry) return writeJson(res, 404, { error: { message: `Response not found: ${responseId}`, type: "invalid_request_error" } });
2348
- if (entry.response.status === "in_progress") entry.response.status = "cancelled";
2349
- writeJson(res, 200, entry.response);
2350
- }
2351
-
2352
- export function handleOpenAIResponsesInputItems(_req: IncomingMessage, res: ServerResponse, responseId: string): void {
2353
- const entry = getStoredResponse(responseId);
2354
- if (!entry) return writeJson(res, 404, { error: { message: `Response not found: ${responseId}`, type: "invalid_request_error" } });
2355
- writeJson(res, 200, { object: "list", data: entry.inputItems, has_more: false, first_id: entry.inputItems[0]?.id ?? null, last_id: entry.inputItems.at(-1)?.id ?? null });
1096
+ writeJson(res, 200, {
1097
+ models: MODEL_CATALOG.map(
1098
+ ({ id, ctx, family, quotaPool, multimodal, tools }) => ({
1099
+ name: `models/${id}`,
1100
+ baseModelId: family,
1101
+ version: "v2.0",
1102
+ displayName: id,
1103
+ description: `Pi Antigravity Rotator Gemini-compatible model entry for ${id}`,
1104
+ inputTokenLimit: ctx,
1105
+ outputTokenLimit: ctx,
1106
+ supportedGenerationMethods: [
1107
+ "generateContent",
1108
+ "streamGenerateContent",
1109
+ ],
1110
+ capabilities: {
1111
+ tools,
1112
+ multimodal,
1113
+ quotaPool,
1114
+ },
1115
+ }),
1116
+ ),
1117
+ });
1118
+ }
1119
+
1120
+ export async function handleGeminiGenerateContent(
1121
+ req: IncomingMessage,
1122
+ res: ServerResponse,
1123
+ rotator: AccountRotator,
1124
+ ): Promise<void> {
1125
+ let parsed: unknown;
1126
+ try {
1127
+ parsed = await readJsonBody(req);
1128
+ } catch (err) {
1129
+ if (err instanceof PayloadTooLargeError)
1130
+ return writeJson(res, 413, {
1131
+ error: { message: "Payload too large", status: "INVALID_ARGUMENT" },
1132
+ });
1133
+ return writeJson(res, 400, {
1134
+ error: { message: "Invalid JSON body", status: "INVALID_ARGUMENT" },
1135
+ });
1136
+ }
1137
+ if (!isRecord(parsed))
1138
+ return writeJson(res, 400, {
1139
+ error: { message: "Body must be an object", status: "INVALID_ARGUMENT" },
1140
+ });
1141
+
1142
+ const pathname = new URL(req.url || "/", "http://localhost").pathname;
1143
+ const modelToken = pathname.match(
1144
+ /\/v1beta\/models\/(.+):(generateContent|streamGenerateContent)$/,
1145
+ )?.[1];
1146
+ const model = modelToken
1147
+ ? decodeURIComponent(modelToken).replace(/^models\//, "")
1148
+ : null;
1149
+ if (!model)
1150
+ return writeJson(res, 400, {
1151
+ error: { message: "Model path is required", status: "INVALID_ARGUMENT" },
1152
+ });
1153
+
1154
+ const body: RequestBody = {
1155
+ model,
1156
+ project: "",
1157
+ request: {
1158
+ contents: Array.isArray(parsed.contents) ? parsed.contents : [],
1159
+ systemInstruction: parsed.systemInstruction,
1160
+ generationConfig: parsed.generationConfig,
1161
+ tools: parsed.tools,
1162
+ },
1163
+ };
1164
+ const result = await completeViaRotator(req, res, rotator, body, "none");
1165
+ if (result.status !== 200) {
1166
+ return writeJson(res, result.status, {
1167
+ error: {
1168
+ message: result.errorText || "Upstream error",
1169
+ status: "UPSTREAM_ERROR",
1170
+ },
1171
+ });
1172
+ }
1173
+ if (result.streamed) return;
1174
+ writeJson(res, 200, {
1175
+ candidates: [
1176
+ {
1177
+ content: {
1178
+ role: "model",
1179
+ parts: [{ text: result.completion.text }],
1180
+ },
1181
+ finishReason: "STOP",
1182
+ },
1183
+ ],
1184
+ usageMetadata: {
1185
+ promptTokenCount: result.completion.inputTokens,
1186
+ candidatesTokenCount: result.completion.outputTokens,
1187
+ totalTokenCount:
1188
+ result.completion.inputTokens + result.completion.outputTokens,
1189
+ },
1190
+ });
1191
+ }
1192
+
1193
+ export async function handleOpenAIChatCompletions(
1194
+ req: IncomingMessage,
1195
+ res: ServerResponse,
1196
+ rotator: AccountRotator,
1197
+ ): Promise<void> {
1198
+ let parsed: unknown;
1199
+ try {
1200
+ parsed = await readJsonBody(req);
1201
+ } catch (err) {
1202
+ if (err instanceof PayloadTooLargeError)
1203
+ return writeJson(res, 413, {
1204
+ error: { message: "Payload too large", type: "invalid_request_error" },
1205
+ });
1206
+ return writeJson(res, 400, {
1207
+ error: { message: "Invalid JSON body", type: "invalid_request_error" },
1208
+ });
1209
+ }
1210
+ const validation = validateOpenAIChatCompletionRequest(
1211
+ normalizeOpenAIChatCompletionRequest(parsed),
1212
+ );
1213
+ if (!validation.ok)
1214
+ return writeJson(res, 400, {
1215
+ error: {
1216
+ message: validation.errors.join("; "),
1217
+ type: "invalid_request_error",
1218
+ },
1219
+ });
1220
+
1221
+ const started = Date.now();
1222
+ const streamMode = validation.value.stream ? "openai" : "none";
1223
+ const result = await completeViaRotator(
1224
+ req,
1225
+ res,
1226
+ rotator,
1227
+ openAIToAntigravityBody(validation.value),
1228
+ streamMode,
1229
+ );
1230
+ if (result.status !== 200) {
1231
+ compatLogger.warn(
1232
+ `OpenAI compat upstream failed status=${result.status} model=${validation.value.model}`,
1233
+ );
1234
+ if (!res.headersSent) {
1235
+ return writeJson(res, result.status, {
1236
+ error: {
1237
+ message: result.errorText || "Upstream error",
1238
+ type: "upstream_error",
1239
+ },
1240
+ });
1241
+ }
1242
+ return;
1243
+ }
1244
+ if (result.streamed) {
1245
+ return;
1246
+ }
1247
+ const hasToolCalls =
1248
+ result.completion.toolCalls && result.completion.toolCalls.length > 0;
1249
+ writeJson(res, 200, {
1250
+ id: `chatcmpl-${started.toString(36)}`,
1251
+ object: "chat.completion",
1252
+ created: Math.floor(started / 1000),
1253
+ model: validation.value.model,
1254
+ choices: [
1255
+ {
1256
+ index: 0,
1257
+ message: {
1258
+ role: "assistant",
1259
+ ...(hasToolCalls
1260
+ ? { content: null, tool_calls: result.completion.toolCalls }
1261
+ : { content: result.completion.text }),
1262
+ ...(result.completion.thinkingText
1263
+ ? { reasoning_content: result.completion.thinkingText }
1264
+ : {}),
1265
+ },
1266
+ finish_reason: hasToolCalls ? "tool_calls" : "stop",
1267
+ },
1268
+ ],
1269
+ usage: {
1270
+ prompt_tokens: result.completion.inputTokens,
1271
+ completion_tokens: result.completion.outputTokens,
1272
+ total_tokens:
1273
+ result.completion.inputTokens + result.completion.outputTokens,
1274
+ },
1275
+ });
1276
+ }
1277
+
1278
+ export async function handleOpenAIResponsesCreate(
1279
+ req: IncomingMessage,
1280
+ res: ServerResponse,
1281
+ rotator: AccountRotator,
1282
+ ): Promise<void> {
1283
+ let parsed: unknown;
1284
+ try {
1285
+ parsed = await readJsonBody(req);
1286
+ } catch (err) {
1287
+ if (err instanceof PayloadTooLargeError)
1288
+ return writeJson(res, 413, {
1289
+ error: { message: "Payload too large", type: "invalid_request_error" },
1290
+ });
1291
+ return writeJson(res, 400, {
1292
+ error: { message: "Invalid JSON body", type: "invalid_request_error" },
1293
+ });
1294
+ }
1295
+
1296
+ const normalized = normalizeOpenAIResponsesRequest(parsed);
1297
+ const validation = validateOpenAIResponsesRequest(normalized);
1298
+ if (!validation.ok)
1299
+ return writeJson(res, 400, {
1300
+ error: {
1301
+ message: validation.errors.join("; "),
1302
+ type: "invalid_request_error",
1303
+ },
1304
+ });
1305
+
1306
+ let converted: ResponsesConversionResult;
1307
+ try {
1308
+ converted = convertResponsesToChatRequest(validation.value);
1309
+ } catch (err) {
1310
+ return writeJson(res, 400, {
1311
+ error: {
1312
+ message: err instanceof Error ? err.message : String(err),
1313
+ type: "invalid_request_error",
1314
+ },
1315
+ });
1316
+ }
1317
+
1318
+ const responseId = makeCompatId("resp");
1319
+ const createdAt = Math.floor(Date.now() / 1000);
1320
+ const requestBody = openAIToAntigravityBody(converted.chatRequest);
1321
+ requestBody.requestId = responseId;
1322
+
1323
+ if (validation.value.store !== false) {
1324
+ const expiresAt = Date.now() + 6 * 60 * 60 * 1000;
1325
+ setStoredResponse(responseId, {
1326
+ response: buildResponsesResponse(
1327
+ validation.value,
1328
+ responseId,
1329
+ createdAt,
1330
+ { text: "", inputTokens: 0, outputTokens: 0, toolCalls: [] },
1331
+ "in_progress",
1332
+ converted.previousResponseId,
1333
+ ),
1334
+ inputItems: converted.inputItems,
1335
+ conversationMessages: converted.conversationMessages as unknown as Array<
1336
+ Record<string, unknown>
1337
+ >,
1338
+ callIdToName: {},
1339
+ expiresAt,
1340
+ });
1341
+ }
1342
+
1343
+ if (validation.value.stream) {
1344
+ const result = await completeResponsesViaRotator(
1345
+ req,
1346
+ res,
1347
+ rotator,
1348
+ validation.value,
1349
+ requestBody,
1350
+ responseId,
1351
+ converted.previousResponseId,
1352
+ );
1353
+ if (result.status !== 200) {
1354
+ responsesStore.delete(responseId);
1355
+ if (!res.headersSent)
1356
+ return writeJson(res, result.status, {
1357
+ error: {
1358
+ message: result.errorText || "Upstream error",
1359
+ type: "upstream_error",
1360
+ },
1361
+ });
1362
+ return;
1363
+ }
1364
+ if (validation.value.store !== false) {
1365
+ const responseObject = buildResponsesResponse(
1366
+ validation.value,
1367
+ responseId,
1368
+ createdAt,
1369
+ result.completion,
1370
+ "completed",
1371
+ converted.previousResponseId,
1372
+ );
1373
+ saveResponsesEntry(
1374
+ responseObject,
1375
+ converted.inputItems,
1376
+ converted.conversationMessages,
1377
+ result.completion,
1378
+ );
1379
+ }
1380
+ return;
1381
+ }
1382
+
1383
+ const result = await completeViaRotator(
1384
+ req,
1385
+ res,
1386
+ rotator,
1387
+ requestBody,
1388
+ "none",
1389
+ );
1390
+ if (result.status !== 200) {
1391
+ responsesStore.delete(responseId);
1392
+ return writeJson(res, result.status, {
1393
+ error: {
1394
+ message: result.errorText || "Upstream error",
1395
+ type: "upstream_error",
1396
+ },
1397
+ });
1398
+ }
1399
+
1400
+ const responseObject = buildResponsesResponse(
1401
+ validation.value,
1402
+ responseId,
1403
+ createdAt,
1404
+ result.completion,
1405
+ "completed",
1406
+ converted.previousResponseId,
1407
+ );
1408
+ if (validation.value.store !== false) {
1409
+ saveResponsesEntry(
1410
+ responseObject,
1411
+ converted.inputItems,
1412
+ converted.conversationMessages,
1413
+ result.completion,
1414
+ );
1415
+ } else {
1416
+ responsesStore.delete(responseId);
1417
+ }
1418
+ writeJson(res, 200, responseObject);
1419
+ }
1420
+
1421
+ export function handleOpenAIResponsesRetrieve(
1422
+ _req: IncomingMessage,
1423
+ res: ServerResponse,
1424
+ responseId: string,
1425
+ ): void {
1426
+ const entry = getStoredResponse(responseId);
1427
+ if (!entry)
1428
+ return writeJson(res, 404, {
1429
+ error: {
1430
+ message: `Response not found: ${responseId}`,
1431
+ type: "invalid_request_error",
1432
+ },
1433
+ });
1434
+ writeJson(res, 200, entry.response);
1435
+ }
1436
+
1437
+ export function handleOpenAIResponsesDelete(
1438
+ _req: IncomingMessage,
1439
+ res: ServerResponse,
1440
+ responseId: string,
1441
+ ): void {
1442
+ writeJson(res, 200, {
1443
+ id: responseId,
1444
+ object: "response.deleted",
1445
+ deleted: responsesStore.delete(responseId),
1446
+ });
2356
1447
  }
2357
1448
 
2358
- export async function handleAnthropicMessages(req: IncomingMessage, res: ServerResponse, rotator: AccountRotator): Promise<void> {
2359
- let parsed: unknown;
2360
- try {
2361
- parsed = await readJsonBody(req);
2362
- } catch (err) {
2363
- if (err instanceof PayloadTooLargeError) return writeJson(res, 413, { type: "error", error: { type: "invalid_request_error", message: "Payload too large" } });
2364
- return writeJson(res, 400, { type: "error", error: { type: "invalid_request_error", message: "Invalid JSON body" } });
2365
- }
2366
- const validation = validateAnthropicMessagesRequest(normalizeAnthropicMessagesRequest(parsed));
2367
- if (!validation.ok) return writeJson(res, 400, { type: "error", error: { type: "invalid_request_error", message: validation.errors.join("; ") } });
2368
-
2369
- const started = Date.now();
2370
- const streamMode = validation.value.stream ? "anthropic" : "none";
2371
- const result = await completeViaRotator(req, res, rotator, anthropicToAntigravityBody(validation.value), streamMode);
2372
- if (result.status !== 200) {
2373
- compatLogger.warn(`Anthropic compat upstream failed status=${result.status} model=${validation.value.model}`);
2374
- if (!res.headersSent) {
2375
- return writeJson(res, result.status, { type: "error", error: { type: "upstream_error", message: result.errorText || "Upstream error" } });
2376
- }
2377
- return;
2378
- }
2379
- if (result.streamed) {
2380
- return;
2381
- }
2382
- const contentBlocks: Array<Record<string, unknown>> = [];
2383
- if (result.completion.thinkingText) {
2384
- contentBlocks.push({ type: "thinking", thinking: result.completion.thinkingText });
2385
- }
2386
- if (result.completion.text) {
2387
- contentBlocks.push({ type: "text", text: result.completion.text });
2388
- }
2389
- if (result.completion.toolCalls && result.completion.toolCalls.length > 0) {
2390
- for (const tc of result.completion.toolCalls) {
2391
- let parsedInput: unknown;
2392
- try { parsedInput = JSON.parse(tc.function.arguments || "{}"); } catch { parsedInput = {}; }
2393
- contentBlocks.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: parsedInput });
2394
- }
2395
- }
2396
- const stopReason = (result.completion.toolCalls && result.completion.toolCalls.length > 0) ? "tool_use" : "end_turn";
2397
- writeJson(res, 200, {
2398
- id: `msg_${started.toString(36)}`,
2399
- type: "message",
2400
- role: "assistant",
2401
- model: validation.value.model,
2402
- content: contentBlocks,
2403
- stop_reason: stopReason,
2404
- stop_sequence: null,
2405
- usage: {
2406
- input_tokens: result.completion.inputTokens,
2407
- output_tokens: result.completion.outputTokens,
2408
- },
2409
- });
1449
+ export function handleOpenAIResponsesCancel(
1450
+ _req: IncomingMessage,
1451
+ res: ServerResponse,
1452
+ responseId: string,
1453
+ ): void {
1454
+ const entry = getStoredResponse(responseId);
1455
+ if (!entry)
1456
+ return writeJson(res, 404, {
1457
+ error: {
1458
+ message: `Response not found: ${responseId}`,
1459
+ type: "invalid_request_error",
1460
+ },
1461
+ });
1462
+ if (entry.response.status === "in_progress")
1463
+ entry.response.status = "cancelled";
1464
+ writeJson(res, 200, entry.response);
1465
+ }
1466
+
1467
+ export function handleOpenAIResponsesInputItems(
1468
+ _req: IncomingMessage,
1469
+ res: ServerResponse,
1470
+ responseId: string,
1471
+ ): void {
1472
+ const entry = getStoredResponse(responseId);
1473
+ if (!entry)
1474
+ return writeJson(res, 404, {
1475
+ error: {
1476
+ message: `Response not found: ${responseId}`,
1477
+ type: "invalid_request_error",
1478
+ },
1479
+ });
1480
+ writeJson(res, 200, {
1481
+ object: "list",
1482
+ data: entry.inputItems,
1483
+ has_more: false,
1484
+ first_id: entry.inputItems[0]?.id ?? null,
1485
+ last_id: entry.inputItems.at(-1)?.id ?? null,
1486
+ });
1487
+ }
1488
+
1489
+ export async function handleAnthropicMessages(
1490
+ req: IncomingMessage,
1491
+ res: ServerResponse,
1492
+ rotator: AccountRotator,
1493
+ ): Promise<void> {
1494
+ let parsed: unknown;
1495
+ try {
1496
+ parsed = await readJsonBody(req);
1497
+ } catch (err) {
1498
+ if (err instanceof PayloadTooLargeError)
1499
+ return writeJson(res, 413, {
1500
+ type: "error",
1501
+ error: { type: "invalid_request_error", message: "Payload too large" },
1502
+ });
1503
+ return writeJson(res, 400, {
1504
+ type: "error",
1505
+ error: { type: "invalid_request_error", message: "Invalid JSON body" },
1506
+ });
1507
+ }
1508
+ const validation = validateAnthropicMessagesRequest(
1509
+ normalizeAnthropicMessagesRequest(parsed),
1510
+ );
1511
+ if (!validation.ok)
1512
+ return writeJson(res, 400, {
1513
+ type: "error",
1514
+ error: {
1515
+ type: "invalid_request_error",
1516
+ message: validation.errors.join("; "),
1517
+ },
1518
+ });
1519
+
1520
+ const started = Date.now();
1521
+ const streamMode = validation.value.stream ? "anthropic" : "none";
1522
+ const result = await completeViaRotator(
1523
+ req,
1524
+ res,
1525
+ rotator,
1526
+ anthropicToAntigravityBody(validation.value),
1527
+ streamMode,
1528
+ );
1529
+ if (result.status !== 200) {
1530
+ compatLogger.warn(
1531
+ `Anthropic compat upstream failed status=${result.status} model=${validation.value.model}`,
1532
+ );
1533
+ if (!res.headersSent) {
1534
+ return writeJson(res, result.status, {
1535
+ type: "error",
1536
+ error: {
1537
+ type: "upstream_error",
1538
+ message: result.errorText || "Upstream error",
1539
+ },
1540
+ });
1541
+ }
1542
+ return;
1543
+ }
1544
+ if (result.streamed) {
1545
+ return;
1546
+ }
1547
+ const contentBlocks: Array<Record<string, unknown>> = [];
1548
+ if (result.completion.thinkingText) {
1549
+ contentBlocks.push({
1550
+ type: "thinking",
1551
+ thinking: result.completion.thinkingText,
1552
+ });
1553
+ }
1554
+ if (result.completion.text) {
1555
+ contentBlocks.push({ type: "text", text: result.completion.text });
1556
+ }
1557
+ if (result.completion.toolCalls && result.completion.toolCalls.length > 0) {
1558
+ for (const tc of result.completion.toolCalls) {
1559
+ let parsedInput: unknown;
1560
+ try {
1561
+ parsedInput = JSON.parse(tc.function.arguments || "{}");
1562
+ } catch {
1563
+ parsedInput = {};
1564
+ }
1565
+ contentBlocks.push({
1566
+ type: "tool_use",
1567
+ id: tc.id,
1568
+ name: tc.function.name,
1569
+ input: parsedInput,
1570
+ });
1571
+ }
1572
+ }
1573
+ const stopReason =
1574
+ result.completion.toolCalls && result.completion.toolCalls.length > 0
1575
+ ? "tool_use"
1576
+ : "end_turn";
1577
+ writeJson(res, 200, {
1578
+ id: `msg_${started.toString(36)}`,
1579
+ type: "message",
1580
+ role: "assistant",
1581
+ model: validation.value.model,
1582
+ content: contentBlocks,
1583
+ stop_reason: stopReason,
1584
+ stop_sequence: null,
1585
+ usage: {
1586
+ input_tokens: result.completion.inputTokens,
1587
+ output_tokens: result.completion.outputTokens,
1588
+ },
1589
+ });
2410
1590
  }