@fanchaozz/provider-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/store.ts ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * store.ts — models.json 读写 + 校验 + 备份 + 合并
3
+ *
4
+ * 单一职责:所有对 ~/.pi/agent/models.json 的写盘都走这里。
5
+ * - atomic write(tmp + rename)
6
+ * - 写前自动 .bak
7
+ * - 单例 write mutex(防止 TUI 编辑和 LLM 工具并发写)
8
+ * - hand-rolled 校验(pi 不导出 schema 运行时,TypeBox 留待后续)
9
+ */
10
+
11
+ import { readFile, writeFile, rename, copyFile, chmod } from "node:fs/promises";
12
+ import { existsSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
15
+
16
+ // ============================================================================
17
+ // Types
18
+ // ============================================================================
19
+
20
+ export type ApiType =
21
+ | "openai-completions"
22
+ | "openai-responses"
23
+ | "anthropic-messages"
24
+ | "google-generative-ai"
25
+ | "azure-openai-responses"
26
+ | "openai-codex-responses"
27
+ | "mistral-conversations"
28
+ | "bedrock-converse-stream";
29
+
30
+ export const ALLOWED_APIS: readonly ApiType[] = [
31
+ "openai-completions",
32
+ "openai-responses",
33
+ "anthropic-messages",
34
+ "google-generative-ai",
35
+ "azure-openai-responses",
36
+ "openai-codex-responses",
37
+ "mistral-conversations",
38
+ "bedrock-converse-stream",
39
+ ];
40
+
41
+ export type ModelConfig = {
42
+ id: string;
43
+ name?: string;
44
+ api?: ApiType | string;
45
+ baseUrl?: string;
46
+ reasoning?: boolean;
47
+ thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", string | null>>;
48
+ input?: Array<"text" | "image">;
49
+ cost?: {
50
+ input: number;
51
+ output: number;
52
+ cacheRead: number;
53
+ cacheWrite: number;
54
+ tiers?: Array<{ inputTokensAbove: number; input: number; output: number; cacheRead: number; cacheWrite: number }>;
55
+ };
56
+ contextWindow?: number;
57
+ maxTokens?: number;
58
+ samplingParams?: Record<string, unknown>;
59
+ headers?: Record<string, string>;
60
+ compat?: Record<string, unknown>;
61
+ };
62
+
63
+ export type ModelOverrideConfig = Partial<Omit<ModelConfig, "id">>;
64
+
65
+ export type ProviderConfig = {
66
+ name?: string;
67
+ baseUrl?: string;
68
+ api?: ApiType | string;
69
+ apiKey?: string;
70
+ oauth?: "radius";
71
+ headers?: Record<string, string>;
72
+ compat?: Record<string, unknown>;
73
+ authHeader?: boolean;
74
+ models?: ModelConfig[];
75
+ modelOverrides?: Record<string, ModelOverrideConfig>;
76
+ };
77
+
78
+ export type ModelsJson = { providers: Record<string, ProviderConfig> };
79
+
80
+ // ============================================================================
81
+ // Paths
82
+ // ============================================================================
83
+
84
+ export function getModelsJsonPath(): string {
85
+ const ov = (globalThis as any)[Symbol.for("pi-provider-manager:models-path-override")] as string | undefined;
86
+ if (ov) return ov;
87
+ return join(getAgentDir(), "models.json");
88
+ }
89
+
90
+ export function getBackupPath(): string {
91
+ const ov = (globalThis as any)[Symbol.for("pi-provider-manager:backup-path-override")] as string | undefined;
92
+ if (ov) return ov;
93
+ return join(getAgentDir(), "models.json.bak");
94
+ }
95
+
96
+ // ============================================================================
97
+ // Read
98
+ // ============================================================================
99
+
100
+ export async function readModelsJson(): Promise<ModelsJson> {
101
+ const path = getModelsJsonPath();
102
+ if (!existsSync(path)) return { providers: {} };
103
+ const text = await readFile(path, "utf8");
104
+ let parsed: unknown;
105
+ try {
106
+ parsed = JSON.parse(text);
107
+ } catch (err) {
108
+ throw new Error(`models.json 不是合法 JSON: ${err instanceof Error ? err.message : err}`);
109
+ }
110
+ if (!parsed || typeof parsed !== "object" || !("providers" in parsed) || typeof (parsed as any).providers !== "object") {
111
+ throw new Error("models.json 缺少 'providers' 对象");
112
+ }
113
+ return parsed as ModelsJson;
114
+ }
115
+
116
+ // ============================================================================
117
+ // Write (atomic + backup + mutex)
118
+ // ============================================================================
119
+
120
+ // 全局写锁:所有写盘走同一个 Promise 链,串行化
121
+ let writeChain: Promise<void> = Promise.resolve();
122
+
123
+ export async function writeModelsJson(
124
+ next: ModelsJson,
125
+ opts: { backup?: boolean } = { backup: true },
126
+ ): Promise<{ path: string; backupPath: string }> {
127
+ const path = getModelsJsonPath();
128
+ const bak = getBackupPath();
129
+ const tmp = path + ".tmp";
130
+
131
+ const task = writeChain.then(async () => {
132
+ if (opts.backup && existsSync(path)) {
133
+ await copyFile(path, bak);
134
+ }
135
+ await writeFile(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
136
+ await rename(tmp, path);
137
+ try {
138
+ await chmod(path, 0o600);
139
+ } catch {
140
+ // Windows: chmod 是 no-op,忽略
141
+ }
142
+ });
143
+ // 不让 chain 被前一个错误污染
144
+ writeChain = task.catch(() => undefined);
145
+ await task;
146
+ return { path, backupPath: bak };
147
+ }
148
+
149
+ export async function restoreBackup(): Promise<boolean> {
150
+ const path = getModelsJsonPath();
151
+ const bak = getBackupPath();
152
+ if (!existsSync(bak)) return false;
153
+ await copyFile(bak, path);
154
+ return true;
155
+ }
156
+
157
+ export function backupExists(): boolean {
158
+ return existsSync(getBackupPath());
159
+ }
160
+
161
+ // ============================================================================
162
+ // Validation
163
+ // ============================================================================
164
+
165
+ export type ValidationError = { path: string; message: string };
166
+ export type ValidationResult = { ok: true } | { ok: false; errors: ValidationError[] };
167
+
168
+ export function validateProvider(id: string, p: unknown): ValidationResult {
169
+ const errors: ValidationError[] = [];
170
+ if (typeof id !== "string" || !id) {
171
+ errors.push({ path: "<id>", message: "provider id 必须是非空字符串" });
172
+ }
173
+ if (typeof p !== "object" || p === null) {
174
+ errors.push({ path: `providers.${id}`, message: "必须是 object" });
175
+ return { ok: false, errors };
176
+ }
177
+ const prov = p as Record<string, unknown>;
178
+
179
+ if ("name" in prov && typeof prov.name !== "string") {
180
+ errors.push({ path: `providers.${id}.name`, message: "必须是 string" });
181
+ }
182
+ if ("baseUrl" in prov && typeof prov.baseUrl !== "string") {
183
+ errors.push({ path: `providers.${id}.baseUrl`, message: "必须是 string" });
184
+ }
185
+ if ("apiKey" in prov && typeof prov.apiKey !== "string") {
186
+ errors.push({ path: `providers.${id}.apiKey`, message: "必须是 string" });
187
+ }
188
+ if ("api" in prov) {
189
+ if (typeof prov.api !== "string" || !ALLOWED_APIS.includes(prov.api as ApiType)) {
190
+ errors.push({ path: `providers.${id}.api`, message: `必须是 ${ALLOWED_APIS.join(" | ")}` });
191
+ }
192
+ }
193
+ if ("authHeader" in prov && typeof prov.authHeader !== "boolean") {
194
+ errors.push({ path: `providers.${id}.authHeader`, message: "必须是 boolean" });
195
+ }
196
+ if ("headers" in prov) {
197
+ if (typeof prov.headers !== "object" || prov.headers === null || Array.isArray(prov.headers)) {
198
+ errors.push({ path: `providers.${id}.headers`, message: "必须是 Record<string,string>" });
199
+ }
200
+ }
201
+ if ("models" in prov) {
202
+ if (!Array.isArray(prov.models)) {
203
+ errors.push({ path: `providers.${id}.models`, message: "必须是 array" });
204
+ } else {
205
+ (prov.models as unknown[]).forEach((m, i) => {
206
+ const r = validateModel(m);
207
+ if (!r.ok) for (const e of r.errors) errors.push({ path: `providers.${id}.models[${i}].${e.path}`, message: e.message });
208
+ });
209
+ }
210
+ }
211
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
212
+ }
213
+
214
+ export function validateModel(m: unknown): ValidationResult {
215
+ const errors: ValidationError[] = [];
216
+ if (typeof m !== "object" || m === null) {
217
+ return { ok: false, errors: [{ path: "<model>", message: "必须是 object" }] };
218
+ }
219
+ const model = m as Record<string, unknown>;
220
+ if (typeof model.id !== "string" || !model.id) {
221
+ errors.push({ path: "id", message: "必须是非空 string" });
222
+ }
223
+ if ("name" in model && typeof model.name !== "string") {
224
+ errors.push({ path: "name", message: "必须是 string" });
225
+ }
226
+ if ("reasoning" in model && typeof model.reasoning !== "boolean") {
227
+ errors.push({ path: "reasoning", message: "必须是 boolean" });
228
+ }
229
+ if ("contextWindow" in model && (typeof model.contextWindow !== "number" || model.contextWindow < 0)) {
230
+ errors.push({ path: "contextWindow", message: "必须是非负 number" });
231
+ }
232
+ if ("maxTokens" in model && (typeof model.maxTokens !== "number" || model.maxTokens < 0)) {
233
+ errors.push({ path: "maxTokens", message: "必须是非负 number" });
234
+ }
235
+ if ("input" in model) {
236
+ if (!Array.isArray(model.input) || !model.input.every((x) => x === "text" || x === "image")) {
237
+ errors.push({ path: "input", message: '必须是 ("text" | "image")[]' });
238
+ }
239
+ }
240
+ if ("thinkingLevelMap" in model) {
241
+ if (typeof model.thinkingLevelMap !== "object" || model.thinkingLevelMap === null) {
242
+ errors.push({ path: "thinkingLevelMap", message: "必须是 object" });
243
+ }
244
+ }
245
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
246
+ }
247
+
248
+ export function validateAll(json: ModelsJson): ValidationResult {
249
+ const errors: ValidationError[] = [];
250
+ for (const [id, p] of Object.entries(json.providers)) {
251
+ const r = validateProvider(id, p);
252
+ if (!r.ok) errors.push(...r.errors);
253
+ }
254
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
255
+ }
256
+
257
+ // ============================================================================
258
+ // Shallow merge (drop undefined)
259
+ // ============================================================================
260
+
261
+ function shallowMerge<T extends Record<string, any>>(base: T, patch: Partial<T>): T {
262
+ const out: T = { ...base };
263
+ for (const [k, v] of Object.entries(patch)) {
264
+ if (v === undefined) continue;
265
+ (out as any)[k] = v;
266
+ }
267
+ return out;
268
+ }
269
+
270
+ export const mergeProvider = shallowMerge<ProviderConfig>;
271
+ export const mergeModel = shallowMerge<ModelConfig>;
272
+
273
+ // ============================================================================
274
+ // Display helpers
275
+ // ============================================================================
276
+
277
+ /** 把 apiKey 遮成 "abcd••••wxyz"(首 4 + 末 4,中间省略号),UI 用 */
278
+ export function maskApiKey(key: string | undefined): string {
279
+ if (!key) return "(none)";
280
+ if (key.length <= 10) return "•".repeat(key.length);
281
+ return key.slice(0, 4) + "••••" + key.slice(-4);
282
+ }
package/sync.ts ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * sync.ts — 远端 model 拉取 + 字段推断
3
+ *
4
+ * 2 个 preset:
5
+ * - google: https://generativelanguage.googleapis.com/v1beta, /models?key=$KEY
6
+ * - custom: 任意 OpenAI-compat /v1/models
7
+ *
8
+ * 启发式(基于 id 命名字符串,仅 best-effort):
9
+ * - input: 含 vision/gpt-4/claude/gemini/grok/llava/moondream → ["text","image"]
10
+ * - reasoning: 含 o1/o3/o4/reasoning/thinking/deepseek-r/qwq/qwen3 → true
11
+ * - 过滤: embed/tts/whisper/dall-e/clip/image-/moderation
12
+ *
13
+ * 真实成本/上下文对 OpenAI-compat /v1/models 拿不到;v1 默认 128k/16k,
14
+ * user 在 step 3 引导里可逐个改。
15
+ */
16
+
17
+ import type { ModelConfig } from "./store.ts";
18
+ import { DEFAULT_MODEL_CONFIG } from "./forms.ts";
19
+
20
+ // ============================================================================
21
+ // Types
22
+ // ============================================================================
23
+
24
+ export type ApiKind = "openai-compat" | "google";
25
+
26
+ export type FetchedModel = {
27
+ id: string;
28
+ name?: string;
29
+ };
30
+
31
+ export type FetchResult = {
32
+ baseUrl: string;
33
+ apiKind: ApiKind;
34
+ models: FetchedModel[];
35
+ warnings: string[];
36
+ };
37
+
38
+ // ============================================================================
39
+ // Presets
40
+ // ============================================================================
41
+
42
+ export type SyncPreset = {
43
+ id: "google" | "custom";
44
+ label: string;
45
+ baseUrl?: string; // google 是固定,custom 留空让用户填
46
+ api: ApiKind;
47
+ };
48
+
49
+ export const SYNC_PRESETS: SyncPreset[] = [
50
+ { id: "google", label: "Google Generative AI", baseUrl: "https://generativelanguage.googleapis.com/v1beta", api: "google" },
51
+ { id: "custom", label: "Custom OpenAI-compatible URL...", api: "openai-compat" },
52
+ ];
53
+
54
+ // ============================================================================
55
+ // Heuristics
56
+ // ============================================================================
57
+
58
+ const NOISE_PATTERN = /\b(embed|embedding|tts|whisper|dall-?e|clip|moderation)\b|^image[-_]|image-generation/i;
59
+ const REASONING_PATTERN = /(?:^|[^a-z])(o[1-9]|reasoning|thinking|deepseek-?r|qwq|qwen3)(?:$|[^a-z])/i;
60
+ const VISION_PATTERN = /(vision|claude|gemini|gpt-4|gpt-5|grok|llava|moondream|pixtral|llava|nova-?pro)/i;
61
+ const INPUT_IMAGE_PATTERN = /(vision|multimodal|image[-_]?input)/i;
62
+
63
+ const DEFAULT_CONTEXT = 1000000;
64
+ const DEFAULT_MAX_TOKENS = 128000;
65
+
66
+ /** 是否是 noise(embedding / tts / image-gen 等) */
67
+ export function isNoise(id: string): boolean {
68
+ return NOISE_PATTERN.test(id);
69
+ }
70
+
71
+ /** 推断 reasoning(extended thinking 支持) */
72
+ export function inferReasoning(id: string): boolean {
73
+ return REASONING_PATTERN.test(id);
74
+ }
75
+
76
+ /** 推断 input 类型 */
77
+ export function inferInput(id: string): ("text" | "image")[] {
78
+ if (INPUT_IMAGE_PATTERN.test(id) || VISION_PATTERN.test(id)) return ["text", "image"];
79
+ return ["text"];
80
+ }
81
+
82
+ /** 把 FetchedModel 变成 ModelConfig。缺省值以 DEFAULT_MODEL_CONFIG 为准(reasoning=yes / input=[text,image] / ctx/max / thinkingLevelMap.medium=medium),
83
+ * 启发式仅在缺省为 no 时下调(比如「明显不是 reasoning」)。可选 3 个参传入覆盖上下文窗口 / max / defaults。 */
84
+ export function inferModel(id: string, fetched: FetchedModel, overrides?: { contextWindow?: number; maxTokens?: number; defaults?: typeof DEFAULT_MODEL_CONFIG }): ModelConfig {
85
+ const cfg = overrides?.defaults ?? DEFAULT_MODEL_CONFIG;
86
+ const heuristicReasoning = inferReasoning(id);
87
+ const heuristicInput = inferInput(id);
88
+ return {
89
+ id,
90
+ name: fetched.name && fetched.name !== id ? fetched.name : undefined,
91
+ reasoning: cfg.reasoning || heuristicReasoning,
92
+ input: cfg.input.includes("image") || heuristicInput.includes("image")
93
+ ? ["text", "image"]
94
+ : cfg.input,
95
+ contextWindow: overrides?.contextWindow ?? cfg.contextWindow,
96
+ maxTokens: overrides?.maxTokens ?? cfg.maxTokens,
97
+ thinkingLevelMap: { ...cfg.thinkingLevelMap },
98
+ };
99
+ }
100
+
101
+ // ============================================================================
102
+ // Fetcher
103
+ // ============================================================================
104
+
105
+ /** 拉取远端 model 列表;自带超时和 noise 过滤 */
106
+ export async function fetchListing(opts: {
107
+ baseUrl: string;
108
+ apiKey?: string;
109
+ apiKind: ApiKind;
110
+ signal?: AbortSignal;
111
+ timeoutMs?: number;
112
+ }): Promise<FetchResult> {
113
+ const { baseUrl, apiKey, apiKind, signal, timeoutMs = 10000 } = opts;
114
+ const warnings: string[] = [];
115
+
116
+ // 用 AbortController 双重保护:外部 signal + 超时
117
+ const ctrl = new AbortController();
118
+ const timer = setTimeout(() => ctrl.abort(new Error("timeout")), timeoutMs);
119
+ const onAbort = () => ctrl.abort(signal!.reason);
120
+ if (signal) signal.addEventListener("abort", onAbort);
121
+
122
+ try {
123
+ const base = baseUrl.replace(/\/+$/, "");
124
+ let url: string;
125
+ let headers: Record<string, string> = {};
126
+ let body: any;
127
+
128
+ if (apiKind === "google") {
129
+ // GET {base}/models?key=$KEY → { models: [{ name, ... }] }
130
+ const keyParam = apiKey ? `?key=${encodeURIComponent(apiKey)}` : "";
131
+ url = `${base}/models${keyParam}`;
132
+ } else {
133
+ // GET {base}/models → { data: [{ id, name, ... }] }
134
+ url = `${base}/models`;
135
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
136
+ }
137
+
138
+ const res = await fetch(url, { method: "GET", headers, signal: ctrl.signal });
139
+ if (!res.ok) {
140
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
141
+ }
142
+
143
+ const MAX = 5 * 1024 * 1024; // 5MB 硬上限,防止 OOM
144
+ const reader = res.body?.getReader();
145
+ if (!reader) throw new Error("no response body");
146
+ let received = 0;
147
+ const chunks: Uint8Array[] = [];
148
+ while (true) {
149
+ const { done, value } = await reader.read();
150
+ if (done) break;
151
+ received += value.byteLength;
152
+ if (received > MAX) {
153
+ reader.cancel();
154
+ throw new Error(`response too large (>${MAX} bytes)`);
155
+ }
156
+ chunks.push(value);
157
+ }
158
+ const text = new TextDecoder().decode(Buffer.concat(chunks));
159
+ const json = JSON.parse(text);
160
+
161
+ // 提取 models
162
+ let raw: any[] = [];
163
+ if (apiKind === "google") {
164
+ raw = Array.isArray(json?.models) ? json.models : [];
165
+ } else {
166
+ raw = Array.isArray(json?.data) ? json.data : [];
167
+ }
168
+
169
+ const models: FetchedModel[] = [];
170
+ for (const m of raw) {
171
+ let id: string | undefined;
172
+ if (apiKind === "google") {
173
+ // Google 返回 name 形如 "models/gemini-1.5-pro-latest"
174
+ const name = typeof m?.name === "string" ? m.name : "";
175
+ id = name.startsWith("models/") ? name.slice("models/".length) : name;
176
+ // 只保留支持 generateContent 的(chat model)
177
+ const methods = m?.supportedGenerationMethods;
178
+ if (Array.isArray(methods) && !methods.includes("generateContent")) continue;
179
+ } else {
180
+ if (typeof m?.id === "string") id = m.id;
181
+ }
182
+ if (!id) continue;
183
+ if (isNoise(id)) continue;
184
+ const out: FetchedModel = { id };
185
+ if (typeof m?.displayName === "string" && apiKind === "google") out.name = m.displayName;
186
+ else if (typeof m?.name === "string" && apiKind === "openai-compat" && m.name !== id) out.name = m.name;
187
+ models.push(out);
188
+ }
189
+
190
+ return { baseUrl: base, apiKind, models, warnings };
191
+ } finally {
192
+ clearTimeout(timer);
193
+ if (signal) signal.removeEventListener("abort", onAbort);
194
+ }
195
+ }
196
+
197
+ // ============================================================================
198
+ // Sync existing providers: 给定 json,对每个有 baseUrl 的 provider 跑 fetchListing
199
+ // ============================================================================
200
+
201
+ export async function syncExisting(
202
+ providers: Record<string, { baseUrl?: string; apiKey?: string; api?: string }>,
203
+ signal?: AbortSignal,
204
+ timeoutMs?: number,
205
+ ): Promise<Array<{ providerId: string; result: FetchResult | { error: string } }>> {
206
+ const out: Array<{ providerId: string; result: FetchResult | { error: string } }> = [];
207
+ for (const [pid, p] of Object.entries(providers)) {
208
+ if (!p.baseUrl) continue;
209
+ const apiKind: ApiKind = p.api === "google-generative-ai" ? "google" : "openai-compat";
210
+ try {
211
+ const result = await fetchListing({
212
+ baseUrl: p.baseUrl,
213
+ apiKey: p.apiKey,
214
+ apiKind,
215
+ signal,
216
+ timeoutMs,
217
+ });
218
+ out.push({ providerId: pid, result });
219
+ } catch (err) {
220
+ out.push({ providerId: pid, result: { error: err instanceof Error ? err.message : String(err) } });
221
+ }
222
+ }
223
+ return out;
224
+ }
225
+
226
+ // ============================================================================
227
+ // 合并逻辑
228
+ // ============================================================================
229
+
230
+ /** 把 FetchedModel[] 和现有 models[] 合并:去重(按 id)、用启发式生成新 model 字段。
231
+ * 返回 { toAdd: ModelConfig[], toUpdate: ModelConfig[], skipped: string[] }
232
+ * - toAdd: 新 id(不在 existing)
233
+ * - toUpdate: 已存在 id(不更新,保持用户手工改的字段)
234
+ * - skipped: 跳过的 id(noise 等)
235
+ * - defaults: 透传给 inferModel(控制 ctx/max 和 thinkingLevelMap 等默认值) */
236
+ export function diffModels(
237
+ fetched: FetchedModel[],
238
+ existing: { id: string }[],
239
+ overrides?: { contextWindow?: number; maxTokens?: number; defaults?: typeof DEFAULT_MODEL_CONFIG },
240
+ ): { toAdd: ModelConfig[]; skipped: string[] } {
241
+ const existingIds = new Set(existing.map((m) => m.id));
242
+ const toAdd: ModelConfig[] = [];
243
+ const skipped: string[] = [];
244
+ for (const f of fetched) {
245
+ if (isNoise(f.id)) {
246
+ skipped.push(f.id);
247
+ continue;
248
+ }
249
+ if (existingIds.has(f.id)) continue; // 已存在不更新
250
+ toAdd.push(inferModel(f.id, f, overrides));
251
+ }
252
+ return { toAdd, skipped };
253
+ }