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 +5 -1
- package/package.json +1 -1
- package/src/bakeoff.ts +1 -1
- package/src/models.ts +37 -2
- package/src/openrouter.ts +113 -0
- package/src/pm.ts +5 -3
- package/src/roles.ts +3 -1
- package/src/run-build.ts +1 -0
- package/src/tui/App.tsx +2 -1
- package/src/tui/Settings.tsx +72 -4
- package/src/tui/config.ts +1 -0
- package/src/tui/engine.ts +2 -0
- package/src/tui/validate.ts +5 -0
- package/src/types.ts +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://www.npmjs.com/package/projectinator)
|
|
8
8
|

|
|
9
9
|

|
|
10
|
-

|
|
11
11
|

|
|
12
12
|

|
|
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.
|
|
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
|
|
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 (
|
|
91
|
-
|
|
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 —
|
|
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).
|
|
279
|
-
`lacks access to ${pick.model}.
|
|
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
|
|
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"><
|
|
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>
|
package/src/tui/Settings.tsx
CHANGED
|
@@ -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
|
-
|
|
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
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). */
|
package/src/tui/validate.ts
CHANGED
|
@@ -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.
|