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