@bramburn/pi-model-council 1.6.2 → 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/CHANGELOG.md +342 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +128 -0
- package/DISCLAIMER.md +62 -0
- package/LICENSE +21 -0
- package/README.md +460 -0
- package/SECURITY.md +80 -0
- package/SUPPORT.md +72 -0
- package/commandParser.ts +333 -0
- package/councilRunner.ts +499 -0
- package/index.ts +304 -0
- package/markdown.ts +113 -0
- package/openrouterClient.ts +244 -0
- package/package.json +76 -1
- package/prompts.ts +299 -0
- package/retry.ts +92 -0
- package/runnerHelpers.ts +130 -0
- package/schemas.ts +44 -0
- package/searchSelector.ts +313 -0
- package/secondOpinionRunner.ts +203 -0
- package/settings-ui.ts +488 -0
- package/settings.ts +135 -0
- package/structuredOutput.ts +500 -0
- package/types.ts +169 -0
- package/index.js +0 -1
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
|
+
}
|