@tritard/waterbrother 0.5.0

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/src/config.js ADDED
@@ -0,0 +1,216 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { normalizeAutonomyMode, normalizeExperienceMode } from "./modes.js";
5
+
6
+ const CONFIG_DIR = path.join(os.homedir(), ".waterbrother");
7
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
8
+ const PROJECT_CONFIG_DIRNAME = ".waterbrother";
9
+ const PROJECT_CONFIG_FILENAME = "config.json";
10
+ const DEFAULT_APPROVAL_POLICY = {
11
+ autoApprovePaths: ["src/**", "app/**", "lib/**", "tests/**", "test/**", "docs/**", "public/**"],
12
+ askPaths: ["package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock", "tsconfig.json", ".env*", "**/migrations/**", "**/.github/**", "vercel.json", "Dockerfile", "docker-compose*.yml"],
13
+ denyPaths: [".git/**"],
14
+ allowShellCommands: ["git status", "git diff", "git rev-parse", "git branch", "git log", "pwd", "ls", "rg", "cat", "npm test", "npm run test", "npm run lint", "npm run typecheck", "pnpm test", "pnpm lint", "pnpm typecheck", "yarn test", "yarn lint", "yarn typecheck", "bun test", "bun run lint", "bun run typecheck"],
15
+ askShellCommands: ["npm install", "pnpm install", "yarn install", "bun install"],
16
+ denyShellPatterns: ["(^|\\s)rm\\s+-rf\\s+(/|~|\\.)", "git\\s+reset\\s+--hard", "curl[^\\n|]*\\|\\s*(sh|bash)", "wget[^\\n|]*\\|\\s*(sh|bash)"]
17
+ };
18
+
19
+ function asStringArray(value) {
20
+ if (!Array.isArray(value)) return [];
21
+ return value.map((item) => String(item || '').trim()).filter(Boolean);
22
+ }
23
+
24
+ function normalizeApprovalPolicy(policy = {}) {
25
+ const source = policy && typeof policy === "object" ? policy : {};
26
+ return {
27
+ autoApprovePaths: asStringArray(source.autoApprovePaths !== undefined ? source.autoApprovePaths : DEFAULT_APPROVAL_POLICY.autoApprovePaths),
28
+ askPaths: asStringArray(source.askPaths !== undefined ? source.askPaths : DEFAULT_APPROVAL_POLICY.askPaths),
29
+ denyPaths: asStringArray(source.denyPaths !== undefined ? source.denyPaths : DEFAULT_APPROVAL_POLICY.denyPaths),
30
+ allowShellCommands: asStringArray(source.allowShellCommands !== undefined ? source.allowShellCommands : DEFAULT_APPROVAL_POLICY.allowShellCommands),
31
+ askShellCommands: asStringArray(source.askShellCommands !== undefined ? source.askShellCommands : DEFAULT_APPROVAL_POLICY.askShellCommands),
32
+ denyShellPatterns: asStringArray(source.denyShellPatterns !== undefined ? source.denyShellPatterns : DEFAULT_APPROVAL_POLICY.denyShellPatterns)
33
+ };
34
+ }
35
+
36
+ function normalizeVerification(value = {}) {
37
+ const source = value && typeof value === "object" ? value : {};
38
+ return {
39
+ commands: asStringArray(source.commands),
40
+ timeoutMs: Number.isFinite(Number(source.timeoutMs)) ? Math.max(1000, Math.floor(Number(source.timeoutMs))) : 180000
41
+ };
42
+ }
43
+
44
+ function normalizeReviewer(value = {}) {
45
+ const source = value && typeof value === "object" ? value : {};
46
+ const thresholdRaw = String(source.blockOn || "none").trim().toLowerCase();
47
+ return {
48
+ enabled: source.enabled !== undefined ? Boolean(source.enabled) : true,
49
+ model: String(source.model || "").trim(),
50
+ maxDiffChars: Number.isFinite(Number(source.maxDiffChars)) ? Math.max(2000, Math.floor(Number(source.maxDiffChars))) : 24000,
51
+ blockOn: ["none", "block", "caution"].includes(thresholdRaw) ? thresholdRaw : "none"
52
+ };
53
+ }
54
+
55
+ function normalizeTaskDefaults(value = {}) {
56
+ const source = value && typeof value === "object" ? value : {};
57
+ return {
58
+ branchPrefix: String(source.branchPrefix || "wb/").trim(),
59
+ autoSuggestContract: source.autoSuggestContract !== false,
60
+ requireDecisionBeforeBuild: source.requireDecisionBeforeBuild !== false
61
+ };
62
+ }
63
+
64
+ function normalizeImpact(value = {}) {
65
+ const source = value && typeof value === "object" ? value : {};
66
+ return {
67
+ enabled: source.enabled !== undefined ? Boolean(source.enabled) : true,
68
+ maxRelated: Number.isFinite(Number(source.maxRelated)) ? Math.max(4, Math.min(30, Math.floor(Number(source.maxRelated)))) : 10,
69
+ maxTests: Number.isFinite(Number(source.maxTests)) ? Math.max(2, Math.min(20, Math.floor(Number(source.maxTests)))) : 6
70
+ };
71
+ }
72
+
73
+ async function readJsonFile(filePath) {
74
+ try {
75
+ const raw = await fs.readFile(filePath, "utf8");
76
+ return JSON.parse(raw);
77
+ } catch (error) {
78
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
79
+ return {};
80
+ }
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ function mergeConfigLayers(userConfig = {}, projectConfig = {}) {
86
+ const mergedMcpServers = {
87
+ ...(userConfig.mcpServers && typeof userConfig.mcpServers === "object" ? userConfig.mcpServers : {}),
88
+ ...(projectConfig.mcpServers && typeof projectConfig.mcpServers === "object" ? projectConfig.mcpServers : {})
89
+ };
90
+
91
+ return {
92
+ ...userConfig,
93
+ ...projectConfig,
94
+ ...(Object.keys(mergedMcpServers).length > 0 ? { mcpServers: mergedMcpServers } : {})
95
+ };
96
+ }
97
+
98
+ export function getProjectConfigDir(cwd) {
99
+ return path.join(cwd, PROJECT_CONFIG_DIRNAME);
100
+ }
101
+
102
+ export function getProjectConfigPath(cwd) {
103
+ return path.join(getProjectConfigDir(cwd), PROJECT_CONFIG_FILENAME);
104
+ }
105
+
106
+ export async function ensureConfigDir(scope = "user", cwd = process.cwd()) {
107
+ if (scope === "project") {
108
+ await fs.mkdir(getProjectConfigDir(cwd), { recursive: true });
109
+ return;
110
+ }
111
+
112
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
113
+ }
114
+
115
+ export async function loadUserConfig() {
116
+ return readJsonFile(CONFIG_FILE);
117
+ }
118
+
119
+ export async function loadProjectConfig(cwd) {
120
+ if (!cwd) return {};
121
+ return readJsonFile(getProjectConfigPath(cwd));
122
+ }
123
+
124
+ export async function loadConfigLayers(cwd = process.cwd()) {
125
+ const [userConfig, projectConfig] = await Promise.all([loadUserConfig(), loadProjectConfig(cwd)]);
126
+ return {
127
+ userConfig,
128
+ projectConfig,
129
+ config: mergeConfigLayers(userConfig, projectConfig)
130
+ };
131
+ }
132
+
133
+ export async function loadConfig(cwd = process.cwd()) {
134
+ const layers = await loadConfigLayers(cwd);
135
+ return layers.config;
136
+ }
137
+
138
+ export async function saveConfig(config, { scope = "user", cwd = process.cwd() } = {}) {
139
+ const filePath = scope === "project" ? getProjectConfigPath(cwd) : CONFIG_FILE;
140
+ await ensureConfigDir(scope, cwd);
141
+ await fs.writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
142
+ }
143
+
144
+ export function resolveRuntimeConfig(config, overrides = {}) {
145
+ const baseUrl =
146
+ overrides.baseUrl ||
147
+ process.env.XAI_BASE_URL ||
148
+ process.env.GROK_BASE_URL ||
149
+ config.baseUrl ||
150
+ "https://api.x.ai/v1";
151
+
152
+ const apiKey =
153
+ overrides.apiKey ||
154
+ process.env.XAI_API_KEY ||
155
+ process.env.GROK_API_KEY ||
156
+ config.apiKey ||
157
+ "";
158
+
159
+ const model =
160
+ overrides.model ||
161
+ process.env.XAI_MODEL ||
162
+ process.env.GROK_MODEL ||
163
+ config.model ||
164
+ "grok-code-fast-1";
165
+
166
+ return {
167
+ baseUrl,
168
+ apiKey,
169
+ model,
170
+ approvalMode: overrides.approvalMode || config.approvalMode || "on-request",
171
+ traceMode: overrides.traceMode || config.traceMode || "on",
172
+ receiptMode: overrides.receiptMode || config.receiptMode || "auto",
173
+ autoCompactThreshold:
174
+ overrides.autoCompactThreshold !== undefined
175
+ ? overrides.autoCompactThreshold
176
+ : config.autoCompactThreshold !== undefined
177
+ ? config.autoCompactThreshold
178
+ : 0.9,
179
+ agentProfile: overrides.agentProfile || config.agentProfile || "coder",
180
+ designModel: overrides.designModel || config.designModel || "grok-4.20-multi-agent-beta-0309",
181
+ requireTurnContracts:
182
+ overrides.requireTurnContracts !== undefined
183
+ ? Boolean(overrides.requireTurnContracts)
184
+ : config.requireTurnContracts !== undefined
185
+ ? Boolean(config.requireTurnContracts)
186
+ : true,
187
+ approvalPolicy: normalizeApprovalPolicy(overrides.approvalPolicy !== undefined ? overrides.approvalPolicy : config.approvalPolicy),
188
+ verification: normalizeVerification(overrides.verification !== undefined ? overrides.verification : config.verification),
189
+ reviewer: normalizeReviewer(overrides.reviewer !== undefined ? overrides.reviewer : config.reviewer),
190
+ impact: normalizeImpact(overrides.impact !== undefined ? overrides.impact : config.impact),
191
+ experienceMode: normalizeExperienceMode(overrides.experienceMode !== undefined ? overrides.experienceMode : config.experienceMode || "standard"),
192
+ autonomyMode: normalizeAutonomyMode(overrides.autonomyMode !== undefined ? overrides.autonomyMode : config.autonomyMode || "scoped"),
193
+ panelEnabled:
194
+ overrides.panelEnabled !== undefined
195
+ ? Boolean(overrides.panelEnabled)
196
+ : config.panelEnabled !== undefined
197
+ ? Boolean(config.panelEnabled)
198
+ : true,
199
+ decisionModel: overrides.decisionModel || config.decisionModel || "",
200
+ taskDefaults: normalizeTaskDefaults(
201
+ overrides.taskDefaults !== undefined ? overrides.taskDefaults : config.taskDefaults
202
+ ),
203
+ profiles: config.profiles && typeof config.profiles === "object" ? config.profiles : {},
204
+ onboardingCompleted: Boolean(config.onboardingCompleted),
205
+ mcpServers:
206
+ overrides.mcpServers && typeof overrides.mcpServers === "object"
207
+ ? overrides.mcpServers
208
+ : config.mcpServers && typeof config.mcpServers === "object"
209
+ ? config.mcpServers
210
+ : {}
211
+ };
212
+ }
213
+
214
+ export function getConfigPath({ scope = "user", cwd = process.cwd() } = {}) {
215
+ return scope === "project" ? getProjectConfigPath(cwd) : CONFIG_FILE;
216
+ }
package/src/decider.js ADDED
@@ -0,0 +1,131 @@
1
+ import { createJsonCompletion } from "./grok-client.js";
2
+
3
+ const DECISION_SYSTEM_PROMPT = `You are a senior software architect helping a developer choose an implementation strategy.
4
+
5
+ You will be given a task goal and optionally some project context. Your job is to:
6
+ 1. Analyze the goal and propose 2-4 concrete implementation options
7
+ 2. Each option should have a clear scope (file paths, commands)
8
+ 3. Recommend one option with a rationale
9
+ 4. Flag open risks
10
+
11
+ Respond with ONLY a JSON object matching this schema:
12
+ {
13
+ "goal": "one-line restatement of the goal",
14
+ "options": [
15
+ {
16
+ "id": "minimal|strategic|exploratory|custom_id",
17
+ "title": "Short title",
18
+ "summary": "One paragraph explaining the approach",
19
+ "pros": ["..."],
20
+ "cons": ["..."],
21
+ "risk": "low|medium|high",
22
+ "fileCount": 4,
23
+ "scope": {
24
+ "paths": ["src/auth/**", "tests/auth/**"],
25
+ "commands": ["npm test -- auth"]
26
+ }
27
+ }
28
+ ],
29
+ "recommendation": "option_id",
30
+ "rationale": "Why this option fits best given current constraints",
31
+ "openRisks": ["risk description"]
32
+ }
33
+
34
+ Rules:
35
+ - Always include at least a "minimal" option
36
+ - Be concrete about file paths and commands — guess from the goal if needed
37
+ - Keep summaries factual, not promotional
38
+ - If you cannot determine scope, say so in openRisks
39
+ - Do not include markdown, code fences, or explanatory text outside the JSON`;
40
+
41
+ function buildDecisionPrompt({ goal, memory, taskName }) {
42
+ const parts = [`Goal: ${goal}`];
43
+ if (taskName) parts.push(`Task: ${taskName}`);
44
+ if (memory) parts.push(`Project context (WATERBROTHER.md):\n${memory}`);
45
+ parts.push("Propose implementation options as JSON.");
46
+ return parts.join("\n\n");
47
+ }
48
+
49
+ export function normalizeDecision(decision) {
50
+ if (!decision || typeof decision !== "object") return null;
51
+ const options = Array.isArray(decision.options) ? decision.options : [];
52
+ return {
53
+ goal: String(decision.goal || "").trim(),
54
+ options: options.map((opt) => ({
55
+ id: String(opt.id || "unknown").trim(),
56
+ title: String(opt.title || "").trim(),
57
+ summary: String(opt.summary || "").trim(),
58
+ pros: Array.isArray(opt.pros) ? opt.pros.map(String) : [],
59
+ cons: Array.isArray(opt.cons) ? opt.cons.map(String) : [],
60
+ risk: ["low", "medium", "high"].includes(opt.risk) ? opt.risk : "medium",
61
+ fileCount: Number.isFinite(Number(opt.fileCount)) ? Math.max(0, Math.floor(Number(opt.fileCount))) : null,
62
+ scope: opt.scope && typeof opt.scope === "object"
63
+ ? {
64
+ paths: Array.isArray(opt.scope.paths) ? opt.scope.paths.map(String) : [],
65
+ commands: Array.isArray(opt.scope.commands) ? opt.scope.commands.map(String) : []
66
+ }
67
+ : { paths: [], commands: [] }
68
+ })),
69
+ recommendation: String(decision.recommendation || "").trim(),
70
+ rationale: String(decision.rationale || "").trim(),
71
+ openRisks: Array.isArray(decision.openRisks) ? decision.openRisks.map(String) : []
72
+ };
73
+ }
74
+
75
+ export async function runDecisionPass({
76
+ apiKey,
77
+ baseUrl,
78
+ model,
79
+ goal,
80
+ taskName,
81
+ memory,
82
+ signal
83
+ }) {
84
+ if (!goal) throw new Error("goal is required for /decide");
85
+
86
+ const messages = [
87
+ { role: "system", content: DECISION_SYSTEM_PROMPT },
88
+ { role: "user", content: buildDecisionPrompt({ goal, memory, taskName }) }
89
+ ];
90
+
91
+ const completion = await createJsonCompletion({
92
+ apiKey,
93
+ baseUrl,
94
+ model,
95
+ messages,
96
+ temperature: 0.3,
97
+ signal
98
+ });
99
+
100
+ const decision = normalizeDecision(completion.json);
101
+ if (!decision || decision.options.length === 0) {
102
+ throw new Error("Decision pass returned no options");
103
+ }
104
+
105
+ return {
106
+ decision,
107
+ usage: completion.usage || null
108
+ };
109
+ }
110
+
111
+ export function formatDecisionForDisplay(decision) {
112
+ if (!decision) return "No decision available.";
113
+ const lines = [];
114
+ lines.push(`Goal: ${decision.goal}`);
115
+ lines.push("");
116
+ for (const opt of decision.options) {
117
+ const rec = opt.id === decision.recommendation ? " (recommended)" : "";
118
+ lines.push(`Option: ${opt.id} — ${opt.title}${rec}`);
119
+ lines.push(` ${opt.summary}`);
120
+ if (opt.fileCount != null) lines.push(` Files: ~${opt.fileCount}`);
121
+ lines.push(` Risk: ${opt.risk}`);
122
+ if (opt.pros.length > 0) lines.push(` Pros: ${opt.pros.join(", ")}`);
123
+ if (opt.cons.length > 0) lines.push(` Cons: ${opt.cons.join(", ")}`);
124
+ if (opt.scope.paths.length > 0) lines.push(` Scope: ${opt.scope.paths.join(", ")}`);
125
+ if (opt.scope.commands.length > 0) lines.push(` Commands: ${opt.scope.commands.join(", ")}`);
126
+ lines.push("");
127
+ }
128
+ if (decision.rationale) lines.push(`Rationale: ${decision.rationale}`);
129
+ if (decision.openRisks.length > 0) lines.push(`Open risks: ${decision.openRisks.join("; ")}`);
130
+ return lines.join("\n");
131
+ }
@@ -0,0 +1,268 @@
1
+ function trimTrailingSlash(url) {
2
+ return url.endsWith("/") ? url.slice(0, -1) : url;
3
+ }
4
+
5
+ function buildChatPayload({ model, messages, tools, temperature }) {
6
+ const payload = {
7
+ model,
8
+ messages,
9
+ temperature
10
+ };
11
+
12
+ if (tools && tools.length > 0) {
13
+ payload.tools = tools;
14
+ payload.tool_choice = "auto";
15
+ }
16
+
17
+ return payload;
18
+ }
19
+
20
+ function appendToolCallDelta(message, deltaToolCall) {
21
+ if (!deltaToolCall || typeof deltaToolCall !== "object") return;
22
+ const index = Number.isInteger(deltaToolCall.index) ? deltaToolCall.index : 0;
23
+ if (!Array.isArray(message.tool_calls)) message.tool_calls = [];
24
+ if (!message.tool_calls[index]) {
25
+ message.tool_calls[index] = {
26
+ id: deltaToolCall.id || null,
27
+ type: "function",
28
+ function: {
29
+ name: "",
30
+ arguments: ""
31
+ }
32
+ };
33
+ }
34
+
35
+ const target = message.tool_calls[index];
36
+ if (deltaToolCall.id) target.id = deltaToolCall.id;
37
+ if (deltaToolCall.type) target.type = deltaToolCall.type;
38
+ if (deltaToolCall.function?.name) target.function.name += deltaToolCall.function.name;
39
+ if (deltaToolCall.function?.arguments) target.function.arguments += deltaToolCall.function.arguments;
40
+ }
41
+
42
+ async function requestJson({ apiKey, baseUrl, endpoint, method = "GET", payload, signal }) {
43
+ if (!apiKey) {
44
+ throw new Error("Missing API key. Set XAI_API_KEY or run: waterbrother config set apiKey <key>");
45
+ }
46
+
47
+ const response = await fetch(`${trimTrailingSlash(baseUrl)}${endpoint}`, {
48
+ method,
49
+ headers: {
50
+ "content-type": "application/json",
51
+ authorization: `Bearer ${apiKey}`
52
+ },
53
+ body: payload ? JSON.stringify(payload) : undefined,
54
+ signal
55
+ });
56
+
57
+ const bodyText = await response.text();
58
+ let body;
59
+ try {
60
+ body = JSON.parse(bodyText);
61
+ } catch {
62
+ body = null;
63
+ }
64
+
65
+ if (!response.ok) {
66
+ const details = body ? JSON.stringify(body, null, 2) : bodyText;
67
+ throw new Error(`Grok API error (${response.status}): ${details}`);
68
+ }
69
+
70
+ return body;
71
+ }
72
+
73
+ export async function createChatCompletion({
74
+ apiKey,
75
+ baseUrl,
76
+ model,
77
+ messages,
78
+ tools,
79
+ temperature = 0.2,
80
+ signal
81
+ }) {
82
+ const payload = buildChatPayload({ model, messages, tools, temperature });
83
+
84
+ const body = await requestJson({
85
+ apiKey,
86
+ baseUrl,
87
+ endpoint: "/chat/completions",
88
+ method: "POST",
89
+ payload,
90
+ signal
91
+ });
92
+
93
+ const message = body?.choices?.[0]?.message;
94
+ if (!message) {
95
+ throw new Error(`Unexpected Grok API response: ${JSON.stringify(body)}`);
96
+ }
97
+
98
+ return {
99
+ id: body.id,
100
+ usage: body.usage,
101
+ message
102
+ };
103
+ }
104
+
105
+ export async function createChatCompletionStream({
106
+ apiKey,
107
+ baseUrl,
108
+ model,
109
+ messages,
110
+ tools,
111
+ temperature = 0.2,
112
+ signal,
113
+ onDelta
114
+ }) {
115
+ if (!apiKey) {
116
+ throw new Error("Missing API key. Set XAI_API_KEY or run: waterbrother config set apiKey <key>");
117
+ }
118
+
119
+ const payload = {
120
+ ...buildChatPayload({ model, messages, tools, temperature }),
121
+ stream: true
122
+ };
123
+
124
+ const response = await fetch(`${trimTrailingSlash(baseUrl)}/chat/completions`, {
125
+ method: "POST",
126
+ headers: {
127
+ "content-type": "application/json",
128
+ authorization: `Bearer ${apiKey}`
129
+ },
130
+ body: JSON.stringify(payload),
131
+ signal
132
+ });
133
+
134
+ if (!response.ok) {
135
+ const bodyText = await response.text();
136
+ throw new Error(`Grok API error (${response.status}): ${bodyText}`);
137
+ }
138
+ if (!response.body) {
139
+ throw new Error("Grok API stream error: response body missing");
140
+ }
141
+
142
+ const reader = response.body.getReader();
143
+ const decoder = new TextDecoder();
144
+ const message = { role: "assistant", content: "" };
145
+ let usage = null;
146
+ let id = null;
147
+ let buffer = "";
148
+
149
+ try {
150
+ while (true) {
151
+ const { done, value } = await reader.read();
152
+ if (done) break;
153
+ buffer += decoder.decode(value, { stream: true });
154
+
155
+ while (true) {
156
+ const splitAt = buffer.indexOf("\n\n");
157
+ if (splitAt === -1) break;
158
+ const eventBlock = buffer.slice(0, splitAt);
159
+ buffer = buffer.slice(splitAt + 2);
160
+
161
+ const lines = eventBlock
162
+ .split("\n")
163
+ .map((line) => line.trim())
164
+ .filter((line) => line.startsWith("data:"));
165
+
166
+ for (const line of lines) {
167
+ const data = line.slice(5).trim();
168
+ if (!data) continue;
169
+ if (data === "[DONE]") {
170
+ continue;
171
+ }
172
+
173
+ let parsed;
174
+ try {
175
+ parsed = JSON.parse(data);
176
+ } catch {
177
+ continue;
178
+ }
179
+
180
+ id = parsed.id || id;
181
+ usage = parsed.usage || usage;
182
+ const delta = parsed?.choices?.[0]?.delta;
183
+ if (!delta) continue;
184
+
185
+ if (typeof delta.content === "string" && delta.content.length > 0) {
186
+ message.content += delta.content;
187
+ if (onDelta) onDelta(delta.content);
188
+ }
189
+
190
+ if (Array.isArray(delta.tool_calls)) {
191
+ for (const deltaToolCall of delta.tool_calls) {
192
+ appendToolCallDelta(message, deltaToolCall);
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+ } finally {
199
+ reader.releaseLock();
200
+ }
201
+
202
+ return {
203
+ id,
204
+ usage,
205
+ message
206
+ };
207
+ }
208
+
209
+ function extractJson(text) {
210
+ const raw = String(text || "").trim();
211
+ // Try raw parse first
212
+ try { return JSON.parse(raw); } catch {}
213
+ // Try markdown-fenced JSON
214
+ const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
215
+ if (fenced) {
216
+ try { return JSON.parse(fenced[1].trim()); } catch {}
217
+ }
218
+ // Try first { ... } block
219
+ const braceStart = raw.indexOf("{");
220
+ const braceEnd = raw.lastIndexOf("}");
221
+ if (braceStart !== -1 && braceEnd > braceStart) {
222
+ try { return JSON.parse(raw.slice(braceStart, braceEnd + 1)); } catch {}
223
+ }
224
+ return null;
225
+ }
226
+
227
+ export async function createJsonCompletion({
228
+ apiKey,
229
+ baseUrl,
230
+ model,
231
+ messages,
232
+ temperature = 0.2,
233
+ signal
234
+ }) {
235
+ const completion = await createChatCompletion({
236
+ apiKey,
237
+ baseUrl,
238
+ model,
239
+ messages,
240
+ temperature,
241
+ signal
242
+ });
243
+
244
+ const content = String(completion?.message?.content || "").trim();
245
+ const parsed = extractJson(content);
246
+ if (!parsed) {
247
+ const error = new Error(`Failed to parse JSON from model response`);
248
+ error.rawContent = content;
249
+ throw error;
250
+ }
251
+ return { ...completion, json: parsed };
252
+ }
253
+
254
+ export async function listModels({ apiKey, baseUrl }) {
255
+ const body = await requestJson({
256
+ apiKey,
257
+ baseUrl,
258
+ endpoint: "/models",
259
+ method: "GET"
260
+ });
261
+
262
+ const models = Array.isArray(body?.data) ? body.data : [];
263
+ return models.map((item) => ({
264
+ id: item.id,
265
+ created: item.created,
266
+ owned_by: item.owned_by
267
+ }));
268
+ }