cabletidy 0.2.0 → 0.3.0-rc.3

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.
@@ -1,214 +0,0 @@
1
- import {
2
- buildCodexArtifacts,
3
- prepareCodexArtifacts,
4
- publicArtifacts as publicCodexArtifacts,
5
- } from "./codex-native-provider.mjs";
6
- import { clientModelIdForProfile, resolveModelProfile } from "./model-resolver.mjs";
7
- import { normalizeConfig } from "./config.mjs";
8
- import { configurationId, providerIdForConfiguration, configurationBaseUrl } from "../web/config-identity.js";
9
- import { prepareClaudeSettings } from "./claude-config-file.mjs";
10
- import { CLAUDE_MODEL_ALIASES } from "../web/claude-models.js";
11
-
12
- export const CLAUDE_TARGET_FORMAT = "claude.settings.json.v1";
13
- export const CLAUDE_ENDPOINTS = [
14
- { method: "POST", path: "/v1/messages", purpose: "messages", required: true },
15
- { method: "POST", path: "/v1/messages/count_tokens", purpose: "token_counting", required: false },
16
- { method: "GET", path: "/v1/models", purpose: "model_discovery", required: false },
17
- { method: "HEAD", path: "/api/hello", purpose: "connection_probe", required: false },
18
- ];
19
-
20
- export async function prepareTargetArtifacts(config, options = {}, secrets = {}) {
21
- const requested = findBinding(config, options.bindingId);
22
- config = normalizeConfig(config);
23
- const binding = requested && findBinding(config, configurationId(requested.name, requested.id));
24
- if (!binding) throw new Error("找不到 Target binding");
25
- if (binding.target === "codex") {
26
- return prepareCodexArtifacts(config, { ...options, bindingId: binding.id }, secrets);
27
- }
28
- if (binding.target === "claude-code") {
29
- return prepareClaudeSettings(buildClaudeArtifacts(config, binding), options);
30
- }
31
- return buildTargetArtifacts(config, { ...options, bindingId: binding.id }, secrets);
32
- }
33
-
34
- export function buildTargetArtifacts(config, options = {}, secrets = {}) {
35
- const requested = findBinding(config, options.bindingId);
36
- config = normalizeConfig(config);
37
- const binding = requested && findBinding(config, configurationId(requested.name, requested.id));
38
- if (!binding) throw new Error("找不到 Target binding");
39
-
40
- if (binding.target === "codex") {
41
- return buildCodexArtifacts(
42
- config,
43
- { bindingId: binding.id },
44
- secrets,
45
- );
46
- }
47
- if (binding.target === "claude-code") {
48
- return buildClaudeArtifacts(config, binding);
49
- }
50
- if (binding.target === "generic-env") {
51
- return buildGenericEnvArtifacts(config, binding);
52
- }
53
- throw new Error(`不支持的 target: ${binding.target}`);
54
- }
55
-
56
- export function publicTargetArtifacts(artifacts) {
57
- if (artifacts?.target === "codex") return publicCodexArtifacts(artifacts);
58
- return {
59
- ...artifacts,
60
- environment: publicEnvironment(artifacts.environment),
61
- };
62
- }
63
-
64
- function findBinding(config, bindingId) {
65
- const entry = bindingId && Object.hasOwn(config.bindings || {}, bindingId)
66
- ? [bindingId, config.bindings[bindingId]]
67
- : Object.entries(config.bindings || {}).find(
68
- ([id, item]) => item?.enabled !== false && (!bindingId || id === bindingId || item?.id === bindingId),
69
- );
70
- if (!entry?.[1]) return null;
71
- const [id, binding] = entry;
72
- const nameId = configurationId(binding.name, id);
73
- if (Object.entries(config.bindings).filter(([id, item]) =>
74
- configurationId(item?.name, id) === nameId).length > 1) {
75
- throw new Error(`配置名称规范化后重复: ${nameId}`);
76
- }
77
- return { ...binding, id };
78
- }
79
-
80
- function buildClaudeArtifacts(config, binding) {
81
- const virtualProvider = getVirtualProvider(config, binding);
82
- if (virtualProvider.ingressProtocol !== "anthropic.messages") {
83
- throw new Error("Claude Code binding 必须连接 anthropic.messages Virtual Provider");
84
- }
85
- const { profile, clientModelId: clientModel } = defaultProfile(
86
- config,
87
- binding,
88
- virtualProvider,
89
- );
90
- const baseUrl = configurationBaseUrl(config, binding.id);
91
- const vars = {
92
- ANTHROPIC_BASE_URL: baseUrl,
93
- // Satisfy Claude Code's client-side credential check; the local service
94
- // ignores this public placeholder and authenticates only to the upstream.
95
- ANTHROPIC_AUTH_TOKEN: "cabletidy-local",
96
- ANTHROPIC_API_KEY: "",
97
- CLAUDE_CODE_OAUTH_TOKEN: "",
98
- ANTHROPIC_CUSTOM_HEADERS: "",
99
- CLAUDE_CODE_USE_BEDROCK: "0",
100
- CLAUDE_CODE_USE_VERTEX: "0",
101
- CLAUDE_CODE_USE_FOUNDRY: "0",
102
- CLAUDE_CODE_USE_ANTHROPIC_AWS: "0",
103
- CLAUDE_CODE_USE_MANTLE: "0",
104
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: binding.claude?.discoverModels ? "1" : "0",
105
- ...(binding.claude?.setModel === false || !clientModel ? {} : { ANTHROPIC_MODEL: clientModel }),
106
- };
107
- for (const family of ["opus", "sonnet", "fable", "haiku"]) {
108
- const model = binding.claude?.models?.[family];
109
- if (model) vars[`ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`] = model;
110
- }
111
- if (binding.claude?.models?.subagent) vars.CLAUDE_CODE_SUBAGENT_MODEL = binding.claude.models.subagent;
112
- const contents = `${JSON.stringify({ env: vars }, null, 2)}\n`;
113
-
114
- return {
115
- format: CLAUDE_TARGET_FORMAT,
116
- target: "claude-code",
117
- mode: "managed_proxy",
118
- bindingId: binding.id,
119
- virtualProviderId: virtualProvider.id,
120
- clientModelId: clientModel,
121
- files: [{ path: "cabletidy-claude.settings.json", kind: "json", contents }],
122
- requiredEndpoints: CLAUDE_ENDPOINTS,
123
- environment: {
124
- vars,
125
- shell: shellExports(vars),
126
- powershell: Object.entries(vars).map(([key, value]) => `$env:${key} = '${String(value).replaceAll("'", "''")}'`).join("\n"),
127
- },
128
- instructions: [
129
- "应用时只合并到用户 settings.json 的 env,保留其他设置;原值保存以便撤销接入。",
130
- "预览文件也可另存为独立文件,通过 claude --settings cabletidy-claude.settings.json 临时接入。",
131
- "本地入口无需 Key;cabletidy-local 仅满足客户端认证检查,真实凭据只由 CableTidy 发给上游。",
132
- `ANTHROPIC_BASE_URL 指向本地 Virtual Provider: ${baseUrl}`,
133
- clientModel ? `Claude 客户端模型名: ${clientModel}` : "模型由 Claude Code 选择,默认透传请求中的模型名。",
134
- ],
135
- };
136
- }
137
-
138
- function buildGenericEnvArtifacts(config, binding) {
139
- const virtualProvider = getVirtualProvider(config, binding);
140
- const { profile, clientModelId: clientModel } = defaultProfile(
141
- config,
142
- binding,
143
- virtualProvider,
144
- );
145
- const baseUrl = configurationBaseUrl(config, binding.id);
146
- const keyPrefix = binding.env?.prefix || "CABLETIDY";
147
- if (!/^[A-Z_][A-Z0-9_]*$/.test(keyPrefix)) {
148
- throw new Error(`generic env prefix 不合法: ${keyPrefix}`);
149
- }
150
- const vars = {
151
- [`${keyPrefix}_BASE_URL`]: baseUrl,
152
- ...(clientModel ? { [`${keyPrefix}_MODEL`]: clientModel } : {}),
153
- };
154
- return {
155
- format: "generic.env.v1",
156
- target: "generic-env",
157
- mode: "managed_proxy",
158
- bindingId: binding.id,
159
- virtualProviderId: virtualProvider.id,
160
- clientModelId: clientModel,
161
- files: [],
162
- environment: {
163
- vars,
164
- shell: shellExports(vars),
165
- },
166
- };
167
- }
168
-
169
- function getVirtualProvider(config, binding) {
170
- const virtualProvider = config.virtualProviders?.[binding.virtualProvider];
171
- if (!virtualProvider) {
172
- throw new Error("Binding 引用的 Virtual Provider 不存在");
173
- }
174
- if (virtualProvider.id !== providerIdForConfiguration(binding.id) ||
175
- Object.values(config.bindings).filter((item) => item?.virtualProvider === virtualProvider.id).length !== 1) {
176
- throw new Error("配置与 Virtual Provider 必须一对一,Virtual Provider ID 必须为 cabletidy_<配置ID>");
177
- }
178
- return virtualProvider;
179
- }
180
-
181
- function defaultProfile(config, binding, virtualProvider) {
182
- const requested = binding.defaultModel || virtualProvider.defaultModel;
183
- if (!requested) return { profileId: null, profile: null, clientModelId: null };
184
- // A Claude alias takes precedence over a same-named internal profile reference.
185
- if (binding.target === "claude-code" && CLAUDE_MODEL_ALIASES.defaultModel.includes(requested)) {
186
- return { profileId: null, profile: null, clientModelId: requested };
187
- }
188
- const resolved = resolveModelProfile(config, virtualProvider, requested);
189
- return {
190
- profileId: resolved.profileId,
191
- profile: resolved.profile,
192
- clientModelId: resolved.profile
193
- ? clientModelIdForProfile(resolved.profileId, resolved.profile)
194
- : resolved.clientModelId,
195
- };
196
- }
197
-
198
- function shellExports(vars) {
199
- return Object.entries(vars)
200
- .map(([key, value]) => `export ${key}=${quoteShell(value)}`)
201
- .join("\n");
202
- }
203
-
204
- function quoteShell(value) {
205
- return `'${String(value ?? "").replaceAll("'", "'\\''")}'`;
206
- }
207
-
208
- function publicEnvironment(environment = {}) {
209
- return {
210
- vars: environment.vars || {},
211
- shell: environment.shell || "",
212
- ...(environment.powershell ? { powershell: environment.powershell } : {}),
213
- };
214
- }
@@ -1,393 +0,0 @@
1
- import { normalizeConfig } from "./config.mjs";
2
- import { configurationId, providerIdForConfiguration } from "../web/config-identity.js";
3
-
4
- const ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
5
- const ENV_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
6
- const HEADER_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
7
- const PROTOCOLS = new Set([
8
- "openai.responses",
9
- "openai.chat_completions",
10
- "anthropic.messages",
11
- "gemini.generate_content",
12
- ]);
13
- const INTEGRATIONS = new Set(["codex-native-provider"]);
14
- const TARGET_FORMATS = new Set(["codex.config.toml.v1", "claude.env.v1", "claude.settings.json.v1", "generic.env.v1"]);
15
- const TARGETS = new Set(["codex", "claude-code", "generic-env"]);
16
- const COMPACT_STRATEGIES = new Set(["auto", "manual", "disabled"]);
17
- const IMPLEMENTED_INGRESS_PROTOCOLS = new Set([
18
- "openai.responses",
19
- "anthropic.messages",
20
- ]);
21
-
22
- function add(errors, path, message) {
23
- errors.push({ path, message });
24
- }
25
-
26
- function isRecord(value) {
27
- return value !== null && typeof value === "object" && !Array.isArray(value);
28
- }
29
-
30
- function hasId(value) {
31
- return typeof value === "string" && ID_PATTERN.test(value);
32
- }
33
-
34
- function isNumber(value) {
35
- return typeof value === "number" && Number.isFinite(value) && value >= 0;
36
- }
37
-
38
- function isInteger(value) {
39
- return isNumber(value) && Number.isInteger(value);
40
- }
41
-
42
- function canonicalHost(value) {
43
- const host = String(value || "").trim().toLowerCase();
44
- if (host === "localhost" || host === "::1" || host === "[::1]") return "loopback";
45
- if (host === "0.0.0.0" || host === "::" || host === "[::]") return "*";
46
- if (host === "127.0.0.1") return "loopback";
47
- return host;
48
- }
49
-
50
- export function validateConfig(input) {
51
- const config = normalizeConfig(input);
52
- const errors = [];
53
- const warnings = [];
54
-
55
- if (config.version !== 1) add(errors, "version", "当前 MVP 只支持 version = 1");
56
- if (!isInteger(config.web.port) || config.web.port < 1 || config.web.port > 65535) {
57
- add(errors, "web.port", "Web 端口必须是 1 到 65535 之间的整数");
58
- }
59
- if (!config.web.listenHost) add(errors, "web.listenHost", "Web listenHost 不能为空");
60
- else if (!isLoopback(config.web.listenHost)) {
61
- add(
62
- errors,
63
- "web.listenHost",
64
- "Web 管理台必须监听 127.0.0.1、localhost 或 ::1;本地管理台不支持远程监听",
65
- );
66
- }
67
-
68
- for (const [id, rawUpstream] of Object.entries(config.upstreams)) {
69
- const upstream = isRecord(rawUpstream) ? rawUpstream : {};
70
- if (!isRecord(rawUpstream)) add(errors, `upstreams.${id}`, "upstream 必须是 object");
71
- if (!hasId(id)) add(errors, `upstreams.${id}`, "upstream ID 包含非法字符");
72
- if (!upstream.baseUrl || !isUrl(upstream.baseUrl)) {
73
- add(errors, `upstreams.${id}.baseUrl`, "必须是 http 或 https URL");
74
- }
75
- if (!PROTOCOLS.has(upstream.protocol)) {
76
- add(errors, `upstreams.${id}.protocol`, `不支持的协议: ${upstream.protocol || "(empty)"}`);
77
- }
78
- if (upstream.integration && !INTEGRATIONS.has(upstream.integration)) {
79
- add(errors, `upstreams.${id}.integration`, "未注册的上游接入方式");
80
- }
81
- const authHeader = upstream.auth?.header || upstream.authHeader;
82
- if (authHeader && !HEADER_PATTERN.test(authHeader)) {
83
- add(errors, `upstreams.${id}.auth.header`, "认证 header 名称不合法");
84
- }
85
- if (upstream.auth?.scheme !== undefined &&
86
- (typeof upstream.auth.scheme !== "string" || !HEADER_PATTERN.test(upstream.auth.scheme))) {
87
- add(errors, `upstreams.${id}.auth.scheme`, "认证 scheme 必须是合法的 HTTP token");
88
- }
89
- }
90
-
91
- for (const [id, rawModel] of Object.entries(config.models)) {
92
- const model = isRecord(rawModel) ? rawModel : {};
93
- if (!isRecord(rawModel)) add(errors, `models.${id}`, "Model Profile 必须是 object");
94
- if (!hasId(id)) add(errors, `models.${id}`, "Model Profile ID 包含非法字符");
95
- if (!Array.isArray(model.aliases)) {
96
- add(errors, `models.${id}.aliases`, "aliases 必须是数组");
97
- }
98
- if (
99
- model.clientModelId !== undefined &&
100
- (typeof model.clientModelId !== "string" || !model.clientModelId.trim())
101
- ) {
102
- add(errors, `models.${id}.clientModelId`, "clientModelId 必须是非空字符串");
103
- }
104
- if (model.capabilities !== undefined && !Array.isArray(model.capabilities)) {
105
- add(errors, `models.${id}.capabilities`, "capabilities 必须是数组");
106
- }
107
- if (
108
- Array.isArray(model.capabilities) &&
109
- model.capabilities.some((item) => typeof item !== "string" || !item.trim())
110
- ) {
111
- add(errors, `models.${id}.capabilities`, "capabilities 必须是非空字符串数组");
112
- }
113
- if (
114
- model.contextWindow !== undefined &&
115
- (!isInteger(model.contextWindow) || model.contextWindow < 1)
116
- ) {
117
- add(errors, `models.${id}.contextWindow`, "contextWindow 必须是正整数");
118
- }
119
- if (model.codex !== undefined) {
120
- if (!isRecord(model.codex) || !["official", "override"].includes(model.codex.metadataMode)) {
121
- add(errors, `models.${id}.codex`, "Codex metadataMode 必须为 official 或 override");
122
- } else if (model.codex.inputModalities !== undefined &&
123
- (!Array.isArray(model.codex.inputModalities) || !model.codex.inputModalities.includes("text") ||
124
- model.codex.inputModalities.some((item) => !["text", "image"].includes(item)))) {
125
- add(errors, `models.${id}.codex.inputModalities`, "输入类型必须包含 text,且只能包含 text 或 image");
126
- }
127
- }
128
- if (
129
- model.compact !== undefined &&
130
- (!isRecord(model.compact) ||
131
- (model.compact.tokenLimit !== undefined &&
132
- (!isInteger(model.compact.tokenLimit) || model.compact.tokenLimit < 1)))
133
- ) {
134
- add(errors, `models.${id}.compact`, "compact 必须包含合法的 tokenLimit");
135
- }
136
- if (
137
- isRecord(model.compact) &&
138
- model.compact.strategy !== undefined &&
139
- !COMPACT_STRATEGIES.has(model.compact.strategy)
140
- ) {
141
- add(errors, `models.${id}.compact.strategy`, "不支持的 compact strategy");
142
- }
143
- const aliases = new Set([
144
- id,
145
- ...(model.clientModelId ? [model.clientModelId] : []),
146
- ...(Array.isArray(model.aliases) ? model.aliases : []),
147
- ]);
148
- for (const alias of aliases) {
149
- if (typeof alias !== "string" || !alias.trim()) {
150
- add(errors, `models.${id}.aliases`, "模型 alias 不能为空");
151
- continue;
152
- }
153
- }
154
- if (!isRecord(model.upstreams)) {
155
- add(errors, `models.${id}.upstreams`, "必须提供唯一的 upstream 模型映射");
156
- }
157
- if (isRecord(model.upstreams)) {
158
- if (Object.keys(model.upstreams).length !== 1) {
159
- add(errors, `models.${id}.upstreams`, "每个 Model Profile 必须且只能映射一个 upstream");
160
- }
161
- for (const [upstreamId, binding] of Object.entries(model.upstreams)) {
162
- if (!config.upstreams[upstreamId]) {
163
- add(errors, `models.${id}.upstreams.${upstreamId}`, "引用的 upstream 不存在");
164
- }
165
- if (!isRecord(binding)) {
166
- add(errors, `models.${id}.upstreams.${upstreamId}`, "模型设置必须是 object");
167
- } else if (binding.upstreamModelId !== undefined &&
168
- (typeof binding.upstreamModelId !== "string" || !binding.upstreamModelId.trim())) {
169
- add(errors, `models.${id}.upstreams.${upstreamId}.upstreamModelId`, "如需改名,必须填写非空字符串");
170
- }
171
- if (
172
- binding?.capabilityOverrides !== undefined &&
173
- (!Array.isArray(binding.capabilityOverrides) ||
174
- binding.capabilityOverrides.some(
175
- (item) => typeof item !== "string" || !item.trim(),
176
- ))
177
- ) {
178
- add(
179
- errors,
180
- `models.${id}.upstreams.${upstreamId}.capabilityOverrides`,
181
- "capabilityOverrides 必须是非空字符串数组",
182
- );
183
- }
184
- }
185
- }
186
- }
187
-
188
- for (const [id, rawRoute] of Object.entries(config.routes)) {
189
- const route = isRecord(rawRoute) ? rawRoute : {};
190
- if (!isRecord(rawRoute)) add(errors, `routes.${id}`, "route 必须是 object");
191
- if (!hasId(id)) add(errors, `routes.${id}`, "route ID 包含非法字符");
192
- if (!Array.isArray(route.backends) || route.backends.length === 0) {
193
- add(errors, `routes.${id}.backends`, "每份配置必须且只能连接一个 upstream(一个 backend)");
194
- continue;
195
- }
196
- if (route.backends.length !== 1) {
197
- add(errors, `routes.${id}.backends`, "每份配置必须且只能连接一个 upstream(一个 backend)");
198
- }
199
- for (const [index, rawBackend] of route.backends.entries()) {
200
- const backend = isRecord(rawBackend) ? rawBackend : {};
201
- const base = `routes.${id}.backends.${index}`;
202
- if (!isRecord(rawBackend)) add(errors, base, "backend 必须是 object");
203
- if (!config.upstreams[backend.upstream]) add(errors, `${base}.upstream`, "upstream 不存在");
204
- if (backend.models !== undefined && !Array.isArray(backend.models)) {
205
- add(errors, `${base}.models`, "models 必须是数组,可留空以透传模型名");
206
- }
207
- for (const modelId of Array.isArray(backend.models) ? backend.models : []) {
208
- if (typeof modelId !== "string" || !modelId.trim()) {
209
- add(errors, `${base}.models`, "模型 ID 必须是非空字符串");
210
- continue;
211
- }
212
- if (!config.models[modelId]) add(errors, `${base}.models`, `模型不存在: ${modelId}`);
213
- if (config.models[modelId] && !config.models[modelId].upstreams?.[backend.upstream]) {
214
- add(
215
- errors,
216
- `${base}.models`,
217
- `Model Profile ${modelId} 没有 upstream ${backend.upstream} 的模型映射`,
218
- );
219
- }
220
- }
221
- }
222
- }
223
-
224
- for (const [id, rawProvider] of Object.entries(config.virtualProviders)) {
225
- const provider = isRecord(rawProvider) ? rawProvider : {};
226
- if (!isRecord(rawProvider)) add(errors, `virtualProviders.${id}`, "Virtual Provider 必须是 object");
227
- if (!hasId(id)) add(errors, `virtualProviders.${id}`, "Virtual Provider ID 包含非法字符");
228
- if (!id.startsWith("cabletidy_")) {
229
- add(errors, `virtualProviders.${id}.id`, "Virtual Provider ID 应为 cabletidy_<配置ID>,请检查名称或 ID 冲突");
230
- }
231
- if (id === providerIdForConfiguration("api")) {
232
- add(errors, `virtualProviders.${id}.id`, "配置 ID api 是管理接口保留路径,请使用其他配置名称");
233
- }
234
- if (!PROTOCOLS.has(provider.ingressProtocol)) {
235
- add(errors, `virtualProviders.${id}.ingressProtocol`, "未注册的 ingress protocol");
236
- } else if (!IMPLEMENTED_INGRESS_PROTOCOLS.has(provider.ingressProtocol)) {
237
- warnings.push({
238
- path: `virtualProviders.${id}.ingressProtocol`,
239
- message: `当前数据面尚未实现 ${provider.ingressProtocol},请求会返回 501`,
240
- });
241
- }
242
- if (!config.routes[provider.route]) add(errors, `virtualProviders.${id}.route`, "route 不存在");
243
- if (provider.allowedModels !== undefined && !Array.isArray(provider.allowedModels)) {
244
- add(errors, `virtualProviders.${id}.allowedModels`, "模型设置必须是数组,可留空以透传模型名");
245
- } else {
246
- const aliasOwners = new Map();
247
- for (const modelId of provider.allowedModels || []) {
248
- if (typeof modelId !== "string" || !modelId.trim()) {
249
- add(errors, `virtualProviders.${id}.allowedModels`, "模型 ID 必须是非空字符串");
250
- continue;
251
- }
252
- if (!config.models[modelId]) add(errors, `virtualProviders.${id}.allowedModels`, `模型不存在: ${modelId}`);
253
- const model = config.models[modelId];
254
- if (model) {
255
- const backend = config.routes[provider.route]?.backends?.[0];
256
- if (backend && (!backend.models?.includes(modelId) || !model.upstreams?.[backend.upstream])) {
257
- add(errors, `virtualProviders.${id}.allowedModels`, `模型 ${modelId} 必须映射到当前配置的 upstream ${backend.upstream}`);
258
- }
259
- for (const alias of new Set([modelId, model.clientModelId, ...(Array.isArray(model.aliases) ? model.aliases : [])].filter(Boolean))) {
260
- const owner = aliasOwners.get(alias);
261
- if (owner && owner !== modelId) add(errors, `virtualProviders.${id}.allowedModels`, `模型名或 alias "${alias}" 在当前 Virtual Provider 内重复`);
262
- aliasOwners.set(alias, modelId);
263
- }
264
- }
265
- }
266
- }
267
- if (provider.defaultModel !== undefined &&
268
- (typeof provider.defaultModel !== "string" || !provider.defaultModel.trim())) {
269
- add(errors, `virtualProviders.${id}.defaultModel`, "defaultModel 必须是非空字符串");
270
- }
271
- }
272
-
273
- const configurationNames = new Map();
274
- const providerOwners = new Map();
275
- for (const [id, rawBinding] of Object.entries(config.bindings)) {
276
- const binding = isRecord(rawBinding) ? rawBinding : {};
277
- if (!isRecord(rawBinding)) add(errors, `bindings.${id}`, "Binding 必须是 object");
278
- if (!hasId(id)) add(errors, `bindings.${id}`, "Binding ID 包含非法字符");
279
- const nameId = configurationId(binding.name, id);
280
- if (nameId === "api") add(errors, `bindings.${id}.name`, "配置 ID api 是管理接口保留路径,请使用其他配置名称");
281
- if (configurationNames.has(nameId)) {
282
- add(errors, `bindings.${id}.name`, `配置名称规范化后与 ${configurationNames.get(nameId)} 重复: ${nameId}`);
283
- }
284
- configurationNames.set(nameId, id);
285
- if (id !== nameId) add(errors, `bindings.${id}.id`, `配置 ID 应为 ${nameId},请检查名称或 ID 冲突`);
286
- if (binding.virtualProvider) {
287
- if (providerOwners.has(binding.virtualProvider)) {
288
- add(errors, `bindings.${id}.virtualProvider`, `Virtual Provider 已被配置 ${providerOwners.get(binding.virtualProvider)} 使用;配置与 Virtual Provider 必须一对一`);
289
- }
290
- providerOwners.set(binding.virtualProvider, id);
291
- if (binding.virtualProvider !== providerIdForConfiguration(id)) {
292
- add(errors, `bindings.${id}.virtualProvider`, `Virtual Provider ID 应为 ${providerIdForConfiguration(id)}`);
293
- }
294
- }
295
- if (!TARGETS.has(binding.target)) {
296
- add(errors, `bindings.${id}.target`, `不支持的 target: ${binding.target || "(empty)"}`);
297
- }
298
- if (!config.virtualProviders[binding.virtualProvider]) {
299
- add(errors, `bindings.${id}.virtualProvider`, "Virtual Provider 不存在");
300
- } else {
301
- const virtualProvider = config.virtualProviders[binding.virtualProvider];
302
- if (
303
- binding.target === "codex" &&
304
- virtualProvider.ingressProtocol !== "openai.responses"
305
- ) {
306
- add(
307
- errors,
308
- `bindings.${id}.virtualProvider`,
309
- "Codex binding 必须连接 openai.responses Virtual Provider",
310
- );
311
- }
312
- if (
313
- binding.target === "claude-code" &&
314
- virtualProvider.ingressProtocol !== "anthropic.messages"
315
- ) {
316
- add(
317
- errors,
318
- `bindings.${id}.virtualProvider`,
319
- "Claude Code binding 必须连接 anthropic.messages Virtual Provider",
320
- );
321
- }
322
- }
323
- if (binding.defaultModel !== undefined &&
324
- (typeof binding.defaultModel !== "string" || !binding.defaultModel.trim())) {
325
- add(errors, `bindings.${id}.defaultModel`, "defaultModel 必须是非空字符串");
326
- }
327
- if (binding.integration && !INTEGRATIONS.has(binding.integration)) {
328
- add(errors, `bindings.${id}.integration`, "未注册的上游接入方式");
329
- }
330
- if (binding.targetFormat && !TARGET_FORMATS.has(binding.targetFormat)) {
331
- add(errors, `bindings.${id}.targetFormat`, "未注册的 target format");
332
- }
333
- if (
334
- binding.target === "codex" &&
335
- binding.targetFormat &&
336
- binding.targetFormat !== "codex.config.toml.v1"
337
- ) {
338
- add(errors, `bindings.${id}.targetFormat`, "Codex binding 必须使用本地 config.toml 接入");
339
- }
340
- if (binding.target === "claude-code" && binding.targetFormat &&
341
- !["claude.env.v1", "claude.settings.json.v1"].includes(binding.targetFormat)) {
342
- add(errors, `bindings.${id}.targetFormat`, "Claude Code binding 必须使用 Claude settings 或 env 接入");
343
- }
344
- if (binding.target === "claude-code" && binding.claude !== undefined) {
345
- if (!isRecord(binding.claude)) add(errors, `bindings.${id}.claude`, "Claude 设置必须是 object");
346
- else {
347
- for (const key of ["setModel", "discoverModels"]) {
348
- if (binding.claude[key] !== undefined && typeof binding.claude[key] !== "boolean") {
349
- add(errors, `bindings.${id}.claude.${key}`, "必须是 boolean");
350
- }
351
- }
352
- if (binding.claude.models !== undefined) {
353
- if (!isRecord(binding.claude.models)) add(errors, `bindings.${id}.claude.models`, "模型选择必须是 object");
354
- else for (const [key, value] of Object.entries(binding.claude.models)) {
355
- if (!["opus", "sonnet", "fable", "haiku", "subagent"].includes(key) || typeof value !== "string" || !value.trim()) {
356
- add(errors, `bindings.${id}.claude.models.${key}`, "仅支持 opus、sonnet、fable、haiku、subagent 的非空模型 ID");
357
- }
358
- }
359
- }
360
- }
361
- }
362
- if (
363
- binding.target === "generic-env" &&
364
- binding.env?.prefix &&
365
- !ENV_PATTERN.test(binding.env.prefix)
366
- ) {
367
- add(errors, `bindings.${id}.env.prefix`, "generic env prefix 必须是大写环境变量名");
368
- }
369
- }
370
-
371
- if (Object.keys(config.upstreams).length === 0) {
372
- warnings.push({ path: "upstreams", message: "还没有配置 upstream" });
373
- }
374
- if (Object.keys(config.virtualProviders).length === 0) {
375
- warnings.push({ path: "virtualProviders", message: "还没有配置本地 Virtual Provider" });
376
- }
377
-
378
- return { ok: errors.length === 0, errors, warnings, config };
379
- }
380
-
381
- function isLoopback(host) {
382
- const canonical = canonicalHost(host);
383
- return canonical === "loopback";
384
- }
385
-
386
- function isUrl(value) {
387
- try {
388
- const url = new URL(value);
389
- return url.protocol === "http:" || url.protocol === "https:";
390
- } catch {
391
- return false;
392
- }
393
- }