@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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { resolvePiAgentDir } from "./pi-import.mjs";
|
|
6
|
+
|
|
7
|
+
export const FEATURED_NATIVE_PROVIDERS = Object.freeze([
|
|
8
|
+
"openai-codex",
|
|
9
|
+
"anthropic",
|
|
10
|
+
"github-copilot",
|
|
11
|
+
"google",
|
|
12
|
+
"xai",
|
|
13
|
+
"openrouter"
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export function resolvePiPackageDir(executable) {
|
|
17
|
+
try {
|
|
18
|
+
const real = fs.realpathSync(executable);
|
|
19
|
+
if (real.endsWith(`${path.sep}dist${path.sep}bundle${path.sep}cli.js`)) {
|
|
20
|
+
return path.resolve(path.dirname(real), "../..");
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// The executable may be missing in tests.
|
|
24
|
+
}
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadPiSdk(executable) {
|
|
29
|
+
const packageDir = resolvePiPackageDir(executable);
|
|
30
|
+
if (!packageDir) throw new Error("无法定位本机 Pi 安装包");
|
|
31
|
+
return import(pathToFileURL(path.join(packageDir, "dist/index.js")).href);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function providerAuthMethods(provider) {
|
|
35
|
+
const methods = [];
|
|
36
|
+
if (provider?.auth?.oauth) methods.push("oauth");
|
|
37
|
+
if (provider?.auth?.apiKey?.login) methods.push("api_key");
|
|
38
|
+
return methods;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function summarizeNativeProvider(runtime, provider) {
|
|
42
|
+
const status = runtime.getProviderAuthStatus?.(provider.id) || { configured: false };
|
|
43
|
+
return {
|
|
44
|
+
id: provider.id,
|
|
45
|
+
name: provider.name || provider.id,
|
|
46
|
+
kind: "native-subscription",
|
|
47
|
+
piProvider: provider.id,
|
|
48
|
+
authMethods: providerAuthMethods(provider),
|
|
49
|
+
credentialConfigured: Boolean(status.configured),
|
|
50
|
+
authSource: status.source || "",
|
|
51
|
+
authLabel: status.label || "",
|
|
52
|
+
models: (runtime.getModels?.(provider.id) || []).map((model) => ({
|
|
53
|
+
id: model.id,
|
|
54
|
+
name: model.name || model.id,
|
|
55
|
+
reasoning: Boolean(model.reasoning),
|
|
56
|
+
thinkingLevelMap: model.thinkingLevelMap || {},
|
|
57
|
+
thinkingLevels: [],
|
|
58
|
+
thinkingMapSource: "provider-default",
|
|
59
|
+
thinkingMapVerified: false,
|
|
60
|
+
input: Array.isArray(model.input) && model.input.length ? model.input : ["text"],
|
|
61
|
+
contextWindow: Number(model.contextWindow) || 128000,
|
|
62
|
+
maxTokens: Number(model.maxTokens) || 32000,
|
|
63
|
+
cost: model.cost || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
64
|
+
}))
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createNativeAuth({
|
|
69
|
+
executable = "pi",
|
|
70
|
+
agentDir = resolvePiAgentDir(),
|
|
71
|
+
loadSdk,
|
|
72
|
+
openUrl
|
|
73
|
+
} = {}) {
|
|
74
|
+
let runtimePromise;
|
|
75
|
+
const sessions = new Map();
|
|
76
|
+
|
|
77
|
+
function resetRuntime() {
|
|
78
|
+
runtimePromise = undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function getRuntime() {
|
|
82
|
+
if (!runtimePromise) {
|
|
83
|
+
runtimePromise = (async () => {
|
|
84
|
+
const sdk = loadSdk ? await loadSdk() : await loadPiSdk(executable);
|
|
85
|
+
if (!sdk?.ModelRuntime?.create) throw new Error("当前 Pi SDK 不支持 ModelRuntime");
|
|
86
|
+
return sdk.ModelRuntime.create({
|
|
87
|
+
refreshOnCreate: false,
|
|
88
|
+
allowModelNetwork: false,
|
|
89
|
+
authPath: path.join(agentDir, "auth.json"),
|
|
90
|
+
modelsPath: fs.existsSync(path.join(agentDir, "models.json")) ? path.join(agentDir, "models.json") : null
|
|
91
|
+
});
|
|
92
|
+
})();
|
|
93
|
+
}
|
|
94
|
+
return runtimePromise;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function listNativeProviders({ featured = false } = {}) {
|
|
98
|
+
const runtime = await getRuntime();
|
|
99
|
+
const configured = new Set((await runtime.listCredentials?.() || []).map((item) => item.providerId));
|
|
100
|
+
const featuredIds = new Set(FEATURED_NATIVE_PROVIDERS);
|
|
101
|
+
return runtime.getProviders()
|
|
102
|
+
.filter((provider) => {
|
|
103
|
+
const methods = providerAuthMethods(provider);
|
|
104
|
+
if (methods.length === 0) return false;
|
|
105
|
+
const isConfigured = configured.has(provider.id) || Boolean(runtime.getProviderAuthStatus?.(provider.id)?.configured);
|
|
106
|
+
if (featured) return featuredIds.has(provider.id);
|
|
107
|
+
return featuredIds.has(provider.id) && isConfigured;
|
|
108
|
+
})
|
|
109
|
+
.map((provider) => summarizeNativeProvider(runtime, provider));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getSession(loginId) {
|
|
113
|
+
const session = sessions.get(String(loginId || ""));
|
|
114
|
+
if (!session) throw new Error("登录会话不存在或已结束");
|
|
115
|
+
return session;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function publicSession(session) {
|
|
119
|
+
return {
|
|
120
|
+
loginId: session.id,
|
|
121
|
+
providerId: session.providerId,
|
|
122
|
+
type: session.type,
|
|
123
|
+
status: session.status,
|
|
124
|
+
authUrl: session.authUrl,
|
|
125
|
+
prompt: session.prompt,
|
|
126
|
+
error: session.error
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function login({ providerId, type, apiKey = "" }) {
|
|
131
|
+
const runtime = await getRuntime();
|
|
132
|
+
const provider = runtime.getProvider(providerId);
|
|
133
|
+
if (!provider) throw new Error("Pi 原生渠道不存在");
|
|
134
|
+
const methods = providerAuthMethods(provider);
|
|
135
|
+
const authType = type || (methods.includes("oauth") ? "oauth" : "api_key");
|
|
136
|
+
if (!methods.includes(authType)) throw new Error(`该渠道不支持 ${authType} 登录`);
|
|
137
|
+
|
|
138
|
+
if (authType === "api_key") {
|
|
139
|
+
const key = String(apiKey || "").trim();
|
|
140
|
+
if (!key) throw new Error("API Key 不能为空");
|
|
141
|
+
await runtime.login(providerId, "api_key", {
|
|
142
|
+
async prompt(prompt) {
|
|
143
|
+
if (prompt.type === "select") return prompt.options?.[0]?.id || "";
|
|
144
|
+
return key;
|
|
145
|
+
},
|
|
146
|
+
notify() {}
|
|
147
|
+
});
|
|
148
|
+
resetRuntime();
|
|
149
|
+
return { status: "completed", providerId, type: "api_key" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const session = {
|
|
153
|
+
id: crypto.randomUUID(),
|
|
154
|
+
providerId,
|
|
155
|
+
type: "oauth",
|
|
156
|
+
status: "pending",
|
|
157
|
+
authUrl: "",
|
|
158
|
+
prompt: null,
|
|
159
|
+
error: "",
|
|
160
|
+
resolvePrompt: null
|
|
161
|
+
};
|
|
162
|
+
sessions.set(session.id, session);
|
|
163
|
+
session.promise = runtime.login(providerId, "oauth", {
|
|
164
|
+
async prompt(prompt) {
|
|
165
|
+
session.prompt = {
|
|
166
|
+
type: prompt.type,
|
|
167
|
+
message: prompt.message,
|
|
168
|
+
placeholder: prompt.placeholder || "",
|
|
169
|
+
options: prompt.options || []
|
|
170
|
+
};
|
|
171
|
+
session.status = "need_prompt";
|
|
172
|
+
return await new Promise((resolve, reject) => {
|
|
173
|
+
session.resolvePrompt = { resolve, reject };
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
notify(event) {
|
|
177
|
+
if (event.type === "auth_url" && event.url) {
|
|
178
|
+
session.authUrl = event.url;
|
|
179
|
+
session.status = "need_url";
|
|
180
|
+
if (typeof openUrl === "function") {
|
|
181
|
+
Promise.resolve(openUrl(event.url)).catch(() => {});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}).then(() => {
|
|
186
|
+
session.status = "completed";
|
|
187
|
+
session.prompt = null;
|
|
188
|
+
resetRuntime();
|
|
189
|
+
return publicSession(session);
|
|
190
|
+
}).catch((error) => {
|
|
191
|
+
session.status = "error";
|
|
192
|
+
session.error = error instanceof Error ? error.message : String(error);
|
|
193
|
+
throw error;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return publicSession(session);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function answerPrompt(loginId, value) {
|
|
200
|
+
const session = getSession(loginId);
|
|
201
|
+
if (!session.resolvePrompt) throw new Error("当前没有等待输入的登录步骤");
|
|
202
|
+
const answer = String(value || "").trim();
|
|
203
|
+
if (!answer) throw new Error("登录输入不能为空");
|
|
204
|
+
session.resolvePrompt.resolve(answer);
|
|
205
|
+
session.resolvePrompt = null;
|
|
206
|
+
session.prompt = null;
|
|
207
|
+
session.status = "pending";
|
|
208
|
+
return publicSession(session);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function loginStatus(loginId) {
|
|
212
|
+
const session = getSession(loginId);
|
|
213
|
+
if (session.promise && session.status === "pending") {
|
|
214
|
+
try {
|
|
215
|
+
await Promise.race([session.promise, new Promise((resolve) => setTimeout(resolve, 20))]);
|
|
216
|
+
} catch {
|
|
217
|
+
// Status is already recorded on the session.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return publicSession(session);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function logout(providerId) {
|
|
224
|
+
const runtime = await getRuntime();
|
|
225
|
+
await runtime.logout(providerId);
|
|
226
|
+
resetRuntime();
|
|
227
|
+
return { providerId, status: "logged_out" };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
getRuntime,
|
|
232
|
+
listNativeProviders,
|
|
233
|
+
listFeaturedNativeProviders: () => listNativeProviders({ featured: true }),
|
|
234
|
+
login,
|
|
235
|
+
loginStatus,
|
|
236
|
+
answerPrompt,
|
|
237
|
+
logout
|
|
238
|
+
};
|
|
239
|
+
}
|
package/src/profile.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { safeId, writeJsonAtomic } from "./store.mjs";
|
|
4
|
+
import { assertSupportedThinkingLevel, getThinkingLevelValue } from "./thinking.mjs";
|
|
5
|
+
|
|
6
|
+
function shellQuote(value) {
|
|
7
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function removeLegacyManagerFiles(runtimeDir) {
|
|
11
|
+
const legacyDir = path.join(runtimeDir, ".pi");
|
|
12
|
+
const legacyFiles = [
|
|
13
|
+
path.join(legacyDir, "settings.json"),
|
|
14
|
+
path.join(legacyDir, "models.json"),
|
|
15
|
+
path.join(legacyDir, "extensions", "pi-manager-provider.ts")
|
|
16
|
+
];
|
|
17
|
+
for (const filePath of legacyFiles) {
|
|
18
|
+
try {
|
|
19
|
+
fs.unlinkSync(filePath);
|
|
20
|
+
} catch {
|
|
21
|
+
// A missing legacy file is expected for new profiles.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const dirPath of [path.join(legacyDir, "extensions"), legacyDir]) {
|
|
25
|
+
try {
|
|
26
|
+
fs.rmdirSync(dirPath);
|
|
27
|
+
} catch {
|
|
28
|
+
// Preserve any non-Manager files left in an older profile.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function piThinkingMap(provider, model) {
|
|
34
|
+
return model.thinkingLevelMap || {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function providerApiKeyEnvName(providerId) {
|
|
38
|
+
const normalized = safeId(providerId).replaceAll("-", "_").toUpperCase();
|
|
39
|
+
return `PI_MANAGER_${normalized || "PROVIDER"}_API_KEY`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveModelReference(providers, ref) {
|
|
43
|
+
const normalizedRef = String(ref || "").trim();
|
|
44
|
+
if (!normalizedRef) return null;
|
|
45
|
+
const separatorIndex = normalizedRef.indexOf("/");
|
|
46
|
+
if (separatorIndex <= 0 || separatorIndex >= normalizedRef.length - 1) return null;
|
|
47
|
+
const providerId = normalizedRef.slice(0, separatorIndex);
|
|
48
|
+
const modelId = normalizedRef.slice(separatorIndex + 1);
|
|
49
|
+
const provider = providers.find((item) => item.id === providerId);
|
|
50
|
+
const model = provider?.models.find((item) => item.id === modelId);
|
|
51
|
+
if (!provider || !model) return null;
|
|
52
|
+
return { provider, model };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildEnabledModels(state) {
|
|
56
|
+
const cycleRefs = Array.isArray(state.cycle?.modelRefs) ? state.cycle.modelRefs : [];
|
|
57
|
+
const enabledModels = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
const missing = [];
|
|
60
|
+
|
|
61
|
+
for (const rawRef of cycleRefs) {
|
|
62
|
+
const ref = String(rawRef || "").trim();
|
|
63
|
+
if (!ref || seen.has(ref)) continue;
|
|
64
|
+
seen.add(ref);
|
|
65
|
+
if (!resolveModelReference(state.providers || [], ref)) missing.push(ref);
|
|
66
|
+
else enabledModels.push(ref);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (missing.length > 0) {
|
|
70
|
+
throw new Error(`循环列表包含不存在的模型: ${missing.join(" / ")}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return enabledModels;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildModelOverrides(provider) {
|
|
77
|
+
const modelOverrides = Object.fromEntries(
|
|
78
|
+
provider.models.map((item) => [item.id, {
|
|
79
|
+
contextWindow: item.contextWindow,
|
|
80
|
+
...(item.reasoning && item.thinkingLevelMap && Object.keys(item.thinkingLevelMap).length > 0
|
|
81
|
+
? { thinkingLevelMap: item.thinkingLevelMap }
|
|
82
|
+
: {})
|
|
83
|
+
}])
|
|
84
|
+
);
|
|
85
|
+
return Object.keys(modelOverrides).length > 0 ? modelOverrides : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toPiModel(provider, model) {
|
|
89
|
+
return {
|
|
90
|
+
id: model.id,
|
|
91
|
+
name: `${model.name} (${provider.name})`,
|
|
92
|
+
reasoning: Boolean(model.reasoning),
|
|
93
|
+
thinkingLevelMap: model.reasoning ? piThinkingMap(provider, model) : undefined,
|
|
94
|
+
input: model.input || ["text"],
|
|
95
|
+
cost: model.cost || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
96
|
+
contextWindow: model.contextWindow || 128000,
|
|
97
|
+
maxTokens: model.maxTokens || 32000,
|
|
98
|
+
compat: {
|
|
99
|
+
supportsDeveloperRole: false,
|
|
100
|
+
supportsStore: false,
|
|
101
|
+
maxTokensField: "max_tokens"
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildModelsJson(state, credentials = {}) {
|
|
107
|
+
const providers = {};
|
|
108
|
+
for (const provider of state.providers || []) {
|
|
109
|
+
if (provider.kind === "native-subscription") {
|
|
110
|
+
const modelOverrides = buildModelOverrides(provider);
|
|
111
|
+
if (modelOverrides) {
|
|
112
|
+
providers[provider.piProvider || provider.id] = { modelOverrides };
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const secret = String(credentials?.[provider.id] || "").trim();
|
|
118
|
+
if (!secret) continue;
|
|
119
|
+
|
|
120
|
+
providers[provider.id] = {
|
|
121
|
+
baseUrl: provider.baseUrl,
|
|
122
|
+
api: "openai-completions",
|
|
123
|
+
apiKey: `$${providerApiKeyEnvName(provider.id)}`,
|
|
124
|
+
models: provider.models.map((item) => toPiModel(provider, item))
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return Object.keys(providers).length > 0 ? { providers } : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildCredentialExports(state, credentials) {
|
|
131
|
+
return (state.providers || [])
|
|
132
|
+
.filter((provider) => provider.kind !== "native-subscription")
|
|
133
|
+
.flatMap((provider) => {
|
|
134
|
+
const secret = credentials?.[provider.id];
|
|
135
|
+
const normalized = String(secret || "").trim();
|
|
136
|
+
if (!normalized) return [];
|
|
137
|
+
return [{ name: providerApiKeyEnvName(provider.id), value: normalized }];
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function writeLauncher({ launcherPath, providerId, active, runtimeDir, targetProject, piExecutable, credentialExports = [] }) {
|
|
142
|
+
const args = ["--no-extensions", "--provider", providerId, "--model", active.modelId];
|
|
143
|
+
if (active.thinking) args.push("--thinking", active.thinking);
|
|
144
|
+
|
|
145
|
+
const lines = [
|
|
146
|
+
"#!/bin/zsh",
|
|
147
|
+
"set -e",
|
|
148
|
+
`cd ${shellQuote(targetProject)}`,
|
|
149
|
+
`export PI_CODING_AGENT_DIR=${shellQuote(runtimeDir)}`,
|
|
150
|
+
`export PI_CODING_AGENT_SESSION_DIR=${shellQuote(path.join(runtimeDir, "sessions"))}`
|
|
151
|
+
];
|
|
152
|
+
for (const entry of credentialExports) {
|
|
153
|
+
lines.push(`export ${entry.name}=${shellQuote(entry.value)}`);
|
|
154
|
+
}
|
|
155
|
+
lines.push(`exec ${shellQuote(piExecutable)} ${args.map(shellQuote).join(" ")}`);
|
|
156
|
+
fs.writeFileSync(launcherPath, `${lines.join("\n")}\n`, { mode: 0o700 });
|
|
157
|
+
fs.chmodSync(launcherPath, 0o700);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function writePiProfile({ dataDir, state, piExecutable = "pi", credentials = {} }) {
|
|
161
|
+
const provider = state.providers.find((item) => item.id === state.active.providerId);
|
|
162
|
+
if (!provider) throw new Error("当前渠道不存在");
|
|
163
|
+
const model = provider.models.find((item) => item.id === state.active.modelId);
|
|
164
|
+
if (!model) throw new Error("当前模型不存在");
|
|
165
|
+
assertSupportedThinkingLevel(model, state.active.thinking);
|
|
166
|
+
|
|
167
|
+
const runtimeDir = path.join(dataDir, "profiles", "active");
|
|
168
|
+
const piDir = runtimeDir;
|
|
169
|
+
fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
|
|
170
|
+
fs.mkdirSync(path.join(runtimeDir, "sessions"), { recursive: true, mode: 0o700 });
|
|
171
|
+
removeLegacyManagerFiles(runtimeDir);
|
|
172
|
+
const settingsPath = path.join(piDir, "settings.json");
|
|
173
|
+
const modelsPath = path.join(piDir, "models.json");
|
|
174
|
+
const launcherPath = path.join(runtimeDir, process.platform === "darwin" ? "launch-pi.command" : "launch-pi.sh");
|
|
175
|
+
const extensionPath = "";
|
|
176
|
+
|
|
177
|
+
const native = provider.kind === "native-subscription";
|
|
178
|
+
const settings = {
|
|
179
|
+
defaultProvider: native ? provider.piProvider || provider.id : provider.id,
|
|
180
|
+
defaultModel: model.id,
|
|
181
|
+
enabledModels: buildEnabledModels(state)
|
|
182
|
+
};
|
|
183
|
+
writeJsonAtomic(settingsPath, settings);
|
|
184
|
+
|
|
185
|
+
const modelsConfig = buildModelsJson(state, credentials);
|
|
186
|
+
if (modelsConfig) {
|
|
187
|
+
writeJsonAtomic(modelsPath, modelsConfig);
|
|
188
|
+
} else {
|
|
189
|
+
try {
|
|
190
|
+
fs.unlinkSync(modelsPath);
|
|
191
|
+
} catch {
|
|
192
|
+
// No provider/model metadata needs to be injected for this profile.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const credentialExports = buildCredentialExports(state, credentials);
|
|
197
|
+
|
|
198
|
+
const manifestPath = path.join(runtimeDir, "profile.json");
|
|
199
|
+
writeJsonAtomic(manifestPath, {
|
|
200
|
+
version: 1,
|
|
201
|
+
generatedAt: new Date().toISOString(),
|
|
202
|
+
targetProject: state.targetProject,
|
|
203
|
+
providerId: provider.id,
|
|
204
|
+
providerName: provider.name,
|
|
205
|
+
modelId: model.id,
|
|
206
|
+
thinking: state.active.thinking,
|
|
207
|
+
thinkingValue: getThinkingLevelValue(model, state.active.thinking),
|
|
208
|
+
cycleModelRefs: buildEnabledModels(state),
|
|
209
|
+
mode: native ? "native-subscription" : "models-json"
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
writeLauncher({
|
|
213
|
+
launcherPath,
|
|
214
|
+
providerId: native ? provider.piProvider || provider.id : provider.id,
|
|
215
|
+
active: state.active,
|
|
216
|
+
runtimeDir,
|
|
217
|
+
targetProject: state.targetProject,
|
|
218
|
+
piExecutable,
|
|
219
|
+
credentialExports
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return { runtimeDir, extensionPath, settingsPath, modelsPath, launcherPath, manifestPath, mode: native ? "native-subscription" : "models-json" };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export { piThinkingMap, shellQuote, toPiModel, providerApiKeyEnvName, buildEnabledModels, buildModelOverrides };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { upstreamUrl } from "./gateway.mjs";
|
|
2
|
+
import { classifyConnectionError, CONNECTION_TEST_CATEGORIES, sanitizeConnectionTestUrl } from "./provider-test.mjs";
|
|
3
|
+
|
|
4
|
+
function normalizeModels(payload) {
|
|
5
|
+
const values = Array.isArray(payload)
|
|
6
|
+
? payload
|
|
7
|
+
: Array.isArray(payload?.data)
|
|
8
|
+
? payload.data
|
|
9
|
+
: Array.isArray(payload?.models)
|
|
10
|
+
? payload.models
|
|
11
|
+
: null;
|
|
12
|
+
if (!values) throw new Error("上游 /models 返回格式无法识别");
|
|
13
|
+
|
|
14
|
+
const models = [];
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
for (const item of values) {
|
|
17
|
+
const source = typeof item === "string" ? { id: item } : item;
|
|
18
|
+
const id = String(source?.id || source?.model || "").trim();
|
|
19
|
+
if (!id || seen.has(id)) continue;
|
|
20
|
+
seen.add(id);
|
|
21
|
+
models.push({
|
|
22
|
+
id,
|
|
23
|
+
name: String(source?.name || source?.display_name || id).trim() || id,
|
|
24
|
+
reasoning: Boolean(source?.reasoning || source?.supports_reasoning),
|
|
25
|
+
input: Array.isArray(source?.input) && source.input.length ? source.input.map(String) : ["text"],
|
|
26
|
+
contextWindow: Number(source?.contextWindow || source?.context_window || source?.context_length) || undefined,
|
|
27
|
+
maxTokens: Number(source?.maxTokens || source?.max_tokens || source?.max_output_tokens) || undefined,
|
|
28
|
+
ownedBy: String(source?.owned_by || source?.ownedBy || "").trim()
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return models;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function errorResult({ category, message, detail, status = 0, durationMs }) {
|
|
35
|
+
return { ok: false, category, message, detail, status, durationMs, models: [] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function modelsUrlCandidates(baseUrl) {
|
|
39
|
+
const normalized = String(baseUrl || "").trim();
|
|
40
|
+
const base = new URL(normalized);
|
|
41
|
+
base.username = "";
|
|
42
|
+
base.password = "";
|
|
43
|
+
base.search = "";
|
|
44
|
+
base.hash = "";
|
|
45
|
+
const candidates = [upstreamUrl(base.toString(), "models")];
|
|
46
|
+
if (!/\/v1\/?$/.test(base.pathname)) {
|
|
47
|
+
const withV1 = new URL(base.toString());
|
|
48
|
+
withV1.pathname = `${withV1.pathname.replace(/\/$/, "")}/v1`;
|
|
49
|
+
candidates.push(upstreamUrl(withV1.toString(), "models"));
|
|
50
|
+
}
|
|
51
|
+
return [...new Set(candidates)];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function discoverProviderModels({ baseUrl, apiKey = "", fetchImpl = fetch, timeoutMs = 5000 } = {}) {
|
|
55
|
+
const startedAt = Date.now();
|
|
56
|
+
let candidateUrls;
|
|
57
|
+
try {
|
|
58
|
+
candidateUrls = modelsUrlCandidates(baseUrl);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return errorResult({
|
|
61
|
+
...classifyConnectionError(error),
|
|
62
|
+
detail: "Base URL 无效",
|
|
63
|
+
durationMs: Date.now() - startedAt
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const headers = { accept: "application/json" };
|
|
68
|
+
if (String(apiKey || "").trim()) headers.authorization = `Bearer ${String(apiKey).trim()}`;
|
|
69
|
+
let response;
|
|
70
|
+
let modelsUrl = candidateUrls[0];
|
|
71
|
+
for (const candidateUrl of candidateUrls) {
|
|
72
|
+
modelsUrl = candidateUrl;
|
|
73
|
+
try {
|
|
74
|
+
response = await fetchImpl(candidateUrl, {
|
|
75
|
+
method: "GET",
|
|
76
|
+
headers,
|
|
77
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const failed = classifyConnectionError(error);
|
|
81
|
+
return errorResult({
|
|
82
|
+
category: failed.category,
|
|
83
|
+
message: failed.message,
|
|
84
|
+
detail: sanitizeConnectionTestUrl(candidateUrl),
|
|
85
|
+
durationMs: Date.now() - startedAt
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const contentType = response.headers.get("content-type") || "";
|
|
89
|
+
const canTryV1 = candidateUrl !== candidateUrls.at(-1)
|
|
90
|
+
&& (response.status === 404 || (response.ok && !/application\/(?:[^;]+\+)?json/i.test(contentType)));
|
|
91
|
+
if (!canTryV1) break;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const status = response.status;
|
|
96
|
+
const classified = status === 401 || status === 403
|
|
97
|
+
? { category: CONNECTION_TEST_CATEGORIES.AUTH, message: `认证失败(HTTP ${status}),请检查 API Key` }
|
|
98
|
+
: status === 404
|
|
99
|
+
? { category: CONNECTION_TEST_CATEGORIES.NOT_FOUND, message: "上游没有提供 /models 目录,可手工填写模型" }
|
|
100
|
+
: status === 429
|
|
101
|
+
? { category: CONNECTION_TEST_CATEGORIES.RATE_LIMIT, message: "上游模型目录触发限流,请稍后重试" }
|
|
102
|
+
: { category: CONNECTION_TEST_CATEGORIES.PROTOCOL, message: `上游返回 HTTP ${status}` };
|
|
103
|
+
return errorResult({
|
|
104
|
+
...classified,
|
|
105
|
+
detail: sanitizeConnectionTestUrl(modelsUrl),
|
|
106
|
+
status,
|
|
107
|
+
durationMs: Date.now() - startedAt
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let payload;
|
|
112
|
+
try {
|
|
113
|
+
payload = await response.json();
|
|
114
|
+
} catch {
|
|
115
|
+
return errorResult({
|
|
116
|
+
category: CONNECTION_TEST_CATEGORIES.PROTOCOL,
|
|
117
|
+
message: "上游 /models 返回的不是有效 JSON",
|
|
118
|
+
detail: sanitizeConnectionTestUrl(modelsUrl),
|
|
119
|
+
status: response.status,
|
|
120
|
+
durationMs: Date.now() - startedAt
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const models = normalizeModels(payload);
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
category: CONNECTION_TEST_CATEGORIES.SUCCESS,
|
|
129
|
+
message: `已从上游读取 ${models.length} 个模型`,
|
|
130
|
+
detail: sanitizeConnectionTestUrl(modelsUrl),
|
|
131
|
+
status: response.status,
|
|
132
|
+
durationMs: Date.now() - startedAt,
|
|
133
|
+
models
|
|
134
|
+
};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return errorResult({
|
|
137
|
+
category: CONNECTION_TEST_CATEGORIES.PROTOCOL,
|
|
138
|
+
message: error instanceof Error ? error.message : String(error),
|
|
139
|
+
detail: sanitizeConnectionTestUrl(modelsUrl),
|
|
140
|
+
status: response.status,
|
|
141
|
+
durationMs: Date.now() - startedAt
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { normalizeModels };
|