anthropic-gateway 1.2.0 → 1.3.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
@@ -52,18 +52,51 @@ anthropic-gateway setup --base-url https://api.openai.com/v1 --api-key sk-... --
52
52
 
53
53
  | Command | Description |
54
54
  | --- | --- |
55
- | `anthropic-gateway setup` | Create/overwrite `~/.anthropic-gateway/config.json`; tests connectivity |
55
+ | `anthropic-gateway setup` | Create/overwrite `~/.anthropic-gateway/config.json`; tests connectivity (`--full-logging` to enable full-body logging) |
56
56
  | `anthropic-gateway start` | Start the gateway |
57
57
  | `anthropic-gateway stop` | Stop the gateway |
58
- | `anthropic-gateway status` | Is the gateway up? |
58
+ | `anthropic-gateway status` | Pretty status: version, pid, upstream, uptime, request/token totals, full-logging state |
59
+ | `anthropic-gateway fulllogging on\|off` | Toggle full request/response logging (restart to apply) |
59
60
  | `anthropic-gateway autostart on\|off` | Launch at Windows logon (Startup folder) |
61
+ | `anthropic-gateway version` | Print the version (also `--version` / `-v`) |
60
62
 
61
63
  ## config.json
62
64
 
63
65
  See `config.example.json`. Key fields: `upstreamBaseUrl`, `upstreamApiKey`,
64
66
  `upstreamModel`, `port`, `proxyTokens` (accepts several), `advertisedModels`
65
67
  (models shown to Claude Code model discovery — must look Anthropic-ish, e.g.
66
- `claude-sonnet-4-5` or `sonnet`), `toolFormat`.
68
+ `claude-sonnet-4-5` or `sonnet`), `toolFormat`, `fullLogging`.
69
+
70
+ ## Logging
71
+
72
+ `gateway.log` (in `~/.anthropic-gateway/`) records, per request, the tokens
73
+ sent/received and a running session total (input / output / total since the
74
+ gateway started).
75
+
76
+ To also capture the **full request and response bodies** (useful for debugging
77
+ upstream mismatches), enable full logging:
78
+
79
+ ```bash
80
+ anthropic-gateway fulllogging on # or: setup --full-logging
81
+ anthropic-gateway stop && anthropic-gateway start # restart to apply
82
+ ```
83
+
84
+ Full bodies are written to the log file only (not the console), so streaming
85
+ stdout stays clean. Turn it off the same way with `fulllogging off`.
86
+
87
+ `status` shows the same token totals plus version, pid, uptime and the
88
+ full-logging state:
89
+
90
+ ```
91
+ Status UP
92
+ Version 1.2.0
93
+ PID 23628
94
+ Listen 127.0.0.1:3080
95
+ Upstream model Qwen/Qwen3.8-27B
96
+ ...
97
+ Requests 42
98
+ Tokens (in/out/total) 118300 / 9210 / 127510
99
+ ```
67
100
 
68
101
  ## Using it with Claude Code GUI (desktop app)
