pi-model-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.
Files changed (43) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +8 -0
  3. package/README.en.md +206 -0
  4. package/README.md +210 -0
  5. package/atomic-write.ts +100 -0
  6. package/builtin-model-catalog.ts +92 -0
  7. package/claude-code-compat.ts +56 -0
  8. package/common.ts +66 -0
  9. package/compat-settings.ts +25 -0
  10. package/config-value-reference.ts +51 -0
  11. package/configuration-persistence.ts +72 -0
  12. package/header-profile-mutations.ts +46 -0
  13. package/index.ts +82 -0
  14. package/local-proxy-service.ts +313 -0
  15. package/model-mutations.ts +210 -0
  16. package/models-json-manager.ts +238 -0
  17. package/models-json-mutations.ts +90 -0
  18. package/models-json-sync.ts +386 -0
  19. package/openai-responses-payload.ts +80 -0
  20. package/openai-service-tier.ts +36 -0
  21. package/package.json +74 -0
  22. package/presets/builtin-client-headers.ts +23 -0
  23. package/presets/client-headers.ts +126 -0
  24. package/presets/providers.ts +80 -0
  25. package/presets/thinking.ts +81 -0
  26. package/provider-registrar.ts +281 -0
  27. package/request-pipeline.ts +88 -0
  28. package/rescue.ts +69 -0
  29. package/runtime-base-url.ts +54 -0
  30. package/state-cache.ts +55 -0
  31. package/state-document.ts +569 -0
  32. package/state-metadata-store.ts +455 -0
  33. package/state-store.ts +238 -0
  34. package/tui/dashboard.ts +376 -0
  35. package/tui/editor-model.ts +339 -0
  36. package/tui/editor-provider.ts +219 -0
  37. package/tui/header-profiles-panel.ts +307 -0
  38. package/tui/model-list-fetch.ts +259 -0
  39. package/tui/model-picker.ts +266 -0
  40. package/tui/models-json-panel.ts +269 -0
  41. package/tui/persistent-menu.ts +349 -0
  42. package/tui/ui-helpers.ts +289 -0
  43. package/types.ts +165 -0
