anthropic-gateway 1.3.0 → 1.5.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 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 (keep this console open)
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,24 +48,38 @@ 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
+ - List all defined profiles: `anthropic-gateway profiles`
56
+ - Start a specific profile: `anthropic-gateway start openai`
57
+ - Set the default profile for future `start` commands: `anthropic-gateway select kilo`
58
+ - Then `anthropic-gateway start` will use the kilo profile without specifying it.
59
+
51
60
  ## Commands
52
61
 
53
62
  | Command | Description |
54
63
  | --- | --- |
55
- | `anthropic-gateway setup` | Create/overwrite `~/.anthropic-gateway/config.json`; tests connectivity (`--full-logging` to enable full-body logging) |
56
- | `anthropic-gateway start` | Start the gateway |
64
+ | `anthropic-gateway setup` | Create/overwrite `~/.anthropic-gateway/config.json` with a profiles array; tests connectivity (`--full-logging` to enable full-body logging) |
65
+ | `anthropic-gateway start [profile]` | Start the gateway using the given profile name; if omitted, uses `defaultProfile` from config |
57
66
  | `anthropic-gateway stop` | Stop the gateway |
58
67
  | `anthropic-gateway status` | Pretty status: version, pid, upstream, uptime, request/token totals, full-logging state |
59
68
  | `anthropic-gateway fulllogging on\|off` | Toggle full request/response logging (restart to apply) |
60
69
  | `anthropic-gateway autostart on\|off` | Launch at Windows logon (Startup folder) |
70
+ | `anthropic-gateway select <profile>` | Set the default profile for future `start` commands |
71
+ | `anthropic-gateway profiles` | List all defined profiles in the config file |
61
72
  | `anthropic-gateway version` | Print the version (also `--version` / `-v`) |
62
73
 
63
74
  ## config.json
64
75
 
65
- See `config.example.json`. Key fields: `upstreamBaseUrl`, `upstreamApiKey`,
76
+ See `config.example.json`. The config now contains a `profiles` array and a `defaultProfile`.
77
+ Each profile object includes: `providerName`, `upstreamBaseUrl`, `upstreamApiKey`,
66
78
  `upstreamModel`, `port`, `proxyTokens` (accepts several), `advertisedModels`
67
79
  (models shown to Claude Code model discovery — must look Anthropic-ish, e.g.
68
- `claude-sonnet-4-5` or `sonnet`), `toolFormat`, `fullLogging`.
80
+ `claude-sonnet-4-5` or `sonnet`), `toolFormat`, `fullLogging`, `logFile`.
81
+ Only the profile named by `defaultProfile` (or the first profile if unspecified)
82
+ is used by `anthropic-gateway start` unless a profile name is given explicitly.
69
83
 
70
84
  ## Logging
71
85
 
package/cli.js CHANGED
@@ -80,9 +80,31 @@ function randomToken() {
80
80
  return 'ag-' + require('crypto').randomBytes(24).toString('base64url');
81
81
  }
82
82
 
83
- function loadConfig(cfgPath) {
84
- if (!fs.existsSync(cfgPath)) fail('no config at ' + cfgPath + ' run "node cli.js setup" first');
85
- return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
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 };
86
108
  }
87
109
 
