pi-free 2.0.13 → 2.0.15

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.
@@ -108,7 +108,7 @@ export default async function qwenProvider(pi: ExtensionAPI) {
108
108
  function registerProvider(m = models) {
109
109
  pi.registerProvider(PROVIDER_QWEN, {
110
110
  baseUrl: DEFAULT_BASE_URL,
111
- apiKey: "QWEN_API_KEY",
111
+ apiKey: "$QWEN_API_KEY",
112
112
  api: "openai-completions" as const,
113
113
  headers: {
114
114
  "User-Agent": "pi-free",
@@ -125,7 +125,7 @@ export default async function qwenProvider(pi: ExtensionAPI) {
125
125
  const reRegister = createReRegister(pi, {
126
126
  providerId: PROVIDER_QWEN,
127
127
  baseUrl: DEFAULT_BASE_URL,
128
- apiKey: "QWEN_API_KEY",
128
+ apiKey: "$QWEN_API_KEY",
129
129
  oauth: oauthConfig as any,
130
130
  });
131
131
 
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Routeway AI Provider Extension
3
+ *
4
+ * Routeway exposes an OpenAI-compatible chat completions API with a model
5
+ * catalog that includes free models marked by a `:free` suffix and zero token
6
+ * pricing.
7
+ *
8
+ * API: https://api.routeway.ai/v1
9
+ * Models: /v1/models
10
+ * Docs: https://docs.routeway.ai
11
+ *
12
+ * Setup:
13
+ * ROUTEWAY_API_KEY=sk-...
14
+ * # or add routeway_api_key to ~/.pi/free.json
15
+ */
16
+
17
+ import type {
18
+ ExtensionAPI,
19
+ ProviderModelConfig,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import {
22
+ getRoutewayApiKey,
23
+ getRoutewayShowPaid,
24
+ loadConfigFile,
25
+ saveConfig,
26
+ } from "../../config.ts";
27
+ import {
28
+ BASE_URL_ROUTEWAY,
29
+ DEFAULT_FETCH_TIMEOUT_MS,
30
+ PROVIDER_ROUTEWAY,
31
+ } from "../../constants.ts";
32
+ import { applyHidden } from "../../config.ts";
33
+ import { createLogger } from "../../lib/logger.ts";
34
+ import {
35
+ getProxyModelCompat,
36
+ isLikelyReasoningModel,
37
+ } from "../../lib/provider-compat.ts";
38
+ import {
39
+ getModelsDueForProbe,
40
+ recordModelProbeResults,
41
+ } from "../../lib/probe-cache.ts";
42
+ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
43
+ import { cleanModelName, fetchWithRetry } from "../../lib/util.ts";
44
+ import { fetchWithTimeout } from "../../lib/util.ts";
45
+ import { createReRegister, setupProvider } from "../../provider-helper.ts";
46
+
47
+ const _logger = createLogger("routeway");
48
+
49
+ interface RoutewayPrice {
50
+ unit?: string;
51
+ price_per_million_t?: number;
52
+ price_per_token_usd?: string;
53
+ }
54
+
55
+ interface RoutewayModel {
56
+ id: string;
57
+ name?: string;
58
+ short_name?: string;
59
+ description?: string;
60
+ context_length?: number;
61
+ available?: boolean;
62
+ type?: string;
63
+ endpoints?: string[];
64
+ pricing?: {
65
+ input?: RoutewayPrice;
66
+ output?: RoutewayPrice;
67
+ caching?: { read?: RoutewayPrice; write?: RoutewayPrice };
68
+ };
69
+ supported_parameters?: string[];
70
+ capabilities?: {
71
+ vision?: boolean;
72
+ function_call?: boolean;
73
+ reasoning?: boolean;
74
+ };
75
+ }
76
+
77
+ function parsePricePerToken(price: RoutewayPrice | undefined): number {
78
+ if (!price) return 0;
79
+ if (typeof price.price_per_token_usd === "string") {
80
+ const parsed = Number.parseFloat(price.price_per_token_usd);
81
+ if (!Number.isNaN(parsed)) return parsed;
82
+ }
83
+ if (typeof price.price_per_million_t === "number") {
84
+ return price.price_per_million_t / 1_000_000;
85
+ }
86
+ return 0;
87
+ }
88
+
89
+ function isChatModel(model: RoutewayModel): boolean {
90
+ return (
91
+ model.available !== false &&
92
+ (model.type === "chat.completions" ||
93
+ (model.endpoints ?? []).includes("/v1/chat/completions"))
94
+ );
95
+ }
96
+
97
+ function mapRoutewayModel(
98
+ model: RoutewayModel,
99
+ ): ProviderModelConfig & { _pricingKnown?: boolean } {
100
+ const rawName = model.short_name || model.name || model.id;
101
+ const name = cleanModelName(rawName);
102
+ const inputCost = parsePricePerToken(model.pricing?.input);
103
+ const outputCost = parsePricePerToken(model.pricing?.output);
104
+ const cacheRead = parsePricePerToken(model.pricing?.caching?.read);
105
+ const cacheWrite = parsePricePerToken(model.pricing?.caching?.write);
106
+ const hasPricing = !!(model.pricing?.input || model.pricing?.output);
107
+ const reasoning =
108
+ model.capabilities?.reasoning === true ||
109
+ (model.supported_parameters ?? []).includes("reasoning_effort") ||
110
+ isLikelyReasoningModel({ id: model.id, name });
111
+ const free = inputCost === 0 && outputCost === 0;
112
+
113
+ return {
114
+ id: model.id,
115
+ name: `${name} (Routeway)${free ? "" : " 💰"}`,
116
+ reasoning,
117
+ input: model.capabilities?.vision ? ["text", "image"] : ["text"],
118
+ cost: {
119
+ input: inputCost,
120
+ output: outputCost,
121
+ cacheRead,
122
+ cacheWrite,
123
+ },
124
+ contextWindow: model.context_length ?? 128_000,
125
+ maxTokens: 16_384,
126
+ compat: getProxyModelCompat({ id: model.id, name }),
127
+ _pricingKnown: hasPricing,
128
+ } as ProviderModelConfig & { _pricingKnown?: boolean };
129
+ }
130
+
131
+ async function fetchRoutewayModels(
132
+ apiKey: string,
133
+ ): Promise<ProviderModelConfig[]> {
134
+ _logger.info("[routeway] Fetching models from Routeway API...");
135
+
136
+ try {
137
+ const response = await fetchWithRetry(
138
+ `${BASE_URL_ROUTEWAY}/models`,
139
+ {
140
+ headers: {
141
+ Authorization: `Bearer ${apiKey}`,
142
+ Accept: "application/json",
143
+ "Content-Type": "application/json",
144
+ },
145
+ },
146
+ 3,
147
+ 1000,
148
+ DEFAULT_FETCH_TIMEOUT_MS,
149
+ );
150
+
151
+ if (!response.ok) {
152
+ throw new Error(`Routeway API error: ${response.status}`);
153
+ }
154
+
155
+ const json = (await response.json()) as { data?: RoutewayModel[] };
156
+ const models = (json.data ?? []).filter(isChatModel);
157
+
158
+ _logger.info(`[routeway] Fetched ${models.length} chat models`);
159
+ return applyHidden(models.map(mapRoutewayModel), PROVIDER_ROUTEWAY);
160
+ } catch (error) {
161
+ _logger.error("[routeway] Failed to fetch models", {
162
+ error: error instanceof Error ? error.message : String(error),
163
+ });
164
+ return [];
165
+ }
166
+ }
167
+
168
+ // =============================================================================
169
+ // Probe
170
+ // =============================================================================
171
+
172
+ async function probeRoutewayModel(
173
+ apiKey: string,
174
+ modelId: string,
175
+ ): Promise<"ok" | "broken" | "unknown"> {
176
+ try {
177
+ const response = await fetchWithTimeout(
178
+ `${BASE_URL_ROUTEWAY}/chat/completions`,
179
+ {
180
+ method: "POST",
181
+ headers: {
182
+ Authorization: `Bearer ${apiKey}`,
183
+ "Content-Type": "application/json",
184
+ "User-Agent": "pi-free-providers",
185
+ },
186
+ body: JSON.stringify({
187
+ model: modelId,
188
+ messages: [{ role: "user", content: "hi" }],
189
+ max_tokens: 1,
190
+ }),
191
+ },
192
+ 10000, // 10 second timeout
193
+ );
194
+
195
+ // 5xx = upstream server error (model unavailable)
196
+ if (response.status >= 500) return "broken";
197
+ // 404 = model not found / not provisioned
198
+ if (response.status === 404) return "broken";
199
+ // 429 = rate limited (model works)
200
+ if (response.status === 429) return "ok";
201
+ // 401 = auth issue (model exists, key issue)
202
+ if (response.status === 401) return "ok";
203
+ // 400 = bad request (model exists, param issue)
204
+ if (response.status === 400) return "ok";
205
+ // 200 = success
206
+ if (response.ok) return "ok";
207
+ return "ok";
208
+ } catch {
209
+ return "unknown";
210
+ }
211
+ }
212
+
213
+ async function runRoutewayProbe(
214
+ apiKey: string,
215
+ modelsToTest: ProviderModelConfig[],
216
+ stored: { free: ProviderModelConfig[]; all: ProviderModelConfig[] },
217
+ reRegister: (models: ProviderModelConfig[]) => void,
218
+ options: { useCache?: boolean } = {},
219
+ ): Promise<string[]> {
220
+ const modelIdsToProbe = options.useCache
221
+ ? new Set(
222
+ getModelsDueForProbe(
223
+ PROVIDER_ROUTEWAY,
224
+ modelsToTest.map((m) => m.id),
225
+ ),
226
+ )
227
+ : undefined;
228
+ const probeCandidates = modelIdsToProbe
229
+ ? modelsToTest.filter((m) => modelIdsToProbe.has(m.id))
230
+ : modelsToTest;
231
+
232
+ if (probeCandidates.length === 0) {
233
+ _logger.info("Auto-probe: Routeway probe cache is fresh");
234
+ return [];
235
+ }
236
+
237
+ const broken: string[] = [];
238
+ const cacheableResults: Array<{ modelId: string; status: "ok" | "broken" }> =
239
+ [];
240
+ const batchSize = 5;
241
+
242
+ for (let i = 0; i < probeCandidates.length; i += batchSize) {
243
+ const batch = probeCandidates.slice(i, i + batchSize);
244
+ const results = await Promise.all(
245
+ batch.map(async (m) => {
246
+ const status = await probeRoutewayModel(apiKey, m.id);
247
+ return { id: m.id, status };
248
+ }),
249
+ );
250
+ for (const r of results) {
251
+ if (r.status === "broken") broken.push(r.id);
252
+ if (r.status !== "unknown") {
253
+ cacheableResults.push({ modelId: r.id, status: r.status });
254
+ }
255
+ }
256
+ }
257
+
258
+ recordModelProbeResults(PROVIDER_ROUTEWAY, cacheableResults);
259
+
260
+ if (broken.length === 0) {
261
+ _logger.info("Auto-probe: all checked Routeway models are routable");
262
+ return [];
263
+ }
264
+
265
+ // Auto-hide broken models in config (provider-scoped)
266
+ const cfg = loadConfigFile();
267
+ const existingHidden = new Set(cfg.hidden_models ?? []);
268
+ for (const id of broken) existingHidden.add(`${PROVIDER_ROUTEWAY}/${id}`);
269
+ saveConfig({ hidden_models: Array.from(existingHidden) });
270
+
271
+ // Re-register so hidden models disappear immediately
272
+ const filtered = await fetchRoutewayModels(apiKey);
273
+ stored.free = filtered;
274
+ stored.all = filtered;
275
+ reRegister(filtered);
276
+
277
+ _logger.info(
278
+ `Auto-probe: found ${broken.length} broken models (auto-hidden)`,
279
+ );
280
+ return broken;
281
+ }
282
+
283
+ // =============================================================================
284
+ // Extension Entry Point
285
+ // =============================================================================
286
+
287
+ export default async function routewayProvider(pi: ExtensionAPI) {
288
+ const apiKey = getRoutewayApiKey();
289
+
290
+ if (!apiKey) {
291
+ _logger.info(
292
+ "[routeway] Skipping — ROUTEWAY_API_KEY not set. Sign up at https://routeway.ai/",
293
+ );
294
+ return;
295
+ }
296
+
297
+ const allModels = await fetchRoutewayModels(apiKey);
298
+
299
+ if (allModels.length === 0) {
300
+ _logger.warn("[routeway] No chat models available");
301
+ return;
302
+ }
303
+
304
+ const freeModels = allModels.filter((m) =>
305
+ isFreeModel({ ...m, provider: PROVIDER_ROUTEWAY }, allModels),
306
+ );
307
+ const stored = { free: freeModels, all: allModels };
308
+
309
+ _logger.info(
310
+ `[routeway] Registered ${allModels.length} models (${freeModels.length} free)`,
311
+ );
312
+
313
+ const reRegister = createReRegister(pi, {
314
+ providerId: PROVIDER_ROUTEWAY,
315
+ baseUrl: BASE_URL_ROUTEWAY,
316
+ apiKey,
317
+ });
318
+
319
+ registerWithGlobalToggle(PROVIDER_ROUTEWAY, stored, reRegister, true);
320
+
321
+ setupProvider(
322
+ pi,
323
+ {
324
+ providerId: PROVIDER_ROUTEWAY,
325
+ initialShowPaid: getRoutewayShowPaid(),
326
+ tosUrl: "https://routeway.ai/terms",
327
+ reRegister: (models, _stored) => {
328
+ if (_stored) {
329
+ stored.free = _stored.free;
330
+ stored.all = _stored.all;
331
+ }
332
+ reRegister(models);
333
+ },
334
+ },
335
+ stored,
336
+ );
337
+
338
+ // ── Lazy auto-probe on first session_start ──────────────────────
339
+ let _autoProbeDone = false;
340
+ pi.on("session_start", async () => {
341
+ if (_autoProbeDone || !apiKey) return;
342
+ _autoProbeDone = true;
343
+ _logger.info("Starting lazy auto-probe of Routeway models...");
344
+ runRoutewayProbe(apiKey, allModels, stored, reRegister, {
345
+ useCache: true,
346
+ }).catch((err) => {
347
+ _logger.warn("Auto-probe failed", {
348
+ error: err instanceof Error ? err.message : String(err),
349
+ });
350
+ });
351
+ });
352
+
353
+ // ── Probe command: test all registered models for 5xx ─────────────
354
+ pi.registerCommand("probe-routeway", {
355
+ description:
356
+ "Test all Routeway models for server errors and auto-hide broken ones",
357
+ handler: async (_args, ctx) => {
358
+ if (!apiKey) {
359
+ ctx.ui.notify("ROUTEWAY_API_KEY not set", "error");
360
+ return;
361
+ }
362
+
363
+ const modelsToTest = allModels;
364
+ ctx.ui.notify(
365
+ `Probing ${modelsToTest.length} Routeway models…`,
366
+ "info",
367
+ );
368
+
369
+ await runRoutewayProbe(apiKey, modelsToTest, stored, reRegister);
370
+
371
+ // Check if any were hidden (re-read config)
372
+ const cfgAfter = loadConfigFile();
373
+ const newHidden = (cfgAfter.hidden_models ?? []).filter((h) =>
374
+ h.startsWith(`${PROVIDER_ROUTEWAY}/`),
375
+ );
376
+ if (newHidden.length > 0) {
377
+ ctx.ui.notify(
378
+ `Found ${newHidden.length} broken models (auto-hidden):\n${newHidden.join("\n")}`,
379
+ "warning",
380
+ );
381
+ } else {
382
+ ctx.ui.notify("All Routeway models are routable ✅", "info");
383
+ }
384
+ },
385
+ });
386
+
387
+ const showPaid = getRoutewayShowPaid();
388
+ const initialModels =
389
+ showPaid && stored.all.length > 0 ? stored.all : freeModels;
390
+ reRegister(initialModels);
391
+ }