@bramburn/pi-model-council 1.6.3 → 1.6.11

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/index.ts ADDED
@@ -0,0 +1,304 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { councilInputSchema, secondOpinionInputSchema } from "./schemas.js";
3
+ import type { CouncilInput, SecondOpinionInput } from "./types.js";
4
+ import { CouncilSetupError, OpinionSetupError } from "./types.js";
5
+ import { runCouncil } from "./councilRunner.js";
6
+ import { runSecondOpinion } from "./secondOpinionRunner.js";
7
+ import { parseCouncilCommandArgs, parseSecondOpinionCommandArgs } from "./commandParser.js";
8
+ import {
9
+ showCurrentSettings,
10
+ resetSettings,
11
+ openCouncilSettingsUI,
12
+ openOpinionSettingsUI,
13
+ } from "./settings-ui.js";
14
+ import { mkdir, writeFile } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+
17
+ /** Save the most recent council report under `<cwd>/.pi/council/`. */
18
+ async function saveLatestCouncilReport(cwd: string, markdown: string): Promise<string> {
19
+ const dir = join(cwd, ".pi", "council");
20
+ await mkdir(dir, { recursive: true });
21
+ const path = join(dir, "last-decision.md");
22
+ await writeFile(path, markdown, "utf8");
23
+ return path;
24
+ }
25
+
26
+ /** Save the most recent second-opinion report under `<cwd>/.pi/council/`. */
27
+ async function saveLatestSecondOpinion(cwd: string, markdown: string): Promise<string> {
28
+ const dir = join(cwd, ".pi", "council");
29
+ await mkdir(dir, { recursive: true });
30
+ const path = join(dir, "last-opinion.md");
31
+ await writeFile(path, markdown, "utf8");
32
+ return path;
33
+ }
34
+
35
+ export default function modelCouncilExtension(pi: ExtensionAPI) {
36
+ // Status keys are namespaced so a long-running /council and a quick /opinion
37
+ // don't clobber each other's footer message when invoked concurrently.
38
+ const STATUS_COUNCIL = "model-council:run";
39
+ const STATUS_OPINION = "model-council:opinion";
40
+
41
+ // ── Tools ──────────────────────────────────────────────────────────────────
42
+
43
+ // Register the council_decide tool
44
+ pi.registerTool({
45
+ name: "council_decide",
46
+ label: "Model Council Decision",
47
+ description:
48
+ "Ask three hard-coded OpenRouter models for a second opinion on a fix, technical question, or architecture decision. Returns a structured plan for the main Pi coding model to implement. This tool does not edit files or run commands.",
49
+ parameters: councilInputSchema,
50
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
51
+ const input = params as unknown as CouncilInput;
52
+
53
+ try {
54
+ const result = await runCouncil({
55
+ input,
56
+ signal: ctx.signal,
57
+ onStatus: (message) => ctx.ui.setStatus(STATUS_COUNCIL, message),
58
+ cwd: ctx.cwd,
59
+ isProjectTrusted: ctx.isProjectTrusted(),
60
+ modelRegistry: ctx.modelRegistry,
61
+ });
62
+
63
+ // Save the report so the user can read it later — same artifact the
64
+ // /council slash command writes, so behaviour is consistent regardless
65
+ // of which entry point invoked the council.
66
+ let savedPath: string | undefined;
67
+ try {
68
+ savedPath = await saveLatestCouncilReport(ctx.cwd, result.markdown);
69
+ } catch {
70
+ // Non-fatal — the tool result still contains the full markdown.
71
+ }
72
+
73
+ const header = savedPath
74
+ ? `\n_Saved to \`${savedPath}\`_\n\n`
75
+ : "";
76
+ return {
77
+ content: [{ type: "text", text: `${header}${result.markdown}` }],
78
+ details: {
79
+ decision: result.decision,
80
+ rawModelResults: result.rawModelResults,
81
+ savedPath,
82
+ },
83
+ };
84
+ } catch (error) {
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ ctx.ui.setStatus(STATUS_COUNCIL, undefined);
87
+
88
+ // Show helpful setup instructions for setup errors
89
+ if (error instanceof CouncilSetupError) {
90
+ ctx.ui.notify(message, "warning");
91
+ }
92
+
93
+ return {
94
+ content: [{ type: "text", text: `Model council failed: ${message}` }],
95
+ details: { error: message },
96
+ isError: true,
97
+ };
98
+ }
99
+ },
100
+ });
101
+
102
+ // Register the second_opinion tool
103
+ pi.registerTool({
104
+ name: "second_opinion",
105
+ label: "Second Opinion",
106
+ description:
107
+ "Get a quick second opinion from a configurable model on a fix, technical question, or architecture decision. Faster than the full council but uses one model.",
108
+ parameters: secondOpinionInputSchema,
109
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
110
+ const input = params as unknown as SecondOpinionInput;
111
+
112
+ try {
113
+ const result = await runSecondOpinion({
114
+ input,
115
+ signal: ctx.signal,
116
+ onStatus: (message) => ctx.ui.setStatus(STATUS_OPINION, message),
117
+ cwd: ctx.cwd,
118
+ isProjectTrusted: ctx.isProjectTrusted(),
119
+ modelRegistry: ctx.modelRegistry,
120
+ });
121
+
122
+ let savedPath: string | undefined;
123
+ try {
124
+ savedPath = await saveLatestSecondOpinion(ctx.cwd, result.markdown);
125
+ } catch {
126
+ // Non-fatal — the tool result still contains the full markdown.
127
+ }
128
+
129
+ const header = savedPath
130
+ ? `\n_Saved to \`${savedPath}\`_\n\n`
131
+ : "";
132
+ return {
133
+ content: [{ type: "text", text: `${header}${result.markdown}` }],
134
+ details: {
135
+ opinion: result.opinion,
136
+ savedPath,
137
+ },
138
+ };
139
+ } catch (error) {
140
+ const message = error instanceof Error ? error.message : String(error);
141
+
142
+ ctx.ui.setStatus(STATUS_OPINION, undefined);
143
+
144
+ if (error instanceof OpinionSetupError) {
145
+ ctx.ui.notify(message, "warning");
146
+ }
147
+
148
+ return {
149
+ content: [{ type: "text", text: `Second opinion failed: ${message}` }],
150
+ details: { error: message },
151
+ isError: true,
152
+ };
153
+ }
154
+ },
155
+ });
156
+
157
+ // ── Commands ───────────────────────────────────────────────────────────────
158
+
159
+ // Register the /council command
160
+ pi.registerCommand("council", {
161
+ description: "Ask the model council: /council [ask|fix|architecture] \"problem\"",
162
+ handler: async (args, ctx) => {
163
+ try {
164
+ const input = parseCouncilCommandArgs(args);
165
+
166
+ ctx.ui.setStatus(STATUS_COUNCIL, "Council: starting...");
167
+
168
+ const result = await runCouncil({
169
+ input,
170
+ signal: ctx.signal,
171
+ onStatus: (message) => ctx.ui.setStatus(STATUS_COUNCIL, message),
172
+ cwd: ctx.cwd,
173
+ isProjectTrusted: ctx.isProjectTrusted(),
174
+ modelRegistry: ctx.modelRegistry,
175
+ });
176
+
177
+ await saveLatestCouncilReport(ctx.cwd, result.markdown);
178
+
179
+ // Notify user
180
+ ctx.ui.notify("Model council complete. Report saved to .pi/council/last-decision.md", "info");
181
+
182
+ ctx.ui.setStatus(STATUS_COUNCIL, undefined);
183
+ } catch (error) {
184
+ const message = error instanceof Error ? error.message : String(error);
185
+ ctx.ui.setStatus(STATUS_COUNCIL, undefined);
186
+ if (error instanceof CouncilSetupError) {
187
+ ctx.ui.notify(message, "warning");
188
+ } else {
189
+ ctx.ui.notify(`Model council failed: ${message}`, "error");
190
+ }
191
+ }
192
+ },
193
+ });
194
+
195
+ // Register the /opinion command
196
+ pi.registerCommand("opinion", {
197
+ description: 'Get a second opinion: /opinion [fix|ask|architecture|general] "problem"',
198
+ handler: async (args, ctx) => {
199
+ try {
200
+ const input = parseSecondOpinionCommandArgs(args);
201
+
202
+ ctx.ui.setStatus(STATUS_OPINION, "Second opinion: starting...");
203
+
204
+ const result = await runSecondOpinion({
205
+ input,
206
+ signal: ctx.signal,
207
+ onStatus: (message) => ctx.ui.setStatus(STATUS_OPINION, message),
208
+ cwd: ctx.cwd,
209
+ isProjectTrusted: ctx.isProjectTrusted(),
210
+ modelRegistry: ctx.modelRegistry,
211
+ });
212
+
213
+ await saveLatestSecondOpinion(ctx.cwd, result.markdown);
214
+
215
+ // Notify user
216
+ ctx.ui.notify("Second opinion complete. Report saved to .pi/council/last-opinion.md", "info");
217
+
218
+ ctx.ui.setStatus(STATUS_OPINION, undefined);
219
+ } catch (error) {
220
+ const message = error instanceof Error ? error.message : String(error);
221
+ ctx.ui.setStatus(STATUS_OPINION, undefined);
222
+ if (error instanceof OpinionSetupError) {
223
+ ctx.ui.notify(message, "warning");
224
+ } else {
225
+ ctx.ui.notify(`Second opinion failed: ${message}`, "error");
226
+ }
227
+ }
228
+ },
229
+ });
230
+
231
+ // Register the /council-settings command
232
+ pi.registerCommand("council-settings", {
233
+ description:
234
+ "Configure model council: /council-settings [list|reset] — opens interactive UI by default",
235
+ handler: async (args, ctx) => {
236
+ const normalizedArgs = args?.trim().toLowerCase() ?? "";
237
+
238
+ if (normalizedArgs === "list") {
239
+ await showCurrentSettings(ctx);
240
+ return;
241
+ }
242
+
243
+ if (normalizedArgs === "reset") {
244
+ const confirmed = await ctx.ui.confirm(
245
+ "Reset Council Settings",
246
+ "This will clear the 3 council models, the synthesis model, and the OpenRouter API key. Your opinion model is kept. Continue?",
247
+ );
248
+ if (confirmed) {
249
+ await resetSettings(ctx, "council");
250
+ }
251
+ return;
252
+ }
253
+
254
+ // Default: open settings UI
255
+ await openCouncilSettingsUI(ctx);
256
+ },
257
+ });
258
+
259
+ // Register the /opinion-settings command
260
+ pi.registerCommand("opinion-settings", {
261
+ description:
262
+ "Configure second opinion model: /opinion-settings [list|reset] — opens interactive UI by default",
263
+ handler: async (args, ctx) => {
264
+ const normalizedArgs = args?.trim().toLowerCase() ?? "";
265
+
266
+ if (normalizedArgs === "list") {
267
+ // Show opinion model from settings
268
+ const { formatSettingsForDisplay } = await import("./settings.js");
269
+ const { loadSettings } = await import("./settings.js");
270
+ const settings = await loadSettings(ctx.cwd, ctx.isProjectTrusted());
271
+ const lines = formatSettingsForDisplay(settings);
272
+ const output = lines.slice(3, 5).join("\n"); // Opinion line only
273
+ ctx.ui.notify(output, "info");
274
+ return;
275
+ }
276
+
277
+ if (normalizedArgs === "reset") {
278
+ const { loadSettings, saveSettings, createDefaultSettings } = await import("./settings.js");
279
+ const defaults = createDefaultSettings();
280
+ const confirmed = await ctx.ui.confirm(
281
+ "Reset Opinion Settings",
282
+ `Reset opinion model to default (${defaults.opinion.provider}/${defaults.opinion.modelId})?`,
283
+ );
284
+ if (confirmed) {
285
+ const existing = await loadSettings(ctx.cwd, ctx.isProjectTrusted());
286
+ const settings = existing ?? createDefaultSettings();
287
+ // Source the opinion model from createDefaultSettings() so the
288
+ // command stays in lock-step with the rest of the codebase.
289
+ settings.opinion = { ...defaults.opinion };
290
+ settings.lastUpdated = new Date().toISOString();
291
+ await saveSettings(settings, ctx.cwd, ctx.isProjectTrusted());
292
+ ctx.ui.notify(
293
+ `Opinion model reset to ${defaults.opinion.provider}/${defaults.opinion.modelId}`,
294
+ "info",
295
+ );
296
+ }
297
+ return;
298
+ }
299
+
300
+ // Default: open opinion settings UI
301
+ await openOpinionSettingsUI(ctx);
302
+ },
303
+ });
304
+ }
package/markdown.ts ADDED
@@ -0,0 +1,113 @@
1
+ import type { CouncilDecision } from "./types.js";
2
+
3
+ export function renderCouncilDecisionMarkdown(decision: CouncilDecision): string {
4
+ const lines: string[] = [];
5
+
6
+ lines.push("# Model Council Decision");
7
+ lines.push("");
8
+ lines.push("Generated by: model-council");
9
+ lines.push("");
10
+
11
+ // Add degraded mode warning if applicable
12
+ if (decision.metadata?.degraded) {
13
+ lines.push("> **Warning:** This council decision used degraded mode. Some model outputs failed, were repaired, or synthesis fell back.");
14
+ lines.push("");
15
+ }
16
+
17
+ lines.push(`**Mode:** ${decision.mode}`);
18
+ lines.push(`**Confidence:** ${decision.confidence}`);
19
+ lines.push(`**Decision ID:** ${decision.decisionId}`);
20
+ lines.push("");
21
+
22
+ // Add system warnings if any
23
+ if (decision.metadata?.warnings && decision.metadata.warnings.length > 0) {
24
+ lines.push("## System Warnings");
25
+ for (const warning of decision.metadata.warnings) {
26
+ lines.push(`- ${warning}`);
27
+ }
28
+ lines.push("");
29
+ }
30
+
31
+ lines.push("## Recommended Plan");
32
+ lines.push(decision.recommendedPlan.summary);
33
+ lines.push("");
34
+
35
+ if (decision.recommendedPlan.steps.length > 0) {
36
+ lines.push("## Steps");
37
+ for (let i = 0; i < decision.recommendedPlan.steps.length; i++) {
38
+ lines.push(`${i + 1}. ${decision.recommendedPlan.steps[i]}`);
39
+ }
40
+ lines.push("");
41
+ }
42
+
43
+ if (decision.consensus.agreements.length > 0) {
44
+ lines.push("## Agreements");
45
+ for (const agreement of decision.consensus.agreements) {
46
+ lines.push(`- ${agreement}`);
47
+ }
48
+ lines.push("");
49
+ }
50
+
51
+ if (decision.consensus.disagreements.length > 0) {
52
+ lines.push("## Disagreements");
53
+ for (const disagreement of decision.consensus.disagreements) {
54
+ lines.push(`- ${disagreement}`);
55
+ }
56
+ lines.push("");
57
+ }
58
+
59
+ if (decision.consensus.unknowns.length > 0) {
60
+ lines.push("## Unknowns");
61
+ for (const unknown of decision.consensus.unknowns) {
62
+ lines.push(`- ${unknown}`);
63
+ }
64
+ lines.push("");
65
+ }
66
+
67
+ if (decision.implementationGuidance.filesToEdit.length > 0) {
68
+ lines.push("## Files to Edit");
69
+ for (const file of decision.implementationGuidance.filesToEdit) {
70
+ lines.push(`- \`${file.path}\`: ${file.action} — ${file.reason}`);
71
+ }
72
+ lines.push("");
73
+ }
74
+
75
+ if (decision.implementationGuidance.testsToRun.length > 0) {
76
+ lines.push("## Verification / Tests to Run");
77
+ for (const test of decision.implementationGuidance.testsToRun) {
78
+ lines.push(`- ${test}`);
79
+ }
80
+ lines.push("");
81
+ }
82
+
83
+ if (decision.implementationGuidance.guardrails.length > 0) {
84
+ lines.push("## Guardrails");
85
+ for (const guardrail of decision.implementationGuidance.guardrails) {
86
+ lines.push(`- ${guardrail}`);
87
+ }
88
+ lines.push("");
89
+ }
90
+
91
+ if (decision.modelNotes.length > 0) {
92
+ lines.push("## Model Notes");
93
+ for (const note of decision.modelNotes) {
94
+ lines.push(`### ${note.model}`);
95
+ lines.push(`**Stance:** ${note.stance}`);
96
+ if (note.keyRisks.length > 0) {
97
+ lines.push(`**Key Risks:** ${note.keyRisks.join(", ")}`);
98
+ }
99
+ lines.push("");
100
+ }
101
+ }
102
+
103
+ lines.push("## Handoff Prompt");
104
+ lines.push(decision.handoffPrompt);
105
+
106
+ lines.push("");
107
+ lines.push("***");
108
+ lines.push("");
109
+
110
+ lines.push("This council decision is advisory. The main Pi coding model must still inspect the code, apply changes, and verify with tests.");
111
+
112
+ return lines.join("\n");
113
+ }
@@ -0,0 +1,244 @@
1
+ export async function callOpenRouterChat(args: {
2
+ apiKey: string;
3
+ model: string;
4
+ systemPrompt: string;
5
+ userPrompt: string;
6
+ signal?: AbortSignal;
7
+ maxTokens?: number;
8
+ temperature?: number;
9
+ structuredOutputSchema?: unknown;
10
+ structuredOutputName?: string;
11
+ }): Promise<string> {
12
+ const {
13
+ apiKey,
14
+ model,
15
+ systemPrompt,
16
+ userPrompt,
17
+ signal,
18
+ // 15k tokens for longer responses with file snippets
19
+ maxTokens = 15000,
20
+ temperature = 0.2,
21
+ structuredOutputSchema,
22
+ structuredOutputName,
23
+ } = args;
24
+
25
+ const requestBody: Record<string, unknown> = {
26
+ model,
27
+ messages: [
28
+ {
29
+ role: "system",
30
+ content: systemPrompt,
31
+ },
32
+ {
33
+ role: "user",
34
+ content: userPrompt,
35
+ },
36
+ ],
37
+ temperature,
38
+ max_tokens: maxTokens,
39
+ };
40
+
41
+ // Add structured output if schema is provided
42
+ if (structuredOutputSchema) {
43
+ requestBody.response_format = {
44
+ type: "json_schema",
45
+ json_schema: {
46
+ name: structuredOutputName ?? "structured_response",
47
+ strict: true,
48
+ schema: structuredOutputSchema,
49
+ },
50
+ };
51
+ }
52
+
53
+ const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
54
+ method: "POST",
55
+ headers: {
56
+ "Authorization": `Bearer ${apiKey}`,
57
+ "Content-Type": "application/json",
58
+ "HTTP-Referer": "https://pi.dev",
59
+ "X-OpenRouter-Title": "Pi Model Council",
60
+ },
61
+ body: JSON.stringify(requestBody),
62
+ signal,
63
+ });
64
+
65
+ if (!response.ok) {
66
+ const errorText = await response.text();
67
+ throw new Error(`OpenRouter API error (${response.status}): ${errorText}`);
68
+ }
69
+
70
+ const data = await response.json() as {
71
+ choices?: Array<{ message?: { content?: string | null } }>;
72
+ };
73
+
74
+ const content = data.choices?.[0]?.message?.content;
75
+
76
+ if (content === null || content === undefined) {
77
+ throw new Error("No content in response");
78
+ }
79
+
80
+ // Handle array of content parts
81
+ if (Array.isArray(content)) {
82
+ return content
83
+ .filter((part): part is { type: string; text?: string } =>
84
+ typeof part === "object" && part !== null && part.type === "text"
85
+ )
86
+ .map(part => part.text ?? "")
87
+ .join("");
88
+ }
89
+
90
+ return content;
91
+ }
92
+
93
+ export function extractJsonObject(text: string): unknown {
94
+ // First try direct parse
95
+ try {
96
+ return JSON.parse(text);
97
+ } catch {
98
+ // Continue to fallback methods
99
+ }
100
+
101
+ // Remove markdown code fences
102
+ const withoutFences = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
103
+ try {
104
+ return JSON.parse(withoutFences);
105
+ } catch {
106
+ // Continue to substring extraction
107
+ }
108
+
109
+ // Find the first top-level { and its matching closing brace via
110
+ // brace-balance scan. More robust than the naive "first { to last }"
111
+ // approach because it handles JSON strings that legitimately contain
112
+ // closing braces and tolerates truncation when the JSON object is cut
113
+ // off mid-stream (e.g. max_tokens hit before close).
114
+ const firstBrace = withoutFences.indexOf("{");
115
+ if (firstBrace === -1) {
116
+ throw new Error("No JSON object found in text");
117
+ }
118
+
119
+ const matchedEnd = findMatchingCloseBrace(withoutFences, firstBrace);
120
+ const substringEnd = matchedEnd ?? withoutFences.lastIndexOf("}");
121
+ if (substringEnd > firstBrace) {
122
+ const jsonSubstring = withoutFences.substring(firstBrace, substringEnd + 1);
123
+ try {
124
+ return JSON.parse(jsonSubstring);
125
+ } catch (err) {
126
+ // Last-ditch: try repairing common LLM JSON mistakes (trailing
127
+ // commas, single quotes, Python literals) and parsing again.
128
+ const repaired = repairCommonJsonMistakes(jsonSubstring);
129
+ try {
130
+ return JSON.parse(repaired);
131
+ } catch {
132
+ throw new Error(
133
+ `Failed to parse JSON from text. Extracted substring length: ${jsonSubstring.length}; ${err instanceof Error ? err.message : String(err)}`,
134
+ );
135
+ }
136
+ }
137
+ }
138
+
139
+ throw new Error("No JSON object found in text");
140
+ }
141
+
142
+ /**
143
+ * Walk forward from `openIdx` (the index of an opening brace) and return
144
+ * the index of its matching closing brace, ignoring braces that appear
145
+ * inside string literals. Returns `null` if no match is found (e.g. the
146
+ * JSON was truncated before the close brace).
147
+ */
148
+ function findMatchingCloseBrace(text: string, openIdx: number): number | null {
149
+ let depth = 0;
150
+ let inString = false;
151
+ let escape = false;
152
+ for (let i = openIdx; i < text.length; i++) {
153
+ const ch = text[i];
154
+ if (escape) { escape = false; continue; }
155
+ if (inString) {
156
+ if (ch === "\\") { escape = true; continue; }
157
+ if (ch === '"') { inString = false; }
158
+ continue;
159
+ }
160
+ if (ch === '"') { inString = true; continue; }
161
+ if (ch === "{") { depth++; continue; }
162
+ if (ch === "}") {
163
+ depth--;
164
+ if (depth === 0) return i;
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * Minimal repair pass for common LLM JSON mistakes. Conservative by
172
+ * design — only fixes things that are unambiguously safe so we don't
173
+ * silently corrupt valid JSON.
174
+ *
175
+ * - Replace `True` / `False` / `None` (Python literals) with their JSON
176
+ * equivalents.
177
+ * - Strip trailing commas before `}` or `]`.
178
+ *
179
+ * Single-quote strings are intentionally NOT rewritten because doing so
180
+ * safely requires understanding escape semantics inside the string.
181
+ */
182
+ function repairCommonJsonMistakes(text: string): string {
183
+ return text
184
+ .replace(/\bTrue\b/g, "true")
185
+ .replace(/\bFalse\b/g, "false")
186
+ .replace(/\bNone\b/g, "null")
187
+ .replace(/,(\s*[}\]])/g, "$1");
188
+ }
189
+
190
+ export function safeStringify(value: unknown): string {
191
+ try {
192
+ return JSON.stringify(value, null, 2);
193
+ } catch {
194
+ return String(value);
195
+ }
196
+ }
197
+
198
+ // --- OpenRouter discovery & validation ---
199
+
200
+ export async function fetchOpenRouterModels(apiKey: string): Promise<Array<{ id: string; name: string }>> {
201
+ const response = await fetch("https://openrouter.ai/api/v1/models", {
202
+ headers: { Authorization: `Bearer ${apiKey}` },
203
+ });
204
+
205
+ if (!response.ok) {
206
+ throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
207
+ }
208
+
209
+ const data = await response.json() as {
210
+ data: Array<{ id: string; name?: string; created?: number }>;
211
+ };
212
+
213
+ return data.data.map(m => ({
214
+ id: m.id,
215
+ name: m.name ?? m.id,
216
+ }));
217
+ }
218
+
219
+ export async function pingOpenRouter(apiKey: string): Promise<{
220
+ ok: boolean;
221
+ error?: string;
222
+ quota?: string;
223
+ }> {
224
+ try {
225
+ const response = await fetch("https://openrouter.ai/api/v1/models", {
226
+ headers: { Authorization: `Bearer ${apiKey}` },
227
+ });
228
+
229
+ if (!response.ok) {
230
+ if (response.status === 401) return { ok: false, error: "Invalid API key" };
231
+ if (response.status === 403) return { ok: false, error: "Forbidden — check API key permissions" };
232
+ if (response.status === 429) return { ok: false, error: "Rate limited — try again later" };
233
+ return { ok: false, error: `HTTP ${response.status}` };
234
+ }
235
+
236
+ // Parse quota from x-current-credits header
237
+ const quotaHeader = response.headers.get("x-current-credits");
238
+ const quota = quotaHeader ? `$${parseFloat(quotaHeader).toFixed(2)} remaining` : undefined;
239
+
240
+ return { ok: true, quota };
241
+ } catch (error) {
242
+ return { ok: false, error: error instanceof Error ? error.message : "Network error" };
243
+ }
244
+ }