88
110
  async function testUpstream(baseUrl, apiKey, model) {
@@ -145,7 +167,47 @@ async function cmdSetup(args) {
145
167
  const advertisedModels = adv.split(',').map((s) => s.trim()).filter(Boolean);
146
168
  const fullLoggingFlag = args['full-logging'];
147
169
  const fullLogging = fullLoggingFlag !== undefined && String(fullLoggingFlag) !== 'false';
148
- const config = {
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 = {
149
211
  providerName: preset.name || 'custom',
150
212
  upstreamBaseUrl: baseUrl.replace(/\/+$/, ''),
151
213
  upstreamApiKey: apiKey,
@@ -155,16 +217,34 @@ async function cmdSetup(args) {
155
217
  toolFormat: 'native',
156
218
  advertisedModels,
157
219
  fullLogging,
158
- logFile: LOG_FILE,
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,
159
239
  };
160
240
 
161
241
  console.log('Testing upstream connectivity...');
162
- const ok = await testUpstream(config.upstreamBaseUrl, config.upstreamApiKey, config.upstreamModel);
242
+ const ok = await testUpstream(newProfile.upstreamBaseUrl, newProfile.upstreamApiKey, newProfile.upstreamModel);
163
243
  if (!ok && !yes) {
164
244
  const c = await ask(readline.createInterface({ input: process.stdin, output: process.stdout }), 'Connectivity test failed. Save anyway? (y/N):', 'N');
165
245
  if (c.toLowerCase() !== 'y') fail('aborted — nothing written');
166
246
  }
167
- fs.writeFileSync(cfgPath, JSON.stringify(config, null, 2));
247
+ fs.writeFileSync(cfgPath, JSON.stringify(configOut, null, 2));
168
248
  console.log('Config written to ' + cfgPath);
169
249
  console.log('Proxy token (use as API key / x-api-key / Bearer in the client): ' + token);
170
250
  console.log('Next: anthropic-gateway start (or: anthropic-gateway autostart on)');
@@ -173,17 +253,34 @@ async function cmdSetup(args) {
173
253
  async function cmdStart(args) {
174
254
  ensureHomeDir();
175
255
  const cfgPath = args.config ? path.resolve(args.config) : DEFAULT_CONFIG;
176
- const cfg = loadConfig(cfgPath);
256
+ const profileArg = args.profile;
257
+ const cfg = loadProfile(cfgPath, profileArg); // loadProfile now takes profileName
177
258
  if (cfg.logFile && !path.isAbsolute(cfg.logFile)) cfg.logFile = path.join(HOME_DIR, cfg.logFile);
178
259
  const { makeServer } = require('./lib/server');
179
260
  const server = makeServer(cfg);
180
- const started = await server.start();
261
+ let started;
262
+ try {
263
+ started = await server.start();
264
+ } catch (err) {
265
+ if (err.code === 'EADDRINUSE') {
266
+ fail(`Port ${cfg.port} is already in use. Another process might be running on this port.\n` +
267
+ `To resolve:\n` +
268
+ ` 1. Stop the existing gateway: anthropic-gateway stop\n` +
269
+ ` 2. Or change the port in your config (or use a different profile with a different port).\n` +
270
+ `Underlying error: ${err.message}`);
271
+ }
272
+ throw err; // re-throw if we didn't handle it
273
+ }
181
274
  fs.writeFileSync(PID_FILE, String(process.pid));
275
+ // Also write the active profile to a file
276
+ const activeProfilePath = path.join(HOME_DIR, 'active_profile');
277
+ fs.writeFileSync(activeProfilePath, profileArg || cfg._defaultProfile || (cfg._profiles && cfg._profiles[0]?.providerName));
182
278
  console.log('pid ' + process.pid + ' written to ' + PID_FILE);
183
279
  console.log('Health: http://127.0.0.1:' + started + '/health');
184
280
  const shutdown = async () => {
185
281
  await server.stop();
186
282
  try { fs.unlinkSync(PID_FILE); } catch {}
283
+ try { fs.unlinkSync(activeProfilePath); } catch {}
187
284
  process.exit(0);
188
285
  };
189
286
  process.on('SIGINT', shutdown);
@@ -273,6 +370,61 @@ async function cmdAutostart(mode) {
273
370
  }
274
371
  }
275
372
 
373
+ async function cmdSelect(profileName, args) {
374
+ ensureHomeDir();
375
+ const cfgPath = args && args.config ? path.resolve(args.config) : DEFAULT_CONFIG;
376
+ if (!fs.existsSync(cfgPath)) fail('no config at ' + cfgPath + ' — run "anthropic-gateway setup" first');
377
+ let cfg;
378
+ try {
379
+ cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
380
+ } catch (e) {
381
+ fail('failed to parse config at ' + cfgPath);
382
+ }
383
+ // Ensure we are dealing with the new profiles format
384
+ if (!Array.isArray(cfg.profiles)) {
385
+ fail('config is in legacy format; please run setup again to migrate to profiles format');
386
+ }
387
+ const profileIndex = cfg.profiles.findIndex(p => p.providerName === profileName);
388
+ if (profileIndex === -1) {
389
+ fail('profile "' + profileName + '" not found in ' + cfgPath + ' (available: ' + cfg.profiles.map(p => p.providerName).join(', ') + ')');
390
+ }
391
+ cfg.defaultProfile = profileName;
392
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
393
+ console.log('defaultProfile set to "' + profileName + '" in ' + cfgPath);
394
+ }
395
+
396
+ async function cmdListProfiles(args) {
397
+ ensureHomeDir();
398
+ const cfgPath = args && args.config ? path.resolve(args.config) : DEFAULT_CONFIG;
399
+ if (!fs.existsSync(cfgPath)) fail('no config at ' + cfgPath + ' — run "anthropic-gateway setup" first');
400
+ let cfg;
401
+ try {
402
+ cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
403
+ } catch (e) {
404
+ fail('failed to parse config at ' + cfgPath);
405
+ }
406
+ // Handle legacy config
407
+ if (!Array.isArray(cfg.profiles)) {
408
+ // Legacy single profile
409
+ if (cfg.providerName) {
410
+ console.log('Profiles (legacy format):');
411
+ console.log(' * ' + cfg.providerName + ' (default)');
412
+ } else {
413
+ console.log('No profiles found in legacy config.');
414
+ }
415
+ return;
416
+ }
417
+ if (cfg.profiles.length === 0) {
418
+ console.log('No profiles defined.');
419
+ return;
420
+ }
421
+ console.log('Profiles:');
422
+ for (const p of cfg.profiles) {
423
+ const marker = (p.providerName === cfg.defaultProfile) ? ' (default)' : '';
424
+ console.log(' * ' + p.providerName + marker);
425
+ }
426
+ }
427
+
276
428
  function parseFlags(argv) {
277
429
  const out = {};
278
430
  for (let i = 0; i < argv.length; i++) {
@@ -309,11 +461,13 @@ function parseFlags(argv) {
309
461
  '',
310
462
  'usage:',
311
463
  ' anthropic-gateway setup [--provider kilo|openai|custom] [--base-url URL] [--api-key KEY] [--model ID] [--port N] [--token TOK] [--full-logging] [--yes]',
312
- ' anthropic-gateway start',
464
+ ' anthropic-gateway start [profile] # if profile omitted, uses defaultProfile',
313
465
  ' anthropic-gateway stop',
314
466
  ' anthropic-gateway status',
315
467
  ' anthropic-gateway fulllogging on|off # log full request/response bodies to the log file (restart to apply)',
316
468
  ' anthropic-gateway autostart on|off',
469
+ ' anthropic-gateway select <profile> # set defaultProfile for future start commands',
470
+ ' anthropic-gateway profiles # list all defined profiles',
317
471
  ' anthropic-gateway version # or: --version, -v',
318
472
  ].join('\n')
319
473
  );
@@ -324,10 +478,17 @@ function parseFlags(argv) {
324
478
  process.exit(0);
325
479
  }
326
480
  if (cmd === 'setup') await cmdSetup(flags);
327
- else if (cmd === 'start') await cmdStart(flags);
481
+ else if (cmd === 'start') {
482
+ // If a profile name is provided as the second argument (after 'start'), use it
483
+ const profileName = flags._ && flags._.length > 1 ? flags._[1] : undefined;
484
+ flags.profile = profileName;
485
+ await cmdStart(flags);
486
+ }
328
487
  else if (cmd === 'stop') await cmdStop();
329
488
  else if (cmd === 'status') await cmdStatus(flags);
330
489
  else if (cmd === 'fulllogging') await cmdFullLogging(flags._[1], flags);
331
490
  else if (cmd === 'autostart') await cmdAutostart(flags._[1]);
491
+ else if (cmd === 'select') await cmdSelect(flags._[1], flags);
492
+ else if (cmd === 'profiles') await cmdListProfiles(flags);
332
493
  else fail('unknown command: ' + cmd);
333
494
  })();
@@ -1,21 +1,47 @@
1
1
  {
2
- "providerName": "example-provider",
3
- "upstreamBaseUrl": "https://PROVIDER-HOST/v1",
4
- "upstreamApiKey": "<YOUR-PROVIDER-API-KEY>",
5
- "upstreamModel": "your-model-id",
6
- "port": 3080,
7
- "proxyTokens": [
8
- "<YOUR-PROXY-TOKEN>"
9
- ],
10
- "toolFormat": "native",
11
- "fullLogging": false,
12
- "advertisedModels": [
13
- "claude-sonnet-4-5",
14
- "claude-haiku-4-5",
15
- "claude-opus-4-5",
16
- "sonnet",
17
- "haiku",
18
- "opus"
19
- ],
20
- "logFile": "gateway.log"
21
- }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anthropic-gateway",
3
- "version": "1.3.0",
3
+ "version": "1.5.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": {