pi-provider-utils 0.0.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/LICENSE +21 -0
- package/README.md +155 -0
- package/package.json +65 -0
- package/src/__tests__/agent-paths.test.ts +255 -0
- package/src/__tests__/providers.test.ts +187 -0
- package/src/__tests__/streams.test.ts +345 -0
- package/src/agent-paths.ts +148 -0
- package/src/providers.ts +95 -0
- package/src/streams.ts +180 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for stream and error primitives.
|
|
3
|
+
*/
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
// Mock pi-ai before importing the module under test
|
|
7
|
+
vi.mock("@mariozechner/pi-ai", () => {
|
|
8
|
+
function createAssistantMessageEventStream() {
|
|
9
|
+
const events: unknown[] = [];
|
|
10
|
+
let done = false;
|
|
11
|
+
let resolve: (() => void) | undefined;
|
|
12
|
+
let waitPromise: Promise<void> | undefined;
|
|
13
|
+
|
|
14
|
+
const stream = {
|
|
15
|
+
push(event: unknown) {
|
|
16
|
+
if (done) return;
|
|
17
|
+
events.push(event);
|
|
18
|
+
const ev = event as { type?: string };
|
|
19
|
+
if (ev.type === "done" || ev.type === "error") {
|
|
20
|
+
done = true;
|
|
21
|
+
}
|
|
22
|
+
if (resolve) {
|
|
23
|
+
resolve();
|
|
24
|
+
resolve = undefined;
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
end() {
|
|
28
|
+
done = true;
|
|
29
|
+
if (resolve) {
|
|
30
|
+
resolve();
|
|
31
|
+
resolve = undefined;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
async *[Symbol.asyncIterator]() {
|
|
35
|
+
let index = 0;
|
|
36
|
+
while (true) {
|
|
37
|
+
if (index < events.length) {
|
|
38
|
+
const event = events[index];
|
|
39
|
+
index++;
|
|
40
|
+
yield event;
|
|
41
|
+
const ev = event as { type?: string };
|
|
42
|
+
if (ev.type === "done" || ev.type === "error") return;
|
|
43
|
+
} else if (done) {
|
|
44
|
+
return;
|
|
45
|
+
} else {
|
|
46
|
+
waitPromise = new Promise<void>((r) => {
|
|
47
|
+
resolve = r;
|
|
48
|
+
});
|
|
49
|
+
await waitPromise;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
_events: events,
|
|
54
|
+
};
|
|
55
|
+
return stream;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { createAssistantMessageEventStream };
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
import type { Api, AssistantMessageEvent, Model } from "@mariozechner/pi-ai";
|
|
62
|
+
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
|
|
63
|
+
import {
|
|
64
|
+
createErrorAssistantMessage,
|
|
65
|
+
createImmediateErrorStream,
|
|
66
|
+
createLinkedAbortController,
|
|
67
|
+
createTimeoutController,
|
|
68
|
+
normalizeUnknownError,
|
|
69
|
+
pipeAssistantStream,
|
|
70
|
+
pushErrorEvent,
|
|
71
|
+
rewriteProviderOnEvent,
|
|
72
|
+
} from "../streams.js";
|
|
73
|
+
|
|
74
|
+
function createMockModel(): Model<Api> {
|
|
75
|
+
return {
|
|
76
|
+
id: "test-model",
|
|
77
|
+
name: "Test Model",
|
|
78
|
+
api: "openai" as Api,
|
|
79
|
+
provider: "test-provider",
|
|
80
|
+
reasoning: false,
|
|
81
|
+
input: ["text"] as ("text" | "image")[],
|
|
82
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
83
|
+
contextWindow: 128_000,
|
|
84
|
+
maxTokens: 16_384,
|
|
85
|
+
baseUrl: "https://api.test.com",
|
|
86
|
+
} as Model<Api>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
vi.clearAllMocks();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// normalizeUnknownError
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
describe("normalizeUnknownError", () => {
|
|
98
|
+
it("extracts message from Error instances", () => {
|
|
99
|
+
expect(normalizeUnknownError(new Error("boom"))).toBe("boom");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns string values as-is", () => {
|
|
103
|
+
expect(normalizeUnknownError("something failed")).toBe("something failed");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("JSON-serializes other values", () => {
|
|
107
|
+
expect(normalizeUnknownError({ code: 500 })).toBe('{"code":500}');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("handles null", () => {
|
|
111
|
+
expect(normalizeUnknownError(null)).toBe("null");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("handles undefined", () => {
|
|
115
|
+
expect(normalizeUnknownError(undefined)).toBe(undefined);
|
|
116
|
+
// JSON.stringify(undefined) returns undefined
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("handles numbers", () => {
|
|
120
|
+
expect(normalizeUnknownError(42)).toBe("42");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// createErrorAssistantMessage
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
describe("createErrorAssistantMessage", () => {
|
|
129
|
+
it("builds a zero-usage error message", () => {
|
|
130
|
+
const model = createMockModel();
|
|
131
|
+
const msg = createErrorAssistantMessage(model, "test error");
|
|
132
|
+
|
|
133
|
+
expect(msg.role).toBe("assistant");
|
|
134
|
+
expect(msg.content).toEqual([]);
|
|
135
|
+
expect(msg.api).toBe("openai");
|
|
136
|
+
expect(msg.provider).toBe("test-provider");
|
|
137
|
+
expect(msg.model).toBe("test-model");
|
|
138
|
+
expect(msg.stopReason).toBe("error");
|
|
139
|
+
expect(msg.errorMessage).toBe("test error");
|
|
140
|
+
expect(msg.usage.totalTokens).toBe(0);
|
|
141
|
+
expect(msg.usage.cost.total).toBe(0);
|
|
142
|
+
expect(msg.timestamp).toBeGreaterThan(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// pushErrorEvent
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
describe("pushErrorEvent", () => {
|
|
151
|
+
it("pushes an error event into the stream", () => {
|
|
152
|
+
const stream = createAssistantMessageEventStream();
|
|
153
|
+
const model = createMockModel();
|
|
154
|
+
|
|
155
|
+
pushErrorEvent(stream, model, "credential missing");
|
|
156
|
+
|
|
157
|
+
const events = (stream as unknown as { _events: unknown[] })._events;
|
|
158
|
+
expect(events).toHaveLength(1);
|
|
159
|
+
const event = events[0] as { type: string; reason: string };
|
|
160
|
+
expect(event.type).toBe("error");
|
|
161
|
+
expect(event.reason).toBe("error");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// createImmediateErrorStream
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
describe("createImmediateErrorStream", () => {
|
|
170
|
+
it("creates a stream with a single error event", async () => {
|
|
171
|
+
const model = createMockModel();
|
|
172
|
+
const stream = createImmediateErrorStream(model, "no backend");
|
|
173
|
+
|
|
174
|
+
const collected: AssistantMessageEvent[] = [];
|
|
175
|
+
for await (const event of stream) {
|
|
176
|
+
collected.push(event as AssistantMessageEvent);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
expect(collected).toHaveLength(1);
|
|
180
|
+
expect(collected[0]?.type).toBe("error");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// pipeAssistantStream
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
describe("pipeAssistantStream", () => {
|
|
189
|
+
it("forwards all events from source to target", async () => {
|
|
190
|
+
const source = createAssistantMessageEventStream();
|
|
191
|
+
const target = createAssistantMessageEventStream();
|
|
192
|
+
|
|
193
|
+
source.push({
|
|
194
|
+
type: "text_delta",
|
|
195
|
+
contentIndex: 0,
|
|
196
|
+
delta: "hello",
|
|
197
|
+
} as unknown as AssistantMessageEvent);
|
|
198
|
+
source.push({
|
|
199
|
+
type: "done",
|
|
200
|
+
reason: "stop",
|
|
201
|
+
} as unknown as AssistantMessageEvent);
|
|
202
|
+
|
|
203
|
+
await pipeAssistantStream(source, target);
|
|
204
|
+
|
|
205
|
+
const events = (target as unknown as { _events: unknown[] })._events;
|
|
206
|
+
expect(events).toHaveLength(2);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// rewriteProviderOnEvent
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
describe("rewriteProviderOnEvent", () => {
|
|
215
|
+
it("rewrites provider on partial events", () => {
|
|
216
|
+
const event: AssistantMessageEvent = {
|
|
217
|
+
type: "partial",
|
|
218
|
+
partial: { provider: "internal", content: "hi" },
|
|
219
|
+
} as unknown as AssistantMessageEvent;
|
|
220
|
+
|
|
221
|
+
const rewritten = rewriteProviderOnEvent(event, "public-provider");
|
|
222
|
+
|
|
223
|
+
expect(
|
|
224
|
+
(rewritten as { partial: { provider: string } }).partial.provider,
|
|
225
|
+
).toBe("public-provider");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("rewrites provider on done events", () => {
|
|
229
|
+
const event: AssistantMessageEvent = {
|
|
230
|
+
type: "done",
|
|
231
|
+
message: { provider: "internal", content: "hi" },
|
|
232
|
+
} as unknown as AssistantMessageEvent;
|
|
233
|
+
|
|
234
|
+
const rewritten = rewriteProviderOnEvent(event, "public-provider");
|
|
235
|
+
|
|
236
|
+
expect(
|
|
237
|
+
(rewritten as { message: { provider: string } }).message.provider,
|
|
238
|
+
).toBe("public-provider");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("rewrites provider on error events", () => {
|
|
242
|
+
const event: AssistantMessageEvent = {
|
|
243
|
+
type: "error",
|
|
244
|
+
reason: "error",
|
|
245
|
+
error: { provider: "internal", errorMessage: "fail" },
|
|
246
|
+
} as unknown as AssistantMessageEvent;
|
|
247
|
+
|
|
248
|
+
const rewritten = rewriteProviderOnEvent(event, "public-provider");
|
|
249
|
+
|
|
250
|
+
expect((rewritten as { error: { provider: string } }).error.provider).toBe(
|
|
251
|
+
"public-provider",
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("returns unmodified events that have no recognized shape", () => {
|
|
256
|
+
const event = {
|
|
257
|
+
type: "other",
|
|
258
|
+
data: 42,
|
|
259
|
+
} as unknown as AssistantMessageEvent;
|
|
260
|
+
|
|
261
|
+
const rewritten = rewriteProviderOnEvent(event, "public-provider");
|
|
262
|
+
|
|
263
|
+
expect(rewritten).toEqual(event);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// createLinkedAbortController
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
describe("createLinkedAbortController", () => {
|
|
272
|
+
it("creates a standalone controller when no signal is provided", () => {
|
|
273
|
+
const controller = createLinkedAbortController();
|
|
274
|
+
expect(controller.signal.aborted).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("creates an already-aborted controller when signal is aborted", () => {
|
|
278
|
+
const parent = new AbortController();
|
|
279
|
+
parent.abort();
|
|
280
|
+
|
|
281
|
+
const linked = createLinkedAbortController(parent.signal);
|
|
282
|
+
|
|
283
|
+
expect(linked.signal.aborted).toBe(true);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("aborts the linked controller when the parent signal fires", () => {
|
|
287
|
+
const parent = new AbortController();
|
|
288
|
+
const linked = createLinkedAbortController(parent.signal);
|
|
289
|
+
|
|
290
|
+
expect(linked.signal.aborted).toBe(false);
|
|
291
|
+
parent.abort();
|
|
292
|
+
expect(linked.signal.aborted).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("does not abort the parent when the linked controller is aborted", () => {
|
|
296
|
+
const parent = new AbortController();
|
|
297
|
+
const linked = createLinkedAbortController(parent.signal);
|
|
298
|
+
|
|
299
|
+
linked.abort();
|
|
300
|
+
|
|
301
|
+
expect(parent.signal.aborted).toBe(false);
|
|
302
|
+
expect(linked.signal.aborted).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// createTimeoutController
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
describe("createTimeoutController", () => {
|
|
311
|
+
it("creates a linked controller with a timeout", () => {
|
|
312
|
+
const { controller, clear } = createTimeoutController(undefined, 5000);
|
|
313
|
+
|
|
314
|
+
expect(controller.signal.aborted).toBe(false);
|
|
315
|
+
clear();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("aborts after the specified timeout", async () => {
|
|
319
|
+
const { controller, clear } = createTimeoutController(undefined, 10);
|
|
320
|
+
|
|
321
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
322
|
+
|
|
323
|
+
expect(controller.signal.aborted).toBe(true);
|
|
324
|
+
clear();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("can be cleared before timeout fires", async () => {
|
|
328
|
+
const { controller, clear } = createTimeoutController(undefined, 10);
|
|
329
|
+
clear();
|
|
330
|
+
|
|
331
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
332
|
+
|
|
333
|
+
expect(controller.signal.aborted).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("links to parent signal", () => {
|
|
337
|
+
const parent = new AbortController();
|
|
338
|
+
const { controller, clear } = createTimeoutController(parent.signal, 5000);
|
|
339
|
+
|
|
340
|
+
parent.abort();
|
|
341
|
+
|
|
342
|
+
expect(controller.signal.aborted).toBe(true);
|
|
343
|
+
clear();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical ~/.pi/agent/* path helpers and JSON file I/O.
|
|
3
|
+
*
|
|
4
|
+
* Both pi-credential-vault and pi-multicodex read and write files under
|
|
5
|
+
* the agent directory. This module provides a single, permission-aware
|
|
6
|
+
* set of helpers so path construction and file I/O are not duplicated.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Path helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a path relative to the agent directory (~/.pi/agent/).
|
|
19
|
+
*
|
|
20
|
+
* When called with no arguments, returns the agent directory itself.
|
|
21
|
+
*/
|
|
22
|
+
export function getAgentPath(...segments: string[]): string {
|
|
23
|
+
return join(getAgentDir(), ...segments);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get the canonical path to the shared settings file.
|
|
28
|
+
*
|
|
29
|
+
* Returns `~/.pi/agent/settings.json`.
|
|
30
|
+
*/
|
|
31
|
+
export function getAgentSettingsPath(): string {
|
|
32
|
+
return getAgentPath("settings.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the canonical path to the native auth credentials file.
|
|
37
|
+
*
|
|
38
|
+
* Returns `~/.pi/agent/auth.json`.
|
|
39
|
+
*/
|
|
40
|
+
export function getAgentAuthPath(): string {
|
|
41
|
+
return getAgentPath("auth.json");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Directory helpers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Ensure the parent directory of a file path exists.
|
|
50
|
+
*
|
|
51
|
+
* Creates intermediate directories as needed with mode 0o755.
|
|
52
|
+
* No-op if the directory already exists.
|
|
53
|
+
*/
|
|
54
|
+
export function ensureParentDir(filePath: string): void {
|
|
55
|
+
const dir = dirname(filePath);
|
|
56
|
+
if (!existsSync(dir)) {
|
|
57
|
+
mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Async variant of ensureParentDir.
|
|
63
|
+
*/
|
|
64
|
+
export async function ensureParentDirAsync(filePath: string): Promise<void> {
|
|
65
|
+
const dir = dirname(filePath);
|
|
66
|
+
try {
|
|
67
|
+
await stat(dir);
|
|
68
|
+
} catch {
|
|
69
|
+
await mkdir(dir, { recursive: true, mode: 0o755 });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// JSON file I/O (sync)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read a JSON file and return its contents as a plain object.
|
|
79
|
+
*
|
|
80
|
+
* Returns an empty object if the file does not exist, is not valid JSON,
|
|
81
|
+
* or does not contain a JSON object at the top level.
|
|
82
|
+
*/
|
|
83
|
+
export function readJsonObjectFile(filePath: string): Record<string, unknown> {
|
|
84
|
+
try {
|
|
85
|
+
if (!existsSync(filePath)) {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
89
|
+
const parsed: unknown = JSON.parse(raw);
|
|
90
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
return parsed as Record<string, unknown>;
|
|
94
|
+
} catch {
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write a plain object to a JSON file.
|
|
101
|
+
*
|
|
102
|
+
* Creates parent directories if they do not exist.
|
|
103
|
+
* Uses 2-space indentation for human readability.
|
|
104
|
+
*/
|
|
105
|
+
export function writeJsonObjectFile(
|
|
106
|
+
filePath: string,
|
|
107
|
+
data: Record<string, unknown>,
|
|
108
|
+
): void {
|
|
109
|
+
ensureParentDir(filePath);
|
|
110
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// JSON file I/O (async)
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Async variant of readJsonObjectFile.
|
|
119
|
+
*/
|
|
120
|
+
export async function readJsonObjectFileAsync(
|
|
121
|
+
filePath: string,
|
|
122
|
+
): Promise<Record<string, unknown>> {
|
|
123
|
+
try {
|
|
124
|
+
const stats = await stat(filePath).catch(() => undefined);
|
|
125
|
+
if (!stats) {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
const raw = await readFile(filePath, "utf-8");
|
|
129
|
+
const parsed: unknown = JSON.parse(raw);
|
|
130
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
return parsed as Record<string, unknown>;
|
|
134
|
+
} catch {
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Async variant of writeJsonObjectFile.
|
|
141
|
+
*/
|
|
142
|
+
export async function writeJsonObjectFileAsync(
|
|
143
|
+
filePath: string,
|
|
144
|
+
data: Record<string, unknown>,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
await ensureParentDirAsync(filePath);
|
|
147
|
+
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
148
|
+
}
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider mirror metadata and model-registry helpers.
|
|
3
|
+
*
|
|
4
|
+
* Both pi-credential-vault and pi-multicodex need to mirror existing
|
|
5
|
+
* provider configurations before installing extension-owned behavior.
|
|
6
|
+
* This module provides a single copy of that logic.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
getApiProvider,
|
|
10
|
+
getModels,
|
|
11
|
+
getProviders,
|
|
12
|
+
type KnownProvider,
|
|
13
|
+
} from "@mariozechner/pi-ai";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Model definition shape shared by provider mirrors.
|
|
17
|
+
*
|
|
18
|
+
* Captures the subset of a pi-ai Model that extensions need when
|
|
19
|
+
* re-registering a provider via pi.registerProvider().
|
|
20
|
+
*/
|
|
21
|
+
export interface MirroredModelDef {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly reasoning: boolean;
|
|
25
|
+
readonly input: ReadonlyArray<"text" | "image">;
|
|
26
|
+
readonly cost: {
|
|
27
|
+
readonly input: number;
|
|
28
|
+
readonly output: number;
|
|
29
|
+
readonly cacheRead: number;
|
|
30
|
+
readonly cacheWrite: number;
|
|
31
|
+
};
|
|
32
|
+
readonly contextWindow: number;
|
|
33
|
+
readonly maxTokens: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A snapshot of an existing provider's configuration.
|
|
38
|
+
*
|
|
39
|
+
* Contains everything an extension needs to re-register the provider
|
|
40
|
+
* with modified behavior while preserving the original models and base URL.
|
|
41
|
+
*/
|
|
42
|
+
export interface MirroredProvider {
|
|
43
|
+
readonly providerId: string;
|
|
44
|
+
readonly baseUrl: string;
|
|
45
|
+
readonly api: string;
|
|
46
|
+
readonly models: ReadonlyArray<MirroredModelDef>;
|
|
47
|
+
readonly hasStreamSimple: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Mirror an existing provider's configuration from pi-ai's registry.
|
|
52
|
+
*
|
|
53
|
+
* Returns undefined if the provider has no registered models.
|
|
54
|
+
*/
|
|
55
|
+
export function mirrorProvider(
|
|
56
|
+
providerId: KnownProvider,
|
|
57
|
+
): MirroredProvider | undefined {
|
|
58
|
+
const models = getModels(providerId);
|
|
59
|
+
if (models.length === 0) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const first = models[0];
|
|
64
|
+
if (!first) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const baseProvider = getApiProvider(first.api);
|
|
69
|
+
const baseUrl = first.baseUrl ?? "";
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
providerId,
|
|
73
|
+
baseUrl,
|
|
74
|
+
api: first.api,
|
|
75
|
+
models: models.map((m) => ({
|
|
76
|
+
id: m.id,
|
|
77
|
+
name: m.name,
|
|
78
|
+
reasoning: m.reasoning,
|
|
79
|
+
input: m.input,
|
|
80
|
+
cost: m.cost,
|
|
81
|
+
contextWindow: m.contextWindow,
|
|
82
|
+
maxTokens: m.maxTokens,
|
|
83
|
+
})),
|
|
84
|
+
hasStreamSimple: Boolean(baseProvider),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* List all provider IDs that have at least one registered model.
|
|
90
|
+
*
|
|
91
|
+
* Uses pi-ai's getProviders() — no hardcoded list needed.
|
|
92
|
+
*/
|
|
93
|
+
export function listProviderIdsWithModels(): KnownProvider[] {
|
|
94
|
+
return getProviders().filter((id) => getModels(id).length > 0);
|
|
95
|
+
}
|