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/cli.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Free Antigravity CLI - Open Source Community Edition
|
|
4
|
+
* Supports custom AI models alongside Gemini.
|
|
5
|
+
*/
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import { loadModels, addModel, removeModel, listModels, getModel, ensureConfigDir, CustomModelEntry } from './config';
|
|
10
|
+
import { startProxy, getProxyPort, loadCustomModels, stopProxy } from './proxy';
|
|
11
|
+
import { startChat } from './chat';
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('antigravity')
|
|
17
|
+
.description('Free Antigravity CLI - Open Source Community Edition\nSupports OpenAI, Anthropic, Ollama, OpenRouter, Google AI Studio, and custom providers.')
|
|
18
|
+
.version('1.0.0');
|
|
19
|
+
|
|
20
|
+
// --- Chat command ---
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.command('chat')
|
|
24
|
+
.description('Start interactive chat (default command)')
|
|
25
|
+
.argument('[prompt]', 'One-shot prompt (non-interactive)')
|
|
26
|
+
.option('-m, --model <name>', 'Model to use')
|
|
27
|
+
.action(async (prompt?: string, options?: { model?: string }) => {
|
|
28
|
+
if (prompt) {
|
|
29
|
+
// One-shot mode
|
|
30
|
+
console.log(`Sending to ${options?.model || 'default model'}...`);
|
|
31
|
+
await startChat(options?.model);
|
|
32
|
+
// In a full implementation, would send the prompt and exit
|
|
33
|
+
console.log('One-shot mode: Use interactive chat for now.');
|
|
34
|
+
} else {
|
|
35
|
+
await startChat(options?.model);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// --- Models command ---
|
|
40
|
+
|
|
41
|
+
const modelsCmd = program.command('models').description('Manage custom AI models');
|
|
42
|
+
|
|
43
|
+
modelsCmd
|
|
44
|
+
.command('list')
|
|
45
|
+
.description('List all configured models')
|
|
46
|
+
.action(() => {
|
|
47
|
+
const models = listModels();
|
|
48
|
+
if (models.length === 0) {
|
|
49
|
+
console.log('No models configured. Use "antigravity models add" to add one.');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
console.log('\nConfigured Models:');
|
|
53
|
+
console.log('─'.repeat(60));
|
|
54
|
+
for (const m of models) {
|
|
55
|
+
const keyStatus = m.apiKey && m.apiKey !== 'none' ? '🔑' : '🔓';
|
|
56
|
+
console.log(` ${keyStatus} ${m.displayName || m.name}`);
|
|
57
|
+
console.log(` Provider: ${m.provider} | Model: ${m.externalModelName}`);
|
|
58
|
+
console.log(` URL: ${m.apiUrl}`);
|
|
59
|
+
console.log();
|
|
60
|
+
}
|
|
61
|
+
console.log(`${models.length} model(s) configured.`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
modelsCmd
|
|
65
|
+
.command('add')
|
|
66
|
+
.description('Add a new custom model (interactive wizard)')
|
|
67
|
+
.action(async () => {
|
|
68
|
+
const inquirer = require('inquirer');
|
|
69
|
+
|
|
70
|
+
console.log('\n Add Custom AI Model\n' + '─'.repeat(40));
|
|
71
|
+
|
|
72
|
+
const answers = await inquirer.prompt([
|
|
73
|
+
{ type: 'list', name: 'provider', message: 'Provider:', choices: ['openai', 'anthropic', 'google', 'ollama', 'openrouter', 'custom'] },
|
|
74
|
+
{ type: 'input', name: 'modelId', message: 'Model ID (e.g. gpt-4o):', validate: (v: string) => v.length > 0 },
|
|
75
|
+
{ type: 'input', name: 'displayName', message: 'Display name (optional):' },
|
|
76
|
+
{ type: 'password', name: 'apiKey', message: 'API Key:', mask: '*' },
|
|
77
|
+
{ type: 'input', name: 'apiUrl', message: 'API URL:', default: (ans: any) => {
|
|
78
|
+
const defaults: Record<string, string> = { openai: 'https://api.openai.com/v1/chat/completions', anthropic: 'https://api.anthropic.com/v1/messages', ollama: 'http://localhost:11434/v1/chat/completions', openrouter: 'https://openrouter.ai/api/v1/chat/completions', custom: 'https://api.together.xyz/v1' };
|
|
79
|
+
return ans.provider === 'google' ? `https://generativelanguage.googleapis.com/v1beta/models/${ans.modelId}:generateContent` : (defaults[ans.provider] || '');
|
|
80
|
+
}},
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const entry: CustomModelEntry = {
|
|
84
|
+
name: 'models/' + answers.modelId,
|
|
85
|
+
displayName: answers.displayName || answers.modelId,
|
|
86
|
+
description: `${answers.displayName || answers.modelId} custom model via Free Antigravity CLI`,
|
|
87
|
+
provider: answers.provider,
|
|
88
|
+
apiKey: answers.apiKey || 'none',
|
|
89
|
+
apiUrl: answers.apiUrl,
|
|
90
|
+
externalModelName: answers.modelId,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const result = addModel(entry);
|
|
94
|
+
if (result.success) {
|
|
95
|
+
console.log(`\n Model "${entry.displayName}" added successfully!`);
|
|
96
|
+
console.log(' Restart the proxy or chat to use it.\n');
|
|
97
|
+
} else {
|
|
98
|
+
console.error(`\n Failed: ${result.error}\n`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
modelsCmd
|
|
103
|
+
.command('remove')
|
|
104
|
+
.description('Remove a model')
|
|
105
|
+
.argument('<name>', 'Model name or display name')
|
|
106
|
+
.action((name: string) => {
|
|
107
|
+
const result = removeModel(name);
|
|
108
|
+
if (result.success) console.log(`Model "${name}" removed.`);
|
|
109
|
+
else console.error(`Failed: ${result.error}`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
modelsCmd
|
|
113
|
+
.command('import')
|
|
114
|
+
.description('Import models from Antigravity desktop custom_models.json')
|
|
115
|
+
.action(() => {
|
|
116
|
+
const desktopPath = path.join(require('os').homedir(), '.gemini', 'antigravity', 'custom_models.json');
|
|
117
|
+
if (!fs.existsSync(desktopPath)) {
|
|
118
|
+
console.log('Desktop custom_models.json not found at:', desktopPath);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const content = fs.readFileSync(desktopPath, 'utf-8');
|
|
123
|
+
const parsed = JSON.parse(content);
|
|
124
|
+
const models = parsed.models || [];
|
|
125
|
+
if (models.length === 0) { console.log('No models in desktop config.'); return; }
|
|
126
|
+
|
|
127
|
+
ensureConfigDir();
|
|
128
|
+
// Decrypt desktop keys using the crypto module
|
|
129
|
+
const { decryptString } = require('./crypto');
|
|
130
|
+
const imported: CustomModelEntry[] = [];
|
|
131
|
+
for (const m of models) {
|
|
132
|
+
let apiKey = m.apiKey || 'none';
|
|
133
|
+
if (m.encrypted && apiKey !== 'none') {
|
|
134
|
+
try { apiKey = decryptString(apiKey); } catch { /* keep as-is */ }
|
|
135
|
+
}
|
|
136
|
+
imported.push({
|
|
137
|
+
name: m.name, displayName: m.displayName, description: m.description,
|
|
138
|
+
provider: m.provider, apiKey, apiUrl: m.apiUrl,
|
|
139
|
+
externalModelName: m.externalModelName, allowUnauthorized: m.allowUnauthorized,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Save all
|
|
143
|
+
const { saveModels } = require('./config');
|
|
144
|
+
saveModels(imported);
|
|
145
|
+
console.log(`Imported ${imported.length} model(s) from desktop Antigravity.`);
|
|
146
|
+
for (const m of imported) console.log(` - ${m.displayName} (${m.provider})`);
|
|
147
|
+
} catch (e) { console.error('Import failed:', e); }
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// --- Proxy command ---
|
|
151
|
+
|
|
152
|
+
program
|
|
153
|
+
.command('proxy')
|
|
154
|
+
.description('Start the proxy server (for IDE integration)')
|
|
155
|
+
.action(async () => {
|
|
156
|
+
try {
|
|
157
|
+
const port = await startProxy();
|
|
158
|
+
console.log(`Proxy running on http://127.0.0.1:${port}`);
|
|
159
|
+
console.log('Press Ctrl+C to stop.');
|
|
160
|
+
// Keep alive
|
|
161
|
+
process.on('SIGINT', async () => { await stopProxy(); process.exit(0); });
|
|
162
|
+
} catch (e) { console.error('Failed to start proxy:', e); }
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// --- Configure command ---
|
|
166
|
+
|
|
167
|
+
program
|
|
168
|
+
.command('configure')
|
|
169
|
+
.description('Show configuration info')
|
|
170
|
+
.action(() => {
|
|
171
|
+
const configPath = (require('./config') as typeof import('./config')).getModelsPath();
|
|
172
|
+
console.log('Free Antigravity CLI Configuration');
|
|
173
|
+
console.log('─'.repeat(40));
|
|
174
|
+
console.log(`Models file: ${configPath}`);
|
|
175
|
+
console.log(`Models: ${listModels().length} configured`);
|
|
176
|
+
console.log(`Proxy port: ${getProxyPort() || 'not running'}`);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Default: chat if no command
|
|
180
|
+
program.action(() => {
|
|
181
|
+
startChat();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
program.parse(process.argv);
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration and model storage management.
|
|
3
|
+
* Stores custom models in ~/.free-antigravity/models.json
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
import { encryptString, decryptString } from './crypto';
|
|
9
|
+
|
|
10
|
+
export interface CustomModelEntry {
|
|
11
|
+
name: string;
|
|
12
|
+
displayName?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
provider: string;
|
|
15
|
+
apiKey: string;
|
|
16
|
+
apiUrl: string;
|
|
17
|
+
externalModelName: string;
|
|
18
|
+
allowUnauthorized?: boolean;
|
|
19
|
+
encrypted?: boolean;
|
|
20
|
+
timeout?: number;
|
|
21
|
+
maxRetries?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getConfigDir(): string {
|
|
25
|
+
return path.join(os.homedir(), '.free-antigravity');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getModelsPath(): string {
|
|
29
|
+
return path.join(getConfigDir(), 'models.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ensureConfigDir(): void {
|
|
33
|
+
const dir = getConfigDir();
|
|
34
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadModels(): CustomModelEntry[] {
|
|
38
|
+
const filePath = getModelsPath();
|
|
39
|
+
if (!fs.existsSync(filePath)) return [];
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
43
|
+
const parsed = JSON.parse(content) as { models?: CustomModelEntry[] };
|
|
44
|
+
const models = parsed.models || [];
|
|
45
|
+
|
|
46
|
+
return models.map((m) => {
|
|
47
|
+
if (m.encrypted && m.apiKey && m.apiKey !== 'none') {
|
|
48
|
+
try { return { ...m, apiKey: decryptString(m.apiKey), encrypted: false }; }
|
|
49
|
+
catch { return m; }
|
|
50
|
+
}
|
|
51
|
+
return m;
|
|
52
|
+
});
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error('Failed to load models:', e);
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function saveModels(models: CustomModelEntry[]): void {
|
|
60
|
+
ensureConfigDir();
|
|
61
|
+
const encrypted = models.map((m) => {
|
|
62
|
+
if (m.apiKey && m.apiKey !== 'none' && !m.encrypted) {
|
|
63
|
+
return { ...m, apiKey: encryptString(m.apiKey), encrypted: true };
|
|
64
|
+
}
|
|
65
|
+
return m;
|
|
66
|
+
});
|
|
67
|
+
fs.writeFileSync(getModelsPath(), JSON.stringify({ models: encrypted }, null, 2), 'utf-8');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function addModel(model: CustomModelEntry): { success: boolean; error?: string } {
|
|
71
|
+
const models = loadModels();
|
|
72
|
+
const existingIdx = models.findIndex((m) => m.name === model.name);
|
|
73
|
+
|
|
74
|
+
if (existingIdx !== -1) {
|
|
75
|
+
if (model.apiKey && (model.apiKey.includes('...') || model.apiKey.startsWith('***'))) {
|
|
76
|
+
model.apiKey = models[existingIdx].apiKey;
|
|
77
|
+
}
|
|
78
|
+
models[existingIdx] = model;
|
|
79
|
+
} else {
|
|
80
|
+
models.push(model);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
saveModels(models);
|
|
84
|
+
return { success: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function removeModel(modelName: string): { success: boolean; error?: string } {
|
|
88
|
+
const models = loadModels().filter((m) => m.name !== modelName);
|
|
89
|
+
saveModels(models);
|
|
90
|
+
return { success: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getModel(modelName: string): CustomModelEntry | undefined {
|
|
94
|
+
return loadModels().find((m) => m.name === modelName || m.displayName === modelName);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function listModels(): CustomModelEntry[] {
|
|
98
|
+
return loadModels();
|
|
99
|
+
}
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API key encryption for CLI.
|
|
3
|
+
* Uses base64 encoding as a basic obfuscation layer.
|
|
4
|
+
* For production use, consider integrating with OS keychain via keytar.
|
|
5
|
+
*/
|
|
6
|
+
import * as crypto from 'crypto';
|
|
7
|
+
|
|
8
|
+
const ALGORITHM = 'aes-256-cbc';
|
|
9
|
+
// Derive a machine-specific key - in production, use keytar or OS keychain
|
|
10
|
+
const MACHINE_KEY = crypto.createHash('sha256').update(process.env.USERNAME || process.env.USER || 'antigravity-cli').digest();
|
|
11
|
+
|
|
12
|
+
export function encryptString(plainText: string): string {
|
|
13
|
+
if (!plainText || plainText === 'none') return plainText;
|
|
14
|
+
try {
|
|
15
|
+
const iv = crypto.randomBytes(16);
|
|
16
|
+
const cipher = crypto.createCipheriv(ALGORITHM, MACHINE_KEY, iv);
|
|
17
|
+
const encrypted = Buffer.concat([cipher.update(plainText, 'utf-8'), cipher.final()]);
|
|
18
|
+
return 'enc:' + Buffer.concat([iv, encrypted]).toString('base64');
|
|
19
|
+
} catch {
|
|
20
|
+
return 'fallback:' + Buffer.from(plainText, 'utf-8').toString('base64');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function decryptString(encryptedText: string): string {
|
|
25
|
+
if (!encryptedText || encryptedText === 'none') return encryptedText;
|
|
26
|
+
|
|
27
|
+
if (encryptedText.startsWith('enc:')) {
|
|
28
|
+
try {
|
|
29
|
+
const data = Buffer.from(encryptedText.substring(4), 'base64');
|
|
30
|
+
const iv = data.subarray(0, 16);
|
|
31
|
+
const encrypted = data.subarray(16);
|
|
32
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, MACHINE_KEY, iv);
|
|
33
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8');
|
|
34
|
+
} catch { return 'DECRYPTION_FAILED'; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (encryptedText.startsWith('fallback:')) {
|
|
38
|
+
try { return Buffer.from(encryptedText.substring(9), 'base64').toString('utf-8'); }
|
|
39
|
+
catch { return 'DECRYPTION_FAILED'; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return encryptedText; // plaintext
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isEncryptionAvailable(): boolean { return true; }
|
|
46
|
+
export function backupFile(filePath: string): void {
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
if (fs.existsSync(filePath)) fs.copyFileSync(filePath, filePath + '.bak');
|
|
49
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized model capability detection.
|
|
3
|
+
* Replaces ~9 duplicate regex blocks across proxy.js.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface CustomModelConfig {
|
|
9
|
+
name: string;
|
|
10
|
+
provider: string;
|
|
11
|
+
externalModelName?: string;
|
|
12
|
+
displayName?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ModelCapabilities {
|
|
16
|
+
isThinking: boolean;
|
|
17
|
+
isDeepSeek: boolean;
|
|
18
|
+
isClaude: boolean;
|
|
19
|
+
maxTokens: number;
|
|
20
|
+
maxOutputTokens: number;
|
|
21
|
+
supportsImages: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ModelNameCapabilities {
|
|
25
|
+
isClaudeThinkingModel: boolean;
|
|
26
|
+
isThinkingModel: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Detection ────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const THINKING_PATTERN = /thinking|reasoning|reasoner|o1|o3|r1|opus-4|sonnet-4|claude-4|3-7|4-7|3\.7|4\.7/i;
|
|
32
|
+
const DEEPSEEK_PATTERN = /deepseek/i;
|
|
33
|
+
const CLAUDE_PATTERN = /claude|opus|sonnet/i;
|
|
34
|
+
const CLAUDE_THINKING_PATTERN = /opus-4|sonnet-4|claude-4|claude-3-5|claude-3-7/i;
|
|
35
|
+
const THINKING_MODEL_PATTERN = /opus-4|sonnet-4|claude-4/i;
|
|
36
|
+
const IMAGE_SUPPORT_PATTERN = /gpt-4o|gpt-4-turbo|claude|gemini|vision|llava|qwenvl|pixtral|yi-vision|cogvlm|kimi|moonshot/i;
|
|
37
|
+
const NO_IMAGE_PATTERN = /deepseek(?!.*vision)|llama(?!.*vision)|mixtral(?!.*vision)|mistral(?!.*pixtral)|codestral|qwen(?!.*vl)/i;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Detects model capabilities from a custom model config object.
|
|
41
|
+
*/
|
|
42
|
+
export function detectModelCapabilities(m: CustomModelConfig, includeDisplayName = true): ModelCapabilities {
|
|
43
|
+
const nameLower = (m.name || '').toLowerCase();
|
|
44
|
+
const extLower = (m.externalModelName || '').toLowerCase();
|
|
45
|
+
const displayLower = includeDisplayName ? (m.displayName || '').toLowerCase() : '';
|
|
46
|
+
|
|
47
|
+
const isThinking =
|
|
48
|
+
m.provider === 'anthropic' ||
|
|
49
|
+
m.provider === 'openai' ||
|
|
50
|
+
m.provider === 'openrouter' ||
|
|
51
|
+
THINKING_PATTERN.test(nameLower) ||
|
|
52
|
+
THINKING_PATTERN.test(extLower) ||
|
|
53
|
+
(includeDisplayName && THINKING_PATTERN.test(displayLower));
|
|
54
|
+
|
|
55
|
+
const isDeepSeek =
|
|
56
|
+
DEEPSEEK_PATTERN.test(nameLower) ||
|
|
57
|
+
DEEPSEEK_PATTERN.test(extLower) ||
|
|
58
|
+
(includeDisplayName && DEEPSEEK_PATTERN.test(displayLower));
|
|
59
|
+
|
|
60
|
+
const isClaude = m.provider === 'anthropic' || CLAUDE_PATTERN.test(nameLower) || CLAUDE_PATTERN.test(extLower);
|
|
61
|
+
|
|
62
|
+
const maxTokens = isClaude ? 200_000 : 1_048_576;
|
|
63
|
+
const maxOutputTokens = isDeepSeek ? 32_768 : isThinking ? 32_768 : 16_384;
|
|
64
|
+
|
|
65
|
+
// Image support: Claude, GPT-4o, Gemini always support images. DeepSeek, Ollama text models don't.
|
|
66
|
+
const allNames = nameLower + ' ' + extLower + ' ' + displayLower;
|
|
67
|
+
const supportsImages =
|
|
68
|
+
m.provider === 'anthropic' ||
|
|
69
|
+
m.provider === 'google' ||
|
|
70
|
+
(m.provider === 'openai' && IMAGE_SUPPORT_PATTERN.test(allNames)) ||
|
|
71
|
+
(m.provider === 'openrouter' && IMAGE_SUPPORT_PATTERN.test(allNames)) ||
|
|
72
|
+
(IMAGE_SUPPORT_PATTERN.test(allNames) && !NO_IMAGE_PATTERN.test(allNames));
|
|
73
|
+
|
|
74
|
+
return { isThinking, isDeepSeek, isClaude, maxTokens, maxOutputTokens, supportsImages };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Simplified detection for Gemini↔Anthropic translation (checks modelName string only).
|
|
79
|
+
*/
|
|
80
|
+
export function detectModelCapabilitiesByName(modelName: string): ModelNameCapabilities {
|
|
81
|
+
const lower = (modelName || '').toLowerCase();
|
|
82
|
+
return {
|
|
83
|
+
isClaudeThinkingModel: CLAUDE_THINKING_PATTERN.test(lower),
|
|
84
|
+
isThinkingModel: THINKING_MODEL_PATTERN.test(lower),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider Translator Registry.
|
|
3
|
+
* Auto-discovers translator modules and provides a unified interface for request/response mapping.
|
|
4
|
+
*
|
|
5
|
+
* To add a new provider:
|
|
6
|
+
* 1. Create a file in ./translators/ named <provider>.ts
|
|
7
|
+
* 2. Export: mapGeminiTo<Provider>, map<Provider>ToGemini, map<Provider>ChunkToGemini
|
|
8
|
+
* 3. The registry detects it automatically — no config changes needed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { log } from '../logger';
|
|
12
|
+
import * as openaiModule from './translators/openai';
|
|
13
|
+
import * as anthropicModule from './translators/anthropic';
|
|
14
|
+
import * as googleModule from './translators/google';
|
|
15
|
+
import * as ollamaModule from './translators/ollama';
|
|
16
|
+
|
|
17
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface TranslatorModule {
|
|
20
|
+
mapGeminiToOpenAI?: (body: unknown, modelName: string) => unknown;
|
|
21
|
+
mapOpenAIToGemini?: (res: unknown, modelName: string) => unknown;
|
|
22
|
+
mapOpenAIChunkToGemini?: (chunk: unknown, modelName: string) => unknown | null;
|
|
23
|
+
mapGeminiToAnthropic?: (body: unknown, modelName: string) => unknown;
|
|
24
|
+
mapAnthropicToGemini?: (res: unknown, modelName: string) => unknown;
|
|
25
|
+
mapAnthropicChunkToGemini?: (chunk: unknown, modelName: string) => unknown | null;
|
|
26
|
+
mapGeminiToGoogle?: (body: unknown, modelName: string) => unknown;
|
|
27
|
+
mapGoogleToGemini?: (res: unknown, modelName: string) => unknown;
|
|
28
|
+
mapGoogleChunkToGemini?: (chunk: unknown, modelName: string) => unknown | null;
|
|
29
|
+
getGoogleApiUrl?: (baseUrl: string, modelName: string, isStream: boolean) => string;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ProviderHeaders {
|
|
34
|
+
'Content-Type': string;
|
|
35
|
+
Authorization?: string;
|
|
36
|
+
'x-api-key'?: string;
|
|
37
|
+
'anthropic-version'?: string;
|
|
38
|
+
'x-goog-api-key'?: string;
|
|
39
|
+
'HTTP-Referer'?: string;
|
|
40
|
+
'X-Title'?: string;
|
|
41
|
+
[key: string]: string | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Registry State ───────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const translators = new Map<string, TranslatorModule>();
|
|
47
|
+
|
|
48
|
+
// ─── Auto-Discovery ───────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function loadTranslators(): void {
|
|
51
|
+
translators.set('openai', openaiModule as unknown as TranslatorModule);
|
|
52
|
+
translators.set('anthropic', anthropicModule as unknown as TranslatorModule);
|
|
53
|
+
translators.set('google', googleModule as unknown as TranslatorModule);
|
|
54
|
+
translators.set('ollama', ollamaModule as unknown as TranslatorModule);
|
|
55
|
+
log.info(`[TranslatorRegistry] 4 provider translator(s) loaded: openai, anthropic, google, ollama`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Public API ───────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export function getTranslator(provider: string): TranslatorModule | null {
|
|
61
|
+
// openrouter uses OpenAI-compatible API — reuse OpenAI translator
|
|
62
|
+
if (provider === 'openrouter') return translators.get('openai') || null;
|
|
63
|
+
const key = provider === 'custom' ? 'openai' : provider;
|
|
64
|
+
return translators.get(key) || translators.get('openai') || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function translateRequest(provider: string, geminiBody: unknown, modelName: string): unknown {
|
|
68
|
+
const t = getTranslator(provider);
|
|
69
|
+
|
|
70
|
+
if (provider === 'google') {
|
|
71
|
+
return geminiBody; // passthrough
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (provider === 'openai' || provider === 'ollama' || provider === 'custom' || provider === 'openrouter') {
|
|
75
|
+
return t?.mapGeminiToOpenAI ? t.mapGeminiToOpenAI(geminiBody, modelName) : geminiBody;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (provider === 'anthropic') {
|
|
79
|
+
return t?.mapGeminiToAnthropic ? t.mapGeminiToAnthropic(geminiBody, modelName) : geminiBody;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Generic: try mapGeminiTo<Provider> convention
|
|
83
|
+
const fnName = `mapGeminiTo${provider.charAt(0).toUpperCase() + provider.slice(1)}`;
|
|
84
|
+
if (t && typeof t[fnName] === 'function') {
|
|
85
|
+
return (t[fnName] as (...args: unknown[]) => unknown)(geminiBody, modelName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
log.warn(`[TranslatorRegistry] No request translator for provider "${provider}", passing through`);
|
|
89
|
+
return geminiBody;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function translateResponse(provider: string, providerRes: unknown, modelName: string): unknown {
|
|
93
|
+
const t = getTranslator(provider);
|
|
94
|
+
|
|
95
|
+
if (provider === 'google') return providerRes;
|
|
96
|
+
|
|
97
|
+
if (provider === 'openai' || provider === 'ollama' || provider === 'custom' || provider === 'openrouter') {
|
|
98
|
+
return t?.mapOpenAIToGemini ? t.mapOpenAIToGemini(providerRes, modelName) : providerRes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (provider === 'anthropic') {
|
|
102
|
+
return t?.mapAnthropicToGemini ? t.mapAnthropicToGemini(providerRes, modelName) : providerRes;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const fnName = `map${provider.charAt(0).toUpperCase() + provider.slice(1)}ToGemini`;
|
|
106
|
+
if (t && typeof t[fnName] === 'function') {
|
|
107
|
+
return (t[fnName] as (...args: unknown[]) => unknown)(providerRes, modelName);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
log.warn(`[TranslatorRegistry] No response translator for provider "${provider}", passing through`);
|
|
111
|
+
return providerRes;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function translateStreamChunk(provider: string, chunk: unknown, modelName: string): unknown {
|
|
115
|
+
const t = getTranslator(provider);
|
|
116
|
+
|
|
117
|
+
if (provider === 'google') {
|
|
118
|
+
return t?.mapGoogleChunkToGemini ? t.mapGoogleChunkToGemini(chunk, modelName) : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (provider === 'openai' || provider === 'ollama' || provider === 'custom' || provider === 'openrouter') {
|
|
122
|
+
return t?.mapOpenAIChunkToGemini ? t.mapOpenAIChunkToGemini(chunk, modelName) : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (provider === 'anthropic') {
|
|
126
|
+
return t?.mapAnthropicChunkToGemini ? t.mapAnthropicChunkToGemini(chunk, modelName) : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const fnName = `map${provider.charAt(0).toUpperCase() + provider.slice(1)}ChunkToGemini`;
|
|
130
|
+
if (t && typeof t[fnName] === 'function') {
|
|
131
|
+
return (t[fnName] as (...args: unknown[]) => unknown)(chunk, modelName);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function getProviderHeaders(provider: string, apiKey: string): ProviderHeaders {
|
|
138
|
+
const headers: ProviderHeaders = { 'Content-Type': 'application/json' };
|
|
139
|
+
|
|
140
|
+
switch (provider) {
|
|
141
|
+
case 'openai':
|
|
142
|
+
case 'custom':
|
|
143
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
144
|
+
break;
|
|
145
|
+
case 'openrouter':
|
|
146
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
147
|
+
// OpenRouter optional headers for leaderboard attribution
|
|
148
|
+
headers['HTTP-Referer'] = 'https://antigravity.google';
|
|
149
|
+
headers['X-Title'] = 'Antigravity';
|
|
150
|
+
break;
|
|
151
|
+
case 'anthropic':
|
|
152
|
+
headers['x-api-key'] = apiKey;
|
|
153
|
+
headers['anthropic-version'] = '2025-04-01';
|
|
154
|
+
break;
|
|
155
|
+
case 'google':
|
|
156
|
+
headers['x-goog-api-key'] = apiKey;
|
|
157
|
+
break;
|
|
158
|
+
case 'ollama':
|
|
159
|
+
// Ollama typically doesn't need auth headers
|
|
160
|
+
break;
|
|
161
|
+
default:
|
|
162
|
+
if (apiKey && apiKey !== 'none') {
|
|
163
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return headers;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function supportsStreaming(provider: string): boolean {
|
|
172
|
+
return ['openai', 'ollama', 'custom', 'anthropic', 'google', 'openrouter'].includes(provider);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── URL Helpers ──────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
export function getProviderUrl(
|
|
178
|
+
baseUrl: string,
|
|
179
|
+
modelName: string,
|
|
180
|
+
isStream: boolean,
|
|
181
|
+
translator: TranslatorModule | null,
|
|
182
|
+
): string {
|
|
183
|
+
// Google AI Studio: dynamic streaming vs non-streaming URL
|
|
184
|
+
if (translator && typeof translator['getGoogleApiUrl'] === 'function') {
|
|
185
|
+
return (translator['getGoogleApiUrl'] as (...args: unknown[]) => string)(baseUrl, modelName, isStream);
|
|
186
|
+
}
|
|
187
|
+
// Ollama: normalize to standard /v1/chat/completions endpoint
|
|
188
|
+
if (translator && typeof translator['getOllamaApiUrl'] === 'function') {
|
|
189
|
+
return (translator['getOllamaApiUrl'] as (...args: unknown[]) => string)(baseUrl);
|
|
190
|
+
}
|
|
191
|
+
return baseUrl;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Boot ─────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
loadTranslators();
|