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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Victor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # pi-provider-utils
2
+
3
+ Shared provider mirror, stream, and agent-path helpers for Pi extension packages.
4
+
5
+ ## Status
6
+
7
+ This package extracts duplicated helper code from `pi-credential-vault` and `pi-multicodex` into a single shared dependency. It provides generic provider and agent utilities — not vault-specific or multicodex-specific policy.
8
+
9
+ Current consumers:
10
+
11
+ - `@victor-software-house/pi-credential-vault`
12
+ - `@victor-software-house/pi-multicodex`
13
+
14
+ Current next steps:
15
+
16
+ 1. publish this package to npm
17
+ 2. switch consuming repos from `link:` to npm dependency
18
+ 3. adopt pnpm workspace and Turborepo once the published package is the common dependency boundary
19
+
20
+ ## Install
21
+
22
+ This package is a shared library, not a standalone Pi extension. Do not install it with `pi install`.
23
+
24
+ Consume it from another package instead:
25
+
26
+ ```json
27
+ {
28
+ "peerDependencies": {
29
+ "@victor-software-house/pi-provider-utils": "*"
30
+ },
31
+ "devDependencies": {
32
+ "@victor-software-house/pi-provider-utils": "*"
33
+ }
34
+ }
35
+ ```
36
+
37
+ For local multi-repo development before publication, use a `link:` dev dependency:
38
+
39
+ ```json
40
+ {
41
+ "devDependencies": {
42
+ "@victor-software-house/pi-provider-utils": "link:../pi-provider-utils"
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Entrypoints
48
+
49
+ ### `@victor-software-house/pi-provider-utils/providers`
50
+
51
+ Provider mirror metadata and model-registry helpers.
52
+
53
+ ```typescript
54
+ import {
55
+ mirrorProvider,
56
+ listProviderIdsWithModels,
57
+ type MirroredProvider,
58
+ type MirroredModelDef,
59
+ } from "@victor-software-house/pi-provider-utils/providers";
60
+
61
+ // Mirror an existing provider's configuration for re-registration
62
+ const mirror = mirrorProvider("openai");
63
+
64
+ // List all provider IDs that have registered models
65
+ const ids = listProviderIdsWithModels();
66
+ ```
67
+
68
+ ### `@victor-software-house/pi-provider-utils/streams`
69
+
70
+ Stream and error primitives for extension-owned provider wrappers.
71
+
72
+ ```typescript
73
+ import {
74
+ normalizeUnknownError,
75
+ createErrorAssistantMessage,
76
+ pushErrorEvent,
77
+ createImmediateErrorStream,
78
+ pipeAssistantStream,
79
+ rewriteProviderOnEvent,
80
+ createLinkedAbortController,
81
+ createTimeoutController,
82
+ } from "@victor-software-house/pi-provider-utils/streams";
83
+
84
+ // Normalize an unknown thrown value into a string
85
+ const msg = normalizeUnknownError(error);
86
+
87
+ // Create a stream that immediately emits an error
88
+ const stream = createImmediateErrorStream(model, "no credentials");
89
+
90
+ // Pipe events from one stream to another
91
+ await pipeAssistantStream(source, target);
92
+
93
+ // Rewrite the provider field on stream events
94
+ const rewritten = rewriteProviderOnEvent(event, "my-provider");
95
+
96
+ // Create an AbortController linked to a parent signal
97
+ const controller = createLinkedAbortController(parentSignal);
98
+
99
+ // Create a linked controller that auto-aborts after a timeout
100
+ const { controller: tc, clear } = createTimeoutController(signal, 30_000);
101
+ ```
102
+
103
+ ### `@victor-software-house/pi-provider-utils/agent-paths`
104
+
105
+ Canonical `~/.pi/agent/*` path helpers and JSON file I/O.
106
+
107
+ ```typescript
108
+ import {
109
+ getAgentPath,
110
+ getAgentSettingsPath,
111
+ getAgentAuthPath,
112
+ readJsonObjectFile,
113
+ writeJsonObjectFile,
114
+ ensureParentDir,
115
+ } from "@victor-software-house/pi-provider-utils/agent-paths";
116
+
117
+ // Resolve a path relative to ~/.pi/agent/
118
+ const vaultPath = getAgentPath("vault.age.json");
119
+
120
+ // Read and write JSON object files (sync)
121
+ const settings = readJsonObjectFile(getAgentSettingsPath());
122
+ settings["my-extension"] = { enabled: true };
123
+ writeJsonObjectFile(getAgentSettingsPath(), settings);
124
+
125
+ // Async variants are also available
126
+ import {
127
+ readJsonObjectFileAsync,
128
+ writeJsonObjectFileAsync,
129
+ } from "@victor-software-house/pi-provider-utils/agent-paths";
130
+ ```
131
+
132
+ ## What this package does NOT contain
133
+
134
+ - OAuth login or refresh orchestration
135
+ - Quota classification or retry policy
136
+ - Vault backend registry logic
137
+ - Multicodex account selection logic
138
+ - Footer rendering or settings-panel components
139
+
140
+ These belong in the owning extension packages (`pi-credential-vault`, `pi-multicodex`), not in shared utilities.
141
+
142
+ ## Development
143
+
144
+ ```bash
145
+ pnpm install
146
+ pnpm typecheck
147
+ pnpm test
148
+ npm pack --dry-run
149
+ ```
150
+
151
+ Validation status at extraction time:
152
+
153
+ - 52 tests covering all three entrypoints
154
+ - `pnpm typecheck` passes
155
+ - `npm pack --dry-run` includes only the library contract and tests
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "pi-provider-utils",
3
+ "version": "0.0.0",
4
+ "description": "Shared provider mirror, stream, and agent-path helpers for Pi extension packages",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "packageManager": "pnpm@10.32.1",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "pi-coding-agent",
12
+ "provider",
13
+ "stream",
14
+ "utilities"
15
+ ],
16
+ "exports": {
17
+ "./providers": "./src/providers.ts",
18
+ "./streams": "./src/streams.ts",
19
+ "./agent-paths": "./src/agent-paths.ts"
20
+ },
21
+ "scripts": {
22
+ "lint": "biome check .",
23
+ "test": "vitest run",
24
+ "tsgo": "tsgo -p tsconfig.json",
25
+ "check": "pnpm lint && pnpm tsgo && pnpm test",
26
+ "release:dry": "pnpm exec semantic-release --dry-run"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/victor-software-house/pi-provider-utils.git"
31
+ },
32
+ "homepage": "https://github.com/victor-software-house/pi-provider-utils#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/victor-software-house/pi-provider-utils/issues"
35
+ },
36
+ "author": "Victor",
37
+ "files": [
38
+ "src/",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "peerDependencies": {
43
+ "@mariozechner/pi-ai": "*",
44
+ "@mariozechner/pi-coding-agent": "*"
45
+ },
46
+ "devDependencies": {
47
+ "@biomejs/biome": "^2.4.7",
48
+ "@commitlint/cli": "^20.4.4",
49
+ "@commitlint/config-conventional": "^20.4.4",
50
+ "@mariozechner/pi-ai": "^0.63.1",
51
+ "@mariozechner/pi-coding-agent": "^0.63.1",
52
+ "@semantic-release/changelog": "^6.0.3",
53
+ "@semantic-release/commit-analyzer": "^13.0.1",
54
+ "@semantic-release/git": "^10.0.1",
55
+ "@semantic-release/github": "^12.0.6",
56
+ "@semantic-release/npm": "^13.1.5",
57
+ "@semantic-release/release-notes-generator": "^14.1.0",
58
+ "@typescript/native-preview": "7.0.0-dev.20260314.1",
59
+ "semantic-release": "^25.0.3",
60
+ "vitest": "^4.1.0"
61
+ },
62
+ "engines": {
63
+ "node": "24.14.0"
64
+ }
65
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Tests for agent-path helpers and JSON file I/O.
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as fsp from "node:fs/promises";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ // Mock pi-coding-agent before importing the module under test
12
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
13
+ getAgentDir: () => path.join(os.homedir(), ".pi", "agent"),
14
+ }));
15
+
16
+ import {
17
+ ensureParentDir,
18
+ getAgentAuthPath,
19
+ getAgentPath,
20
+ getAgentSettingsPath,
21
+ readJsonObjectFile,
22
+ readJsonObjectFileAsync,
23
+ writeJsonObjectFile,
24
+ writeJsonObjectFileAsync,
25
+ } from "../agent-paths.js";
26
+
27
+ // Use a temp directory for file I/O tests
28
+ let tmpDir: string;
29
+
30
+ beforeEach(() => {
31
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-provider-utils-"));
32
+ });
33
+
34
+ afterEach(() => {
35
+ fs.rmSync(tmpDir, { recursive: true, force: true });
36
+ });
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Path helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ describe("getAgentPath", () => {
43
+ it("returns the agent directory when called with no arguments", () => {
44
+ const result = getAgentPath();
45
+ expect(result).toBe(path.join(os.homedir(), ".pi", "agent"));
46
+ });
47
+
48
+ it("joins segments onto the agent directory", () => {
49
+ const result = getAgentPath("settings.json");
50
+ expect(result).toBe(
51
+ path.join(os.homedir(), ".pi", "agent", "settings.json"),
52
+ );
53
+ });
54
+
55
+ it("joins multiple segments", () => {
56
+ const result = getAgentPath("data", "vault.age.json");
57
+ expect(result).toBe(
58
+ path.join(os.homedir(), ".pi", "agent", "data", "vault.age.json"),
59
+ );
60
+ });
61
+ });
62
+
63
+ describe("getAgentSettingsPath", () => {
64
+ it("returns the settings.json path", () => {
65
+ expect(getAgentSettingsPath()).toBe(
66
+ path.join(os.homedir(), ".pi", "agent", "settings.json"),
67
+ );
68
+ });
69
+ });
70
+
71
+ describe("getAgentAuthPath", () => {
72
+ it("returns the auth.json path", () => {
73
+ expect(getAgentAuthPath()).toBe(
74
+ path.join(os.homedir(), ".pi", "agent", "auth.json"),
75
+ );
76
+ });
77
+ });
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // ensureParentDir
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe("ensureParentDir", () => {
84
+ it("creates parent directories that do not exist", () => {
85
+ const filePath = path.join(tmpDir, "a", "b", "file.json");
86
+
87
+ ensureParentDir(filePath);
88
+
89
+ expect(fs.existsSync(path.join(tmpDir, "a", "b"))).toBe(true);
90
+ });
91
+
92
+ it("is a no-op when the parent directory already exists", () => {
93
+ const filePath = path.join(tmpDir, "file.json");
94
+
95
+ ensureParentDir(filePath);
96
+
97
+ expect(fs.existsSync(tmpDir)).toBe(true);
98
+ });
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // readJsonObjectFile (sync)
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe("readJsonObjectFile", () => {
106
+ it("returns empty object for nonexistent file", () => {
107
+ const result = readJsonObjectFile(path.join(tmpDir, "missing.json"));
108
+ expect(result).toEqual({});
109
+ });
110
+
111
+ it("reads a valid JSON object file", () => {
112
+ const filePath = path.join(tmpDir, "test.json");
113
+ fs.writeFileSync(filePath, JSON.stringify({ key: "value" }));
114
+
115
+ const result = readJsonObjectFile(filePath);
116
+
117
+ expect(result).toEqual({ key: "value" });
118
+ });
119
+
120
+ it("returns empty object for a JSON array file", () => {
121
+ const filePath = path.join(tmpDir, "array.json");
122
+ fs.writeFileSync(filePath, JSON.stringify([1, 2, 3]));
123
+
124
+ const result = readJsonObjectFile(filePath);
125
+
126
+ expect(result).toEqual({});
127
+ });
128
+
129
+ it("returns empty object for invalid JSON", () => {
130
+ const filePath = path.join(tmpDir, "bad.json");
131
+ fs.writeFileSync(filePath, "not json at all");
132
+
133
+ const result = readJsonObjectFile(filePath);
134
+
135
+ expect(result).toEqual({});
136
+ });
137
+
138
+ it("returns empty object for a JSON null file", () => {
139
+ const filePath = path.join(tmpDir, "null.json");
140
+ fs.writeFileSync(filePath, "null");
141
+
142
+ const result = readJsonObjectFile(filePath);
143
+
144
+ expect(result).toEqual({});
145
+ });
146
+
147
+ it("preserves nested objects", () => {
148
+ const data = { outer: { inner: 42 }, list: [1, 2] };
149
+ const filePath = path.join(tmpDir, "nested.json");
150
+ fs.writeFileSync(filePath, JSON.stringify(data));
151
+
152
+ const result = readJsonObjectFile(filePath);
153
+
154
+ expect(result).toEqual(data);
155
+ });
156
+ });
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // writeJsonObjectFile (sync)
160
+ // ---------------------------------------------------------------------------
161
+
162
+ describe("writeJsonObjectFile", () => {
163
+ it("writes a JSON object with 2-space indentation", () => {
164
+ const filePath = path.join(tmpDir, "out.json");
165
+
166
+ writeJsonObjectFile(filePath, { hello: "world" });
167
+
168
+ const raw = fs.readFileSync(filePath, "utf-8");
169
+ expect(raw).toBe(JSON.stringify({ hello: "world" }, null, 2));
170
+ });
171
+
172
+ it("creates parent directories when needed", () => {
173
+ const filePath = path.join(tmpDir, "deep", "nested", "out.json");
174
+
175
+ writeJsonObjectFile(filePath, { created: true });
176
+
177
+ expect(fs.existsSync(filePath)).toBe(true);
178
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<
179
+ string,
180
+ unknown
181
+ >;
182
+ expect(parsed).toEqual({ created: true });
183
+ });
184
+
185
+ it("overwrites an existing file", () => {
186
+ const filePath = path.join(tmpDir, "overwrite.json");
187
+ fs.writeFileSync(filePath, JSON.stringify({ old: true }));
188
+
189
+ writeJsonObjectFile(filePath, { new: true });
190
+
191
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<
192
+ string,
193
+ unknown
194
+ >;
195
+ expect(parsed).toEqual({ new: true });
196
+ });
197
+ });
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // readJsonObjectFileAsync
201
+ // ---------------------------------------------------------------------------
202
+
203
+ describe("readJsonObjectFileAsync", () => {
204
+ it("returns empty object for nonexistent file", async () => {
205
+ const result = await readJsonObjectFileAsync(
206
+ path.join(tmpDir, "missing.json"),
207
+ );
208
+ expect(result).toEqual({});
209
+ });
210
+
211
+ it("reads a valid JSON object file", async () => {
212
+ const filePath = path.join(tmpDir, "test.json");
213
+ await fsp.writeFile(filePath, JSON.stringify({ async: true }));
214
+
215
+ const result = await readJsonObjectFileAsync(filePath);
216
+
217
+ expect(result).toEqual({ async: true });
218
+ });
219
+
220
+ it("returns empty object for a JSON array file", async () => {
221
+ const filePath = path.join(tmpDir, "array.json");
222
+ await fsp.writeFile(filePath, JSON.stringify([1, 2]));
223
+
224
+ const result = await readJsonObjectFileAsync(filePath);
225
+
226
+ expect(result).toEqual({});
227
+ });
228
+ });
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // writeJsonObjectFileAsync
232
+ // ---------------------------------------------------------------------------
233
+
234
+ describe("writeJsonObjectFileAsync", () => {
235
+ it("writes a JSON object with 2-space indentation", async () => {
236
+ const filePath = path.join(tmpDir, "async-out.json");
237
+
238
+ await writeJsonObjectFileAsync(filePath, { async: "write" });
239
+
240
+ const raw = await fsp.readFile(filePath, "utf-8");
241
+ expect(raw).toBe(JSON.stringify({ async: "write" }, null, 2));
242
+ });
243
+
244
+ it("creates parent directories when needed", async () => {
245
+ const filePath = path.join(tmpDir, "deep", "async", "out.json");
246
+
247
+ await writeJsonObjectFileAsync(filePath, { deep: true });
248
+
249
+ const exists = await fsp
250
+ .stat(filePath)
251
+ .then(() => true)
252
+ .catch(() => false);
253
+ expect(exists).toBe(true);
254
+ });
255
+ });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Tests for provider mirror metadata and model-registry helpers.
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
+ getModels: vi.fn(),
9
+ getProviders: vi.fn(),
10
+ getApiProvider: vi.fn(),
11
+ }));
12
+
13
+ import { getApiProvider, getModels, getProviders } from "@mariozechner/pi-ai";
14
+ import type { MirroredProvider } from "../providers.js";
15
+ import { listProviderIdsWithModels, mirrorProvider } from "../providers.js";
16
+
17
+ // Cast to vi.Mock for mock API access without strict generics
18
+ const mockGetModels = getModels as unknown as ReturnType<typeof vi.fn>;
19
+ const mockGetProviders = getProviders as unknown as ReturnType<typeof vi.fn>;
20
+ const mockGetApiProvider = getApiProvider as unknown as ReturnType<
21
+ typeof vi.fn
22
+ >;
23
+
24
+ function createMockModel(overrides: Record<string, unknown> = {}) {
25
+ return {
26
+ id: "gpt-4o",
27
+ name: "GPT-4o",
28
+ reasoning: false,
29
+ input: ["text", "image"],
30
+ cost: { input: 5, output: 15, cacheRead: 2.5, cacheWrite: 5 },
31
+ contextWindow: 128_000,
32
+ maxTokens: 16_384,
33
+ api: "openai",
34
+ provider: "openai",
35
+ baseUrl: "https://api.openai.com/v1",
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ });
43
+
44
+ describe("mirrorProvider", () => {
45
+ it("returns undefined when the provider has no models", () => {
46
+ mockGetModels.mockReturnValue([]);
47
+
48
+ const result = mirrorProvider("openai");
49
+ expect(result).toBeUndefined();
50
+ });
51
+
52
+ it("mirrors a provider with a single model", () => {
53
+ const model = createMockModel();
54
+ mockGetModels.mockReturnValue([model]);
55
+ mockGetApiProvider.mockReturnValue({
56
+ api: "openai",
57
+ stream: vi.fn(),
58
+ streamSimple: vi.fn(),
59
+ });
60
+
61
+ const result = mirrorProvider("openai");
62
+
63
+ expect(result).toBeDefined();
64
+ const mirror = result as MirroredProvider;
65
+ expect(mirror.providerId).toBe("openai");
66
+ expect(mirror.baseUrl).toBe("https://api.openai.com/v1");
67
+ expect(mirror.api).toBe("openai");
68
+ expect(mirror.hasStreamSimple).toBe(true);
69
+ expect(mirror.models).toHaveLength(1);
70
+ expect(mirror.models[0]?.id).toBe("gpt-4o");
71
+ });
72
+
73
+ it("mirrors a provider with multiple models", () => {
74
+ const models = [
75
+ createMockModel({ id: "gpt-4o", name: "GPT-4o" }),
76
+ createMockModel({
77
+ id: "gpt-4o-mini",
78
+ name: "GPT-4o Mini",
79
+ maxTokens: 8_192,
80
+ }),
81
+ ];
82
+ mockGetModels.mockReturnValue(models);
83
+ mockGetApiProvider.mockReturnValue({
84
+ api: "openai",
85
+ stream: vi.fn(),
86
+ streamSimple: vi.fn(),
87
+ });
88
+
89
+ const result = mirrorProvider("openai");
90
+
91
+ expect(result).toBeDefined();
92
+ const mirror = result as MirroredProvider;
93
+ expect(mirror.models).toHaveLength(2);
94
+ expect(mirror.models[0]?.id).toBe("gpt-4o");
95
+ expect(mirror.models[1]?.id).toBe("gpt-4o-mini");
96
+ });
97
+
98
+ it("reports hasStreamSimple false when no API provider found", () => {
99
+ mockGetModels.mockReturnValue([createMockModel()]);
100
+ mockGetApiProvider.mockReturnValue(undefined);
101
+
102
+ const result = mirrorProvider("openai");
103
+
104
+ expect(result).toBeDefined();
105
+ const mirror = result as MirroredProvider;
106
+ expect(mirror.hasStreamSimple).toBe(false);
107
+ });
108
+
109
+ it("uses empty string for baseUrl when model has none", () => {
110
+ const model = createMockModel({ baseUrl: undefined });
111
+ mockGetModels.mockReturnValue([model]);
112
+ mockGetApiProvider.mockReturnValue({
113
+ api: "openai",
114
+ stream: vi.fn(),
115
+ streamSimple: vi.fn(),
116
+ });
117
+
118
+ const result = mirrorProvider("openai");
119
+
120
+ expect(result).toBeDefined();
121
+ const mirror = result as MirroredProvider;
122
+ expect(mirror.baseUrl).toBe("");
123
+ });
124
+
125
+ it("preserves model cost and dimension fields", () => {
126
+ const model = createMockModel({
127
+ cost: { input: 10, output: 30, cacheRead: 5, cacheWrite: 10 },
128
+ contextWindow: 200_000,
129
+ maxTokens: 32_768,
130
+ reasoning: true,
131
+ });
132
+ mockGetModels.mockReturnValue([model]);
133
+ mockGetApiProvider.mockReturnValue({
134
+ api: "openai",
135
+ stream: vi.fn(),
136
+ streamSimple: vi.fn(),
137
+ });
138
+
139
+ const result = mirrorProvider("openai");
140
+
141
+ expect(result).toBeDefined();
142
+ const mirror = result as MirroredProvider;
143
+ const mirrored = mirror.models[0];
144
+ expect(mirrored).toBeDefined();
145
+ expect(mirrored?.cost).toEqual({
146
+ input: 10,
147
+ output: 30,
148
+ cacheRead: 5,
149
+ cacheWrite: 10,
150
+ });
151
+ expect(mirrored?.contextWindow).toBe(200_000);
152
+ expect(mirrored?.maxTokens).toBe(32_768);
153
+ expect(mirrored?.reasoning).toBe(true);
154
+ });
155
+ });
156
+
157
+ describe("listProviderIdsWithModels", () => {
158
+ it("returns only providers that have registered models", () => {
159
+ mockGetProviders.mockReturnValue(["openai", "anthropic", "groq"]);
160
+ mockGetModels.mockImplementation((id: string) => {
161
+ if (id === "openai") return [createMockModel()];
162
+ if (id === "anthropic") return [createMockModel({ api: "anthropic" })];
163
+ return [];
164
+ });
165
+
166
+ const ids = listProviderIdsWithModels();
167
+
168
+ expect(ids).toEqual(["openai", "anthropic"]);
169
+ });
170
+
171
+ it("returns empty array when no providers have models", () => {
172
+ mockGetProviders.mockReturnValue(["openai", "anthropic"]);
173
+ mockGetModels.mockReturnValue([]);
174
+
175
+ const ids = listProviderIdsWithModels();
176
+
177
+ expect(ids).toEqual([]);
178
+ });
179
+
180
+ it("returns empty array when no providers exist", () => {
181
+ mockGetProviders.mockReturnValue([]);
182
+
183
+ const ids = listProviderIdsWithModels();
184
+
185
+ expect(ids).toEqual([]);
186
+ });
187
+ });