@mono-agent/agent-runtime 0.1.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.
Files changed (60) hide show
  1. package/ARCHITECTURE.md +219 -0
  2. package/LICENSE +674 -0
  3. package/README.md +430 -0
  4. package/package.json +46 -0
  5. package/src/agent/allowlists.js +49 -0
  6. package/src/agent/approval.js +211 -0
  7. package/src/agent/compaction.js +752 -0
  8. package/src/agent/index.js +40 -0
  9. package/src/agent/prompt/skill-index.js +66 -0
  10. package/src/agent/tool-bloat.js +164 -0
  11. package/src/agent/tools/bash.js +156 -0
  12. package/src/agent/tools/edit.js +15 -0
  13. package/src/agent/tools/glob.js +71 -0
  14. package/src/agent/tools/grep.js +84 -0
  15. package/src/agent/tools/index.js +17 -0
  16. package/src/agent/tools/pi-bridge.js +638 -0
  17. package/src/agent/tools/read.js +39 -0
  18. package/src/agent/tools/shared/constants.js +21 -0
  19. package/src/agent/tools/shared/dedup.js +31 -0
  20. package/src/agent/tools/shared/output-truncation.js +54 -0
  21. package/src/agent/tools/shared/path-resolver.js +156 -0
  22. package/src/agent/tools/shared/ripgrep.js +130 -0
  23. package/src/agent/tools/shared/runtime-context.js +69 -0
  24. package/src/agent/tools/web-fetch.js +59 -0
  25. package/src/agent/tools/web-search.js +21 -0
  26. package/src/agent/tools/write.js +14 -0
  27. package/src/agent/transcript.js +227 -0
  28. package/src/ai/backend.js +17 -0
  29. package/src/ai/cost.js +164 -0
  30. package/src/ai/failure.js +165 -0
  31. package/src/ai/file-change-stats.js +234 -0
  32. package/src/ai/index.js +16 -0
  33. package/src/ai/live-input-prompt.js +15 -0
  34. package/src/ai/observer.js +233 -0
  35. package/src/ai/providers/claude-cli.js +694 -0
  36. package/src/ai/providers/claude-sdk.js +864 -0
  37. package/src/ai/providers/claude-subagents.js +67 -0
  38. package/src/ai/providers/codex-app.js +1045 -0
  39. package/src/ai/providers/opencode-app.js +356 -0
  40. package/src/ai/providers/opencode-discovery.js +39 -0
  41. package/src/ai/providers/pi-events.js +62 -0
  42. package/src/ai/providers/pi-messages.js +68 -0
  43. package/src/ai/providers/pi-models.js +111 -0
  44. package/src/ai/providers/pi-sdk.js +1310 -0
  45. package/src/ai/registry.js +5 -0
  46. package/src/ai/runtime/capabilities-used.js +56 -0
  47. package/src/ai/runtime/capabilities.js +44 -0
  48. package/src/ai/runtime/context-windows.js +38 -0
  49. package/src/ai/runtime/fast-mode.js +8 -0
  50. package/src/ai/runtime/model-refs.js +144 -0
  51. package/src/ai/runtime/registry.js +57 -0
  52. package/src/ai/runtime/router.js +214 -0
  53. package/src/ai/runtime/sessions.js +126 -0
  54. package/src/ai/streaming/codex-events.js +139 -0
  55. package/src/ai/streaming/opencode-events.js +54 -0
  56. package/src/ai/types.js +70 -0
  57. package/src/index.js +23 -0
  58. package/src/pi-auth.js +80 -0
  59. package/src/runtime-brand.js +32 -0
  60. package/src/runtime.js +104 -0