69
102
 
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()));
@@ -123,6 +143,8 @@ async function cmdSetup(args) {
123
143
  if (!Number.isInteger(port) || port < 1 || port > 65535) fail('invalid port');
124
144
 
125
145
  const advertisedModels = adv.split(',').map((s) => s.trim()).filter(Boolean);
146
+ const fullLoggingFlag = args['full-logging'];
147
+ const fullLogging = fullLoggingFlag !== undefined && String(fullLoggingFlag) !== 'false';
126
148
  const config = {
127
149
  providerName: preset.name || 'custom',
128
150
  upstreamBaseUrl: baseUrl.replace(/\/+$/, ''),
@@ -132,6 +154,7 @@ async function cmdSetup(args) {
132
154
  proxyTokens: [token],
133
155
  toolFormat: 'native',
134
156
  advertisedModels,
157
+ fullLogging,
135
158
  logFile: LOG_FILE,
136
159
  };
137
160
 
@@ -184,15 +207,45 @@ async function cmdStatus(args) {
184
207
  ensureHomeDir();
185
208
  const cfgPath = args.config ? path.resolve(args.config) : DEFAULT_CONFIG;
186
209
  let cfg;
187
- try { cfg = loadConfig(cfgPath); } catch (e) { console.log('down (no config)'); return; }
210
+ try { cfg = loadConfig(cfgPath); } catch (e) { console.log('down (no config — run "anthropic-gateway setup")'); return; }
188
211
  try {
189
212
  const res = await fetch('http://127.0.0.1:' + cfg.port + '/health');
190
- console.log('UP on ' + cfg.port + ' — ' + (await res.text()));
213
+ const h = await res.json();
214
+ const t = h.totals || { requests: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 };
215
+ const rows = [
216
+ ['Status', 'UP'],
217
+ ['Version', h.version || getVersion()],
218
+ ['PID', String(h.pid || '—')],
219
+ ['Listen', '127.0.0.1:' + (h.port || cfg.port)],
220
+ ['Upstream model', h.upstream || cfg.upstreamModel],
221
+ ['Upstream URL', h.provider || cfg.upstreamBaseUrl],
222
+ ['API key', maskKey(cfg.upstreamApiKey)],
223
+ ['Tool format', h.toolFormat || cfg.toolFormat || 'native'],
224
+ ['Full logging', h.fullLogging ? 'ON' : 'off'],
225
+ ['Uptime', formatUptime(h.uptimeSec)],
226
+ ['Requests', String(t.requests)],
227
+ ['Tokens (in/out/total)', t.inputTokens + ' / ' + t.outputTokens + ' / ' + t.totalTokens],
228
+ ];
229
+ const w = Math.max(...rows.map((r) => r[0].length));
230
+ for (const [k, v] of rows) console.log(k.padEnd(w) + ' ' + v);
191
231
  } catch {
192
232
  console.log('DOWN — nothing listening on 127.0.0.1:' + cfg.port);
233
+ console.log('Start it with: anthropic-gateway start');
193
234
  }
194
235
  }
195
236
 
237
+ async function cmdFullLogging(mode, args) {
238
+ const m = String(mode || '').toLowerCase();
239
+ if (m !== 'on' && m !== 'off') fail('usage: node cli.js fulllogging on|off');
240
+ const cfgPath = args && args.config ? path.resolve(args.config) : DEFAULT_CONFIG;
241
+ const cfg = loadConfig(cfgPath);
242
+ cfg.fullLogging = m === 'on';
243
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
244
+ console.log('fullLogging = ' + cfg.fullLogging + ' (saved to ' + cfgPath + ')');
245
+ console.log('Restart the gateway to apply: anthropic-gateway stop, then anthropic-gateway start');
246
+ if (cfg.fullLogging && cfg.logFile) console.log('Full request/response bodies will be appended to ' + cfg.logFile);
247
+ }
248
+
196
249
  function startupCmdContents() {
197
250
  const node = process.execPath;
198
251
  return [
@@ -255,10 +308,11 @@ function parseFlags(argv) {
255
308
  'anthropic-gateway — Anthropic-compatible gateway for OpenAI-compatible providers',
256
309
  '',
257
310
  'usage:',
258
- ' anthropic-gateway setup [--provider kilo|openai|custom] [--base-url URL] [--api-key KEY] [--model ID] [--port N] [--token TOK] [--yes]',
311
+ ' anthropic-gateway setup [--provider kilo|openai|custom] [--base-url URL] [--api-key KEY] [--model ID] [--port N] [--token TOK] [--full-logging] [--yes]',
259
312
  ' anthropic-gateway start',
260
313
  ' anthropic-gateway stop',
261
314
  ' anthropic-gateway status',
315
+ ' anthropic-gateway fulllogging on|off # log full request/response bodies to the log file (restart to apply)',
262
316
  ' anthropic-gateway autostart on|off',
263
317
  ' anthropic-gateway version # or: --version, -v',
264
318
  ].join('\n')
@@ -273,6 +327,7 @@ function parseFlags(argv) {
273
327
  else if (cmd === 'start') await cmdStart(flags);
274
328
  else if (cmd === 'stop') await cmdStop();
275
329
  else if (cmd === 'status') await cmdStatus(flags);
330
+ else if (cmd === 'fulllogging') await cmdFullLogging(flags._[1], flags);
276
331
  else if (cmd === 'autostart') await cmdAutostart(flags._[1]);
277
332
  else fail('unknown command: ' + cmd);
278
333
  })();
@@ -8,6 +8,7 @@
8
8
  "<YOUR-PROXY-TOKEN>"
9
9
  ],
10
10
  "toolFormat": "native",
11
+ "fullLogging": false,
11
12
  "advertisedModels": [
12
13
  "claude-sonnet-4-5",
13
14
  "claude-haiku-4-5",
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
- // Token totals for the lifetime of this gateway process (resets on restart).
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 () => ({ status: 'ok', gateway: 'anthropic-gateway', upstream: cfg.upstreamModel }));
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.2.0",
3
+ "version": "1.3.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": {