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.
- package/LICENSE +661 -0
- package/NOTICE +8 -0
- package/README.en.md +206 -0
- package/README.md +210 -0
- package/atomic-write.ts +100 -0
- package/builtin-model-catalog.ts +92 -0
- package/claude-code-compat.ts +56 -0
- package/common.ts +66 -0
- package/compat-settings.ts +25 -0
- package/config-value-reference.ts +51 -0
- package/configuration-persistence.ts +72 -0
- package/header-profile-mutations.ts +46 -0
- package/index.ts +82 -0
- package/local-proxy-service.ts +313 -0
- package/model-mutations.ts +210 -0
- package/models-json-manager.ts +238 -0
- package/models-json-mutations.ts +90 -0
- package/models-json-sync.ts +386 -0
- package/openai-responses-payload.ts +80 -0
- package/openai-service-tier.ts +36 -0
- package/package.json +74 -0
- package/presets/builtin-client-headers.ts +23 -0
- package/presets/client-headers.ts +126 -0
- package/presets/providers.ts +80 -0
- package/presets/thinking.ts +81 -0
- package/provider-registrar.ts +281 -0
- package/request-pipeline.ts +88 -0
- package/rescue.ts +69 -0
- package/runtime-base-url.ts +54 -0
- package/state-cache.ts +55 -0
- package/state-document.ts +569 -0
- package/state-metadata-store.ts +455 -0
- package/state-store.ts +238 -0
- package/tui/dashboard.ts +376 -0
- package/tui/editor-model.ts +339 -0
- package/tui/editor-provider.ts +219 -0
- package/tui/header-profiles-panel.ts +307 -0
- package/tui/model-list-fetch.ts +259 -0
- package/tui/model-picker.ts +266 -0
- package/tui/models-json-panel.ts +269 -0
- package/tui/persistent-menu.ts +349 -0
- package/tui/ui-helpers.ts +289 -0
- package/types.ts +165 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// 客户端请求头:5 个模式(recommended / disabled / claude-code / codex-cli / custom)。
|
|
2
|
+
// ClaudeCode profile 的 body metadata 信号由 ../claude-code-compat.ts 在请求发送前补齐。
|
|
3
|
+
//
|
|
4
|
+
// [喵喵喵]: 内置值由私有抓包工具生成,公开插件不携带捕获能力 (2026-07-26)
|
|
5
|
+
|
|
6
|
+
import { cloneStringRecord, hasStringRecordEntries } from "../common.ts";
|
|
7
|
+
import type { ApiKind, BuiltInClientHeaderProfileId, ClientHeaderProfileId, CompatSettings, StoredClientHeaderCapture } from "../types.ts";
|
|
8
|
+
import { CLAUDE_CODE_CLIENT_HEADERS, CODEX_CLI_CLIENT_HEADERS } from "./builtin-client-headers.ts";
|
|
9
|
+
|
|
10
|
+
export const ANTHROPIC_INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export const CLIENT_HEADER_PROFILE_LABELS: Record<ClientHeaderProfileId, string> = {
|
|
14
|
+
recommended: "自动推荐",
|
|
15
|
+
disabled: "不添加",
|
|
16
|
+
"claude-code": "ClaudeCode",
|
|
17
|
+
"codex-cli": "Codex",
|
|
18
|
+
custom: "自定义请求头",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function removeAnthropicBetaFeature(headers: Record<string, string>, feature: string): void {
|
|
22
|
+
const betaHeaderKey = Object.keys(headers).find((name) => name.toLowerCase() === "anthropic-beta");
|
|
23
|
+
if (!betaHeaderKey) return;
|
|
24
|
+
const retained = headers[betaHeaderKey]!
|
|
25
|
+
.split(",")
|
|
26
|
+
.map((value) => value.trim())
|
|
27
|
+
.filter((value) => value && value !== feature);
|
|
28
|
+
if (retained.length > 0) headers[betaHeaderKey] = retained.join(",");
|
|
29
|
+
else delete headers[betaHeaderKey];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cloneHeadersForCompat(
|
|
33
|
+
headers: Record<string, string>,
|
|
34
|
+
api: ApiKind,
|
|
35
|
+
compat?: CompatSettings,
|
|
36
|
+
): Record<string, string> {
|
|
37
|
+
const cloned = cloneStringRecord(headers);
|
|
38
|
+
if (api === "anthropic-messages" && compat?.forceAdaptiveThinking === true) {
|
|
39
|
+
removeAnthropicBetaFeature(cloned, ANTHROPIC_INTERLEAVED_THINKING_BETA);
|
|
40
|
+
}
|
|
41
|
+
return cloned;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function deleteHeaderCaseInsensitive(headers: Record<string, string>, name: string): void {
|
|
45
|
+
const normalized = name.toLowerCase();
|
|
46
|
+
for (const existingName of Object.keys(headers)) {
|
|
47
|
+
if (existingName.toLowerCase() === normalized) delete headers[existingName];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 模型原生 headers 先保留,插件 profile 对同名字段拥有最终覆盖权。 */
|
|
52
|
+
export function mergeModelRequestHeaders(
|
|
53
|
+
nativeHeaders: Record<string, string> | undefined,
|
|
54
|
+
profileHeaders: Record<string, string> | undefined,
|
|
55
|
+
): Record<string, string> | undefined {
|
|
56
|
+
const merged = cloneStringRecord(nativeHeaders);
|
|
57
|
+
for (const [name, value] of Object.entries(profileHeaders ?? {})) {
|
|
58
|
+
deleteHeaderCaseInsensitive(merged, name);
|
|
59
|
+
merged[name] = value;
|
|
60
|
+
}
|
|
61
|
+
return hasStringRecordEntries(merged) ? merged : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 读取 models.json 时剥离当前 profile 管理的字段,避免把生成值误当作用户原生 headers。 */
|
|
65
|
+
export function stripManagedClientHeaders(
|
|
66
|
+
storedHeaders: Record<string, string> | undefined,
|
|
67
|
+
profileHeaders: Record<string, string> | undefined,
|
|
68
|
+
): Record<string, string> | undefined {
|
|
69
|
+
const nativeHeaders = cloneStringRecord(storedHeaders);
|
|
70
|
+
for (const name of Object.keys(profileHeaders ?? {})) deleteHeaderCaseInsensitive(nativeHeaders, name);
|
|
71
|
+
return hasStringRecordEntries(nativeHeaders) ? nativeHeaders : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getRecommendedClientHeaderProfile(api: ApiKind): ClientHeaderProfileId {
|
|
75
|
+
if (api === "anthropic-messages") return "claude-code";
|
|
76
|
+
if (api === "openai-completions" || api === "openai-responses") return "codex-cli";
|
|
77
|
+
return "disabled";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function resolveClientHeaderProfile(profile: ClientHeaderProfileId, api: ApiKind): ClientHeaderProfileId {
|
|
81
|
+
return profile === "recommended" ? getRecommendedClientHeaderProfile(api) : profile;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getClientHeadersForProfile(
|
|
85
|
+
profile: ClientHeaderProfileId,
|
|
86
|
+
api: ApiKind,
|
|
87
|
+
customHeaders: Record<string, string>,
|
|
88
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>> = {},
|
|
89
|
+
compat?: CompatSettings,
|
|
90
|
+
): Record<string, string> | undefined {
|
|
91
|
+
const resolved = resolveClientHeaderProfile(profile, api);
|
|
92
|
+
if (resolved === "disabled") return undefined;
|
|
93
|
+
if (resolved === "claude-code") {
|
|
94
|
+
const headers = hasStringRecordEntries(clientHeaderCaptures[resolved]?.headers)
|
|
95
|
+
? clientHeaderCaptures[resolved]!.headers
|
|
96
|
+
: CLAUDE_CODE_CLIENT_HEADERS;
|
|
97
|
+
return cloneHeadersForCompat(headers, api, compat);
|
|
98
|
+
}
|
|
99
|
+
if (resolved === "codex-cli") {
|
|
100
|
+
const headers = hasStringRecordEntries(clientHeaderCaptures[resolved]?.headers)
|
|
101
|
+
? clientHeaderCaptures[resolved]!.headers
|
|
102
|
+
: CODEX_CLI_CLIENT_HEADERS;
|
|
103
|
+
return cloneStringRecord(headers);
|
|
104
|
+
}
|
|
105
|
+
return hasStringRecordEntries(customHeaders) ? cloneHeadersForCompat(customHeaders, api, compat) : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function normalizeClientHeaderProfile(value: unknown): ClientHeaderProfileId | undefined {
|
|
109
|
+
if (value === "recommended" || value === "protocol-default") return "recommended";
|
|
110
|
+
if (value === "disabled" || value === "claude-code" || value === "codex-cli" || value === "custom") return value;
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function inferClientHeaderProfile(stored: unknown, modelHeaders: Record<string, string> | undefined): ClientHeaderProfileId {
|
|
115
|
+
const normalized = normalizeClientHeaderProfile(stored);
|
|
116
|
+
if (normalized) return normalized;
|
|
117
|
+
return hasStringRecordEntries(modelHeaders) ? "custom" : "recommended";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getClientHeaderProfileDisplay(profile: ClientHeaderProfileId, api: ApiKind): string {
|
|
121
|
+
const resolved = resolveClientHeaderProfile(profile, api);
|
|
122
|
+
if (profile === "recommended") {
|
|
123
|
+
return `${CLIENT_HEADER_PROFILE_LABELS.recommended} → ${CLIENT_HEADER_PROFILE_LABELS[resolved]}`;
|
|
124
|
+
}
|
|
125
|
+
return CLIENT_HEADER_PROFILE_LABELS[profile];
|
|
126
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// 4 种 API 协议的默认预设。新建接入/模型时用,便于一键填好常用字段。
|
|
2
|
+
|
|
3
|
+
import type { ApiKind, ModelInputKind } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
export interface ProviderPreset {
|
|
6
|
+
api: ApiKind;
|
|
7
|
+
label: string;
|
|
8
|
+
shortLabel: string;
|
|
9
|
+
defaultProviderName: string;
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
apiKey: string;
|
|
12
|
+
authHeader: boolean;
|
|
13
|
+
contextWindow: number;
|
|
14
|
+
maxTokens: number;
|
|
15
|
+
inputKinds: ModelInputKind[];
|
|
16
|
+
defaultReasoning: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|
20
|
+
{
|
|
21
|
+
api: "openai-responses",
|
|
22
|
+
label: "OpenAI Responses — 标准 instructions / input",
|
|
23
|
+
shortLabel: "OpenAI Responses",
|
|
24
|
+
defaultProviderName: "openai-responses",
|
|
25
|
+
baseUrl: "https://api.openai.com/v1",
|
|
26
|
+
apiKey: "$OPENAI_API_KEY",
|
|
27
|
+
authHeader: false,
|
|
28
|
+
contextWindow: 200000,
|
|
29
|
+
maxTokens: 64000,
|
|
30
|
+
inputKinds: ["text", "image"],
|
|
31
|
+
defaultReasoning: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
api: "openai-completions",
|
|
35
|
+
label: "OpenAI Chat Completions — 通用 OpenAI 兼容",
|
|
36
|
+
shortLabel: "OpenAI Chat",
|
|
37
|
+
defaultProviderName: "openai-chat",
|
|
38
|
+
baseUrl: "https://api.openai.com/v1",
|
|
39
|
+
apiKey: "$OPENAI_API_KEY",
|
|
40
|
+
authHeader: false,
|
|
41
|
+
contextWindow: 200000,
|
|
42
|
+
maxTokens: 64000,
|
|
43
|
+
inputKinds: ["text", "image"],
|
|
44
|
+
defaultReasoning: true,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
api: "anthropic-messages",
|
|
48
|
+
label: "Anthropic Messages — Claude thinking",
|
|
49
|
+
shortLabel: "Anthropic Messages",
|
|
50
|
+
defaultProviderName: "anthropic",
|
|
51
|
+
baseUrl: "https://api.anthropic.com",
|
|
52
|
+
apiKey: "$ANTHROPIC_API_KEY",
|
|
53
|
+
authHeader: false,
|
|
54
|
+
contextWindow: 200000,
|
|
55
|
+
maxTokens: 64000,
|
|
56
|
+
inputKinds: ["text", "image"],
|
|
57
|
+
defaultReasoning: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
api: "google-generative-ai",
|
|
61
|
+
label: "Google Generative AI — Gemini thinking",
|
|
62
|
+
shortLabel: "Google Generative AI",
|
|
63
|
+
defaultProviderName: "google",
|
|
64
|
+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
65
|
+
apiKey: "$GEMINI_API_KEY",
|
|
66
|
+
authHeader: false,
|
|
67
|
+
contextWindow: 1048576,
|
|
68
|
+
maxTokens: 65536,
|
|
69
|
+
inputKinds: ["text", "image"],
|
|
70
|
+
defaultReasoning: true,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
export function findPresetForApi(api: ApiKind | undefined): ProviderPreset {
|
|
75
|
+
return PROVIDER_PRESETS.find((preset) => preset.api === api) ?? PROVIDER_PRESETS[0]!;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getProtocolDisplayName(api: ApiKind): string {
|
|
79
|
+
return findPresetForApi(api).shortLabel;
|
|
80
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// thinkingLevelMap 的协议默认值与持久化归一化。
|
|
2
|
+
|
|
3
|
+
import type { ApiKind, CompatSettings, ThinkingLevelMap } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
const EXTENDED_THINKING_LEVEL_MAP: ThinkingLevelMap = {
|
|
6
|
+
xhigh: "xhigh",
|
|
7
|
+
max: "max",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const OPENAI_THINKING_LEVEL_MAP: ThinkingLevelMap = {
|
|
11
|
+
minimal: null,
|
|
12
|
+
low: "low",
|
|
13
|
+
medium: "medium",
|
|
14
|
+
high: "high",
|
|
15
|
+
xhigh: "xhigh",
|
|
16
|
+
max: "max",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// [喵喵喵]: Gemini 原生 ThinkingLevel 只有 MINIMAL/LOW/MEDIUM/HIGH;pi 的 xhigh/max 必须显式禁用。(2026-07-10)
|
|
20
|
+
const GOOGLE_GENERATIVE_AI_THINKING_LEVEL_MAP: ThinkingLevelMap = {
|
|
21
|
+
minimal: "minimal",
|
|
22
|
+
low: "low",
|
|
23
|
+
medium: "medium",
|
|
24
|
+
high: "high",
|
|
25
|
+
xhigh: null,
|
|
26
|
+
max: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function isLegacyShiftedMaxLadder(map: ThinkingLevelMap): boolean {
|
|
30
|
+
return map.minimal === "low"
|
|
31
|
+
&& map.low === "medium"
|
|
32
|
+
&& map.medium === "high"
|
|
33
|
+
&& map.high === "xhigh"
|
|
34
|
+
&& map.xhigh === "max"
|
|
35
|
+
&& map.max === undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildThinkingLevelMap(api: ApiKind, reasoning: boolean): ThinkingLevelMap | undefined {
|
|
39
|
+
if (!reasoning) return undefined;
|
|
40
|
+
if (api === "google-generative-ai") return { ...GOOGLE_GENERATIVE_AI_THINKING_LEVEL_MAP };
|
|
41
|
+
if (api === "openai-completions" || api === "openai-responses") return { ...OPENAI_THINKING_LEVEL_MAP };
|
|
42
|
+
return { ...EXTENDED_THINKING_LEVEL_MAP };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function applyProtocolThinkingLevelLimits(
|
|
46
|
+
api: ApiKind,
|
|
47
|
+
thinkingLevelMap: ThinkingLevelMap | undefined,
|
|
48
|
+
): ThinkingLevelMap | undefined {
|
|
49
|
+
if (api === "google-generative-ai") {
|
|
50
|
+
return { ...(thinkingLevelMap ?? {}), ...GOOGLE_GENERATIVE_AI_THINKING_LEVEL_MAP };
|
|
51
|
+
}
|
|
52
|
+
if (!thinkingLevelMap) return undefined;
|
|
53
|
+
return { ...thinkingLevelMap, ...EXTENDED_THINKING_LEVEL_MAP };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function mergeThinkingLevelMap(
|
|
57
|
+
defaultMap: ThinkingLevelMap | undefined,
|
|
58
|
+
storedMap: ThinkingLevelMap | undefined,
|
|
59
|
+
): ThinkingLevelMap | undefined {
|
|
60
|
+
const merged = { ...(defaultMap ?? {}), ...(storedMap ?? {}) };
|
|
61
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeThinkingLevelMap(
|
|
65
|
+
api: ApiKind,
|
|
66
|
+
reasoning: boolean,
|
|
67
|
+
storedMap: ThinkingLevelMap | undefined,
|
|
68
|
+
): ThinkingLevelMap | undefined {
|
|
69
|
+
if (!reasoning) return undefined;
|
|
70
|
+
const defaultMap = buildThinkingLevelMap(api, true);
|
|
71
|
+
if (storedMap && isLegacyShiftedMaxLadder(storedMap)) return defaultMap;
|
|
72
|
+
return applyProtocolThinkingLevelLimits(api, mergeThinkingLevelMap(defaultMap, storedMap));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function mergeCompatSettings(
|
|
76
|
+
providerCompat: CompatSettings | undefined,
|
|
77
|
+
modelCompat: CompatSettings | undefined,
|
|
78
|
+
): CompatSettings | undefined {
|
|
79
|
+
if (providerCompat && modelCompat) return { ...providerCompat, ...modelCompat };
|
|
80
|
+
return modelCompat ?? providerCompat;
|
|
81
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// provider-registrar.ts
|
|
2
|
+
//
|
|
3
|
+
// StoredProvider → pi 的 ProviderConfig → pi.registerProvider 的统一桥梁。
|
|
4
|
+
//
|
|
5
|
+
// 设计要点:
|
|
6
|
+
// - factory 阶段只注册无需本地 server 的 catalog 配置;session_start 再激活代理 transport。
|
|
7
|
+
// - reconcileProvider 是保存后的完整 runtime 同步入口,统一处理 register/unregister 与回滚。
|
|
8
|
+
// - getClientHeadersForProfile 在这里调,把客户端请求头 profile 翻译成模型级 headers。
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { isBuiltinProviderId } from "./builtin-model-catalog.ts";
|
|
12
|
+
import { mergeCompatSettings } from "./compat-settings.ts";
|
|
13
|
+
import { formatUnknownError } from "./common.ts";
|
|
14
|
+
import {
|
|
15
|
+
getLocalProxyBaseUrl,
|
|
16
|
+
getProviderHttpProxyUrl,
|
|
17
|
+
isProviderHttpProxyEnabled,
|
|
18
|
+
removeProviderLocalProxyRoutes,
|
|
19
|
+
restoreProviderLocalProxyRoutes,
|
|
20
|
+
snapshotProviderLocalProxyRoutes,
|
|
21
|
+
type ProviderProxyRoute,
|
|
22
|
+
} from "./local-proxy-service.ts";
|
|
23
|
+
import { getClientHeadersForProfile, mergeModelRequestHeaders } from "./presets/client-headers.ts";
|
|
24
|
+
import { resolveRuntimeBaseUrl } from "./runtime-base-url.ts";
|
|
25
|
+
import type { ApiKind, BuiltInClientHeaderProfileId, StateDocument, StoredClientHeaderCapture, StoredModel, StoredProvider, StoredRequestHeaderProfile } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
// pi 的 ProviderConfig 类型从 d.ts 拿;这里用结构兼容 + as any 避免拉太多内部类型。
|
|
28
|
+
// 关键字段:name / baseUrl / apiKey / api / authHeader / models
|
|
29
|
+
type ProviderConfig = Parameters<ExtensionAPI["registerProvider"]>[1];
|
|
30
|
+
type ProviderModelConfig = NonNullable<ProviderConfig["models"]>[number];
|
|
31
|
+
|
|
32
|
+
const REGISTERED_PROVIDER_CONFIGS = new Map<string, ProviderConfig>();
|
|
33
|
+
|
|
34
|
+
export function buildProviderConfig(
|
|
35
|
+
provider: StoredProvider,
|
|
36
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile> = {},
|
|
37
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>> = {},
|
|
38
|
+
): ProviderConfig {
|
|
39
|
+
const runtimeBaseUrl = resolveRuntimeBaseUrl(provider.api, provider.baseUrl);
|
|
40
|
+
return buildProviderConfigWithBaseUrl(provider, runtimeBaseUrl, requestHeaderProfiles, clientHeaderCaptures);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildProviderConfigWithBaseUrl(
|
|
44
|
+
provider: StoredProvider,
|
|
45
|
+
runtimeBaseUrl: string,
|
|
46
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
47
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>>,
|
|
48
|
+
modelRuntimeBaseUrls: ReadonlyMap<string, string> = new Map(),
|
|
49
|
+
): ProviderConfig {
|
|
50
|
+
const apiKey = provider.apiKey?.trim();
|
|
51
|
+
return {
|
|
52
|
+
name: provider.name,
|
|
53
|
+
baseUrl: runtimeBaseUrl,
|
|
54
|
+
...(apiKey ? { apiKey } : {}),
|
|
55
|
+
api: provider.api,
|
|
56
|
+
headers: provider.headers,
|
|
57
|
+
authHeader: provider.authHeader,
|
|
58
|
+
models: provider.models.map((model) => buildModelConfig(
|
|
59
|
+
provider,
|
|
60
|
+
model,
|
|
61
|
+
requestHeaderProfiles,
|
|
62
|
+
clientHeaderCaptures,
|
|
63
|
+
modelRuntimeBaseUrls.get(model.id),
|
|
64
|
+
)),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function buildRuntimeProviderConfig(
|
|
69
|
+
providerId: string,
|
|
70
|
+
provider: StoredProvider,
|
|
71
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile> = {},
|
|
72
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>> = {},
|
|
73
|
+
): Promise<ProviderConfig> {
|
|
74
|
+
const upstreamRuntimeBaseUrl = resolveRuntimeBaseUrl(provider.api, provider.baseUrl);
|
|
75
|
+
removeProviderLocalProxyRoutes(providerId);
|
|
76
|
+
if (!isProviderHttpProxyEnabled(provider)) {
|
|
77
|
+
return buildProviderConfigWithBaseUrl(provider, upstreamRuntimeBaseUrl, requestHeaderProfiles, clientHeaderCaptures);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const proxyUrl = getProviderHttpProxyUrl(provider);
|
|
81
|
+
const runtimeBaseUrl = await getLocalProxyBaseUrl(providerId, upstreamRuntimeBaseUrl, proxyUrl);
|
|
82
|
+
const modelRuntimeBaseUrls = new Map<string, string>();
|
|
83
|
+
for (const model of provider.models) {
|
|
84
|
+
const upstreamModelBaseUrl = resolveModelRuntimeBaseUrl(provider, model);
|
|
85
|
+
if (!upstreamModelBaseUrl) continue;
|
|
86
|
+
const routeId = `${providerId}/model/${model.id}`;
|
|
87
|
+
modelRuntimeBaseUrls.set(model.id, await getLocalProxyBaseUrl(routeId, upstreamModelBaseUrl, proxyUrl));
|
|
88
|
+
}
|
|
89
|
+
return buildProviderConfigWithBaseUrl(
|
|
90
|
+
provider,
|
|
91
|
+
runtimeBaseUrl,
|
|
92
|
+
requestHeaderProfiles,
|
|
93
|
+
clientHeaderCaptures,
|
|
94
|
+
modelRuntimeBaseUrls,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function asManagedApi(value: string | undefined, fallback: ApiKind): ApiKind {
|
|
99
|
+
if (value === "openai-completions" || value === "openai-responses"
|
|
100
|
+
|| value === "anthropic-messages" || value === "google-generative-ai") return value;
|
|
101
|
+
return fallback;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveModelRuntimeBaseUrl(provider: StoredProvider, model: StoredModel): string | undefined {
|
|
105
|
+
if (!model.baseUrl) return undefined;
|
|
106
|
+
return resolveRuntimeBaseUrl(asManagedApi(model.api, provider.api), model.baseUrl);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function buildModelRequestHeaders(
|
|
110
|
+
provider: StoredProvider,
|
|
111
|
+
model: StoredModel,
|
|
112
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
113
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>>,
|
|
114
|
+
): Record<string, string> | undefined {
|
|
115
|
+
const customHeaders = provider.clientHeaderProfile === "custom"
|
|
116
|
+
? resolveProviderCustomHeaders(provider, requestHeaderProfiles)
|
|
117
|
+
: {};
|
|
118
|
+
const effectiveApi = asManagedApi(model.api, provider.api);
|
|
119
|
+
const effectiveCompat = mergeCompatSettings(provider.compat, model.compat);
|
|
120
|
+
const profileHeaders = getClientHeadersForProfile(
|
|
121
|
+
provider.clientHeaderProfile,
|
|
122
|
+
effectiveApi,
|
|
123
|
+
customHeaders,
|
|
124
|
+
clientHeaderCaptures,
|
|
125
|
+
effectiveCompat,
|
|
126
|
+
);
|
|
127
|
+
return mergeModelRequestHeaders(model.headers, profileHeaders);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildModelConfig(
|
|
131
|
+
provider: StoredProvider,
|
|
132
|
+
model: StoredModel,
|
|
133
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
134
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>>,
|
|
135
|
+
runtimeBaseUrl?: string,
|
|
136
|
+
): ProviderModelConfig {
|
|
137
|
+
return {
|
|
138
|
+
id: model.id,
|
|
139
|
+
name: model.name ?? model.id,
|
|
140
|
+
api: model.api as ProviderModelConfig["api"],
|
|
141
|
+
baseUrl: runtimeBaseUrl ?? resolveModelRuntimeBaseUrl(provider, model),
|
|
142
|
+
reasoning: model.reasoning,
|
|
143
|
+
thinkingLevelMap: model.thinkingLevelMap,
|
|
144
|
+
input: model.input,
|
|
145
|
+
cost: model.cost,
|
|
146
|
+
contextWindow: model.contextWindow,
|
|
147
|
+
maxTokens: model.maxTokens,
|
|
148
|
+
headers: buildModelRequestHeaders(provider, model, requestHeaderProfiles, clientHeaderCaptures),
|
|
149
|
+
compat: mergeCompatSettings(provider.compat, model.compat) as ProviderModelConfig["compat"],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveProviderCustomHeaders(
|
|
154
|
+
provider: StoredProvider,
|
|
155
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
156
|
+
): Record<string, string> {
|
|
157
|
+
const profileId = provider.requestHeaderProfileId;
|
|
158
|
+
if (profileId && requestHeaderProfiles[profileId]) return requestHeaderProfiles[profileId].headers;
|
|
159
|
+
return provider.customClientHeaders ?? {};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 注销本扩展实际注册的动态 provider,并同步清理代理路由和回滚快照。 */
|
|
163
|
+
export function unregisterManagedProvider(pi: ExtensionAPI, providerId: string): void {
|
|
164
|
+
removeProviderLocalProxyRoutes(providerId);
|
|
165
|
+
const wasRegistered = REGISTERED_PROVIDER_CONFIGS.delete(providerId);
|
|
166
|
+
if (wasRegistered) pi.unregisterProvider(providerId);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function canRegisterManagedProvider(providerId: string, provider: StoredProvider): Promise<boolean> {
|
|
170
|
+
return provider.models.length > 0 && !(await isBuiltinProviderId(providerId));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function replaceManagedProviderConfig(
|
|
174
|
+
pi: ExtensionAPI,
|
|
175
|
+
providerId: string,
|
|
176
|
+
nextConfig: ProviderConfig,
|
|
177
|
+
previousRoutes: ReadonlyMap<string, ProviderProxyRoute>,
|
|
178
|
+
): void {
|
|
179
|
+
const previousConfig = REGISTERED_PROVIDER_CONFIGS.get(providerId);
|
|
180
|
+
try {
|
|
181
|
+
if (previousConfig) pi.unregisterProvider(providerId);
|
|
182
|
+
pi.registerProvider(providerId, nextConfig);
|
|
183
|
+
REGISTERED_PROVIDER_CONFIGS.set(providerId, nextConfig);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
restoreProviderLocalProxyRoutes(providerId, previousRoutes);
|
|
186
|
+
let rollbackError: unknown;
|
|
187
|
+
try {
|
|
188
|
+
if (previousConfig) {
|
|
189
|
+
pi.unregisterProvider(providerId);
|
|
190
|
+
pi.registerProvider(providerId, previousConfig);
|
|
191
|
+
REGISTERED_PROVIDER_CONFIGS.set(providerId, previousConfig);
|
|
192
|
+
} else {
|
|
193
|
+
REGISTERED_PROVIDER_CONFIGS.delete(providerId);
|
|
194
|
+
}
|
|
195
|
+
} catch (restoreError) {
|
|
196
|
+
rollbackError = restoreError;
|
|
197
|
+
}
|
|
198
|
+
const rollbackNote = rollbackError
|
|
199
|
+
? `;恢复上一版 runtime 也失败:${formatUnknownError(rollbackError)}`
|
|
200
|
+
: previousConfig
|
|
201
|
+
? ";已恢复上一版 runtime"
|
|
202
|
+
: ";新配置未注册到当前 runtime";
|
|
203
|
+
throw new Error(`${formatUnknownError(error)}${rollbackNote}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 同步一个 provider 到当前会话 transport。新配置会先完整构建;替换失败时恢复上一版动态配置。 */
|
|
208
|
+
export async function reconcileProvider(
|
|
209
|
+
pi: ExtensionAPI,
|
|
210
|
+
providerId: string,
|
|
211
|
+
provider: StoredProvider,
|
|
212
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile> = {},
|
|
213
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>> = {},
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
// registerProvider 的 models 会整体替换目录;内置 provider 必须保留 Pi 已合成的完整目录。
|
|
216
|
+
if (!(await canRegisterManagedProvider(providerId, provider))) {
|
|
217
|
+
unregisterManagedProvider(pi, providerId);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const previousRoutes = snapshotProviderLocalProxyRoutes(providerId);
|
|
222
|
+
let nextConfig: ProviderConfig;
|
|
223
|
+
try {
|
|
224
|
+
nextConfig = await buildRuntimeProviderConfig(providerId, provider, requestHeaderProfiles, clientHeaderCaptures);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
restoreProviderLocalProxyRoutes(providerId, previousRoutes);
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
replaceManagedProviderConfig(pi, providerId, nextConfig, previousRoutes);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function reconcileProviderCatalog(
|
|
233
|
+
pi: ExtensionAPI,
|
|
234
|
+
providerId: string,
|
|
235
|
+
provider: StoredProvider,
|
|
236
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
237
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>>,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
if (!(await canRegisterManagedProvider(providerId, provider))) {
|
|
240
|
+
unregisterManagedProvider(pi, providerId);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const previousRoutes = snapshotProviderLocalProxyRoutes(providerId);
|
|
244
|
+
removeProviderLocalProxyRoutes(providerId);
|
|
245
|
+
const nextConfig = buildProviderConfig(provider, requestHeaderProfiles, clientHeaderCaptures);
|
|
246
|
+
replaceManagedProviderConfig(pi, providerId, nextConfig, previousRoutes);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
type StateProviderReconciler = (
|
|
250
|
+
pi: ExtensionAPI,
|
|
251
|
+
providerId: string,
|
|
252
|
+
provider: StoredProvider,
|
|
253
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
254
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>>,
|
|
255
|
+
) => Promise<void>;
|
|
256
|
+
|
|
257
|
+
async function reconcileAllFromState(
|
|
258
|
+
pi: ExtensionAPI,
|
|
259
|
+
document: StateDocument,
|
|
260
|
+
reconcile: StateProviderReconciler,
|
|
261
|
+
): Promise<string[]> {
|
|
262
|
+
const warnings: string[] = [];
|
|
263
|
+
for (const [providerId, provider] of Object.entries(document.providers)) {
|
|
264
|
+
try {
|
|
265
|
+
await reconcile(pi, providerId, provider, document.requestHeaderProfiles, document.clientHeaderCaptures);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
warnings.push(`${providerId}: ${formatUnknownError(error)}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return warnings;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** factory 阶段仅注册模型 catalog,不启动长生命周期本地代理。 */
|
|
274
|
+
export async function registerCatalogFromState(pi: ExtensionAPI, document: StateDocument): Promise<string[]> {
|
|
275
|
+
return reconcileAllFromState(pi, document, reconcileProviderCatalog);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** session_start 激活当前 state 的完整 provider transport。 */
|
|
279
|
+
export async function registerAllFromState(pi: ExtensionAPI, document: StateDocument): Promise<string[]> {
|
|
280
|
+
return reconcileAllFromState(pi, document, reconcileProvider);
|
|
281
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// request-pipeline.ts
|
|
2
|
+
//
|
|
3
|
+
// 统一协调 before_provider_request payload transform。每个请求内共享一次
|
|
4
|
+
// StateDocument;跨请求由 state-cache.ts 按配置文件签名复用状态。
|
|
5
|
+
|
|
6
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { injectClaudeCodeMetadata } from "./claude-code-compat.ts";
|
|
8
|
+
import { formatUnknownError } from "./common.ts";
|
|
9
|
+
import { normalizeManagedOpenAIResponsesPayload } from "./openai-responses-payload.ts";
|
|
10
|
+
import { injectOpenAIServiceTier } from "./openai-service-tier.ts";
|
|
11
|
+
import { readCachedState } from "./state-cache.ts";
|
|
12
|
+
import type { StateDocument } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
type StateLoader = () => Promise<StateDocument>;
|
|
15
|
+
|
|
16
|
+
type RequestTransform = {
|
|
17
|
+
id: string;
|
|
18
|
+
warning: string;
|
|
19
|
+
run(payload: unknown, ctx: ExtensionContext, loadState: StateLoader): Promise<unknown | undefined> | unknown | undefined;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function createRequestStateLoader(): StateLoader {
|
|
23
|
+
let statePromise: Promise<StateDocument> | undefined;
|
|
24
|
+
return () => {
|
|
25
|
+
statePromise ??= readCachedState();
|
|
26
|
+
return statePromise;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const REQUEST_TRANSFORMS: RequestTransform[] = [
|
|
31
|
+
{
|
|
32
|
+
id: "claude-code-metadata",
|
|
33
|
+
warning: "ClaudeCode metadata 注入失败,本次请求仅使用请求头",
|
|
34
|
+
async run(payload, ctx, loadState) {
|
|
35
|
+
if (!ctx.model || ctx.model.api !== "anthropic-messages") return undefined;
|
|
36
|
+
return injectClaudeCodeMetadata(payload, ctx.model, await loadState());
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "openai-responses-instructions",
|
|
41
|
+
warning: "OpenAI Responses instructions 标准化失败,本次请求使用原始 payload",
|
|
42
|
+
async run(payload, ctx, loadState) {
|
|
43
|
+
if (!ctx.model || ctx.model.api !== "openai-responses") return undefined;
|
|
44
|
+
return normalizeManagedOpenAIResponsesPayload(payload, ctx.model, await loadState());
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "openai-service-tier",
|
|
49
|
+
warning: "Fast mode 状态读取失败,本次请求未注入 service_tier",
|
|
50
|
+
async run(payload, ctx, loadState) {
|
|
51
|
+
if (!ctx.model || ctx.model.api !== "openai-responses") return undefined;
|
|
52
|
+
return injectOpenAIServiceTier(payload, ctx.model, await loadState());
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export interface RequestPipeline {
|
|
58
|
+
transform(payload: unknown, ctx: ExtensionContext): Promise<unknown | undefined>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createRequestPipeline(): RequestPipeline {
|
|
62
|
+
const notifiedTransformErrors = new Set<string>();
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
async transform(initialPayload, ctx) {
|
|
66
|
+
let payload = initialPayload;
|
|
67
|
+
let changed = false;
|
|
68
|
+
const loadState = createRequestStateLoader();
|
|
69
|
+
|
|
70
|
+
for (const transform of REQUEST_TRANSFORMS) {
|
|
71
|
+
try {
|
|
72
|
+
const nextPayload = await transform.run(payload, ctx, loadState);
|
|
73
|
+
if (nextPayload !== undefined) {
|
|
74
|
+
payload = nextPayload;
|
|
75
|
+
changed = true;
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!notifiedTransformErrors.has(transform.id) && ctx.hasUI) {
|
|
79
|
+
notifiedTransformErrors.add(transform.id);
|
|
80
|
+
ctx.ui.notify(`[pi-model-manager] ${transform.warning}:${formatUnknownError(error)}`, "warning");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return changed ? payload : undefined;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|