cabletidy 0.1.0 → 0.2.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/README.md +94 -10
- package/package.json +6 -1
- package/src/claude-config-file.mjs +172 -0
- package/src/cli.mjs +6 -1
- package/src/codex-catalog.mjs +13 -4
- package/src/config.mjs +44 -64
- package/src/instance-lock.mjs +129 -0
- package/src/model-resolver.mjs +6 -8
- package/src/process-identity.mjs +72 -0
- package/src/server.mjs +142 -76
- package/src/target-artifacts.mjs +40 -3
- package/src/validation.mjs +26 -20
- package/src/windows-codex.ps1 +90 -0
- package/web/app.js +310 -170
- package/web/claude-models.js +39 -0
- package/web/styles.css +0 -56
package/src/server.mjs
CHANGED
|
@@ -6,11 +6,14 @@ import { Readable, Transform } from "node:stream";
|
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
7
|
import { StringDecoder } from "node:string_decoder";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { acquireInstanceLock } from "./instance-lock.mjs";
|
|
9
10
|
|
|
10
11
|
import {
|
|
11
12
|
applySecretPayload,
|
|
12
13
|
backupFile,
|
|
13
14
|
computeConfigDiff,
|
|
15
|
+
defaultConfig,
|
|
16
|
+
ensureStore,
|
|
14
17
|
getPaths,
|
|
15
18
|
loadConfig,
|
|
16
19
|
loadSecrets,
|
|
@@ -26,8 +29,6 @@ import {
|
|
|
26
29
|
} from "./config.mjs";
|
|
27
30
|
import {
|
|
28
31
|
applyCodexArtifacts,
|
|
29
|
-
prepareCodexArtifacts,
|
|
30
|
-
publicArtifacts,
|
|
31
32
|
CODEX_NATIVE_PROVIDER_INTEGRATION,
|
|
32
33
|
CODEX_NATIVE_REQUIRED_ENDPOINTS,
|
|
33
34
|
CODEX_TARGET_FORMAT,
|
|
@@ -35,7 +36,10 @@ import {
|
|
|
35
36
|
import {
|
|
36
37
|
publicTargetArtifacts,
|
|
37
38
|
prepareTargetArtifacts,
|
|
39
|
+
CLAUDE_ENDPOINTS,
|
|
40
|
+
CLAUDE_TARGET_FORMAT,
|
|
38
41
|
} from "./target-artifacts.mjs";
|
|
42
|
+
import { applyClaudeSettings, restoreClaudeSettings } from "./claude-config-file.mjs";
|
|
39
43
|
import {
|
|
40
44
|
listClientModels,
|
|
41
45
|
resolveModelProfile,
|
|
@@ -64,8 +68,28 @@ const CODEX_NATIVE_RESPONSES_PATH = CODEX_NATIVE_REQUIRED_ENDPOINTS.find(
|
|
|
64
68
|
export async function createApplication(options = {}) {
|
|
65
69
|
const paths = options.paths || getPaths();
|
|
66
70
|
await fs.mkdir(paths.home, { recursive: true, mode: 0o700 });
|
|
71
|
+
const { release, identity } = await acquireInstanceLock(paths);
|
|
72
|
+
try {
|
|
73
|
+
const app = await createLockedApplication(options, paths, identity);
|
|
74
|
+
let closing;
|
|
75
|
+
return {
|
|
76
|
+
...app,
|
|
77
|
+
close() {
|
|
78
|
+
return closing ||= app.close().finally(release);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await release();
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function createLockedApplication(options, paths, identity) {
|
|
88
|
+
await ensureStore(paths);
|
|
67
89
|
let config = await loadConfig(paths);
|
|
68
|
-
|
|
90
|
+
const allocatePort = config === null;
|
|
91
|
+
config ||= defaultConfig();
|
|
92
|
+
const secrets = await loadSecrets(paths);
|
|
69
93
|
const startupValidation = validateConfig(config);
|
|
70
94
|
if (!startupValidation.ok) {
|
|
71
95
|
const details = startupValidation.errors
|
|
@@ -76,6 +100,7 @@ export async function createApplication(options = {}) {
|
|
|
76
100
|
|
|
77
101
|
const state = {
|
|
78
102
|
paths,
|
|
103
|
+
identity,
|
|
79
104
|
config,
|
|
80
105
|
secrets,
|
|
81
106
|
events: [],
|
|
@@ -85,12 +110,13 @@ export async function createApplication(options = {}) {
|
|
|
85
110
|
configOperation: Promise.resolve(),
|
|
86
111
|
loadCodexCatalog: options.loadCodexCatalog || loadCodexCatalog,
|
|
87
112
|
codexHome: options.codexHome,
|
|
113
|
+
claudeHome: options.claudeHome,
|
|
88
114
|
};
|
|
89
115
|
|
|
90
116
|
let webServer = null;
|
|
91
117
|
const requests = new Set();
|
|
92
118
|
const host = config.web.listenHost || "127.0.0.1";
|
|
93
|
-
|
|
119
|
+
let port = Number(config.web.port);
|
|
94
120
|
try {
|
|
95
121
|
webServer = http.createServer((request, response) => {
|
|
96
122
|
const task = handleWebRequest(state, request, response).catch((error) => {
|
|
@@ -103,9 +129,24 @@ export async function createApplication(options = {}) {
|
|
|
103
129
|
requests.add(task);
|
|
104
130
|
});
|
|
105
131
|
state.webServer = webServer;
|
|
106
|
-
|
|
132
|
+
try {
|
|
133
|
+
await listen(webServer, host, port);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error.code !== "EADDRINUSE") throw error;
|
|
136
|
+
if (!allocatePort) {
|
|
137
|
+
throw Object.assign(new Error(
|
|
138
|
+
`端口 ${host}:${port} 已被占用 (EADDRINUSE)。请停止占用端口的服务,或修改 ${paths.config} 中的 web.port 后重启,并重新应用客户端配置。`,
|
|
139
|
+
{ cause: error },
|
|
140
|
+
), { code: error.code });
|
|
141
|
+
}
|
|
142
|
+
// Keep the OS-assigned listener open so nobody can claim the chosen port.
|
|
143
|
+
await listen(webServer, host, 0);
|
|
144
|
+
}
|
|
145
|
+
port = webServer.address().port;
|
|
146
|
+
config.web.port = port;
|
|
107
147
|
|
|
108
148
|
await saveSecrets(secrets, paths);
|
|
149
|
+
if (allocatePort) await saveConfig(config, paths);
|
|
109
150
|
await persistRuntimeInfo(state, host, port);
|
|
110
151
|
} catch (error) {
|
|
111
152
|
if (webServer) await closeServer(webServer).catch(() => {});
|
|
@@ -113,10 +154,8 @@ export async function createApplication(options = {}) {
|
|
|
113
154
|
}
|
|
114
155
|
|
|
115
156
|
function updateConfig(nextConfig, nextSecrets) {
|
|
116
|
-
config = nextConfig;
|
|
117
|
-
secrets = nextSecrets;
|
|
118
|
-
state.config = config;
|
|
119
|
-
state.secrets = secrets;
|
|
157
|
+
state.config = nextConfig;
|
|
158
|
+
state.secrets = nextSecrets;
|
|
120
159
|
}
|
|
121
160
|
|
|
122
161
|
state.updateConfig = updateConfig;
|
|
@@ -236,8 +275,9 @@ async function handleApiRequest(state, request, response, url) {
|
|
|
236
275
|
integrations: [CODEX_NATIVE_PROVIDER_INTEGRATION],
|
|
237
276
|
requiredEndpoints: {
|
|
238
277
|
[CODEX_NATIVE_PROVIDER_INTEGRATION]: CODEX_NATIVE_REQUIRED_ENDPOINTS,
|
|
278
|
+
"claude-code": CLAUDE_ENDPOINTS,
|
|
239
279
|
},
|
|
240
|
-
targetFormats: [CODEX_TARGET_FORMAT, "claude.env.v1", "generic.env.v1"],
|
|
280
|
+
targetFormats: [CODEX_TARGET_FORMAT, CLAUDE_TARGET_FORMAT, "claude.env.v1", "generic.env.v1"],
|
|
241
281
|
targets: ["codex", "claude-code", "generic-env"],
|
|
242
282
|
capabilities: [
|
|
243
283
|
"streaming",
|
|
@@ -339,7 +379,7 @@ async function handleApiRequest(state, request, response, url) {
|
|
|
339
379
|
upstreamModelId: result.upstreamModelId,
|
|
340
380
|
routeId: result.routeId,
|
|
341
381
|
capabilities: result.capabilities,
|
|
342
|
-
rejectedBackends:
|
|
382
|
+
rejectedBackends: [],
|
|
343
383
|
});
|
|
344
384
|
} catch (error) {
|
|
345
385
|
sendJson(response, 422, {
|
|
@@ -359,29 +399,26 @@ async function handleApiRequest(state, request, response, url) {
|
|
|
359
399
|
return;
|
|
360
400
|
}
|
|
361
401
|
|
|
362
|
-
if (
|
|
363
|
-
(url.pathname === "/api/v1/config/preview-target-artifacts" ||
|
|
364
|
-
url.pathname === "/api/v1/config/preview-codex-config") &&
|
|
365
|
-
method === "POST"
|
|
366
|
-
) {
|
|
402
|
+
if (method === "POST" && url.pathname === "/api/v1/config/preview-target-artifacts") {
|
|
367
403
|
await previewTargetArtifacts(state, body, response);
|
|
368
404
|
return;
|
|
369
405
|
}
|
|
370
406
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
if (url.pathname === "/api/v1/config/preview-provider-artifacts" && method === "POST") {
|
|
374
|
-
await previewTargetArtifacts(state, body, response);
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (url.pathname === "/api/v1/targets/codex/apply" && method === "POST") {
|
|
379
|
-
await applyCodexTarget(state, body, response);
|
|
407
|
+
if (method === "POST" && url.pathname === "/api/v1/targets/apply") {
|
|
408
|
+
await applyTarget(state, body, response);
|
|
380
409
|
return;
|
|
381
410
|
}
|
|
382
411
|
|
|
383
|
-
if (url.pathname === "/api/v1/targets/
|
|
384
|
-
|
|
412
|
+
if (method === "POST" && url.pathname === "/api/v1/targets/restore") {
|
|
413
|
+
try {
|
|
414
|
+
const binding = state.config.bindings[body.bindingId];
|
|
415
|
+
if (binding?.target !== "claude-code") throw new Error("请选择 Claude Code 配置");
|
|
416
|
+
const report = await restoreClaudeSettings(body.bindingId, { paths: state.paths, claudeHome: state.claudeHome });
|
|
417
|
+
recordEvent(state, "target.restore", { bindingId: body.bindingId, target: "claude-code" });
|
|
418
|
+
sendJson(response, 200, { ok: true, target: "claude-code", report });
|
|
419
|
+
} catch (error) {
|
|
420
|
+
sendJson(response, 422, { error: { code: "target_restore_failed", message: error.message } });
|
|
421
|
+
}
|
|
385
422
|
return;
|
|
386
423
|
}
|
|
387
424
|
|
|
@@ -592,6 +629,7 @@ async function previewTargetArtifacts(state, body, response) {
|
|
|
592
629
|
bindingId: body.bindingId,
|
|
593
630
|
loadCatalog: state.loadCodexCatalog,
|
|
594
631
|
codexHome: state.codexHome,
|
|
632
|
+
claudeHome: state.claudeHome,
|
|
595
633
|
}, secrets);
|
|
596
634
|
sendJson(response, 200, {
|
|
597
635
|
ok: true,
|
|
@@ -604,35 +642,15 @@ async function previewTargetArtifacts(state, body, response) {
|
|
|
604
642
|
}
|
|
605
643
|
}
|
|
606
644
|
|
|
607
|
-
async function applyCodexTarget(state, body, response) {
|
|
608
|
-
try {
|
|
609
|
-
const artifacts = await prepareCodexArtifacts(state.config, {
|
|
610
|
-
bindingId: body.bindingId,
|
|
611
|
-
loadCatalog: state.loadCodexCatalog,
|
|
612
|
-
codexHome: state.codexHome,
|
|
613
|
-
}, state.secrets);
|
|
614
|
-
const report = await applyCodexArtifacts(artifacts, { paths: state.paths, codexHome: state.codexHome });
|
|
615
|
-
recordEvent(state, "target.codex.apply", { bindingId: body.bindingId || null, files: report.applied });
|
|
616
|
-
sendJson(response, 200, {
|
|
617
|
-
ok: true,
|
|
618
|
-
report: {
|
|
619
|
-
...report,
|
|
620
|
-
environment: publicArtifacts({ ...artifacts, ...report }).environment,
|
|
621
|
-
},
|
|
622
|
-
});
|
|
623
|
-
} catch (error) {
|
|
624
|
-
sendJson(response, 422, { error: { code: "codex_apply_failed", message: error.message } });
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
645
|
async function applyTarget(state, body, response) {
|
|
629
646
|
try {
|
|
630
647
|
const artifacts = await prepareTargetArtifacts(state.config, {
|
|
631
648
|
bindingId: body.bindingId,
|
|
632
649
|
loadCatalog: state.loadCodexCatalog,
|
|
633
650
|
codexHome: state.codexHome,
|
|
651
|
+
claudeHome: state.claudeHome,
|
|
634
652
|
}, state.secrets);
|
|
635
|
-
if (artifacts.target
|
|
653
|
+
if (!["codex", "claude-code"].includes(artifacts.target)) {
|
|
636
654
|
sendJson(response, 501, {
|
|
637
655
|
error: {
|
|
638
656
|
code: "target_apply_not_supported",
|
|
@@ -644,8 +662,9 @@ async function applyTarget(state, body, response) {
|
|
|
644
662
|
});
|
|
645
663
|
return;
|
|
646
664
|
}
|
|
647
|
-
|
|
648
|
-
|
|
665
|
+
const report = artifacts.target === "claude-code"
|
|
666
|
+
? await applyClaudeSettings(artifacts, { paths: state.paths, claudeHome: state.claudeHome })
|
|
667
|
+
: await applyCodexArtifacts(artifacts, { paths: state.paths, codexHome: state.codexHome });
|
|
649
668
|
recordEvent(state, "target.apply", {
|
|
650
669
|
bindingId: artifacts.bindingId,
|
|
651
670
|
target: artifacts.target,
|
|
@@ -674,6 +693,7 @@ async function handleProxyRequest(state, config, secrets, virtualProviderId, pro
|
|
|
674
693
|
}
|
|
675
694
|
|
|
676
695
|
const protocol = provider.ingressProtocol;
|
|
696
|
+
const isClaude = protocol === "anthropic.messages";
|
|
677
697
|
|
|
678
698
|
if (!IMPLEMENTED_INGRESS_PROTOCOLS.has(protocol)) {
|
|
679
699
|
sendProtocolError(
|
|
@@ -686,6 +706,34 @@ async function handleProxyRequest(state, config, secrets, virtualProviderId, pro
|
|
|
686
706
|
return;
|
|
687
707
|
}
|
|
688
708
|
|
|
709
|
+
if (isClaude && request.method === "HEAD" && url.pathname === "/api/hello") {
|
|
710
|
+
response.writeHead(204, { "cache-control": "no-store" });
|
|
711
|
+
response.end();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (isClaude && request.method === "GET" && url.pathname === "/v1/models") {
|
|
715
|
+
const limit = url.searchParams.get("limit") ?? "1000";
|
|
716
|
+
if (!/^\d+$/.test(limit) || Number(limit) < 1 || Number(limit) > 1000) {
|
|
717
|
+
sendProtocolError(response, protocol, 400, "invalid_request_error", "limit 必须是 1 到 1000 之间的整数");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
let models = listClientModels(config, provider).map((model) => ({
|
|
721
|
+
id: model.id, type: "model", display_name: model.id,
|
|
722
|
+
}));
|
|
723
|
+
const after = url.searchParams.get("after_id");
|
|
724
|
+
if (after) {
|
|
725
|
+
const index = models.findIndex((model) => model.id === after);
|
|
726
|
+
if (index < 0) {
|
|
727
|
+
sendProtocolError(response, protocol, 400, "invalid_request_error", "after_id 不在当前模型列表中");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
models = models.slice(index + 1);
|
|
731
|
+
}
|
|
732
|
+
const data = models.slice(0, Number(limit));
|
|
733
|
+
sendJson(response, 200, { data, has_more: models.length > data.length,
|
|
734
|
+
first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null });
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
689
737
|
if (
|
|
690
738
|
protocol === "openai.responses" &&
|
|
691
739
|
request.method === "GET" &&
|
|
@@ -695,8 +743,9 @@ async function handleProxyRequest(state, config, secrets, virtualProviderId, pro
|
|
|
695
743
|
sendJson(response, 200, { object: "list", data: models });
|
|
696
744
|
return;
|
|
697
745
|
}
|
|
698
|
-
const
|
|
699
|
-
|
|
746
|
+
const countTokens = isClaude && url.pathname === "/v1/messages/count_tokens";
|
|
747
|
+
const supportedPaths = isClaude
|
|
748
|
+
? ["/v1/messages", "/messages", "/v1/messages/count_tokens"]
|
|
700
749
|
: [CODEX_NATIVE_RESPONSES_PATH, "/responses"];
|
|
701
750
|
if (request.method !== "POST" || !supportedPaths.includes(url.pathname)) {
|
|
702
751
|
sendProtocolError(
|
|
@@ -705,7 +754,7 @@ async function handleProxyRequest(state, config, secrets, virtualProviderId, pro
|
|
|
705
754
|
404,
|
|
706
755
|
"not_found",
|
|
707
756
|
protocol === "anthropic.messages"
|
|
708
|
-
? "
|
|
757
|
+
? "支持 POST /v1/messages、POST /v1/messages/count_tokens、GET /v1/models 和 HEAD /api/hello"
|
|
709
758
|
: "只支持 POST /v1/responses 和 GET /v1/models",
|
|
710
759
|
);
|
|
711
760
|
return;
|
|
@@ -748,20 +797,14 @@ async function handleProxyRequest(state, config, secrets, virtualProviderId, pro
|
|
|
748
797
|
config,
|
|
749
798
|
provider,
|
|
750
799
|
resolution,
|
|
751
|
-
body,
|
|
800
|
+
countTokens ? {} : body,
|
|
752
801
|
);
|
|
753
802
|
} catch (error) {
|
|
754
803
|
sendProtocolError(response, protocol, 503, error.code || "upstream_unavailable", error.message);
|
|
755
804
|
return;
|
|
756
805
|
}
|
|
757
|
-
if (protocol !== selected.upstream.protocol) {
|
|
758
|
-
sendProtocolError(response, protocol, 501, "unsupported_transform", "MVP 暂不执行跨协议数据面转换");
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
806
|
const outgoing = structuredClone(body);
|
|
763
|
-
//
|
|
764
|
-
// tools and per-request reasoning effort constructed by Codex.
|
|
807
|
+
// Only the protocol model field is mapped; prompts and tool payloads stay intact.
|
|
765
808
|
outgoing.model = selected.upstreamModelId;
|
|
766
809
|
const upstreamUrl = joinUpstreamUrl(selected.upstream.baseUrl, url.pathname, url.search);
|
|
767
810
|
const upstreamHeaders = {
|
|
@@ -778,6 +821,7 @@ async function handleProxyRequest(state, config, secrets, virtualProviderId, pro
|
|
|
778
821
|
headers: upstreamHeaders,
|
|
779
822
|
body: JSON.stringify(outgoing),
|
|
780
823
|
signal: upstreamAbort.signal,
|
|
824
|
+
...(isClaude ? { redirect: "manual" } : {}),
|
|
781
825
|
});
|
|
782
826
|
recordHealth(
|
|
783
827
|
state,
|
|
@@ -790,6 +834,7 @@ async function handleProxyRequest(state, config, secrets, virtualProviderId, pro
|
|
|
790
834
|
upstreamId: selected.upstream.id,
|
|
791
835
|
clientModelId: resolution.clientModelId,
|
|
792
836
|
upstreamModelId: selected.upstreamModelId,
|
|
837
|
+
path: url.pathname,
|
|
793
838
|
status: upstreamResponse.status,
|
|
794
839
|
latencyMs: Date.now() - started,
|
|
795
840
|
});
|
|
@@ -798,6 +843,8 @@ async function handleProxyRequest(state, config, secrets, virtualProviderId, pro
|
|
|
798
843
|
upstreamResponse,
|
|
799
844
|
resolution.clientModelId,
|
|
800
845
|
selected.upstreamModelId,
|
|
846
|
+
protocol,
|
|
847
|
+
countTokens,
|
|
801
848
|
);
|
|
802
849
|
return;
|
|
803
850
|
} catch (error) {
|
|
@@ -811,22 +858,30 @@ async function handleProxyRequest(state, config, secrets, virtualProviderId, pro
|
|
|
811
858
|
}
|
|
812
859
|
}
|
|
813
860
|
|
|
814
|
-
async function relayResponse(response, upstreamResponse, clientModelId, upstreamModelId) {
|
|
861
|
+
async function relayResponse(response, upstreamResponse, clientModelId, upstreamModelId, protocol, countTokens = false) {
|
|
815
862
|
const contentType = upstreamResponse.headers.get("content-type") || "application/json";
|
|
816
863
|
const isEventStream = contentType.includes("text/event-stream");
|
|
817
864
|
const headers = {
|
|
818
865
|
"content-type": contentType,
|
|
819
866
|
"cache-control": upstreamResponse.headers.get("cache-control") || "no-cache",
|
|
820
867
|
};
|
|
821
|
-
const
|
|
822
|
-
|
|
868
|
+
for (const [name, value] of upstreamResponse.headers) {
|
|
869
|
+
if (["x-request-id", "request-id", "retry-after", "x-should-retry"].includes(name) || name.startsWith("anthropic-ratelimit-")) headers[name] = value;
|
|
870
|
+
}
|
|
823
871
|
response.writeHead(upstreamResponse.status, headers);
|
|
872
|
+
response.flushHeaders();
|
|
824
873
|
|
|
825
874
|
if (!upstreamResponse.body) {
|
|
826
875
|
response.end();
|
|
827
876
|
return;
|
|
828
877
|
}
|
|
829
878
|
|
|
879
|
+
if (!upstreamResponse.ok || countTokens || clientModelId === upstreamModelId) {
|
|
880
|
+
await pipeline(Readable.fromWeb(upstreamResponse.body), response);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const rewrite = protocol === "anthropic.messages" ? rewriteClaudeModel : rewriteModelFields;
|
|
884
|
+
|
|
830
885
|
if (!isEventStream) {
|
|
831
886
|
const text = await upstreamResponse.text();
|
|
832
887
|
if (!text) {
|
|
@@ -834,7 +889,7 @@ async function relayResponse(response, upstreamResponse, clientModelId, upstream
|
|
|
834
889
|
return;
|
|
835
890
|
}
|
|
836
891
|
try {
|
|
837
|
-
const body =
|
|
892
|
+
const body = rewrite(JSON.parse(text), clientModelId, upstreamModelId);
|
|
838
893
|
response.end(JSON.stringify(body));
|
|
839
894
|
} catch {
|
|
840
895
|
response.end(text);
|
|
@@ -844,12 +899,20 @@ async function relayResponse(response, upstreamResponse, clientModelId, upstream
|
|
|
844
899
|
|
|
845
900
|
await pipeline(
|
|
846
901
|
Readable.fromWeb(upstreamResponse.body),
|
|
847
|
-
createSseRewriteTransform(clientModelId, upstreamModelId),
|
|
902
|
+
createSseRewriteTransform(clientModelId, upstreamModelId, rewrite),
|
|
848
903
|
response,
|
|
849
904
|
);
|
|
850
905
|
}
|
|
851
906
|
|
|
852
|
-
function
|
|
907
|
+
function rewriteClaudeModel(value, clientModelId, upstreamModelId) {
|
|
908
|
+
if (value?.type === "message" && value.model === upstreamModelId) return { ...value, model: clientModelId };
|
|
909
|
+
if (value?.type === "message_start" && value.message?.model === upstreamModelId) {
|
|
910
|
+
return { ...value, message: { ...value.message, model: clientModelId } };
|
|
911
|
+
}
|
|
912
|
+
return value;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function createSseRewriteTransform(clientModelId, upstreamModelId, rewrite) {
|
|
853
916
|
const decoder = new StringDecoder("utf8");
|
|
854
917
|
let buffer = "";
|
|
855
918
|
|
|
@@ -859,7 +922,7 @@ function createSseRewriteTransform(clientModelId, upstreamModelId) {
|
|
|
859
922
|
buffer += Buffer.isBuffer(chunk)
|
|
860
923
|
? decoder.write(chunk)
|
|
861
924
|
: String(chunk, encoding);
|
|
862
|
-
const drained = drainSseBuffer(buffer, clientModelId, upstreamModelId);
|
|
925
|
+
const drained = drainSseBuffer(buffer, clientModelId, upstreamModelId, rewrite);
|
|
863
926
|
buffer = drained.remaining;
|
|
864
927
|
callback(null, drained.output || undefined);
|
|
865
928
|
} catch (error) {
|
|
@@ -872,7 +935,7 @@ function createSseRewriteTransform(clientModelId, upstreamModelId) {
|
|
|
872
935
|
callback(
|
|
873
936
|
null,
|
|
874
937
|
buffer
|
|
875
|
-
? rewriteSseEvent(buffer, clientModelId, upstreamModelId)
|
|
938
|
+
? rewriteSseEvent(buffer, clientModelId, upstreamModelId, rewrite)
|
|
876
939
|
: undefined,
|
|
877
940
|
);
|
|
878
941
|
} catch (error) {
|
|
@@ -882,21 +945,21 @@ function createSseRewriteTransform(clientModelId, upstreamModelId) {
|
|
|
882
945
|
});
|
|
883
946
|
}
|
|
884
947
|
|
|
885
|
-
function drainSseBuffer(buffer, clientModelId, upstreamModelId) {
|
|
948
|
+
function drainSseBuffer(buffer, clientModelId, upstreamModelId, rewrite) {
|
|
886
949
|
let remaining = buffer;
|
|
887
950
|
let output = "";
|
|
888
951
|
while (true) {
|
|
889
952
|
const delimiter = remaining.match(/\r?\n\r?\n/);
|
|
890
953
|
if (!delimiter || delimiter.index === undefined) break;
|
|
891
954
|
const event = remaining.slice(0, delimiter.index);
|
|
892
|
-
output += rewriteSseEvent(event, clientModelId, upstreamModelId);
|
|
955
|
+
output += rewriteSseEvent(event, clientModelId, upstreamModelId, rewrite);
|
|
893
956
|
output += delimiter[0];
|
|
894
957
|
remaining = remaining.slice(delimiter.index + delimiter[0].length);
|
|
895
958
|
}
|
|
896
959
|
return { output, remaining };
|
|
897
960
|
}
|
|
898
961
|
|
|
899
|
-
function rewriteSseEvent(event, clientModelId, upstreamModelId) {
|
|
962
|
+
function rewriteSseEvent(event, clientModelId, upstreamModelId, rewrite) {
|
|
900
963
|
const separator = event.includes("\r\n") ? "\r\n" : "\n";
|
|
901
964
|
const lines = event.split(/\r?\n/);
|
|
902
965
|
const dataLines = [];
|
|
@@ -910,7 +973,8 @@ function rewriteSseEvent(event, clientModelId, upstreamModelId) {
|
|
|
910
973
|
if (!payload || payload === "[DONE]") return event;
|
|
911
974
|
try {
|
|
912
975
|
const parsed = JSON.parse(payload);
|
|
913
|
-
const rewritten =
|
|
976
|
+
const rewritten = rewrite(parsed, clientModelId, upstreamModelId);
|
|
977
|
+
if (rewritten === parsed) return event;
|
|
914
978
|
const first = dataLines[0];
|
|
915
979
|
const prefix = lines[first.index].match(/^data:/)?.[0] || "data:";
|
|
916
980
|
lines[first.index] = `${prefix} ${JSON.stringify(rewritten)}`;
|
|
@@ -923,7 +987,7 @@ function rewriteSseEvent(event, clientModelId, upstreamModelId) {
|
|
|
923
987
|
|
|
924
988
|
function copyProtocolHeaders(request, target, protocol) {
|
|
925
989
|
const names = protocol === "anthropic.messages"
|
|
926
|
-
?
|
|
990
|
+
? Object.keys(request.headers).filter((name) => name.startsWith("anthropic-") || name.startsWith("x-claude-code-"))
|
|
927
991
|
: ["openai-beta", "openai-organization", "openai-project"];
|
|
928
992
|
for (const name of names) {
|
|
929
993
|
const value = request.headers[name];
|
|
@@ -1027,6 +1091,7 @@ async function persistRuntimeInfo(state, host, port) {
|
|
|
1027
1091
|
await writeRuntimeInfo(
|
|
1028
1092
|
{
|
|
1029
1093
|
pid: process.pid,
|
|
1094
|
+
pidStartTime: state.identity.startTime,
|
|
1030
1095
|
startedAt: state.startedAt,
|
|
1031
1096
|
web: {
|
|
1032
1097
|
host,
|
|
@@ -1186,7 +1251,8 @@ function sendProtocolError(response, protocol, status, code, message) {
|
|
|
1186
1251
|
? "overloaded_error"
|
|
1187
1252
|
: code === "not_found"
|
|
1188
1253
|
? "not_found_error"
|
|
1189
|
-
: "
|
|
1254
|
+
: status === 413 ? "request_too_large"
|
|
1255
|
+
: status >= 500 ? "api_error" : "invalid_request_error";
|
|
1190
1256
|
sendJson(response, status, {
|
|
1191
1257
|
type: "error",
|
|
1192
1258
|
error: {
|
package/src/target-artifacts.mjs
CHANGED
|
@@ -6,6 +6,16 @@ import {
|
|
|
6
6
|
import { clientModelIdForProfile, resolveModelProfile } from "./model-resolver.mjs";
|
|
7
7
|
import { normalizeConfig } from "./config.mjs";
|
|
8
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
|
+
];
|
|
9
19
|
|
|
10
20
|
export async function prepareTargetArtifacts(config, options = {}, secrets = {}) {
|
|
11
21
|
const requested = findBinding(config, options.bindingId);
|
|
@@ -15,6 +25,9 @@ export async function prepareTargetArtifacts(config, options = {}, secrets = {})
|
|
|
15
25
|
if (binding.target === "codex") {
|
|
16
26
|
return prepareCodexArtifacts(config, { ...options, bindingId: binding.id }, secrets);
|
|
17
27
|
}
|
|
28
|
+
if (binding.target === "claude-code") {
|
|
29
|
+
return prepareClaudeSettings(buildClaudeArtifacts(config, binding), options);
|
|
30
|
+
}
|
|
18
31
|
return buildTargetArtifacts(config, { ...options, bindingId: binding.id }, secrets);
|
|
19
32
|
}
|
|
20
33
|
|
|
@@ -80,23 +93,42 @@ function buildClaudeArtifacts(config, binding) {
|
|
|
80
93
|
// Satisfy Claude Code's client-side credential check; the local service
|
|
81
94
|
// ignores this public placeholder and authenticates only to the upstream.
|
|
82
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",
|
|
83
105
|
...(binding.claude?.setModel === false || !clientModel ? {} : { ANTHROPIC_MODEL: clientModel }),
|
|
84
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`;
|
|
85
113
|
|
|
86
114
|
return {
|
|
87
|
-
format:
|
|
115
|
+
format: CLAUDE_TARGET_FORMAT,
|
|
88
116
|
target: "claude-code",
|
|
89
117
|
mode: "managed_proxy",
|
|
90
118
|
bindingId: binding.id,
|
|
91
119
|
virtualProviderId: virtualProvider.id,
|
|
92
120
|
clientModelId: clientModel,
|
|
93
|
-
files: [],
|
|
121
|
+
files: [{ path: "cabletidy-claude.settings.json", kind: "json", contents }],
|
|
122
|
+
requiredEndpoints: CLAUDE_ENDPOINTS,
|
|
94
123
|
environment: {
|
|
95
124
|
vars,
|
|
96
125
|
shell: shellExports(vars),
|
|
126
|
+
powershell: Object.entries(vars).map(([key, value]) => `$env:${key} = '${String(value).replaceAll("'", "''")}'`).join("\n"),
|
|
97
127
|
},
|
|
98
128
|
instructions: [
|
|
99
|
-
"
|
|
129
|
+
"应用时只合并到用户 settings.json 的 env,保留其他设置;原值保存以便撤销接入。",
|
|
130
|
+
"预览文件也可另存为独立文件,通过 claude --settings cabletidy-claude.settings.json 临时接入。",
|
|
131
|
+
"本地入口无需 Key;cabletidy-local 仅满足客户端认证检查,真实凭据只由 CableTidy 发给上游。",
|
|
100
132
|
`ANTHROPIC_BASE_URL 指向本地 Virtual Provider: ${baseUrl}`,
|
|
101
133
|
clientModel ? `Claude 客户端模型名: ${clientModel}` : "模型由 Claude Code 选择,默认透传请求中的模型名。",
|
|
102
134
|
],
|
|
@@ -149,6 +181,10 @@ function getVirtualProvider(config, binding) {
|
|
|
149
181
|
function defaultProfile(config, binding, virtualProvider) {
|
|
150
182
|
const requested = binding.defaultModel || virtualProvider.defaultModel;
|
|
151
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
|
+
}
|
|
152
188
|
const resolved = resolveModelProfile(config, virtualProvider, requested);
|
|
153
189
|
return {
|
|
154
190
|
profileId: resolved.profileId,
|
|
@@ -173,5 +209,6 @@ function publicEnvironment(environment = {}) {
|
|
|
173
209
|
return {
|
|
174
210
|
vars: environment.vars || {},
|
|
175
211
|
shell: environment.shell || "",
|
|
212
|
+
...(environment.powershell ? { powershell: environment.powershell } : {}),
|
|
176
213
|
};
|
|
177
214
|
}
|
package/src/validation.mjs
CHANGED
|
@@ -11,9 +11,8 @@ const PROTOCOLS = new Set([
|
|
|
11
11
|
"gemini.generate_content",
|
|
12
12
|
]);
|
|
13
13
|
const INTEGRATIONS = new Set(["codex-native-provider"]);
|
|
14
|
-
const TARGET_FORMATS = new Set(["codex.config.toml.v1", "claude.env.v1", "generic.env.v1"]);
|
|
14
|
+
const TARGET_FORMATS = new Set(["codex.config.toml.v1", "claude.env.v1", "claude.settings.json.v1", "generic.env.v1"]);
|
|
15
15
|
const TARGETS = new Set(["codex", "claude-code", "generic-env"]);
|
|
16
|
-
const ROUTE_STRATEGIES = new Set(["priority"]);
|
|
17
16
|
const COMPACT_STRATEGIES = new Set(["auto", "manual", "disabled"]);
|
|
18
17
|
const IMPLEMENTED_INGRESS_PROTOCOLS = new Set([
|
|
19
18
|
"openai.responses",
|
|
@@ -83,6 +82,10 @@ export function validateConfig(input) {
|
|
|
83
82
|
if (authHeader && !HEADER_PATTERN.test(authHeader)) {
|
|
84
83
|
add(errors, `upstreams.${id}.auth.header`, "认证 header 名称不合法");
|
|
85
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
|
+
}
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
for (const [id, rawModel] of Object.entries(config.models)) {
|
|
@@ -137,13 +140,6 @@ export function validateConfig(input) {
|
|
|
137
140
|
) {
|
|
138
141
|
add(errors, `models.${id}.compact.strategy`, "不支持的 compact strategy");
|
|
139
142
|
}
|
|
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
143
|
const aliases = new Set([
|
|
148
144
|
id,
|
|
149
145
|
...(model.clientModelId ? [model.clientModelId] : []),
|
|
@@ -193,9 +189,6 @@ export function validateConfig(input) {
|
|
|
193
189
|
const route = isRecord(rawRoute) ? rawRoute : {};
|
|
194
190
|
if (!isRecord(rawRoute)) add(errors, `routes.${id}`, "route 必须是 object");
|
|
195
191
|
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
192
|
if (!Array.isArray(route.backends) || route.backends.length === 0) {
|
|
200
193
|
add(errors, `routes.${id}.backends`, "每份配置必须且只能连接一个 upstream(一个 backend)");
|
|
201
194
|
continue;
|
|
@@ -225,12 +218,6 @@ export function validateConfig(input) {
|
|
|
225
218
|
);
|
|
226
219
|
}
|
|
227
220
|
}
|
|
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
221
|
}
|
|
235
222
|
}
|
|
236
223
|
|
|
@@ -350,8 +337,27 @@ export function validateConfig(input) {
|
|
|
350
337
|
) {
|
|
351
338
|
add(errors, `bindings.${id}.targetFormat`, "Codex binding 必须使用本地 config.toml 接入");
|
|
352
339
|
}
|
|
353
|
-
if (binding.target === "claude-code" && binding.targetFormat &&
|
|
354
|
-
|
|
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
|
+
}
|
|
355
361
|
}
|
|
356
362
|
if (
|
|
357
363
|
binding.target === "generic-env" &&
|