pi-custom-provider-model 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 +175 -0
- package/docs/design.md +54 -0
- package/package.json +53 -0
- package/src/discovery.ts +124 -0
- package/src/index.ts +63 -0
- package/src/limits.ts +75 -0
- package/src/probes.ts +150 -0
- package/src/server.ts +109 -0
- package/src/service.ts +205 -0
- package/src/standalone.ts +15 -0
- package/src/storage.ts +117 -0
- package/src/types.ts +112 -0
- package/web/app.js +589 -0
- package/web/index.html +73 -0
- package/web/style.css +293 -0
package/src/probes.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { randomBytes, randomInt } from "node:crypto";
|
|
2
|
+
import { deflateSync } from "node:zlib";
|
|
3
|
+
import { Type, validateToolCall, type Api, type AssistantMessage, type Context, type Model, type Tool } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { endpointUrls } from "./discovery.ts";
|
|
6
|
+
import { APIS, AppError, type Protocol } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export const CAPABILITIES = ["chat", "tools", "reasoning", "vision"] as const;
|
|
9
|
+
export type Capability = typeof CAPABILITIES[number];
|
|
10
|
+
export interface ProbeResult {
|
|
11
|
+
capability: Capability;
|
|
12
|
+
outcome: "supported" | "inconclusive" | "failed";
|
|
13
|
+
code: string;
|
|
14
|
+
ok: boolean;
|
|
15
|
+
url: string;
|
|
16
|
+
status?: number;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
requests: number;
|
|
19
|
+
inputTokens: number;
|
|
20
|
+
outputTokens: number;
|
|
21
|
+
reasoningTokens?: number;
|
|
22
|
+
stopReason?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Generate a fresh, lossless image locally. The answer is never supplied in text.
|
|
26
|
+
function visionChallenge() {
|
|
27
|
+
const palette: [string, number[]][] = [
|
|
28
|
+
["red", [255, 0, 0]], ["green", [0, 180, 0]], ["blue", [0, 0, 255]],
|
|
29
|
+
["yellow", [255, 255, 0]], ["black", [0, 0, 0]], ["white", [255, 255, 255]],
|
|
30
|
+
];
|
|
31
|
+
const colors = Array.from({ length: 4 }, () => palette.splice(randomInt(palette.length), 1)[0]);
|
|
32
|
+
const pixels = Buffer.alloc(128 * (128 * 3 + 1));
|
|
33
|
+
for (let y = 0; y < 128; y++) for (let x = 0; x < 128; x++) {
|
|
34
|
+
const rgb = colors[(y >= 64 ? 2 : 0) + (x >= 64 ? 1 : 0)][1];
|
|
35
|
+
for (let c = 0; c < 3; c++) pixels[y * 385 + 1 + x * 3 + c] = rgb[c];
|
|
36
|
+
}
|
|
37
|
+
const chunk = (type: string, data: Buffer) => {
|
|
38
|
+
const content = Buffer.concat([Buffer.from(type), data]);
|
|
39
|
+
let crc = 0xffffffff;
|
|
40
|
+
for (const byte of content) {
|
|
41
|
+
crc ^= byte;
|
|
42
|
+
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
|
43
|
+
}
|
|
44
|
+
const size = Buffer.alloc(4); size.writeUInt32BE(data.length);
|
|
45
|
+
const checksum = Buffer.alloc(4); checksum.writeUInt32BE((crc ^ 0xffffffff) >>> 0);
|
|
46
|
+
return Buffer.concat([size, content, checksum]);
|
|
47
|
+
};
|
|
48
|
+
const header = Buffer.alloc(13); header.writeUInt32BE(128, 0); header.writeUInt32BE(128, 4); header[8] = 8; header[9] = 2;
|
|
49
|
+
const image = Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), chunk("IHDR", header), chunk("IDAT", deflateSync(pixels)), chunk("IEND", Buffer.alloc(0))]);
|
|
50
|
+
return { image: image.toString("base64"), answer: colors.map(([name]) => name) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const textOf = (message: AssistantMessage) => message.content.filter((c) => c.type === "text").map((c) => c.text).join("").trim();
|
|
54
|
+
const displayUrl = (url: string) => { const parsed = new URL(url); return `${parsed.origin}${parsed.pathname}`; };
|
|
55
|
+
|
|
56
|
+
export async function probeModel(runtime: ModelRuntime, original: Model<Api>, apiKey: string | undefined, capability: Capability, signal?: AbortSignal): Promise<ProbeResult> {
|
|
57
|
+
if (!APIS.includes(original.api as Protocol)) throw new AppError("This model's API override cannot be tested here.");
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
const timeout = AbortSignal.any([AbortSignal.timeout(capability === "chat" ? 30000 : 60000), ...(signal ? [signal] : [])]);
|
|
60
|
+
const model = { ...original, maxTokens: Math.min(original.maxTokens, capability === "reasoning" ? 2048 : 256) };
|
|
61
|
+
// Enable only in this transient runtime so Pi actually sends the capability probe.
|
|
62
|
+
if (capability === "reasoning") model.reasoning = true;
|
|
63
|
+
if (capability === "vision") model.input = ["text", "image"];
|
|
64
|
+
let status: number | undefined;
|
|
65
|
+
let url = displayUrl(endpointUrls(model.baseUrl, model.api as Protocol).chat);
|
|
66
|
+
let requests = 0, inputTokens = 0, outputTokens = 0;
|
|
67
|
+
let reasoningTokens: number | undefined, stopReason: string | undefined;
|
|
68
|
+
let developerRoleRejected = false;
|
|
69
|
+
const report = (outcome: ProbeResult["outcome"], code: string): ProbeResult => ({
|
|
70
|
+
capability, outcome, code, ok: outcome === "supported", url, status, durationMs: Date.now() - started,
|
|
71
|
+
requests, inputTokens, outputTokens, reasoningTokens, stopReason,
|
|
72
|
+
});
|
|
73
|
+
const complete = async (context: Context) => {
|
|
74
|
+
// Exercise Pi's system/developer-role serialization, not just a bare user message.
|
|
75
|
+
const result = await runtime.completeSimple(model, { ...context, systemPrompt: "Follow the user's instructions for this synthetic capability check." }, {
|
|
76
|
+
apiKey, signal: timeout, maxTokens: model.maxTokens, maxRetries: 0, timeoutMs: 60000,
|
|
77
|
+
transport: "sse", cacheRetention: "none",
|
|
78
|
+
...(capability === "reasoning" ? { reasoning: "low" as const, thinkingBudgets: { low: 1024 } } : {}),
|
|
79
|
+
fetch: async (request, init) => {
|
|
80
|
+
const target = new URL(request instanceof Request ? request.url : String(request));
|
|
81
|
+
// Never expose credentials in model-override query strings or URL userinfo.
|
|
82
|
+
url = displayUrl(target.toString());
|
|
83
|
+
status = undefined; requests++;
|
|
84
|
+
const response = await fetch(request, { ...init, redirect: "manual" });
|
|
85
|
+
status = response.status;
|
|
86
|
+
return response;
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
inputTokens += result.usage.input; outputTokens += result.usage.output;
|
|
90
|
+
if (result.usage.reasoning !== undefined) reasoningTokens = (reasoningTokens ?? 0) + result.usage.reasoning;
|
|
91
|
+
stopReason = result.stopReason;
|
|
92
|
+
if ((status === 400 || status === 422) && result.errorMessage) {
|
|
93
|
+
// Inspect only for a known compatibility error; never return upstream text.
|
|
94
|
+
developerRoleRejected = /unknown variant\s+[`'"]developer[`'"]/i.test(result.errorMessage)
|
|
95
|
+
|| /(?:unsupported|invalid)\s+(?:message\s+)?role\s*:?\s*[`'"]?developer\b/i.test(result.errorMessage)
|
|
96
|
+
|| /(?:role\s*:?\s*)?[`'"]?developer[`'"]?\s+(?:role\s+)?(?:is\s+)?not supported/i.test(result.errorMessage);
|
|
97
|
+
}
|
|
98
|
+
if (result.stopReason === "error" || result.stopReason === "aborted" || (status !== undefined && status >= 300)) throw new Error("probe failed");
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
const user = (text: string): Context => ({ messages: [{ role: "user", content: text, timestamp: Date.now() }] });
|
|
102
|
+
try {
|
|
103
|
+
if (capability === "chat") {
|
|
104
|
+
const result = await complete(user("Reply with the single word OK."));
|
|
105
|
+
return textOf(result) ? report("supported", "chat_reply") : report("inconclusive", "no_text");
|
|
106
|
+
}
|
|
107
|
+
if (capability === "tools") {
|
|
108
|
+
const nonce = randomBytes(8).toString("hex");
|
|
109
|
+
const tool: Tool = { name: "provider_probe", description: "A harmless local capability check. Returns a receipt to repeat verbatim.",
|
|
110
|
+
parameters: Type.Object({ nonce: Type.Literal(nonce) }, { additionalProperties: false }) };
|
|
111
|
+
const context = user(`Call provider_probe once with nonce "${nonce}". After receiving its result, reply with only the receipt value.`);
|
|
112
|
+
context.tools = [tool];
|
|
113
|
+
const first = await complete(context);
|
|
114
|
+
const calls = first.content.filter((c) => c.type === "toolCall");
|
|
115
|
+
if (!calls.length) return report("inconclusive", "no_tool_call");
|
|
116
|
+
if (first.stopReason !== "toolUse" || calls.length !== 1) return report("inconclusive", "invalid_tool_call");
|
|
117
|
+
try { validateToolCall([tool], calls[0]); } catch { return report("inconclusive", "invalid_tool_call"); }
|
|
118
|
+
const receipt = randomBytes(12).toString("hex");
|
|
119
|
+
context.messages.push(first, { role: "toolResult", toolCallId: calls[0].id, toolName: tool.name,
|
|
120
|
+
content: [{ type: "text", text: JSON.stringify({ receipt }) }], isError: false, timestamp: Date.now() });
|
|
121
|
+
const second = await complete(context);
|
|
122
|
+
return second.stopReason === "stop" && textOf(second) === receipt ? report("supported", "tool_roundtrip") : report("inconclusive", "tool_result_unconfirmed");
|
|
123
|
+
}
|
|
124
|
+
if (capability === "reasoning") {
|
|
125
|
+
if (model.maxTokens < 2048) return report("inconclusive", "reasoning_budget");
|
|
126
|
+
const result = await complete(user("Solve this carefully: a box has 17 red and 23 blue balls. How many must be drawn without looking to guarantee 3 of each color? Give a concise answer."));
|
|
127
|
+
const observed = result.content.some((c) => c.type === "thinking" && (!!c.thinking.trim() || !!c.thinkingSignature || c.redacted));
|
|
128
|
+
return observed || (result.usage.reasoning ?? 0) > 0 ? report("supported", "reasoning_observed") : report("inconclusive", "no_reasoning_evidence");
|
|
129
|
+
}
|
|
130
|
+
const challenge = visionChallenge();
|
|
131
|
+
const result = await complete({ messages: [{ role: "user", timestamp: Date.now(), content: [
|
|
132
|
+
{ type: "text", text: 'Identify the four solid quadrant colors in the image: top-left, top-right, bottom-left, bottom-right. Reply only with a JSON array of four lowercase color names. Use red, green, blue, yellow, black or white.' },
|
|
133
|
+
{ type: "image", mimeType: "image/png", data: challenge.image },
|
|
134
|
+
] }] });
|
|
135
|
+
let answer: unknown;
|
|
136
|
+
try { answer = JSON.parse(textOf(result).replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "")); } catch { /* inconclusive */ }
|
|
137
|
+
return Array.isArray(answer) && answer.length === 4 && answer.every((c, i) => c === challenge.answer[i])
|
|
138
|
+
? report("supported", "vision_matched") : report("inconclusive", "vision_unconfirmed");
|
|
139
|
+
} catch {
|
|
140
|
+
// Fixed codes only: upstream text, thinking, tool arguments and errors stay private.
|
|
141
|
+
const code = timeout.aborted ? (signal?.aborted ? "cancelled" : "timeout")
|
|
142
|
+
: status === 401 || status === 403 ? "auth_failed"
|
|
143
|
+
: status === 429 ? "rate_limited"
|
|
144
|
+
: status && status >= 300 && status < 400 ? "redirect"
|
|
145
|
+
: developerRoleRejected ? "developer_role_unsupported"
|
|
146
|
+
: status && status >= 500 ? "gateway_error"
|
|
147
|
+
: status && status >= 400 ? "request_rejected" : status ? "invalid_stream" : "connection_failed";
|
|
148
|
+
return report("failed", code);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { ProviderService } from "./service.ts";
|
|
6
|
+
import { AppError, object } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
async function body(request: IncomingMessage) {
|
|
9
|
+
if (!request.headers["content-type"]?.startsWith("application/json")) throw new AppError("JSON content type required.", 415);
|
|
10
|
+
let size = 0;
|
|
11
|
+
const chunks: Buffer[] = [];
|
|
12
|
+
for await (const chunk of request) {
|
|
13
|
+
size += chunk.length;
|
|
14
|
+
if (size > 2_000_000) throw new AppError("Request too large.", 413);
|
|
15
|
+
chunks.push(chunk);
|
|
16
|
+
}
|
|
17
|
+
try { const value = JSON.parse(Buffer.concat(chunks).toString("utf8")); if (object(value)) return value; }
|
|
18
|
+
catch { /* handled below */ }
|
|
19
|
+
throw new AppError("Invalid JSON body.");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function json(response: ServerResponse, status: number, value: unknown) {
|
|
23
|
+
response.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
24
|
+
response.end(JSON.stringify(value));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function startManager(dir: string, onSaved?: () => Promise<void>) {
|
|
28
|
+
const token = randomBytes(32).toString("hex");
|
|
29
|
+
const service = new ProviderService(dir, onSaved);
|
|
30
|
+
await service.initialize();
|
|
31
|
+
const lifecycle = new AbortController();
|
|
32
|
+
let origin = "";
|
|
33
|
+
let mutationPending = false;
|
|
34
|
+
const server = createServer(async (request, response) => {
|
|
35
|
+
response.setHeader("Cache-Control", "no-store");
|
|
36
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
37
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
38
|
+
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
|
|
39
|
+
try {
|
|
40
|
+
if (request.headers.host !== new URL(origin).host || (request.headers.origin && request.headers.origin !== origin)) {
|
|
41
|
+
throw new AppError("Origin not allowed.", 403);
|
|
42
|
+
}
|
|
43
|
+
const path = new URL(request.url ?? "/", origin).pathname;
|
|
44
|
+
if (!path.startsWith("/api/")) {
|
|
45
|
+
if (request.method !== "GET") throw new AppError("Method not allowed.", 405);
|
|
46
|
+
const assets: Record<string, [string, string]> = {
|
|
47
|
+
"/": ["index.html", "text/html"], "/app.js": ["app.js", "text/javascript"], "/style.css": ["style.css", "text/css"],
|
|
48
|
+
};
|
|
49
|
+
const asset = assets[path];
|
|
50
|
+
if (!asset) throw new AppError("Not found.", 404);
|
|
51
|
+
const content = await readFile(fileURLToPath(new URL(`../web/${asset[0]}`, import.meta.url)));
|
|
52
|
+
response.writeHead(200, { "Content-Type": `${asset[1]}; charset=utf-8` });
|
|
53
|
+
response.end(content);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const candidate = request.headers["x-manager-token"];
|
|
57
|
+
if (typeof candidate !== "string" || !/^[a-f0-9]{64}$/.test(candidate) || !timingSafeEqual(Buffer.from(candidate), Buffer.from(token))) {
|
|
58
|
+
throw new AppError("Open the manager using the link printed by Pi.", 401);
|
|
59
|
+
}
|
|
60
|
+
if (path === "/api/state" && request.method === "GET") { json(response, 200, await service.state()); return; }
|
|
61
|
+
if (request.method !== "POST") throw new AppError("Method not allowed.", 405);
|
|
62
|
+
const input = await body(request);
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
response.on("close", () => { if (!response.writableEnded) controller.abort(); });
|
|
65
|
+
const signal = AbortSignal.any([controller.signal, lifecycle.signal]);
|
|
66
|
+
const writes = ["/api/save", "/api/delete", "/api/remove-key", "/api/default"];
|
|
67
|
+
const isWrite = writes.includes(path);
|
|
68
|
+
if (isWrite && mutationPending) throw new AppError("Another save is in progress. Try again.", 409);
|
|
69
|
+
if (isWrite) mutationPending = true;
|
|
70
|
+
try {
|
|
71
|
+
let result: unknown;
|
|
72
|
+
switch (path) {
|
|
73
|
+
case "/api/preview": result = await service.preview(input); break;
|
|
74
|
+
case "/api/model-limits": result = service.modelLimits(input.modelId); break;
|
|
75
|
+
case "/api/connection": result = await service.fetchModels(input, false, signal); break;
|
|
76
|
+
case "/api/discover": result = await service.fetchModels(input, true, signal); break;
|
|
77
|
+
case "/api/test-model": result = await service.testModel(input.provider, input.modelId, signal, input.capability); break;
|
|
78
|
+
case "/api/save": result = await service.save(input); break;
|
|
79
|
+
case "/api/delete": result = await service.remove(input.id, input.revision); break;
|
|
80
|
+
case "/api/remove-key": result = await service.removeKey(input.id); break;
|
|
81
|
+
case "/api/default": result = await service.setDefault(input.id, input.modelId); break;
|
|
82
|
+
default: throw new AppError("Not found.", 404);
|
|
83
|
+
}
|
|
84
|
+
json(response, 200, result);
|
|
85
|
+
} finally { if (isWrite) mutationPending = false; }
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (!response.destroyed && !response.headersSent) {
|
|
88
|
+
json(response, error instanceof AppError ? error.status : 500,
|
|
89
|
+
{ error: error instanceof AppError ? error.message : "Operation failed. Check the configuration and filesystem permissions." });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
|
94
|
+
const address = server.address();
|
|
95
|
+
if (!address || typeof address === "string") throw new Error("Could not start manager.");
|
|
96
|
+
origin = `http://127.0.0.1:${address.port}`;
|
|
97
|
+
server.unref();
|
|
98
|
+
let closing: Promise<void> | undefined;
|
|
99
|
+
return {
|
|
100
|
+
url: `${origin}/#token=${token}`, origin, token,
|
|
101
|
+
close() {
|
|
102
|
+
if (!closing) {
|
|
103
|
+
lifecycle.abort();
|
|
104
|
+
closing = new Promise<void>((resolve) => { server.close(() => resolve()); server.closeAllConnections(); });
|
|
105
|
+
}
|
|
106
|
+
return closing;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ModelRuntime, readStoredCredential, SettingsManager, VERSION } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { ConfigStore, parseDocument, patch, readText } from "./storage.ts";
|
|
4
|
+
import { discover, endpointUrls, requestHeaders } from "./discovery.ts";
|
|
5
|
+
import { CAPABILITIES, probeModel, type Capability } from "./probes.ts";
|
|
6
|
+
import { LimitCatalog, LIMIT_FIELDS } from "./limits.ts";
|
|
7
|
+
import { APIS, AppError, mergeCompat, validateDraft, validateId, type JsonObject, type ModelInput, type ProviderDraft } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
function visibleCompat(entry: JsonObject): ModelInput["compat"] {
|
|
10
|
+
return typeof entry.compat?.supportsDeveloperRole === "boolean" ? { supportsDeveloperRole: entry.compat.supportsDeveloperRole } : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function visibleModel(model: JsonObject): ModelInput {
|
|
14
|
+
const visible = Object.fromEntries(["id", "name", "input", "reasoning", "contextWindow", "maxTokens"]
|
|
15
|
+
.filter((key) => model[key] !== undefined).map((key) => [key, model[key]])) as unknown as ModelInput;
|
|
16
|
+
const compat = visibleCompat(model);
|
|
17
|
+
if (compat) visible.compat = compat;
|
|
18
|
+
return visible;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ProviderService {
|
|
22
|
+
readonly store: ConfigStore;
|
|
23
|
+
private reserved: Set<string> = new Set();
|
|
24
|
+
private limitCatalog = new LimitCatalog();
|
|
25
|
+
constructor(readonly dir: string, private onSaved?: () => Promise<void>) { this.store = new ConfigStore(dir); }
|
|
26
|
+
|
|
27
|
+
private async runtime() {
|
|
28
|
+
return ModelRuntime.create({ authPath: join(this.dir, "auth.json"), modelsPath: this.store.path, refreshOnCreate: false });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async initialize() {
|
|
32
|
+
const baseline = await ModelRuntime.create({ authPath: join(this.dir, "auth.json"), modelsPath: null, refreshOnCreate: false });
|
|
33
|
+
this.reserved = new Set(baseline.getProviders().map((p) => p.id));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async state() {
|
|
37
|
+
const current = await this.store.read();
|
|
38
|
+
const settings = parseDocument(await readText(join(this.dir, "settings.json")));
|
|
39
|
+
const providers = Object.entries(current.data.providers).map(([id, entry]) => {
|
|
40
|
+
const p = entry as JsonObject;
|
|
41
|
+
const credential = readStoredCredential(id, join(this.dir, "auth.json"));
|
|
42
|
+
return {
|
|
43
|
+
id, baseUrl: p.baseUrl ?? "", api: p.api ?? "", authHeader: p.authHeader ?? false,
|
|
44
|
+
compat: visibleCompat(p),
|
|
45
|
+
models: (p.models ?? []).map((model: JsonObject) => ({ ...visibleModel(model), limitHints: this.limitCatalog.lookup(model.id) })),
|
|
46
|
+
readOnly: this.reserved.has(id) || !APIS.includes(p.api) || !!p.oauth,
|
|
47
|
+
credentialType: credential?.type ?? null,
|
|
48
|
+
authSource: credential ? `auth.json (${credential.type})` : p.apiKey ? "models.json" : "not configured",
|
|
49
|
+
hasHiddenSettings: !!(p.headers || p.compat || p.modelOverrides || p.models?.some((m: JsonObject) => m.headers || m.api || m.baseUrl || m.compat)),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
dir: this.dir, piVersion: VERSION, revision: current.revision, providers,
|
|
54
|
+
defaultProvider: settings.defaultProvider ?? "", defaultModel: settings.defaultModel ?? "",
|
|
55
|
+
reservedIds: [...this.reserved],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private assertEditable(id: string, entry?: JsonObject) {
|
|
60
|
+
if (this.reserved.has(id) || entry?.oauth || (entry && !APIS.includes(entry.api))) {
|
|
61
|
+
throw new AppError("This provider is managed by Pi or another integration. Use a new custom provider ID.");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async prepare(input: unknown, signal?: AbortSignal) {
|
|
66
|
+
const draft = validateDraft(input);
|
|
67
|
+
const current = await this.store.read();
|
|
68
|
+
const existing = current.data.providers[draft.id];
|
|
69
|
+
this.assertEditable(draft.id, existing);
|
|
70
|
+
const credential = readStoredCredential(draft.id, join(this.dir, "auth.json"));
|
|
71
|
+
if (credential?.type === "oauth") throw new AppError("Manage this provider's OAuth credentials using Pi /login.");
|
|
72
|
+
const runtime = await this.runtime();
|
|
73
|
+
const providerCompat = mergeCompat(existing?.compat, draft.compat);
|
|
74
|
+
try {
|
|
75
|
+
runtime.registerProvider(draft.id, {
|
|
76
|
+
...existing, baseUrl: draft.baseUrl, api: draft.api,
|
|
77
|
+
compat: providerCompat,
|
|
78
|
+
...(draft.authHeader === undefined ? {} : { authHeader: draft.authHeader }),
|
|
79
|
+
models: draft.models.map((model) => {
|
|
80
|
+
const old = { ...(existing?.models?.find((m: JsonObject) => m.id === model.id) ?? {}) };
|
|
81
|
+
for (const key of ["name", "input", "reasoning", "contextWindow", "maxTokens"]) delete old[key];
|
|
82
|
+
const merged = { ...old, ...model };
|
|
83
|
+
// registerProvider requires complete model metadata, unlike models.json.
|
|
84
|
+
// These documented Pi defaults are only materialized for the test runtime.
|
|
85
|
+
return {
|
|
86
|
+
...merged, name: model.name ?? model.id, input: model.input ?? ["text"],
|
|
87
|
+
reasoning: model.reasoning ?? false, contextWindow: model.contextWindow ?? 128000,
|
|
88
|
+
maxTokens: model.maxTokens ?? 16384,
|
|
89
|
+
cost: merged.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
90
|
+
compat: { ...providerCompat, ...mergeCompat(old.compat, model.compat) },
|
|
91
|
+
};
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
await runtime.refresh({ allowNetwork: false, providers: [draft.id], signal });
|
|
95
|
+
if (!runtime.getProvider(draft.id)) throw new Error("Provider unavailable");
|
|
96
|
+
} catch {
|
|
97
|
+
throw new AppError("Pi rejected the provider configuration. Check model fields and any existing advanced settings.");
|
|
98
|
+
}
|
|
99
|
+
return { draft, runtime, existing, credential };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async preview(input: unknown) {
|
|
103
|
+
const draft = validateDraft(input);
|
|
104
|
+
return endpointUrls(draft.baseUrl, draft.api);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
modelLimits(input: unknown) {
|
|
108
|
+
if (typeof input !== "string" || !input.trim() || input.length > 300 || /[\x00-\x1f]/.test(input)) throw new AppError("Enter a valid model ID.");
|
|
109
|
+
return this.limitCatalog.lookup(input.trim());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async resolveAuth(runtime: ModelRuntime, draft: ProviderDraft, signal?: AbortSignal) {
|
|
113
|
+
try {
|
|
114
|
+
const auth = await runtime.getAuth(draft.id, { apiKey: draft.apiKey, signal });
|
|
115
|
+
if (!auth) throw new Error("Missing auth");
|
|
116
|
+
return auth;
|
|
117
|
+
} catch {
|
|
118
|
+
throw new AppError("No usable API key. Enter a key or check the provider's existing Pi credentials/environment variables.", 401);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async fetchModels(input: unknown, allPages: boolean, signal?: AbortSignal) {
|
|
123
|
+
const { draft, runtime, existing } = await this.prepare(input, signal);
|
|
124
|
+
const auth = await this.resolveAuth(runtime, draft, signal);
|
|
125
|
+
const result = await discover({ api: draft.api, baseUrl: draft.baseUrl,
|
|
126
|
+
headers: requestHeaders(draft.api, auth.auth.apiKey, auth.auth.headers), allPages, signal });
|
|
127
|
+
for (const model of result.models) {
|
|
128
|
+
const override = existing?.models?.find((m: JsonObject) => m.id === model.id);
|
|
129
|
+
const differentEndpoint = (override?.baseUrl && override.baseUrl !== draft.baseUrl) || (override?.api && override.api !== draft.api);
|
|
130
|
+
model.limitHints = this.limitCatalog.lookup(model.id, differentEndpoint ? {} : model.limitHints);
|
|
131
|
+
for (const field of LIMIT_FIELDS) {
|
|
132
|
+
if (model.limitHints[field]) model[field] = model.limitHints[field]!.value;
|
|
133
|
+
else delete model[field];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async testModel(input: unknown, modelId: string, signal?: AbortSignal, capability: unknown = "chat") {
|
|
140
|
+
if (!CAPABILITIES.includes(capability as Capability)) throw new AppError("Choose a supported capability test.");
|
|
141
|
+
const { draft, runtime } = await this.prepare(input, signal);
|
|
142
|
+
const model = runtime.getModel(draft.id, modelId);
|
|
143
|
+
if (!model) throw new AppError("Choose a model first.");
|
|
144
|
+
await this.resolveAuth(runtime, draft, signal);
|
|
145
|
+
return probeModel(runtime, model, draft.apiKey, capability as Capability, signal);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async save(input: unknown) {
|
|
149
|
+
const { draft, runtime, credential } = await this.prepare(input);
|
|
150
|
+
if (!draft.models.length) throw new AppError("Select or add at least one model before saving.");
|
|
151
|
+
const settings = parseDocument(await readText(join(this.dir, "settings.json")));
|
|
152
|
+
if (settings.defaultProvider === draft.id && !draft.models.some((m) => m.id === settings.defaultModel)) {
|
|
153
|
+
throw new AppError("Choose another default model before removing the current default from this provider.");
|
|
154
|
+
}
|
|
155
|
+
if (draft.apiKey && credential?.type === "api_key" && credential.env && Object.keys(credential.env).length) {
|
|
156
|
+
throw new AppError("This credential has provider-scoped environment settings. Update its key with Pi's native auth configuration to preserve those settings.");
|
|
157
|
+
}
|
|
158
|
+
await this.store.save(draft);
|
|
159
|
+
let warning: string | undefined;
|
|
160
|
+
if (draft.apiKey) {
|
|
161
|
+
try {
|
|
162
|
+
// Let Pi own auth.json format, interpolation, locking and login semantics.
|
|
163
|
+
// The form accepts a literal key. Escape Pi's template/command syntax on storage.
|
|
164
|
+
const key = draft.apiKey.replace(/\$/g, "$$$$").replace(/^!/, "$!");
|
|
165
|
+
await runtime.login(draft.id, "api_key", { prompt: async () => key, notify: () => {} });
|
|
166
|
+
} catch { warning = "Models saved, but key storage did not finish successfully. Use Pi /login before using this provider."; }
|
|
167
|
+
}
|
|
168
|
+
try { await this.onSaved?.(); }
|
|
169
|
+
catch { warning = [warning, "Configuration saved. Reopen Pi /model to refresh the active runtime."].filter(Boolean).join(" "); }
|
|
170
|
+
return { ok: true, warning, state: await this.state() };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async remove(idInput: unknown, expected: string) {
|
|
174
|
+
const id = validateId(idInput);
|
|
175
|
+
const current = await this.store.read();
|
|
176
|
+
this.assertEditable(id, current.data.providers[id]);
|
|
177
|
+
const settings = parseDocument(await readText(join(this.dir, "settings.json")));
|
|
178
|
+
if (settings.defaultProvider === id) throw new AppError("Choose another default provider before deleting this one.");
|
|
179
|
+
await this.store.update(expected, (text) => patch(text, ["providers", id], undefined));
|
|
180
|
+
await this.onSaved?.();
|
|
181
|
+
return { ok: true, message: "Provider removed. Its credentials remain in auth.json; remove them separately if needed.", state: await this.state() };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async removeKey(idInput: unknown) {
|
|
185
|
+
const id = validateId(idInput);
|
|
186
|
+
const credential = readStoredCredential(id, join(this.dir, "auth.json"));
|
|
187
|
+
if (credential?.type === "oauth") throw new AppError("Use Pi /logout to manage OAuth credentials.");
|
|
188
|
+
const runtime = await this.runtime();
|
|
189
|
+
await runtime.logout(id);
|
|
190
|
+
await this.onSaved?.();
|
|
191
|
+
return { ok: true, state: await this.state() };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async setDefault(idInput: unknown, modelId: unknown) {
|
|
195
|
+
const id = validateId(idInput);
|
|
196
|
+
const runtime = await this.runtime();
|
|
197
|
+
if (typeof modelId !== "string" || !runtime.getModel(id, modelId)) throw new AppError("Save the provider and model before setting a default.");
|
|
198
|
+
const settings = SettingsManager.create(this.dir, this.dir, { projectTrusted: false });
|
|
199
|
+
if (settings.drainErrors().length) throw new AppError("Fix settings.json before setting a default.", 409);
|
|
200
|
+
settings.setDefaultModelAndProvider(id, modelId);
|
|
201
|
+
await settings.flush();
|
|
202
|
+
if (settings.drainErrors().length) throw new AppError("Could not save default settings.", 500);
|
|
203
|
+
return { ok: true, state: await this.state() };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { startManager } from "./server.ts";
|
|
4
|
+
|
|
5
|
+
const argument = process.argv.indexOf("--agent-dir");
|
|
6
|
+
if (argument >= 0 && !process.argv[argument + 1]) throw new Error("--agent-dir needs a directory.");
|
|
7
|
+
// Development mode defaults to an isolated sandbox. --real-config is explicit.
|
|
8
|
+
const dir = argument >= 0 ? resolve(process.argv[argument + 1])
|
|
9
|
+
: process.argv.includes("--real-config") ? getAgentDir() : resolve(".sandbox", "agent");
|
|
10
|
+
const manager = await startManager(dir);
|
|
11
|
+
console.log(`Pi Provider Manager\nConfig: ${dir}\nOpen: ${manager.url}\nCtrl+C to stop.`);
|
|
12
|
+
const keepAlive = setInterval(() => {}, 60000);
|
|
13
|
+
const stop = async () => { clearInterval(keepAlive); await manager.close(); };
|
|
14
|
+
process.once("SIGINT", stop);
|
|
15
|
+
process.once("SIGTERM", stop);
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser";
|
|
5
|
+
import lockfile from "proper-lockfile";
|
|
6
|
+
import { AppError, mergeCompat, object, type JsonObject, type ModelInput, type ProviderDraft } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
function storedModel(model: ModelInput) {
|
|
9
|
+
const { compat: edits, ...fields } = model;
|
|
10
|
+
const compat = mergeCompat(undefined, edits);
|
|
11
|
+
return { ...fields, ...(Object.keys(compat).length ? { compat } : {}) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseDocument(text: string): JsonObject {
|
|
15
|
+
const errors: ParseError[] = [];
|
|
16
|
+
const value = parse(text.replace(/^\uFEFF/, ""), errors, { allowTrailingComma: false });
|
|
17
|
+
if (errors.length || !object(value)) throw new AppError("Configuration contains invalid JSON. Fix it before saving.", 409);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function patch(text: string, path: (string | number)[], value: unknown): string {
|
|
22
|
+
return applyEdits(text, modify(text, path, value, {
|
|
23
|
+
formattingOptions: { insertSpaces: true, tabSize: 2, eol: text.includes("\r\n") ? "\r\n" : "\n" },
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function revision(text: string): string {
|
|
28
|
+
return createHash("sha256").update(text).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readText(path: string, fallback = "{}\n"): Promise<string> {
|
|
32
|
+
try { return await readFile(path, "utf8"); }
|
|
33
|
+
catch (error: any) { if (error.code === "ENOENT") return fallback; throw error; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function atomicWrite(path: string, content: string): Promise<void> {
|
|
37
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
38
|
+
try {
|
|
39
|
+
await writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
40
|
+
await rename(temporary, path);
|
|
41
|
+
} finally { await unlink(temporary).catch(() => {}); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class ConfigStore {
|
|
45
|
+
readonly path: string;
|
|
46
|
+
constructor(readonly dir: string) { this.path = join(dir, "models.json"); }
|
|
47
|
+
|
|
48
|
+
async read() {
|
|
49
|
+
const text = await readText(this.path, '{"providers":{}}\n');
|
|
50
|
+
const data = parseDocument(text);
|
|
51
|
+
if (!object(data.providers)) throw new AppError("models.json must contain a providers object.", 409);
|
|
52
|
+
return { text, data, revision: revision(text) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async update(expected: string, transform: (text: string, data: JsonObject) => string) {
|
|
56
|
+
await mkdir(this.dir, { recursive: true });
|
|
57
|
+
const release = await lockfile.lock(this.path, { realpath: false, retries: { retries: 20, minTimeout: 20, maxTimeout: 100 } });
|
|
58
|
+
try {
|
|
59
|
+
const current = await this.read();
|
|
60
|
+
if (current.revision !== expected) throw new AppError("Configuration changed in another window. Reload before saving.", 409);
|
|
61
|
+
const next = transform(current.text, current.data);
|
|
62
|
+
parseDocument(next);
|
|
63
|
+
if (next === current.text) return current.revision;
|
|
64
|
+
const backupDir = join(this.dir, "provider-manager-backups");
|
|
65
|
+
await mkdir(backupDir, { recursive: true, mode: 0o700 });
|
|
66
|
+
await writeFile(join(backupDir, `${Date.now()}-${randomUUID()}.json`), current.text, { mode: 0o600 });
|
|
67
|
+
// Catch edits made by a non-cooperating editor during backup creation.
|
|
68
|
+
if ((await this.read()).revision !== current.revision) throw new AppError("Configuration changed while saving. Reload and retry.", 409);
|
|
69
|
+
await atomicWrite(this.path, next);
|
|
70
|
+
const backups = (await readdir(backupDir)).filter((f) => /^\d+-[\da-f-]+\.json$/.test(f)).sort().reverse();
|
|
71
|
+
await Promise.all(backups.slice(10).map((f) => unlink(join(backupDir, f)).catch(() => {})));
|
|
72
|
+
return revision(next);
|
|
73
|
+
} finally { await release(); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async save(draft: ProviderDraft) {
|
|
77
|
+
if (!draft.revision) throw new AppError("Reload configuration before saving.", 409);
|
|
78
|
+
return this.update(draft.revision, (text, data) => {
|
|
79
|
+
const path = ["providers", draft.id];
|
|
80
|
+
const existing = data.providers[draft.id];
|
|
81
|
+
const compat = mergeCompat(undefined, draft.compat);
|
|
82
|
+
if (!existing) return patch(text, path, {
|
|
83
|
+
baseUrl: draft.baseUrl, api: draft.api,
|
|
84
|
+
...(Object.keys(compat).length ? { compat } : {}),
|
|
85
|
+
...(draft.authHeader === undefined ? {} : { authHeader: draft.authHeader }), models: draft.models.map(storedModel),
|
|
86
|
+
});
|
|
87
|
+
let next = patch(patch(text, [...path, "baseUrl"], draft.baseUrl), [...path, "api"], draft.api);
|
|
88
|
+
if (draft.authHeader !== undefined) next = patch(next, [...path, "authHeader"], draft.authHeader);
|
|
89
|
+
if (draft.compat?.supportsDeveloperRole !== undefined && (draft.compat.supportsDeveloperRole !== null || existing.compat?.supportsDeveloperRole !== undefined)) {
|
|
90
|
+
next = patch(next, [...path, "compat", "supportsDeveloperRole"], draft.compat.supportsDeveloperRole ?? undefined);
|
|
91
|
+
}
|
|
92
|
+
// Update by ID, preserving hidden fields and comments on surviving models.
|
|
93
|
+
let oldModels = existing.models ?? [];
|
|
94
|
+
const keep = new Set(draft.models.map((m) => m.id));
|
|
95
|
+
for (let i = oldModels.length - 1; i >= 0; i--) {
|
|
96
|
+
if (!keep.has(oldModels[i].id)) next = patch(next, [...path, "models", i], undefined);
|
|
97
|
+
}
|
|
98
|
+
oldModels = parseDocument(next).providers[draft.id].models ?? [];
|
|
99
|
+
if (!existing.models) next = patch(next, [...path, "models"], []);
|
|
100
|
+
for (const model of draft.models) {
|
|
101
|
+
const index = oldModels.findIndex((m: JsonObject) => m.id === model.id);
|
|
102
|
+
if (index < 0) {
|
|
103
|
+
next = patch(next, [...path, "models", -1], storedModel(model));
|
|
104
|
+
oldModels.push(model);
|
|
105
|
+
} else {
|
|
106
|
+
for (const field of ["name", "contextWindow", "maxTokens", "reasoning", "input"] as const) {
|
|
107
|
+
next = patch(next, [...path, "models", index, field], model[field]);
|
|
108
|
+
}
|
|
109
|
+
if (model.compat?.supportsDeveloperRole !== undefined && (model.compat.supportsDeveloperRole !== null || oldModels[index].compat?.supportsDeveloperRole !== undefined)) {
|
|
110
|
+
next = patch(next, [...path, "models", index, "compat", "supportsDeveloperRole"], model.compat.supportsDeveloperRole ?? undefined);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return next;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|