cctra 0.3.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +135 -0
  3. package/bin/cctra +2 -0
  4. package/bin/cctra-daemon.exe +0 -0
  5. package/bin/cctra.js +2 -0
  6. package/examples/plugins/oauth-internal.js +46 -0
  7. package/examples/plugins/openai-compatible.js +27 -0
  8. package/package.json +53 -0
  9. package/src/canonical/types.ts +132 -0
  10. package/src/commands/add.ts +159 -0
  11. package/src/commands/daemon.ts +102 -0
  12. package/src/commands/ls.ts +49 -0
  13. package/src/commands/model.ts +95 -0
  14. package/src/commands/plugin.ts +167 -0
  15. package/src/commands/rename.ts +33 -0
  16. package/src/commands/rm.ts +37 -0
  17. package/src/commands/serve.ts +29 -0
  18. package/src/commands/shared.ts +14 -0
  19. package/src/commands/show.ts +46 -0
  20. package/src/commands/tier.ts +91 -0
  21. package/src/convert/common/content-blocks.ts +29 -0
  22. package/src/convert/common/extras.ts +38 -0
  23. package/src/convert/common/reasoning.ts +19 -0
  24. package/src/convert/common/system-prompt.ts +15 -0
  25. package/src/convert/common/tool-calls.ts +29 -0
  26. package/src/convert/common/usage.ts +19 -0
  27. package/src/convert/inbound/anthropic-to-canonical.ts +106 -0
  28. package/src/convert/inbound/chat-to-canonical.ts +132 -0
  29. package/src/convert/inbound/responses-to-canonical.ts +92 -0
  30. package/src/convert/outbound/canonical-to-anthropic.ts +62 -0
  31. package/src/convert/outbound/canonical-to-chat.ts +101 -0
  32. package/src/convert/outbound/canonical-to-responses.ts +105 -0
  33. package/src/convert/streaming/inbound/anthropic-stream.ts +14 -0
  34. package/src/convert/streaming/inbound/chat-stream.ts +219 -0
  35. package/src/convert/streaming/inbound/pick.ts +21 -0
  36. package/src/convert/streaming/inbound/responses-stream.ts +276 -0
  37. package/src/convert/streaming/outbound/format-anthropic.ts +19 -0
  38. package/src/convert/streaming/outbound/format-chat.ts +133 -0
  39. package/src/convert/streaming/outbound/format-responses.ts +184 -0
  40. package/src/convert/upstream/canonical-to-anthropic.ts +111 -0
  41. package/src/convert/upstream/canonical-to-chat.ts +115 -0
  42. package/src/convert/upstream/canonical-to-responses.ts +123 -0
  43. package/src/core/config.ts +156 -0
  44. package/src/core/model-fetch.ts +124 -0
  45. package/src/core/resolve.ts +73 -0
  46. package/src/core/routing.ts +31 -0
  47. package/src/core/source.ts +28 -0
  48. package/src/daemon/install.ts +47 -0
  49. package/src/daemon/platform/linux.ts +65 -0
  50. package/src/daemon/platform/macos.ts +71 -0
  51. package/src/daemon/platform/windows.ts +70 -0
  52. package/src/daemon/start.ts +22 -0
  53. package/src/daemon/status.ts +19 -0
  54. package/src/daemon/stop.ts +58 -0
  55. package/src/index.ts +34 -0
  56. package/src/plugin/contract.ts +51 -0
  57. package/src/plugin/host.ts +27 -0
  58. package/src/plugin/loader.ts +55 -0
  59. package/src/plugin/sandbox.ts +3 -0
  60. package/src/providers/presets.ts +167 -0
  61. package/src/server/anthropic-parser.ts +44 -0
  62. package/src/server/cancelable-fetch.ts +21 -0
  63. package/src/server/chat-parser.ts +81 -0
  64. package/src/server/error-status.ts +18 -0
  65. package/src/server/error.ts +16 -0
  66. package/src/server/handlers/chat-completions.ts +94 -0
  67. package/src/server/handlers/messages.ts +89 -0
  68. package/src/server/handlers/models.ts +35 -0
  69. package/src/server/handlers/responses.ts +89 -0
  70. package/src/server/keepalive.ts +63 -0
  71. package/src/server/responses-parser.ts +62 -0
  72. package/src/server/serve.ts +79 -0
  73. package/src/server/sse.ts +61 -0
  74. package/src/server/upstream.ts +251 -0
  75. package/src/tier/builtin.ts +9 -0
  76. package/src/tier/resolve.ts +33 -0
  77. package/src/tier/store.ts +3 -0
  78. package/src/types.ts +94 -0
  79. package/src/ui/format.ts +44 -0
  80. package/src/ui/prompts.ts +34 -0
  81. package/src/utils/fuzzy.ts +48 -0
  82. package/src/utils/logger.ts +32 -0
  83. package/src/utils/paths.ts +48 -0