@@ -0,0 +1,356 @@
1
+ import { createOpencode } from "@opencode-ai/sdk";
2
+ import { estimateCost } from "../cost.js";
3
+ import { buildCapabilitiesUsed } from "../runtime/capabilities-used.js";
4
+ import {
5
+ toolUseEvent,
6
+ toolResultEvent,
7
+ thinkingEvent,
8
+ assistantTextEvent,
9
+ toolPartSettled,
10
+ } from "../streaming/opencode-events.js";
11
+
12
+ // OpenCode agent backend (sdk='opencode', execution_mode='cli'). Drives the local
13
+ // `opencode` server via @opencode-ai/sdk and resolves provider credentials from
14
+ // OpenCode's own auth.json (Copilot / ChatGPT / Zen / 75+ providers). Structurally
15
+ // modeled on codex-app.js, but over the SDK's HTTP + event-stream surface.
16
+ //
17
+ // Capability notes (verified against @opencode-ai/sdk 1.15.x):
18
+ // - `session.prompt` blocks until the turn is done and returns the final message.
19
+ // - The published SDK has NO structured-output (`format`) field, so we do not
20
+ // enforce a host-specific result schema here; the system prompt asks for
21
+ // JSON and the host can recover it (same as codex-app).
22
+ // - No mid-turn steering primitive and no native-subagent injection in this SDK
23
+ // revision, so supports_live_input / supports_native_subagents are false.
24
+ const OPENCODE_APP_CAPABILITIES = {
25
+ kind: "opencode-app",
26
+ runtime: "app-server",
27
+ streaming: true,
28
+ structured_output: false,
29
+ supports_session_resume: true,
30
+ native_runtime_config: null,
31
+ supports_mcp: true,
32
+ supports_skills: false,
33
+ supports_builtin_tools: true,
34
+ supports_live_input: false,
35
+ supports_native_subagents: false,
36
+ supports_fast_mode: false,
37
+ };
38
+
39
+ // How long to keep draining the event stream after session.prompt resolves, in
40
+ // case the terminal session.idle event lands just after the HTTP response.
41
+ const POST_PROMPT_DRAIN_MS = 1500;
42
+
43
+ function delay(ms) {
44
+ return new Promise((resolve) => {
45
+ const timer = setTimeout(resolve, Math.max(0, Number(ms) || 0));
46
+ timer.unref?.();
47
+ });
48
+ }
49
+
50
+ function num(value) {
51
+ return Number.isFinite(value) ? value : null;
52
+ }
53
+
54
+ function promptFromMessages(messages) {
55
+ return Array.isArray(messages)
56
+ ? messages
57
+ .map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)))
58
+ .join("\n\n")
59
+ : String(messages || "");
60
+ }
61
+
62
+ // hey-api RequestResult resolves to { data, error }. Surface errors as throws so
63
+ // the caller's try/catch maps them to a failure kind.
64
+ function unwrap(result) {
65
+ if (result && typeof result === "object" && ("data" in result || "error" in result)) {
66
+ if (result.error) {
67
+ const message = typeof result.error === "string"
68
+ ? result.error
69
+ : (result.error?.message || JSON.stringify(result.error));
70
+ throw Object.assign(new Error(message), { opencodeError: result.error });
71
+ }
72
+ return result.data;
73
+ }
74
+ return result;
75
+ }
76
+
77
+ export function opencodeMcpConfig(mcpServers = {}) {
78
+ const out = {};
79
+ for (const [name, cfg] of Object.entries(mcpServers || {})) {
80
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) continue;
81
+ if (cfg?.command) {
82
+ out[name] = {
83
+ type: "local",
84
+ command: [cfg.command, ...(Array.isArray(cfg.args) ? cfg.args : [])],
85
+ ...(cfg.env && typeof cfg.env === "object" ? { environment: cfg.env } : {}),
86
+ enabled: true,
87
+ };
88
+ } else if (cfg?.url) {
89
+ out[name] = {
90
+ type: "remote",
91
+ url: cfg.url,
92
+ ...(cfg.headers && typeof cfg.headers === "object" ? { headers: cfg.headers } : {}),
93
+ enabled: true,
94
+ };
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function usageFromInfo(info) {
101
+ const tokens = info?.tokens || {};
102
+ const cache = tokens.cache || {};
103
+ return {
104
+ input_tokens: num(tokens.input),
105
+ output_tokens: num(tokens.output),
106
+ cache_read_tokens: num(cache.read),
107
+ cache_creation_tokens: num(cache.write),
108
+ };
109
+ }
110
+
111
+ function finalTextFromParts(parts) {
112
+ const text = (Array.isArray(parts) ? parts : [])
113
+ .filter((p) => p?.type === "text")
114
+ .map((p) => p.text || "")
115
+ .join("")
116
+ .trim();
117
+ return text || null;
118
+ }
119
+
120
+ export function mapErrorFailureKind(error) {
121
+ const name = error?.name || error?.data?.name || "";
122
+ if (name === "MessageAbortedError") return "cancelled";
123
+ if (name === "MessageOutputLengthError") return "usage_limit";
124
+ return "provider_unavailable";
125
+ }
126
+
127
+ export function mapSpawnFailureKind(err) {
128
+ const text = `${err?.code || ""} ${err?.message || ""}`.toLowerCase();
129
+ if (/enoent|command not found|spawn/.test(text)) return "spawn";
130
+ return "provider_unavailable";
131
+ }
132
+
133
+ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
134
+ const start = Date.now();
135
+ const resolved = options.model?.sdk
136
+ ? options.model
137
+ : { sdk: "opencode", provider: "", model: String(options.model || "") };
138
+ const providerID = resolved.provider;
139
+ const modelID = resolved.model;
140
+ const reference = resolved.reference || `opencode:${providerID}:${modelID}`;
141
+
142
+ const events = [];
143
+ const emit = (event) => {
144
+ if (!event) return;
145
+ events.push(event);
146
+ try { options.onEvent?.(event); } catch { /* listener errors must not abort the run */ }
147
+ };
148
+
149
+ const mcp = opencodeMcpConfig(options.mcpServers);
150
+ let sessionId = (typeof options.providerSessionId === "string" && options.providerSessionId)
151
+ || (typeof options.sessionId === "string" && options.sessionId)
152
+ || null;
153
+
154
+ let client = null;
155
+ let server = null;
156
+ let usage = null;
157
+ let errorMessage = null;
158
+ let failureKind = null;
159
+ let finalText = null;
160
+ const seenToolUse = new Set();
161
+
162
+ const abortHandler = () => {
163
+ try { if (sessionId) client?.session?.abort?.({ path: { id: sessionId } }); } catch { /* best effort */ }
164
+ };
165
+
166
+ try {
167
+ const opencode = await createOpencode({
168
+ ...(Object.keys(mcp).length ? { config: { mcp } } : { config: {} }),
169
+ ...(options.abortSignal ? { signal: options.abortSignal } : {}),
170
+ });
171
+ client = opencode.client;
172
+ server = opencode.server;
173
+ options.abortSignal?.addEventListener?.("abort", abortHandler, { once: true });
174
+
175
+ if (!sessionId) {
176
+ const created = unwrap(await client.session.create({ body: {} }));
177
+ sessionId = created?.id;
178
+ if (!sessionId) throw new Error("opencode did not return a session id");
179
+ }
180
+
181
+ let pumpDone = false;
182
+
183
+ const respondToPermission = async (perm) => {
184
+ if (perm.sessionID && perm.sessionID !== sessionId) return;
185
+ let decision = "once";
186
+ if (typeof options.onToolApprovalRequest === "function") {
187
+ try {
188
+ const verdict = await options.onToolApprovalRequest({
189
+ id: perm.id,
190
+ tool: perm.type,
191
+ title: perm.title,
192
+ input: perm.metadata,
193
+ riskTier: options.approvalDefaultRiskTier,
194
+ });
195
+ if (verdict === false || verdict?.approved === false) decision = "reject";
196
+ else if (verdict?.always) decision = "always";
197
+ else decision = "once";
198
+ } catch {
199
+ decision = "reject";
200
+ }
201
+ }
202
+ try {
203
+ await client.postSessionIdPermissionsPermissionId({
204
+ path: { id: sessionId, permissionID: perm.id },
205
+ body: { response: decision },
206
+ });
207
+ } catch { /* the turn will surface the denial on its own */ }
208
+ };
209
+
210
+ const handleEvent = async (event) => {
211
+ if (!event || typeof event !== "object") return;
212
+ const props = event.properties || {};
213
+ switch (event.type) {
214
+ case "message.part.updated": {
215
+ const part = props.part;
216
+ if (!part || (part.sessionID && part.sessionID !== sessionId)) return;
217
+ if (part.type === "tool") {
218
+ if (!seenToolUse.has(part.callID)) {
219
+ seenToolUse.add(part.callID);
220
+ emit(toolUseEvent(part));
221
+ }
222
+ if (toolPartSettled(part)) emit(toolResultEvent(part));
223
+ } else if (part.type === "reasoning" && part.text) {
224
+ emit(thinkingEvent(part));
225
+ }
226
+ return;
227
+ }
228
+ case "message.updated": {
229
+ const info = props.info;
230
+ if (info?.role === "assistant" && info?.tokens) usage = usageFromInfo(info);
231
+ return;
232
+ }
233
+ case "permission.updated":
234
+ await respondToPermission(props);
235
+ return;
236
+ case "session.error":
237
+ if (props.sessionID && props.sessionID !== sessionId) return;
238
+ errorMessage = props.error?.message || "opencode session error";
239
+ failureKind = mapErrorFailureKind(props.error);
240
+ pumpDone = true;
241
+ return;
242
+ case "session.idle":
243
+ if (props.sessionID === sessionId) pumpDone = true;
244
+ return;
245
+ default:
246
+ return;
247
+ }
248
+ };
249
+
250
+ const subscription = await client.event.subscribe();
251
+ const pump = (async () => {
252
+ try {
253
+ for await (const event of subscription.stream) {
254
+ await handleEvent(event);
255
+ if (pumpDone) break;
256
+ }
257
+ } catch { /* stream closed; teardown handles the rest */ }
258
+ })();
259
+
260
+ const promptResult = unwrap(await client.session.prompt({
261
+ path: { id: sessionId },
262
+ body: {
263
+ model: { providerID, modelID },
264
+ system: systemPrompt,
265
+ parts: [{ type: "text", text: promptFromMessages(options.messages) }],
266
+ },
267
+ }));
268
+
269
+ // Let the pump drain to the terminal session.idle (which lands around when
270
+ // prompt resolves); don't force-stop it or in-flight tool events are lost.
271
+ await Promise.race([pump, delay(POST_PROMPT_DRAIN_MS)]);
272
+
273
+ const info = promptResult?.info || {};
274
+ if (!usage) usage = usageFromInfo(info);
275
+ if (info.error && !errorMessage) {
276
+ errorMessage = info.error?.message || "opencode turn failed";
277
+ failureKind = mapErrorFailureKind(info.error);
278
+ }
279
+ finalText = finalTextFromParts(promptResult?.parts);
280
+ if (finalText) emit(assistantTextEvent(finalText));
281
+
282
+ const reportedCost = num(info.cost);
283
+ const costUsd = reportedCost !== null ? reportedCost : estimateCost({
284
+ resolveCustomPricing: options.resolveCustomPricing,
285
+ model: reference,
286
+ inputTokens: Math.max(0, (usage?.input_tokens || 0) - (usage?.cache_read_tokens || 0)),
287
+ outputTokens: usage?.output_tokens || 0,
288
+ cachedTokens: usage?.cache_read_tokens || 0,
289
+ cacheWriteTokens: usage?.cache_creation_tokens || 0,
290
+ });
291
+
292
+ return {
293
+ text: finalText,
294
+ structuredResult: undefined,
295
+ structuredResultSource: null,
296
+ events,
297
+ usage: { ...(usage || {}), cost_usd: costUsd },
298
+ durationMs: Date.now() - start,
299
+ numTurns: 1,
300
+ model: reference,
301
+ effort: options.effort || null,
302
+ sdk: "opencode",
303
+ providerSessionId: sessionId || null,
304
+ provider_session_id: sessionId || null,
305
+ cancelled: !!options.abortSignal?.aborted,
306
+ error: errorMessage,
307
+ failureKind,
308
+ diagnostics: {},
309
+ capabilitiesUsed: buildCapabilitiesUsed({
310
+ promptCacheActive: (usage?.cache_read_tokens || 0) > 0 || (usage?.cache_creation_tokens || 0) > 0,
311
+ thinkingEnabled: null,
312
+ structuredOutputEnforced: false,
313
+ subagentInvoked: null,
314
+ mcpServersUsed: Object.keys(options.mcpServers || {}),
315
+ nativeSubagentsUsed: [],
316
+ toolCompactionApplied: false,
317
+ contextCompactionApplied: null,
318
+ }),
319
+ };
320
+ } catch (err) {
321
+ return {
322
+ text: finalText,
323
+ structuredResult: undefined,
324
+ structuredResultSource: null,
325
+ events,
326
+ usage: usage ? { ...usage, cost_usd: 0 } : null,
327
+ durationMs: Date.now() - start,
328
+ numTurns: events.length ? 1 : 0,
329
+ model: reference,
330
+ effort: options.effort || null,
331
+ sdk: "opencode",
332
+ providerSessionId: sessionId || null,
333
+ provider_session_id: sessionId || null,
334
+ cancelled: !!options.abortSignal?.aborted,
335
+ error: err?.message || String(err),
336
+ failureKind: failureKind
337
+ || (err?.opencodeError ? mapErrorFailureKind(err.opencodeError) : mapSpawnFailureKind(err)),
338
+ diagnostics: { ...(events.length ? { had_partial_progress: true } : {}) },
339
+ capabilitiesUsed: buildCapabilitiesUsed({
340
+ structuredOutputEnforced: false,
341
+ mcpServersUsed: Object.keys(options.mcpServers || {}),
342
+ }),
343
+ };
344
+ } finally {
345
+ options.abortSignal?.removeEventListener?.("abort", abortHandler);
346
+ try { server?.close?.(); } catch { /* best effort */ }
347
+ }
348
+ }
349
+
350
+ export const opencodeAppRuntimeBridge = {
351
+ id: "opencode-app",
352
+ kind: "opencode-app",
353
+ capabilities: OPENCODE_APP_CAPABILITIES,
354
+ supports: (ref, options) => ref?.sdk === "opencode" && options?.executionMode === "cli",
355
+ execute: generateOpencodeAppResponse,
356
+ };
@@ -0,0 +1,39 @@
1
+ import { createOpencode } from "@opencode-ai/sdk";
2
+
3
+ // Enumerate the providers/models OpenCode is configured for (auth.json + opencode.json
4
+ // + models.dev). Boots a transient `opencode` server, so callers should cache the
5
+ // result rather than call this per request. Returns a normalized provider/model list.
6
+ //
7
+ // config.providers() → { data: { providers: Provider[], default } }, where each
8
+ // Provider has { id, name, source, models: { [id]: Model } } and each Model exposes
9
+ // capabilities { reasoning, toolcall, input.image }, limit.context, and status.
10
+ export async function discoverOpencodeProviders({ createServer = createOpencode } = {}) {
11
+ const opencode = await createServer({ config: {} });
12
+ const { client, server } = opencode;
13
+ try {
14
+ const result = await client.config.providers();
15
+ if (result?.error) {
16
+ const message = typeof result.error === "string"
17
+ ? result.error
18
+ : (result.error?.message || JSON.stringify(result.error));
19
+ throw new Error(message);
20
+ }
21
+ const providers = result?.data?.providers || result?.providers || [];
22
+ return providers.map((provider) => ({
23
+ providerID: provider.id,
24
+ name: provider.name || provider.id,
25
+ source: provider.source || null,
26
+ models: Object.values(provider.models || {}).map((model) => ({
27
+ id: model.id,
28
+ name: model.name || model.id,
29
+ reasoning: !!model.capabilities?.reasoning,
30
+ toolCall: !!model.capabilities?.toolcall,
31
+ vision: !!model.capabilities?.input?.image,
32
+ contextWindow: Number(model.limit?.context) || null,
33
+ status: model.status || null,
34
+ })),
35
+ }));
36
+ } finally {
37
+ try { server?.close?.(); } catch { /* best effort */ }
38
+ }
39
+ }
@@ -0,0 +1,62 @@
1
+ import { normalizePiBuiltinToolParams } from "../../agent/tools/pi-bridge.js";
2
+
3
+ export function streamContentKey(streamEvent, fallback) {
4
+ return streamEvent?.contentIndex ?? fallback;
5
+ }
6
+
7
+ export function jsonSerializable(value, fallback = null) {
8
+ try {
9
+ JSON.stringify(value);
10
+ return value;
11
+ } catch {
12
+ return fallback;
13
+ }
14
+ }
15
+
16
+ function compactJsonPreview(value, { limit = 4000 } = {}) {
17
+ let raw;
18
+ try {
19
+ raw = JSON.stringify(value || {});
20
+ } catch {
21
+ raw = String(value ?? "");
22
+ }
23
+ if (raw.length <= limit) return { value, truncated: false, originalLength: raw.length };
24
+ return {
25
+ value: {
26
+ truncated: true,
27
+ original_length: raw.length,
28
+ preview: `${raw.slice(0, limit)}\n[truncated raw tool result]`,
29
+ },
30
+ truncated: true,
31
+ originalLength: raw.length,
32
+ };
33
+ }
34
+
35
+ export function compactToolRawResult(result, resultContent) {
36
+ const raw = compactJsonPreview(result);
37
+ const details = compactJsonPreview(result?.details || {});
38
+ return {
39
+ ...(raw.truncated ? {
40
+ truncated: true,
41
+ original_length: raw.originalLength,
42
+ preview: raw.value.preview,
43
+ } : {}),
44
+ content: {
45
+ omitted: true,
46
+ reason: "already represented by tool_result.content",
47
+ original_length: String(resultContent || "").length,
48
+ },
49
+ details: details.value,
50
+ ...(details.truncated ? { details_truncated: true } : {}),
51
+ };
52
+ }
53
+
54
+ export function eventToolArgs(toolName, args, { cwd, toolLimits } = {}) {
55
+ return normalizePiBuiltinToolParams(toolName, args || {}, { cwd, toolLimits });
56
+ }
57
+
58
+ export function emitCaptured(events, onEvent, event) {
59
+ if (!event) return;
60
+ events.push(event);
61
+ onEvent?.(event);
62
+ }
@@ -0,0 +1,68 @@
1
+ import { EMPTY_USAGE } from "./pi-models.js";
2
+
3
+ export function promptTextFromMessages(messages) {
4
+ if (!Array.isArray(messages) || !messages.length) return "";
5
+ return messages
6
+ .filter((message) => message?.role === "user")
7
+ .map((message) => typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""))
8
+ .join("\n");
9
+ }
10
+
11
+ function messageContent(value) {
12
+ if (typeof value === "string") return value;
13
+ if (Array.isArray(value)) {
14
+ return value.map((part) => {
15
+ if (typeof part === "string") return { type: "text", text: part };
16
+ if (part?.type === "text" && typeof part.text === "string") return { type: "text", text: part.text };
17
+ if (part?.type === "image" && part.data) return { type: "image", data: part.data, mimeType: part.mimeType || part.mime_type || "image/png" };
18
+ return { type: "text", text: JSON.stringify(part ?? "") };
19
+ });
20
+ }
21
+ return String(value ?? "");
22
+ }
23
+
24
+ export function toAgentMessages(messages, model) {
25
+ const source = Array.isArray(messages) && messages.length
26
+ ? messages
27
+ : [{ role: "user", content: "" }];
28
+ return source.flatMap((message) => {
29
+ const timestamp = message.timestamp || Date.now();
30
+ if (message.role === "user") return [{ role: "user", content: messageContent(message.content), timestamp }];
31
+ if (message.role === "assistant") {
32
+ return [{
33
+ role: "assistant",
34
+ content: [{ type: "text", text: typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? "") }],
35
+ api: model.api,
36
+ provider: model.provider,
37
+ model: model.id,
38
+ usage: EMPTY_USAGE,
39
+ stopReason: "stop",
40
+ timestamp,
41
+ }];
42
+ }
43
+ if (message.role === "toolResult") return [message];
44
+ return [];
45
+ });
46
+ }
47
+
48
+ export function textFromContent(content) {
49
+ if (!Array.isArray(content)) return "";
50
+ return content
51
+ .filter((block) => block?.type === "text" && typeof block.text === "string")
52
+ .map((block) => block.text)
53
+ .join("");
54
+ }
55
+
56
+ export function thinkingFromContent(content) {
57
+ if (!Array.isArray(content)) return "";
58
+ return content
59
+ .filter((block) => block?.type === "thinking" && typeof block.thinking === "string")
60
+ .map((block) => block.thinking)
61
+ .join("");
62
+ }
63
+
64
+ export function toolResultContent(result) {
65
+ const content = result?.content;
66
+ if (!Array.isArray(content)) return "";
67
+ return content.map((block) => block?.type === "text" ? block.text || "" : JSON.stringify(block)).filter(Boolean).join("\n");
68
+ }
@@ -0,0 +1,111 @@
1
+ import { getModel as getPiModel } from "@earendil-works/pi-ai";
2
+ import { readRuntimeBrand } from "../../agent/tools/shared/runtime-context.js";
3
+
4
+ export const EMPTY_USAGE = {
5
+ input: 0,
6
+ output: 0,
7
+ cacheRead: 0,
8
+ cacheWrite: 0,
9
+ totalTokens: 0,
10
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
11
+ };
12
+
13
+ function rootUrl(baseUrl) {
14
+ return String(baseUrl || "").replace(/\/+$/, "").replace(/\/(api|v1)$/, "");
15
+ }
16
+
17
+ function openAiCompatBaseUrl(provider) {
18
+ const baseUrl = String(provider?.base_url || "").replace(/\/+$/, "");
19
+ if (provider?.provider_type === "ollama") return `${rootUrl(baseUrl)}/v1`;
20
+ return /\/v\d+$/.test(baseUrl) ? baseUrl : `${baseUrl}/v1`;
21
+ }
22
+
23
+ function customProviderName(provider) {
24
+ return `${readRuntimeBrand().providerModelPrefix}-${provider.id}`;
25
+ }
26
+
27
+ function customProviderKey(provider, isPrivate) {
28
+ if (provider?.api_key) return provider.api_key;
29
+ return isPrivate ? "ollama" : "";
30
+ }
31
+
32
+ function customCompat(capabilities, isPrivate) {
33
+ return {
34
+ supportsStore: false,
35
+ supportsDeveloperRole: !isPrivate,
36
+ supportsReasoningEffort: capabilities?.reasoning_mode === "effort",
37
+ maxTokensField: "max_tokens",
38
+ };
39
+ }
40
+
41
+ // Build the pi-runtime view of a custom provider/model from
42
+ // pre-resolved primitives. The caller (core/ai.js#generateResponse) reads
43
+ // the provider/model rows and computes the capabilities + isPrivate flag
44
+ // before invoking the provider, so this function never reaches into the
45
+ // domain layer.
46
+ function resolveCustomPiModel(resolved, options) {
47
+ const provider = options.customProvider;
48
+ if (!provider) {
49
+ throw new Error(
50
+ `pi custom provider context missing for ${resolved.provider}: caller must pass options.customProvider`,
51
+ );
52
+ }
53
+ if (!provider.enabled) throw new Error(`provider disabled: ${resolved.provider}`);
54
+ const modelRow = options.customModel || null;
55
+ if (modelRow && modelRow.enabled === false) {
56
+ throw new Error(`model disabled: ${resolved.model}`);
57
+ }
58
+ const capabilities = options.modelCapabilities;
59
+ if (!capabilities || typeof capabilities !== "object") {
60
+ throw new Error(
61
+ `pi custom model capabilities missing for ${resolved.model}: caller must pass options.modelCapabilities`,
62
+ );
63
+ }
64
+ const isPrivate = typeof options.isPrivateProvider === "boolean"
65
+ ? options.isPrivateProvider
66
+ : false;
67
+ const providerName = customProviderName(provider);
68
+ const pricing = modelRow?.pricing || {};
69
+ return {
70
+ model: {
71
+ id: resolved.model,
72
+ name: modelRow?.display_name || resolved.model,
73
+ api: "openai-completions",
74
+ provider: providerName,
75
+ baseUrl: openAiCompatBaseUrl(provider),
76
+ reasoning: !!capabilities.reasoning,
77
+ input: capabilities.vision === false ? ["text"] : ["text", "image"],
78
+ cost: {
79
+ input: Number(pricing.input_per_million) || 0,
80
+ output: Number(pricing.output_per_million) || 0,
81
+ cacheRead: Number(pricing.cached_input_per_million) || 0,
82
+ cacheWrite: Number(pricing.cache_write_per_million) || 0,
83
+ },
84
+ contextWindow: Number(capabilities.context_window || capabilities.num_ctx) || 128000,
85
+ maxTokens: Number(capabilities.max_tokens) || 16384,
86
+ compat: customCompat(capabilities, isPrivate),
87
+ },
88
+ capabilities,
89
+ apiKeys: new Map([[providerName, customProviderKey(provider, isPrivate)]]),
90
+ };
91
+ }
92
+
93
+ export function resolvePiRuntimeModel(resolved, options) {
94
+ if (options.customProvider) return resolveCustomPiModel(resolved, options);
95
+ if (resolved.sdk !== "pi") throw new Error(`unsupported pi sdk: ${resolved.sdk}`);
96
+ const provider = resolved.provider;
97
+ const model = getPiModel(provider, resolved.model);
98
+ return {
99
+ model,
100
+ capabilities: {
101
+ tool_use: true,
102
+ reasoning: !!model.reasoning,
103
+ reasoning_mode: model.reasoning ? "effort" : "none",
104
+ reasoning_levels: model.reasoning ? ["none", "low", "medium", "high", "xhigh"] : undefined,
105
+ reasoning_disable_supported: true,
106
+ vision: Array.isArray(model.input) ? model.input.includes("image") : false,
107
+ json_mode: true,
108
+ },
109
+ apiKeys: new Map(),
110
+ };
111
+ }