@kshlm/pi-devpass-provider 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaushal M
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @kshlm/pi-devpass-provider
2
+
3
+ A [pi](https://github.com/earendil-works/pi-coding-agent) extension that registers a `devpass` model provider backed by [LLM Gateway](https://llmgateway.io) and the [DevPass](https://devpass.llmgateway.io) coding plan.
4
+
5
+ - Models and $/M rates are fetched live from the gateway at startup, with a 24 h on-disk cache and stale fallback when offline
6
+ - DevPass credit balance shows in the status line as `devpass balance $<balance>` while a `devpass/*` model is active
7
+ - `/devpass` prints the full key status, loaded-model count, and premium credit usage
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pi install npm:@kshlm/pi-devpass-provider
13
+ ```
14
+
15
+ Or from a local checkout:
16
+
17
+ ```sh
18
+ pi install /abs/path/to/pi-devpass-provider
19
+ ```
20
+
21
+ ## Setup
22
+
23
+ 1. Get a DevPass plan key (`llmgtwy_...`) from <https://devpass.llmgateway.io>.
24
+ 2. Make it available to pi, either by running `/login devpass` inside pi and pasting the key (stored in `~/.pi/agent/auth.json`), or by exporting it:
25
+
26
+ ```sh
27
+ export LLM_GATEWAY_API_KEY=llmgtwy_...
28
+ ```
29
+
30
+ A saved `/login` credential takes precedence over the environment variable.
31
+
32
+ ## Usage
33
+
34
+ - `/model`: pick a `devpass/*` model. DevPass plan keys use root model ids (`claude-sonnet-4-5`); provider-pinned ids (`anthropic/...`) are unavailable on coding plans.
35
+ - `/devpass`: show the credit balance and key status at any time.
36
+
37
+ ## Configuration
38
+
39
+ | Variable | Purpose |
40
+ | --- | --- |
41
+ | `LLM_GATEWAY_API_KEY` | DevPass plan key, used when no `/login devpass` credential is saved |
42
+ | `LLM_GATEWAY_BASE_URL` | Override the gateway base URL (default `https://api.llmgateway.io/v1`), e.g. for a self-hosted gateway or proxy |
43
+
44
+ ## Notes
45
+
46
+ - The model catalog is cached at `~/.pi/agent/cache/devpass-models-*.json` (24 h TTL, keyed per base URL). `/v1/models` is public, so models load even without a key; requests and the balance display still need one.
47
+ - Rates shown by pi are for display and cost tracking only; the gateway meters and bills your actual usage.
48
+ - A missing or invalid key never crashes pi: the provider registers with zero models and the status line carries the error.
49
+
50
+ ## Development
51
+
52
+ ```sh
53
+ npm install
54
+ npm run check # tsc --noEmit + selfcheck asserts
55
+ ```
56
+
57
+ Try it live with `pi -e .`, then `/login devpass` and `/model`.
58
+
59
+ ## License
60
+
61
+ [MIT](./LICENSE)
package/index.ts ADDED
@@ -0,0 +1,420 @@
1
+ /**
2
+ * @kshlm/pi-devpass-provider — LLM Gateway / DevPass model provider for pi.
3
+ *
4
+ * Registers a "devpass" provider (OpenAI-compatible, https://api.llmgateway.io/v1)
5
+ * whose coding models and $/M rates are fetched from GET /v1/models, filtered
6
+ * to the DevPass coding-plan set (24h on-disk cache, stale-fallback on
7
+ * network failure) and surfaces the DevPass credit balance
8
+ * (GET /v1/key) in the status line — only while a `devpass/*` model is active.
9
+ *
10
+ * Setup:
11
+ * pi -e /path/to/pi-devpass-provider
12
+ * /login devpass # paste your DevPass key (llmgtwy_...) — stored in ~/.pi/agent/auth.json
13
+ * (or) export LLM_GATEWAY_API_KEY=llmgtwy_... # DevPass plan key from https://devpass.llmgateway.io
14
+ * Then pick a model with /model (root ids like `claude-sonnet-4-5` — DevPass
15
+ * keys cannot use provider-pinned ids) and check /devpass for the balance.
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
20
+ import { readFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { dirname, join } from "node:path";
23
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
25
+
26
+ const PROVIDER_ID = "devpass";
27
+ const DEFAULT_BASE_URL = "https://api.llmgateway.io/v1";
28
+ const API_KEY_ENV = "LLM_GATEWAY_API_KEY";
29
+ const FETCH_TIMEOUT_MS = 15_000;
30
+ // Static gateway key: no expiry, so park `expires` far out and refresh is identity.
31
+ const TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
32
+
33
+ /** baseUrl precedence: LL_GATEWAY_BASE_URL env > models.json providers.devpass.baseUrl > default. */
34
+ export function resolveBaseUrl(env: string | undefined, modelsJson: string | undefined): string {
35
+ const fromEnv = env?.trim();
36
+ if (fromEnv) return fromEnv;
37
+ try {
38
+ const fromFile = (JSON.parse(modelsJson ?? "")?.providers?.[PROVIDER_ID]?.baseUrl ?? "").trim?.() ?? "";
39
+ if (fromFile) return fromFile;
40
+ } catch {
41
+ // corrupt/missing models.json — pi itself will surface that; fall through
42
+ }
43
+ return DEFAULT_BASE_URL;
44
+ }
45
+
46
+ const BASE_URL = resolveBaseUrl(
47
+ process.env.LLM_GATEWAY_BASE_URL,
48
+ readFileSync(join(homedir(), ".pi", "agent", "models.json"), "utf8"),
49
+ );
50
+ // per-base-URL cache file: self-hosted gateways must not be served the cloud catalog
51
+ const CACHE_FILE = join(
52
+ homedir(),
53
+ ".pi",
54
+ "agent",
55
+ "cache",
56
+ `devpass-models-${createHash("sha1").update(BASE_URL).digest("hex").slice(0, 12)}.json`,
57
+ );
58
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
59
+ const CACHE_V = 3;
60
+
61
+ // --- Gateway API shapes (subset of fields we consume) ---
62
+
63
+ interface GwPricing {
64
+ prompt?: string;
65
+ completion?: string;
66
+ input_cache_read?: string;
67
+ input_cache_write?: string;
68
+ }
69
+
70
+ interface GwProviderMapping {
71
+ reasoning?: boolean;
72
+ vision?: boolean;
73
+ tools?: boolean;
74
+ streaming?: boolean | "only";
75
+ stability?: string | null;
76
+ pricing?: GwPricing;
77
+ }
78
+
79
+ interface GwModel {
80
+ id: string;
81
+ name?: string;
82
+ display_name?: string;
83
+ free?: boolean | null;
84
+ stability?: string | null;
85
+ deprecated_at?: string | null;
86
+ deactivated_at?: string | null;
87
+ context_length?: number;
88
+ max_output?: number;
89
+ architecture?: { input_modalities?: string[]; output_modalities?: string[] };
90
+ providers?: GwProviderMapping[];
91
+ pricing?: GwPricing;
92
+ }
93
+
94
+ interface GwKeyStatus {
95
+ data?: {
96
+ label?: string;
97
+ usage?: string;
98
+ limit?: string;
99
+ devPlan?: string;
100
+ devPlanCreditsUsed?: string;
101
+ devPlanCreditsLimit?: string;
102
+ devPlanCreditsRemaining?: string;
103
+ devPlanPremiumWeeklyLimit?: string;
104
+ devPlanPremiumCreditsUsed?: string;
105
+ devPlanPremiumWeekResetsAt?: string;
106
+ };
107
+ }
108
+
109
+ // --- Pure helpers (assert-checked in selfcheck.ts) ---
110
+
111
+ const fmtUSD = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2));
112
+
113
+ /**
114
+ * Gateway pricing strings are USD per token in scientific notation
115
+ * ("5e-6" = $5 per million tokens). Guard: a value >= 0.01 is already $/M
116
+ * (real per-token prices are < 0.002).
117
+ */
118
+ export function toPerMillion(raw?: string | number): number {
119
+ const n = typeof raw === "number" ? raw : parseFloat(raw ?? "");
120
+ if (!Number.isFinite(n) || n <= 0) return 0;
121
+ const perMillion = n >= 0.01 ? n : n * 1e6;
122
+ return +perMillion.toFixed(6);
123
+ }
124
+
125
+ function isUnstable(stability?: string | null): boolean {
126
+ return stability === "unstable" || stability === "experimental";
127
+ }
128
+
129
+ /**
130
+ * Public /v1/models writes missing cachedInputPrice as "0". Official DevPass
131
+ * gate treats a set field (including "0") as cache support, so "0" here is
132
+ * indistinguishable from absent. Non-zero cache read/write is the public-API
133
+ * stand-in; a true "0" cache rate still looks like no cache.
134
+ */
135
+ export function hasCachedInput(raw?: string): boolean {
136
+ if (raw === undefined || raw === null || raw === "") return false;
137
+ const n = Number(raw);
138
+ return Number.isFinite(n) && n !== 0;
139
+ }
140
+
141
+ /** One provider mapping can serve coding-plan traffic. */
142
+ export function mappingSupportsCoding(p: GwProviderMapping): boolean {
143
+ if (isUnstable(p.stability)) return false;
144
+ return (
145
+ p.tools === true &&
146
+ p.streaming !== false &&
147
+ (hasCachedInput(p.pricing?.input_cache_read) || hasCachedInput(p.pricing?.input_cache_write))
148
+ );
149
+ }
150
+
151
+ /**
152
+ * DevPass coding model: paid, stable, and served by a mapping with tools,
153
+ * streaming, and cached input. Same gate as GET /v1/chat on a coding plan and
154
+ * the All tab on https://devpass.llmgateway.io/coding-models.
155
+ */
156
+ export function isCodingModel(m: GwModel): boolean {
157
+ if (m.id === "custom" || m.id === "auto") return false;
158
+ if (m.free || isUnstable(m.stability)) return false;
159
+ return (m.providers ?? []).some(mappingSupportsCoding);
160
+ }
161
+
162
+ /** Chat-capable and not deprecated/deactivated. Placeholder entries ("custom") are excluded. */
163
+ export function isChatModel(m: GwModel): boolean {
164
+ if (m.id === "custom") return false; // BYOK placeholder, not a real model
165
+ if (m.deprecated_at || m.deactivated_at) return false;
166
+ const outputs = m.architecture?.output_modalities ?? ["text"]; // missing metadata defaults to text
167
+ const inputs = m.architecture?.input_modalities ?? ["text"];
168
+ // text-only output: image/audio/video-output models aren't chat-completions usable
169
+ return outputs.length === 1 && outputs[0] === "text" && inputs.includes("text");
170
+ }
171
+
172
+ export function toPiModel(m: GwModel) {
173
+ const input = toPerMillion(m.pricing?.prompt);
174
+ const output = toPerMillion(m.pricing?.completion);
175
+ const name = m.display_name || m.name || m.id;
176
+ // LLM Gateway defines Premium from live catalog prices; /models exposes no category field.
177
+ const premium = input >= 5 || output >= 15;
178
+ return {
179
+ id: m.id,
180
+ name: premium ? `[Premium] ${name}` : name,
181
+ reasoning: m.providers?.some((p) => p.reasoning) ?? false,
182
+ input: (m.providers?.some((p) => p.vision) ? ["text", "image"] : ["text"]) as ("text" | "image")[],
183
+ cost: {
184
+ input,
185
+ output,
186
+ cacheRead: toPerMillion(m.pricing?.input_cache_read),
187
+ cacheWrite: toPerMillion(m.pricing?.input_cache_write),
188
+ },
189
+ contextWindow: m.context_length || 128_000,
190
+ maxTokens: m.max_output || 8_192,
191
+ };
192
+ }
193
+
194
+ /** One-line balance summary for the status line; "" when nothing reportable. */
195
+ export function formatBalance(d: GwKeyStatus["data"]): string {
196
+ if (!d) return "";
197
+ const num = (v?: string) => {
198
+ const n = Number(v);
199
+ return Number.isFinite(n) ? n : undefined;
200
+ };
201
+ const planLimit = num(d.devPlanCreditsLimit);
202
+ const planRemaining = num(d.devPlanCreditsRemaining);
203
+ if (planLimit && planLimit > 0 && planRemaining !== undefined) {
204
+ return `$${fmtUSD(planRemaining)}/$${fmtUSD(planLimit)} left`;
205
+ }
206
+ const used = num(d.usage);
207
+ const keyLimit = num(d.limit);
208
+ if (keyLimit && keyLimit > 0 && used !== undefined) {
209
+ return `$${fmtUSD(used)}/$${fmtUSD(keyLimit)} used`;
210
+ }
211
+ return "";
212
+ }
213
+
214
+ /** True when the active model belongs to this provider. */
215
+ export function isDevpassModel(model?: { provider?: string } | null): boolean {
216
+ return model?.provider === PROVIDER_ID;
217
+ }
218
+
219
+ /** Extract the key string from an auth.json entry (oauth `access` or api_key `key`). */
220
+ export function keyFromAuthEntry(e: unknown): string | undefined {
221
+ if (typeof e !== "object" || e === null) return;
222
+ const { access, key } = e as { access?: unknown; key?: unknown };
223
+ for (const v of [access, key]) {
224
+ if (typeof v === "string" && v.trim()) return v.trim();
225
+ }
226
+ }
227
+
228
+ // --- Gateway client ---
229
+
230
+ async function gwFetch<T>(path: string, apiKey?: string, signal?: AbortSignal): Promise<T> {
231
+ const res = await fetch(`${BASE_URL}${path}`, {
232
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, // /v1/models is public
233
+ signal: signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS),
234
+ });
235
+ if (!res.ok) {
236
+ throw new Error(`GET ${path} -> HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
237
+ }
238
+ return (res.json() as Promise<T>);
239
+ }
240
+
241
+ /** Live catalog: fetch, keep DevPass coding models, map to pi model configs. */
242
+ async function fetchCatalog(signal: AbortSignal, apiKey?: string) {
243
+ const payload = await gwFetch<{ data: GwModel[] }>("/models?exclude_deprecated=true", apiKey, signal);
244
+ return (payload.data ?? []).filter(isCodingModel).map(toPiModel);
245
+ }
246
+
247
+ // --- Catalog cache (~/.pi/agent/cache/devpass-models.json) ---
248
+
249
+ type PiModelConfig = ReturnType<typeof toPiModel>;
250
+
251
+ interface CacheEntry {
252
+ v: number;
253
+ fetchedAt: number;
254
+ models: PiModelConfig[];
255
+ }
256
+
257
+ export function isCacheFresh(entry: CacheEntry | undefined | null, now = Date.now()): boolean {
258
+ return !!entry && now - entry.fetchedAt < CACHE_TTL_MS;
259
+ }
260
+
261
+ export async function readCacheFrom(path: string): Promise<CacheEntry | undefined> {
262
+ try {
263
+ const entry = JSON.parse(await readFile(path, "utf8")) as CacheEntry;
264
+ return entry.v === CACHE_V && Array.isArray(entry.models) ? entry : undefined;
265
+ } catch {
266
+ return; // missing or corrupt — treat as no cache
267
+ }
268
+ }
269
+
270
+ export async function writeCacheTo(path: string, models: PiModelConfig[]): Promise<void> {
271
+ try {
272
+ await mkdir(dirname(path), { recursive: true });
273
+ await writeFile(path, JSON.stringify({ v: CACHE_V, fetchedAt: Date.now(), models }));
274
+ } catch {
275
+ // ponytail: cache write failure is non-fatal — next run refetches
276
+ }
277
+ }
278
+
279
+ // --- Extension ---
280
+
281
+ /** Saved `/login devpass` token from ~/.pi/agent/auth.json (re-read per call: works right after login). */
282
+ function readSavedKey(): string | undefined {
283
+ try {
284
+ return keyFromAuthEntry(JSON.parse(readFileSync(join(homedir(), ".pi", "agent", "auth.json"), "utf8"))?.[PROVIDER_ID]);
285
+ } catch {
286
+ return; // missing/corrupt auth.json — env var still applies
287
+ }
288
+ }
289
+
290
+ /** Key precedence mirrors pi: auth.json (/login) > env var. Used for balance + catalog fetches. */
291
+ const currentApiKey = () => readSavedKey() ?? process.env[API_KEY_ENV];
292
+
293
+ export default async function devpassProvider(pi: ExtensionAPI) {
294
+ let models: PiModelConfig[] = [];
295
+ // /v1/models is public — the key is only needed for streaming and balance.
296
+ // Fresh cache short-circuits the network; stale cache beats an empty list.
297
+ const cached = await readCacheFrom(CACHE_FILE);
298
+ if (cached && isCacheFresh(cached)) {
299
+ models = cached.models;
300
+ } else {
301
+ try {
302
+ models = await fetchCatalog(AbortSignal.timeout(FETCH_TIMEOUT_MS), currentApiKey());
303
+ await writeCacheTo(CACHE_FILE, models);
304
+ } catch {
305
+ if (cached) models = cached.models;
306
+ }
307
+ }
308
+
309
+ pi.registerProvider(PROVIDER_ID, {
310
+ name: "LLM Gateway (DevPass)",
311
+ baseUrl: BASE_URL,
312
+ apiKey: `$${API_KEY_ENV}`,
313
+ api: "openai-completions",
314
+ models,
315
+ // Registration/session start triggers a cache-only refresh (the factory
316
+ // above owns the 24h TTL). allowNetwork=true only arrives from explicit
317
+ // user refreshes (/model selector, ctx.modelRegistry.refresh) — those
318
+ // always refetch. `pi update --models` never reaches us: it builds an
319
+ // extension-free runtime from builtins + models.json.
320
+ async refreshModels({ signal, allowNetwork }) {
321
+ if (!allowNetwork) return models;
322
+ const next = await fetchCatalog(signal, currentApiKey());
323
+ models = next;
324
+ await writeCacheTo(CACHE_FILE, models);
325
+ return models;
326
+ },
327
+ // `/login devpass`: prompt for the gateway key, verify it, persist as credentials.
328
+ oauth: {
329
+ name: "LLM Gateway (DevPass)",
330
+ async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
331
+ const key = (await callbacks.onPrompt({ message: "LLM Gateway API key (llmgtwy_…):" })).trim();
332
+ if (!key) throw new Error("Login cancelled");
333
+ callbacks.onProgress?.(`devpass: verifying key against ${BASE_URL}/key…`);
334
+ try {
335
+ await gwFetch<GwKeyStatus>("/key", key);
336
+ } catch (e) {
337
+ throw new Error(`Key rejected: ${e instanceof Error ? e.message : String(e)}`);
338
+ }
339
+ return { refresh: key, access: key, expires: Date.now() + TOKEN_TTL_MS };
340
+ },
341
+ async refreshToken(credentials) {
342
+ return credentials; // static key — never actually expires
343
+ },
344
+ getApiKey: (credentials) => credentials.access,
345
+ },
346
+ });
347
+
348
+ async function fetchBalance(): Promise<GwKeyStatus["data"] | undefined> {
349
+ const key = currentApiKey();
350
+ if (!key) return;
351
+ try {
352
+ return (await gwFetch<GwKeyStatus>("/key", key)).data;
353
+ } catch {
354
+ return; // ponytail: silent — status line just keeps the last known balance
355
+ }
356
+ }
357
+
358
+ let lastBalance = "";
359
+
360
+ function statusLine(ui: ExtensionContext["ui"]): string | undefined {
361
+ return lastBalance ? ui.theme.fg("dim", `devpass balance ${lastBalance}`) : undefined;
362
+ }
363
+
364
+ // Status (balance only) only while a devpass model is active.
365
+ async function paintStatus(
366
+ model: { provider?: string } | null | undefined,
367
+ ui: ExtensionContext["ui"],
368
+ refresh = false,
369
+ ) {
370
+ if (!isDevpassModel(model)) {
371
+ ui.setStatus(PROVIDER_ID, undefined);
372
+ return;
373
+ }
374
+ if (refresh) {
375
+ const next = formatBalance(await fetchBalance());
376
+ if (next) lastBalance = next; // keep last known on fail
377
+ }
378
+ ui.setStatus(PROVIDER_ID, statusLine(ui));
379
+ }
380
+
381
+ pi.on("session_start", (_event, ctx) => paintStatus(ctx.model, ctx.ui, true));
382
+ pi.on("model_select", (event, ctx) => paintStatus(event.model, ctx.ui, true));
383
+
384
+ // event.message is the finalized assistant message — typed provider access
385
+ // (verified live: /v1/models uses per-token sci-notation pricing)
386
+ pi.on("turn_end", (event, ctx) => {
387
+ if (event.message.role !== "assistant" || event.message.provider !== PROVIDER_ID) return;
388
+ return paintStatus(ctx.model, ctx.ui, true);
389
+ });
390
+
391
+ pi.registerCommand("devpass", {
392
+ description: "Show DevPass credit balance and refresh the status line",
393
+ handler: async (_args, ctx) => {
394
+ if (!currentApiKey()) {
395
+ ctx.ui.notify(`devpass: /login devpass first (or set ${API_KEY_ENV}).`, "error");
396
+ return;
397
+ }
398
+ try {
399
+ const d = await fetchBalance();
400
+ const balance = formatBalance(d);
401
+ if (balance) lastBalance = balance;
402
+ const lines = [
403
+ d?.label ? `Key: ${d.label}` : null,
404
+ d?.devPlan && d.devPlan !== "none" ? `Dev plan: ${d.devPlan}` : null,
405
+ balance || null,
406
+ d?.devPlanCreditsUsed ? `Credits used: $${d.devPlanCreditsUsed}` : null,
407
+ d?.devPlanPremiumCreditsUsed
408
+ ? `Premium used: $${d.devPlanPremiumCreditsUsed}${d.devPlanPremiumWeeklyLimit ? `/$${d.devPlanPremiumWeeklyLimit}` : ""}${d.devPlanPremiumWeekResetsAt ? ` (resets ${d.devPlanPremiumWeekResetsAt})` : ""}`
409
+ : null,
410
+ d?.usage ? `Key usage: $${d.usage}` : null,
411
+ `Models loaded: ${models.length}`,
412
+ ].filter((l): l is string => l !== null);
413
+ await paintStatus(ctx.model, ctx.ui); // paints only if a devpass model is active
414
+ ctx.ui.notify(lines.join("\n"), "info");
415
+ } catch (e) {
416
+ ctx.ui.notify(`devpass: ${e instanceof Error ? e.message : String(e)}`, "error");
417
+ }
418
+ },
419
+ });
420
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@kshlm/pi-devpass-provider",
3
+ "version": "0.1.2",
4
+ "description": "pi extension: LLM Gateway / DevPass provider with auto-fetched models, rates, and credit balance",
5
+ "type": "module",
6
+ "files": [
7
+ "index.ts",
8
+ "selfcheck.ts"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/kshlm/pi-devpass-provider.git"
13
+ },
14
+ "keywords": [
15
+ "pi-package"
16
+ ],
17
+ "author": "Kaushal M",
18
+ "license": "MIT",
19
+ "scripts": {
20
+ "check": "tsc --noEmit && node selfcheck.ts"
21
+ },
22
+ "pi": {
23
+ "extensions": [
24
+ "./index.ts"
25
+ ]
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/pi-ai": "*",
29
+ "@earendil-works/pi-coding-agent": "*"
30
+ },
31
+ "devDependencies": {
32
+ "@earendil-works/pi-ai": "*",
33
+ "@earendil-works/pi-coding-agent": "*",
34
+ "@types/node": "*",
35
+ "typescript": "^5"
36
+ }
37
+ }
package/selfcheck.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Assert-based self-check for the pure helpers in index.ts.
3
+ * Run: npm run check (or: node selfcheck.ts — Node >= 22.6 strips types natively)
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { mkdtempSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { formatBalance, hasCachedInput, isCacheFresh, isChatModel, isCodingModel, isDevpassModel, keyFromAuthEntry, mappingSupportsCoding, readCacheFrom, resolveBaseUrl, toPerMillion, toPiModel, writeCacheTo } from "./index.ts";
10
+
11
+ // keyFromAuthEntry — auth.json shapes: oauth ({access}) and manual api_key ({key})
12
+ assert.equal(keyFromAuthEntry({ type: "oauth", access: "llmgtwy_a", refresh: "llmgtwy_a", expires: 1 }), "llmgtwy_a");
13
+ assert.equal(keyFromAuthEntry({ type: "api_key", key: "llmgtwy_b" }), "llmgtwy_b");
14
+ assert.equal(keyFromAuthEntry({ type: "oauth", access: " llmgtwy_c \n" }), "llmgtwy_c", "trimmed");
15
+ assert.equal(keyFromAuthEntry({ type: "oauth", access: "" }), undefined, "empty access");
16
+ assert.equal(keyFromAuthEntry({}), undefined);
17
+ assert.equal(keyFromAuthEntry(undefined), undefined);
18
+ assert.equal(keyFromAuthEntry("nope"), undefined);
19
+
20
+ // resolveBaseUrl precedence: env > models.json > default
21
+ assert.equal(resolveBaseUrl(undefined, undefined), "https://api.llmgateway.io/v1");
22
+ assert.equal(resolveBaseUrl("", ""), "https://api.llmgateway.io/v1");
23
+ assert.equal(resolveBaseUrl(undefined, '{"providers":{"devpass":{"baseUrl":"https://gw.self/v1"}}}'), "https://gw.self/v1");
24
+ assert.equal(resolveBaseUrl("http://env:9", '{"providers":{"devpass":{"baseUrl":"https://gw.self/v1"}}}'), "http://env:9", "env wins over file");
25
+ assert.equal(resolveBaseUrl(undefined, '{"providers":{"other":{"baseUrl":"https://x"}}}'), "https://api.llmgateway.io/v1");
26
+ assert.equal(resolveBaseUrl(undefined, '{"providers":{"devpass":{}}}'), "https://api.llmgateway.io/v1");
27
+ assert.equal(resolveBaseUrl(undefined, "not json"), "https://api.llmgateway.io/v1", "corrupt file ignored");
28
+
29
+ // toPerMillion — gateway sends USD-per-token scientific notation
30
+ assert.equal(toPerMillion("5e-6"), 5);
31
+ assert.equal(toPerMillion("0.000003"), 3);
32
+ assert.equal(toPerMillion("30e-6"), 30);
33
+ assert.equal(toPerMillion("0.3e-6"), 0.3);
34
+ assert.equal(toPerMillion("3.75e-6"), 3.75);
35
+ assert.equal(toPerMillion("0.05"), 0.05, "already $/M — not multiplied");
36
+ assert.equal(toPerMillion("0"), 0);
37
+ assert.equal(toPerMillion(undefined), 0);
38
+ assert.equal(toPerMillion("not-a-number"), 0);
39
+
40
+ // isCodingModel — DevPass gate: paid + stable + tools + stream + cache
41
+ const codingMap = { tools: true, streaming: true, pricing: { input_cache_read: "0.1e-6" } };
42
+ assert.equal(isCodingModel({ id: "ok", providers: [codingMap] }), true);
43
+ assert.equal(isCodingModel({ id: "custom", providers: [codingMap] }), false, "BYOK placeholder");
44
+ assert.equal(isCodingModel({ id: "auto", providers: [codingMap] }), false, "auto-router");
45
+ assert.equal(isCodingModel({ id: "free", free: true, providers: [codingMap] }), false);
46
+ assert.equal(isCodingModel({ id: "unstable", stability: "unstable", providers: [codingMap] }), false);
47
+ assert.equal(isCodingModel({ id: "experimental", stability: "experimental", providers: [codingMap] }), false);
48
+ assert.equal(isCodingModel({ id: "no-tools", providers: [{ ...codingMap, tools: false }] }), false);
49
+ assert.equal(isCodingModel({ id: "no-stream", providers: [{ ...codingMap, streaming: false }] }), false);
50
+ assert.equal(isCodingModel({ id: "stream-only", providers: [{ ...codingMap, streaming: "only" }] }), true);
51
+ assert.equal(isCodingModel({ id: "no-cache", providers: [{ tools: true, streaming: true, pricing: { input_cache_read: "0" } }] }), false, "public API 0 = missing cache");
52
+ assert.equal(isCodingModel({ id: "cache-write", providers: [{ tools: true, streaming: true, pricing: { input_cache_write: "3e-6" } }] }), true);
53
+ assert.equal(
54
+ isCodingModel({
55
+ id: "one-good",
56
+ providers: [{ ...codingMap, tools: false }, codingMap],
57
+ }),
58
+ true,
59
+ "one usable mapping is enough",
60
+ );
61
+ assert.equal(isCodingModel({ id: "empty", providers: [] }), false);
62
+ assert.equal(mappingSupportsCoding({ ...codingMap, stability: "unstable" }), false);
63
+ assert.equal(hasCachedInput("0"), false);
64
+ assert.equal(hasCachedInput("0.1e-6"), true);
65
+ assert.equal(hasCachedInput(undefined), false);
66
+
67
+ // isChatModel
68
+ const base = { id: "m", context_length: 1000, max_output: 100 };
69
+ assert.equal(isChatModel({ ...base }), true);
70
+ assert.equal(isChatModel({ ...base, deprecated_at: "2026-01-01" }), false);
71
+ assert.equal(isChatModel({ ...base, deactivated_at: "2026-01-01" }), false);
72
+ assert.equal(isChatModel({ ...base, architecture: { output_modalities: ["image"] } }), false);
73
+ assert.equal(isChatModel({ ...base, architecture: { input_modalities: ["audio"] } }), false);
74
+ // live-catalog shapes (/v1/models, verified 2026-08): embeddings/rerank/tts are excluded
75
+ assert.equal(isChatModel({ ...base, architecture: { output_modalities: ["embedding"] } }), false);
76
+ assert.equal(isChatModel({ ...base, architecture: { output_modalities: ["rerank"] } }), false);
77
+ assert.equal(isChatModel({ ...base, architecture: { output_modalities: ["audio"] } }), false);
78
+ assert.equal(isChatModel({ ...base, id: "custom" }), false, "BYOK placeholder excluded");
79
+ assert.equal(isChatModel({ ...base, id: "auto" }), true, "gateway auto-router kept");
80
+ assert.equal(isChatModel({ ...base, architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] } }), true);
81
+ // image-output hybrids excluded (text+image output)
82
+ assert.equal(
83
+ isChatModel({ ...base, architecture: { input_modalities: ["text"], output_modalities: ["text", "image"] } }),
84
+ false,
85
+ );
86
+ assert.equal(
87
+ isChatModel({ ...base, architecture: { input_modalities: ["text", "image"], output_modalities: ["text", "image"] } }),
88
+ false,
89
+ );
90
+
91
+ // real catalog pricing strings (gpt-4o-mini)
92
+ assert.deepEqual(
93
+ toPiModel({
94
+ id: "gpt-4o-mini",
95
+ pricing: { prompt: "0.15e-6", completion: "0.6e-6", input_cache_read: "0.075e-6", input_cache_write: "0" },
96
+ }).cost,
97
+ { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0 },
98
+ );
99
+ assert.equal(toPerMillion("0"), 0, "free/custom models cost 0");
100
+ assert.equal(toPerMillion("3e-05"), 30, "catalog max $30/M in");
101
+
102
+ // toPiModel
103
+ const mapped = toPiModel({
104
+ id: "claude-sonnet-4-5",
105
+ display_name: "Claude Sonnet 4.5",
106
+ context_length: 200_000,
107
+ max_output: 64_000,
108
+ providers: [{ reasoning: true, vision: true }],
109
+ pricing: { prompt: "3e-6", completion: "15e-6", input_cache_read: "0.3e-6", input_cache_write: "3.75e-6" },
110
+ });
111
+ assert.deepEqual(mapped, {
112
+ id: "claude-sonnet-4-5",
113
+ name: "[Premium] Claude Sonnet 4.5",
114
+ reasoning: true,
115
+ input: ["text", "image"],
116
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
117
+ contextWindow: 200_000,
118
+ maxTokens: 64_000,
119
+ });
120
+ assert.equal(
121
+ toPiModel({ id: "input-threshold", display_name: "Input Threshold", pricing: { prompt: "5e-6", completion: "1e-6" } }).name,
122
+ "[Premium] Input Threshold",
123
+ );
124
+ assert.equal(
125
+ toPiModel({ id: "standard", display_name: "Standard", pricing: { prompt: "4.999e-6", completion: "14.999e-6" } }).name,
126
+ "Standard",
127
+ );
128
+
129
+ // defaults
130
+ assert.deepEqual(toPiModel({ id: "bare" }), {
131
+ id: "bare",
132
+ name: "bare",
133
+ reasoning: false,
134
+ input: ["text"],
135
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
136
+ contextWindow: 128_000,
137
+ maxTokens: 8_192,
138
+ });
139
+
140
+ // formatBalance
141
+ assert.equal(
142
+ formatBalance({ devPlanCreditsRemaining: "8.12", devPlanCreditsLimit: "25" }),
143
+ "$8.12/$25 left",
144
+ );
145
+ assert.equal(formatBalance({ usage: "3.5", limit: "10" }), "$3.50/$10 used");
146
+ assert.equal(formatBalance({ devPlan: "none" }), "");
147
+ assert.equal(formatBalance(undefined), "");
148
+
149
+ // isDevpassModel — status line / balance only while this provider is active
150
+ assert.equal(isDevpassModel({ provider: "devpass" }), true);
151
+ assert.equal(isDevpassModel({ provider: "anthropic" }), false);
152
+ assert.equal(isDevpassModel({ provider: "openai" }), false);
153
+ assert.equal(isDevpassModel({}), false);
154
+ assert.equal(isDevpassModel(undefined), false);
155
+ assert.equal(isDevpassModel(null), false);
156
+
157
+ // cache: round-trip, freshness, corruption, schema version
158
+ const cachePath = join(mkdtempSync(join(tmpdir(), "devpass-cache-")), "cache.json");
159
+ assert.equal(await readCacheFrom(cachePath), undefined, "missing file → undefined");
160
+ const sample = [toPiModel({ id: "x", pricing: { prompt: "3e-6", completion: "15e-6" } })];
161
+ await writeCacheTo(cachePath, sample);
162
+ const entry = await readCacheFrom(cachePath);
163
+ assert.ok(entry, "round-trip readable");
164
+ assert.deepEqual(entry.models, sample, "round-trip preserves models");
165
+ assert.ok(isCacheFresh(entry), "fresh just after write");
166
+ assert.ok(!isCacheFresh({ ...entry, fetchedAt: Date.now() - 25 * 3600_000 }), "stale after 24h TTL");
167
+ assert.ok(!isCacheFresh(undefined), "missing entry not fresh");
168
+ writeFileSync(cachePath, "{corrupt json");
169
+ assert.equal(await readCacheFrom(cachePath), undefined, "corrupt → undefined");
170
+ writeFileSync(cachePath, JSON.stringify({ v: 2, fetchedAt: Date.now(), models: [] }));
171
+ assert.equal(await readCacheFrom(cachePath), undefined, "pre-coding-filter cache invalidated");
172
+
173
+ console.log("selfcheck passed");