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.
@@ -0,0 +1,1538 @@
1
+ import { logger, redactSensitive } from "../logger.js";
2
+ import { applyModelAlias } from "../types.js";
3
+ import type { RequestBody } from "../proxy.js";
4
+ import {
5
+ isRecord,
6
+ sanitizeGeminiSchema,
7
+ sanitizeClaudeViaGeminiSchema,
8
+ } from "./schema-sanitizer.js";
9
+ import {
10
+ getModelFamily,
11
+ getModelSpec,
12
+ isThinkingModel,
13
+ } from "./model-specs.js";
14
+ import {
15
+ thoughtSignatureCache,
16
+ getStoredResponse,
17
+ setStoredResponse,
18
+ makeCompatId,
19
+ } from "./cache.js";
20
+
21
+ const compatLogger = logger.child("compat");
22
+
23
+ const VALIDATION_LOG_MAX_CHARS = 200;
24
+
25
+ export function logValidationFailure(scope: string, payload: unknown): void {
26
+ const truncated = redactSensitive(JSON.stringify(payload));
27
+ const clipped =
28
+ truncated.length > VALIDATION_LOG_MAX_CHARS
29
+ ? `${truncated.slice(0, VALIDATION_LOG_MAX_CHARS)}…[+${truncated.length - VALIDATION_LOG_MAX_CHARS} chars]`
30
+ : truncated;
31
+ compatLogger.warn(`${scope}: ${clipped}`);
32
+ }
33
+
34
+ export interface ResponseOutputText {
35
+ type: "output_text";
36
+ text: string;
37
+ annotations: unknown[];
38
+ }
39
+
40
+ export interface ResponseMessageOutputItem {
41
+ id: string;
42
+ type: "message";
43
+ status: "completed";
44
+ role: "assistant";
45
+ content: ResponseOutputText[];
46
+ }
47
+
48
+ export interface ResponseFunctionCallOutputItem {
49
+ id: string;
50
+ type: "function_call";
51
+ call_id: string;
52
+ name: string;
53
+ arguments: string;
54
+ status: "completed";
55
+ }
56
+
57
+ export type ResponseOutputItem =
58
+ | ResponseMessageOutputItem
59
+ | ResponseFunctionCallOutputItem;
60
+
61
+ export interface ChatMessage {
62
+ role: "system" | "developer" | "user" | "assistant" | "model" | "tool";
63
+ content:
64
+ | string
65
+ | Array<{ type: string; text?: string; [key: string]: unknown }>
66
+ | null;
67
+ tool_calls?: OpenAIToolCall[];
68
+ tool_call_id?: string;
69
+ name?: string;
70
+ }
71
+
72
+ export interface OpenAITool {
73
+ type: "function";
74
+ function: {
75
+ name: string;
76
+ description?: string;
77
+ parameters?: Record<string, unknown>;
78
+ };
79
+ }
80
+
81
+ export interface OpenAIToolCall {
82
+ id: string;
83
+ type: "function";
84
+ function: {
85
+ name: string;
86
+ arguments: string;
87
+ };
88
+ }
89
+
90
+ export interface OpenAIToolChoice {
91
+ type: "function";
92
+ function: { name: string };
93
+ }
94
+
95
+ export interface OpenAIChatCompletionRequest {
96
+ model: string;
97
+ messages: ChatMessage[];
98
+ stream?: boolean;
99
+ temperature?: number;
100
+ max_tokens?: number;
101
+ prompt?: string | string[];
102
+ input?: unknown;
103
+ tools?: OpenAITool[];
104
+ tool_choice?: unknown;
105
+ /** OpenAI-style reasoning effort. Mapped to Gemini thinkingLevel. */
106
+ reasoning_effort?: string;
107
+ [key: string]: unknown;
108
+ }
109
+
110
+ export interface OpenAIResponsesRequest {
111
+ model: string;
112
+ input?: unknown;
113
+ instructions?:
114
+ | string
115
+ | Array<{ type: string; text?: string; [key: string]: unknown }>
116
+ | null;
117
+ stream?: boolean;
118
+ temperature?: number;
119
+ max_output_tokens?: number;
120
+ tools?: Array<Record<string, unknown>>;
121
+ tool_choice?: unknown;
122
+ reasoning?: { effort?: string | null; [key: string]: unknown } | null;
123
+ metadata?: Record<string, string>;
124
+ store?: boolean;
125
+ previous_response_id?: string | null;
126
+ conversation?: unknown;
127
+ parallel_tool_calls?: boolean;
128
+ [key: string]: unknown;
129
+ }
130
+
131
+ export interface AnthropicMessagesRequest {
132
+ model: string;
133
+ messages: ChatMessage[];
134
+ system?:
135
+ | string
136
+ | Array<{ type: string; text?: string; [key: string]: unknown }>;
137
+ stream?: boolean;
138
+ max_tokens?: number;
139
+ temperature?: number;
140
+ [key: string]: unknown;
141
+ }
142
+
143
+ export interface CompatCompletion {
144
+ text: string;
145
+ thinkingText?: string; // Gemini thought blocks (thought: true), emitted as reasoning_content
146
+ inputTokens: number;
147
+ outputTokens: number;
148
+ responseId?: string;
149
+ toolCalls?: OpenAIToolCall[];
150
+ }
151
+
152
+ export type AntigravityPart =
153
+ | { text: string }
154
+ | { inlineData: { mimeType: string; data: string } };
155
+ export type GeminiContent = { role: "user" | "model"; parts: unknown[] };
156
+
157
+ interface GeminiFunctionDeclaration {
158
+ name: string;
159
+ description?: string;
160
+ parameters?: Record<string, unknown>;
161
+ }
162
+
163
+ interface GeminiToolConfig {
164
+ functionCallingConfig: {
165
+ mode: "AUTO" | "NONE" | "ANY";
166
+ allowedFunctionNames?: string[];
167
+ };
168
+ }
169
+
170
+ export type ResponsesConversionResult = {
171
+ chatRequest: OpenAIChatCompletionRequest;
172
+ inputItems: Array<Record<string, unknown>>;
173
+ conversationMessages: ChatMessage[];
174
+ previousResponseId: string | null;
175
+ };
176
+
177
+ export function isNonEmptyString(value: unknown): value is string {
178
+ return typeof value === "string" && value.trim().length > 0;
179
+ }
180
+
181
+ function cleanCacheControl<T>(content: T): T {
182
+ if (!Array.isArray(content)) return content;
183
+ return content.map((block: Record<string, unknown>) => {
184
+ if (!block || typeof block !== "object") return block;
185
+ if ("cache_control" in block) {
186
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
187
+ const { cache_control: _cc, ...rest } = block;
188
+ return rest;
189
+ }
190
+ return block;
191
+ }) as T;
192
+ }
193
+
194
+ export function extractText(content: ChatMessage["content"]): string {
195
+ if (typeof content === "string") return content;
196
+ if (!Array.isArray(content)) return "";
197
+ return cleanCacheControl(content)
198
+ .filter(
199
+ (p: { type?: string; text?: string; thinking?: string }) =>
200
+ (p.type === "text" && typeof p.text === "string") ||
201
+ (p.type === "thinking" && typeof p.thinking === "string"),
202
+ )
203
+ .map((p: { type?: string; text?: string; thinking?: string }) =>
204
+ p.type === "thinking"
205
+ ? `[Thinking]\n${p.thinking}\n[/Thinking]`
206
+ : (p.text as string),
207
+ )
208
+ .join("\n");
209
+ }
210
+
211
+ export function dataUrlToInlineData(url: string): AntigravityPart | null {
212
+ const match = url.match(/^data:([^;,]+);base64,(.+)$/s);
213
+ if (!match) return null;
214
+ return { inlineData: { mimeType: match[1], data: match[2] } };
215
+ }
216
+
217
+ export function extractParts(
218
+ content: ChatMessage["content"],
219
+ ): AntigravityPart[] {
220
+ if (content === null) return [];
221
+ if (typeof content === "string") return content ? [{ text: content }] : [];
222
+ if (!Array.isArray(content)) return [];
223
+ const parts: AntigravityPart[] = [];
224
+ for (const part of content) {
225
+ if (part.type === "text" && typeof part.text === "string" && part.text) {
226
+ parts.push({ text: part.text });
227
+ continue;
228
+ }
229
+ if (
230
+ part.type === "thinking" &&
231
+ typeof part.thinking === "string" &&
232
+ part.thinking
233
+ ) {
234
+ parts.push({ text: `[Thinking]\n${part.thinking}\n[/Thinking]` });
235
+ continue;
236
+ }
237
+ if (
238
+ part.type === "image_url" &&
239
+ isRecord(part.image_url) &&
240
+ typeof part.image_url.url === "string"
241
+ ) {
242
+ const inline = dataUrlToInlineData(part.image_url.url);
243
+ if (inline) parts.push(inline);
244
+ continue;
245
+ }
246
+ if (
247
+ part.type === "image" &&
248
+ isRecord(part.source) &&
249
+ part.source.type === "base64" &&
250
+ typeof part.source.media_type === "string" &&
251
+ typeof part.source.data === "string"
252
+ ) {
253
+ parts.push({
254
+ inlineData: {
255
+ mimeType: part.source.media_type,
256
+ data: part.source.data,
257
+ },
258
+ });
259
+ }
260
+ }
261
+ return parts;
262
+ }
263
+
264
+ export function convertOpenAIToolsToGemini(
265
+ tools: OpenAITool[],
266
+ isClaude: boolean = false,
267
+ ): { functionDeclarations: GeminiFunctionDeclaration[] }[] {
268
+ const decls: GeminiFunctionDeclaration[] = tools
269
+ .filter((t) => t.type === "function" && isNonEmptyString(t.function?.name))
270
+ .map((t) => {
271
+ const sanitized = t.function.parameters
272
+ ? ((isClaude
273
+ ? sanitizeClaudeViaGeminiSchema(t.function.parameters)
274
+ : sanitizeGeminiSchema(t.function.parameters)) as Record<
275
+ string,
276
+ unknown
277
+ >)
278
+ : undefined;
279
+ return {
280
+ name: t.function.name,
281
+ ...(t.function.description
282
+ ? { description: t.function.description }
283
+ : {}),
284
+ ...(sanitized ? { parameters: sanitized } : {}),
285
+ };
286
+ });
287
+ return decls.length > 0 ? [{ functionDeclarations: decls }] : [];
288
+ }
289
+
290
+ export function convertToolChoiceToGemini(
291
+ toolChoice: unknown,
292
+ ): GeminiToolConfig | undefined {
293
+ if (!toolChoice || toolChoice === "none")
294
+ return { functionCallingConfig: { mode: "NONE" } };
295
+ if (toolChoice === "auto" || toolChoice === "required")
296
+ return { functionCallingConfig: { mode: "AUTO" } };
297
+ if (
298
+ isRecord(toolChoice) &&
299
+ toolChoice.type === "function" &&
300
+ isRecord(toolChoice.function) &&
301
+ isNonEmptyString(toolChoice.function.name)
302
+ ) {
303
+ return {
304
+ functionCallingConfig: {
305
+ mode: "ANY",
306
+ allowedFunctionNames: [toolChoice.function.name],
307
+ },
308
+ };
309
+ }
310
+ return { functionCallingConfig: { mode: "AUTO" } };
311
+ }
312
+
313
+ export function validateMessages(value: unknown): value is ChatMessage[] {
314
+ return (
315
+ Array.isArray(value) &&
316
+ value.every((msg) => {
317
+ if (!isRecord(msg)) return false;
318
+ if (
319
+ !["system", "developer", "user", "assistant", "model", "tool"].includes(
320
+ String(msg.role),
321
+ )
322
+ )
323
+ return false;
324
+ return (
325
+ typeof msg.content === "string" ||
326
+ msg.content === null ||
327
+ Array.isArray(msg.content)
328
+ );
329
+ })
330
+ );
331
+ }
332
+
333
+ export function extractTextFromUnknownContent(content: unknown): string {
334
+ if (typeof content === "string") return content;
335
+ if (!Array.isArray(content)) return "";
336
+ return content
337
+ .map((part) => {
338
+ if (typeof part === "string") return part;
339
+ if (!isRecord(part)) return "";
340
+ if (typeof part.text === "string") return part.text;
341
+ if (typeof part.input_text === "string") return part.input_text;
342
+ if (typeof part.output_text === "string") return part.output_text;
343
+ return "";
344
+ })
345
+ .filter(Boolean)
346
+ .join("\n");
347
+ }
348
+
349
+ export function normalizeContentBlocks(
350
+ content: unknown,
351
+ ): ChatMessage["content"] {
352
+ if (typeof content === "string" || content === null) return content;
353
+ if (!Array.isArray(content)) return extractTextFromUnknownContent(content);
354
+ const blocks = content.flatMap((part) => {
355
+ if (typeof part === "string") return [{ type: "text", text: part }];
356
+ if (!isRecord(part)) return [];
357
+ if (part.type === "input_text" && typeof part.text === "string")
358
+ return [{ type: "text", text: part.text }];
359
+ if (part.type === "output_text" && typeof part.text === "string")
360
+ return [{ type: "text", text: part.text }];
361
+ if (typeof part.text === "string")
362
+ return [
363
+ {
364
+ ...part,
365
+ type: typeof part.type === "string" ? part.type : "text",
366
+ text: part.text,
367
+ },
368
+ ];
369
+ if (typeof part.input_text === "string")
370
+ return [{ type: "text", text: part.input_text }];
371
+ if (typeof part.output_text === "string")
372
+ return [{ type: "text", text: part.output_text }];
373
+ if (part.type === "input_image" && typeof part.image_url === "string") {
374
+ return [{ type: "image_url", image_url: { url: part.image_url } }];
375
+ }
376
+ return [part as { type: string; text?: string; [key: string]: unknown }];
377
+ });
378
+ return blocks.length > 0 ? blocks : "";
379
+ }
380
+
381
+ export function normalizeInstructionsContent(
382
+ content: OpenAIResponsesRequest["instructions"],
383
+ ): ChatMessage["content"] {
384
+ if (typeof content === "string" || content === null || content === undefined)
385
+ return content ?? "";
386
+ return normalizeContentBlocks(content);
387
+ }
388
+
389
+ export function contentToResponseInputBlocks(
390
+ content: ChatMessage["content"],
391
+ role: string,
392
+ ): Array<Record<string, unknown>> {
393
+ if (typeof content === "string") {
394
+ if (!content) return [];
395
+ return [
396
+ {
397
+ type:
398
+ role === "assistant" || role === "model"
399
+ ? "output_text"
400
+ : "input_text",
401
+ text: content,
402
+ },
403
+ ];
404
+ }
405
+ if (!Array.isArray(content)) return [];
406
+ return cleanCacheControl(content).flatMap((part) => {
407
+ if (!isRecord(part)) return [];
408
+ if (typeof part.text === "string") {
409
+ return [
410
+ {
411
+ type:
412
+ role === "assistant" || role === "model"
413
+ ? "output_text"
414
+ : "input_text",
415
+ text: part.text,
416
+ },
417
+ ];
418
+ }
419
+ if (
420
+ part.type === "image_url" &&
421
+ isRecord(part.image_url) &&
422
+ typeof part.image_url.url === "string"
423
+ ) {
424
+ return [{ type: "input_image", image_url: part.image_url.url }];
425
+ }
426
+ return [part];
427
+ });
428
+ }
429
+
430
+ export function parseResponsesInput(
431
+ input: unknown,
432
+ callIdToName: Map<string, string> = new Map(),
433
+ ): ParsedResponsesInput {
434
+ if (typeof input === "string") {
435
+ return {
436
+ inputItems: [
437
+ {
438
+ id: makeCompatId("in"),
439
+ type: "message",
440
+ role: "user",
441
+ content: [{ type: "input_text", text: input }],
442
+ },
443
+ ],
444
+ messages: [{ role: "user", content: input }],
445
+ };
446
+ }
447
+ if (!Array.isArray(input)) return { inputItems: [], messages: [] };
448
+
449
+ const inputItems: Array<Record<string, unknown>> = [];
450
+ const messages: ChatMessage[] = [];
451
+
452
+ for (const rawItem of input) {
453
+ if (typeof rawItem === "string") {
454
+ inputItems.push({
455
+ id: makeCompatId("in"),
456
+ type: "message",
457
+ role: "user",
458
+ content: [{ type: "input_text", text: rawItem }],
459
+ });
460
+ messages.push({ role: "user", content: rawItem });
461
+ continue;
462
+ }
463
+ if (!isRecord(rawItem)) continue;
464
+
465
+ if (
466
+ rawItem.type === "function_call_output" &&
467
+ typeof rawItem.call_id === "string"
468
+ ) {
469
+ const outputText =
470
+ typeof rawItem.output === "string"
471
+ ? rawItem.output
472
+ : JSON.stringify(rawItem.output ?? "");
473
+ const toolName =
474
+ typeof rawItem.name === "string"
475
+ ? rawItem.name
476
+ : callIdToName.get(rawItem.call_id) || "unknown";
477
+ inputItems.push({
478
+ id: makeCompatId("in"),
479
+ type: "function_call_output",
480
+ call_id: rawItem.call_id,
481
+ output: outputText,
482
+ });
483
+ messages.push({
484
+ role: "tool",
485
+ content: outputText,
486
+ name: toolName,
487
+ tool_call_id: rawItem.call_id,
488
+ });
489
+ continue;
490
+ }
491
+
492
+ if (rawItem.type === "function_call" && typeof rawItem.name === "string") {
493
+ const callId =
494
+ typeof rawItem.call_id === "string"
495
+ ? rawItem.call_id
496
+ : makeCompatId("call");
497
+ const args =
498
+ typeof rawItem.arguments === "string"
499
+ ? rawItem.arguments
500
+ : JSON.stringify(rawItem.arguments ?? {});
501
+ inputItems.push({
502
+ id: makeCompatId("in"),
503
+ type: "function_call",
504
+ call_id: callId,
505
+ name: rawItem.name,
506
+ arguments: args,
507
+ });
508
+ const lastMsg = messages[messages.length - 1];
509
+ if (
510
+ lastMsg &&
511
+ lastMsg.role === "assistant" &&
512
+ Array.isArray(lastMsg.tool_calls)
513
+ ) {
514
+ lastMsg.tool_calls.push({
515
+ id: callId,
516
+ type: "function",
517
+ function: { name: rawItem.name, arguments: args },
518
+ });
519
+ } else {
520
+ messages.push({
521
+ role: "assistant",
522
+ content: null,
523
+ tool_calls: [
524
+ {
525
+ id: callId,
526
+ type: "function",
527
+ function: { name: rawItem.name, arguments: args },
528
+ },
529
+ ],
530
+ });
531
+ }
532
+ continue;
533
+ }
534
+
535
+ const isMessage =
536
+ rawItem.type === "message" ||
537
+ typeof rawItem.role === "string" ||
538
+ "content" in rawItem;
539
+ if (!isMessage) continue;
540
+ const rawRole = typeof rawItem.role === "string" ? rawItem.role : "user";
541
+ const role = rawRole === "developer" ? "system" : rawRole;
542
+ if (!["system", "user", "assistant", "model", "tool"].includes(role))
543
+ continue;
544
+ const content =
545
+ "content" in rawItem
546
+ ? normalizeContentBlocks(rawItem.content)
547
+ : extractTextFromUnknownContent(rawItem);
548
+ inputItems.push({
549
+ id: makeCompatId("in"),
550
+ type: "message",
551
+ role: rawRole,
552
+ content: contentToResponseInputBlocks(content, role),
553
+ });
554
+ messages.push({ role: role as ChatMessage["role"], content });
555
+ }
556
+
557
+ return { inputItems, messages };
558
+ }
559
+
560
+ type ParsedResponsesInput = {
561
+ inputItems: Array<Record<string, unknown>>;
562
+ messages: ChatMessage[];
563
+ };
564
+
565
+ export function messagesFromResponsesInput(
566
+ input: unknown,
567
+ ): ChatMessage[] | null {
568
+ if (typeof input === "string") return [{ role: "user", content: input }];
569
+ if (!Array.isArray(input)) return null;
570
+
571
+ const messages: ChatMessage[] = [];
572
+ for (const item of input) {
573
+ if (typeof item === "string") {
574
+ messages.push({ role: "user", content: item });
575
+ continue;
576
+ }
577
+ if (!isRecord(item)) continue;
578
+ const role = typeof item.role === "string" ? item.role : "user";
579
+ if (!["system", "user", "assistant", "model", "tool"].includes(role))
580
+ continue;
581
+ const content =
582
+ "content" in item
583
+ ? normalizeContentBlocks(item.content)
584
+ : extractTextFromUnknownContent(item);
585
+ messages.push({ role: role as ChatMessage["role"], content });
586
+ }
587
+ return messages.length > 0 ? messages : null;
588
+ }
589
+
590
+ export function messagesFromLooseMessages(
591
+ value: unknown,
592
+ ): ChatMessage[] | null {
593
+ if (typeof value === "string") return [{ role: "user", content: value }];
594
+ if (isRecord(value)) return messagesFromResponsesInput([value]);
595
+ return null;
596
+ }
597
+
598
+ export function messagesFromAntigravityRequest(
599
+ value: Record<string, unknown>,
600
+ ): ChatMessage[] | null {
601
+ const request = isRecord(value.request) ? value.request : null;
602
+ if (!request || !Array.isArray(request.contents)) return null;
603
+ const messages: ChatMessage[] = [];
604
+ if (
605
+ isRecord(request.systemInstruction) &&
606
+ Array.isArray(request.systemInstruction.parts)
607
+ ) {
608
+ const systemText = request.systemInstruction.parts
609
+ .map((part) =>
610
+ isRecord(part) && typeof part.text === "string" ? part.text : "",
611
+ )
612
+ .filter(Boolean)
613
+ .join("\n");
614
+ if (systemText) messages.push({ role: "system", content: systemText });
615
+ }
616
+ for (const turn of request.contents) {
617
+ if (!isRecord(turn) || !Array.isArray(turn.parts)) continue;
618
+ const role =
619
+ turn.role === "model" || turn.role === "assistant" ? "assistant" : "user";
620
+ const content = turn.parts
621
+ .map((part) =>
622
+ isRecord(part) && typeof part.text === "string" ? part.text : "",
623
+ )
624
+ .filter(Boolean)
625
+ .join("\n");
626
+ messages.push({ role, content });
627
+ }
628
+ return messages.length > 0 ? messages : null;
629
+ }
630
+
631
+ export function normalizeOpenAIChatCompletionRequest(value: unknown): unknown {
632
+ if (!isRecord(value) || Array.isArray(value.messages)) return value;
633
+ const messages = (() => {
634
+ if ("messages" in value) return messagesFromLooseMessages(value.messages);
635
+ if (typeof value.prompt === "string")
636
+ return [{ role: "user", content: value.prompt }];
637
+ if (Array.isArray(value.prompt))
638
+ return (
639
+ messagesFromResponsesInput(value.prompt) ??
640
+ value.prompt.map((prompt) => ({
641
+ role: "user",
642
+ content: String(prompt),
643
+ }))
644
+ );
645
+ if ("input" in value) return messagesFromResponsesInput(value.input);
646
+ return messagesFromAntigravityRequest(value);
647
+ })();
648
+ return messages ? { ...value, messages } : value;
649
+ }
650
+
651
+ export function normalizeOpenAIResponsesRequest(value: unknown): unknown {
652
+ if (!isRecord(value)) return value;
653
+
654
+ let normalized: Record<string, unknown> = { ...value };
655
+ if (Array.isArray(value.tools)) {
656
+ const before = value.tools.length;
657
+ const filtered: unknown[] = [];
658
+ for (const t of value.tools) {
659
+ if (!isRecord(t) || typeof t.type !== "string") continue;
660
+ if (t.type !== "function") continue;
661
+
662
+ if (isNonEmptyString(t.name) && !isRecord(t.function)) {
663
+ filtered.push({
664
+ type: "function",
665
+ function: {
666
+ name: t.name,
667
+ ...(typeof t.description === "string"
668
+ ? { description: t.description }
669
+ : {}),
670
+ ...(isRecord(t.parameters) ? { parameters: t.parameters } : {}),
671
+ },
672
+ });
673
+ continue;
674
+ }
675
+
676
+ if (isRecord(t.function) && isNonEmptyString(t.function.name)) {
677
+ filtered.push(t);
678
+ }
679
+ }
680
+ const dropped = before - filtered.length;
681
+ if (dropped > 0) {
682
+ compatLogger.warn(
683
+ `Filtered ${dropped} unsupported/unnamed tool(s) from Responses request (kept ${filtered.length} function tools)`,
684
+ );
685
+ }
686
+ normalized = {
687
+ ...normalized,
688
+ tools: filtered.length > 0 ? filtered : undefined,
689
+ };
690
+ }
691
+
692
+ if ("input" in normalized) return normalized;
693
+ if ("messages" in normalized)
694
+ return { ...normalized, input: normalized.messages };
695
+ if ("prompt" in normalized)
696
+ return { ...normalized, input: normalized.prompt };
697
+ return normalized;
698
+ }
699
+
700
+ export function normalizeAnthropicMessagesRequest(value: unknown): unknown {
701
+ if (!isRecord(value) || Array.isArray(value.messages)) return value;
702
+ const messages =
703
+ "messages" in value
704
+ ? messagesFromLooseMessages(value.messages)
705
+ : "input" in value
706
+ ? messagesFromResponsesInput(value.input)
707
+ : messagesFromAntigravityRequest(value);
708
+ return messages ? { ...value, messages } : value;
709
+ }
710
+
711
+ export function convertResponsesToChatRequest(
712
+ input: OpenAIResponsesRequest,
713
+ ): ResponsesConversionResult {
714
+ const previousResponseId = input.previous_response_id ?? null;
715
+ const previous = previousResponseId
716
+ ? getStoredResponse(previousResponseId)
717
+ : null;
718
+ if (previousResponseId && !previous) {
719
+ throw new Error(`previous_response_id not found: ${previousResponseId}`);
720
+ }
721
+
722
+ const previousCallIdToName = previous
723
+ ? new Map(Object.entries(previous.callIdToName))
724
+ : new Map<string, string>();
725
+ const previousConversationMessages: ChatMessage[] = previous
726
+ ? (previous.conversationMessages as unknown as ChatMessage[])
727
+ : [];
728
+
729
+ const parsed = parseResponsesInput(input.input, previousCallIdToName);
730
+ const conversationMessages = [
731
+ ...previousConversationMessages,
732
+ ...parsed.messages,
733
+ ];
734
+ const chatMessages = [
735
+ ...(input.instructions
736
+ ? [
737
+ {
738
+ role: "system" as const,
739
+ content: normalizeInstructionsContent(input.instructions),
740
+ },
741
+ ]
742
+ : []),
743
+ ...conversationMessages,
744
+ ];
745
+
746
+ return {
747
+ chatRequest: {
748
+ model: input.model,
749
+ messages: chatMessages,
750
+ stream: input.stream,
751
+ temperature: input.temperature,
752
+ max_tokens: input.max_output_tokens,
753
+ tools: input.tools as OpenAITool[] | undefined,
754
+ tool_choice: input.tool_choice,
755
+ reasoning_effort:
756
+ typeof input.reasoning?.effort === "string"
757
+ ? input.reasoning.effort
758
+ : undefined,
759
+ parallel_tool_calls: input.parallel_tool_calls,
760
+ },
761
+ inputItems: parsed.inputItems,
762
+ conversationMessages,
763
+ previousResponseId,
764
+ };
765
+ }
766
+
767
+ export function openAIToAntigravityBody(
768
+ input: OpenAIChatCompletionRequest,
769
+ ): RequestBody {
770
+ const systemParts: string[] = [];
771
+ const conversationMessages = input.messages.filter((msg) => {
772
+ if (msg.role === "system" || msg.role === "developer") {
773
+ const text =
774
+ typeof msg.content === "string"
775
+ ? msg.content
776
+ : extractText(msg.content);
777
+ if (text) systemParts.push(text);
778
+ return false;
779
+ }
780
+ return true;
781
+ });
782
+
783
+ const isClaude = /^claude-/i.test(input.model);
784
+ const isThinking = isThinkingModel(input.model);
785
+ const isGeminiThinking = !isClaude && isThinking;
786
+
787
+ const contents: GeminiContent[] = [];
788
+ for (let i = 0; i < conversationMessages.length; i++) {
789
+ const msg = conversationMessages[i];
790
+ if (msg.role === "assistant" || msg.role === "model") {
791
+ let msgToolCalls = msg.tool_calls;
792
+ if (Array.isArray(msgToolCalls) && msgToolCalls.length > 0) {
793
+ const completedToolCallIds = new Set<string>();
794
+ let j = i + 1;
795
+ while (
796
+ j < conversationMessages.length &&
797
+ conversationMessages[j].role === "tool"
798
+ ) {
799
+ const toolCallId = conversationMessages[j].tool_call_id;
800
+ if (typeof toolCallId === "string") {
801
+ completedToolCallIds.add(toolCallId);
802
+ }
803
+ j++;
804
+ }
805
+ msgToolCalls = msgToolCalls.filter(
806
+ (tc) => tc.id && completedToolCallIds.has(tc.id),
807
+ );
808
+ if (msgToolCalls.length === 0) {
809
+ msgToolCalls = undefined;
810
+ }
811
+ }
812
+
813
+ const hasMissingSig =
814
+ isGeminiThinking &&
815
+ Array.isArray(msgToolCalls) &&
816
+ msgToolCalls.length > 0 &&
817
+ !thoughtSignatureCache.has(msgToolCalls[0].id);
818
+
819
+ if (hasMissingSig) {
820
+ const toolNames = msgToolCalls!
821
+ .map((tc) => tc.function.name)
822
+ .join(", ");
823
+ const resultParts: string[] = [];
824
+ while (
825
+ i + 1 < conversationMessages.length &&
826
+ conversationMessages[i + 1].role === "tool"
827
+ ) {
828
+ i++;
829
+ const toolMsg = conversationMessages[i];
830
+ const toolText =
831
+ typeof toolMsg.content === "string"
832
+ ? toolMsg.content
833
+ : extractText(toolMsg.content);
834
+ resultParts.push(
835
+ `${toolMsg.name || "tool"}: ${toolText.slice(0, 500)}`,
836
+ );
837
+ }
838
+ const summaryText = `[Context: The assistant used tools (${toolNames}) and received results:\n${resultParts.join("\n")}]`;
839
+ contents.push({ role: "user", parts: [{ text: summaryText }] });
840
+ contents.push({
841
+ role: "model",
842
+ parts: [{ text: "Understood, I have the tool results." }],
843
+ });
844
+ continue;
845
+ }
846
+
847
+ const parts: unknown[] = [];
848
+ if (msg.content) {
849
+ const textContent =
850
+ typeof msg.content === "string"
851
+ ? msg.content
852
+ : extractText(msg.content);
853
+ if (textContent) parts.push({ text: textContent });
854
+ }
855
+ if (Array.isArray(msgToolCalls) && msgToolCalls.length > 0) {
856
+ let isFirstInMessage = true;
857
+ for (const tc of msgToolCalls) {
858
+ let args: unknown;
859
+ try {
860
+ args =
861
+ typeof tc.function.arguments === "string"
862
+ ? JSON.parse(tc.function.arguments)
863
+ : tc.function.arguments;
864
+ } catch {
865
+ args = {};
866
+ }
867
+ const cachedSig = isFirstInMessage
868
+ ? thoughtSignatureCache.get(tc.id)
869
+ : undefined;
870
+ parts.push({
871
+ ...(cachedSig ? { thoughtSignature: cachedSig } : {}),
872
+ functionCall: {
873
+ ...(isClaude ? { id: tc.id } : {}),
874
+ name: tc.function.name,
875
+ args,
876
+ },
877
+ });
878
+ isFirstInMessage = false;
879
+ }
880
+ }
881
+ if (parts.length === 0) {
882
+ parts.push({ text: "..." });
883
+ }
884
+ if (parts.length > 0) {
885
+ if (isClaude) {
886
+ const lastContent = contents[contents.length - 1];
887
+ const prevHasFunctionCall =
888
+ lastContent &&
889
+ lastContent.role === "model" &&
890
+ lastContent.parts.some((p: any) => p.functionCall);
891
+ const hasFunctionCall = parts.some((p: any) => p.functionCall);
892
+ if (prevHasFunctionCall && !hasFunctionCall) {
893
+ // Skip
894
+ } else if (hasFunctionCall) {
895
+ const fcOnly = parts.filter((p: any) => p.functionCall);
896
+ if (prevHasFunctionCall) {
897
+ lastContent.parts.push(...fcOnly);
898
+ } else {
899
+ contents.push({ role: "model", parts: fcOnly });
900
+ }
901
+ } else {
902
+ contents.push({ role: "model", parts });
903
+ }
904
+ } else {
905
+ contents.push({ role: "model", parts });
906
+ }
907
+ }
908
+ } else if (msg.role === "tool") {
909
+ const responseText =
910
+ typeof msg.content === "string"
911
+ ? msg.content
912
+ : extractText(msg.content);
913
+ const fnName = msg.name || "unknown";
914
+ const toolCallId = msg.tool_call_id;
915
+ let responseData: unknown;
916
+ try {
917
+ const parsed = JSON.parse(responseText);
918
+ responseData =
919
+ parsed !== null &&
920
+ typeof parsed === "object" &&
921
+ !Array.isArray(parsed)
922
+ ? parsed
923
+ : { output: parsed };
924
+ } catch {
925
+ responseData = { output: responseText };
926
+ }
927
+ const toolImages: AntigravityPart[] = [];
928
+ if (msg.content && Array.isArray(msg.content)) {
929
+ for (const part of msg.content) {
930
+ if (
931
+ part.type === "image_url" &&
932
+ isRecord(part.image_url) &&
933
+ typeof part.image_url.url === "string"
934
+ ) {
935
+ const inlineData = dataUrlToInlineData(part.image_url.url);
936
+ if (inlineData) toolImages.push(inlineData);
937
+ } else if (
938
+ part.type === "image" &&
939
+ isRecord(part.source) &&
940
+ typeof part.source.data === "string"
941
+ ) {
942
+ const mediaType =
943
+ typeof part.source.media_type === "string"
944
+ ? part.source.media_type
945
+ : "image/png";
946
+ toolImages.push({
947
+ inlineData: { mimeType: mediaType, data: part.source.data },
948
+ });
949
+ }
950
+ }
951
+ }
952
+
953
+ const fnResponsePart = {
954
+ functionResponse: {
955
+ ...(isClaude && toolCallId ? { id: toolCallId } : {}),
956
+ name: fnName,
957
+ response: responseData,
958
+ },
959
+ };
960
+ const lastContent = contents[contents.length - 1];
961
+ if (
962
+ lastContent &&
963
+ lastContent.role === "user" &&
964
+ Array.isArray(lastContent.parts) &&
965
+ lastContent.parts.length > 0 &&
966
+ isRecord(lastContent.parts[0] as any) &&
967
+ (lastContent.parts[0] as any).functionResponse !== undefined
968
+ ) {
969
+ const firstNonFnIdx = lastContent.parts.findIndex(
970
+ (p: any) => !isRecord(p) || p.functionResponse === undefined,
971
+ );
972
+ if (firstNonFnIdx === -1) {
973
+ lastContent.parts.push(fnResponsePart);
974
+ } else {
975
+ lastContent.parts.splice(firstNonFnIdx, 0, fnResponsePart);
976
+ }
977
+ if (toolImages.length > 0) {
978
+ lastContent.parts.push(...toolImages);
979
+ }
980
+ } else {
981
+ contents.push({ role: "user", parts: [fnResponsePart, ...toolImages] });
982
+ }
983
+ } else {
984
+ const msgParts = extractParts(msg.content);
985
+ if (msgParts.length > 0) contents.push({ role: "user", parts: msgParts });
986
+ }
987
+ }
988
+
989
+ if (contents.length === 0)
990
+ contents.push({ role: "user", parts: [{ text: "Hello" }] });
991
+
992
+ const inputTools = Array.isArray(input.tools)
993
+ ? (input.tools as OpenAITool[])
994
+ : [];
995
+ const geminiTools = convertOpenAIToolsToGemini(inputTools, isClaude);
996
+ const geminiToolConfig =
997
+ input.tool_choice !== undefined
998
+ ? convertToolChoiceToGemini(input.tool_choice)
999
+ : undefined;
1000
+
1001
+ const modelSpec = getModelSpec(input.model);
1002
+ const modelFamily = getModelFamily(input.model);
1003
+ let maxOutputTokens =
1004
+ typeof input.max_tokens === "number" ? input.max_tokens : undefined;
1005
+ if (maxOutputTokens && maxOutputTokens > modelSpec.maxOutputTokens) {
1006
+ compatLogger.debug(
1007
+ `Capping ${input.model} maxOutputTokens ${maxOutputTokens} → ${modelSpec.maxOutputTokens}`,
1008
+ );
1009
+ maxOutputTokens = modelSpec.maxOutputTokens;
1010
+ }
1011
+
1012
+ let thinkingConfigObj: Record<string, unknown> | undefined;
1013
+ if (modelFamily === "claude" && isThinking) {
1014
+ const tb = modelSpec.thinkingBudget;
1015
+ thinkingConfigObj = { include_thoughts: true, thinking_budget: tb };
1016
+ if (!maxOutputTokens || maxOutputTokens <= tb) {
1017
+ maxOutputTokens = Math.min(tb + 8192, modelSpec.maxOutputTokens);
1018
+ compatLogger.debug(
1019
+ `Adjusted Claude maxOutputTokens → ${maxOutputTokens}`,
1020
+ );
1021
+ }
1022
+ } else if (isThinking) {
1023
+ const tb = modelSpec.thinkingBudget;
1024
+ thinkingConfigObj =
1025
+ tb === -1
1026
+ ? { includeThoughts: true }
1027
+ : { includeThoughts: true, thinkingBudget: tb };
1028
+ if (tb !== -1 && (!maxOutputTokens || maxOutputTokens <= tb)) {
1029
+ maxOutputTokens = Math.min(tb + 8192, modelSpec.maxOutputTokens);
1030
+ compatLogger.debug(
1031
+ `Adjusted Gemini maxOutputTokens → ${maxOutputTokens}`,
1032
+ );
1033
+ }
1034
+ } else if (input.reasoning_effort) {
1035
+ const budgets: Record<string, number> = {
1036
+ low: Math.round(modelSpec.thinkingBudget / 4),
1037
+ medium: Math.round(modelSpec.thinkingBudget / 2),
1038
+ high: modelSpec.thinkingBudget,
1039
+ };
1040
+ const b = budgets[input.reasoning_effort.toLowerCase()];
1041
+ if (b) thinkingConfigObj = { includeThoughts: true, thinkingBudget: b };
1042
+ }
1043
+
1044
+ const generationConfig: Record<string, unknown> = {
1045
+ ...(typeof input.temperature === "number"
1046
+ ? { temperature: input.temperature }
1047
+ : {}),
1048
+ ...(maxOutputTokens ? { maxOutputTokens } : {}),
1049
+ ...(thinkingConfigObj ? { thinkingConfig: thinkingConfigObj } : {}),
1050
+ };
1051
+
1052
+ const request: Record<string, unknown> = {
1053
+ contents,
1054
+ generationConfig,
1055
+ };
1056
+
1057
+ if (systemParts.length > 0) {
1058
+ if (!isClaude && isThinking) {
1059
+ const firstTurn = contents[0];
1060
+ if (
1061
+ firstTurn &&
1062
+ firstTurn.role === "user" &&
1063
+ (firstTurn.parts[0] as any)?.text !== undefined
1064
+ ) {
1065
+ (firstTurn.parts[0] as any).text =
1066
+ systemParts.join("\n\n") + "\n\n" + (firstTurn.parts[0] as any).text;
1067
+ } else if (firstTurn && firstTurn.role === "user") {
1068
+ firstTurn.parts.unshift({ text: systemParts.join("\n\n") + "\n\n" });
1069
+ } else {
1070
+ contents.unshift({
1071
+ role: "user",
1072
+ parts: [{ text: systemParts.join("\n\n") }],
1073
+ });
1074
+ }
1075
+ } else {
1076
+ request.systemInstruction = {
1077
+ role: "system",
1078
+ parts: [{ text: systemParts.join("\n\n") }],
1079
+ };
1080
+ }
1081
+ }
1082
+
1083
+ if (geminiTools.length > 0) request.tools = geminiTools;
1084
+ if (geminiToolConfig) request.toolConfig = geminiToolConfig;
1085
+
1086
+ const mappedModel = applyModelAlias(input.model);
1087
+
1088
+ return {
1089
+ project: "compat-placeholder",
1090
+ model: mappedModel,
1091
+ displayModel: input.model,
1092
+ userAgent: "antigravity",
1093
+ requestType: "agent",
1094
+ request,
1095
+ };
1096
+ }
1097
+
1098
+ export function convertAnthropicToolsToOpenAI(
1099
+ tools: unknown,
1100
+ ): OpenAITool[] | undefined {
1101
+ if (!Array.isArray(tools) || tools.length === 0) return undefined;
1102
+ const result: OpenAITool[] = [];
1103
+ for (const t of tools) {
1104
+ if (!isRecord(t) || !isNonEmptyString(t.name)) continue;
1105
+ result.push({
1106
+ type: "function",
1107
+ function: {
1108
+ name: t.name as string,
1109
+ ...(typeof t.description === "string"
1110
+ ? { description: t.description }
1111
+ : {}),
1112
+ ...(isRecord(t.input_schema)
1113
+ ? { parameters: t.input_schema as Record<string, unknown> }
1114
+ : {}),
1115
+ },
1116
+ });
1117
+ }
1118
+ return result.length > 0 ? result : undefined;
1119
+ }
1120
+
1121
+ export function convertAnthropicToolChoice(toolChoice: unknown): unknown {
1122
+ if (!isRecord(toolChoice)) return toolChoice;
1123
+ if (toolChoice.type === "auto") return "auto";
1124
+ if (toolChoice.type === "any") return "required";
1125
+ if (toolChoice.type === "tool" && isNonEmptyString(toolChoice.name)) {
1126
+ return { type: "function", function: { name: toolChoice.name } };
1127
+ }
1128
+ return "auto";
1129
+ }
1130
+
1131
+ export function convertAnthropicMessagesToOpenAI(
1132
+ messages: ChatMessage[],
1133
+ ): ChatMessage[] {
1134
+ const result: ChatMessage[] = [];
1135
+ for (const msg of messages) {
1136
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
1137
+ const blocks = msg.content as Array<Record<string, unknown>>;
1138
+ const toolUseBlocks = blocks.filter(
1139
+ (b) => isRecord(b) && b.type === "tool_use" && isNonEmptyString(b.name),
1140
+ );
1141
+ if (toolUseBlocks.length > 0) {
1142
+ const textParts = blocks
1143
+ .filter(
1144
+ (b) =>
1145
+ isRecord(b) && b.type === "text" && typeof b.text === "string",
1146
+ )
1147
+ .map((b) => b.text as string)
1148
+ .join("");
1149
+ const toolCalls: OpenAIToolCall[] = toolUseBlocks.map((b) => ({
1150
+ id: (b.id as string) || `call_${Date.now().toString(36)}`,
1151
+ type: "function" as const,
1152
+ function: {
1153
+ name: b.name as string,
1154
+ arguments:
1155
+ typeof b.input === "string"
1156
+ ? b.input
1157
+ : JSON.stringify(b.input ?? {}),
1158
+ },
1159
+ }));
1160
+ result.push({
1161
+ role: "assistant",
1162
+ content: textParts || null,
1163
+ tool_calls: toolCalls,
1164
+ });
1165
+ continue;
1166
+ }
1167
+ }
1168
+ if (msg.role === "user" && Array.isArray(msg.content)) {
1169
+ const blocks = msg.content as Array<Record<string, unknown>>;
1170
+ const toolResults = blocks.filter(
1171
+ (b) => isRecord(b) && b.type === "tool_result",
1172
+ );
1173
+ if (toolResults.length > 0) {
1174
+ const otherBlocks = blocks.filter(
1175
+ (b) => !isRecord(b) || b.type !== "tool_result",
1176
+ );
1177
+ if (otherBlocks.length > 0) {
1178
+ result.push({
1179
+ role: "user",
1180
+ content: otherBlocks as ChatMessage["content"],
1181
+ });
1182
+ }
1183
+ for (const tr of toolResults) {
1184
+ const content =
1185
+ typeof tr.content === "string"
1186
+ ? tr.content
1187
+ : Array.isArray(tr.content)
1188
+ ? extractTextFromUnknownContent(tr.content)
1189
+ : JSON.stringify(tr.content ?? "");
1190
+ result.push({
1191
+ role: "tool",
1192
+ content,
1193
+ tool_call_id: tr.tool_use_id as string,
1194
+ });
1195
+ }
1196
+ continue;
1197
+ }
1198
+ }
1199
+ result.push(msg);
1200
+ }
1201
+ return result;
1202
+ }
1203
+
1204
+ export function anthropicToAntigravityBody(
1205
+ input: AnthropicMessagesRequest,
1206
+ ): RequestBody {
1207
+ const systemText =
1208
+ typeof input.system === "string"
1209
+ ? input.system
1210
+ : Array.isArray(input.system)
1211
+ ? extractText(input.system as ChatMessage["content"])
1212
+ : "";
1213
+ const tools = convertAnthropicToolsToOpenAI(input.tools);
1214
+ const toolChoice = convertAnthropicToolChoice(input.tool_choice);
1215
+ const convertedMessages = convertAnthropicMessagesToOpenAI(input.messages);
1216
+ return openAIToAntigravityBody({
1217
+ model: input.model,
1218
+ stream: input.stream,
1219
+ temperature: input.temperature,
1220
+ max_tokens: input.max_tokens,
1221
+ tools,
1222
+ tool_choice: toolChoice,
1223
+ messages: [
1224
+ ...(systemText ? [{ role: "system" as const, content: systemText }] : []),
1225
+ ...convertedMessages,
1226
+ ],
1227
+ });
1228
+ }
1229
+
1230
+ export function mapReasoningEffortToThinkingLevel(
1231
+ effort: string | undefined,
1232
+ modelId: string,
1233
+ ): number | undefined {
1234
+ const lowerModel = modelId.toLowerCase();
1235
+ const isGemini31Pro = /gemini-3\.1-pro/i.test(modelId);
1236
+ const isGemini3Flash =
1237
+ lowerModel.includes("gemini-3-flash") ||
1238
+ lowerModel.includes("gemini-3.5-flash");
1239
+
1240
+ let effectiveEffort = effort;
1241
+ if (!effectiveEffort) {
1242
+ if (lowerModel.endsWith("-high") || lowerModel.includes("gemini-pro-agent"))
1243
+ effectiveEffort = "high";
1244
+ else if (lowerModel.endsWith("-low")) effectiveEffort = "low";
1245
+ else if (isGemini3Flash) effectiveEffort = "high";
1246
+ }
1247
+
1248
+ if (!effectiveEffort) return undefined;
1249
+
1250
+ if (isGemini31Pro) {
1251
+ switch (effectiveEffort.toLowerCase()) {
1252
+ case "high":
1253
+ return 10001;
1254
+ case "medium":
1255
+ return 5000;
1256
+ case "low":
1257
+ return 1001;
1258
+ default:
1259
+ return undefined;
1260
+ }
1261
+ }
1262
+
1263
+ if (isGemini3Flash) {
1264
+ switch (effectiveEffort.toLowerCase()) {
1265
+ case "high":
1266
+ return -1;
1267
+ case "medium":
1268
+ return 4096;
1269
+ case "low":
1270
+ return 1024;
1271
+ default:
1272
+ return undefined;
1273
+ }
1274
+ }
1275
+
1276
+ return undefined;
1277
+ }
1278
+
1279
+ export function validateOpenAIChatCompletionRequest(
1280
+ value: unknown,
1281
+ ):
1282
+ | { ok: true; value: OpenAIChatCompletionRequest }
1283
+ | { ok: false; errors: string[] } {
1284
+ if (!isRecord(value))
1285
+ return { ok: false, errors: ["body must be a JSON object"] };
1286
+ const errors: string[] = [];
1287
+ if (!isNonEmptyString(value.model))
1288
+ errors.push("body.model must be a non-empty string");
1289
+ if (!validateMessages(value.messages)) {
1290
+ logValidationFailure("OpenAI messages validation failed", value.messages);
1291
+ errors.push("body.messages must be an array of chat messages");
1292
+ }
1293
+ if (value.stream !== undefined && typeof value.stream !== "boolean")
1294
+ errors.push("body.stream must be boolean when provided");
1295
+ if (value.temperature !== undefined && typeof value.temperature !== "number")
1296
+ errors.push("body.temperature must be number when provided");
1297
+ if (value.max_tokens !== undefined && typeof value.max_tokens !== "number")
1298
+ errors.push("body.max_tokens must be number when provided");
1299
+ return errors.length > 0
1300
+ ? { ok: false, errors }
1301
+ : { ok: true, value: value as unknown as OpenAIChatCompletionRequest };
1302
+ }
1303
+
1304
+ function validateResponsesTools(value: unknown): string[] {
1305
+ if (value === undefined) return [];
1306
+ if (!Array.isArray(value))
1307
+ return ["body.tools must be an array when provided"];
1308
+ const errors: string[] = [];
1309
+ for (const tool of value) {
1310
+ if (!isRecord(tool)) {
1311
+ errors.push("each tool must be an object");
1312
+ continue;
1313
+ }
1314
+ if (tool.type !== "function") {
1315
+ errors.push(`only function tools are supported (got: ${tool.type})`);
1316
+ }
1317
+ }
1318
+ return errors;
1319
+ }
1320
+
1321
+ export function validateOpenAIResponsesRequest(
1322
+ value: unknown,
1323
+ ):
1324
+ | { ok: true; value: OpenAIResponsesRequest }
1325
+ | { ok: false; errors: string[] } {
1326
+ if (!isRecord(value))
1327
+ return { ok: false, errors: ["body must be a JSON object"] };
1328
+ const errors: string[] = [];
1329
+ if (!isNonEmptyString(value.model))
1330
+ errors.push("body.model must be a non-empty string");
1331
+ if (value.stream !== undefined && typeof value.stream !== "boolean")
1332
+ errors.push("body.stream must be boolean when provided");
1333
+ if (value.temperature !== undefined && typeof value.temperature !== "number")
1334
+ errors.push("body.temperature must be number when provided");
1335
+ if (
1336
+ value.max_output_tokens !== undefined &&
1337
+ typeof value.max_output_tokens !== "number"
1338
+ )
1339
+ errors.push("body.max_output_tokens must be number when provided");
1340
+ if (value.store !== undefined && typeof value.store !== "boolean")
1341
+ errors.push("body.store must be boolean when provided");
1342
+ if (
1343
+ value.previous_response_id !== undefined &&
1344
+ value.previous_response_id !== null &&
1345
+ !isNonEmptyString(value.previous_response_id)
1346
+ ) {
1347
+ errors.push("body.previous_response_id must be a non-empty string or null");
1348
+ }
1349
+ if (value.conversation !== undefined && value.conversation !== null) {
1350
+ errors.push(
1351
+ "body.conversation is not supported; use previous_response_id instead",
1352
+ );
1353
+ }
1354
+ if (value.metadata !== undefined && !isRecord(value.metadata))
1355
+ errors.push("body.metadata must be an object when provided");
1356
+ if (
1357
+ value.reasoning !== undefined &&
1358
+ value.reasoning !== null &&
1359
+ !isRecord(value.reasoning)
1360
+ ) {
1361
+ errors.push("body.reasoning must be an object when provided");
1362
+ } else if (
1363
+ isRecord(value.reasoning) &&
1364
+ value.reasoning.effort !== undefined &&
1365
+ value.reasoning.effort !== null &&
1366
+ typeof value.reasoning.effort !== "string"
1367
+ ) {
1368
+ errors.push("body.reasoning.effort must be a string when provided");
1369
+ }
1370
+ if (
1371
+ value.instructions !== undefined &&
1372
+ value.instructions !== null &&
1373
+ typeof value.instructions !== "string" &&
1374
+ !Array.isArray(value.instructions)
1375
+ ) {
1376
+ errors.push(
1377
+ "body.instructions must be a string or content array when provided",
1378
+ );
1379
+ }
1380
+ errors.push(...validateResponsesTools(value.tools));
1381
+ return errors.length > 0
1382
+ ? { ok: false, errors }
1383
+ : { ok: true, value: value as unknown as OpenAIResponsesRequest };
1384
+ }
1385
+
1386
+ export function validateAnthropicMessagesRequest(
1387
+ value: unknown,
1388
+ ):
1389
+ | { ok: true; value: AnthropicMessagesRequest }
1390
+ | { ok: false; errors: string[] } {
1391
+ if (!isRecord(value))
1392
+ return { ok: false, errors: ["body must be a JSON object"] };
1393
+ const errors: string[] = [];
1394
+ if (!isNonEmptyString(value.model))
1395
+ errors.push("body.model must be a non-empty string");
1396
+ if (!validateMessages(value.messages))
1397
+ errors.push("body.messages must be an array of chat messages");
1398
+ if (
1399
+ value.system !== undefined &&
1400
+ typeof value.system !== "string" &&
1401
+ !Array.isArray(value.system)
1402
+ )
1403
+ errors.push("body.system must be string or content array when provided");
1404
+ if (value.stream !== undefined && typeof value.stream !== "boolean")
1405
+ errors.push("body.stream must be boolean when provided");
1406
+ if (value.temperature !== undefined && typeof value.temperature !== "number")
1407
+ errors.push("body.temperature must be number when provided");
1408
+ if (value.max_tokens !== undefined && typeof value.max_tokens !== "number")
1409
+ errors.push("body.max_tokens must be number when provided");
1410
+ return errors.length > 0
1411
+ ? { ok: false, errors }
1412
+ : { ok: true, value: value as unknown as AnthropicMessagesRequest };
1413
+ }
1414
+
1415
+ export function responseUsageFromCompletion(
1416
+ completion: CompatCompletion,
1417
+ ): Record<string, unknown> {
1418
+ return {
1419
+ input_tokens: completion.inputTokens,
1420
+ input_tokens_details: { cached_tokens: 0 },
1421
+ output_tokens: completion.outputTokens,
1422
+ output_tokens_details: { reasoning_tokens: 0 },
1423
+ total_tokens: completion.inputTokens + completion.outputTokens,
1424
+ };
1425
+ }
1426
+
1427
+ export function buildResponsesOutput(completion: CompatCompletion): {
1428
+ output: ResponseOutputItem[];
1429
+ outputText: string;
1430
+ callIdToName: Map<string, string>;
1431
+ } {
1432
+ const output: ResponseOutputItem[] = [];
1433
+ const callIdToName = new Map<string, string>();
1434
+ if (completion.thinkingText) {
1435
+ output.push({
1436
+ id: makeCompatId("rs"),
1437
+ type: "reasoning",
1438
+ status: "completed",
1439
+ summary: [{ type: "summary_text", text: completion.thinkingText }],
1440
+ } as unknown as ResponseOutputItem);
1441
+ }
1442
+ if (completion.text) {
1443
+ output.push({
1444
+ id: makeCompatId("msg"),
1445
+ type: "message",
1446
+ status: "completed",
1447
+ role: "assistant",
1448
+ content: [
1449
+ { type: "output_text", text: completion.text, annotations: [] },
1450
+ ],
1451
+ });
1452
+ }
1453
+ for (const toolCall of completion.toolCalls ?? []) {
1454
+ callIdToName.set(toolCall.id, toolCall.function.name);
1455
+ output.push({
1456
+ id: makeCompatId("fc"),
1457
+ type: "function_call",
1458
+ call_id: toolCall.id,
1459
+ name: toolCall.function.name,
1460
+ arguments: toolCall.function.arguments,
1461
+ status: "completed",
1462
+ });
1463
+ }
1464
+ return { output, outputText: completion.text, callIdToName };
1465
+ }
1466
+
1467
+ export function buildAssistantMessageFromCompletion(
1468
+ completion: CompatCompletion,
1469
+ ): ChatMessage {
1470
+ return completion.toolCalls && completion.toolCalls.length > 0
1471
+ ? {
1472
+ role: "assistant",
1473
+ content: completion.text || null,
1474
+ tool_calls: completion.toolCalls,
1475
+ }
1476
+ : { role: "assistant", content: completion.text };
1477
+ }
1478
+
1479
+ export function buildResponsesResponse(
1480
+ request: OpenAIResponsesRequest,
1481
+ responseId: string,
1482
+ createdAt: number,
1483
+ completion: CompatCompletion,
1484
+ status: "in_progress" | "completed" | "cancelled",
1485
+ previousResponseId: string | null,
1486
+ ): Record<string, unknown> {
1487
+ const { output, outputText } = buildResponsesOutput(completion);
1488
+ return {
1489
+ id: responseId,
1490
+ object: "response",
1491
+ created_at: createdAt,
1492
+ status,
1493
+ error: null,
1494
+ incomplete_details: null,
1495
+ instructions: request.instructions ?? null,
1496
+ max_output_tokens: request.max_output_tokens ?? null,
1497
+ model: request.model,
1498
+ output,
1499
+ output_text: outputText,
1500
+ parallel_tool_calls: request.parallel_tool_calls ?? true,
1501
+ previous_response_id: previousResponseId,
1502
+ reasoning: { effort: request.reasoning?.effort ?? null },
1503
+ store: request.store !== false,
1504
+ temperature: request.temperature ?? null,
1505
+ text: { format: { type: "text" } },
1506
+ tool_choice: request.tool_choice ?? "auto",
1507
+ tools: Array.isArray(request.tools) ? request.tools : [],
1508
+ top_p: null,
1509
+ truncation: "disabled",
1510
+ usage: responseUsageFromCompletion(completion),
1511
+ metadata: isRecord(request.metadata) ? request.metadata : {},
1512
+ };
1513
+ }
1514
+
1515
+ export function saveResponsesEntry(
1516
+ response: Record<string, unknown>,
1517
+ inputItems: Array<Record<string, unknown>>,
1518
+ conversationMessages: ChatMessage[],
1519
+ completion: CompatCompletion,
1520
+ ): void {
1521
+ const responseId = typeof response.id === "string" ? response.id : null;
1522
+ if (!responseId) return;
1523
+ const { callIdToName } = buildResponsesOutput(completion);
1524
+ const mergedConversation = [
1525
+ ...conversationMessages,
1526
+ buildAssistantMessageFromCompletion(completion),
1527
+ ];
1528
+ const expiresAt = Date.now() + 6 * 60 * 60 * 1000;
1529
+ setStoredResponse(responseId, {
1530
+ response,
1531
+ inputItems,
1532
+ conversationMessages: mergedConversation as unknown as Array<
1533
+ Record<string, unknown>
1534
+ >,
1535
+ callIdToName: Object.fromEntries(callIdToName),
1536
+ expiresAt,
1537
+ });
1538
+ }