@wax0629/pi-manager 0.1.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 +93 -0
- package/package.json +51 -0
- package/src/cli.mjs +155 -0
- package/src/defaults.mjs +215 -0
- package/src/gateway.mjs +199 -0
- package/src/pi-apply.mjs +230 -0
- package/src/pi-auth.mjs +109 -0
- package/src/pi-import.mjs +120 -0
- package/src/pi-native.mjs +239 -0
- package/src/profile.mjs +225 -0
- package/src/provider-discovery.mjs +146 -0
- package/src/provider-test.mjs +178 -0
- package/src/secrets.mjs +123 -0
- package/src/server.mjs +761 -0
- package/src/store.mjs +615 -0
- package/src/thinking.mjs +107 -0
- package/web/README.md +32 -0
- package/web/dist/assets/index-CW8mUwad.js +9 -0
- package/web/dist/assets/index-Dnr7dLeF.css +2 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/icons.svg +24 -0
- package/web/dist/index.html +17 -0
package/src/gateway.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { getThinkingLevelValue } from "./thinking.mjs";
|
|
4
|
+
|
|
5
|
+
function sendJson(res, status, value) {
|
|
6
|
+
const body = JSON.stringify(value);
|
|
7
|
+
res.statusCode = status;
|
|
8
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
9
|
+
res.setHeader("cache-control", "no-store");
|
|
10
|
+
res.end(body);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sameSecret(left, right) {
|
|
14
|
+
if (!left || !right) return false;
|
|
15
|
+
const a = Buffer.from(left);
|
|
16
|
+
const b = Buffer.from(right);
|
|
17
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function bearerToken(req) {
|
|
21
|
+
const header = req.headers.authorization || "";
|
|
22
|
+
return header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function upstreamUrl(baseUrl, endpoint) {
|
|
26
|
+
const root = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
27
|
+
return new URL(endpoint.replace(/^\//, ""), root).toString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readJson(req) {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let size = 0;
|
|
33
|
+
for await (const chunk of req) {
|
|
34
|
+
size += chunk.length;
|
|
35
|
+
if (size > 8 * 1024 * 1024) throw new Error("请求体过大");
|
|
36
|
+
chunks.push(chunk);
|
|
37
|
+
}
|
|
38
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(raw || "{}");
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error("请求体不是有效 JSON");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function copyResponseHeaders(upstream, response) {
|
|
47
|
+
for (const name of ["content-type", "cache-control", "x-request-id", "x-ratelimit-limit-requests", "x-ratelimit-remaining-requests", "x-ratelimit-reset-requests"]) {
|
|
48
|
+
const value = upstream.headers.get(name);
|
|
49
|
+
if (value) response.setHeader(name, value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function pipeResponse(upstream, response) {
|
|
54
|
+
copyResponseHeaders(upstream, response);
|
|
55
|
+
response.statusCode = upstream.status;
|
|
56
|
+
if (!upstream.body) {
|
|
57
|
+
response.end();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for await (const chunk of upstream.body) response.write(Buffer.from(chunk));
|
|
61
|
+
response.end();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createGateway({ getState, getCredential, onRequest = () => {} }) {
|
|
65
|
+
let server = null;
|
|
66
|
+
const stats = {
|
|
67
|
+
requests: 0,
|
|
68
|
+
successful: 0,
|
|
69
|
+
failed: 0,
|
|
70
|
+
lastRequestAt: null,
|
|
71
|
+
lastError: null
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function currentProvider() {
|
|
75
|
+
const state = getState();
|
|
76
|
+
return state.providers.find((provider) => provider.id === state.active.providerId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function currentModel() {
|
|
80
|
+
const state = getState();
|
|
81
|
+
const provider = currentProvider();
|
|
82
|
+
return provider?.models.find((model) => model.id === state.active.modelId);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function handle(req, res) {
|
|
86
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
87
|
+
if (req.method === "OPTIONS") {
|
|
88
|
+
res.statusCode = 204;
|
|
89
|
+
res.setHeader("access-control-allow-origin", "*");
|
|
90
|
+
res.setHeader("access-control-allow-headers", "authorization, content-type");
|
|
91
|
+
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
92
|
+
res.end();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const state = getState();
|
|
97
|
+
if (!sameSecret(bearerToken(req), state.gateway.clientKey)) {
|
|
98
|
+
sendJson(res, 401, { error: { message: "Invalid Manager gateway key", type: "authentication_error" } });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const provider = currentProvider();
|
|
103
|
+
if (url.pathname === "/health" && req.method === "GET") {
|
|
104
|
+
const model = currentModel();
|
|
105
|
+
sendJson(res, 200, {
|
|
106
|
+
status: "ok",
|
|
107
|
+
route: {
|
|
108
|
+
providerId: provider?.id || "",
|
|
109
|
+
modelId: state.active.modelId,
|
|
110
|
+
thinking: state.active.thinking,
|
|
111
|
+
upstreamThinking: getThinkingLevelValue(model, state.active.thinking)
|
|
112
|
+
},
|
|
113
|
+
stats
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (url.pathname === "/v1/models" && req.method === "GET") {
|
|
119
|
+
const models = provider?.models || [];
|
|
120
|
+
sendJson(res, 200, {
|
|
121
|
+
object: "list",
|
|
122
|
+
data: models.map((model) => ({ id: model.id, object: "model", created: 0, owned_by: provider.id }))
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (url.pathname !== "/v1/chat/completions" || req.method !== "POST") {
|
|
128
|
+
sendJson(res, 404, { error: { message: "Not Found", type: "invalid_request_error" } });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
stats.requests += 1;
|
|
133
|
+
stats.lastRequestAt = new Date().toISOString();
|
|
134
|
+
const startedAt = Date.now();
|
|
135
|
+
try {
|
|
136
|
+
if (!provider) throw new Error("当前渠道不存在");
|
|
137
|
+
if (provider.kind === "native-subscription") throw new Error("官方订阅由 Pi 原生 provider 直连,不经过 Manager gateway");
|
|
138
|
+
const body = await readJson(req);
|
|
139
|
+
const requestedModel = String(body.model || "");
|
|
140
|
+
const modelId = requestedModel.startsWith("pi-manager/") ? requestedModel.slice("pi-manager/".length) : requestedModel;
|
|
141
|
+
if (!provider.models.some((model) => model.id === modelId)) throw new Error(`模型不可用: ${requestedModel || "(empty)"}`);
|
|
142
|
+
const credential = getCredential(provider);
|
|
143
|
+
if (!credential) throw new Error(`渠道 ${provider.name} 尚未配置凭据`);
|
|
144
|
+
|
|
145
|
+
const upstreamResponse = await fetch(upstreamUrl(provider.baseUrl, "chat/completions"), {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: {
|
|
148
|
+
authorization: `Bearer ${credential}`,
|
|
149
|
+
"content-type": "application/json",
|
|
150
|
+
accept: body.stream ? "text/event-stream" : "application/json"
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({ ...body, model: modelId })
|
|
153
|
+
});
|
|
154
|
+
await pipeResponse(upstreamResponse, res);
|
|
155
|
+
if (upstreamResponse.ok) stats.successful += 1;
|
|
156
|
+
else stats.failed += 1;
|
|
157
|
+
onRequest({ providerId: provider.id, modelId, status: upstreamResponse.status, durationMs: Date.now() - startedAt });
|
|
158
|
+
} catch (error) {
|
|
159
|
+
stats.failed += 1;
|
|
160
|
+
stats.lastError = error.message;
|
|
161
|
+
onRequest({ providerId: provider?.id || "", modelId: state.active.modelId, status: 502, error: error.message, durationMs: Date.now() - startedAt });
|
|
162
|
+
sendJson(res, 502, { error: { message: error.message, type: "upstream_error" } });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
getStats() {
|
|
168
|
+
return { ...stats };
|
|
169
|
+
},
|
|
170
|
+
isRunning() {
|
|
171
|
+
return Boolean(server);
|
|
172
|
+
},
|
|
173
|
+
start() {
|
|
174
|
+
if (server) return Promise.resolve();
|
|
175
|
+
const state = getState();
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const nextServer = http.createServer((req, res) => {
|
|
178
|
+
handle(req, res).catch((error) => sendJson(res, 500, { error: { message: error.message, type: "server_error" } }));
|
|
179
|
+
});
|
|
180
|
+
nextServer.once("error", (error) => {
|
|
181
|
+
nextServer.close();
|
|
182
|
+
reject(error);
|
|
183
|
+
});
|
|
184
|
+
nextServer.listen(state.gateway.port, state.gateway.host, () => {
|
|
185
|
+
server = nextServer;
|
|
186
|
+
resolve();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
stop() {
|
|
191
|
+
if (!server) return Promise.resolve();
|
|
192
|
+
const oldServer = server;
|
|
193
|
+
server = null;
|
|
194
|
+
return new Promise((resolve) => oldServer.close(() => resolve()));
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export { upstreamUrl };
|
package/src/pi-apply.mjs
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { buildEnabledModels, buildModelOverrides, toPiModel } from "./profile.mjs";
|
|
5
|
+
import { resolvePiAgentDir } from "./pi-import.mjs";
|
|
6
|
+
import { writeJsonAtomic } from "./store.mjs";
|
|
7
|
+
|
|
8
|
+
const LIVE_FILES = ["settings.json", "models.json", "auth.json"];
|
|
9
|
+
|
|
10
|
+
function ensureDir(dir) {
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readJsonIfExists(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error && error.code === "ENOENT") return null;
|
|
19
|
+
throw new Error(`无法解析 ${filePath}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function copyIfExists(source, destination) {
|
|
24
|
+
if (!fs.existsSync(source)) return false;
|
|
25
|
+
ensureDir(path.dirname(destination));
|
|
26
|
+
fs.copyFileSync(source, destination);
|
|
27
|
+
try {
|
|
28
|
+
fs.chmodSync(destination, 0o600);
|
|
29
|
+
} catch {
|
|
30
|
+
// Best effort on filesystems without POSIX permissions.
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function timestampId(now = new Date()) {
|
|
36
|
+
return now.toISOString().replaceAll(":", "-").replaceAll(".", "-");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parsePiListModels(output) {
|
|
40
|
+
const refs = [];
|
|
41
|
+
for (const line of String(output || "").split(/\r?\n/)) {
|
|
42
|
+
const match = line.trim().match(/^(\S+)\s+(\S+)\s+/);
|
|
43
|
+
if (!match) continue;
|
|
44
|
+
const [, providerId, modelId] = match;
|
|
45
|
+
if (!providerId || providerId === "provider" || !modelId || modelId === "model") continue;
|
|
46
|
+
refs.push(`${providerId}/${modelId}`);
|
|
47
|
+
}
|
|
48
|
+
return refs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function mergeLiveSettings(existing, state) {
|
|
52
|
+
const provider = (state.providers || []).find((item) => item.id === state.active?.providerId);
|
|
53
|
+
if (!provider) throw new Error("当前渠道不存在");
|
|
54
|
+
const current = existing && typeof existing === "object" ? existing : {};
|
|
55
|
+
return {
|
|
56
|
+
...current,
|
|
57
|
+
defaultProvider: provider.kind === "native-subscription" ? provider.piProvider || provider.id : provider.id,
|
|
58
|
+
defaultModel: state.active.modelId,
|
|
59
|
+
defaultThinkingLevel: state.active.thinking,
|
|
60
|
+
enabledModels: buildEnabledModels(state)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function mergeLiveModels(existing, state) {
|
|
65
|
+
const current = existing && typeof existing === "object" ? existing : {};
|
|
66
|
+
const providers = { ...(current.providers && typeof current.providers === "object" ? current.providers : {}) };
|
|
67
|
+
|
|
68
|
+
for (const provider of state.providers || []) {
|
|
69
|
+
if (provider.kind === "native-subscription") {
|
|
70
|
+
const modelOverrides = buildModelOverrides(provider);
|
|
71
|
+
const key = provider.piProvider || provider.id;
|
|
72
|
+
if (modelOverrides) {
|
|
73
|
+
providers[key] = { ...(providers[key] || {}), modelOverrides };
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!provider.baseUrl) continue;
|
|
78
|
+
const entry = {
|
|
79
|
+
...(providers[provider.id] || {}),
|
|
80
|
+
name: provider.name,
|
|
81
|
+
baseUrl: provider.baseUrl,
|
|
82
|
+
api: "openai-completions",
|
|
83
|
+
models: provider.models.map((model) => toPiModel(provider, model))
|
|
84
|
+
};
|
|
85
|
+
if (provider.credentialEnv) entry.apiKey = `$${provider.credentialEnv}`;
|
|
86
|
+
else delete entry.apiKey;
|
|
87
|
+
providers[provider.id] = entry;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { ...current, providers };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function mergeLiveAuth(existing, state, credentials = {}) {
|
|
94
|
+
const next = existing && typeof existing === "object" ? { ...existing } : {};
|
|
95
|
+
for (const provider of state.providers || []) {
|
|
96
|
+
if (provider.kind === "native-subscription") continue;
|
|
97
|
+
const secret = String(credentials?.[provider.id] || "").trim();
|
|
98
|
+
if (!secret) continue;
|
|
99
|
+
const current = next[provider.id];
|
|
100
|
+
if (current && typeof current === "object" && current.type === "oauth") continue;
|
|
101
|
+
next[provider.id] = { type: "api_key", key: secret };
|
|
102
|
+
}
|
|
103
|
+
return next;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function backupLivePiConfig({ agentDir, backupRoot, now = new Date() }) {
|
|
107
|
+
ensureDir(backupRoot);
|
|
108
|
+
const backupDir = path.join(backupRoot, timestampId(now));
|
|
109
|
+
ensureDir(backupDir);
|
|
110
|
+
const files = {};
|
|
111
|
+
for (const name of LIVE_FILES) {
|
|
112
|
+
files[name] = copyIfExists(path.join(agentDir, name), path.join(backupDir, name));
|
|
113
|
+
}
|
|
114
|
+
const manifest = {
|
|
115
|
+
createdAt: now.toISOString(),
|
|
116
|
+
agentDir,
|
|
117
|
+
files
|
|
118
|
+
};
|
|
119
|
+
writeJsonAtomic(path.join(backupDir, "manifest.json"), manifest);
|
|
120
|
+
return { backupDir, manifest };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function restoreLivePiBackup({ agentDir, backupDir }) {
|
|
124
|
+
if (!backupDir || !fs.existsSync(backupDir)) throw new Error("没有可回滚的本机 Pi 备份");
|
|
125
|
+
ensureDir(agentDir);
|
|
126
|
+
for (const name of LIVE_FILES) {
|
|
127
|
+
const source = path.join(backupDir, name);
|
|
128
|
+
const destination = path.join(agentDir, name);
|
|
129
|
+
if (fs.existsSync(source)) {
|
|
130
|
+
copyIfExists(source, destination);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
fs.unlinkSync(destination);
|
|
135
|
+
} catch {
|
|
136
|
+
// File was created by a later import and should disappear on rollback.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { agentDir, backupDir };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function verifyLiveModels({ agentDir, piExecutable, enabledModels, listModels }) {
|
|
143
|
+
if (typeof listModels === "function") {
|
|
144
|
+
const output = listModels({ agentDir, piExecutable });
|
|
145
|
+
return { ok: true, output: String(output || ""), refs: parsePiListModels(output) };
|
|
146
|
+
}
|
|
147
|
+
if (!piExecutable) {
|
|
148
|
+
return { ok: false, output: "", refs: [], error: "未提供 Pi 可执行文件" };
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const output = execFileSync(piExecutable, ["--list-models", "--offline"], {
|
|
152
|
+
encoding: "utf8",
|
|
153
|
+
env: {
|
|
154
|
+
...process.env,
|
|
155
|
+
PI_CODING_AGENT_DIR: agentDir,
|
|
156
|
+
PI_OFFLINE: "1"
|
|
157
|
+
},
|
|
158
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
159
|
+
});
|
|
160
|
+
const refs = parsePiListModels(output);
|
|
161
|
+
const missing = (enabledModels || []).filter((ref) => !refs.includes(ref));
|
|
162
|
+
return {
|
|
163
|
+
ok: missing.length === 0,
|
|
164
|
+
output,
|
|
165
|
+
refs,
|
|
166
|
+
missing,
|
|
167
|
+
error: missing.length ? `Pi 未列出循环列表模型: ${missing.join(" / ")}` : ""
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const stderr = typeof error?.stderr === "string" ? error.stderr : "";
|
|
171
|
+
const stdout = typeof error?.stdout === "string" ? error.stdout : "";
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
output: stdout,
|
|
175
|
+
refs: parsePiListModels(stdout),
|
|
176
|
+
error: stderr || (error instanceof Error ? error.message : String(error))
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function applyLivePiConfig({
|
|
182
|
+
agentDir = resolvePiAgentDir(),
|
|
183
|
+
backupRoot,
|
|
184
|
+
state,
|
|
185
|
+
credentials = {},
|
|
186
|
+
piExecutable = "pi",
|
|
187
|
+
listModels,
|
|
188
|
+
now = new Date()
|
|
189
|
+
}) {
|
|
190
|
+
if (!backupRoot) throw new Error("缺少备份目录");
|
|
191
|
+
ensureDir(agentDir);
|
|
192
|
+
const settingsPath = path.join(agentDir, "settings.json");
|
|
193
|
+
const modelsPath = path.join(agentDir, "models.json");
|
|
194
|
+
const authPath = path.join(agentDir, "auth.json");
|
|
195
|
+
const existingSettings = readJsonIfExists(settingsPath);
|
|
196
|
+
const existingModels = readJsonIfExists(modelsPath);
|
|
197
|
+
const existingAuth = readJsonIfExists(authPath);
|
|
198
|
+
const nextSettings = mergeLiveSettings(existingSettings, state);
|
|
199
|
+
const nextModels = mergeLiveModels(existingModels, state);
|
|
200
|
+
const nextAuth = mergeLiveAuth(existingAuth, state, credentials);
|
|
201
|
+
const backup = backupLivePiConfig({ agentDir, backupRoot, now });
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
writeJsonAtomic(settingsPath, nextSettings);
|
|
205
|
+
writeJsonAtomic(modelsPath, nextModels);
|
|
206
|
+
writeJsonAtomic(authPath, nextAuth, 0o600);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
restoreLivePiBackup({ agentDir, backupDir: backup.backupDir });
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const verify = verifyLiveModels({
|
|
213
|
+
agentDir,
|
|
214
|
+
piExecutable,
|
|
215
|
+
enabledModels: nextSettings.enabledModels,
|
|
216
|
+
listModels
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
agentDir,
|
|
221
|
+
backupDir: backup.backupDir,
|
|
222
|
+
settingsPath,
|
|
223
|
+
modelsPath,
|
|
224
|
+
authPath,
|
|
225
|
+
enabledModels: nextSettings.enabledModels,
|
|
226
|
+
defaultProvider: nextSettings.defaultProvider,
|
|
227
|
+
defaultModel: nextSettings.defaultModel,
|
|
228
|
+
verify
|
|
229
|
+
};
|
|
230
|
+
}
|
package/src/pi-auth.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFileSync as defaultExecFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const READY_STATUSES = new Set(["ready", "authenticated"]);
|
|
4
|
+
const AUTH_TYPES = new Set(["api_key", "oauth", "subscription"]);
|
|
5
|
+
const AUTH_REASONS = new Set([
|
|
6
|
+
"auth_required",
|
|
7
|
+
"check_failed",
|
|
8
|
+
"credentials_not_configured",
|
|
9
|
+
"expired",
|
|
10
|
+
"invalid_credentials",
|
|
11
|
+
"invalid_response",
|
|
12
|
+
"unknown_provider"
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function booleanFlag(value) {
|
|
16
|
+
return value === true || value === "true";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeAuthType(value) {
|
|
20
|
+
const normalized = String(value || "").trim().toLowerCase().replaceAll("-", "_");
|
|
21
|
+
return AUTH_TYPES.has(normalized) ? normalized : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeReason(value) {
|
|
25
|
+
const normalized = String(value || "").trim().toLowerCase().replaceAll("-", "_");
|
|
26
|
+
return AUTH_REASONS.has(normalized) ? normalized : "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parsePiAuthCheck(raw, { providerId = "", checkedAt = Date.now() } = {}) {
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(String(raw || ""));
|
|
33
|
+
} catch {
|
|
34
|
+
return {
|
|
35
|
+
providerId,
|
|
36
|
+
ready: false,
|
|
37
|
+
status: "error",
|
|
38
|
+
authType: "",
|
|
39
|
+
reason: "invalid_response",
|
|
40
|
+
checkedAt
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const status = typeof parsed?.status === "string" ? parsed.status.trim().toLowerCase() : "unknown";
|
|
45
|
+
const ready = READY_STATUSES.has(status)
|
|
46
|
+
|| booleanFlag(parsed?.authenticated)
|
|
47
|
+
|| booleanFlag(parsed?.ready)
|
|
48
|
+
|| booleanFlag(parsed?.ok);
|
|
49
|
+
return {
|
|
50
|
+
providerId,
|
|
51
|
+
ready,
|
|
52
|
+
status,
|
|
53
|
+
authType: safeAuthType(parsed?.authType),
|
|
54
|
+
reason: safeReason(parsed?.reason),
|
|
55
|
+
checkedAt
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createPiAuthProbe({
|
|
60
|
+
executable = "pi",
|
|
61
|
+
cacheTtlMs = 2000,
|
|
62
|
+
now = () => Date.now(),
|
|
63
|
+
execFileSync = defaultExecFileSync
|
|
64
|
+
} = {}) {
|
|
65
|
+
const cache = new Map();
|
|
66
|
+
|
|
67
|
+
function check(providerId, { force = false } = {}) {
|
|
68
|
+
const normalizedProviderId = String(providerId || "").trim();
|
|
69
|
+
const checkedAt = Number(now());
|
|
70
|
+
if (!normalizedProviderId) {
|
|
71
|
+
return parsePiAuthCheck("", { providerId: normalizedProviderId, checkedAt });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const previous = cache.get(normalizedProviderId);
|
|
75
|
+
if (!force && previous && checkedAt - previous.checkedAt < cacheTtlMs) return previous;
|
|
76
|
+
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
const raw = execFileSync(executable, ["auth", "check", "--provider", normalizedProviderId, "--json", "--no-refresh"], {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
82
|
+
});
|
|
83
|
+
result = parsePiAuthCheck(raw, { providerId: normalizedProviderId, checkedAt });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const stdout = typeof error?.stdout === "string"
|
|
86
|
+
? error.stdout
|
|
87
|
+
: Buffer.isBuffer(error?.stdout) ? error.stdout.toString("utf8") : "";
|
|
88
|
+
result = stdout.trim()
|
|
89
|
+
? parsePiAuthCheck(stdout, { providerId: normalizedProviderId, checkedAt })
|
|
90
|
+
: {
|
|
91
|
+
providerId: normalizedProviderId,
|
|
92
|
+
ready: false,
|
|
93
|
+
status: "error",
|
|
94
|
+
authType: "",
|
|
95
|
+
reason: "check_failed",
|
|
96
|
+
checkedAt
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
cache.set(normalizedProviderId, result);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function clear(providerId) {
|
|
104
|
+
if (providerId === undefined) cache.clear();
|
|
105
|
+
else cache.delete(String(providerId || "").trim());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { check, clear };
|
|
109
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const OPENAI_COMPAT_APIS = new Set(["openai-completions", "openai-responses"]);
|
|
6
|
+
|
|
7
|
+
export function resolvePiAgentDir(env = process.env) {
|
|
8
|
+
return env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function classifyPiApiKey(value) {
|
|
12
|
+
const raw = String(value || "").trim();
|
|
13
|
+
if (!raw) return { kind: "missing", env: "", hasLiteral: false };
|
|
14
|
+
if (raw.startsWith("!")) return { kind: "command", env: "", hasLiteral: false };
|
|
15
|
+
const braced = raw.match(/^\$\{([A-Z][A-Z0-9_]*)\}$/);
|
|
16
|
+
if (braced) return { kind: "env", env: braced[1], hasLiteral: false };
|
|
17
|
+
const plain = raw.match(/^\$([A-Z][A-Z0-9_]*)$/);
|
|
18
|
+
if (plain) return { kind: "env", env: plain[1], hasLiteral: false };
|
|
19
|
+
return { kind: "literal", env: "", hasLiteral: true };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function providerApi(config) {
|
|
23
|
+
return String(config?.api || config?.models?.[0]?.api || "").trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isImportablePiProvider(id, config) {
|
|
27
|
+
if (!id || !config || typeof config !== "object") return false;
|
|
28
|
+
if (!Array.isArray(config.models) || config.models.length === 0) return false;
|
|
29
|
+
if (!String(config.baseUrl || "").trim()) return false;
|
|
30
|
+
const api = providerApi(config);
|
|
31
|
+
return !api || OPENAI_COMPAT_APIS.has(api);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function summarizePiProvider(id, config) {
|
|
35
|
+
const apiKey = classifyPiApiKey(config?.apiKey);
|
|
36
|
+
const models = Array.isArray(config?.models)
|
|
37
|
+
? config.models.map((model) => {
|
|
38
|
+
if (typeof model === "string") return { id: model, name: model };
|
|
39
|
+
return {
|
|
40
|
+
id: String(model?.id || "").trim(),
|
|
41
|
+
name: String(model?.name || model?.id || "").trim(),
|
|
42
|
+
reasoning: Boolean(model?.reasoning),
|
|
43
|
+
thinkingLevelMap: model?.thinkingLevelMap && typeof model.thinkingLevelMap === "object" ? model.thinkingLevelMap : undefined,
|
|
44
|
+
input: Array.isArray(model?.input) ? model.input : ["text"],
|
|
45
|
+
contextWindow: model?.contextWindow,
|
|
46
|
+
maxTokens: model?.maxTokens,
|
|
47
|
+
cost: model?.cost
|
|
48
|
+
};
|
|
49
|
+
}).filter((model) => model.id)
|
|
50
|
+
: [];
|
|
51
|
+
return {
|
|
52
|
+
id: String(id),
|
|
53
|
+
name: String(config?.name || id).trim() || String(id),
|
|
54
|
+
baseUrl: String(config?.baseUrl || "").trim().replace(/\/$/, ""),
|
|
55
|
+
api: providerApi(config) || "openai-completions",
|
|
56
|
+
models,
|
|
57
|
+
credentialEnv: apiKey.env,
|
|
58
|
+
credentialKind: apiKey.kind,
|
|
59
|
+
credentialConfigured: apiKey.kind === "env" || apiKey.kind === "literal" || apiKey.kind === "command"
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readPiModelsConfig(agentDir = resolvePiAgentDir()) {
|
|
64
|
+
const modelsPath = path.join(agentDir, "models.json");
|
|
65
|
+
let raw = "";
|
|
66
|
+
try {
|
|
67
|
+
raw = fs.readFileSync(modelsPath, "utf8");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error && error.code === "ENOENT") {
|
|
70
|
+
return { modelsPath, exists: false, providers: {} };
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(raw);
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error(`无法解析 ${modelsPath}`);
|
|
79
|
+
}
|
|
80
|
+
const providers = parsed?.providers && typeof parsed.providers === "object" ? parsed.providers : {};
|
|
81
|
+
return { modelsPath, exists: true, providers };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function previewPiProviderImport({ modelsConfig, existingProviders = [] }) {
|
|
85
|
+
const existingById = new Map((existingProviders || []).map((provider) => [provider.id, provider]));
|
|
86
|
+
const candidates = [];
|
|
87
|
+
const skipped = [];
|
|
88
|
+
|
|
89
|
+
for (const [id, config] of Object.entries(modelsConfig?.providers || {})) {
|
|
90
|
+
if (!isImportablePiProvider(id, config)) {
|
|
91
|
+
skipped.push({
|
|
92
|
+
id,
|
|
93
|
+
reason: Array.isArray(config?.models) && config.models.length
|
|
94
|
+
? "not-openai-compatible"
|
|
95
|
+
: "missing-models-or-baseurl"
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const summary = summarizePiProvider(id, config);
|
|
100
|
+
const existing = existingById.get(id);
|
|
101
|
+
candidates.push({
|
|
102
|
+
...summary,
|
|
103
|
+
conflict: Boolean(existing),
|
|
104
|
+
existingKind: existing?.kind || "",
|
|
105
|
+
existingName: existing?.name || ""
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
modelsPath: modelsConfig?.modelsPath || "",
|
|
111
|
+
candidates,
|
|
112
|
+
skipped,
|
|
113
|
+
conflicts: candidates.filter((item) => item.conflict)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function literalApiKeyFromPiProvider(config) {
|
|
118
|
+
const classified = classifyPiApiKey(config?.apiKey);
|
|
119
|
+
return classified.kind === "literal" ? String(config.apiKey).trim() : "";
|
|
120
|
+
}
|