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.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/bin/cctra +2 -0
- package/bin/cctra-daemon.exe +0 -0
- package/bin/cctra.js +2 -0
- package/examples/plugins/oauth-internal.js +46 -0
- package/examples/plugins/openai-compatible.js +27 -0
- package/package.json +53 -0
- package/src/canonical/types.ts +132 -0
- package/src/commands/add.ts +159 -0
- package/src/commands/daemon.ts +102 -0
- package/src/commands/ls.ts +49 -0
- package/src/commands/model.ts +95 -0
- package/src/commands/plugin.ts +167 -0
- package/src/commands/rename.ts +33 -0
- package/src/commands/rm.ts +37 -0
- package/src/commands/serve.ts +29 -0
- package/src/commands/shared.ts +14 -0
- package/src/commands/show.ts +46 -0
- package/src/commands/tier.ts +91 -0
- package/src/convert/common/content-blocks.ts +29 -0
- package/src/convert/common/extras.ts +38 -0
- package/src/convert/common/reasoning.ts +19 -0
- package/src/convert/common/system-prompt.ts +15 -0
- package/src/convert/common/tool-calls.ts +29 -0
- package/src/convert/common/usage.ts +19 -0
- package/src/convert/inbound/anthropic-to-canonical.ts +106 -0
- package/src/convert/inbound/chat-to-canonical.ts +132 -0
- package/src/convert/inbound/responses-to-canonical.ts +92 -0
- package/src/convert/outbound/canonical-to-anthropic.ts +62 -0
- package/src/convert/outbound/canonical-to-chat.ts +101 -0
- package/src/convert/outbound/canonical-to-responses.ts +105 -0
- package/src/convert/streaming/inbound/anthropic-stream.ts +14 -0
- package/src/convert/streaming/inbound/chat-stream.ts +219 -0
- package/src/convert/streaming/inbound/pick.ts +21 -0
- package/src/convert/streaming/inbound/responses-stream.ts +276 -0
- package/src/convert/streaming/outbound/format-anthropic.ts +19 -0
- package/src/convert/streaming/outbound/format-chat.ts +133 -0
- package/src/convert/streaming/outbound/format-responses.ts +184 -0
- package/src/convert/upstream/canonical-to-anthropic.ts +111 -0
- package/src/convert/upstream/canonical-to-chat.ts +115 -0
- package/src/convert/upstream/canonical-to-responses.ts +123 -0
- package/src/core/config.ts +156 -0
- package/src/core/model-fetch.ts +124 -0
- package/src/core/resolve.ts +73 -0
- package/src/core/routing.ts +31 -0
- package/src/core/source.ts +28 -0
- package/src/daemon/install.ts +47 -0
- package/src/daemon/platform/linux.ts +65 -0
- package/src/daemon/platform/macos.ts +71 -0
- package/src/daemon/platform/windows.ts +70 -0
- package/src/daemon/start.ts +22 -0
- package/src/daemon/status.ts +19 -0
- package/src/daemon/stop.ts +58 -0
- package/src/index.ts +34 -0
- package/src/plugin/contract.ts +51 -0
- package/src/plugin/host.ts +27 -0
- package/src/plugin/loader.ts +55 -0
- package/src/plugin/sandbox.ts +3 -0
- package/src/providers/presets.ts +167 -0
- package/src/server/anthropic-parser.ts +44 -0
- package/src/server/cancelable-fetch.ts +21 -0
- package/src/server/chat-parser.ts +81 -0
- package/src/server/error-status.ts +18 -0
- package/src/server/error.ts +16 -0
- package/src/server/handlers/chat-completions.ts +94 -0
- package/src/server/handlers/messages.ts +89 -0
- package/src/server/handlers/models.ts +35 -0
- package/src/server/handlers/responses.ts +89 -0
- package/src/server/keepalive.ts +63 -0
- package/src/server/responses-parser.ts +62 -0
- package/src/server/serve.ts +79 -0
- package/src/server/sse.ts +61 -0
- package/src/server/upstream.ts +251 -0
- package/src/tier/builtin.ts +9 -0
- package/src/tier/resolve.ts +33 -0
- package/src/tier/store.ts +3 -0
- package/src/types.ts +94 -0
- package/src/ui/format.ts +44 -0
- package/src/ui/prompts.ts +34 -0
- package/src/utils/fuzzy.ts +48 -0
- package/src/utils/logger.ts +32 -0
- package/src/utils/paths.ts +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cha133
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# cctra
|
|
2
|
+
|
|
3
|
+
> Local LLM subscription protocol converter + plugin host
|
|
4
|
+
|
|
5
|
+
`cctra` runs a daemon on `127.0.0.1:3133` that translates between **OpenAI Chat Completions / OpenAI Responses / Anthropic Messages**, with a **tier-based model aliasing** system and **local-path plugin** support for non-standard upstream auth (OAuth, mTLS, etc.).
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# install (once)
|
|
11
|
+
bun add -g cctra
|
|
12
|
+
# or npm i -g cctra
|
|
13
|
+
|
|
14
|
+
# add a subscription (interactive)
|
|
15
|
+
cctra add
|
|
16
|
+
|
|
17
|
+
# start the daemon (foreground)
|
|
18
|
+
cctra serve
|
|
19
|
+
|
|
20
|
+
# or install as system startup item (Windows / macOS / Linux)
|
|
21
|
+
cctra daemon install
|
|
22
|
+
cctra daemon start
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Endpoints
|
|
26
|
+
|
|
27
|
+
cctra exposes exactly **3 protocol endpoints** on `127.0.0.1:3133`:
|
|
28
|
+
|
|
29
|
+
| Protocol | Path |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Anthropic Messages | `POST /anthropic/v1/messages` |
|
|
32
|
+
| OpenAI Chat Completions | `POST /v1/chat/completions` |
|
|
33
|
+
| OpenAI Responses | `POST /v1/responses` |
|
|
34
|
+
| OpenAI Models | `GET /v1/models` |
|
|
35
|
+
| Health | `GET /healthz` |
|
|
36
|
+
|
|
37
|
+
### Client configuration
|
|
38
|
+
|
|
39
|
+
⚠️ **baseURL must include the protocol namespace prefix**:
|
|
40
|
+
|
|
41
|
+
| Client | baseURL |
|
|
42
|
+
|---|---|
|
|
43
|
+
| Claude Code | `http://127.0.0.1:3133/anthropic` |
|
|
44
|
+
| OpenAI SDK / Codex / Cursor | `http://127.0.0.1:3133/v1` |
|
|
45
|
+
|
|
46
|
+
## Tier aliases
|
|
47
|
+
|
|
48
|
+
cctra ships 4 built-in semantic tier names that you map to concrete `(subscription, model)` pairs:
|
|
49
|
+
|
|
50
|
+
| Tier | Purpose |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `cctra` | Default (medium quality, cheap) |
|
|
53
|
+
| `cctra-pro` | Deep reasoning (slow but strong) |
|
|
54
|
+
| `cctra-flash` | High speed (small & fast) |
|
|
55
|
+
| `cctra-vision` | Multimodal |
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# map cctra-pro to your actual deepseek model
|
|
59
|
+
cctra tier set cctra-pro ark-agent-plan/deepseek-v4-pro
|
|
60
|
+
|
|
61
|
+
# now configure Claude Code to use it (won't change when you switch models)
|
|
62
|
+
# ANTHROPIC_MODEL=cctra-pro
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Plugin system
|
|
66
|
+
|
|
67
|
+
Add custom JS plugins for non-standard upstream auth:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cctra plugin add my-internal /path/to/my-internal.js
|
|
71
|
+
cctra plugin ls
|
|
72
|
+
cctra plugin enable my-internal
|
|
73
|
+
cctra plugin disable my-internal
|
|
74
|
+
cctra plugin rm my-internal
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A plugin exports:
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
export default {
|
|
81
|
+
name: "my-internal-llm",
|
|
82
|
+
displayName: "My Company LLM",
|
|
83
|
+
async getConfig(ctx) {
|
|
84
|
+
// OAuth / mTLS / custom header logic
|
|
85
|
+
return { baseUrl: "...", path: "/v1/chat/completions", apiFormat: "openai-chat", authHeader: { /* ... */ }, modelId: "..." };
|
|
86
|
+
},
|
|
87
|
+
async listModels(ctx) { return [{ id: "..." }, { id: "..." }]; },
|
|
88
|
+
};
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
See `examples/plugins/` for working examples.
|
|
92
|
+
|
|
93
|
+
## CLI
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
cctra add # interactive subscription wizard
|
|
97
|
+
cctra ls # list all sources
|
|
98
|
+
cctra show <name> # show details
|
|
99
|
+
cctra rm <name> # remove
|
|
100
|
+
cctra rename <old> <new> # rename
|
|
101
|
+
cctra model add <sub> # add model to a subscription
|
|
102
|
+
cctra model ls <sub> # list models
|
|
103
|
+
cctra model rm <sub> <m> # remove model
|
|
104
|
+
cctra model rename <sub> <m> <alias>
|
|
105
|
+
cctra plugin add <name> <path>
|
|
106
|
+
cctra plugin ls / show / enable / disable / rm
|
|
107
|
+
cctra tier set <name> <target>
|
|
108
|
+
cctra tier ls / show / rm
|
|
109
|
+
cctra daemon install / uninstall / start / stop / status
|
|
110
|
+
cctra serve [--port N] # foreground HTTP server
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Configuration
|
|
114
|
+
|
|
115
|
+
Persisted at `~/.cctra/config.toml` (TOML format, edited via CLI).
|
|
116
|
+
|
|
117
|
+
Plugin configs go in `~/.cctra/plugins/<name>/config.json`.
|
|
118
|
+
|
|
119
|
+
## Architecture
|
|
120
|
+
|
|
121
|
+
- `src/canonical/` — protocol-agnostic internal types
|
|
122
|
+
- `src/convert/` — bidirectional protocol conversions
|
|
123
|
+
- `src/server/` — Bun.serve() routes, upstream forwarding
|
|
124
|
+
- `src/plugin/` — local-path plugin loader + author contract
|
|
125
|
+
- `src/tier/` — 4 builtin tier system
|
|
126
|
+
- `src/daemon/` — cross-platform install (Windows registry / macOS LaunchAgent / Linux systemd)
|
|
127
|
+
- `launcher/` — tiny Rust .exe for Windows startup (hides console, registers in Task Manager)
|
|
128
|
+
|
|
129
|
+
## Credits
|
|
130
|
+
|
|
131
|
+
The vendor preset list (`src/providers/presets.ts`) — provider names and endpoint URLs — is derived from [cc-switch](https://github.com/farion1231/cc-switch) (MIT, Copyright (c) 2025 Jason Young). Thanks to Jason and the cc-switch contributors for maintaining this comprehensive registry.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT — see [LICENSE](LICENSE).
|
package/bin/cctra
ADDED
|
Binary file
|
package/bin/cctra.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 示例插件:内部公司服务,OAuth 鉴权
|
|
3
|
+
// 演示 token 缓存、refresh、ctx.fetch 用法
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
name: "oauth-internal",
|
|
8
|
+
displayName: "Internal OAuth Service",
|
|
9
|
+
|
|
10
|
+
async getConfig(ctx) {
|
|
11
|
+
const { clientId, clientSecret, baseUrl, modelIds, workspaceId } = ctx.config;
|
|
12
|
+
|
|
13
|
+
// 缓存 token(避免每次请求都重新拿)
|
|
14
|
+
let tokenData = await ctx.cacheGet("oauth-token");
|
|
15
|
+
if (!tokenData) {
|
|
16
|
+
tokenData = await refreshToken(clientId, clientSecret);
|
|
17
|
+
// token 提前 60s 过期,留缓冲
|
|
18
|
+
await ctx.cacheSet("oauth-token", tokenData, (tokenData.expires_in - 60) * 1000);
|
|
19
|
+
ctx.logger(`refreshed OAuth token, expires in ${tokenData.expires_in}s`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return modelIds.map((id) => ({
|
|
23
|
+
baseUrl: baseUrl.replace(/\/+$/, ""),
|
|
24
|
+
path: "/v1/chat/completions",
|
|
25
|
+
apiFormat: "openai-chat",
|
|
26
|
+
authHeader: {
|
|
27
|
+
Authorization: `Bearer ${tokenData.access_token}`,
|
|
28
|
+
"X-Workspace-Id": workspaceId,
|
|
29
|
+
},
|
|
30
|
+
modelId: id,
|
|
31
|
+
}));
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async listModels(ctx) {
|
|
35
|
+
return ctx.config.modelIds.map((id) => ({ id }));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
async function refreshToken(_clientId, _clientSecret) {
|
|
40
|
+
// v1 示例:实际应调用 OAuth 端点
|
|
41
|
+
// 这里模拟一个 token
|
|
42
|
+
return {
|
|
43
|
+
access_token: "simulated-token-" + Date.now(),
|
|
44
|
+
expires_in: 3600,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 示例插件:标准 OpenAI 兼容端点(无鉴权)
|
|
3
|
+
// 复制到 ~/.cctra/plugins/openai-compatible.js 后用 `cctra plugin add` 注册
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
name: "openai-compatible",
|
|
8
|
+
displayName: "OpenAI-Compatible Endpoint",
|
|
9
|
+
|
|
10
|
+
async getConfig(ctx) {
|
|
11
|
+
// 用户填的 config(来自 cctra plugin add 时输入)
|
|
12
|
+
const { baseUrl, token, modelIds } = ctx.config;
|
|
13
|
+
|
|
14
|
+
return modelIds.map((id) => ({
|
|
15
|
+
baseUrl: baseUrl.replace(/\/+$/, ""),
|
|
16
|
+
path: "/v1/chat/completions",
|
|
17
|
+
apiFormat: "openai-chat",
|
|
18
|
+
authHeader: token ? { Authorization: `Bearer ${token}` } : {},
|
|
19
|
+
modelId: id,
|
|
20
|
+
}));
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
async listModels(ctx) {
|
|
24
|
+
const { modelIds } = ctx.config;
|
|
25
|
+
return modelIds.map((id) => ({ id, alias: id }));
|
|
26
|
+
},
|
|
27
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cctra",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Local LLM subscription protocol converter + plugin host. Run a daemon on 127.0.0.1:3133 that translates between OpenAI Chat / OpenAI Responses / Anthropic Messages, with a tier-based model aliasing system and local-path plugin support.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cctra": "bin/cctra.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"examples/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"dev": "bun run src/index.ts",
|
|
18
|
+
"serve": "bun run src/index.ts serve",
|
|
19
|
+
"test": "bun test",
|
|
20
|
+
"typecheck": "bunx tsc --noEmit",
|
|
21
|
+
"verify": "bunx tsc --noEmit && bun test",
|
|
22
|
+
"publish": "pwsh -ExecutionPolicy Bypass -File scripts/publish.ps1"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.0.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"llm",
|
|
29
|
+
"proxy",
|
|
30
|
+
"openai",
|
|
31
|
+
"anthropic",
|
|
32
|
+
"claude-code",
|
|
33
|
+
"codex",
|
|
34
|
+
"translation",
|
|
35
|
+
"plugin"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"author": "cha133",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/cha133/cctra.git"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@clack/prompts": "^1.5.0",
|
|
45
|
+
"commander": "^15.0.0",
|
|
46
|
+
"confbox": "^0.2.4",
|
|
47
|
+
"picocolors": "^1.1.1"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/bun": "latest",
|
|
51
|
+
"typescript": "^5"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Canonical 内部表示:与具体协议解耦的统一数据结构
|
|
3
|
+
// shape 接近 Anthropic Messages(因为它表达能力最丰富)
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
export type ApiFormat = "openai-chat" | "openai-responses" | "anthropic-messages";
|
|
7
|
+
|
|
8
|
+
export type StopReason = "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "error";
|
|
9
|
+
|
|
10
|
+
// ---------- Per-protocol extras(透传未识别字段) ----------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 按协议分类的「未识别字段」桶。
|
|
14
|
+
* - inbound 时把协议原请求中未识别的字段塞进对应协议桶
|
|
15
|
+
* - outbound 时按目标协议把对应桶 spread 进结果对象
|
|
16
|
+
* 例:anthropic → canonical → anthropic 链路里 cache_control 字段不会丢失
|
|
17
|
+
*/
|
|
18
|
+
export interface ProtocolExtras {
|
|
19
|
+
anthropic?: Record<string, unknown>;
|
|
20
|
+
openaiChat?: Record<string, unknown>;
|
|
21
|
+
openaiResponses?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------- Request ----------
|
|
25
|
+
|
|
26
|
+
export interface CanonicalRequest {
|
|
27
|
+
model: string;
|
|
28
|
+
messages: CanonicalMessage[];
|
|
29
|
+
system?: string | CanonicalContentBlock[];
|
|
30
|
+
tools?: CanonicalTool[];
|
|
31
|
+
maxTokens?: number;
|
|
32
|
+
temperature?: number;
|
|
33
|
+
topP?: number;
|
|
34
|
+
stopSequences?: string[];
|
|
35
|
+
stream: boolean;
|
|
36
|
+
metadata?: Record<string, unknown>;
|
|
37
|
+
// OpenAI Responses 多轮链路 ID(cctra 仅做透传,不维护链路状态)
|
|
38
|
+
previousResponseId?: string;
|
|
39
|
+
// 思考强度(OpenAI Responses `reasoning.effort` / Anthropic `thinking.budget_tokens` 的统一抽象)
|
|
40
|
+
reasoning?: { effort?: "low" | "medium" | "high" };
|
|
41
|
+
// 透传未识别字段(按协议分类)
|
|
42
|
+
extras?: ProtocolExtras;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CanonicalMessage {
|
|
46
|
+
role: "user" | "assistant";
|
|
47
|
+
content: CanonicalContentBlock[];
|
|
48
|
+
extras?: ProtocolExtras;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type CanonicalContentBlock = (
|
|
52
|
+
| { type: "text"; text: string }
|
|
53
|
+
| { type: "image"; source: ImageSource }
|
|
54
|
+
| { type: "document"; source: DocumentSource }
|
|
55
|
+
| { type: "tool_use"; id: string; name: string; input: unknown }
|
|
56
|
+
| { type: "tool_result"; toolUseId: string; content: string | CanonicalContentBlock[]; isError?: boolean }
|
|
57
|
+
| { type: "thinking"; thinking: string; signature?: string }
|
|
58
|
+
| { type: "refusal"; refusal: string }
|
|
59
|
+
) & { extras?: ProtocolExtras };
|
|
60
|
+
|
|
61
|
+
export interface ImageSource {
|
|
62
|
+
kind: "url" | "base64";
|
|
63
|
+
mediaType: string; // e.g. "image/png"
|
|
64
|
+
data: string; // URL string or base64-encoded data
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DocumentSource {
|
|
68
|
+
kind: "url" | "base64";
|
|
69
|
+
mediaType: string;
|
|
70
|
+
data: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CanonicalTool {
|
|
74
|
+
name: string;
|
|
75
|
+
description?: string;
|
|
76
|
+
inputSchema: Record<string, unknown>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------- Response ----------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 错误响应的结构化字段。
|
|
83
|
+
* - `message`:人类可读错误消息
|
|
84
|
+
* - `status`:上游 HTTP status code(4xx/5xx 透传用;plugin/network/parse 错无此字段)
|
|
85
|
+
* - `type`:错误分类标签
|
|
86
|
+
*/
|
|
87
|
+
export interface CanonicalResponseError {
|
|
88
|
+
message: string;
|
|
89
|
+
status?: number;
|
|
90
|
+
type?: "upstream_error" | "network_error" | "plugin_error" | "parse_error";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface CanonicalResponse {
|
|
94
|
+
id: string;
|
|
95
|
+
model: string;
|
|
96
|
+
content: CanonicalContentBlock[];
|
|
97
|
+
stopReason: StopReason;
|
|
98
|
+
usage: CanonicalUsage;
|
|
99
|
+
/** 错误响应的结构化字段(替代过去用 `content[0].text` + `stopReason: "error"` 表达错误) */
|
|
100
|
+
error?: CanonicalResponseError;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface CanonicalUsage {
|
|
104
|
+
inputTokens: number;
|
|
105
|
+
outputTokens: number;
|
|
106
|
+
cacheReadTokens?: number;
|
|
107
|
+
cacheWriteTokens?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------- Streaming Chunks ----------
|
|
111
|
+
// 字段跟 Anthropic SSE 几乎一致,方便直接复用 streaming 状态机
|
|
112
|
+
|
|
113
|
+
export type CanonicalChunk =
|
|
114
|
+
| { type: "message_start"; message: CanonicalResponse }
|
|
115
|
+
| { type: "content_block_start"; index: number; content_block: CanonicalContentBlock }
|
|
116
|
+
| { type: "content_block_delta"; index: number; delta: ContentBlockDelta }
|
|
117
|
+
| { type: "content_block_stop"; index: number }
|
|
118
|
+
| { type: "message_delta"; delta: MessageDelta; usage?: Partial<CanonicalUsage> }
|
|
119
|
+
| { type: "message_stop" }
|
|
120
|
+
| { type: "ping" }
|
|
121
|
+
| { type: "error"; error: string };
|
|
122
|
+
|
|
123
|
+
export type ContentBlockDelta =
|
|
124
|
+
| { type: "text_delta"; text: string }
|
|
125
|
+
| { type: "input_json_delta"; partial_json: string }
|
|
126
|
+
| { type: "thinking_delta"; thinking: string }
|
|
127
|
+
| { type: "signature_delta"; signature: string };
|
|
128
|
+
|
|
129
|
+
export interface MessageDelta {
|
|
130
|
+
stop_reason?: StopReason;
|
|
131
|
+
stop_sequence?: string;
|
|
132
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// cctra add:交互式添加订阅
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import * as p from "@clack/prompts";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { checkCancel } from "../ui/prompts";
|
|
7
|
+
import { success, error as errorOut, info } from "../ui/format";
|
|
8
|
+
import { withConfig } from "./shared";
|
|
9
|
+
import { addSubscription } from "../core/config";
|
|
10
|
+
import { fetchUpstreamModels } from "../core/model-fetch";
|
|
11
|
+
import {
|
|
12
|
+
API_FORMAT_LABELS,
|
|
13
|
+
getEndpointForFormat,
|
|
14
|
+
getPresetHint,
|
|
15
|
+
getSupportedApiFormats,
|
|
16
|
+
getVendorChoices,
|
|
17
|
+
generateProfileName,
|
|
18
|
+
NO_VENDOR,
|
|
19
|
+
type ProviderPreset,
|
|
20
|
+
} from "../providers/presets";
|
|
21
|
+
import type { Subscription, ApiFormat } from "../types";
|
|
22
|
+
|
|
23
|
+
export function registerAdd(program: Command): void {
|
|
24
|
+
program
|
|
25
|
+
.command("add")
|
|
26
|
+
.description("Interactively add a subscription")
|
|
27
|
+
.action(async () => {
|
|
28
|
+
try {
|
|
29
|
+
const sub = await promptNewSubscription();
|
|
30
|
+
withConfig((config) => addSubscription(config, sub));
|
|
31
|
+
success(`Added subscription "${sub.name}" with ${sub.models.length} model(s).`);
|
|
32
|
+
info(`Run \`cctra serve\` to start the daemon.`);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
if ((e as Error).message.includes("cancelled")) return;
|
|
35
|
+
errorOut((e as Error).message);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function promptNewSubscription(): Promise<Subscription> {
|
|
42
|
+
// 1. Vendor(可跳过 → 走纯手输)
|
|
43
|
+
const vendor = checkCancel(
|
|
44
|
+
await p.autocomplete<ProviderPreset>({
|
|
45
|
+
message: "Select a vendor (type to search, or pick '(不使用供应商)' for custom):",
|
|
46
|
+
options: getVendorChoices().map((v) => ({
|
|
47
|
+
value: v,
|
|
48
|
+
label: v.name,
|
|
49
|
+
hint: getPresetHint(v),
|
|
50
|
+
})),
|
|
51
|
+
placeholder: "Type to filter vendors...",
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
const isCustom = vendor.name === NO_VENDOR.name;
|
|
55
|
+
|
|
56
|
+
// 2. 名称(vendor 选中时自动从 vendor.name 生成)
|
|
57
|
+
const defaultName = isCustom ? "" : generateProfileName(vendor.name);
|
|
58
|
+
const name = checkCancel(
|
|
59
|
+
await p.text({
|
|
60
|
+
message: "Subscription name:",
|
|
61
|
+
initialValue: defaultName,
|
|
62
|
+
placeholder: "e.g. ark-agent-plan, deepseek",
|
|
63
|
+
validate: (v) => {
|
|
64
|
+
if (!v?.trim()) return "Name is required.";
|
|
65
|
+
const n = v.trim().toLowerCase();
|
|
66
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(n)) return 'Use kebab-case: lowercase letters, digits, hyphens.';
|
|
67
|
+
return undefined;
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// 3. 协议(vendor 选中时只显示该 preset 支持的协议)
|
|
73
|
+
const supportedFormats = getSupportedApiFormats(vendor);
|
|
74
|
+
const apiFormat = checkCancel(
|
|
75
|
+
await p.select<ApiFormat>({
|
|
76
|
+
message: "Upstream API format:",
|
|
77
|
+
initialValue: supportedFormats[0],
|
|
78
|
+
options: supportedFormats.map((format) => ({
|
|
79
|
+
value: format,
|
|
80
|
+
label: API_FORMAT_LABELS[format],
|
|
81
|
+
})),
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// 4. Endpoint(vendor 选中时按协议预填)
|
|
86
|
+
const endpoint = checkCancel(
|
|
87
|
+
await p.text({
|
|
88
|
+
message: "Endpoint URL (root, no /v1 suffix):",
|
|
89
|
+
initialValue: getEndpointForFormat(vendor, apiFormat),
|
|
90
|
+
placeholder: "e.g. https://ark.cn-beijing.volces.com/api/plan",
|
|
91
|
+
validate: (v) => (!v?.trim() ? "Endpoint is required." : undefined),
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// 4.5 提示 vendor 备注(如有)
|
|
96
|
+
if (vendor.notes && !isCustom) {
|
|
97
|
+
info(`Note: ${vendor.notes}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 5. Token
|
|
101
|
+
const token = checkCancel(
|
|
102
|
+
await p.password({
|
|
103
|
+
message: "API key / token:",
|
|
104
|
+
validate: (v) => (!v?.trim() ? "Token is required." : undefined),
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// 6. 拉模型列表
|
|
109
|
+
const s = p.spinner();
|
|
110
|
+
s.start("Fetching model list from upstream...");
|
|
111
|
+
let modelNames: string[] = [];
|
|
112
|
+
try {
|
|
113
|
+
modelNames = await fetchUpstreamModels({
|
|
114
|
+
endpoint: endpoint.trim(),
|
|
115
|
+
token: token.trim(),
|
|
116
|
+
apiFormat,
|
|
117
|
+
});
|
|
118
|
+
s.stop(`Found ${modelNames.length} model(s).`);
|
|
119
|
+
} catch {
|
|
120
|
+
s.stop("Failed to fetch models, will add manually.");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 7. 选模型
|
|
124
|
+
let selected: string[] = [];
|
|
125
|
+
if (modelNames.length > 0) {
|
|
126
|
+
const result = checkCancel(
|
|
127
|
+
await p.multiselect({
|
|
128
|
+
message: "Select models to add:",
|
|
129
|
+
options: modelNames.map((m) => ({ value: m, label: m })),
|
|
130
|
+
required: false,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
selected = result as string[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (selected.length === 0) {
|
|
137
|
+
// 手动输入
|
|
138
|
+
const manual = checkCancel(
|
|
139
|
+
await p.text({
|
|
140
|
+
message: "Enter model IDs (comma-separated):",
|
|
141
|
+
placeholder: "e.g. deepseek-v4-pro, claude-sonnet-4-6",
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
selected = manual.split(",").map((s) => s.trim()).filter(Boolean);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
kind: "subscription",
|
|
149
|
+
vendor: isCustom ? undefined : vendor.name,
|
|
150
|
+
name: name.trim().toLowerCase(),
|
|
151
|
+
endpoint: endpoint.trim(),
|
|
152
|
+
token: token.trim(),
|
|
153
|
+
apiFormat,
|
|
154
|
+
...(apiFormat === "openai-responses" ? { responsesPath: "/v1/responses" } : {}),
|
|
155
|
+
models: selected.map((id) => ({ id })),
|
|
156
|
+
createdAt: Date.now(),
|
|
157
|
+
updatedAt: Date.now(),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// cctra daemon <subcommand>:守护进程管理
|
|
3
|
+
// install / uninstall / start / stop / status
|
|
4
|
+
// ============================================================================
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { install, uninstall, isInstalled } from "../daemon/install";
|
|
7
|
+
import { checkDaemonStatus } from "../daemon/status";
|
|
8
|
+
import { startDaemon } from "../daemon/start";
|
|
9
|
+
import { stopDaemon } from "../daemon/stop";
|
|
10
|
+
import { success, error as errorOut, info, dim, green, red } from "../ui/format";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const DAEMON_ENTRYPOINT = join(__dirname, "..", "index.ts");
|
|
17
|
+
|
|
18
|
+
export function registerDaemon(program: Command): void {
|
|
19
|
+
const daemon = program.command("daemon").description("Manage the cctra daemon");
|
|
20
|
+
|
|
21
|
+
// cctra daemon install
|
|
22
|
+
daemon
|
|
23
|
+
.command("install")
|
|
24
|
+
.description("Install daemon as a system startup item")
|
|
25
|
+
.action(() => {
|
|
26
|
+
try {
|
|
27
|
+
const bundledLauncher = process.platform === "win32"
|
|
28
|
+
? join(__dirname, "..", "..", "bin", "cctra-daemon.exe")
|
|
29
|
+
: undefined;
|
|
30
|
+
if (process.platform === "win32" && (!bundledLauncher || !existsSync(bundledLauncher))) {
|
|
31
|
+
errorOut(`Bundled launcher not found: ${bundledLauncher}`);
|
|
32
|
+
errorOut("Build it first with scripts/build-launcher.ps1");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
install({ bundledLauncherPath: bundledLauncher, daemonEntrypoint: DAEMON_ENTRYPOINT });
|
|
36
|
+
success(`Installed cctra daemon for ${process.platform}.`);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
errorOut((e as Error).message);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// cctra daemon uninstall
|
|
44
|
+
daemon
|
|
45
|
+
.command("uninstall")
|
|
46
|
+
.description("Remove daemon from system startup")
|
|
47
|
+
.action(() => {
|
|
48
|
+
try {
|
|
49
|
+
uninstall();
|
|
50
|
+
success(`Uninstalled cctra daemon.`);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
errorOut((e as Error).message);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// cctra daemon start
|
|
58
|
+
daemon
|
|
59
|
+
.command("start")
|
|
60
|
+
.description("Start the daemon (background)")
|
|
61
|
+
.action(async () => {
|
|
62
|
+
try {
|
|
63
|
+
await startDaemon();
|
|
64
|
+
success(`Started.`);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
errorOut((e as Error).message);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// cctra daemon stop
|
|
72
|
+
daemon
|
|
73
|
+
.command("stop")
|
|
74
|
+
.description("Stop the daemon")
|
|
75
|
+
.action(async () => {
|
|
76
|
+
try {
|
|
77
|
+
await stopDaemon();
|
|
78
|
+
success(`Stopped.`);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
errorOut((e as Error).message);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// cctra daemon status
|
|
86
|
+
daemon
|
|
87
|
+
.command("status")
|
|
88
|
+
.description("Check daemon status")
|
|
89
|
+
.action(async () => {
|
|
90
|
+
const status = await checkDaemonStatus();
|
|
91
|
+
const installed = isInstalled();
|
|
92
|
+
const runningIcon = status.running ? green("✓ running") : red("✗ not running");
|
|
93
|
+
const installedIcon = installed ? green("✓ installed") : red("✗ not installed");
|
|
94
|
+
console.log(`Daemon: ${runningIcon}`);
|
|
95
|
+
console.log(`Startup: ${installedIcon}`);
|
|
96
|
+
console.log(`Port: ${status.port}`);
|
|
97
|
+
console.log(dim(`Health: http://127.0.0.1:${status.port}/healthz`));
|
|
98
|
+
if (!status.running && !installed) {
|
|
99
|
+
info(`Run \`cctra daemon install\` to register as a startup item, or \`cctra serve\` to run in foreground.`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|