free-antigravity-cli 1.0.0
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/LICENSE +201 -0
- package/README.md +142 -0
- package/dist/chat.d.ts +2 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +212 -0
- package/dist/chat.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +216 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +125 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.d.ts +5 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +93 -0
- package/dist/crypto.js.map +1 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +10 -0
- package/dist/logger.js.map +1 -0
- package/dist/proxy/modelUtils.d.ts +31 -0
- package/dist/proxy/modelUtils.d.ts.map +1 -0
- package/dist/proxy/modelUtils.js +55 -0
- package/dist/proxy/modelUtils.js.map +1 -0
- package/dist/proxy/registry.d.ts +40 -0
- package/dist/proxy/registry.d.ts.map +1 -0
- package/dist/proxy/registry.js +176 -0
- package/dist/proxy/registry.js.map +1 -0
- package/dist/proxy/shared.d.ts +39 -0
- package/dist/proxy/shared.d.ts.map +1 -0
- package/dist/proxy/shared.js +74 -0
- package/dist/proxy/shared.js.map +1 -0
- package/dist/proxy/translators/anthropic.d.ts +119 -0
- package/dist/proxy/translators/anthropic.d.ts.map +1 -0
- package/dist/proxy/translators/anthropic.js +273 -0
- package/dist/proxy/translators/anthropic.js.map +1 -0
- package/dist/proxy/translators/google.d.ts +86 -0
- package/dist/proxy/translators/google.d.ts.map +1 -0
- package/dist/proxy/translators/google.js +111 -0
- package/dist/proxy/translators/google.js.map +1 -0
- package/dist/proxy/translators/ollama.d.ts +27 -0
- package/dist/proxy/translators/ollama.d.ts.map +1 -0
- package/dist/proxy/translators/ollama.js +82 -0
- package/dist/proxy/translators/ollama.js.map +1 -0
- package/dist/proxy/translators/openai.d.ts +132 -0
- package/dist/proxy/translators/openai.d.ts.map +1 -0
- package/dist/proxy/translators/openai.js +396 -0
- package/dist/proxy/translators/openai.js.map +1 -0
- package/dist/proxy/translators/utils.d.ts +60 -0
- package/dist/proxy/translators/utils.d.ts.map +1 -0
- package/dist/proxy/translators/utils.js +504 -0
- package/dist/proxy/translators/utils.js.map +1 -0
- package/dist/proxy.d.ts +22 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +576 -0
- package/dist/proxy.js.map +1 -0
- package/dist/schemaValidator.d.ts +50 -0
- package/dist/schemaValidator.d.ts.map +1 -0
- package/dist/schemaValidator.js +208 -0
- package/dist/schemaValidator.js.map +1 -0
- package/install.cmd +33 -0
- package/package.json +46 -0
- package/src/chat.ts +184 -0
- package/src/cli.ts +184 -0
- package/src/config.ts +99 -0
- package/src/crypto.ts +49 -0
- package/src/logger.ts +8 -0
- package/src/proxy/modelUtils.ts +86 -0
- package/src/proxy/registry.ts +196 -0
- package/src/proxy/shared.ts +102 -0
- package/src/proxy/translators/anthropic.ts +420 -0
- package/src/proxy/translators/google.ts +162 -0
- package/src/proxy/translators/ollama.ts +88 -0
- package/src/proxy/translators/openai.ts +556 -0
- package/src/proxy/translators/utils.ts +552 -0
- package/src/proxy.ts +573 -0
- package/src/schemaValidator.ts +215 -0
- package/tsconfig.json +19 -0
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free Antigravity CLI - Local Proxy Server.
|
|
3
|
+
* Routes requests to Google, OpenAI, Anthropic, Ollama, and custom providers.
|
|
4
|
+
* Intercepts model lists to inject user-defined custom models.
|
|
5
|
+
*/
|
|
6
|
+
import * as http from 'http';
|
|
7
|
+
import * as https from 'https';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
import { log } from './logger';
|
|
12
|
+
|
|
13
|
+
// --- Types ---
|
|
14
|
+
|
|
15
|
+
export interface CustomModel {
|
|
16
|
+
name: string;
|
|
17
|
+
displayName: string;
|
|
18
|
+
description: string;
|
|
19
|
+
provider: string;
|
|
20
|
+
apiKey: string;
|
|
21
|
+
apiUrl: string;
|
|
22
|
+
externalModelName: string;
|
|
23
|
+
allowUnauthorized?: boolean;
|
|
24
|
+
encrypted?: boolean;
|
|
25
|
+
_slug?: string;
|
|
26
|
+
timeout?: number;
|
|
27
|
+
maxRetries?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface GeminiRequestBody {
|
|
31
|
+
model?: string;
|
|
32
|
+
modelId?: string;
|
|
33
|
+
model_id?: string;
|
|
34
|
+
request?: GeminiRequestBody;
|
|
35
|
+
systemInstruction?: { parts: { text?: string }[] };
|
|
36
|
+
contents?: {
|
|
37
|
+
parts?: { text?: string; functionCall?: unknown; functionResponse?: unknown; thought?: boolean }[];
|
|
38
|
+
role?: string;
|
|
39
|
+
}[];
|
|
40
|
+
tools?: unknown[];
|
|
41
|
+
generationConfig?: { temperature?: number; maxOutputTokens?: number };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- State ---
|
|
45
|
+
|
|
46
|
+
let server: http.Server | null = null;
|
|
47
|
+
let proxyPort = 0;
|
|
48
|
+
|
|
49
|
+
import { modelToolCallIds, modelReasoningContent, activeStreamContexts, translatedToolCalls, stateTimestamps, touchStateTimestamp } from './proxy/shared';
|
|
50
|
+
import { detectModelCapabilities } from './proxy/modelUtils';
|
|
51
|
+
import * as registry from './proxy/registry';
|
|
52
|
+
import { decryptString } from './crypto';
|
|
53
|
+
import { validateCustomModel } from './schemaValidator';
|
|
54
|
+
|
|
55
|
+
// --- Model Helpers ---
|
|
56
|
+
|
|
57
|
+
export function generateModelPlaceholderId(model: CustomModel): string {
|
|
58
|
+
const input = (model.displayName || model.name || 'custom-model').toLowerCase();
|
|
59
|
+
let hash = 5381;
|
|
60
|
+
for (let i = 0; i < input.length; i++) {
|
|
61
|
+
hash = (hash << 5) + hash + input.charCodeAt(i);
|
|
62
|
+
hash = hash & hash;
|
|
63
|
+
}
|
|
64
|
+
const placeholderNum = 400 + (Math.abs(hash) % 200);
|
|
65
|
+
return `MODEL_PLACEHOLDER_M${placeholderNum}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getCustomModelsPath(): string {
|
|
69
|
+
const home = os.homedir();
|
|
70
|
+
return path.join(home, '.free-antigravity', 'models.json');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function toSlug(model: CustomModel): string {
|
|
74
|
+
return (
|
|
75
|
+
'custom-' +
|
|
76
|
+
(model.externalModelName || model.name)
|
|
77
|
+
.replace(/^models\//, '')
|
|
78
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
79
|
+
.replace(/^-+|-+$/g, '')
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- Model Loading ---
|
|
85
|
+
|
|
86
|
+
export function loadCustomModels(): CustomModel[] {
|
|
87
|
+
const filePath = getCustomModelsPath();
|
|
88
|
+
|
|
89
|
+
if (!fs.existsSync(filePath)) {
|
|
90
|
+
// Create default config directory and file
|
|
91
|
+
const dir = path.dirname(filePath);
|
|
92
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
93
|
+
fs.writeFileSync(filePath, JSON.stringify({ models: [] }, null, 2), 'utf-8');
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
99
|
+
const parsed = JSON.parse(content) as { models?: CustomModel[] };
|
|
100
|
+
const models = parsed.models || [];
|
|
101
|
+
|
|
102
|
+
// Auto-migration: encrypt plaintext keys
|
|
103
|
+
const needsMigration = models.some(
|
|
104
|
+
(m) => !m.encrypted && m.apiKey && m.apiKey !== 'none' && !m.apiKey.startsWith('enc:') && !m.apiKey.startsWith('fallback:'),
|
|
105
|
+
);
|
|
106
|
+
if (needsMigration) {
|
|
107
|
+
log.info('[Proxy] Migrating plaintext keys to encrypted format...');
|
|
108
|
+
const encryptedModels = models.map((m) => {
|
|
109
|
+
if (m.apiKey && m.apiKey !== 'none' && !m.encrypted) {
|
|
110
|
+
const { encryptString } = require('./crypto');
|
|
111
|
+
return { ...m, apiKey: encryptString(m.apiKey), encrypted: true };
|
|
112
|
+
}
|
|
113
|
+
return m;
|
|
114
|
+
});
|
|
115
|
+
fs.writeFileSync(filePath, JSON.stringify({ models: encryptedModels }, null, 2), 'utf-8');
|
|
116
|
+
return encryptedModels as CustomModel[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Decrypt keys for in-memory use
|
|
120
|
+
const decrypted = models.map((m) => {
|
|
121
|
+
if (m.encrypted && m.apiKey && m.apiKey !== 'none') {
|
|
122
|
+
try { return { ...m, apiKey: decryptString(m.apiKey), encrypted: false }; }
|
|
123
|
+
catch { return m; }
|
|
124
|
+
}
|
|
125
|
+
return m;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Validate models
|
|
129
|
+
const validModels: CustomModel[] = [];
|
|
130
|
+
for (let i = 0; i < decrypted.length; i++) {
|
|
131
|
+
const validation = validateCustomModel(decrypted[i]) as { valid: boolean; error?: string };
|
|
132
|
+
if (validation.valid) {
|
|
133
|
+
validModels.push(decrypted[i] as CustomModel);
|
|
134
|
+
} else {
|
|
135
|
+
log.warn(`[Proxy] Skipping invalid model at index ${i}: ${validation.error}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return validModels;
|
|
139
|
+
} catch (e) {
|
|
140
|
+
log.error('[Proxy] Failed to parse models config:', e);
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- Google Proxy ---
|
|
146
|
+
|
|
147
|
+
function proxyToGoogle(req: http.IncomingMessage, res: http.ServerResponse, reqBody: Buffer): void {
|
|
148
|
+
const targetUrl = 'https://daily-cloudcode-pa.googleapis.com';
|
|
149
|
+
const parsedUrl = new URL(req.url!, targetUrl);
|
|
150
|
+
|
|
151
|
+
const headers: Record<string, string | string[] | undefined> = { ...(req.headers as Record<string, string | string[] | undefined>) };
|
|
152
|
+
headers['host'] = 'daily-cloudcode-pa.googleapis.com';
|
|
153
|
+
delete headers['connection'];
|
|
154
|
+
delete headers['keep-alive'];
|
|
155
|
+
|
|
156
|
+
const options: https.RequestOptions = { method: req.method, headers: headers as Record<string, string> };
|
|
157
|
+
|
|
158
|
+
const proxyReq = https.request(parsedUrl, options, (proxyRes) => {
|
|
159
|
+
proxyReq.setTimeout(60_000, () => {
|
|
160
|
+
proxyReq.destroy();
|
|
161
|
+
if (!res.headersSent) {
|
|
162
|
+
res.writeHead(504, { 'Content-Type': 'application/json' });
|
|
163
|
+
res.end(JSON.stringify({ error: { message: 'Google API request timed out' } }));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers as Record<string, string>);
|
|
168
|
+
proxyRes.pipe(res);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
proxyReq.on('error', (err) => {
|
|
172
|
+
log.error('[Proxy] Google Forwarding Error:', err);
|
|
173
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
174
|
+
res.end(JSON.stringify({ error: { message: 'Proxy forwarding failed: ' + err.message } }));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (reqBody) proxyReq.write(reqBody);
|
|
178
|
+
proxyReq.end();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Custom Model Request Handler ---
|
|
182
|
+
|
|
183
|
+
function handleCustomModelRequest(
|
|
184
|
+
res: http.ServerResponse, model: CustomModel, geminiBody: GeminiRequestBody,
|
|
185
|
+
isStream: boolean, retryCount = 0,
|
|
186
|
+
): void {
|
|
187
|
+
const MAX_RETRIES = Math.min(Math.max(model.maxRetries ?? 3, 0), 5);
|
|
188
|
+
const REQUEST_TIMEOUT_MS = model.timeout || 120_000;
|
|
189
|
+
|
|
190
|
+
const provider = model.provider === 'custom' || model.provider === 'openrouter' ? 'openai' : model.provider;
|
|
191
|
+
const payload = registry.translateRequest(provider, geminiBody, model.externalModelName);
|
|
192
|
+
const headers = registry.getProviderHeaders(provider, model.apiKey);
|
|
193
|
+
|
|
194
|
+
if (isStream && registry.supportsStreaming(provider)) {
|
|
195
|
+
(payload as Record<string, unknown>).stream = true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let finalUrlStr = model.apiUrl;
|
|
199
|
+
if (provider === 'google' || provider === 'ollama') {
|
|
200
|
+
const t = registry.getTranslator(provider);
|
|
201
|
+
finalUrlStr = registry.getProviderUrl(finalUrlStr, model.externalModelName, isStream, t);
|
|
202
|
+
} else if (provider === 'openai' || model.provider === 'custom' || model.provider === 'openrouter') {
|
|
203
|
+
const urlLower = finalUrlStr.toLowerCase();
|
|
204
|
+
if (!urlLower.includes('/chat/completions') && !urlLower.includes('/completions')) {
|
|
205
|
+
if (finalUrlStr.endsWith('/v1')) finalUrlStr += '/chat/completions';
|
|
206
|
+
else if (!finalUrlStr.endsWith('/')) finalUrlStr += '/v1/chat/completions';
|
|
207
|
+
else finalUrlStr += 'v1/chat/completions';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const url = new URL(finalUrlStr);
|
|
212
|
+
const client = url.protocol === 'https:' ? https : http;
|
|
213
|
+
|
|
214
|
+
const options: https.RequestOptions = { method: 'POST', headers: headers as Record<string, string> };
|
|
215
|
+
if (model.allowUnauthorized) {
|
|
216
|
+
(options as Record<string, unknown>).rejectUnauthorized = false;
|
|
217
|
+
log.warn(`[Proxy] SSL verification DISABLED for ${model.name}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
log.info(`[Proxy] Routing ${model.name} → ${model.provider} (${isStream ? 'stream' : 'non-stream'})${retryCount > 0 ? ` retry ${retryCount}` : ''}`);
|
|
221
|
+
|
|
222
|
+
const request = client.request(url, options, (apiRes) => {
|
|
223
|
+
apiRes.on('error', (err) => {
|
|
224
|
+
log.error(`[Proxy] Upstream error for ${model.name}:`, err.message);
|
|
225
|
+
if (!res.headersSent) {
|
|
226
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
227
|
+
res.end(JSON.stringify({ error: { message: 'Upstream error: ' + err.message } }));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (isStream) {
|
|
232
|
+
if (apiRes.statusCode! >= 400) {
|
|
233
|
+
let errorBody = '';
|
|
234
|
+
apiRes.on('data', (chunk: Buffer) => errorBody += chunk.toString());
|
|
235
|
+
apiRes.on('end', () => {
|
|
236
|
+
log.error(`[Proxy] Stream API error (${apiRes.statusCode}): ${errorBody.substring(0, 200)}`);
|
|
237
|
+
if (retryCount < MAX_RETRIES) {
|
|
238
|
+
setTimeout(() => handleCustomModelRequest(res, model, geminiBody, isStream, retryCount + 1), 1000 * (retryCount + 1));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
res.writeHead(apiRes.statusCode!, { 'Content-Type': 'application/json' });
|
|
242
|
+
res.end(errorBody);
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
248
|
+
|
|
249
|
+
let buffer = '';
|
|
250
|
+
apiRes.on('data', (chunk: Buffer) => {
|
|
251
|
+
buffer += chunk.toString('utf-8');
|
|
252
|
+
const lines = buffer.split('\n');
|
|
253
|
+
buffer = lines.pop() || '';
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
const trimmed = line.trim();
|
|
256
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
257
|
+
const dataStr = trimmed.substring(6).trim();
|
|
258
|
+
if (dataStr === '[DONE]') continue;
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(dataStr);
|
|
261
|
+
const mapped = registry.translateStreamChunk(provider, parsed, model.name);
|
|
262
|
+
if (mapped) {
|
|
263
|
+
res.write(`data: ${JSON.stringify({ response: { candidates: [mapped] }, traceId: '', metadata: {} })}\n\n`);
|
|
264
|
+
}
|
|
265
|
+
} catch { /* partial chunk - ignore */ }
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
apiRes.on('end', () => {
|
|
270
|
+
if (buffer.trim().startsWith('data: ')) {
|
|
271
|
+
const dataStr = buffer.trim().substring(6).trim();
|
|
272
|
+
if (dataStr !== '[DONE]') {
|
|
273
|
+
try {
|
|
274
|
+
const parsed = JSON.parse(dataStr);
|
|
275
|
+
const mapped = registry.translateStreamChunk(provider, parsed, model.name);
|
|
276
|
+
if (mapped) {
|
|
277
|
+
res.write(`data: ${JSON.stringify({ response: { candidates: [mapped] }, traceId: '', metadata: {} })}\n\n`);
|
|
278
|
+
}
|
|
279
|
+
} catch { /* ignore */ }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const finalChunk = { response: { candidates: [{ content: { parts: [], role: 'model' }, finishReason: 'STOP', index: 0 }] }, traceId: '', metadata: {} };
|
|
283
|
+
res.write(`data: ${JSON.stringify(finalChunk)}\n\n`);
|
|
284
|
+
res.end();
|
|
285
|
+
});
|
|
286
|
+
} else {
|
|
287
|
+
let body = '';
|
|
288
|
+
apiRes.on('data', (chunk: Buffer) => (body += chunk));
|
|
289
|
+
apiRes.on('end', () => {
|
|
290
|
+
if (apiRes.statusCode! >= 500 && retryCount < MAX_RETRIES) {
|
|
291
|
+
setTimeout(() => handleCustomModelRequest(res, model, geminiBody, isStream, retryCount + 1), 1000 * Math.pow(2, retryCount));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (apiRes.statusCode === 429 && retryCount < MAX_RETRIES) {
|
|
295
|
+
setTimeout(() => handleCustomModelRequest(res, model, geminiBody, isStream, retryCount + 1), 2000 * Math.pow(2, retryCount));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (apiRes.statusCode! >= 400) {
|
|
299
|
+
res.writeHead(apiRes.statusCode!, { 'Content-Type': 'application/json' });
|
|
300
|
+
res.end(body);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
305
|
+
const reasoning = (parsed as { choices?: { message?: { reasoning_content?: string } }[] }).choices?.[0]?.message?.reasoning_content;
|
|
306
|
+
if (reasoning) { modelReasoningContent.set(model.name, reasoning); touchStateTimestamp(stateTimestamps.reasoning, model.name); }
|
|
307
|
+
|
|
308
|
+
const providerForResponse = model.provider === 'custom' || model.provider === 'openrouter' ? 'openai' : model.provider;
|
|
309
|
+
const mapped = registry.translateResponse(providerForResponse, parsed, model.name);
|
|
310
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
311
|
+
res.end(JSON.stringify({ response: mapped, traceId: '', metadata: {} }));
|
|
312
|
+
} catch (e) {
|
|
313
|
+
if (retryCount < MAX_RETRIES) {
|
|
314
|
+
setTimeout(() => handleCustomModelRequest(res, model, geminiBody, isStream, retryCount + 1), 1000 * (retryCount + 1));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
318
|
+
res.end(JSON.stringify({ error: { message: 'Failed to translate response' } }));
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
325
|
+
request.destroy();
|
|
326
|
+
if (retryCount < MAX_RETRIES) {
|
|
327
|
+
setTimeout(() => handleCustomModelRequest(res, model, geminiBody, isStream, retryCount + 1), 1000 * (retryCount + 1));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!res.headersSent) {
|
|
331
|
+
res.writeHead(504, { 'Content-Type': 'application/json' });
|
|
332
|
+
res.end(JSON.stringify({ error: { message: `Request timeout after ${REQUEST_TIMEOUT_MS / 1000}s` } }));
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
request.on('error', (err) => {
|
|
337
|
+
log.error('[Proxy] Custom Model Request Error:', err);
|
|
338
|
+
if (retryCount < MAX_RETRIES) {
|
|
339
|
+
setTimeout(() => handleCustomModelRequest(res, model, geminiBody, isStream, retryCount + 1), 1000 * (retryCount + 1));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (!res.headersSent) {
|
|
343
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
344
|
+
res.end(JSON.stringify({ error: { message: 'Custom model request failed: ' + err.message } }));
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
request.write(JSON.stringify(payload));
|
|
349
|
+
request.end();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- Main Request Handler ---
|
|
353
|
+
|
|
354
|
+
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
355
|
+
// Strip binary patch padding
|
|
356
|
+
req.url = req.url!.replace(/\/v1internal\/x{7}/, '');
|
|
357
|
+
|
|
358
|
+
// CORS
|
|
359
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
360
|
+
res.setHeader('Access-Control-Allow-Methods', '*');
|
|
361
|
+
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
362
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
363
|
+
|
|
364
|
+
// Health check
|
|
365
|
+
if (req.method === 'GET' && (req.url === '/health' || req.url === '/healthz')) {
|
|
366
|
+
const memUsage = process.memoryUsage();
|
|
367
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
368
|
+
res.end(JSON.stringify({ status: 'ok', uptime: process.uptime(), port: proxyPort, memory: { rssMB: Math.round(memUsage.rss / 1024 / 1024), heapUsedMB: Math.round(memUsage.heapUsed / 1024 / 1024) }, timestamp: new Date().toISOString() }));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const MAX_BODY_SIZE = 10 * 1024 * 1024;
|
|
373
|
+
let bodyLength = 0;
|
|
374
|
+
let bodyRejected = false;
|
|
375
|
+
const bodyChunks: Buffer[] = [];
|
|
376
|
+
|
|
377
|
+
req.on('data', (chunk) => {
|
|
378
|
+
bodyLength += chunk.length;
|
|
379
|
+
if (bodyLength > MAX_BODY_SIZE) {
|
|
380
|
+
if (!bodyRejected) { bodyRejected = true; req.destroy(); res.writeHead(413); res.end(JSON.stringify({ error: 'Request body too large' })); }
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
bodyChunks.push(chunk);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
req.on('end', () => {
|
|
387
|
+
if (bodyRejected) return;
|
|
388
|
+
const fullBody = Buffer.concat(bodyChunks);
|
|
389
|
+
|
|
390
|
+
log.info(`[Proxy] ${req.method} ${req.url}`);
|
|
391
|
+
|
|
392
|
+
// 1. Intercept fetchAvailableModels - inject custom models
|
|
393
|
+
if (req.url!.includes('/v1internal:fetchAvailableModels')) {
|
|
394
|
+
log.info('[Proxy] Intercepting fetchAvailableModels');
|
|
395
|
+
|
|
396
|
+
const targetUrl = 'https://daily-cloudcode-pa.googleapis.com';
|
|
397
|
+
const fwdHeaders: Record<string, string | string[] | undefined> = { ...(req.headers as Record<string, string | string[] | undefined>) };
|
|
398
|
+
fwdHeaders['host'] = 'daily-cloudcode-pa.googleapis.com';
|
|
399
|
+
delete fwdHeaders['connection'];
|
|
400
|
+
delete fwdHeaders['keep-alive'];
|
|
401
|
+
|
|
402
|
+
const googleReq = https.request(new URL(req.url!, targetUrl), { method: req.method, headers: fwdHeaders as Record<string, string> }, (googleRes) => {
|
|
403
|
+
googleReq.setTimeout(30_000, () => { googleReq.destroy(); if (!res.headersSent) { res.writeHead(200); res.end(JSON.stringify({ models: {} })); } });
|
|
404
|
+
|
|
405
|
+
let googleBody = '';
|
|
406
|
+
googleRes.on('data', (chunk) => (googleBody += chunk));
|
|
407
|
+
googleRes.on('end', () => {
|
|
408
|
+
try {
|
|
409
|
+
const googleJson = JSON.parse(googleBody) as Record<string, unknown>;
|
|
410
|
+
const customModels = loadCustomModels();
|
|
411
|
+
|
|
412
|
+
const mergeModels = (target: unknown): unknown => {
|
|
413
|
+
if (Array.isArray(target)) {
|
|
414
|
+
const mapped = customModels.map((m) => {
|
|
415
|
+
const cap = detectModelCapabilities(m, true);
|
|
416
|
+
return { name: 'models/' + generateModelPlaceholderId(m), version: '1.0', displayName: m.displayName, description: m.description, inputTokenLimit: cap.maxTokens, outputTokenLimit: cap.maxOutputTokens, supportedGenerationMethods: ['generateContent', 'countTokens'], temperature: cap.isThinking ? undefined : 0.7, topP: cap.isThinking ? undefined : 0.9, topK: cap.isThinking ? undefined : 40 };
|
|
417
|
+
});
|
|
418
|
+
return [...mapped, ...target];
|
|
419
|
+
} else if (target && typeof target === 'object') {
|
|
420
|
+
const result = { ...(target as Record<string, unknown>) };
|
|
421
|
+
customModels.forEach((m) => {
|
|
422
|
+
const slug = toSlug(m);
|
|
423
|
+
const cap = detectModelCapabilities(m, true);
|
|
424
|
+
(result as Record<string, unknown>)[slug] = { displayName: m.displayName, supportsImages: cap.supportsImages, supportsThinking: cap.isThinking, recommended: true, maxTokens: cap.maxTokens, maxOutputTokens: cap.maxOutputTokens, tokenizerType: 'LLAMA_WITH_SPECIAL', model: generateModelPlaceholderId(m), apiProvider: 'API_PROVIDER_GOOGLE_GEMINI', modelProvider: 'MODEL_PROVIDER_GOOGLE' };
|
|
425
|
+
m._slug = slug;
|
|
426
|
+
});
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
429
|
+
return target;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
let merged = false;
|
|
433
|
+
if (googleJson.models) { googleJson.models = mergeModels(googleJson.models); merged = true; }
|
|
434
|
+
if (googleJson.availableModels) { googleJson.availableModels = mergeModels(googleJson.availableModels); merged = true; }
|
|
435
|
+
if (googleJson.available_models) { googleJson.available_models = mergeModels(googleJson.available_models); merged = true; }
|
|
436
|
+
|
|
437
|
+
if (!merged) {
|
|
438
|
+
const modelsMap: Record<string, unknown> = {};
|
|
439
|
+
customModels.forEach((m) => { const slug = toSlug(m); modelsMap[slug] = { displayName: m.displayName, recommended: true, maxTokens: 1048576, maxOutputTokens: 4096, tokenizerType: 'LLAMA_WITH_SPECIAL', model: generateModelPlaceholderId(m), apiProvider: 'API_PROVIDER_GOOGLE_GEMINI', modelProvider: 'MODEL_PROVIDER_GOOGLE' }; m._slug = slug; });
|
|
440
|
+
googleJson.models = modelsMap;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
444
|
+
res.end(JSON.stringify(googleJson));
|
|
445
|
+
} catch (err) {
|
|
446
|
+
log.error('[Proxy] fetchAvailableModels failed, returning custom only:', err);
|
|
447
|
+
const customModels = loadCustomModels();
|
|
448
|
+
const mappedCustom: Record<string, unknown> = {};
|
|
449
|
+
customModels.forEach((m) => { mappedCustom[toSlug(m)] = { displayName: m.displayName, maxTokens: 1048576, maxOutputTokens: 4096, model: generateModelPlaceholderId(m), apiProvider: 'API_PROVIDER_GOOGLE_GEMINI', modelProvider: 'MODEL_PROVIDER_GOOGLE' }; });
|
|
450
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
451
|
+
res.end(JSON.stringify({ models: mappedCustom }));
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
googleReq.on('error', (err) => {
|
|
456
|
+
log.error('[Proxy] fetchAvailableModels forward error:', err);
|
|
457
|
+
const customModels = loadCustomModels();
|
|
458
|
+
const mappedCustom: Record<string, unknown> = {};
|
|
459
|
+
customModels.forEach((m) => { mappedCustom[toSlug(m)] = { displayName: m.displayName, maxTokens: 1048576, maxOutputTokens: 4096, model: generateModelPlaceholderId(m), apiProvider: 'API_PROVIDER_GOOGLE_GEMINI', modelProvider: 'MODEL_PROVIDER_GOOGLE' }; });
|
|
460
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
461
|
+
res.end(JSON.stringify({ models: mappedCustom }));
|
|
462
|
+
});
|
|
463
|
+
if (fullBody.length > 0) googleReq.write(fullBody);
|
|
464
|
+
googleReq.end();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 2. Intercept standard model list
|
|
469
|
+
if (req.method === 'GET' && (req.url!.endsWith('/models') || req.url!.includes('/models?'))) {
|
|
470
|
+
log.info('[Proxy] Intercepting models list');
|
|
471
|
+
const targetUrl = 'https://generativelanguage.googleapis.com';
|
|
472
|
+
const mdlHeaders: Record<string, string | string[] | undefined> = { ...(req.headers as Record<string, string | string[] | undefined>) };
|
|
473
|
+
mdlHeaders['host'] = 'generativelanguage.googleapis.com';
|
|
474
|
+
delete mdlHeaders['connection'];
|
|
475
|
+
|
|
476
|
+
const googleReq = https.request(new URL(req.url!, targetUrl), { method: 'GET', headers: mdlHeaders as Record<string, string> }, (googleRes) => {
|
|
477
|
+
let body = '';
|
|
478
|
+
googleRes.on('data', (chunk) => (body += chunk));
|
|
479
|
+
googleRes.on('end', () => {
|
|
480
|
+
try {
|
|
481
|
+
const googleJson = JSON.parse(body) as { models?: unknown[] };
|
|
482
|
+
const customModels = loadCustomModels();
|
|
483
|
+
const mappedCustom = customModels.map((m) => ({ name: 'models/' + generateModelPlaceholderId(m), version: '1.0', displayName: m.displayName, description: m.description, inputTokenLimit: 1048576, outputTokenLimit: 4096, supportedGenerationMethods: ['generateContent', 'countTokens'], temperature: 0.7, topP: 0.9, topK: 40 }));
|
|
484
|
+
googleJson.models = [...mappedCustom, ...(googleJson.models || [])];
|
|
485
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
486
|
+
res.end(JSON.stringify(googleJson));
|
|
487
|
+
} catch {
|
|
488
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
489
|
+
res.end(body);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
googleReq.on('error', () => { res.writeHead(502); res.end(); });
|
|
494
|
+
googleReq.end();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 3. Intercept generateContent / streamGenerateContent for custom models
|
|
499
|
+
const isCloudCodeStream = req.url!.includes('/v1internal:streamGenerateContent') || req.url!.includes('/v1internal:generateContent');
|
|
500
|
+
if (req.method === 'POST' && isCloudCodeStream) {
|
|
501
|
+
try {
|
|
502
|
+
const reqJson = JSON.parse(fullBody.toString('utf-8')) as Record<string, unknown>;
|
|
503
|
+
const modelName = reqJson.model as string | undefined;
|
|
504
|
+
if (modelName) {
|
|
505
|
+
const customModels = loadCustomModels();
|
|
506
|
+
const matched = customModels.find((m) => {
|
|
507
|
+
const enumName = generateModelPlaceholderId(m);
|
|
508
|
+
return m.name === modelName || toSlug(m) === modelName || enumName === modelName || enumName === (reqJson.modelId || reqJson.model_id);
|
|
509
|
+
});
|
|
510
|
+
if (matched) {
|
|
511
|
+
log.info(`[Proxy] Custom model match: ${modelName} → ${matched.displayName}`);
|
|
512
|
+
const isStream = req.url!.includes('streamGenerateContent') || req.url!.includes('alt=sse');
|
|
513
|
+
handleCustomModelRequest(res, matched, (reqJson.request || reqJson) as GeminiRequestBody, isStream);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} catch (err) { log.error('[Proxy] Parse error:', err); }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 4. Intercept standard Gemini generateContent for custom models
|
|
521
|
+
const generateMatch = req.url!.match(/\/(?:v1|v1beta)\/(models\/[^:]+):generateContent/);
|
|
522
|
+
const streamMatch = req.url!.match(/\/(?:v1|v1beta)\/(models\/[^:]+):streamGenerateContent/);
|
|
523
|
+
if (req.method === 'POST' && (generateMatch || streamMatch)) {
|
|
524
|
+
const matchedModelName = generateMatch ? generateMatch![1] : streamMatch![1];
|
|
525
|
+
const customModels = loadCustomModels();
|
|
526
|
+
const matched = customModels.find((m) => {
|
|
527
|
+
const enumName = generateModelPlaceholderId(m);
|
|
528
|
+
return m.name === matchedModelName || toSlug(m) === matchedModelName || enumName === matchedModelName || 'models/' + enumName === matchedModelName;
|
|
529
|
+
});
|
|
530
|
+
if (matched) {
|
|
531
|
+
try {
|
|
532
|
+
handleCustomModelRequest(res, matched, JSON.parse(fullBody.toString('utf-8')) as GeminiRequestBody, !!streamMatch);
|
|
533
|
+
return;
|
|
534
|
+
} catch { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid JSON' })); return; }
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 5. Fallback: transparent proxy to Google
|
|
539
|
+
proxyToGoogle(req, res, fullBody);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// --- Server Start/Stop ---
|
|
544
|
+
|
|
545
|
+
export function startProxy(): Promise<number> {
|
|
546
|
+
return new Promise((resolve, reject) => {
|
|
547
|
+
server = http.createServer(handleRequest);
|
|
548
|
+
server.listen(50999, '127.0.0.1', () => {
|
|
549
|
+
proxyPort = (server!.address() as import('net').AddressInfo).port;
|
|
550
|
+
log.info(`[Proxy] Listening on http://127.0.0.1:${proxyPort}`);
|
|
551
|
+
resolve(proxyPort);
|
|
552
|
+
});
|
|
553
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
554
|
+
if (err.code === 'EADDRINUSE') {
|
|
555
|
+
log.warn('[Proxy] Port 50999 in use, trying dynamic...');
|
|
556
|
+
server!.listen(0, '127.0.0.1', () => {
|
|
557
|
+
proxyPort = (server!.address() as import('net').AddressInfo).port;
|
|
558
|
+
log.info(`[Proxy] Listening on http://127.0.0.1:${proxyPort}`);
|
|
559
|
+
resolve(proxyPort);
|
|
560
|
+
});
|
|
561
|
+
} else { reject(err); }
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function stopProxy(): Promise<void> {
|
|
567
|
+
return new Promise((resolve) => {
|
|
568
|
+
if (server) { server.close(() => { server = null; resolve(); }); }
|
|
569
|
+
else { resolve(); }
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function getProxyPort(): number { return proxyPort; }
|