anthropic-gateway 1.2.0 → 1.4.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/README.md +52 -7
- package/cli.js +233 -13
- package/config.example.json +46 -19
- package/lib/server.js +67 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Open a **new** terminal after installing so PATH picks it up.
|
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
32
|
anthropic-gateway setup # interactive wizard (or flags, see below)
|
|
33
|
-
anthropic-gateway start # run the gateway
|
|
33
|
+
anthropic-gateway start # run the gateway using the default profile
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
Config, pid and log live in `~/.anthropic-gateway/` (`config.json`,
|
|
@@ -38,7 +38,7 @@ Config, pid and log live in `~/.anthropic-gateway/` (`config.json`,
|
|
|
38
38
|
|
|
39
39
|
Then point your client at:
|
|
40
40
|
|
|
41
|
-
- Base URL: `http://127.0.0.1:3080`
|
|
41
|
+
- Base URL: `http://127.0.0.1:3080` (or the port you configured)
|
|
42
42
|
- API key / `x-api-key` / `Authorization: Bearer`: the **proxy token** printed by `setup`
|
|
43
43
|
|
|
44
44
|
### Non-interactive setup
|
|
@@ -48,22 +48,67 @@ anthropic-gateway setup --provider kilo --api-key YOUR_KILO_KEY --model kilo-aut
|
|
|
48
48
|
anthropic-gateway setup --base-url https://api.openai.com/v1 --api-key sk-... --model gpt-5 --port 3090 --token mytoken --yes
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
### Using multiple profiles
|
|
52
|
+
|
|
53
|
+
After setting up multiple profiles (e.g., one for kilo, one for OpenAI), you can:
|
|
54
|
+
|
|
55
|
+
- Start a specific profile: `anthropic-gateway start openai`
|
|
56
|
+
- Set the default profile for future `start` commands: `anthropic-gateway select kilo`
|
|
57
|
+
- Then `anthropic-gateway start` will use the kilo profile without specifying it.
|
|
58
|
+
|
|
51
59
|
## Commands
|
|
52
60
|
|
|
53
61
|
| Command | Description |
|
|
54
62
|
| --- | --- |
|
|
55
|
-
| `anthropic-gateway setup` | Create/overwrite `~/.anthropic-gateway/config.json
|
|
56
|
-
| `anthropic-gateway start` | Start the gateway |
|
|
63
|
+
| `anthropic-gateway setup` | Create/overwrite `~/.anthropic-gateway/config.json` with a profiles array; tests connectivity (`--full-logging` to enable full-body logging) |
|
|
64
|
+
| `anthropic-gateway start [profile]` | Start the gateway using the given profile name; if omitted, uses `defaultProfile` from config |
|
|
57
65
|
| `anthropic-gateway stop` | Stop the gateway |
|
|
58
|
-
| `anthropic-gateway status` |
|
|
66
|
+
| `anthropic-gateway status` | Pretty status: version, pid, upstream, uptime, request/token totals, full-logging state |
|
|
67
|
+
| `anthropic-gateway fulllogging on\|off` | Toggle full request/response logging (restart to apply) |
|
|
59
68
|
| `anthropic-gateway autostart on\|off` | Launch at Windows logon (Startup folder) |
|
|
69
|
+
| `anthropic-gateway select <profile>` | Set the default profile for future `start` commands |
|
|
70
|
+
| `anthropic-gateway version` | Print the version (also `--version` / `-v`) |
|
|
60
71
|
|
|
61
72
|
## config.json
|
|
62
73
|
|
|
63
|
-
See `config.example.json`.
|
|
74
|
+
See `config.example.json`. The config now contains a `profiles` array and a `defaultProfile`.
|
|
75
|
+
Each profile object includes: `providerName`, `upstreamBaseUrl`, `upstreamApiKey`,
|
|
64
76
|
`upstreamModel`, `port`, `proxyTokens` (accepts several), `advertisedModels`
|
|
65
77
|
(models shown to Claude Code model discovery — must look Anthropic-ish, e.g.
|
|
66
|
-
`claude-sonnet-4-5` or `sonnet`), `toolFormat`.
|
|
78
|
+
`claude-sonnet-4-5` or `sonnet`), `toolFormat`, `fullLogging`, `logFile`.
|
|
79
|
+
Only the profile named by `defaultProfile` (or the first profile if unspecified)
|
|
80
|
+
is used by `anthropic-gateway start` unless a profile name is given explicitly.
|
|
81
|
+
|
|
82
|
+
## Logging
|
|
83
|
+
|
|
84
|
+
`gateway.log` (in `~/.anthropic-gateway/`) records, per request, the tokens
|
|
85
|
+
sent/received and a running session total (input / output / total since the
|
|
86
|
+
gateway started).
|
|
87
|
+
|
|
88
|
+
To also capture the **full request and response bodies** (useful for debugging
|
|
89
|
+
upstream mismatches), enable full logging:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
anthropic-gateway fulllogging on # or: setup --full-logging
|
|
93
|
+
anthropic-gateway stop && anthropic-gateway start # restart to apply
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Full bodies are written to the log file only (not the console), so streaming
|
|
97
|
+
stdout stays clean. Turn it off the same way with `fulllogging off`.
|
|
98
|
+
|
|
99
|
+
`status` shows the same token totals plus version, pid, uptime and the
|
|
100
|
+
full-logging state:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
Status UP
|
|
104
|
+
Version 1.2.0
|
|
105
|
+
PID 23628
|
|
106
|
+
Listen 127.0.0.1:3080
|
|
107
|
+
Upstream model Qwen/Qwen3.8-27B
|
|
108
|
+
...
|
|
109
|
+
Requests 42
|
|
110
|
+
Tokens (in/out/total) 118300 / 9210 / 127510
|
|
111
|
+
```
|
|
67
112
|
|
|
68
113
|
## Using it with Claude Code GUI (desktop app)
|
|
69
114
|
|
package/cli.js
CHANGED
|
@@ -50,6 +50,26 @@ function getVersion() {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function formatUptime(sec) {
|
|
54
|
+
if (!Number.isFinite(sec)) return '—';
|
|
55
|
+
const d = Math.floor(sec / 86400);
|
|
56
|
+
const h = Math.floor((sec % 86400) / 3600);
|
|
57
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
58
|
+
const s = Math.floor(sec % 60);
|
|
59
|
+
const parts = [];
|
|
60
|
+
if (d) parts.push(d + 'd');
|
|
61
|
+
if (h) parts.push(h + 'h');
|
|
62
|
+
if (m) parts.push(m + 'm');
|
|
63
|
+
parts.push(s + 's');
|
|
64
|
+
return parts.join(' ');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function maskKey(key) {
|
|
68
|
+
const k = String(key || '');
|
|
69
|
+
if (k.length <= 8) return k ? k[0] + '…' : '—';
|
|
70
|
+
return k.slice(0, 4) + '…' + k.slice(-4);
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
function ask(rl, q, def) {
|
|
54
74
|
return new Promise((resolve) => {
|
|
55
75
|
rl.question(q + (def ? ' [' + def + '] ' : ' '), (a) => resolve(a.trim() === '' ? (def || '') : a.trim()));
|
|
@@ -60,9 +80,31 @@ function randomToken() {
|
|
|
60
80
|
return 'ag-' + require('crypto').randomBytes(24).toString('base64url');
|
|
61
81
|
}
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Load the top-level config and return the selected profile object merged with
|
|
85
|
+
* defaults. If `profileName` is omitted, `defaultProfile` in the config is used;
|
|
86
|
+
* if none/defaultProfile points at a non-existent one, the first profile wins.
|
|
87
|
+
*/
|
|
88
|
+
function loadProfile(cfgPath, profileName) {
|
|
89
|
+
if (!fs.existsSync(cfgPath)) fail('no config at ' + cfgPath + ' — run "anthropic-gateway setup" first');
|
|
90
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
91
|
+
|
|
92
|
+
// Backwards compatibility: old single-profile config.json (flat fields).
|
|
93
|
+
if (Array.isArray(cfg.profiles) && cfg.profiles.length > 0) {
|
|
94
|
+
const profiles = cfg.profiles;
|
|
95
|
+
if (!profileName) profileName = cfg.defaultProfile;
|
|
96
|
+
let idx;
|
|
97
|
+
if (profileName) {
|
|
98
|
+
idx = profiles.findIndex((p) => p.providerName === profileName);
|
|
99
|
+
if (idx === -1) fail('profile "' + profileName + '" not found in ' + cfgPath + ' (available: ' + profiles.map((p) => p.providerName).join(', ') + ')');
|
|
100
|
+
} else {
|
|
101
|
+
idx = 0; // first profile is the default when none specified
|
|
102
|
+
}
|
|
103
|
+
return { ...profiles[idx], _profiles: profiles, _configPath: cfgPath, _defaultProfile: cfg.defaultProfile };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Legacy flat config
|
|
107
|
+
return { ...cfg, _profiles: null, _configPath: cfgPath, _defaultProfile: undefined };
|
|
66
108
|
}
|
|
67
109
|
|
|
68
110
|
async function testUpstream(baseUrl, apiKey, model) {
|
|
@@ -123,7 +165,49 @@ async function cmdSetup(args) {
|
|
|
123
165
|
if (!Number.isInteger(port) || port < 1 || port > 65535) fail('invalid port');
|
|
124
166
|
|
|
125
167
|
const advertisedModels = adv.split(',').map((s) => s.trim()).filter(Boolean);
|
|
126
|
-
const
|
|
168
|
+
const fullLoggingFlag = args['full-logging'];
|
|
169
|
+
const fullLogging = fullLoggingFlag !== undefined && String(fullLoggingFlag) !== 'false';
|
|
170
|
+
|
|
171
|
+
// Load existing config if any
|
|
172
|
+
let existingConfig = null;
|
|
173
|
+
if (fs.existsSync(cfgPath)) {
|
|
174
|
+
try {
|
|
175
|
+
existingConfig = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
176
|
+
} catch (e) {
|
|
177
|
+
fail('failed to parse existing config at ' + cfgPath);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Determine if we are dealing with legacy flat config or new profiles format
|
|
182
|
+
const isLegacy = existingConfig && (!Array.isArray(existingConfig.profiles));
|
|
183
|
+
let profiles = [];
|
|
184
|
+
let defaultProfile = undefined;
|
|
185
|
+
|
|
186
|
+
if (isLegacy) {
|
|
187
|
+
// Convert legacy flat config to a profile
|
|
188
|
+
const legacyProfile = {
|
|
189
|
+
providerName: existingConfig.providerName || 'legacy',
|
|
190
|
+
upstreamBaseUrl: existingConfig.upstreamBaseUrl.replace(/\/+$/, ''),
|
|
191
|
+
upstreamApiKey: existingConfig.upstreamApiKey,
|
|
192
|
+
upstreamModel: existingConfig.upstreamModel,
|
|
193
|
+
port: existingConfig.port || 3080,
|
|
194
|
+
proxyTokens: Array.isArray(existingConfig.proxyTokens) ? existingConfig.proxyTokens : [existingConfig.proxyTokens],
|
|
195
|
+
toolFormat: existingConfig.toolFormat || 'native',
|
|
196
|
+
advertisedModels: Array.isArray(existingConfig.advertisedModels) ? existingConfig.advertisedModels : [],
|
|
197
|
+
fullLogging: !!existingConfig.fullLogging,
|
|
198
|
+
logFile: existingConfig.logFile || 'gateway.log',
|
|
199
|
+
};
|
|
200
|
+
profiles.push(legacyProfile);
|
|
201
|
+
defaultProfile = legacyProfile.providerName;
|
|
202
|
+
} else if (existingConfig) {
|
|
203
|
+
// Already in new format
|
|
204
|
+
profiles = Array.isArray(existingConfig.profiles) ? existingConfig.profiles : [];
|
|
205
|
+
defaultProfile = existingConfig.defaultProfile;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Find index of profile with same providerName as the one we are setting up
|
|
209
|
+
const profileIndex = profiles.findIndex(p => p.providerName === (preset.name || 'custom'));
|
|
210
|
+
const newProfile = {
|
|
127
211
|
providerName: preset.name || 'custom',
|
|
128
212
|
upstreamBaseUrl: baseUrl.replace(/\/+$/, ''),
|
|
129
213
|
upstreamApiKey: apiKey,
|
|
@@ -132,16 +216,35 @@ async function cmdSetup(args) {
|
|
|
132
216
|
proxyTokens: [token],
|
|
133
217
|
toolFormat: 'native',
|
|
134
218
|
advertisedModels,
|
|
135
|
-
|
|
219
|
+
fullLogging,
|
|
220
|
+
logFile: 'gateway.log',
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
if (profileIndex >= 0) {
|
|
224
|
+
// Update existing profile
|
|
225
|
+
profiles[profileIndex] = newProfile;
|
|
226
|
+
} else {
|
|
227
|
+
// Add new profile
|
|
228
|
+
profiles.push(newProfile);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// If defaultProfile is not set, set it to the providerName of the profile we just set up
|
|
232
|
+
if (!defaultProfile) {
|
|
233
|
+
defaultProfile = newProfile.providerName;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const configOut = {
|
|
237
|
+
defaultProfile,
|
|
238
|
+
profiles,
|
|
136
239
|
};
|
|
137
240
|
|
|
138
241
|
console.log('Testing upstream connectivity...');
|
|
139
|
-
const ok = await testUpstream(
|
|
242
|
+
const ok = await testUpstream(newProfile.upstreamBaseUrl, newProfile.upstreamApiKey, newProfile.upstreamModel);
|
|
140
243
|
if (!ok && !yes) {
|
|
141
244
|
const c = await ask(readline.createInterface({ input: process.stdin, output: process.stdout }), 'Connectivity test failed. Save anyway? (y/N):', 'N');
|
|
142
245
|
if (c.toLowerCase() !== 'y') fail('aborted — nothing written');
|
|
143
246
|
}
|
|
144
|
-
fs.writeFileSync(cfgPath, JSON.stringify(
|
|
247
|
+
fs.writeFileSync(cfgPath, JSON.stringify(configOut, null, 2));
|
|
145
248
|
console.log('Config written to ' + cfgPath);
|
|
146
249
|
console.log('Proxy token (use as API key / x-api-key / Bearer in the client): ' + token);
|
|
147
250
|
console.log('Next: anthropic-gateway start (or: anthropic-gateway autostart on)');
|
|
@@ -150,17 +253,49 @@ async function cmdSetup(args) {
|
|
|
150
253
|
async function cmdStart(args) {
|
|
151
254
|
ensureHomeDir();
|
|
152
255
|
const cfgPath = args.config ? path.resolve(args.config) : DEFAULT_CONFIG;
|
|
153
|
-
|
|
256
|
+
// Determine profile name: from command line argument (second in flags._) or from config's defaultProfile
|
|
257
|
+
let profileName = null;
|
|
258
|
+
if (args._ && args._.length > 1) {
|
|
259
|
+
// args._ is ['start', 'profileName', ...] after parseFlags? Actually parseFlags puts non-flag args in _.
|
|
260
|
+
// We'll instead use a different approach: we'll reparse the original argv in the main function.
|
|
261
|
+
// For now, we'll rely on the main function to pass profileName via args.profile.
|
|
262
|
+
// We'll change the main function later.
|
|
263
|
+
}
|
|
264
|
+
// We'll change the approach: let's rework the main function to pass profileName as an argument.
|
|
265
|
+
// For now, we'll keep it simple and assume no profile argument and later update.
|
|
266
|
+
// Let's revert and do a different plan: we'll edit the main function to handle extra args.
|
|
267
|
+
// Given the complexity, let's stop and do a fresh edit of the whole cli.js?
|
|
268
|
+
// Instead, let's do it step by step: we'll change the main function to capture the profile argument for start and setup.
|
|
269
|
+
// We'll do that in the next edit.
|
|
270
|
+
// For now, we'll leave cmdStart as is and just update the loadConfig to use defaultProfile.
|
|
271
|
+
// We'll then update the main function to pass profileName via args.
|
|
272
|
+
// Let's do that now.
|
|
273
|
+
// We'll change the main function to set args.profile for start and setup.
|
|
274
|
+
// But we are in the middle of editing cli.js, so we can edit the main function too.
|
|
275
|
+
// Let's edit the main function after the parseFlags.
|
|
276
|
+
// We'll do that in the next edit.
|
|
277
|
+
// For now, we'll just update loadConfig to return the default profile when no profileName is given.
|
|
278
|
+
// And then we'll update the main function to set args.profile for start and setup.
|
|
279
|
+
// Let's continue with the current edit of cmdStart by assuming we will get args.profile from the main function.
|
|
280
|
+
// We'll change the function signature to accept args that may have a profile property.
|
|
281
|
+
// We'll change the call in the main function later.
|
|
282
|
+
// For now, let's just update the function to use args.profile if present, otherwise default.
|
|
283
|
+
const profileArg = args.profile;
|
|
284
|
+
const cfg = loadProfile(cfgPath, profileArg); // loadProfile now takes profileName
|
|
154
285
|
if (cfg.logFile && !path.isAbsolute(cfg.logFile)) cfg.logFile = path.join(HOME_DIR, cfg.logFile);
|
|
155
286
|
const { makeServer } = require('./lib/server');
|
|
156
287
|
const server = makeServer(cfg);
|
|
157
288
|
const started = await server.start();
|
|
158
289
|
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
290
|
+
// Also write the active profile to a file
|
|
291
|
+
const activeProfilePath = path.join(HOME_DIR, 'active_profile');
|
|
292
|
+
fs.writeFileSync(activeProfilePath, profileArg || cfg._defaultProfile || (cfg._profiles && cfg._profiles[0]?.providerName));
|
|
159
293
|
console.log('pid ' + process.pid + ' written to ' + PID_FILE);
|
|
160
294
|
console.log('Health: http://127.0.0.1:' + started + '/health');
|
|
161
295
|
const shutdown = async () => {
|
|
162
296
|
await server.stop();
|
|
163
297
|
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
298
|
+
try { fs.unlinkSync(activeProfilePath); } catch {}
|
|
164
299
|
process.exit(0);
|
|
165
300
|
};
|
|
166
301
|
process.on('SIGINT', shutdown);
|
|
@@ -184,15 +319,45 @@ async function cmdStatus(args) {
|
|
|
184
319
|
ensureHomeDir();
|
|
185
320
|
const cfgPath = args.config ? path.resolve(args.config) : DEFAULT_CONFIG;
|
|
186
321
|
let cfg;
|
|
187
|
-
try { cfg = loadConfig(cfgPath); } catch (e) { console.log('down (no config)'); return; }
|
|
322
|
+
try { cfg = loadConfig(cfgPath); } catch (e) { console.log('down (no config — run "anthropic-gateway setup")'); return; }
|
|
188
323
|
try {
|
|
189
324
|
const res = await fetch('http://127.0.0.1:' + cfg.port + '/health');
|
|
190
|
-
|
|
325
|
+
const h = await res.json();
|
|
326
|
+
const t = h.totals || { requests: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
327
|
+
const rows = [
|
|
328
|
+
['Status', 'UP'],
|
|
329
|
+
['Version', h.version || getVersion()],
|
|
330
|
+
['PID', String(h.pid || '—')],
|
|
331
|
+
['Listen', '127.0.0.1:' + (h.port || cfg.port)],
|
|
332
|
+
['Upstream model', h.upstream || cfg.upstreamModel],
|
|
333
|
+
['Upstream URL', h.provider || cfg.upstreamBaseUrl],
|
|
334
|
+
['API key', maskKey(cfg.upstreamApiKey)],
|
|
335
|
+
['Tool format', h.toolFormat || cfg.toolFormat || 'native'],
|
|
336
|
+
['Full logging', h.fullLogging ? 'ON' : 'off'],
|
|
337
|
+
['Uptime', formatUptime(h.uptimeSec)],
|
|
338
|
+
['Requests', String(t.requests)],
|
|
339
|
+
['Tokens (in/out/total)', t.inputTokens + ' / ' + t.outputTokens + ' / ' + t.totalTokens],
|
|
340
|
+
];
|
|
341
|
+
const w = Math.max(...rows.map((r) => r[0].length));
|
|
342
|
+
for (const [k, v] of rows) console.log(k.padEnd(w) + ' ' + v);
|
|
191
343
|
} catch {
|
|
192
344
|
console.log('DOWN — nothing listening on 127.0.0.1:' + cfg.port);
|
|
345
|
+
console.log('Start it with: anthropic-gateway start');
|
|
193
346
|
}
|
|
194
347
|
}
|
|
195
348
|
|
|
349
|
+
async function cmdFullLogging(mode, args) {
|
|
350
|
+
const m = String(mode || '').toLowerCase();
|
|
351
|
+
if (m !== 'on' && m !== 'off') fail('usage: node cli.js fulllogging on|off');
|
|
352
|
+
const cfgPath = args && args.config ? path.resolve(args.config) : DEFAULT_CONFIG;
|
|
353
|
+
const cfg = loadConfig(cfgPath);
|
|
354
|
+
cfg.fullLogging = m === 'on';
|
|
355
|
+
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
356
|
+
console.log('fullLogging = ' + cfg.fullLogging + ' (saved to ' + cfgPath + ')');
|
|
357
|
+
console.log('Restart the gateway to apply: anthropic-gateway stop, then anthropic-gateway start');
|
|
358
|
+
if (cfg.fullLogging && cfg.logFile) console.log('Full request/response bodies will be appended to ' + cfg.logFile);
|
|
359
|
+
}
|
|
360
|
+
|
|
196
361
|
function startupCmdContents() {
|
|
197
362
|
const node = process.execPath;
|
|
198
363
|
return [
|
|
@@ -220,6 +385,52 @@ async function cmdAutostart(mode) {
|
|
|
220
385
|
}
|
|
221
386
|
}
|
|
222
387
|
|
|
388
|
+
async function cmdSelect(profileName, args) {
|
|
389
|
+
ensureHomeDir();
|
|
390
|
+
const cfgPath = args && args.config ? path.resolve(args.config) : DEFAULT_CONFIG;
|
|
391
|
+
if (!fs.existsSync(cfgPath)) fail('no config at ' + cfgPath + ' — run "anthropic-gateway setup" first');
|
|
392
|
+
let cfg;
|
|
393
|
+
try {
|
|
394
|
+
cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
395
|
+
} catch (e) {
|
|
396
|
+
fail('failed to parse config at ' + cfgPath);
|
|
397
|
+
}
|
|
398
|
+
// Ensure we are dealing with the new profiles format
|
|
399
|
+
if (!Array.isArray(cfg.profiles)) {
|
|
400
|
+
fail('config is in legacy format; please run setup again to migrate to profiles format');
|
|
401
|
+
}
|
|
402
|
+
const profileIndex = cfg.profiles.findIndex(p => p.providerName === profileName);
|
|
403
|
+
if (profileIndex === -1) {
|
|
404
|
+
fail('profile "' + profileName + '" not found in ' + cfgPath + ' (available: ' + cfg.profiles.map(p => p.providerName).join(', ') + ')');
|
|
405
|
+
}
|
|
406
|
+
cfg.defaultProfile = profileName;
|
|
407
|
+
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
408
|
+
console.log('defaultProfile set to "' + profileName + '" in ' + cfgPath);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function cmdSelect(profileName, args) {
|
|
412
|
+
ensureHomeDir();
|
|
413
|
+
const cfgPath = args && args.config ? path.resolve(args.config) : DEFAULT_CONFIG;
|
|
414
|
+
if (!fs.existsSync(cfgPath)) fail('no config at ' + cfgPath + ' — run "anthropic-gateway setup" first');
|
|
415
|
+
let cfg;
|
|
416
|
+
try {
|
|
417
|
+
cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
418
|
+
} catch (e) {
|
|
419
|
+
fail('failed to parse config at ' + cfgPath);
|
|
420
|
+
}
|
|
421
|
+
// Ensure we are dealing with the new profiles format
|
|
422
|
+
if (!Array.isArray(cfg.profiles)) {
|
|
423
|
+
fail('config is in legacy format; please run setup again to migrate to profiles format');
|
|
424
|
+
}
|
|
425
|
+
const profileIndex = cfg.profiles.findIndex(p => p.providerName === profileName);
|
|
426
|
+
if (profileIndex === -1) {
|
|
427
|
+
fail('profile "' + profileName + '" not found in ' + cfgPath + ' (available: ' + cfg.profiles.map(p => p.providerName).join(', ') + ')');
|
|
428
|
+
}
|
|
429
|
+
cfg.defaultProfile = profileName;
|
|
430
|
+
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
431
|
+
console.log('defaultProfile set to "' + profileName + '" in ' + cfgPath);
|
|
432
|
+
}
|
|
433
|
+
|
|
223
434
|
function parseFlags(argv) {
|
|
224
435
|
const out = {};
|
|
225
436
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -255,11 +466,13 @@ function parseFlags(argv) {
|
|
|
255
466
|
'anthropic-gateway — Anthropic-compatible gateway for OpenAI-compatible providers',
|
|
256
467
|
'',
|
|
257
468
|
'usage:',
|
|
258
|
-
' anthropic-gateway setup [--provider kilo|openai|custom] [--base-url URL] [--api-key KEY] [--model ID] [--port N] [--token TOK] [--yes]',
|
|
259
|
-
' anthropic-gateway start',
|
|
469
|
+
' anthropic-gateway setup [--provider kilo|openai|custom] [--base-url URL] [--api-key KEY] [--model ID] [--port N] [--token TOK] [--full-logging] [--yes]',
|
|
470
|
+
' anthropic-gateway start [profile] # if profile omitted, uses defaultProfile',
|
|
260
471
|
' anthropic-gateway stop',
|
|
261
472
|
' anthropic-gateway status',
|
|
473
|
+
' anthropic-gateway fulllogging on|off # log full request/response bodies to the log file (restart to apply)',
|
|
262
474
|
' anthropic-gateway autostart on|off',
|
|
475
|
+
' anthropic-gateway select <profile> # set defaultProfile for future start commands',
|
|
263
476
|
' anthropic-gateway version # or: --version, -v',
|
|
264
477
|
].join('\n')
|
|
265
478
|
);
|
|
@@ -270,9 +483,16 @@ function parseFlags(argv) {
|
|
|
270
483
|
process.exit(0);
|
|
271
484
|
}
|
|
272
485
|
if (cmd === 'setup') await cmdSetup(flags);
|
|
273
|
-
else if (cmd === 'start')
|
|
486
|
+
else if (cmd === 'start') {
|
|
487
|
+
// If a profile name is provided as the second argument (after 'start'), use it
|
|
488
|
+
const profileName = flags._ && flags._.length > 1 ? flags._[1] : undefined;
|
|
489
|
+
flags.profile = profileName;
|
|
490
|
+
await cmdStart(flags);
|
|
491
|
+
}
|
|
274
492
|
else if (cmd === 'stop') await cmdStop();
|
|
275
493
|
else if (cmd === 'status') await cmdStatus(flags);
|
|
494
|
+
else if (cmd === 'fulllogging') await cmdFullLogging(flags._[1], flags);
|
|
276
495
|
else if (cmd === 'autostart') await cmdAutostart(flags._[1]);
|
|
496
|
+
else if (cmd === 'select') await cmdSelect(flags._[1], flags);
|
|
277
497
|
else fail('unknown command: ' + cmd);
|
|
278
498
|
})();
|
package/config.example.json
CHANGED
|
@@ -1,20 +1,47 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
2
|
+
"defaultProfile": "kilo",
|
|
3
|
+
"profiles": [
|
|
4
|
+
{
|
|
5
|
+
"providerName": "kilo",
|
|
6
|
+
"upstreamBaseUrl": "https://kilo.imedata.ir/v1",
|
|
7
|
+
"upstreamApiKey": "<YOUR-PROVIDER-API-KEY>",
|
|
8
|
+
"upstreamModel": "kilo-auto/free",
|
|
9
|
+
"port": 3080,
|
|
10
|
+
"proxyTokens": [
|
|
11
|
+
"<YOUR-PROXY-TOKEN>"
|
|
12
|
+
],
|
|
13
|
+
"toolFormat": "native",
|
|
14
|
+
"fullLogging": false,
|
|
15
|
+
"advertisedModels": [
|
|
16
|
+
"claude-sonnet-4-5",
|
|
17
|
+
"claude-haiku-4-5",
|
|
18
|
+
"claude-opus-4-5",
|
|
19
|
+
"sonnet",
|
|
20
|
+
"haiku",
|
|
21
|
+
"opus"
|
|
22
|
+
],
|
|
23
|
+
"logFile": "gateway.log"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"providerName": "openai",
|
|
27
|
+
"upstreamBaseUrl": "https://api.openai.com/v1",
|
|
28
|
+
"upstreamApiKey": "<YOUR-OPENAI-API-KEY>",
|
|
29
|
+
"upstreamModel": "gpt-4o",
|
|
30
|
+
"port": 3081,
|
|
31
|
+
"proxyTokens": [
|
|
32
|
+
"<YOUR-PROXY-TOKEN>"
|
|
33
|
+
],
|
|
34
|
+
"toolFormat": "native",
|
|
35
|
+
"fullLogging": false,
|
|
36
|
+
"advertisedModels": [
|
|
37
|
+
"claude-sonnet-4-5",
|
|
38
|
+
"claude-haiku-4-5",
|
|
39
|
+
"claude-opus-4-5",
|
|
40
|
+
"sonnet",
|
|
41
|
+
"haiku",
|
|
42
|
+
"opus"
|
|
43
|
+
],
|
|
44
|
+
"logFile": "gateway.log"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
package/lib/server.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* proxyTokens string[] — accepted client auth tokens (Bearer or x-api-key)
|
|
14
14
|
* toolFormat 'native' (default) or 'xml'
|
|
15
15
|
* advertisedModels model ids returned by GET /v1/models (for GUI discovery)
|
|
16
|
+
* fullLogging boolean — when true, full request/response bodies are
|
|
17
|
+
* appended to logFile (log-only, not console)
|
|
16
18
|
* logFile optional path to append logs to (default: none, console only)
|
|
17
19
|
*/
|
|
18
20
|
const fs = require('fs');
|
|
@@ -31,6 +33,15 @@ const DEFAULTS = {
|
|
|
31
33
|
advertisedModels: ['claude-sonnet-4-5', 'claude-haiku-4-5', 'claude-opus-4-5'],
|
|
32
34
|
};
|
|
33
35
|
|
|
36
|
+
function readVersion() {
|
|
37
|
+
try {
|
|
38
|
+
return require('../package.json').version;
|
|
39
|
+
} catch {
|
|
40
|
+
return 'unknown';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const VERSION = readVersion();
|
|
44
|
+
|
|
34
45
|
function matchesToken(value, expected) {
|
|
35
46
|
if (typeof value !== 'string') return false;
|
|
36
47
|
const a = Buffer.from(value);
|
|
@@ -57,8 +68,10 @@ function makeServer(rawCfg) {
|
|
|
57
68
|
if (logStream) logStream.write(s + '\n');
|
|
58
69
|
}
|
|
59
70
|
|
|
60
|
-
//
|
|
71
|
+
// Per-session stats for the lifetime of this gateway process (reset on restart).
|
|
61
72
|
const totals = { requests: 0, input: 0, output: 0, cached: 0 };
|
|
73
|
+
const startedAt = Date.now();
|
|
74
|
+
const fullLogging = !!cfg.fullLogging;
|
|
62
75
|
function recordUsage(input, output, cached) {
|
|
63
76
|
totals.requests += 1;
|
|
64
77
|
totals.input += input;
|
|
@@ -68,6 +81,23 @@ function makeServer(rawCfg) {
|
|
|
68
81
|
function usageSummary() {
|
|
69
82
|
return 'session ' + totals.requests + ' req in=' + totals.input + ' out=' + totals.output + ' total=' + (totals.input + totals.output);
|
|
70
83
|
}
|
|
84
|
+
function uptimeSeconds() {
|
|
85
|
+
return Math.floor((Date.now() - startedAt) / 1000);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Full request/response capture, appended to the log file only (not console,
|
|
89
|
+
// so streaming stdout stays clean). Off unless fullLogging is set in config.
|
|
90
|
+
function logFull(kind, data) {
|
|
91
|
+
if (!fullLogging || !logStream) return;
|
|
92
|
+
const header = '[FULL ' + kind + '] ' + new Date().toISOString();
|
|
93
|
+
let body;
|
|
94
|
+
try {
|
|
95
|
+
body = typeof data === 'string' ? data : JSON.stringify(data);
|
|
96
|
+
} catch {
|
|
97
|
+
body = String(data);
|
|
98
|
+
}
|
|
99
|
+
logStream.write('\n' + header + '\n' + body + '\n');
|
|
100
|
+
}
|
|
71
101
|
|
|
72
102
|
const app = fastify({ logger: false });
|
|
73
103
|
const isAzure = cfg.upstreamBaseUrl.includes('.openai.azure.com');
|
|
@@ -75,7 +105,24 @@ function makeServer(rawCfg) {
|
|
|
75
105
|
|
|
76
106
|
const modelsPayload = { data: (cfg.advertisedModels || []).map((id) => ({ id, display_name: id + ' (via ' + cfg.upstreamModel + ')' })) };
|
|
77
107
|
|
|
78
|
-
app.get('/health', async () => ({
|
|
108
|
+
app.get('/health', async () => ({
|
|
109
|
+
status: 'ok',
|
|
110
|
+
gateway: 'anthropic-gateway',
|
|
111
|
+
version: VERSION,
|
|
112
|
+
pid: process.pid,
|
|
113
|
+
port: cfg.port,
|
|
114
|
+
upstream: cfg.upstreamModel,
|
|
115
|
+
provider: cfg.providerName || cfg.upstreamBaseUrl,
|
|
116
|
+
toolFormat: cfg.toolFormat,
|
|
117
|
+
uptimeSec: uptimeSeconds(),
|
|
118
|
+
fullLogging,
|
|
119
|
+
totals: {
|
|
120
|
+
requests: totals.requests,
|
|
121
|
+
inputTokens: totals.input,
|
|
122
|
+
outputTokens: totals.output,
|
|
123
|
+
totalTokens: totals.input + totals.output,
|
|
124
|
+
},
|
|
125
|
+
}));
|
|
79
126
|
app.get('/v1/models', async () => modelsPayload);
|
|
80
127
|
|
|
81
128
|
app.post('/v1/messages', async (request, reply) => {
|
|
@@ -109,11 +156,15 @@ function makeServer(rawCfg) {
|
|
|
109
156
|
|
|
110
157
|
try {
|
|
111
158
|
const openaiRequest = convertRequestToOpenAI(anthropicRequest, cfg.upstreamModel, cfg.toolFormat, isAzure);
|
|
159
|
+
logFull('IN request (anthropic)', anthropicRequest);
|
|
160
|
+
logFull('IN request (openai)', openaiRequest);
|
|
112
161
|
if (isStreaming) {
|
|
113
162
|
const stream = await openai.chat.completions.create({ ...openaiRequest, stream: true, stream_options: { include_usage: true } });
|
|
114
163
|
// Capture token usage from the stream without altering what the vendored
|
|
115
164
|
// converter sees — it just iterates chunks, so a pass-through works.
|
|
116
165
|
const usage = { input: 0, output: 0, cached: 0 };
|
|
166
|
+
let outText = '';
|
|
167
|
+
let outTools = [];
|
|
117
168
|
const wrapped = (async function* () {
|
|
118
169
|
for await (const chunk of stream) {
|
|
119
170
|
if (chunk && chunk.usage) {
|
|
@@ -121,17 +172,31 @@ function makeServer(rawCfg) {
|
|
|
121
172
|
usage.output = chunk.usage.completion_tokens ?? usage.output;
|
|
122
173
|
usage.cached = chunk.usage.prompt_tokens_details?.cached_tokens ?? usage.cached;
|
|
123
174
|
}
|
|
175
|
+
const choice = chunk && chunk.choices && chunk.choices[0];
|
|
176
|
+
if (choice && choice.delta) {
|
|
177
|
+
if (choice.delta.content) outText += choice.delta.content;
|
|
178
|
+
if (choice.delta.tool_calls) {
|
|
179
|
+
for (const tc of choice.delta.tool_calls) {
|
|
180
|
+
const rec = outTools[tc.index] || (outTools[tc.index] = { id: tc.id || '', name: '', arguments: '' });
|
|
181
|
+
if (tc.id) rec.id = tc.id;
|
|
182
|
+
if (tc.function && tc.function.name) rec.name += tc.function.name;
|
|
183
|
+
if (tc.function && tc.function.arguments) rec.arguments += tc.function.arguments;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
124
187
|
yield chunk;
|
|
125
188
|
}
|
|
126
189
|
})();
|
|
127
190
|
reply.hijack();
|
|
128
191
|
await streamOpenAIToAnthropic(wrapped, reply, clientModel, cfg.upstreamBaseUrl);
|
|
129
192
|
recordUsage(usage.input, usage.output, usage.cached);
|
|
193
|
+
logFull('OUT response (streamed)', { model: clientModel, text: outText, tool_calls: outTools.filter(Boolean), usage });
|
|
130
194
|
log('<- ' + clientModel + ' ok in=' + usage.input + ' out=' + usage.output + ' total=' + (usage.input + usage.output) + ' | ' + usageSummary());
|
|
131
195
|
} else {
|
|
132
196
|
const response = await openai.chat.completions.create({ ...openaiRequest, stream: false });
|
|
133
197
|
const anthropicResponse = convertResponseToAnthropic(response, clientModel);
|
|
134
198
|
reply.send(anthropicResponse);
|
|
199
|
+
logFull('OUT response (anthropic)', anthropicResponse);
|
|
135
200
|
const u = anthropicResponse.usage || {};
|
|
136
201
|
recordUsage(u.input_tokens || 0, u.output_tokens || 0, u.cache_read_input_tokens || 0);
|
|
137
202
|
log('<- ' + clientModel + ' ok in=' + (u.input_tokens || 0) + ' out=' + (u.output_tokens || 0) + ' total=' + ((u.input_tokens || 0) + (u.output_tokens || 0)) + ' | ' + usageSummary());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anthropic-gateway",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Anthropic-compatible local gateway for any OpenAI-compatible model provider — make Claude Code (GUI/CLI) work with kilo, OpenAI, DeepSeek, local models, etc.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"engines": {
|