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/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
- let secrets = await loadSecrets(paths);
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
- const port = Number(config.web.port || 43100);
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
- await listen(webServer, host, port);
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: result.rejected,
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
- // Compatibility alias for clients built against the first prototype. It
372
- // previews target artifacts and does not inspect or import reference files.
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/apply" && method === "POST") {
384
- await applyTarget(state, body, response);
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 !== "codex") {
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
- let report = { applied: [], mode: artifacts.mode };
648
- report = await applyCodexArtifacts(artifacts, { paths: state.paths, codexHome: state.codexHome });
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 supportedPaths = protocol === "anthropic.messages"
699
- ? ["/v1/messages", "/messages"]
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
- ? "只支持 POST /v1/messages"
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
- // Model metadata is a client-side artifact. Preserve the instructions,
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 requestId = upstreamResponse.headers.get("x-request-id");
822
- if (requestId) headers["x-request-id"] = requestId;
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 = rewriteModelFields(JSON.parse(text), clientModelId, upstreamModelId);
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 createSseRewriteTransform(clientModelId, upstreamModelId) {
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 = rewriteModelFields(parsed, clientModelId, upstreamModelId);
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
- ? ["anthropic-version", "anthropic-beta"]
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
- : "invalid_request_error";
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: {
@@ -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: "claude.env.v1",
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
- "将以下环境变量注入 Claude Code 进程即可,不需要修改上游 provider 配置。",
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
  }
@@ -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 && binding.targetFormat !== "claude.env.v1") {
354
- add(errors, `bindings.${id}.targetFormat`, "Claude Code binding 必须使用 claude.env.v1");
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" &&