projectinator 0.1.2 → 0.1.4

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
@@ -7,7 +7,7 @@
7
7
  [![npm](https://img.shields.io/npm/v/projectinator.svg?color=e0a72d&label=npm)](https://www.npmjs.com/package/projectinator)
8
8
  ![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)
9
9
  ![node](https://img.shields.io/badge/node-%E2%89%A520-brightgreen.svg)
10
- ![tests: 134 passing](https://img.shields.io/badge/tests-134%20passing-brightgreen.svg)
10
+ ![tests: 140 passing](https://img.shields.io/badge/tests-140%20passing-brightgreen.svg)
11
11
  ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)
12
12
  ![built on Pi](https://img.shields.io/badge/built%20on-Pi%20agent%20harness-e0a72d.svg)
13
13
 
@@ -16,6 +16,10 @@ project-manager model breaks it into a Scrum backlog, and each task is dispatche
16
16
  model that's best — and cheapest — for that exact job (planning, design, code, test). You
17
17
  watch it happen from a terminal cockpit: a live board, budget bar, and a standup.
18
18
 
19
+ <p align="center">
20
+ <img src="docs/cockpit.png" alt="Projectinator cockpit — the live Scrum board mid-build" width="860" />
21
+ </p>
22
+
19
23
  Built on the [Pi](https://pi.dev) agent harness (Node/TypeScript). Bring your own API key.
20
24
 
21
25
  **Install & run** (Node ≥ 20):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectinator",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Your AI build team in the terminal — hand it an app idea, a PM model plans a Scrum backlog, and the best model per role designs, codes, and tests it into working files. Bring your own API key.",
5
5
  "type": "module",
6
6
  "private": false,
package/src/bakeoff.ts CHANGED
@@ -95,7 +95,7 @@ async function runCandidate(task: Task, cand: Candidate): Promise<BakeoffEntry>
95
95
  ms,
96
96
  outputTokens: stats.tokens.output,
97
97
  };
98
- if (stats.tokens.total === 0) out.error = "returned 0 tokens (key likely can't access this model)";
98
+ if (stats.tokens.total === 0) out.error = "returned 0 tokens (invalid key, no credit/balance, or no model access)";
99
99
  return out;
100
100
  } finally {
101
101
  session.dispose(); // dispose even when prompt() throws (expected for inaccessible models)
package/src/models.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // for models whose exact cache rates we haven't pinned — refine against provider docs).
5
5
 
6
6
  import type { Model } from "./types.js";
7
+ import { findOpenRouterModel } from "./openrouter.js";
7
8
 
8
9
  export const MODELS: Record<string, Model> = {
9
10
  // ---- OpenAI: GPT-5.6 family ----
@@ -83,10 +84,44 @@ export const MODELS: Record<string, Model> = {
83
84
  contextWindow: 1_000_000,
84
85
  cost: { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625 },
85
86
  },
87
+
88
+ // ---- OpenRouter (one key → frontier models). ids are Pi's OpenRouter-catalog
89
+ // slugs (vendor/model). Pricing mirrors the underlying model (OpenRouter passes
90
+ // it through, ~small margin); ACTUAL cost still comes from Pi per run.
91
+ "anthropic/claude-opus-4.8": {
92
+ id: "anthropic/claude-opus-4.8",
93
+ provider: "openrouter",
94
+ name: "Claude Opus 4.8 (OpenRouter)",
95
+ contextWindow: 1_000_000,
96
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
97
+ },
98
+ "anthropic/claude-sonnet-4.6": {
99
+ id: "anthropic/claude-sonnet-4.6",
100
+ provider: "openrouter",
101
+ name: "Claude Sonnet 4.6 (OpenRouter)",
102
+ contextWindow: 1_000_000,
103
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
104
+ },
105
+ "openai/gpt-5.6-luna": {
106
+ id: "openai/gpt-5.6-luna",
107
+ provider: "openrouter",
108
+ name: "GPT-5.6 Luna (OpenRouter)",
109
+ contextWindow: 272_000,
110
+ cost: { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 },
111
+ },
86
112
  };
87
113
 
88
114
  export function getModel(id: string): Model {
89
115
  const m = MODELS[id];
90
- if (!m) throw new Error(`Unknown model id: "${id}". Add it to src/models.ts.`);
91
- return m;
116
+ if (m) return m;
117
+ // OpenRouter slugs (vendor/model) aren't in the static table — price them from
118
+ // the OpenRouter catalog (live cache or Pi's built-in list). Fall back to a
119
+ // rough estimate so a build never crashes on an unpriced model (actual cost
120
+ // still comes from Pi per run).
121
+ if (id.includes("/")) {
122
+ const or = findOpenRouterModel(id);
123
+ if (or) return { ...or, provider: "openrouter" };
124
+ return { id, provider: "openrouter", name: id, contextWindow: 200_000, cost: { input: 1, output: 3, cacheRead: 0.1, cacheWrite: 1.25 } };
125
+ }
126
+ throw new Error(`Unknown model id: "${id}". Add it to src/models.ts.`);
92
127
  }
@@ -0,0 +1,113 @@
1
+ // The OpenRouter model catalog — so users can pick ANY OpenRouter model by name
2
+ // (Kimi, DeepSeek, Qwen, …) instead of typing slugs.
3
+ //
4
+ // - refreshOpenRouterModels(): live-fetch openrouter.ai/api/v1/models (freshest
5
+ // names + pricing), cache to disk. Falls back to Pi's built-in OR catalog.
6
+ // - openRouterModels() / findOpenRouterModel(): SYNC reads (disk cache, else Pi's
7
+ // built-in list) so cost estimation (getModel) can price any picked model.
8
+
9
+ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import type { Model, ModelCost } from "./types.js";
14
+
15
+ /** A pickable OpenRouter model: our Model shape minus the provider (always "openrouter"). */
16
+ export type ORModel = Omit<Model, "provider">;
17
+
18
+ const CACHE = join(homedir(), ".projectinator", "openrouter-models.json");
19
+
20
+ let builtinMemo: ORModel[] | null = null;
21
+ let diskMemo: ORModel[] | null | undefined; // undefined = not read yet, null = no cache
22
+
23
+ /** Pi's built-in OpenRouter catalog — offline, always available, names + pricing. */
24
+ export function builtinOpenRouterModels(): ORModel[] {
25
+ if (builtinMemo) return builtinMemo;
26
+ try {
27
+ const reg = ModelRegistry.create(AuthStorage.create());
28
+ const all = (reg.getAll() as unknown as Array<{ id: string; provider: string; name?: string; contextWindow?: number; cost?: ModelCost }>);
29
+ builtinMemo = all
30
+ .filter((m) => m.provider === "openrouter" && m.cost)
31
+ .map((m) => ({
32
+ id: m.id,
33
+ name: m.name ?? m.id,
34
+ contextWindow: m.contextWindow ?? 200_000,
35
+ cost: m.cost as ModelCost,
36
+ }))
37
+ .sort((a, b) => a.name.localeCompare(b.name));
38
+ } catch {
39
+ builtinMemo = [];
40
+ }
41
+ return builtinMemo;
42
+ }
43
+
44
+ function readDiskCache(): ORModel[] | null {
45
+ if (diskMemo !== undefined) return diskMemo;
46
+ try {
47
+ if (existsSync(CACHE)) {
48
+ const parsed = JSON.parse(readFileSync(CACHE, "utf8")) as { models?: ORModel[] };
49
+ diskMemo = Array.isArray(parsed.models) && parsed.models.length ? parsed.models : null;
50
+ } else diskMemo = null;
51
+ } catch {
52
+ diskMemo = null;
53
+ }
54
+ return diskMemo;
55
+ }
56
+
57
+ /** The list to show / price against: live-fetched disk cache if present, else Pi's built-in. */
58
+ export function openRouterModels(): ORModel[] {
59
+ const cached = readDiskCache();
60
+ return cached && cached.length ? cached : builtinOpenRouterModels();
61
+ }
62
+
63
+ /** Look up one model's pricing/name by slug (cache first, then built-in). Sync. */
64
+ export function findOpenRouterModel(id: string): ORModel | undefined {
65
+ return openRouterModels().find((m) => m.id === id) ?? builtinOpenRouterModels().find((m) => m.id === id);
66
+ }
67
+
68
+ /** Map OpenRouter's API pricing (USD per token, strings) to our per-1M-token cost. */
69
+ function mapApiModel(m: {
70
+ id: string;
71
+ name?: string;
72
+ context_length?: number;
73
+ pricing?: { prompt?: string; completion?: string; input_cache_read?: string; input_cache_write?: string };
74
+ }): ORModel | null {
75
+ const p = m.pricing ?? {};
76
+ const input = Number(p.prompt) * 1e6;
77
+ const output = Number(p.completion) * 1e6;
78
+ if (!Number.isFinite(input) || !Number.isFinite(output)) return null;
79
+ const cacheRead = p.input_cache_read != null ? Number(p.input_cache_read) * 1e6 : input * 0.1;
80
+ const cacheWrite = p.input_cache_write != null ? Number(p.input_cache_write) * 1e6 : input * 1.25;
81
+ return {
82
+ id: m.id,
83
+ name: m.name ?? m.id,
84
+ contextWindow: m.context_length ?? 200_000,
85
+ cost: { input, output, cacheRead, cacheWrite },
86
+ };
87
+ }
88
+
89
+ /** Live-fetch the catalog and cache it. Falls back to the current list on any failure. */
90
+ export async function refreshOpenRouterModels(timeoutMs = 8000): Promise<ORModel[]> {
91
+ const ctrl = new AbortController();
92
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
93
+ try {
94
+ const res = await fetch("https://openrouter.ai/api/v1/models", { signal: ctrl.signal });
95
+ if (!res.ok) return openRouterModels();
96
+ const json = (await res.json()) as { data?: Array<Parameters<typeof mapApiModel>[0]> };
97
+ const list = (json.data ?? []).map(mapApiModel).filter((m): m is ORModel => m !== null)
98
+ .sort((a, b) => a.name.localeCompare(b.name));
99
+ if (list.length) {
100
+ try {
101
+ mkdirSync(join(homedir(), ".projectinator"), { recursive: true });
102
+ writeFileSync(CACHE, JSON.stringify({ fetchedAt: Date.now(), models: list }));
103
+ diskMemo = list;
104
+ } catch { /* cache write is best-effort */ }
105
+ return list;
106
+ }
107
+ return openRouterModels();
108
+ } catch {
109
+ return openRouterModels();
110
+ } finally {
111
+ clearTimeout(timer);
112
+ }
113
+ }
package/src/pm.ts CHANGED
@@ -273,10 +273,12 @@ export async function decomposeIdea(idea: string, opts: DecomposeOptions): Promi
273
273
  if (!raw || !raw.tasks?.length) {
274
274
  const stats = session.getSessionStats();
275
275
  if (stats.tokens.total === 0) {
276
- // The provider call returned nothing — almost always a bad/inaccessible key.
276
+ // The provider call returned nothing — bad/inaccessible key, OR (very common)
277
+ // the account has no credit/balance so the API rejects the request.
277
278
  throw new Error(
278
- `The ${pick.provider} model returned nothing (0 tokens). Its API key is likely invalid or ` +
279
- `lacks access to ${pick.model}. Set a working key, or pick a different provider in Settings → Preferred provider.`,
279
+ `The ${pick.provider} model returned nothing (0 tokens). Likely causes: the API key is invalid, ` +
280
+ `the account has no credit/balance, or the key lacks access to ${pick.model}. Add credit or set a ` +
281
+ `working key, or pick a different provider in Settings → Preferred provider.`,
280
282
  );
281
283
  }
282
284
  const said = lastAssistantText(session).slice(0, 200).replace(/\s+/g, " ").trim();
package/src/roles.ts CHANGED
@@ -147,6 +147,7 @@ const PROVIDER_MODELS: Record<Provider, { strong: string; mid: string; cheap: st
147
147
  anthropic: { strong: "claude-opus-4-8", mid: "claude-sonnet-4-6", cheap: "claude-haiku-4-5" },
148
148
  openai: { strong: "gpt-5.6-sol", mid: "gpt-5.6-terra", cheap: "gpt-5.6-luna" },
149
149
  google: { strong: "gemini-3.1-pro-preview", mid: "gemini-3.1-pro-preview", cheap: "gemini-3-flash-preview" },
150
+ openrouter: { strong: "anthropic/claude-opus-4.8", mid: "anthropic/claude-sonnet-4.6", cheap: "openai/gpt-5.6-luna" },
150
151
  };
151
152
 
152
153
  const CAP_STRENGTH: Record<Capability, "strong" | "mid" | "cheap"> = {
@@ -194,6 +195,7 @@ const ENV_KEYS: Record<Provider, string[]> = {
194
195
  anthropic: ["ANTHROPIC_API_KEY"],
195
196
  openai: ["OPENAI_API_KEY"],
196
197
  google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
198
+ openrouter: ["OPENROUTER_API_KEY"],
197
199
  };
198
200
 
199
201
  function providersWithKeys(): Provider[] {
@@ -340,7 +342,7 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
340
342
  if (i > 0) opts.onFallback?.({ taskId: task.id, from: decision.provider, to: cand.provider, model: cand.model });
341
343
  return att.result;
342
344
  }
343
- lastErr = new Error(`${cand.provider}/${cand.model} returned 0 tokens (key likely can't access this model)`);
345
+ lastErr = new Error(`${cand.provider}/${cand.model} returned 0 tokens (invalid key, no account credit/balance, or no access to this model)`);
344
346
  } catch (e) {
345
347
  lastErr = e;
346
348
  }
package/src/run-build.ts CHANGED
@@ -114,6 +114,7 @@ const envKey: Record<Provider, string[]> = {
114
114
  anthropic: ["ANTHROPIC_API_KEY"],
115
115
  openai: ["OPENAI_API_KEY"],
116
116
  google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
117
+ openrouter: ["OPENROUTER_API_KEY"],
117
118
  };
118
119
  if (!(envKey[lockProvider] ?? []).some((k) => process.env[k])) {
119
120
  console.error(` No API key for ${lockProvider}. Set: ${(envKey[lockProvider] ?? []).join(", ")}\n`);
package/src/tui/App.tsx CHANGED
@@ -4,6 +4,7 @@
4
4
  import React, { useEffect, useState } from "react";
5
5
  import { Box, Text, useApp, useInput } from "ink";
6
6
  import { Spinner, StatusMessage } from "@inkjs/ui";
7
+ import InkSpinner from "ink-spinner"; // a Text-based spinner, safe as an inline glyph inside <Text>
7
8
  import type { Provider } from "../types.js";
8
9
  import type { OrchestratorEvent } from "../orchestrator.js";
9
10
  import { C, BudgetBar, Panel, Chip, Menu as SelectInput, GroupedMenu, KeyHint, useTermRows, TextField as TextInput, type TaskView, type MenuGroup } from "./components.js";
@@ -1586,7 +1587,7 @@ export default function App(): React.ReactElement {
1586
1587
  return (
1587
1588
  <Box flexDirection="column">
1588
1589
  <Box>
1589
- <Text color="cyan"><Spinner type="dots" /></Text>
1590
+ <Text color="cyan"><InkSpinner type="dots" /></Text>
1590
1591
  <Text bold>{" "}Building</Text>
1591
1592
  <Text color={C.textSubtle}>{` ${running} running`}</Text>
1592
1593
  </Box>
@@ -12,8 +12,9 @@ import { estimateAccuracy } from "../estimate.js";
12
12
  import { availableProviders, effectiveRoster, allModels, setRoleModel, PROVIDER_LABEL } from "./engine.js";
13
13
  import { setKey, getPrefs, setPrefs, loadConfig, setPreferredProvider, getDefaultMode, setDefaultMode, getNotify, setNotify, getPreferredStack, setPreferredStack, ENV_VAR } from "./config.js";
14
14
  import { validateKey } from "./validate.js";
15
+ import { openRouterModels, refreshOpenRouterModels } from "../openrouter.js";
15
16
 
16
- type Sub = "menu" | "keys" | "keyEntry" | "models" | "modelPick" | "prefs" | "provider" | "workflow" | "weblogin" | "accuracy" | "stack";
17
+ type Sub = "menu" | "keys" | "keyEntry" | "models" | "modelPick" | "orBrowse" | "orPick" | "prefs" | "provider" | "workflow" | "weblogin" | "accuracy" | "stack";
17
18
 
18
19
  export function Settings({ onExit }: { onExit: () => void }): React.ReactElement {
19
20
  const [sub, setSub] = useState<Sub>("menu");
@@ -23,6 +24,7 @@ export function Settings({ onExit }: { onExit: () => void }): React.ReactElement
23
24
  const [keyError, setKeyError] = useState("");
24
25
  const [role, setRole] = useState<{ capability: Capability; tier: Tier; label: string } | null>(null);
25
26
  const [notice, setNotice] = useState("");
27
+ const [orQuery, setOrQuery] = useState(""); // OpenRouter model-browser filter
26
28
  const [, force] = useState(0);
27
29
  const refresh = () => force((n) => n + 1);
28
30
 
@@ -72,7 +74,7 @@ export function Settings({ onExit }: { onExit: () => void }): React.ReactElement
72
74
  // ---------- API keys ----------
73
75
  if (sub === "keys") {
74
76
  const have = new Set(availableProviders());
75
- const providers: Provider[] = ["anthropic", "openai", "google"];
77
+ const providers: Provider[] = ["anthropic", "openai", "google", "openrouter"];
76
78
  return (
77
79
  <Box flexDirection="column">
78
80
  <Panel title="API keys">
@@ -170,7 +172,11 @@ export function Settings({ onExit }: { onExit: () => void }): React.ReactElement
170
172
  const [capability, tier] = i.value.split(":") as [Capability, Tier];
171
173
  const r = rows.find((x) => x.capability === capability && x.tier === tier)!;
172
174
  setRole({ capability, tier, label: r.label });
173
- setSub("modelPick");
175
+ if (lock === "openrouter") {
176
+ setOrQuery("");
177
+ void refreshOpenRouterModels().then(() => refresh()); // freshen catalog in the background
178
+ setSub("orBrowse");
179
+ } else setSub("modelPick");
174
180
  }
175
181
  }}
176
182
  />
@@ -209,11 +215,73 @@ export function Settings({ onExit }: { onExit: () => void }): React.ReactElement
209
215
  );
210
216
  }
211
217
 
218
+ // ---------- OpenRouter model browser: filter the whole catalog by name ----------
219
+ if ((sub === "orBrowse" || sub === "orPick") && role) {
220
+ const all = openRouterModels();
221
+ const q = orQuery.trim().toLowerCase();
222
+ const matches = q ? all.filter((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q)) : all;
223
+ const price = (m: (typeof all)[number]) => `$${m.cost.input}/$${m.cost.output}`;
224
+
225
+ if (sub === "orBrowse") {
226
+ return (
227
+ <Box flexDirection="column">
228
+ <Panel title={`OpenRouter model for ${role.label}`}>
229
+ <Text color={C.textMuted}>Type to filter {all.length} models by name or slug — e.g. “kimi”, “deepseek”, “coder”.</Text>
230
+ <Box marginTop={1}>
231
+ <Text color={C.accent}>{"filter › "}</Text>
232
+ <TextInput
233
+ value={orQuery}
234
+ onChange={setOrQuery}
235
+ placeholder="kimi"
236
+ onSubmit={() => {
237
+ if (!orQuery.trim()) { setSub("models"); return; }
238
+ if (matches.length) setSub("orPick");
239
+ }}
240
+ />
241
+ </Box>
242
+ <Box marginTop={1} flexDirection="column">
243
+ <Text color={C.textSubtle}>{matches.length} match{matches.length === 1 ? "" : "es"}{matches.length ? " — Enter to choose:" : ""}</Text>
244
+ {matches.slice(0, 6).map((m) => (
245
+ <Text key={m.id} wrap="truncate-end"><Text color={C.textMuted}>{m.name}</Text> <Text color={C.textSubtle}>{m.id} {price(m)}</Text></Text>
246
+ ))}
247
+ {matches.length > 6 ? <Text color={C.textSubtle}> …refine to narrow</Text> : null}
248
+ </Box>
249
+ <Box marginTop={1}><KeyHint hints={[{ keys: "Enter", label: "choose" }, { keys: "empty Enter", label: "back" }]} /></Box>
250
+ </Panel>
251
+ </Box>
252
+ );
253
+ }
254
+
255
+ // orPick — select from the filtered matches
256
+ return (
257
+ <Box flexDirection="column">
258
+ <Panel title={`Pick a model · “${orQuery}” (${matches.length})`}>
259
+ <SelectInput
260
+ limit={12}
261
+ items={[
262
+ ...matches.slice(0, 50).map((m) => ({ label: `${m.name} ${m.id} ${price(m)}`, value: m.id })),
263
+ { label: "← Refine filter", value: "__refine" },
264
+ { label: "Back", value: "__back" },
265
+ ]}
266
+ onSelect={(i) => {
267
+ if (i.value === "__refine") { setSub("orBrowse"); return; }
268
+ if (i.value === "__back") { setSub("models"); return; }
269
+ setRoleModel(role.capability, role.tier, i.value);
270
+ setNotice(`${role.label} → ${i.value}`);
271
+ refresh();
272
+ setSub("models");
273
+ }}
274
+ />
275
+ </Panel>
276
+ </Box>
277
+ );
278
+ }
279
+
212
280
  // ---------- preferred provider ----------
213
281
  if (sub === "provider") {
214
282
  const have = new Set(availableProviders());
215
283
  const current = loadConfig().preferredProvider;
216
- const providers: Provider[] = ["anthropic", "openai", "google"];
284
+ const providers: Provider[] = ["anthropic", "openai", "google", "openrouter"];
217
285
  return (
218
286
  <Box flexDirection="column">
219
287
  <Panel title="Preferred provider">
package/src/tui/config.ts CHANGED
@@ -29,6 +29,7 @@ export const ENV_VAR: Record<Provider, string> = {
29
29
  anthropic: "ANTHROPIC_API_KEY",
30
30
  openai: "OPENAI_API_KEY",
31
31
  google: "GEMINI_API_KEY",
32
+ openrouter: "OPENROUTER_API_KEY",
32
33
  };
33
34
 
34
35
  function dir(): string {
package/src/tui/engine.ts CHANGED
@@ -27,12 +27,14 @@ const PROVIDER_KEYS: Record<Provider, string[]> = {
27
27
  anthropic: ["ANTHROPIC_API_KEY"],
28
28
  openai: ["OPENAI_API_KEY"],
29
29
  google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
30
+ openrouter: ["OPENROUTER_API_KEY"],
30
31
  };
31
32
 
32
33
  export const PROVIDER_LABEL: Record<Provider, string> = {
33
34
  anthropic: "Anthropic (Claude)",
34
35
  openai: "OpenAI (GPT)",
35
36
  google: "Google (Gemini)",
37
+ openrouter: "OpenRouter",
36
38
  };
37
39
 
38
40
  /** Which providers have a usable API key right now (presence only). */
@@ -31,6 +31,11 @@ export async function validateKey(provider: Provider, key: string): Promise<KeyC
31
31
  res = await withTimeout("https://api.openai.com/v1/models", {
32
32
  headers: { Authorization: `Bearer ${key}` },
33
33
  });
34
+ } else if (provider === "openrouter") {
35
+ // Returns the key's rate-limit/usage info; 401 if the key is bad.
36
+ res = await withTimeout("https://openrouter.ai/api/v1/key", {
37
+ headers: { Authorization: `Bearer ${key}` },
38
+ });
34
39
  } else {
35
40
  res = await withTimeout(
36
41
  `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key)}`,
package/src/types.ts CHANGED
@@ -14,7 +14,7 @@ export type Difficulty = "trivial" | "low" | "medium" | "high";
14
14
  /** Capability tier — the abstract "how strong a model" axis. */
15
15
  export type Tier = "fast" | "mid" | "high";
16
16
 
17
- export type Provider = "anthropic" | "openai" | "google";
17
+ export type Provider = "anthropic" | "openai" | "google" | "openrouter";
18
18
 
19
19
  // ---------------------------------------------------------------------------
20
20
  // Model pricing — mirrors Pi's models.json `cost` shape so it ports 1:1 later.