@holysoles/pi-gatewayhelper 0.0.0-stage → 0.1.1
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 +87 -2
- package/index.ts +298 -0
- package/package.json +27 -4
- package/routing.mjs +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 holysoles
|
|
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
CHANGED
|
@@ -1,3 +1,88 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pi Gateway Helper
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Pi extension for routing virtual models through a private LLM gateway, with optional public-model failover and price synchronization.
|
|
4
|
+
|
|
5
|
+
<!-- toc -->
|
|
6
|
+
|
|
7
|
+
- [Install](#install)
|
|
8
|
+
- [Configuration](#configuration)
|
|
9
|
+
- [Verify](#verify)
|
|
10
|
+
|
|
11
|
+
<!-- tocstop -->
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Install the pinned npm release:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pi install npm:@holysoles/pi-gatewayhelper@0.1.0
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Remove the same release with:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pi remove npm:@holysoles/pi-gatewayhelper@0.1.0
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
For a local checkout, run `pi install ./gatewayhelper` from the repository root; remove it with `pi remove ./gatewayhelper`.
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
Add a `privateLlmGateway` section to your global `settings.json` (normally `~/.pi/agent/settings.json`). The extension reads this file at startup; it does not read project settings or apply project overrides. Set `PI_CODING_AGENT_DIR` to use a different agent directory. Replace the example provider and model IDs with providers configured in Pi and IDs appropriate to your setup:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"privateLlmGateway": {
|
|
36
|
+
"models": {
|
|
37
|
+
"gateway-chat": {
|
|
38
|
+
"private": { "provider": "my-private-gateway", "id": "internal-chat-v2" },
|
|
39
|
+
"public": { "provider": "public-provider", "id": "public-chat-latest" }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Each key under `models` registers a selectable virtual model named `fallback/<key>`; in this example, select `/model fallback/gateway-chat`. The mapped private model must be defined in `~/.pi/agent/models.json`. For price synchronization, the mapped public chat model must have pricing in `~/.pi/agent/models-store.json`.
|
|
47
|
+
|
|
48
|
+
`publicModelFailover` and `priceSync` both default to `true`. With failover enabled, the extension selects the mapped public model if the private model is unavailable or has no configured authentication, and can switch to the public model after a private-model retry failure. Set `publicModelFailover` to `false` to disable this; if the private model is unavailable, there is no fallback route. Set `priceSync` to `false` to disable synchronization. When enabled, at session startup the extension copies the mapped public chat model's prices into the matching private model entries in `models.json`.
|
|
49
|
+
|
|
50
|
+
Foreground children launched by `pi-subagents` do not inherit the parent's virtual model registrations. Load this extension in those children with `subagents.defaultSubagentOnlyExtensions`, and configure each child model as a mapped `fallback/<key>`. For example, with a checkout at `/home/you/code/pi-packages`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"subagents": {
|
|
55
|
+
"defaultSubagentOnlyExtensions": [
|
|
56
|
+
"/home/you/code/pi-packages/gatewayhelper/index.ts"
|
|
57
|
+
],
|
|
58
|
+
"agentOverrides": {
|
|
59
|
+
"worker": { "model": "fallback/gpt-6-luna" },
|
|
60
|
+
"reviewer": { "model": "fallback/gpt-6-sol" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Use the absolute entry-point path for your checkout. Both fallback IDs must also be keys in `privateLlmGateway.models`.
|
|
67
|
+
|
|
68
|
+
Extensions that make direct model calls can import `parsePrivateLlmGatewaySettings` and `resolveFallbackModel` from the package's `./routing` export. Read the same global settings to use the identical mappings and failover policy:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
72
|
+
import {
|
|
73
|
+
parsePrivateLlmGatewaySettings,
|
|
74
|
+
resolveFallbackModel,
|
|
75
|
+
} from "@holysoles/pi-gatewayhelper/routing";
|
|
76
|
+
|
|
77
|
+
const globalSettings = SettingsManager.create(ctx.cwd, getAgentDir()).getGlobalSettings();
|
|
78
|
+
const gateway = parsePrivateLlmGatewaySettings(globalSettings);
|
|
79
|
+
if (!gateway) throw new Error("privateLlmGateway settings are missing or invalid");
|
|
80
|
+
const model = resolveFallbackModel(id, ctx, gateway.models, gateway.publicModelFailover);
|
|
81
|
+
return ctx.modelRegistry.complete(model, context, options);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Pass the mapping key as `id`. A registered `fallback/<key>` is a routing alias, not a physical provider model for a direct `modelRegistry.complete()` call; resolve it first and pass `publicModelFailover` so a disabled policy is respected.
|
|
85
|
+
|
|
86
|
+
## Verify
|
|
87
|
+
|
|
88
|
+
After installation, select one of your mapped fallback models and confirm its provider/model IDs. Check the session notifications for routing or price-sync results. Run the package's isolated tests on Node 24+ with `npm test` from `gatewayhelper`; this does not start Pi or change your Pi configuration.
|
package/index.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readFile,
|
|
7
|
+
realpath,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
stat,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { isDeepStrictEqual } from "node:util";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { chooseFallbackRoute, parsePrivateLlmGatewaySettings } from "./routing.mjs";
|
|
18
|
+
|
|
19
|
+
function getAgentDir() {
|
|
20
|
+
const configured = process.env.PI_CODING_AGENT_DIR;
|
|
21
|
+
if (configured === "~") return homedir();
|
|
22
|
+
if (configured?.startsWith("~/")) return join(homedir(), configured.slice(2));
|
|
23
|
+
return configured || join(homedir(), ".pi", "agent");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type JsonRecord = Record<string, unknown>;
|
|
27
|
+
type ModelMapping = {
|
|
28
|
+
private: { provider: string; id: string };
|
|
29
|
+
public: { provider: string; id: string };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getPricedCost(value: unknown): JsonRecord | undefined {
|
|
37
|
+
if (!isRecord(value)) return undefined;
|
|
38
|
+
return Object.values(value).some((price) => typeof price === "number" && Number.isFinite(price))
|
|
39
|
+
? value
|
|
40
|
+
: undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function updateMappedCosts(
|
|
44
|
+
configuration: unknown,
|
|
45
|
+
catalog: unknown,
|
|
46
|
+
models: Record<string, ModelMapping>,
|
|
47
|
+
) {
|
|
48
|
+
const warnings: string[] = [];
|
|
49
|
+
const changedModels = new Set<string>();
|
|
50
|
+
if (!isRecord(configuration) || !isRecord(configuration.providers)) {
|
|
51
|
+
return { changed: false, changedModels: [], warnings: ["models.json has no providers object; pricing was not synchronized."] };
|
|
52
|
+
}
|
|
53
|
+
if (!isRecord(catalog)) {
|
|
54
|
+
return { changed: false, changedModels: [], warnings: ["models-store.json is not a provider catalog; pricing was not synchronized."] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const mapping of Object.values(models)) {
|
|
58
|
+
const privateProvider = configuration.providers[mapping.private.provider];
|
|
59
|
+
const privateModels = isRecord(privateProvider) && Array.isArray(privateProvider.models)
|
|
60
|
+
? privateProvider.models.filter(
|
|
61
|
+
(model): model is JsonRecord => isRecord(model) && model.id === mapping.private.id,
|
|
62
|
+
)
|
|
63
|
+
: [];
|
|
64
|
+
if (privateModels.length === 0) {
|
|
65
|
+
warnings.push(`No configured private model found for ${mapping.private.provider}/${mapping.private.id}; its cost was left unchanged.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const publicProvider = catalog[mapping.public.provider];
|
|
69
|
+
const publicModels = isRecord(publicProvider) && Array.isArray(publicProvider.models)
|
|
70
|
+
? publicProvider.models
|
|
71
|
+
: [];
|
|
72
|
+
const publicModel = publicModels.find((model): model is JsonRecord => {
|
|
73
|
+
return isRecord(model) && model.id === mapping.public.id &&
|
|
74
|
+
model.type === "chat" && getPricedCost(model.cost) !== undefined;
|
|
75
|
+
});
|
|
76
|
+
const publicCost = publicModel && getPricedCost(publicModel.cost);
|
|
77
|
+
if (!publicCost) {
|
|
78
|
+
warnings.push(`No priced public chat model found for ${mapping.public.provider}/${mapping.public.id}; ${mapping.private.provider}/${mapping.private.id} was left unchanged.`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const privateModel of privateModels) {
|
|
83
|
+
if (isDeepStrictEqual(privateModel.cost, publicCost)) continue;
|
|
84
|
+
privateModel.cost = { ...publicCost };
|
|
85
|
+
changedModels.add(`${mapping.private.provider}/${mapping.private.id}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { changed: changedModels.size > 0, changedModels: [...changedModels], warnings };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function notifyWarnings(ctx: any, warnings: string[]) {
|
|
93
|
+
for (const warning of warnings) ctx.ui.notify(`Private model price sync: ${warning}`, "warning");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function acquireModelLock(lockPath: string) {
|
|
97
|
+
const token = `${process.pid}:${randomUUID()}`;
|
|
98
|
+
const ownerPath = join(lockPath, "owner");
|
|
99
|
+
const deadline = Date.now() + 90_000;
|
|
100
|
+
while (true) {
|
|
101
|
+
try {
|
|
102
|
+
await mkdir(lockPath);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
105
|
+
|
|
106
|
+
let stale = false;
|
|
107
|
+
try {
|
|
108
|
+
const [owner, lockStats] = await Promise.all([
|
|
109
|
+
readFile(ownerPath, "utf8").catch((readError: NodeJS.ErrnoException) => {
|
|
110
|
+
if (readError.code !== "ENOENT") throw readError;
|
|
111
|
+
return undefined;
|
|
112
|
+
}),
|
|
113
|
+
stat(lockPath),
|
|
114
|
+
]);
|
|
115
|
+
if (owner !== undefined) {
|
|
116
|
+
const match = /^(\d+):/.exec(owner);
|
|
117
|
+
if (match) {
|
|
118
|
+
const pid = Number(match[1]);
|
|
119
|
+
let processAlive = true;
|
|
120
|
+
try {
|
|
121
|
+
process.kill(pid, 0);
|
|
122
|
+
} catch (processError) {
|
|
123
|
+
if ((processError as NodeJS.ErrnoException).code === "ESRCH") processAlive = false;
|
|
124
|
+
}
|
|
125
|
+
stale = !processAlive;
|
|
126
|
+
} else {
|
|
127
|
+
stale = Date.now() - lockStats.mtimeMs > 30_000;
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
stale = Date.now() - lockStats.mtimeMs > 30_000;
|
|
131
|
+
}
|
|
132
|
+
} catch (inspectError) {
|
|
133
|
+
if ((inspectError as NodeJS.ErrnoException).code !== "ENOENT") throw inspectError;
|
|
134
|
+
}
|
|
135
|
+
if (stale) {
|
|
136
|
+
const stalePath = `${lockPath}.${process.pid}.${randomUUID()}.stale`;
|
|
137
|
+
try {
|
|
138
|
+
await rename(lockPath, stalePath);
|
|
139
|
+
await rm(stalePath, { recursive: true, force: true });
|
|
140
|
+
} catch (removeError) {
|
|
141
|
+
if ((removeError as NodeJS.ErrnoException).code !== "ENOENT") throw removeError;
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting for the models.json price-sync lock");
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await writeFile(ownerPath, token, { flag: "wx" });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return async () => {
|
|
158
|
+
try {
|
|
159
|
+
if ((await readFile(ownerPath, "utf8")) === token) {
|
|
160
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function readJsonText(path: string) {
|
|
170
|
+
const text = await readFile(path, "utf8");
|
|
171
|
+
return { text, value: JSON.parse(text) as unknown };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function replaceModelsFile(resolvedPath: string, configuration: unknown, expectedSource: string) {
|
|
175
|
+
// Replacing the resolved target preserves models.json symlinks and its file permissions.
|
|
176
|
+
const fileStats = await stat(resolvedPath);
|
|
177
|
+
const mode = fileStats.mode & 0o7777;
|
|
178
|
+
const temporaryPath = `${resolvedPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
179
|
+
let renamed = false;
|
|
180
|
+
try {
|
|
181
|
+
const handle = await open(temporaryPath, "wx", mode);
|
|
182
|
+
try {
|
|
183
|
+
await handle.writeFile(`${JSON.stringify(configuration, null, 2)}\n`, "utf8");
|
|
184
|
+
await handle.chmod(mode);
|
|
185
|
+
} finally {
|
|
186
|
+
await handle.close();
|
|
187
|
+
}
|
|
188
|
+
if ((await readFile(resolvedPath, "utf8")) !== expectedSource) return false;
|
|
189
|
+
await rename(temporaryPath, resolvedPath);
|
|
190
|
+
renamed = true;
|
|
191
|
+
return true;
|
|
192
|
+
} finally {
|
|
193
|
+
if (!renamed) await rm(temporaryPath, { force: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function synchronizeMappedPrices(models: Record<string, ModelMapping>, ctx: any) {
|
|
198
|
+
const agentDir = getAgentDir();
|
|
199
|
+
const modelsPath = join(agentDir, "models.json");
|
|
200
|
+
const catalogPath = join(agentDir, "models-store.json");
|
|
201
|
+
let releaseLock: (() => Promise<void>) | undefined;
|
|
202
|
+
try {
|
|
203
|
+
const resolvedModelsPath = await realpath(modelsPath);
|
|
204
|
+
let source = await readJsonText(resolvedModelsPath);
|
|
205
|
+
let catalog = await readJsonText(catalogPath);
|
|
206
|
+
let result = updateMappedCosts(source.value, catalog.value, models);
|
|
207
|
+
if (!result.changed) {
|
|
208
|
+
notifyWarnings(ctx, result.warnings);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Lock the physical target so all symlink aliases coordinate on the same file.
|
|
213
|
+
releaseLock = await acquireModelLock(`${resolvedModelsPath}.lock`);
|
|
214
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
215
|
+
// Re-read under the lock and again immediately before rename to preserve external edits.
|
|
216
|
+
source = await readJsonText(resolvedModelsPath);
|
|
217
|
+
catalog = await readJsonText(catalogPath);
|
|
218
|
+
result = updateMappedCosts(source.value, catalog.value, models);
|
|
219
|
+
if (!result.changed) {
|
|
220
|
+
notifyWarnings(ctx, result.warnings);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (await replaceModelsFile(resolvedModelsPath, source.value, source.text)) {
|
|
224
|
+
notifyWarnings(ctx, result.warnings);
|
|
225
|
+
if (releaseLock) {
|
|
226
|
+
const release = releaseLock;
|
|
227
|
+
releaseLock = undefined;
|
|
228
|
+
try {
|
|
229
|
+
await release();
|
|
230
|
+
} catch (error) {
|
|
231
|
+
ctx.ui.notify(`Private model price sync could not release its lock: ${String(error)}`, "warning");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
await ctx.modelRegistry.refresh({ allowNetwork: false });
|
|
236
|
+
} catch (error) {
|
|
237
|
+
ctx.ui.notify(`Private model price sync: model registry refresh failed: ${String(error)}`, "warning");
|
|
238
|
+
}
|
|
239
|
+
ctx.ui.notify(`Synchronized private model pricing for ${result.changedModels.join(", ")}.`, "info");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
throw new Error("models.json changed repeatedly during price sync; pricing was not synchronized");
|
|
244
|
+
} catch (error) {
|
|
245
|
+
ctx.ui.notify(`Private model price sync failed: ${String(error)}`, "warning");
|
|
246
|
+
} finally {
|
|
247
|
+
if (releaseLock) {
|
|
248
|
+
try {
|
|
249
|
+
await releaseLock();
|
|
250
|
+
} catch (error) {
|
|
251
|
+
ctx.ui.notify(`Private model price sync could not release its lock: ${String(error)}`, "warning");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function readGlobalSettings() {
|
|
258
|
+
try {
|
|
259
|
+
return JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")) as unknown;
|
|
260
|
+
} catch {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export default function (pi: ExtensionAPI) {
|
|
266
|
+
const settings = parsePrivateLlmGatewaySettings(readGlobalSettings());
|
|
267
|
+
if (!settings) return;
|
|
268
|
+
|
|
269
|
+
for (const id of Object.keys(settings.models)) {
|
|
270
|
+
pi.registerVirtualModel<boolean>({
|
|
271
|
+
provider: "fallback",
|
|
272
|
+
id,
|
|
273
|
+
name: id,
|
|
274
|
+
thinkingLevels: ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
|
|
275
|
+
route(request, ctx) {
|
|
276
|
+
const mapping = settings.models[id];
|
|
277
|
+
const failedPrivate = request.reason === "retry" &&
|
|
278
|
+
request.failed?.model.provider === mapping.private.provider &&
|
|
279
|
+
request.failed?.model.id === mapping.private.id;
|
|
280
|
+
const preferPublic = request.state === true || failedPrivate;
|
|
281
|
+
const route = chooseFallbackRoute(
|
|
282
|
+
id,
|
|
283
|
+
ctx,
|
|
284
|
+
preferPublic,
|
|
285
|
+
settings.models,
|
|
286
|
+
settings.publicModelFailover,
|
|
287
|
+
);
|
|
288
|
+
return { ...route, thinkingLevel: request.thinkingLevel };
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (settings.priceSync && Object.keys(settings.models).length > 0) {
|
|
294
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
295
|
+
await synchronizeMappedPrices(settings.models, ctx);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@holysoles/pi-gatewayhelper",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/holysoles/pi-packages"
|
|
7
|
+
},
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.ts",
|
|
13
|
+
"routing.mjs"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
"./routing": "./routing.mjs"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --experimental-strip-types --test tests/private-llm-gateway.mjs"
|
|
23
|
+
},
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./index.ts"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
}
|
package/routing.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function isEndpoint(value) {
|
|
6
|
+
return isRecord(value) &&
|
|
7
|
+
typeof value.provider === "string" && value.provider.trim().length > 0 &&
|
|
8
|
+
typeof value.id === "string" && value.id.trim().length > 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parsePrivateLlmGatewaySettings(settings) {
|
|
12
|
+
if (!isRecord(settings) || !Object.hasOwn(settings, "privateLlmGateway")) return undefined;
|
|
13
|
+
|
|
14
|
+
const section = settings.privateLlmGateway;
|
|
15
|
+
if (!isRecord(section) || !Object.hasOwn(section, "models")) return undefined;
|
|
16
|
+
if (!isRecord(section.models)) return undefined;
|
|
17
|
+
if (section.publicModelFailover !== undefined && typeof section.publicModelFailover !== "boolean") {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
if (section.priceSync !== undefined && typeof section.priceSync !== "boolean") return undefined;
|
|
21
|
+
|
|
22
|
+
const modelEntries = [];
|
|
23
|
+
for (const [id, mapping] of Object.entries(section.models)) {
|
|
24
|
+
if (id.trim().length === 0 || !isRecord(mapping) ||
|
|
25
|
+
!isEndpoint(mapping.private) || !isEndpoint(mapping.public)) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
modelEntries.push([id, {
|
|
29
|
+
private: { provider: mapping.private.provider, id: mapping.private.id },
|
|
30
|
+
public: { provider: mapping.public.provider, id: mapping.public.id },
|
|
31
|
+
}]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
models: Object.fromEntries(modelEntries),
|
|
36
|
+
publicModelFailover: section.publicModelFailover ?? true,
|
|
37
|
+
priceSync: section.priceSync ?? true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function chooseFallbackRoute(id, ctx, preferPublic, models, publicModelFailover = true) {
|
|
42
|
+
const mapping = models?.[id];
|
|
43
|
+
if (!mapping) throw new Error(`No mapping configured for ${id}`);
|
|
44
|
+
if (preferPublic && !publicModelFailover) {
|
|
45
|
+
throw new Error(`Public model failover is disabled for ${id}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const privateModel = ctx.modelRegistry.find(mapping.private.provider, mapping.private.id);
|
|
49
|
+
if (!publicModelFailover) {
|
|
50
|
+
if (!privateModel || !ctx.modelRegistry.hasConfiguredAuth(privateModel)) {
|
|
51
|
+
throw new Error(`No available model for ${id}`);
|
|
52
|
+
}
|
|
53
|
+
return { model: privateModel, state: undefined };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const usePublic = preferPublic || !privateModel || !ctx.modelRegistry.hasConfiguredAuth(privateModel);
|
|
57
|
+
const model = usePublic
|
|
58
|
+
? ctx.modelRegistry.find(mapping.public.provider, mapping.public.id)
|
|
59
|
+
: privateModel;
|
|
60
|
+
if (!model) throw new Error(`No available model for ${id}`);
|
|
61
|
+
return { model, state: usePublic || undefined };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveFallbackModel(id, ctx, models, publicModelFailover = true) {
|
|
65
|
+
return chooseFallbackRoute(id, ctx, false, models, publicModelFailover).model;
|
|
66
|
+
}
|