pi-tokenrouter 1.0.4 → 1.0.6

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A [pi](https://github.com/badlogic/pi-mono) provider extension for [TokenRouter](https://tokenrouter.com).
4
4
 
5
- Dynamically discovers available models from the TokenRouter API and enriches them with pricing, context window, and max output token data from [OpenRouter](https://openrouter.ai) (TokenRouter shares the same pricing).
5
+ Models are derived from TokenRouter's `/v1/models` list and enriched with metadata from [models.dev](https://models.dev) first, then [OpenRouter](https://openrouter.ai).
6
6
 
7
7
  ## Install
8
8
 
@@ -33,12 +33,9 @@ export TOKENROUTER_API_KEY=sk-...
33
33
 
34
34
  ## How it works
35
35
 
36
- 1. On startup, fetches the model list from TokenRouter's `/v1/models` endpoint.
37
- 2. In parallel, fetches pricing data from OpenRouter's public model catalog.
38
- 3. Matches models by ID and fills in cost, context window, and max output tokens.
39
- 4. Caches everything locally for 1 week (`~/.pi/agent/cache/tokenrouter-models.json`).
40
-
41
- Models that don't match an OpenRouter entry fall back to zero cost and default context limits.
36
+ 1. Registers TokenRouter as an API-key provider, so `/login tokenrouter` is handled under `Use an API key`.
37
+ 2. Uses a checked-in snapshot generated from TokenRouter's authenticated `/v1/models` response.
38
+ 3. Enriches each model with metadata from `models.dev` when available, then falls back to OpenRouter for pricing, context window, max output tokens, reasoning support, and image support.
42
39
 
43
40
  ## License
44
41
 
package/index.ts CHANGED
@@ -2,304 +2,23 @@
2
2
  * TokenRouter Provider Extension
3
3
  *
4
4
  * Registers TokenRouter (https://api.tokenrouter.com/v1) as a custom provider.
5
- * Dynamically fetches available models from the /v1/models endpoint with
6
- * file-based caching to avoid redundant API calls on startup.
7
- *
8
- * Authentication (resolved via AuthStorage with full priority chain):
9
- * 1. Runtime overrides (CLI --api-key)
10
- * 2. API key from auth.json (literal, env var name, or shell command)
11
- * 3. OAuth token from auth.json (from /login tokenrouter)
12
- * 4. TOKENROUTER_API_KEY environment variable
5
+ * Models are derived from TokenRouter's /v1/models list, enriched with
6
+ * metadata from models.dev first and OpenRouter second, so TokenRouter can be
7
+ * configured through pi's API-key login flow before any TokenRouter auth exists.
13
8
  *
14
9
  * Usage:
15
10
  * pi -e /path/to/pi-tokenrouter
16
- * /login tokenrouter # stores key in auth.json
11
+ * /login tokenrouter
17
12
  * # OR add to ~/.pi/agent/auth.json:
18
13
  * # "tokenrouter": { "type": "api_key", "key": "sk-..." }
19
14
  * # "tokenrouter": { "type": "api_key", "key": "TOKENROUTER_API_KEY" }
20
15
  * # "tokenrouter": { "type": "api_key", "key": "!op read 'op://vault/item/key'" }
21
16
  */
22
17
 
23
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
- import { join } from "node:path";
25
- import { homedir } from "node:os";
26
- import type { OAuthCredentials, OAuthLoginCallbacks } from "@mariozechner/pi-ai";
27
- import { AuthStorage } from "@mariozechner/pi-coding-agent";
28
18
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
29
-
30
- const BASE_URL = "https://api.tokenrouter.com/v1";
31
- const PROVIDER_NAME = "tokenrouter";
32
- const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
33
-
34
- const HOME_PI = join(homedir(), ".pi", "agent");
35
- const CACHE_DIR = join(HOME_PI, "cache");
36
- const CACHE_FILE = join(CACHE_DIR, "tokenrouter-models.json");
37
- const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
38
-
39
- const DEFAULT_CONTEXT_WINDOW = 128_000;
40
- const DEFAULT_MAX_TOKENS = 4_096;
41
-
42
- // Reasoning model name patterns
43
- const REASONING_PATTERNS = [
44
- /o1\b/i,
45
- /o3\b/i,
46
- /o4\b/i,
47
- /claude.*thinking/i,
48
- /deepseek-r/i,
49
- /deepseek-prover/i,
50
- /gemini.*thinking/i,
51
- /qwq/i,
52
- /qwen3/i,
53
- /reasoning/i,
54
- ];
55
-
56
- // Vision model name patterns
57
- const VISION_PATTERNS = [
58
- /vision/i,
59
- /claude/i,
60
- /gpt-4o/i,
61
- /gpt-4-turbo/i,
62
- /gemini/i,
63
- /llava/i,
64
- /qwen.*vl/i,
65
- /qwen.*visual/i,
66
- ];
67
-
68
- function isReasoningModel(id: string): boolean {
69
- return REASONING_PATTERNS.some((p) => p.test(id));
70
- }
71
-
72
- function supportsVision(id: string): boolean {
73
- return VISION_PATTERNS.some((p) => p.test(id));
74
- }
75
-
76
- // ---------------------------------------------------------------------------
77
- // OpenRouter pricing lookup
78
- // ---------------------------------------------------------------------------
79
-
80
- interface OpenRouterModel {
81
- id: string;
82
- context_length?: number;
83
- top_provider?: { max_completion_tokens?: number };
84
- pricing?: {
85
- prompt?: string;
86
- completion?: string;
87
- input_cache_read?: string;
88
- input_cache_write?: string;
89
- };
90
- }
91
-
92
- interface Pricing {
93
- input: number;
94
- output: number;
95
- cacheRead: number;
96
- cacheWrite: number;
97
- contextWindow: number;
98
- maxTokens: number;
99
- }
100
-
101
- function parsePerToken(price?: string): number {
102
- const n = parseFloat(price ?? "0");
103
- return Number.isFinite(n) ? n * 1_000_000 : 0;
104
- }
105
-
106
- async function fetchOpenRouterPricing(): Promise<Map<string, Pricing>> {
107
- const response = await fetch(OPENROUTER_MODELS_URL);
108
- if (!response.ok) return new Map();
109
-
110
- const payload = (await response.json()) as { data?: OpenRouterModel[] };
111
- const models = payload.data ?? [];
112
-
113
- const map = new Map<string, Pricing>();
114
- for (const m of models) {
115
- map.set(m.id, {
116
- input: parsePerToken(m.pricing?.prompt),
117
- output: parsePerToken(m.pricing?.completion),
118
- cacheRead: parsePerToken(m.pricing?.input_cache_read),
119
- cacheWrite: parsePerToken(m.pricing?.input_cache_write),
120
- contextWindow: m.context_length ?? DEFAULT_CONTEXT_WINDOW,
121
- maxTokens: m.top_provider?.max_completion_tokens ?? DEFAULT_MAX_TOKENS,
122
- });
123
- }
124
- return map;
125
- }
126
-
127
- // ---------------------------------------------------------------------------
128
- // Model cache
129
- // ---------------------------------------------------------------------------
130
-
131
- interface CachedModels {
132
- fetchedAt: number;
133
- models: RawModel[];
134
- pricing: Record<string, Pricing>;
135
- }
136
-
137
- interface RawModel {
138
- id: string;
139
- name?: string;
140
- context_window?: number;
141
- max_tokens?: number;
142
- [key: string]: unknown;
143
- }
144
-
145
- function readCache(): CachedModels | null {
146
- try {
147
- if (!existsSync(CACHE_FILE)) return null;
148
- const raw = readFileSync(CACHE_FILE, "utf-8");
149
- const data = JSON.parse(raw) as CachedModels;
150
- // Invalidate cache from before pricing was added
151
- if (!data.pricing) return null;
152
- return data;
153
- } catch {
154
- return null;
155
- }
156
- }
157
-
158
- function writeCache(models: RawModel[], pricing: Map<string, Pricing>): void {
159
- try {
160
- if (!existsSync(CACHE_DIR)) {
161
- mkdirSync(CACHE_DIR, { recursive: true });
162
- }
163
- // Serialise pricing map to plain object
164
- const pricingObj: Record<string, Pricing> = {};
165
- for (const [k, v] of pricing) pricingObj[k] = v;
166
- const payload: CachedModels = { fetchedAt: Date.now(), models, pricing: pricingObj };
167
- writeFileSync(CACHE_FILE, JSON.stringify(payload), "utf-8");
168
- } catch {
169
- // Cache write failure is non-fatal
170
- }
171
- }
172
-
173
- function isCacheFresh(cache: CachedModels): boolean {
174
- return Date.now() - cache.fetchedAt < CACHE_TTL_MS;
175
- }
176
-
177
- // ---------------------------------------------------------------------------
178
- // Model fetching
179
- // ---------------------------------------------------------------------------
180
-
181
- async function fetchModels(apiKey: string): Promise<RawModel[]> {
182
- const response = await fetch(`${BASE_URL}/models`, {
183
- headers: {
184
- Authorization: `Bearer ${apiKey}`,
185
- Accept: "application/json",
186
- },
187
- });
188
-
189
- if (!response.ok) {
190
- throw new Error(`TokenRouter /v1/models returned ${response.status}: ${await response.text()}`);
191
- }
192
-
193
- const payload = (await response.json()) as {
194
- data: Array<Record<string, unknown>>;
195
- };
196
-
197
- return payload.data.map((m) => ({
198
- id: m.id as string,
199
- name: (m.name as string | undefined) ?? (m.id as string),
200
- context_window: (m.context_window ?? m.contextWindow ?? undefined) as number | undefined,
201
- max_tokens: (m.max_tokens ?? m.maxTokens ?? undefined) as number | undefined,
202
- }));
203
- }
204
-
205
- async function getModelsAndPricing(apiKey: string): Promise<{ models: RawModel[]; pricing: Map<string, Pricing> }> {
206
- const cache = readCache();
207
- if (cache && isCacheFresh(cache)) {
208
- const pricingMap = new Map<string, Pricing>(Object.entries(cache.pricing));
209
- return { models: cache.models, pricing: pricingMap };
210
- }
211
-
212
- try {
213
- const [models, pricing] = await Promise.all([
214
- fetchModels(apiKey),
215
- fetchOpenRouterPricing(),
216
- ]);
217
- writeCache(models, pricing);
218
- return { models, pricing };
219
- } catch {
220
- if (cache) {
221
- const pricingMap = new Map<string, Pricing>(Object.entries(cache.pricing));
222
- return { models: cache.models, pricing: pricingMap };
223
- }
224
- throw new Error(
225
- "Failed to fetch models from TokenRouter and no cached data available. " +
226
- "Configure authentication via /login tokenrouter, auth.json, or TOKENROUTER_API_KEY.",
227
- );
228
- }
229
- }
230
-
231
- // ---------------------------------------------------------------------------
232
- // OAuth (API key prompt flow for /login)
233
- // ---------------------------------------------------------------------------
234
-
235
- async function loginTokenRouter(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
236
- const key = await callbacks.onPrompt({ message: "Enter your TokenRouter API key:" });
237
- if (!key || !key.trim()) {
238
- throw new Error("No API key provided");
239
- }
240
- // API keys don't expire — set far-future expiry so pi won't try to refresh
241
- const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
242
- return {
243
- refresh: key.trim(),
244
- access: key.trim(),
245
- expires: farFuture,
246
- };
247
- }
248
-
249
- async function refreshTokenRouter(credentials: OAuthCredentials): Promise<OAuthCredentials> {
250
- // API keys don't expire — return as-is
251
- return credentials;
252
- }
253
-
254
- // ---------------------------------------------------------------------------
255
- // Extension entry point
256
- // ---------------------------------------------------------------------------
257
-
258
- const oauth = {
259
- name: "TokenRouter",
260
- login: loginTokenRouter,
261
- refreshToken: refreshTokenRouter,
262
- getApiKey: (cred: OAuthCredentials) => cred.access,
263
- };
19
+ import { TOKENROUTER_MODELS } from "./models.generated.js";
20
+ import { createTokenRouterProviderConfig, PROVIDER_NAME } from "./provider-config.js";
264
21
 
265
22
  export default async function(pi: ExtensionAPI) {
266
- // Resolve API key through AuthStorage with full priority chain:
267
- // runtime overrides → auth.json api_key → auth.json oauth → env vars
268
- const authStorage = AuthStorage.create();
269
- const apiKey = await authStorage.getApiKey(PROVIDER_NAME);
270
-
271
- if (!apiKey) {
272
- // Register provider with OAuth only (no models) so /login works.
273
- // After /login, user needs /reload to fetch and register models.
274
- pi.registerProvider(PROVIDER_NAME, {
275
- baseUrl: BASE_URL,
276
- api: "openai-completions",
277
- authHeader: true,
278
- oauth,
279
- });
280
- return;
281
- }
282
-
283
- const { models: rawModels, pricing } = await getModelsAndPricing(apiKey);
284
-
285
- pi.registerProvider(PROVIDER_NAME, {
286
- baseUrl: BASE_URL,
287
- api: "openai-completions",
288
- authHeader: true,
289
- models: rawModels.map((m) => {
290
- const p = pricing.get(m.id);
291
- return {
292
- id: m.id,
293
- name: m.name ?? m.id,
294
- reasoning: isReasoningModel(m.id),
295
- input: supportsVision(m.id) ? (["text", "image"] as const) : (["text"] as const),
296
- cost: p
297
- ? { input: p.input, output: p.output, cacheRead: p.cacheRead, cacheWrite: p.cacheWrite }
298
- : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
299
- contextWindow: m.context_window ?? p?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
300
- maxTokens: m.max_tokens ?? p?.maxTokens ?? DEFAULT_MAX_TOKENS,
301
- };
302
- }),
303
- oauth,
304
- });
23
+ pi.registerProvider(PROVIDER_NAME, createTokenRouterProviderConfig(TOKENROUTER_MODELS));
305
24
  }