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
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
|
+
});
|