anthropic-gateway 1.3.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 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,36 @@ 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`; tests connectivity (`--full-logging` to enable full-body logging) |
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
66
  | `anthropic-gateway status` | Pretty status: version, pid, upstream, uptime, request/token totals, full-logging state |
59
67
  | `anthropic-gateway fulllogging on\|off` | Toggle full request/response logging (restart to apply) |
60
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 |
61
70
  | `anthropic-gateway version` | Print the version (also `--version` / `-v`) |
62
71
 
63
72
  ## config.json
64
73
 
65
- See `config.example.json`. Key fields: `upstreamBaseUrl`, `upstreamApiKey`,
74
+ See `config.example.json`. The config now contains a `profiles` array and a `defaultProfile`.
75
+ Each profile object includes: `providerName`, `upstreamBaseUrl`, `upstreamApiKey`,
66
76
  `upstreamModel`, `port`, `proxyTokens` (accepts several), `advertisedModels`
67
77
  (models shown to Claude Code model discovery — must look Anthropic-ish, e.g.
68
- `claude-sonnet-4-5` or `sonnet`), `toolFormat`, `fullLogging`.
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.
69
81
 
70
82
  ## Logging
71
83
 
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,49 @@ 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
+ // 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
177
285
  if (cfg.logFile && !path.isAbsolute(cfg.logFile)) cfg.logFile = path.join(HOME_DIR, cfg.logFile);
178
286
  const { makeServer } = require('./lib/server');
179
287
  const server = makeServer(cfg);
180
288
  const started = await server.start();
181
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));
182
293
  console.log('pid ' + process.pid + ' written to ' + PID_FILE);
183
294
  console.log('Health: http://127.0.0.1:' + started + '/health');
184
295
  const shutdown = async () => {
185
296
  await server.stop();
186
297
  try { fs.unlinkSync(PID_FILE); } catch {}
298
+ try { fs.unlinkSync(activeProfilePath); } catch {}
187
299
  process.exit(0);
188
300
  };
189
301
  process.on('SIGINT', shutdown);
@@ -273,6 +385,52 @@ async function cmdAutostart(mode) {
273
385
  }
274
386
  }
275
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
+
276
434
  function parseFlags(argv) {
277
435
  const out = {};
278
436
  for (let i = 0; i < argv.length; i++) {
@@ -309,11 +467,12 @@ function parseFlags(argv) {
309
467
  '',
310
468
  'usage:',
311
469
  ' 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',
470
+ ' anthropic-gateway start [profile] # if profile omitted, uses defaultProfile',
313
471
  ' anthropic-gateway stop',
314
472
  ' anthropic-gateway status',
315
473
  ' anthropic-gateway fulllogging on|off # log full request/response bodies to the log file (restart to apply)',
316
474
  ' anthropic-gateway autostart on|off',
475
+ ' anthropic-gateway select <profile> # set defaultProfile for future start commands',
317
476
  ' anthropic-gateway version # or: --version, -v',
318
477
  ].join('\n')
319
478
  );
@@ -324,10 +483,16 @@ function parseFlags(argv) {
324
483
  process.exit(0);
325
484
  }
326
485
  if (cmd === 'setup') await cmdSetup(flags);
327
- else if (cmd === 'start') await cmdStart(flags);
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
+ }
328
492
  else if (cmd === 'stop') await cmdStop();
329
493
  else if (cmd === 'status') await cmdStatus(flags);
330
494
  else if (cmd === 'fulllogging') await cmdFullLogging(flags._[1], flags);
331
495
  else if (cmd === 'autostart') await cmdAutostart(flags._[1]);
496
+ else if (cmd === 'select') await cmdSelect(flags._[1], flags);
332
497
  else fail('unknown command: ' + cmd);
333
498
  })();
@@ -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.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": {