package/common.ts ADDED
@@ -0,0 +1,66 @@
1
+ // 通用小工具:JSON 解析、字符串处理、错误格式化、克隆。
2
+ // 与 pi-model-add/common.ts 等价;后续 step 不再依赖旧插件。
3
+
4
+ export function isObjectRecord(value: unknown): value is Record<string, unknown> {
5
+ return value !== null && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+
8
+ export function stripJsonNoise(source: string): string {
9
+ return source
10
+ .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (match) => (match.startsWith('"') ? match : ""))
11
+ .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (match, tail) => tail ?? (match.startsWith('"') ? match : ""));
12
+ }
13
+
14
+ export function trimOrFallback(value: string | undefined, fallback: string): string {
15
+ const trimmed = value?.trim();
16
+ return trimmed ? trimmed : fallback;
17
+ }
18
+
19
+ export function trimSingleLine(value: string): string {
20
+ return value.replace(/[\r\n]+/g, " ").trim();
21
+ }
22
+
23
+ export function trimSingleLineOrFallback(value: string | undefined, fallback: string): string {
24
+ const singleLine = value?.replace(/[\r\n]+/g, " ").trim();
25
+ return singleLine ? singleLine : fallback;
26
+ }
27
+
28
+ export function formatUnknownError(error: unknown): string {
29
+ return error instanceof Error ? error.message : String(error);
30
+ }
31
+
32
+ export function cloneStringRecord(record: Record<string, string> | undefined): Record<string, string> {
33
+ return { ...(record ?? {}) };
34
+ }
35
+
36
+ export function hasStringRecordEntries(record: Record<string, string> | undefined): boolean {
37
+ return record !== undefined && Object.keys(record).length > 0;
38
+ }
39
+
40
+ export function parseStringRecordJson(source: string, label: string): Record<string, string> {
41
+ const parsed = JSON.parse(stripJsonNoise(source));
42
+ if (!isObjectRecord(parsed)) throw new Error(`${label} JSON 必须是对象`);
43
+ const record: Record<string, string> = {};
44
+ for (const [key, value] of Object.entries(parsed)) {
45
+ if (typeof value !== "string") throw new Error(`${label} ${key} 的值必须是字符串`);
46
+ const trimmedKey = key.trim();
47
+ if (trimmedKey) record[trimmedKey] = value;
48
+ }
49
+ return record;
50
+ }
51
+
52
+ export function stringifyJson(value: unknown): string {
53
+ return `${JSON.stringify(value, null, 2)}\n`;
54
+ }
55
+
56
+ export function cloneJson<T>(value: T): T {
57
+ return value === undefined ? value : JSON.parse(JSON.stringify(value));
58
+ }
59
+
60
+ export function slugifyName(name: string): string {
61
+ return name
62
+ .trim()
63
+ .toLowerCase()
64
+ .replace(/[^a-z0-9._-]+/g, "-")
65
+ .replace(/^-+|-+$/g, "");
66
+ }
@@ -0,0 +1,25 @@
1
+ // Pi 会把 provider 级 compat 作为默认值,再由模型级 compat 覆盖。
2
+ // 动态 registerProvider 不接受 provider.compat,因此插件注册前在这里复现相同合并语义。
3
+
4
+ import { isObjectRecord } from "./common.ts";
5
+ import type { CompatSettings } from "./types.ts";
6
+
7
+ const NESTED_COMPAT_FIELDS = ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs"] as const;
8
+
9
+ export function mergeCompatSettings(
10
+ providerCompat: CompatSettings | undefined,
11
+ modelCompat: CompatSettings | undefined,
12
+ ): CompatSettings | undefined {
13
+ if (!providerCompat && !modelCompat) return undefined;
14
+ const merged: CompatSettings = { ...providerCompat, ...modelCompat };
15
+ for (const field of NESTED_COMPAT_FIELDS) {
16
+ const providerValue = providerCompat?.[field];
17
+ const modelValue = modelCompat?.[field];
18
+ if (!isObjectRecord(providerValue) && !isObjectRecord(modelValue)) continue;
19
+ merged[field] = {
20
+ ...(isObjectRecord(providerValue) ? providerValue : {}),
21
+ ...(isObjectRecord(modelValue) ? modelValue : {}),
22
+ };
23
+ }
24
+ return merged;
25
+ }
@@ -0,0 +1,51 @@
1
+ // Pi 配置值允许字面量、!command,以及 $NAME/${NAME} 环境变量模板。
2
+ // 这里只解析展示认证状态所需的引用信息,不执行命令或展开敏感值。
3
+
4
+ const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
5
+ const ENV_VAR_NAME_PREFIX_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*/;
6
+
7
+ export function isCommandConfigValue(config: string): boolean {
8
+ return config.startsWith("!");
9
+ }
10
+
11
+ /** [喵喵喵]: 与 Pi 0.80.10 模板语义保持一致,包括嵌入引用和 $$/$! 转义 (2026-07-17) */
12
+ export function getConfigValueEnvVarNames(config: string): string[] {
13
+ if (isCommandConfigValue(config)) return [];
14
+ const names: string[] = [];
15
+ let index = 0;
16
+ while (index < config.length) {
17
+ const dollarIndex = config.indexOf("$", index);
18
+ if (dollarIndex < 0) break;
19
+ const nextCharacter = config[dollarIndex + 1];
20
+ if (nextCharacter === "$" || nextCharacter === "!") {
21
+ index = dollarIndex + 2;
22
+ continue;
23
+ }
24
+ if (nextCharacter === "{") {
25
+ const endIndex = config.indexOf("}", dollarIndex + 2);
26
+ if (endIndex < 0) {
27
+ index = dollarIndex + 1;
28
+ continue;
29
+ }
30
+ const name = config.slice(dollarIndex + 2, endIndex);
31
+ if (ENV_VAR_NAME_PATTERN.test(name) && !names.includes(name)) names.push(name);
32
+ index = endIndex + 1;
33
+ continue;
34
+ }
35
+ const match = config.slice(dollarIndex + 1).match(ENV_VAR_NAME_PREFIX_PATTERN);
36
+ if (match) {
37
+ if (!names.includes(match[0])) names.push(match[0]);
38
+ index = dollarIndex + 1 + match[0].length;
39
+ continue;
40
+ }
41
+ index = dollarIndex + 1;
42
+ }
43
+ return names;
44
+ }
45
+
46
+ export function getSingleConfigValueEnvVarName(config: string): string | undefined {
47
+ const bracedMatch = config.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/);
48
+ if (bracedMatch) return bracedMatch[1];
49
+ const unbracedMatch = config.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/);
50
+ return unbracedMatch?.[1];
51
+ }
@@ -0,0 +1,72 @@
1
+ // 模型管理保存边界:models.json 是模型权威源,state.json 仅承载插件 metadata。
2
+ // 两个文件无法组成单一原子事务;写入完成后必须 reload 当前 registry,清除旧 models.json 配置层。
3
+
4
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
+ import { formatUnknownError } from "./common.ts";
6
+ import {
7
+ syncModelRenameToModelsJson,
8
+ syncProviderRenameToModelsJson,
9
+ syncStateToModelsJson,
10
+ } from "./models-json-sync.ts";
11
+ import { invalidateStateCache } from "./state-cache.ts";
12
+ import { writeState } from "./state-store.ts";
13
+ import type { StateDocument } from "./types.ts";
14
+
15
+ async function persistModelsThenMetadata(
16
+ ctx: ExtensionCommandContext,
17
+ document: StateDocument,
18
+ writeModelsJson: () => Promise<void>,
19
+ ): Promise<void> {
20
+ try {
21
+ await writeModelsJson();
22
+ invalidateStateCache();
23
+ } catch (error) {
24
+ throw new Error(`models.json 未写入:${formatUnknownError(error)}`);
25
+ }
26
+ try {
27
+ await writeState(document);
28
+ invalidateStateCache();
29
+ } catch (error) {
30
+ throw new Error(`models.json 已写入,但 state.json metadata 写入失败:${formatUnknownError(error)}`);
31
+ }
32
+ try {
33
+ await ctx.modelRegistry.refresh();
34
+ } catch (error) {
35
+ throw new Error(`models.json/state.json 已写入,但当前会话 registry 重载失败:${formatUnknownError(error)}。可执行 /reload 重试。`);
36
+ }
37
+ }
38
+
39
+ export async function persistManagedConfiguration(
40
+ ctx: ExtensionCommandContext,
41
+ document: StateDocument,
42
+ removedProviderIds: string[] = [],
43
+ ): Promise<void> {
44
+ await persistModelsThenMetadata(ctx, document, () => syncStateToModelsJson(document, removedProviderIds));
45
+ }
46
+
47
+ export async function persistProviderRenameConfiguration(
48
+ ctx: ExtensionCommandContext,
49
+ document: StateDocument,
50
+ oldProviderId: string,
51
+ newProviderId: string,
52
+ ): Promise<void> {
53
+ await persistModelsThenMetadata(
54
+ ctx,
55
+ document,
56
+ () => syncProviderRenameToModelsJson(document, oldProviderId, newProviderId),
57
+ );
58
+ }
59
+
60
+ export async function persistModelRenameConfiguration(
61
+ ctx: ExtensionCommandContext,
62
+ document: StateDocument,
63
+ providerId: string,
64
+ oldModelId: string,
65
+ newModelId: string,
66
+ ): Promise<void> {
67
+ await persistModelsThenMetadata(
68
+ ctx,
69
+ document,
70
+ () => syncModelRenameToModelsJson(document, providerId, oldModelId, newModelId),
71
+ );
72
+ }
@@ -0,0 +1,46 @@
1
+ // header-profile-mutations.ts
2
+ //
3
+ // 自定义请求头 profile 的事务层:集中处理 state/models.json 持久化和 runtime provider 刷新。
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import { persistManagedConfiguration } from "./configuration-persistence.ts";
7
+ import { registerAllFromState } from "./provider-registrar.ts";
8
+ import { deleteRequestHeaderProfileFromDocument, upsertRequestHeaderProfileInDocument } from "./state-document.ts";
9
+ import type { RequestHeaderProfileDraft, StateDocument } from "./types.ts";
10
+
11
+ function notifyHeaderProfileRefresh(
12
+ ctx: ExtensionCommandContext,
13
+ successMessage: string,
14
+ warnings: string[],
15
+ ): void {
16
+ if (warnings.length === 0) {
17
+ ctx.ui.notify(successMessage, "info");
18
+ return;
19
+ }
20
+ ctx.ui.notify(`${successMessage},但以下接入未能刷新,已保留上一版 runtime:\n- ${warnings.join("\n- ")}`, "warning");
21
+ }
22
+
23
+ export async function saveRequestHeaderProfileConfiguration(
24
+ pi: ExtensionAPI,
25
+ ctx: ExtensionCommandContext,
26
+ state: StateDocument,
27
+ draft: RequestHeaderProfileDraft,
28
+ oldProfileId: string | undefined,
29
+ ): Promise<void> {
30
+ const nextState = upsertRequestHeaderProfileInDocument(state, oldProfileId, draft);
31
+ await persistManagedConfiguration(ctx, nextState);
32
+ const warnings = await registerAllFromState(pi, nextState);
33
+ notifyHeaderProfileRefresh(ctx, `已保存请求头 ${draft.profileId.trim()}`, warnings);
34
+ }
35
+
36
+ export async function deleteRequestHeaderProfileConfiguration(
37
+ pi: ExtensionAPI,
38
+ ctx: ExtensionCommandContext,
39
+ state: StateDocument,
40
+ profileId: string,
41
+ ): Promise<void> {
42
+ const nextState = deleteRequestHeaderProfileFromDocument(state, profileId);
43
+ await persistManagedConfiguration(ctx, nextState);
44
+ const warnings = await registerAllFromState(pi, nextState);
45
+ notifyHeaderProfileRefresh(ctx, `已删除请求头 ${profileId}`, warnings);
46
+ }
package/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ // pi-model-manager 主入口
2
+ //
3
+ // factory 阶段(pi 会等待)读取配置并注册模型 catalog,但不启动长生命周期本地代理。
4
+ // session_start 再激活完整 provider transport,session_shutdown 幂等关闭代理服务。
5
+ //
6
+ // models.json 是模型定义唯一来源;state.json 只保存请求头 profile、抓包缓存、service_tier 等插件私有元数据。
7
+ // 启动期通知用 session_start 事件 + ctx.ui.notify(factory 期没有 ctx)。
8
+ //
9
+ // 命令:/model-manager → TUI 面板
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { formatUnknownError } from "./common.ts";
13
+ import { resetClaudeCodeMetadataSession } from "./claude-code-compat.ts";
14
+ import { closeLocalProxyServer } from "./local-proxy-service.ts";
15
+ import { registerAllFromState, registerCatalogFromState } from "./provider-registrar.ts";
16
+ import { createRequestPipeline } from "./request-pipeline.ts";
17
+ import { createEmptyState, readState } from "./state-store.ts";
18
+ import { runDashboard } from "./tui/dashboard.ts";
19
+
20
+ interface StartupSummary {
21
+ startupErrors: string[];
22
+ }
23
+
24
+ export default async function modelManagerExtension(pi: ExtensionAPI): Promise<void> {
25
+ const summary: StartupSummary = { startupErrors: [] };
26
+
27
+ // ---- 1. 读原生模型配置 + 插件元数据 ----
28
+ try {
29
+ const stateForStartup = await readState().catch((error) => {
30
+ summary.startupErrors.push(`读取 models.json/state.json 失败:${formatUnknownError(error)}(本次启动仅使用内存空配置,不覆盖原文件)`);
31
+ return createEmptyState();
32
+ });
33
+ for (const warning of await registerCatalogFromState(pi, stateForStartup)) {
34
+ summary.startupErrors.push(`注册模型目录失败:${warning}`);
35
+ }
36
+ } catch (error) {
37
+ summary.startupErrors.push(`读取/注册模型配置失败:${formatUnknownError(error)}`);
38
+ }
39
+
40
+ // ---- 2. session_start 激活 transport 并汇报 ----
41
+ let notified = false;
42
+ const requestPipeline = createRequestPipeline();
43
+ pi.on("session_start", async (event, ctx) => {
44
+ resetClaudeCodeMetadataSession();
45
+ const transportErrors: string[] = [];
46
+ try {
47
+ const currentState = await readState();
48
+ for (const warning of await registerAllFromState(pi, currentState)) {
49
+ transportErrors.push(`激活模型接入失败:${warning}`);
50
+ }
51
+ } catch (error) {
52
+ transportErrors.push(`读取或激活模型配置失败:${formatUnknownError(error)}`);
53
+ }
54
+
55
+ if (event.reason === "startup" && !notified) {
56
+ notified = true;
57
+ for (const error of summary.startupErrors) {
58
+ ctx.ui.notify(`[pi-model-manager] ${error}`, "error");
59
+ }
60
+ }
61
+ for (const error of transportErrors) {
62
+ ctx.ui.notify(`[pi-model-manager] ${error}`, "error");
63
+ }
64
+ });
65
+
66
+ pi.on("before_provider_request", async (event, ctx) => requestPipeline.transform(event.payload, ctx));
67
+ pi.on("session_shutdown", async () => {
68
+ await closeLocalProxyServer();
69
+ });
70
+
71
+ // ---- 3. 命令:/model-manager → TUI 面板 ----
72
+ pi.registerCommand("model-manager", {
73
+ description: "模型接入与请求配置",
74
+ handler: async (_args, ctx) => {
75
+ if (ctx.mode !== "tui") {
76
+ ctx.ui.notify("/model-manager 需要 TUI 交互模式", "error");
77
+ return;
78
+ }
79
+ await runDashboard(pi, ctx);
80
+ },
81
+ });
82
+ }
@@ -0,0 +1,313 @@
1
+ // local-proxy-service.ts
2
+ //
3
+ // 插件内置的按接入点代理转发层。
4
+ // 不修改 pi 本体:开启代理的接入点在运行时注册为 127.0.0.1 本地地址,
5
+ // 本服务再通过用户配置的 HTTP/HTTPS 代理转发到真实上游。
6
+
7
+ import http, { type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http";
8
+ import https from "node:https";
9
+ import { HttpProxyAgent } from "http-proxy-agent";
10
+ import { HttpsProxyAgent } from "https-proxy-agent";
11
+ import type { StoredProvider } from "./types.ts";
12
+ import { DEFAULT_PROVIDER_HTTP_PROXY_URL } from "./types.ts";
13
+
14
+ export interface ProviderProxyRoute {
15
+ upstreamOrigin: string;
16
+ proxyUrl: string;
17
+ }
18
+
19
+ export interface TemporaryLocalProxyRoute {
20
+ url: string;
21
+ close(): void;
22
+ }
23
+
24
+ const ROUTES = new Map<string, ProviderProxyRoute>();
25
+ let server: http.Server | undefined;
26
+ let listenPromise: Promise<number> | undefined;
27
+ let listenPort: number | undefined;
28
+ let temporaryRouteSequence = 0;
29
+
30
+ const HOP_BY_HOP_HEADERS = new Set([
31
+ "connection",
32
+ "keep-alive",
33
+ "proxy-authenticate",
34
+ "proxy-authorization",
35
+ "te",
36
+ "trailer",
37
+ "transfer-encoding",
38
+ "upgrade",
39
+ ]);
40
+
41
+ function trimTrailingSlashes(value: string): string {
42
+ return value.replace(/\/+$/, "");
43
+ }
44
+
45
+ function normalizeHttpProxyUrl(proxyUrl: string | undefined): string {
46
+ return proxyUrl?.trim() || DEFAULT_PROVIDER_HTTP_PROXY_URL;
47
+ }
48
+
49
+ export function isProviderHttpProxyEnabled(provider: StoredProvider): boolean {
50
+ return provider.httpProxyEnabled === true;
51
+ }
52
+
53
+ export function getProviderHttpProxyUrl(provider: StoredProvider): string {
54
+ return normalizeHttpProxyUrl(provider.httpProxyUrl);
55
+ }
56
+
57
+ function stripHopByHopHeaders(headers: IncomingHttpHeaders): Record<string, string | string[]> {
58
+ const next: Record<string, string | string[]> = {};
59
+ for (const [name, value] of Object.entries(headers)) {
60
+ if (value === undefined || HOP_BY_HOP_HEADERS.has(name.toLowerCase())) continue;
61
+ next[name] = value;
62
+ }
63
+ return next;
64
+ }
65
+
66
+ function getUrlPort(url: URL): number {
67
+ if (url.port) return Number.parseInt(url.port, 10);
68
+ return url.protocol === "https:" ? 443 : 80;
69
+ }
70
+
71
+ function getHostHeader(url: URL): string {
72
+ return url.host;
73
+ }
74
+
75
+ function ensureSupportedProxyProtocol(proxy: URL): void {
76
+ if (proxy.protocol !== "http:" && proxy.protocol !== "https:") {
77
+ throw new Error("代理地址目前只支持 http:// 或 https:// 代理");
78
+ }
79
+ }
80
+
81
+ function pipeResponse(upstreamResponse: IncomingMessage, response: ServerResponse): void {
82
+ const headers = stripHopByHopHeaders(upstreamResponse.headers);
83
+ response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.statusMessage, headers);
84
+ // 尽快把 SSE 响应头交给本地客户端,后续 body 由 Node 的 HTTP 栈处理 chunked/backpressure。
85
+ response.flushHeaders();
86
+ upstreamResponse.on("error", (error) => {
87
+ if (!response.destroyed) response.destroy(error);
88
+ });
89
+ upstreamResponse.pipe(response);
90
+ }
91
+
92
+
93
+ function formatProxyError(error: unknown): string {
94
+ if (error instanceof Error) {
95
+ const message = error.message.trim();
96
+ if (message) return message;
97
+ const cause = (error as Error & { code?: string }).code;
98
+ if (cause) return `代理请求失败:${cause}`;
99
+ if (error.stack?.trim()) return error.stack.trim().split("\n", 1)[0] ?? "未知代理错误";
100
+ return `${error.name || "Error"}(无错误消息)`;
101
+ }
102
+ const message = String(error).trim();
103
+ return message || "未知代理错误";
104
+ }
105
+
106
+ function writeProxyError(response: ServerResponse, error: unknown): void {
107
+ if (response.headersSent) {
108
+ response.destroy(error instanceof Error ? error : undefined);
109
+ return;
110
+ }
111
+ response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
112
+ response.end(`[pi-model-manager proxy] ${formatProxyError(error)}`);
113
+ }
114
+
115
+ function buildForwardRequestHeaders(target: URL, request: IncomingMessage): Record<string, string | string[]> {
116
+ const headers = stripHopByHopHeaders(request.headers);
117
+ headers.host = getHostHeader(target);
118
+ return headers;
119
+ }
120
+
121
+ function bindClientAbort(request: IncomingMessage, response: ServerResponse, upstreamRequest: http.ClientRequest): void {
122
+ const destroyUpstream = () => {
123
+ if (!upstreamRequest.destroyed) upstreamRequest.destroy();
124
+ };
125
+ const destroyUpstreamWithError = (error: Error) => {
126
+ if (!upstreamRequest.destroyed) upstreamRequest.destroy(error);
127
+ };
128
+ const onResponseClose = () => {
129
+ if (!response.writableEnded) destroyUpstream();
130
+ };
131
+ const cleanup = () => {
132
+ request.off("aborted", destroyUpstream);
133
+ request.off("error", destroyUpstreamWithError);
134
+ response.off("close", onResponseClose);
135
+ };
136
+ request.once("aborted", destroyUpstream);
137
+ request.once("error", destroyUpstreamWithError);
138
+ response.once("close", onResponseClose);
139
+ upstreamRequest.once("close", cleanup);
140
+ }
141
+
142
+ function writeUpstreamRequestError(response: ServerResponse, error: Error): void {
143
+ if (response.destroyed || response.writableEnded) return;
144
+ writeProxyError(response, error);
145
+ }
146
+
147
+ function forwardPlainHttp(target: URL, proxy: URL, request: IncomingMessage, response: ServerResponse): void {
148
+ ensureSupportedProxyProtocol(proxy);
149
+ const headers = buildForwardRequestHeaders(target, request);
150
+ const agent = new HttpProxyAgent(proxy);
151
+ const upstreamRequest = http.request({
152
+ hostname: target.hostname,
153
+ port: getUrlPort(target),
154
+ method: request.method,
155
+ path: `${target.pathname}${target.search}`,
156
+ headers,
157
+ agent,
158
+ }, (upstreamResponse) => pipeResponse(upstreamResponse, response));
159
+ upstreamRequest.once("close", () => agent.destroy());
160
+ bindClientAbort(request, response, upstreamRequest);
161
+ upstreamRequest.on("error", (error) => writeUpstreamRequestError(response, error));
162
+ // [喵喵喵]: 请求体直接流向上游,避免图像等大 payload 在本地代理中形成完整内存副本 (2026-07-17)
163
+ request.pipe(upstreamRequest);
164
+ }
165
+
166
+ function forwardHttps(target: URL, proxy: URL, request: IncomingMessage, response: ServerResponse): void {
167
+ ensureSupportedProxyProtocol(proxy);
168
+ const headers = buildForwardRequestHeaders(target, request);
169
+ const agent = new HttpsProxyAgent(proxy);
170
+ const upstreamRequest = https.request({
171
+ hostname: target.hostname,
172
+ port: getUrlPort(target),
173
+ method: request.method,
174
+ path: `${target.pathname}${target.search}`,
175
+ headers,
176
+ agent,
177
+ }, (upstreamResponse) => pipeResponse(upstreamResponse, response));
178
+ upstreamRequest.once("close", () => agent.destroy());
179
+ bindClientAbort(request, response, upstreamRequest);
180
+ upstreamRequest.on("error", (error) => writeUpstreamRequestError(response, error));
181
+ request.pipe(upstreamRequest);
182
+ }
183
+
184
+ function forwardRequest(route: ProviderProxyRoute, request: IncomingMessage, response: ServerResponse, routePath: string): void {
185
+ const target = new URL(routePath, route.upstreamOrigin);
186
+ const proxy = new URL(route.proxyUrl);
187
+ if (target.protocol === "http:") {
188
+ forwardPlainHttp(target, proxy, request, response);
189
+ return;
190
+ }
191
+ if (target.protocol === "https:") {
192
+ forwardHttps(target, proxy, request, response);
193
+ return;
194
+ }
195
+ throw new Error(`不支持的上游协议:${target.protocol}`);
196
+ }
197
+
198
+ function handleRequest(request: IncomingMessage, response: ServerResponse): void {
199
+ void (async () => {
200
+ try {
201
+ const localUrl = new URL(request.url ?? "/", "http://127.0.0.1");
202
+ const [, encodedProviderId, ...rest] = localUrl.pathname.split("/");
203
+ if (!encodedProviderId) {
204
+ response.writeHead(404);
205
+ response.end("missing provider id");
206
+ return;
207
+ }
208
+ const providerId = decodeURIComponent(encodedProviderId);
209
+ const route = ROUTES.get(providerId);
210
+ if (!route) {
211
+ response.writeHead(404);
212
+ response.end(`unknown provider: ${providerId}`);
213
+ return;
214
+ }
215
+ const path = `/${rest.join("/")}${localUrl.search}`;
216
+ await forwardRequest(route, request, response, path);
217
+ } catch (error) {
218
+ writeProxyError(response, error);
219
+ }
220
+ })();
221
+ }
222
+
223
+ async function ensureServer(): Promise<number> {
224
+ if (listenPort !== undefined) return listenPort;
225
+ if (listenPromise) return listenPromise;
226
+ server = http.createServer(handleRequest);
227
+ listenPromise = new Promise((resolve, reject) => {
228
+ server!.once("error", reject);
229
+ server!.listen(0, "127.0.0.1", () => {
230
+ const address = server!.address();
231
+ if (!address || typeof address === "string") {
232
+ reject(new Error("本地代理转发服务监听地址异常"));
233
+ return;
234
+ }
235
+ listenPort = address.port;
236
+ server!.off("error", reject);
237
+ server!.unref();
238
+ resolve(address.port);
239
+ });
240
+ });
241
+ return listenPromise;
242
+ }
243
+
244
+ export async function getLocalProxyBaseUrl(routeId: string, upstreamRuntimeBaseUrl: string, proxyUrl: string): Promise<string> {
245
+ const upstream = new URL(upstreamRuntimeBaseUrl);
246
+ const proxy = new URL(normalizeHttpProxyUrl(proxyUrl));
247
+ ensureSupportedProxyProtocol(proxy);
248
+ const port = await ensureServer();
249
+ ROUTES.set(routeId, {
250
+ upstreamOrigin: upstream.origin,
251
+ proxyUrl: proxy.toString(),
252
+ });
253
+ const basePath = trimTrailingSlashes(upstream.pathname || "");
254
+ return `http://127.0.0.1:${port}/${encodeURIComponent(routeId)}${basePath}${upstream.search}`;
255
+ }
256
+
257
+ export async function openTemporaryLocalProxyRoute(
258
+ providerId: string,
259
+ upstreamUrl: string,
260
+ proxyUrl: string,
261
+ ): Promise<TemporaryLocalProxyRoute> {
262
+ const upstream = new URL(upstreamUrl);
263
+ const proxy = new URL(normalizeHttpProxyUrl(proxyUrl));
264
+ ensureSupportedProxyProtocol(proxy);
265
+ const port = await ensureServer();
266
+ const routeId = `${providerId}/model-list/${++temporaryRouteSequence}`;
267
+ const route = { upstreamOrigin: upstream.origin, proxyUrl: proxy.toString() };
268
+ ROUTES.set(routeId, route);
269
+ return {
270
+ url: `http://127.0.0.1:${port}/${encodeURIComponent(routeId)}${upstream.pathname}${upstream.search}`,
271
+ close(): void {
272
+ if (ROUTES.get(routeId) === route) ROUTES.delete(routeId);
273
+ },
274
+ };
275
+ }
276
+
277
+ function isProviderRouteId(routeId: string, providerId: string): boolean {
278
+ return routeId === providerId || routeId.startsWith(`${providerId}/`);
279
+ }
280
+
281
+ export function snapshotProviderLocalProxyRoutes(providerId: string): Map<string, ProviderProxyRoute> {
282
+ return new Map(
283
+ [...ROUTES.entries()]
284
+ .filter(([routeId]) => isProviderRouteId(routeId, providerId))
285
+ .map(([routeId, route]): [string, ProviderProxyRoute] => [routeId, { ...route }]),
286
+ );
287
+ }
288
+
289
+ export function restoreProviderLocalProxyRoutes(
290
+ providerId: string,
291
+ snapshot: ReadonlyMap<string, ProviderProxyRoute>,
292
+ ): void {
293
+ removeProviderLocalProxyRoutes(providerId);
294
+ for (const [routeId, route] of snapshot) ROUTES.set(routeId, { ...route });
295
+ }
296
+
297
+ export function removeProviderLocalProxyRoutes(providerId: string): void {
298
+ for (const routeId of ROUTES.keys()) {
299
+ if (isProviderRouteId(routeId, providerId)) ROUTES.delete(routeId);
300
+ }
301
+ }
302
+
303
+ export async function closeLocalProxyServer(): Promise<void> {
304
+ ROUTES.clear();
305
+ listenPromise = undefined;
306
+ listenPort = undefined;
307
+ const current = server;
308
+ server = undefined;
309
+ if (!current) return;
310
+ await new Promise<void>((resolve, reject) => {
311
+ current.close((error) => error ? reject(error) : resolve());
312
+ });
313
+ }