@nvae/llmswitch 0.7.0 → 0.8.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.
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Input token accounting for the `count_tokens` endpoint.
3
+ *
4
+ * Native upstream endpoints are always preferred (see `forwardCountTokens`).
5
+ * This module covers the remaining case: the model lives behind a non-Anthropic
6
+ * upstream that offers no counting API.
7
+ *
8
+ * Two levels of fidelity:
9
+ * - `tokenizer`: a real BPE tokenizer, used when the optional `gpt-tokenizer`
10
+ * package is installed. Exact for OpenAI encodings and a close proxy for
11
+ * other vocabularies.
12
+ * - `heuristic`: a script-aware estimate. Text is segmented by writing system
13
+ * because tokens-per-character differs sharply between CJK, Latin and
14
+ * digits; a flat characters/4 rule is badly wrong on mixed content.
15
+ *
16
+ * Both levels add the parts a text tokenizer cannot know about: per-message
17
+ * framing, tool schema text and image tokens derived from real pixel
18
+ * dimensions. Estimates lean slightly high, since the caller uses the result to
19
+ * decide whether a request fits.
20
+ */
21
+ /** Anthropic bills images at roughly width × height / 750 tokens. */
22
+ const IMAGE_PIXELS_PER_TOKEN = 750;
23
+ /** Used when an image's dimensions cannot be determined. */
24
+ const UNKNOWN_IMAGE_TOKENS = 1_200;
25
+ /** Per-message envelope (role marker plus delimiters). */
26
+ const MESSAGE_OVERHEAD_TOKENS = 3;
27
+ /** Per-tool envelope on top of the serialized schema. */
28
+ const TOOL_OVERHEAD_TOKENS = 10;
29
+ /** Request-level envelope. */
30
+ const REQUEST_OVERHEAD_TOKENS = 8;
31
+ function asRecord(value) {
32
+ if (value && typeof value === "object" && !Array.isArray(value)) {
33
+ return value;
34
+ }
35
+ return null;
36
+ }
37
+ function parsePngSize(buf) {
38
+ // 8-byte signature, then an IHDR chunk whose payload starts at offset 16.
39
+ if (buf.length < 24)
40
+ return null;
41
+ if (buf.readUInt32BE(0) !== 0x89504e47)
42
+ return null;
43
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
44
+ }
45
+ function parseGifSize(buf) {
46
+ if (buf.length < 10)
47
+ return null;
48
+ if (buf.toString("ascii", 0, 3) !== "GIF")
49
+ return null;
50
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
51
+ }
52
+ function parseJpegSize(buf) {
53
+ if (buf.length < 4 || buf.readUInt16BE(0) !== 0xffd8)
54
+ return null;
55
+ let offset = 2;
56
+ while (offset + 9 < buf.length) {
57
+ if (buf[offset] !== 0xff) {
58
+ offset += 1;
59
+ continue;
60
+ }
61
+ const marker = buf[offset + 1];
62
+ // Standalone markers carry no length field.
63
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
64
+ offset += 2;
65
+ continue;
66
+ }
67
+ const length = buf.readUInt16BE(offset + 2);
68
+ // SOF0..SOF15, excluding the DHT/JPG/DAC markers interleaved in that range.
69
+ const isStartOfFrame = marker >= 0xc0 &&
70
+ marker <= 0xcf &&
71
+ marker !== 0xc4 &&
72
+ marker !== 0xc8 &&
73
+ marker !== 0xcc;
74
+ if (isStartOfFrame) {
75
+ return {
76
+ height: buf.readUInt16BE(offset + 5),
77
+ width: buf.readUInt16BE(offset + 7),
78
+ };
79
+ }
80
+ if (length < 2)
81
+ return null;
82
+ offset += 2 + length;
83
+ }
84
+ return null;
85
+ }
86
+ function parseWebpSize(buf) {
87
+ if (buf.length < 30)
88
+ return null;
89
+ if (buf.toString("ascii", 0, 4) !== "RIFF")
90
+ return null;
91
+ if (buf.toString("ascii", 8, 12) !== "WEBP")
92
+ return null;
93
+ const chunk = buf.toString("ascii", 12, 16);
94
+ if (chunk === "VP8X") {
95
+ // 24-bit little-endian canvas size minus one.
96
+ const width = 1 + (buf.readUIntLE(24, 3) & 0xffffff);
97
+ const height = 1 + (buf.readUIntLE(27, 3) & 0xffffff);
98
+ return { width, height };
99
+ }
100
+ if (chunk === "VP8 ") {
101
+ return {
102
+ width: buf.readUInt16LE(26) & 0x3fff,
103
+ height: buf.readUInt16LE(28) & 0x3fff,
104
+ };
105
+ }
106
+ if (chunk === "VP8L") {
107
+ const bits = buf.readUInt32LE(21);
108
+ return {
109
+ width: 1 + (bits & 0x3fff),
110
+ height: 1 + ((bits >> 14) & 0x3fff),
111
+ };
112
+ }
113
+ return null;
114
+ }
115
+ /** Read pixel dimensions from a PNG, JPEG, GIF or WebP header. */
116
+ export function parseImageDimensions(buf) {
117
+ const size = parsePngSize(buf) ||
118
+ parseJpegSize(buf) ||
119
+ parseGifSize(buf) ||
120
+ parseWebpSize(buf);
121
+ if (!size)
122
+ return null;
123
+ if (!Number.isFinite(size.width) ||
124
+ !Number.isFinite(size.height) ||
125
+ size.width <= 0 ||
126
+ size.height <= 0) {
127
+ return null;
128
+ }
129
+ return size;
130
+ }
131
+ export function imageTokensForSize(size) {
132
+ if (!size)
133
+ return UNKNOWN_IMAGE_TOKENS;
134
+ return Math.max(1, Math.ceil((size.width * size.height) / IMAGE_PIXELS_PER_TOKEN));
135
+ }
136
+ /** Token cost of a base64 image payload, decoding only the header bytes. */
137
+ export function imageTokensFromBase64(data) {
138
+ if (!data)
139
+ return UNKNOWN_IMAGE_TOKENS;
140
+ try {
141
+ // 64 base64 chars decode to 48 bytes, enough for every header above except
142
+ // deeply-nested JPEG frames, for which we read a larger prefix.
143
+ const prefix = data.slice(0, 4_096);
144
+ const buf = Buffer.from(prefix, "base64");
145
+ return imageTokensForSize(parseImageDimensions(buf));
146
+ }
147
+ catch {
148
+ return UNKNOWN_IMAGE_TOKENS;
149
+ }
150
+ }
151
+ /**
152
+ * Approximate characters or tokens per unit, by writing system. Calibrated
153
+ * against o200k_base on prose samples; mixed-script text lands within roughly
154
+ * 10-25% and leans high.
155
+ */
156
+ const CJK_TOKENS_PER_CHAR = 0.7;
157
+ const KANA_TOKENS_PER_CHAR = 0.6;
158
+ const HANGUL_TOKENS_PER_CHAR = 0.8;
159
+ const CHARS_PER_LATIN_TOKEN = 4;
160
+ const CHARS_PER_DIGIT_TOKEN = 3;
161
+ function classify(code) {
162
+ // Whitespace is folded into the latin budget: the "~4 characters per token"
163
+ // rule of thumb already counts the spaces between words.
164
+ if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) {
165
+ return "latin";
166
+ }
167
+ if (code >= 0x30 && code <= 0x39)
168
+ return "digit";
169
+ if ((code >= 0x41 && code <= 0x5a) ||
170
+ (code >= 0x61 && code <= 0x7a) ||
171
+ // Latin-1 letters, Latin Extended, Greek, Cyrillic.
172
+ (code >= 0xc0 && code <= 0x24f) ||
173
+ (code >= 0x370 && code <= 0x3ff) ||
174
+ (code >= 0x400 && code <= 0x4ff)) {
175
+ return "latin";
176
+ }
177
+ if ((code >= 0x3400 && code <= 0x4dbf) ||
178
+ (code >= 0x4e00 && code <= 0x9fff) ||
179
+ (code >= 0xf900 && code <= 0xfaff) ||
180
+ (code >= 0x20000 && code <= 0x2ffff) ||
181
+ // CJK symbols/punctuation and fullwidth forms tokenize with the script.
182
+ (code >= 0x3000 && code <= 0x303f) ||
183
+ (code >= 0xff00 && code <= 0xffef)) {
184
+ return "cjk";
185
+ }
186
+ if (code >= 0x3040 && code <= 0x30ff)
187
+ return "kana";
188
+ if ((code >= 0xac00 && code <= 0xd7af) ||
189
+ (code >= 0x1100 && code <= 0x11ff)) {
190
+ return "hangul";
191
+ }
192
+ return "other";
193
+ }
194
+ /**
195
+ * Estimate tokens for a string.
196
+ *
197
+ * Characters are tallied per writing system across the whole string and
198
+ * converted once at the end. Converting per word instead would round up on
199
+ * every word and inflate ordinary prose by a third or more.
200
+ *
201
+ * Digit runs are the exception: BPE never merges digits across a separator, so
202
+ * each run is costed individually plus a token for the run boundary.
203
+ *
204
+ * Known weak spot: punctuation-dense input such as source code can come in
205
+ * under the real count, because symbols are charged at the same rate as prose.
206
+ * Install `gpt-tokenizer` for exact numbers when that matters.
207
+ */
208
+ export function estimateTextTokens(text) {
209
+ if (!text)
210
+ return 0;
211
+ let cjk = 0;
212
+ let kana = 0;
213
+ let hangul = 0;
214
+ let latin = 0;
215
+ let other = 0;
216
+ let digitTokens = 0;
217
+ let digitRun = 0;
218
+ const flushDigits = () => {
219
+ if (digitRun === 0)
220
+ return;
221
+ // ceil(run / 3) for the digit groups, plus the boundary token.
222
+ digitTokens += Math.ceil(digitRun / CHARS_PER_DIGIT_TOKEN) + 1;
223
+ digitRun = 0;
224
+ };
225
+ for (const char of text) {
226
+ const code = char.codePointAt(0) ?? 0;
227
+ const script = classify(code);
228
+ if (script !== "digit")
229
+ flushDigits();
230
+ switch (script) {
231
+ case "cjk":
232
+ cjk += 1;
233
+ break;
234
+ case "kana":
235
+ kana += 1;
236
+ break;
237
+ case "hangul":
238
+ hangul += 1;
239
+ break;
240
+ case "latin":
241
+ latin += 1;
242
+ break;
243
+ case "digit":
244
+ digitRun += 1;
245
+ break;
246
+ case "other":
247
+ // Astral symbols (emoji and friends) cost more than one token.
248
+ other += code > 0xffff ? 2 : 1;
249
+ break;
250
+ }
251
+ }
252
+ flushDigits();
253
+ return (Math.ceil(cjk * CJK_TOKENS_PER_CHAR) +
254
+ Math.ceil(kana * KANA_TOKENS_PER_CHAR) +
255
+ Math.ceil(hangul * HANGUL_TOKENS_PER_CHAR) +
256
+ Math.ceil((latin + other) / CHARS_PER_LATIN_TOKEN) +
257
+ digitTokens);
258
+ }
259
+ let tokenizerPromise = null;
260
+ function extractCounter(module, name) {
261
+ const direct = module.countTokens;
262
+ if (typeof direct === "function") {
263
+ return {
264
+ name,
265
+ count: (text) => Number(direct(text)) || 0,
266
+ };
267
+ }
268
+ const encode = module.encode;
269
+ if (typeof encode === "function") {
270
+ return {
271
+ name,
272
+ count: (text) => {
273
+ const result = encode(text);
274
+ return Array.isArray(result) ? result.length : 0;
275
+ },
276
+ };
277
+ }
278
+ return null;
279
+ }
280
+ /**
281
+ * Load `gpt-tokenizer` if the host project installed it. It is intentionally
282
+ * not a dependency: most users do not need exact counts, and the encoding
283
+ * tables are large. Set LLM_SWITCH_DISABLE_TOKENIZER=1 to force the heuristic.
284
+ */
285
+ export function loadTextCounter() {
286
+ if (process.env.LLM_SWITCH_DISABLE_TOKENIZER === "1") {
287
+ return Promise.resolve(null);
288
+ }
289
+ if (tokenizerPromise)
290
+ return tokenizerPromise;
291
+ tokenizerPromise = (async () => {
292
+ const candidates = [
293
+ "gpt-tokenizer/encoding/o200k_base",
294
+ "gpt-tokenizer",
295
+ ];
296
+ for (const specifier of candidates) {
297
+ try {
298
+ const module = (await import(specifier));
299
+ const counter = extractCounter(module, specifier);
300
+ if (counter) {
301
+ // Confirm it actually runs before trusting it on live traffic.
302
+ counter.count("probe");
303
+ return counter;
304
+ }
305
+ }
306
+ catch {
307
+ // Not installed or failed to initialise; try the next candidate.
308
+ }
309
+ }
310
+ return null;
311
+ })();
312
+ return tokenizerPromise;
313
+ }
314
+ /** Test seam: forget the cached tokenizer lookup. */
315
+ export function resetTextCounterCache() {
316
+ tokenizerPromise = null;
317
+ }
318
+ function collectContent(content, acc) {
319
+ if (typeof content === "string") {
320
+ acc.text.push(content);
321
+ return;
322
+ }
323
+ if (Array.isArray(content)) {
324
+ for (const item of content)
325
+ collectContent(item, acc);
326
+ return;
327
+ }
328
+ const block = asRecord(content);
329
+ if (!block)
330
+ return;
331
+ const type = String(block.type || "");
332
+ if (type === "image") {
333
+ const source = asRecord(block.source);
334
+ if (source && typeof source.data === "string") {
335
+ acc.imageTokens += imageTokensFromBase64(source.data);
336
+ }
337
+ else {
338
+ // URL sources cannot be measured without fetching them.
339
+ acc.imageTokens += UNKNOWN_IMAGE_TOKENS;
340
+ }
341
+ return;
342
+ }
343
+ if (type === "image_url") {
344
+ const nested = asRecord(block.image_url);
345
+ const url = typeof nested?.url === "string"
346
+ ? nested.url
347
+ : typeof block.image_url === "string"
348
+ ? block.image_url
349
+ : "";
350
+ const base64 = url.match(/^data:[^;,]+;base64,([\s\S]*)$/)?.[1];
351
+ acc.imageTokens += base64
352
+ ? imageTokensFromBase64(base64)
353
+ : UNKNOWN_IMAGE_TOKENS;
354
+ return;
355
+ }
356
+ if (typeof block.text === "string")
357
+ acc.text.push(block.text);
358
+ if (typeof block.thinking === "string")
359
+ acc.text.push(block.thinking);
360
+ if (block.content !== undefined)
361
+ collectContent(block.content, acc);
362
+ if (block.input !== undefined && typeof block.input === "object") {
363
+ try {
364
+ acc.text.push(JSON.stringify(block.input));
365
+ }
366
+ catch {
367
+ // Unserializable tool input; skip it.
368
+ }
369
+ }
370
+ else if (typeof block.input === "string") {
371
+ acc.text.push(block.input);
372
+ }
373
+ }
374
+ /**
375
+ * Count input tokens for an Anthropic Messages request body.
376
+ * Shape-compatible with `POST /v1/messages/count_tokens`.
377
+ */
378
+ export async function countAnthropicInputTokens(body) {
379
+ const counter = await loadTextCounter();
380
+ const countText = counter
381
+ ? (text) => counter.count(text)
382
+ : estimateTextTokens;
383
+ const acc = { text: [], imageTokens: 0 };
384
+ collectContent(body.system, acc);
385
+ const messages = Array.isArray(body.messages) ? body.messages : [];
386
+ for (const raw of messages) {
387
+ const message = asRecord(raw);
388
+ if (!message)
389
+ continue;
390
+ collectContent(message.content, acc);
391
+ }
392
+ const toolTexts = [];
393
+ const tools = Array.isArray(body.tools) ? body.tools : [];
394
+ for (const raw of tools) {
395
+ const tool = asRecord(raw);
396
+ if (!tool)
397
+ continue;
398
+ try {
399
+ toolTexts.push(JSON.stringify(tool));
400
+ }
401
+ catch {
402
+ if (typeof tool.name === "string")
403
+ toolTexts.push(tool.name);
404
+ }
405
+ }
406
+ const text = acc.text.reduce((sum, chunk) => sum + countText(chunk), 0);
407
+ const toolTokens = toolTexts.reduce((sum, chunk) => sum + countText(chunk) + TOOL_OVERHEAD_TOKENS, 0);
408
+ const overhead = REQUEST_OVERHEAD_TOKENS +
409
+ messages.length * MESSAGE_OVERHEAD_TOKENS +
410
+ (body.system ? MESSAGE_OVERHEAD_TOKENS : 0);
411
+ const breakdown = {
412
+ text,
413
+ images: acc.imageTokens,
414
+ tools: toolTokens,
415
+ overhead,
416
+ };
417
+ return {
418
+ inputTokens: Math.max(1, breakdown.text + breakdown.images + breakdown.tools + breakdown.overhead),
419
+ method: counter ? "tokenizer" : "heuristic",
420
+ ...(counter ? { tokenizer: counter.name } : {}),
421
+ breakdown,
422
+ };
423
+ }
@@ -0,0 +1,30 @@
1
+ export const DEFAULT_GATEWAY_PORT = 17900;
2
+ export const DEFAULT_GATEWAY_HOST = "127.0.0.1";
3
+ /** Wire formats the gateway can speak, both inbound and upstream. */
4
+ export const GATEWAY_FORMATS = [
5
+ "openai-chat",
6
+ "openai-responses",
7
+ "anthropic",
8
+ ];
9
+ export function isGatewayFormat(value) {
10
+ return GATEWAY_FORMATS.includes(value);
11
+ }
12
+ export const DEFAULT_GATEWAY_FALLBACK = Object.freeze({
13
+ enabled: true,
14
+ maxAttempts: 3,
15
+ retryStatuses: [408, 409, 429, 500, 502, 503, 504, 529],
16
+ });
17
+ export function defaultGatewayConfig() {
18
+ return {
19
+ version: 1,
20
+ defaultProvider: null,
21
+ fallback: { ...DEFAULT_GATEWAY_FALLBACK, retryStatuses: [...DEFAULT_GATEWAY_FALLBACK.retryStatuses] },
22
+ corsOrigins: [],
23
+ rateLimitPerMinute: 0,
24
+ updatedAt: new Date(0).toISOString(),
25
+ };
26
+ }
27
+ /** The API format a provider speaks, expressed as a gateway wire format. */
28
+ export function providerFormat(provider) {
29
+ return provider.apiFormat;
30
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Per-day usage accounting for the gateway data plane.
3
+ *
4
+ * One JSON file with daily buckets; each row aggregates requests and token
5
+ * counts for a (key, provider, model) triple. Writes are read-modify-write via
6
+ * the same atomic-replace pattern as the rest of the store — the daemon is the
7
+ * single writer in practice, so no lock is taken and a crashed process can lose
8
+ * at most the in-flight update.
9
+ */
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { atomicWriteFile, ensureDir } from "../utils/fs.js";
12
+ import { getGatewayDir, getGatewayUsagePath } from "../utils/paths.js";
13
+ const RETENTION_DAYS = 90;
14
+ function emptyFile() {
15
+ return { version: 1, days: {} };
16
+ }
17
+ function asRecord(value) {
18
+ if (value && typeof value === "object" && !Array.isArray(value)) {
19
+ return value;
20
+ }
21
+ return null;
22
+ }
23
+ function nonNegativeNumber(value) {
24
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
25
+ ? value
26
+ : 0;
27
+ }
28
+ export function getUsagePath() {
29
+ return getGatewayUsagePath();
30
+ }
31
+ function dayKey(now = Date.now()) {
32
+ return new Date(now).toISOString().slice(0, 10);
33
+ }
34
+ function readUsage() {
35
+ const path = getUsagePath();
36
+ if (!existsSync(path))
37
+ return emptyFile();
38
+ try {
39
+ const raw = asRecord(JSON.parse(readFileSync(path, "utf8")));
40
+ if (!raw)
41
+ return emptyFile();
42
+ const rawDays = asRecord(raw.days);
43
+ const out = { version: 1, days: {} };
44
+ if (!rawDays)
45
+ return out;
46
+ for (const [day, value] of Object.entries(rawDays)) {
47
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day))
48
+ continue;
49
+ const bucket = asRecord(value);
50
+ const rows = Array.isArray(bucket?.rows) ? bucket.rows : [];
51
+ const clean = [];
52
+ for (const item of rows) {
53
+ const row = asRecord(item);
54
+ if (!row)
55
+ continue;
56
+ const key = String(row.key || "");
57
+ const provider = String(row.provider || "");
58
+ if (!key || !provider)
59
+ continue;
60
+ clean.push({
61
+ key,
62
+ provider,
63
+ model: String(row.model || ""),
64
+ requests: nonNegativeNumber(row.requests),
65
+ inputTokens: nonNegativeNumber(row.inputTokens),
66
+ outputTokens: nonNegativeNumber(row.outputTokens),
67
+ });
68
+ }
69
+ out.days[day] = { rows: clean };
70
+ }
71
+ return out;
72
+ }
73
+ catch {
74
+ return emptyFile();
75
+ }
76
+ }
77
+ function writeUsage(file, now = Date.now()) {
78
+ const cutoff = new Date(now - RETENTION_DAYS * 86_400_000)
79
+ .toISOString()
80
+ .slice(0, 10);
81
+ const days = {};
82
+ for (const [day, bucket] of Object.entries(file.days)) {
83
+ if (day >= cutoff && bucket.rows.length)
84
+ days[day] = bucket;
85
+ }
86
+ ensureDir(getGatewayDir());
87
+ atomicWriteFile(getUsagePath(), JSON.stringify({ version: 1, days }, null, 2) + "\n");
88
+ }
89
+ function mergeRow(rows, record) {
90
+ const key = record.keyId;
91
+ const provider = record.provider;
92
+ const model = record.model || "";
93
+ const existing = rows.find((row) => row.key === key && row.provider === provider && row.model === model);
94
+ if (existing) {
95
+ existing.requests += 1;
96
+ existing.inputTokens += record.inputTokens ?? 0;
97
+ existing.outputTokens += record.outputTokens ?? 0;
98
+ return;
99
+ }
100
+ rows.push({
101
+ key,
102
+ provider,
103
+ model,
104
+ requests: 1,
105
+ inputTokens: record.inputTokens ?? 0,
106
+ outputTokens: record.outputTokens ?? 0,
107
+ });
108
+ }
109
+ /** Best-effort: accounting failures must never break a live request. */
110
+ export function recordUsage(record, now = Date.now()) {
111
+ try {
112
+ const file = readUsage();
113
+ const day = dayKey(now);
114
+ const bucket = file.days[day] ?? { rows: [] };
115
+ mergeRow(bucket.rows, record);
116
+ file.days[day] = bucket;
117
+ writeUsage(file, now);
118
+ }
119
+ catch {
120
+ // Non-fatal.
121
+ }
122
+ }
123
+ /** Aggregated rows for the last `days` days (inclusive of today). */
124
+ export function summarizeUsage(options = {}, now = Date.now()) {
125
+ const days = Math.max(1, Math.min(options.days ?? 7, RETENTION_DAYS));
126
+ const cutoff = new Date(now - (days - 1) * 86_400_000)
127
+ .toISOString()
128
+ .slice(0, 10);
129
+ const file = readUsage();
130
+ const out = [];
131
+ for (const [day, bucket] of Object.entries(file.days)) {
132
+ if (day < cutoff)
133
+ continue;
134
+ for (const row of bucket.rows) {
135
+ out.push({ day, ...row });
136
+ }
137
+ }
138
+ return out.sort((a, b) => b.day.localeCompare(a.day) ||
139
+ b.requests - a.requests ||
140
+ a.provider.localeCompare(b.provider) ||
141
+ a.model.localeCompare(b.model) ||
142
+ a.key.localeCompare(b.key));
143
+ }
144
+ /** Drop every recorded usage (test seam and `llms gateway usage reset`). */
145
+ export function resetUsage() {
146
+ try {
147
+ atomicWriteFile(getUsagePath(), `${JSON.stringify(emptyFile(), null, 2)}\n`);
148
+ }
149
+ catch {
150
+ // Nothing persisted yet.
151
+ }
152
+ }
@@ -26,6 +26,30 @@ export function getStatePath(tool) {
26
26
  export function getBackupsDir(tool) {
27
27
  return join(getToolStoreDir(tool), "backups");
28
28
  }
29
+ export function getGatewayDir() {
30
+ return join(getAppConfigRoot(), "gateway");
31
+ }
32
+ export function getGatewayProvidersDir() {
33
+ return join(getGatewayDir(), "providers");
34
+ }
35
+ export function getGatewayProviderPath(name) {
36
+ return join(getGatewayProvidersDir(), `${name}.json`);
37
+ }
38
+ export function getGatewayRoutesPath() {
39
+ return join(getGatewayDir(), "routes.json");
40
+ }
41
+ export function getGatewayConfigPath() {
42
+ return join(getGatewayDir(), "config.json");
43
+ }
44
+ export function getGatewayKeysPath() {
45
+ return join(getGatewayDir(), "keys.json");
46
+ }
47
+ export function getGatewayUsagePath() {
48
+ return join(getGatewayDir(), "usage.json");
49
+ }
50
+ export function getGatewayStatePath() {
51
+ return join(getGatewayDir(), "state.json");
52
+ }
29
53
  export function getClaudeConfigDir() {
30
54
  return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
31
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nvae/llmswitch",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "CLI to switch LLM providers and models for Claude Code, Codex, and OpenCode",
5
5
  "type": "module",
6
6
  "bin": {