@@ -0,0 +1,51 @@
1
+ // ============================================================================
2
+ // 插件契约:给插件作者用的 .d.ts 类型
3
+ // ============================================================================
4
+ import type { ApiFormat } from "../types";
5
+ import type { CanonicalRequest, CanonicalResponse, CanonicalChunk } from "../canonical/types";
6
+
7
+ /** 插件作者导出的对象 */
8
+ export interface UpstreamPlugin {
9
+ /** 全局唯一名 */
10
+ name: string;
11
+ displayName?: string;
12
+
13
+ /** 声明式模式:返回 ready config,让 cctra 帮你发请求 */
14
+ getConfig?(ctx: PluginContext): Promise<UpstreamReady | UpstreamReady[]>;
15
+
16
+ /** 函数式模式:直接接管请求(更灵活) */
17
+ fetch?(req: CanonicalRequest, ctx: PluginContext): Promise<CanonicalResponse>;
18
+ fetchStream?(req: CanonicalRequest, ctx: PluginContext): AsyncIterable<CanonicalChunk>;
19
+
20
+ /** 可选:返回插件能服务的模型列表 */
21
+ listModels?(ctx: PluginContext): Promise<PluginModel[]>;
22
+ }
23
+
24
+ /** 一次 getConfig 调用返回的"准备好"配置 */
25
+ export interface UpstreamReady {
26
+ baseUrl: string;
27
+ path: string;
28
+ apiFormat: ApiFormat;
29
+ authHeader: Record<string, string>;
30
+ modelId: string;
31
+ modelMetadata?: PluginModel;
32
+ }
33
+
34
+ /** cctra 给插件的 API */
35
+ export interface PluginContext {
36
+ /** 用户填的插件配置(JSON) */
37
+ config: Record<string, unknown>;
38
+ /** 写日志(带插件名前缀) */
39
+ logger: (msg: string) => void;
40
+ /** cctra 的 fetch 包装 */
41
+ fetch: typeof fetch;
42
+ /** 内存缓存 */
43
+ cacheGet: (key: string) => Promise<unknown | undefined>;
44
+ cacheSet: (key: string, value: unknown, ttlMs?: number) => Promise<void>;
45
+ }
46
+
47
+ export interface PluginModel {
48
+ id: string;
49
+ alias?: string;
50
+ metadata?: Record<string, unknown>;
51
+ }
@@ -0,0 +1,27 @@
1
+ // ============================================================================
2
+ // cctra 给插件的 host API 工厂
3
+ // ============================================================================
4
+ import type { PluginContext } from "./contract";
5
+ import { logger } from "../utils/logger";
6
+
7
+ const cache = new Map<string, { value: unknown; expiresAt: number }>();
8
+
9
+ export function makePluginContext(pluginName: string, config: Record<string, unknown>): PluginContext {
10
+ return {
11
+ config,
12
+ logger: (msg: string) => logger.info(`[plugin:${pluginName}] ${msg}`),
13
+ fetch: fetch.bind(globalThis),
14
+ cacheGet: async (key: string) => {
15
+ const entry = cache.get(`${pluginName}:${key}`);
16
+ if (!entry) return undefined;
17
+ if (entry.expiresAt < Date.now()) {
18
+ cache.delete(`${pluginName}:${key}`);
19
+ return undefined;
20
+ }
21
+ return entry.value;
22
+ },
23
+ cacheSet: async (key: string, value: unknown, ttlMs = 60_000) => {
24
+ cache.set(`${pluginName}:${key}`, { value, expiresAt: Date.now() + ttlMs });
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,55 @@
1
+ // ============================================================================
2
+ // 插件加载器:动态 import() 用户的 .js 文件,缓存到内存
3
+ // v1 是 trust 模型:插件 = 任意 JS,可执行任何代码
4
+ // ============================================================================
5
+ import { statSync } from "node:fs";
6
+ import { resolve as resolvePath } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import type { PluginConfig, Config } from "../types";
9
+ import type { UpstreamPlugin } from "./contract";
10
+ import { info, error as errorOut } from "../ui/format";
11
+
12
+ const moduleCache = new Map<string, UpstreamPlugin>();
13
+
14
+ /** 加载并缓存插件。CLI 一次性操作,输出走 format(stdout/stderr)给用户看。 */
15
+ export async function loadPlugin(plugin: PluginConfig, _config: Config): Promise<UpstreamPlugin | null> {
16
+ if (moduleCache.has(plugin.path)) {
17
+ return moduleCache.get(plugin.path)!;
18
+ }
19
+
20
+ // 验证文件存在
21
+ try {
22
+ statSync(plugin.path);
23
+ } catch {
24
+ errorOut(`[plugin:${plugin.name}] file not found: ${plugin.path}`);
25
+ return null;
26
+ }
27
+
28
+ // 用 cache-busting query string 避免 stale cache
29
+ const url = pathToFileURL(resolvePath(plugin.path)).href;
30
+ const cacheBustUrl = `${url}?t=${Date.now()}`;
31
+
32
+ try {
33
+ const mod = await import(cacheBustUrl);
34
+ const instance: UpstreamPlugin = mod.default ?? mod;
35
+ if (!instance.name) {
36
+ instance.name = plugin.name;
37
+ }
38
+ moduleCache.set(plugin.path, instance);
39
+ info(`[plugin:${plugin.name}] loaded from ${plugin.path}`);
40
+ return instance;
41
+ } catch (e) {
42
+ errorOut(`[plugin:${plugin.name}] failed to import: ${(e as Error).message}`);
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** 清空缓存(用户在 CLI 里 disable 插件时调用) */
48
+ export function unloadPlugin(path: string): void {
49
+ moduleCache.delete(path);
50
+ }
51
+
52
+ /** 清空所有缓存(daemon 重新加载配置时用) */
53
+ export function clearPluginCache(): void {
54
+ moduleCache.clear();
55
+ }
@@ -0,0 +1,3 @@
1
+ // 占位:v1 不做沙箱隔离,插件 = 任意 JS
2
+ // 未来如果需要 Worker 隔离,在这里实现
3
+ export {};
@@ -0,0 +1,167 @@
1
+ // ============================================================================
2
+ // Vendor 预设:从 cc-switch 三个 preset 文件人工筛选 + 翻译成 cctra 精简版
3
+ // ============================================================================
4
+ //
5
+ // 数据来源(vendor 名称 & endpoint URL):
6
+ // cc-switch (MIT, Copyright (c) 2025 Jason Young)
7
+ // https://github.com/farion1231/cc-switch
8
+ // - src/config/claudeProviderPresets.ts (Anthropic 格式)
9
+ // - src/config/codexProviderPresets.ts (OpenAI Chat / Responses)
10
+ // - src/config/geminiProviderPresets.ts (OpenAI Responses 兼容)
11
+ //
12
+ // cctra preset 设计:
13
+ // - 一个 vendor 可以有多个协议端点(如 Ark 同时支持 Anthropic + OpenAI Chat)
14
+ // - add wizard 选中 preset 后,下一步协议选择只显示该 preset 支持的协议
15
+ // - 「不使用供应商」才允许 3 种协议全选
16
+ // ============================================================================
17
+
18
+ import type { ApiFormat } from "../canonical/types";
19
+
20
+ export type ProviderEndpoints = Partial<Record<ApiFormat, string>>;
21
+
22
+ export interface ProviderPreset {
23
+ name: string; // 显示名
24
+ endpoints: ProviderEndpoints; // 每个协议对应的 base URL
25
+ notes?: string; // 特殊处理备注
26
+ }
27
+
28
+ export const API_FORMAT_LABELS: Record<ApiFormat, string> = {
29
+ "openai-chat": "OpenAI Chat Completions",
30
+ "openai-responses": "OpenAI Responses",
31
+ "anthropic-messages": "Anthropic Messages",
32
+ };
33
+
34
+ // ============================================================================
35
+ // Provider 预设(从 cc-switch 精确抄录,一个 vendor 可声明多个协议端点)
36
+ // ============================================================================
37
+ export const providerPresets: ProviderPreset[] = [
38
+ // ---- Anthropic + OpenAI Chat ----
39
+ { name: "Ark Agent Plan", endpoints: { "anthropic-messages": "https://ark.cn-beijing.volces.com/api/plan", "openai-chat": "https://ark.cn-beijing.volces.com/api/plan/v3" } },
40
+ { name: "Ark Coding Plan", endpoints: { "anthropic-messages": "https://ark.cn-beijing.volces.com/api/coding", "openai-chat": "https://ark.cn-beijing.volces.com/api/coding/v3" } },
41
+ { name: "Shengsuanyun", endpoints: { "anthropic-messages": "https://router.shengsuanyun.com/api", "openai-chat": "https://router.shengsuanyun.com/api/v1" } },
42
+ { name: "PatewayAI", endpoints: { "anthropic-messages": "https://api.pateway.ai", "openai-chat": "https://api.pateway.ai/v1" } },
43
+ { name: "BytePlus", endpoints: { "anthropic-messages": "https://ark.ap-southeast.bytepluses.com/api/coding", "openai-chat": "https://ark.ap-southeast.bytepluses.com/api/coding/v3" } },
44
+ { name: "DouBaoSeed", endpoints: { "anthropic-messages": "https://ark.cn-beijing.volces.com/api/compatible", "openai-chat": "https://ark.cn-beijing.volces.com/api/v3" } },
45
+ { name: "DeepSeek", endpoints: { "anthropic-messages": "https://api.deepseek.com/anthropic", "openai-chat": "https://api.deepseek.com" } },
46
+ { name: "Zhipu GLM", endpoints: { "anthropic-messages": "https://open.bigmodel.cn/api/anthropic", "openai-chat": "https://open.bigmodel.cn/api/coding/paas/v4" } },
47
+ { name: "Zhipu GLM en", endpoints: { "anthropic-messages": "https://api.z.ai/api/anthropic", "openai-chat": "https://api.z.ai/api/coding/paas/v4" } },
48
+ { name: "Baidu Qianfan Coding Plan", endpoints: { "anthropic-messages": "https://qianfan.baidubce.com/anthropic/coding", "openai-chat": "https://qianfan.baidubce.com/v2/coding" } },
49
+ { name: "Bailian", endpoints: { "anthropic-messages": "https://dashscope.aliyuncs.com/apps/anthropic", "openai-chat": "https://dashscope.aliyuncs.com/compatible-mode/v1" } },
50
+ { name: "Kimi", endpoints: { "anthropic-messages": "https://api.moonshot.cn/anthropic", "openai-chat": "https://api.moonshot.cn/v1" } },
51
+ { name: "StepFun", endpoints: { "anthropic-messages": "https://api.stepfun.com/step_plan", "openai-chat": "https://api.stepfun.com/step_plan/v1" } },
52
+ { name: "StepFun en", endpoints: { "anthropic-messages": "https://api.stepfun.ai/step_plan", "openai-chat": "https://api.stepfun.ai/step_plan/v1" } },
53
+ { name: "ModelScope", endpoints: { "anthropic-messages": "https://api-inference.modelscope.cn", "openai-chat": "https://api-inference.modelscope.cn/v1" } },
54
+ { name: "Longcat", endpoints: { "anthropic-messages": "https://api.longcat.chat/anthropic", "openai-chat": "https://api.longcat.chat/openai/v1" } },
55
+ { name: "MiniMax", endpoints: { "anthropic-messages": "https://api.minimaxi.com/anthropic", "openai-chat": "https://api.minimaxi.com/v1" } },
56
+ { name: "MiniMax en", endpoints: { "anthropic-messages": "https://api.minimax.io/anthropic", "openai-chat": "https://api.minimax.io/v1" } },
57
+ { name: "BaiLing", endpoints: { "anthropic-messages": "https://api.tbox.cn/api/anthropic", "openai-chat": "https://api.tbox.cn/api/llm/v1" } },
58
+ { name: "Xiaomi MiMo", endpoints: { "anthropic-messages": "https://api.xiaomimimo.com/anthropic", "openai-chat": "https://api.xiaomimimo.com/v1" } },
59
+ { name: "Xiaomi MiMo Token Plan (China)", endpoints: { "anthropic-messages": "https://token-plan-cn.xiaomimimo.com/anthropic", "openai-chat": "https://token-plan-cn.xiaomimimo.com/v1" } },
60
+ { name: "SiliconFlow", endpoints: { "anthropic-messages": "https://api.siliconflow.cn", "openai-chat": "https://api.siliconflow.cn/v1" } },
61
+ { name: "SiliconFlow en", endpoints: { "anthropic-messages": "https://api.siliconflow.com", "openai-chat": "https://api.siliconflow.com/v1" } },
62
+ { name: "Novita AI", endpoints: { "anthropic-messages": "https://api.novita.ai/anthropic", "openai-chat": "https://api.novita.ai/openai/v1" } },
63
+ { name: "Nvidia NIM", endpoints: { "anthropic-messages": "https://integrate.api.nvidia.com", "openai-chat": "https://integrate.api.nvidia.com/v1" } },
64
+ { name: "AiHubMix", endpoints: { "anthropic-messages": "https://aihubmix.com", "openai-chat": "https://aihubmix.com/v1" }, notes: "ANTHROPIC_API_KEY header(不是 ANTHROPIC_AUTH_TOKEN)" },
65
+ { name: "CherryIN", endpoints: { "anthropic-messages": "https://open.cherryin.net", "openai-chat": "https://open.cherryin.net/v1" } },
66
+ { name: "DMXAPI", endpoints: { "anthropic-messages": "https://www.dmxapi.cn", "openai-chat": "https://www.dmxapi.cn/v1" } },
67
+ { name: "PackyCode", endpoints: { "anthropic-messages": "https://www.packyapi.com", "openai-chat": "https://www.packyapi.com/v1" } },
68
+ { name: "AtlasCloud", endpoints: { "anthropic-messages": "https://api.atlascloud.ai", "openai-chat": "https://api.atlascloud.ai/v1" } },
69
+ { name: "ClaudeCN", endpoints: { "anthropic-messages": "https://claudecn.top", "openai-chat": "https://claudecn.top/v1" } },
70
+ { name: "RunAPI", endpoints: { "anthropic-messages": "https://runapi.co", "openai-chat": "https://runapi.co/v1" } },
71
+ { name: "RelaxyCode", endpoints: { "anthropic-messages": "https://www.relaxycode.com", "openai-chat": "https://www.relaxycode.com/v1" } },
72
+ { name: "Cubence", endpoints: { "anthropic-messages": "https://api.cubence.com", "openai-chat": "https://api.cubence.com/v1" } },
73
+ { name: "AIGoCode", endpoints: { "anthropic-messages": "https://api.aigocode.com", "openai-chat": "https://api.aigocode.com" } },
74
+ { name: "RightCode", endpoints: { "anthropic-messages": "https://www.right.codes/claude", "openai-chat": "https://right.codes/codex/v1" } },
75
+ { name: "AICodeMirror", endpoints: { "anthropic-messages": "https://api.aicodemirror.com/api/claudecode", "openai-chat": "https://api.aicodemirror.com/api/codex/backend-api/codex" } },
76
+ { name: "CrazyRouter", endpoints: { "anthropic-messages": "https://cn.crazyrouter.com", "openai-chat": "https://cn.crazyrouter.com/v1" } },
77
+ { name: "SSSAiCode", endpoints: { "anthropic-messages": "https://node-hk.sssaicode.com/api", "openai-chat": "https://node-hk.sssaicode.com/api/v1" } },
78
+ { name: "Compshare", endpoints: { "anthropic-messages": "https://api.modelverse.cn", "openai-chat": "https://api.modelverse.cn/v1" } },
79
+ { name: "Compshare Coding Plan", endpoints: { "anthropic-messages": "https://cp.compshare.cn", "openai-chat": "https://cp.compshare.cn/v1" } },
80
+ { name: "Micu", endpoints: { "anthropic-messages": "https://www.micuapi.ai", "openai-chat": "https://www.micuapi.ai/v1" } },
81
+ { name: "CTok.ai", endpoints: { "anthropic-messages": "https://api.ctok.ai", "openai-chat": "https://api.ctok.ai/v1" } },
82
+ { name: "LemonData", endpoints: { "anthropic-messages": "https://api.lemondata.cc", "openai-chat": "https://api.lemondata.cc/v1" } },
83
+ { name: "OpenRouter", endpoints: { "anthropic-messages": "https://openrouter.ai/api", "openai-chat": "https://openrouter.ai/api/v1" } },
84
+ { name: "TheRouter", endpoints: { "anthropic-messages": "https://api.therouter.ai", "openai-chat": "https://api.therouter.ai/v1" } },
85
+
86
+ // ---- Anthropic + OpenAI Responses ----
87
+ { name: "APIKEY.FUN", endpoints: { "anthropic-messages": "https://api.apikey.fun", "openai-responses": "https://api.apikey.fun/v1" } },
88
+ { name: "APINebula", endpoints: { "anthropic-messages": "https://apinebula.com", "openai-responses": "https://apinebula.com/v1" } },
89
+ { name: "SudoCode", endpoints: { "anthropic-messages": "https://sudocode.us", "openai-responses": "https://sudocode.us/v1" } },
90
+ { name: "E-FlowCode", endpoints: { "anthropic-messages": "https://e-flowcode.cc", "openai-responses": "https://e-flowcode.cc/v1" } },
91
+ { name: "PIPELLM", endpoints: { "anthropic-messages": "https://cc-api.pipellm.ai", "openai-responses": "https://cc-api.pipellm.ai/v1" } },
92
+
93
+ // ---- Anthropic 独有(claudeProviderPresets 有但 codex 没有同名项)----
94
+ { name: "Bailian For Coding", endpoints: { "anthropic-messages": "https://coding.dashscope.aliyuncs.com/apps/anthropic" } },
95
+ { name: "Kimi For Coding", endpoints: { "anthropic-messages": "https://api.kimi.com/coding" } },
96
+ { name: "ClaudeAPI", endpoints: { "anthropic-messages": "https://gw.claudeapi.com" } },
97
+
98
+ // ---- OpenAI Chat 独有(codex 有但 claude 没有同名项)----
99
+ { name: "OpenCode Go", endpoints: { "openai-chat": "https://opencode.ai/zen/go" } },
100
+ { name: "OpenAI Official", endpoints: { "openai-chat": "https://api.openai.com/v1" } },
101
+ { name: "GitHub Copilot", endpoints: { "openai-chat": "https://api.githubcopilot.com" }, notes: "需要 OAuth(暂不支持 API key 登录)" },
102
+
103
+ // ---- OpenAI Responses 独有 ----
104
+ { name: "Codex (ChatGPT Plus/Pro)", endpoints: { "openai-responses": "https://chatgpt.com/backend-api/codex" }, notes: "需要 ChatGPT Plus/Pro OAuth token" },
105
+ { name: "Gemini OpenAI-Compat", endpoints: { "openai-responses": "https://generativelanguage.googleapis.com/v1beta/openai" }, notes: "Google Gemini 官方 OpenAI 兼容端点" },
106
+ { name: "OpenAI Responses (Official)", endpoints: { "openai-responses": "https://api.openai.com" } },
107
+ ];
108
+
109
+ /**
110
+ * 「不使用供应商」special entry(用于纯手输流程)
111
+ */
112
+ export const NO_VENDOR: ProviderPreset = {
113
+ name: "(不使用供应商)",
114
+ endpoints: {},
115
+ notes: "",
116
+ };
117
+
118
+ /**
119
+ * 获取供应商列表,第一项是「不使用供应商」
120
+ */
121
+ export function getVendorChoices(): ProviderPreset[] {
122
+ return [NO_VENDOR, ...providerPresets];
123
+ }
124
+
125
+ /**
126
+ * 获取 preset 支持的协议;自定义 preset 支持全部 3 种协议
127
+ */
128
+ export function getSupportedApiFormats(preset: ProviderPreset): ApiFormat[] {
129
+ if (preset === NO_VENDOR || preset.name === NO_VENDOR.name) {
130
+ return ["openai-chat", "openai-responses", "anthropic-messages"];
131
+ }
132
+ return (["anthropic-messages", "openai-chat", "openai-responses"] as const)
133
+ .filter((format) => Boolean(preset.endpoints[format]));
134
+ }
135
+
136
+ /**
137
+ * 获取协议对应 endpoint
138
+ */
139
+ export function getEndpointForFormat(preset: ProviderPreset, format: ApiFormat): string {
140
+ return preset.endpoints[format] ?? "";
141
+ }
142
+
143
+ /**
144
+ * 供应商选择列表 hint
145
+ */
146
+ export function getPresetHint(preset: ProviderPreset): string | undefined {
147
+ const formats = getSupportedApiFormats(preset);
148
+ if (formats.length === 0) return undefined;
149
+ const formatLabels = formats.map((format) => API_FORMAT_LABELS[format]).join(" / ");
150
+ const firstEndpoint = preset.endpoints[formats[0]!];
151
+ return firstEndpoint ? `${formatLabels}: ${firstEndpoint}` : formatLabels;
152
+ }
153
+
154
+ /**
155
+ * 根据供应商名生成 kebab-case profile 名称
156
+ * 例:
157
+ * "Ark Agent Plan" → "ark-agent-plan"
158
+ * "APIKEY.FUN" → "apikey-fun"
159
+ * "Xiaomi MiMo Token Plan (China)" → "xiaomi-mimo-token-plan-china"
160
+ */
161
+ export function generateProfileName(vendorName: string): string {
162
+ return vendorName
163
+ .toLowerCase()
164
+ .replace(/[^a-z0-9]+/g, "-") // 非字母数字 → 连字符
165
+ .replace(/-+/g, "-") // 合并连续连字符
166
+ .replace(/^-|-$/g, ""); // 去首尾连字符
167
+ }
@@ -0,0 +1,44 @@
1
+ // ============================================================================
2
+ // 上游响应解析:Anthropic Messages → CanonicalResponse
3
+ // ============================================================================
4
+ import type { CanonicalResponse, CanonicalContentBlock } from "../canonical/types";
5
+
6
+ interface AnthropicUpstreamResponse {
7
+ id?: string;
8
+ model?: string;
9
+ content?: Array<
10
+ | { type: "text"; text: string }
11
+ | { type: "tool_use"; id: string; name: string; input: unknown }
12
+ | { type: "thinking"; thinking: string; signature?: string }
13
+ >;
14
+ stop_reason?: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use";
15
+ usage?: {
16
+ input_tokens?: number;
17
+ output_tokens?: number;
18
+ cache_read_input_tokens?: number;
19
+ cache_creation_input_tokens?: number;
20
+ };
21
+ }
22
+
23
+ export function parseAnthropicUpstreamResponse(raw: unknown, model: string): CanonicalResponse {
24
+ const r = raw as AnthropicUpstreamResponse;
25
+ const blocks: CanonicalContentBlock[] = [];
26
+ for (const b of r.content ?? []) {
27
+ if (b.type === "text") blocks.push({ type: "text", text: b.text });
28
+ else if (b.type === "tool_use") blocks.push({ type: "tool_use", id: b.id, name: b.name, input: b.input });
29
+ else if (b.type === "thinking") blocks.push({ type: "thinking", thinking: b.thinking, signature: b.signature });
30
+ }
31
+
32
+ return {
33
+ id: r.id ?? `msg-${Date.now()}`,
34
+ model: r.model ?? model,
35
+ content: blocks,
36
+ stopReason: r.stop_reason ?? "end_turn",
37
+ usage: {
38
+ inputTokens: r.usage?.input_tokens ?? 0,
39
+ outputTokens: r.usage?.output_tokens ?? 0,
40
+ cacheReadTokens: r.usage?.cache_read_input_tokens,
41
+ cacheWriteTokens: r.usage?.cache_creation_input_tokens,
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,21 @@
1
+ // ============================================================================
2
+ // cancelableFetch:合并客户端 abort signal + 上游硬超时
3
+ // 客户端断网 / Ctrl+C → 立刻取消上游 fetch(不再浪费 token)
4
+ // ============================================================================
5
+
6
+ const DEFAULT_TIMEOUT_MS = 60_000 * 5; // 5 分钟(保持现有行为)
7
+
8
+ /**
9
+ * 用 AbortSignal.any 合并 clientSignal + timeout signal,注入 fetch
10
+ */
11
+ export function cancelableFetch(
12
+ url: string,
13
+ init: RequestInit,
14
+ clientSignal: AbortSignal | undefined,
15
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
16
+ ): Promise<Response> {
17
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
18
+ const signals: AbortSignal[] = clientSignal ? [clientSignal, timeoutSignal] : [timeoutSignal];
19
+ const merged = signals.length === 1 ? signals[0]! : AbortSignal.any(signals);
20
+ return fetch(url, { ...init, signal: merged });
21
+ }
@@ -0,0 +1,81 @@
1
+ // ============================================================================
2
+ // 上游响应解析:OpenAI Chat Completions → CanonicalResponse
3
+ // 跟 upstream.ts(orchestrator)配套使用
4
+ // ============================================================================
5
+ import type { CanonicalResponse, CanonicalContentBlock } from "../canonical/types";
6
+
7
+ interface ChatUpstreamResponse {
8
+ id?: string;
9
+ model?: string;
10
+ choices?: Array<{
11
+ index?: number;
12
+ message: {
13
+ role: "assistant";
14
+ content?: string | null;
15
+ tool_calls?: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }>;
16
+ };
17
+ finish_reason?: string;
18
+ }>;
19
+ usage?: {
20
+ prompt_tokens?: number;
21
+ completion_tokens?: number;
22
+ };
23
+ }
24
+
25
+ export function parseChatUpstreamResponse(raw: unknown, model: string): CanonicalResponse {
26
+ const r = raw as ChatUpstreamResponse;
27
+ const choice = r.choices?.[0];
28
+ if (!choice) {
29
+ return makeErrorResponse(model, "no_choices", { type: "parse_error" });
30
+ }
31
+
32
+ const blocks: CanonicalContentBlock[] = [];
33
+ if (choice.message.content) {
34
+ blocks.push({ type: "text", text: choice.message.content });
35
+ }
36
+ if (choice.message.tool_calls) {
37
+ for (const tc of choice.message.tool_calls) {
38
+ let input: unknown = {};
39
+ try { input = JSON.parse(tc.function.arguments); } catch { /* keep empty */ }
40
+ blocks.push({ type: "tool_use", id: tc.id, name: tc.function.name, input });
41
+ }
42
+ }
43
+
44
+ return {
45
+ id: r.id ?? `chatcmpl-${Date.now()}`,
46
+ model: r.model ?? model,
47
+ content: blocks,
48
+ stopReason: mapStopReason(choice.finish_reason),
49
+ usage: {
50
+ inputTokens: r.usage?.prompt_tokens ?? 0,
51
+ outputTokens: r.usage?.completion_tokens ?? 0,
52
+ },
53
+ };
54
+ }
55
+
56
+ function mapStopReason(r: string | undefined): CanonicalResponse["stopReason"] {
57
+ switch (r) {
58
+ case "stop": return "end_turn";
59
+ case "length": return "max_tokens";
60
+ case "tool_calls": return "tool_use";
61
+ case "content_filter": return "error";
62
+ default: return "end_turn";
63
+ }
64
+ }
65
+
66
+ function makeErrorResponse(
67
+ model: string,
68
+ reason: string,
69
+ opts?: { type?: "parse_error" },
70
+ ): CanonicalResponse {
71
+ const message = `Upstream returned no valid response: ${reason}`;
72
+ const base: CanonicalResponse = {
73
+ id: `error-${Date.now()}`,
74
+ model,
75
+ content: [{ type: "text", text: message }],
76
+ stopReason: "error",
77
+ usage: { inputTokens: 0, outputTokens: 0 },
78
+ };
79
+ if (!opts?.type) return base;
80
+ return { ...base, error: { message, type: opts.type } };
81
+ }
@@ -0,0 +1,18 @@
1
+ // ============================================================================
2
+ // 错误响应 → HTTP status code 单一来源
3
+ // 避免 cc-switch 那种 `IntoResponse` + `map_proxy_error_to_status` 双轨 footgun
4
+ // ============================================================================
5
+ import type { CanonicalResponse } from "../canonical/types";
6
+
7
+ /**
8
+ * 从 CanonicalResponse 决定 HTTP status code。
9
+ * - 错误响应有 status 字段 → 透传上游
10
+ * - 错误响应无 status 字段(如 plugin/network/parse 错)→ 500
11
+ * - 非错误响应 → undefined(调用方用默认 200)
12
+ */
13
+ export function errorResponseToHttpStatus(
14
+ response: CanonicalResponse,
15
+ ): number | undefined {
16
+ if (!response.error) return undefined;
17
+ return response.error.status ?? 500;
18
+ }
@@ -0,0 +1,16 @@
1
+ // ============================================================================
2
+ // 错误信封:按客户端协议分别包装
3
+ // 上游错误 JSON 优先透传(status + body),代理本身失败才用代理信封
4
+ // ============================================================================
5
+
6
+ export function chatErrorBody(message: string, type = "cctra_error"): Record<string, unknown> {
7
+ return { error: { message, type } };
8
+ }
9
+
10
+ export function anthropicErrorBody(message: string, type = "cctra_error"): Record<string, unknown> {
11
+ return { type: "error", error: { type, message } };
12
+ }
13
+
14
+ export function responsesErrorBody(message: string, code = "cctra_error"): Record<string, unknown> {
15
+ return { error: { message, code } };
16
+ }
@@ -0,0 +1,94 @@
1
+ // ============================================================================
2
+ // POST /v1/chat/completions 处理器
3
+ // ============================================================================
4
+ import type { Config } from "../../types";
5
+ import { chatToCanonical } from "../../convert/inbound/chat-to-canonical";
6
+ import { callUpstream, callUpstreamStream, UpstreamError } from "../upstream";
7
+ import { canonicalToChatResponse } from "../../convert/outbound/canonical-to-chat";
8
+ import { chatErrorBody } from "../error";
9
+ import { errorResponseToHttpStatus } from "../error-status";
10
+ import { resolveRoute } from "../../core/routing";
11
+ import { wrapWithKeepalive } from "../keepalive";
12
+ import { loadConfigFile } from "../../core/config";
13
+ import { logger } from "../../utils/logger";
14
+
15
+ export async function handleChatCompletions(req: Request): Promise<Response> {
16
+ const config = loadConfigFile();
17
+ let body: unknown;
18
+ try {
19
+ body = await req.json();
20
+ } catch {
21
+ return Response.json(chatErrorBody("Invalid JSON body"), { status: 400 });
22
+ }
23
+ const b = body as { model?: string };
24
+ if (!b.model) {
25
+ return Response.json(chatErrorBody("Missing 'model' field"), { status: 400 });
26
+ }
27
+
28
+ let route;
29
+ try {
30
+ route = resolveRoute(b.model, config);
31
+ } catch (e) {
32
+ return Response.json(chatErrorBody((e as Error).message), { status: 400 });
33
+ }
34
+
35
+ const canonical = chatToCanonical(body as Parameters<typeof chatToCanonical>[0]);
36
+ // 用 route 的 upstreamModelId 覆盖(避免客户端用 alias 时传错)
37
+ canonical.model = route.upstreamModelId;
38
+
39
+ if (canonical.stream) {
40
+ try {
41
+ const { upstreamStream, parser, format } = await callUpstreamStream({
42
+ route,
43
+ canonical,
44
+ clientFormat: "openai-chat",
45
+ clientSignal: req.signal,
46
+ });
47
+ const cstream = parser(upstreamStream);
48
+ const encoder = new TextEncoder();
49
+ const inner = new ReadableStream<Uint8Array>({
50
+ async start(controller) {
51
+ try {
52
+ for await (const chunk of cstream) {
53
+ for (const s of format(chunk)) controller.enqueue(encoder.encode(s));
54
+ }
55
+ } catch (e) {
56
+ logger.error(`[chat-completions:stream] error: ${(e as Error).message}`);
57
+ if (e instanceof UpstreamError) {
58
+ // OpenAI Chat 错误 SSE 事件:data: {"error": {...}}
59
+ // 注意:发完 error 后**不再发 [DONE]**(cc-switch 二元化约束)
60
+ const errEvent = `data: ${JSON.stringify({
61
+ error: { message: e.message, type: "upstream_error", code: e.status },
62
+ })}\n\n`;
63
+ controller.enqueue(encoder.encode(errEvent));
64
+ }
65
+ } finally {
66
+ controller.close();
67
+ }
68
+ },
69
+ });
70
+ return new Response(wrapWithKeepalive(inner), {
71
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
72
+ });
73
+ } catch (e) {
74
+ return Response.json(chatErrorBody((e as Error).message), { status: 500 });
75
+ }
76
+ }
77
+
78
+ // 非流式
79
+ try {
80
+ const upstreamRes = await callUpstream({
81
+ route, canonical, clientFormat: "openai-chat", clientSignal: req.signal,
82
+ });
83
+ const body = canonicalToChatResponse(upstreamRes);
84
+ const httpStatus = errorResponseToHttpStatus(upstreamRes);
85
+ return httpStatus !== undefined
86
+ ? Response.json(body, { status: httpStatus })
87
+ : Response.json(body);
88
+ } catch (e) {
89
+ return Response.json(chatErrorBody((e as Error).message), { status: 500 });
90
+ }
91
+ }
92
+
93
+ // 抑制未用导入警告(config 未来会用)
94
+ void (null as unknown as Config);