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