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/CHANGELOG.md +12 -4
- package/README.md +5 -4
- package/package.json +3 -1
- package/src/compat/cache.ts +42 -0
- package/src/compat/model-specs.ts +78 -0
- package/src/compat/schema-sanitizer.ts +277 -0
- package/src/compat/translators.ts +1538 -0
- package/src/compat.ts +1535 -2444
- package/src/notification-poller.ts +1 -1
- package/src/telemetry.ts +1 -1
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 {
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
860
|
-
if (!isRecord(value)) return value;
|
|
105
|
+
// Interfaces and types have been moved to src/compat/translators.ts
|
|
861
106
|
|
|
862
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
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(
|
|
1667
|
-
|
|
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
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
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
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
274
|
+
body: unknown,
|
|
275
|
+
req: IncomingMessage,
|
|
276
|
+
res: ServerResponse,
|
|
277
|
+
model: string,
|
|
278
|
+
format: "openai" | "anthropic",
|
|
1762
279
|
): Promise<CompatCompletion> {
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
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
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
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
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
): Promise<{
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
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
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
): Promise<{
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
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
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
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
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
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
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
}
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
}
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
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
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
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
|
}
|