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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// CanonicalChunk → OpenAI Responses SSE 流式输出格式化
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// 关键状态:
|
|
5
|
+
// - 每个 Canonical block 对应一个 Responses output_index(直接复用 index)
|
|
6
|
+
// - 关 stop 时按 block kind 分发:
|
|
7
|
+
// text → response.output_item.done
|
|
8
|
+
// tool_use → response.function_call_arguments.done + response.output_item.done
|
|
9
|
+
// thinking → response.reasoning_summary_part.done + response.output_item.done
|
|
10
|
+
// - signature_delta 丢(Responses 无对应)
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
import type { CanonicalChunk } from "../../../canonical/types";
|
|
14
|
+
|
|
15
|
+
type BlockKind = "text" | "tool_use" | "thinking";
|
|
16
|
+
|
|
17
|
+
interface BlockMeta {
|
|
18
|
+
kind: BlockKind;
|
|
19
|
+
id?: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ResponsesStreamFormatter {
|
|
24
|
+
private id = `resp_${Date.now()}`;
|
|
25
|
+
private model = "";
|
|
26
|
+
private blocks = new Map<number, BlockMeta>();
|
|
27
|
+
// 流中错已发 error event:抑制 response.completed + [DONE](避免"错 + 完成"矛盾信号)
|
|
28
|
+
private _streamEndedWithError = false;
|
|
29
|
+
|
|
30
|
+
format(chunk: CanonicalChunk): string[] {
|
|
31
|
+
switch (chunk.type) {
|
|
32
|
+
case "message_start": {
|
|
33
|
+
if (chunk.message.id) this.id = chunk.message.id;
|
|
34
|
+
if (chunk.message.model) this.model = chunk.message.model;
|
|
35
|
+
return [event({
|
|
36
|
+
type: "response.created",
|
|
37
|
+
response: {
|
|
38
|
+
id: this.id,
|
|
39
|
+
object: "response",
|
|
40
|
+
model: this.model,
|
|
41
|
+
status: "in_progress",
|
|
42
|
+
output: [],
|
|
43
|
+
},
|
|
44
|
+
})];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case "content_block_start": {
|
|
48
|
+
const block = chunk.content_block;
|
|
49
|
+
if (block.type === "text") {
|
|
50
|
+
this.blocks.set(chunk.index, { kind: "text" });
|
|
51
|
+
return [event({
|
|
52
|
+
type: "response.output_item.added",
|
|
53
|
+
output_index: chunk.index,
|
|
54
|
+
item: {
|
|
55
|
+
type: "message",
|
|
56
|
+
id: `msg_${chunk.index}`,
|
|
57
|
+
status: "in_progress",
|
|
58
|
+
role: "assistant",
|
|
59
|
+
content: [],
|
|
60
|
+
},
|
|
61
|
+
})];
|
|
62
|
+
}
|
|
63
|
+
if (block.type === "tool_use") {
|
|
64
|
+
this.blocks.set(chunk.index, { kind: "tool_use", id: block.id, name: block.name });
|
|
65
|
+
return [event({
|
|
66
|
+
type: "response.output_item.added",
|
|
67
|
+
output_index: chunk.index,
|
|
68
|
+
item: {
|
|
69
|
+
type: "function_call",
|
|
70
|
+
id: `fc_${chunk.index}`,
|
|
71
|
+
status: "in_progress",
|
|
72
|
+
call_id: block.id,
|
|
73
|
+
name: block.name,
|
|
74
|
+
arguments: "",
|
|
75
|
+
},
|
|
76
|
+
})];
|
|
77
|
+
}
|
|
78
|
+
if (block.type === "thinking") {
|
|
79
|
+
this.blocks.set(chunk.index, { kind: "thinking" });
|
|
80
|
+
return [
|
|
81
|
+
event({
|
|
82
|
+
type: "response.output_item.added",
|
|
83
|
+
output_index: chunk.index,
|
|
84
|
+
item: { type: "reasoning", id: `rs_${chunk.index}`, summary: [] },
|
|
85
|
+
}),
|
|
86
|
+
event({
|
|
87
|
+
type: "response.reasoning_summary_part.added",
|
|
88
|
+
output_index: chunk.index,
|
|
89
|
+
summary_index: 0,
|
|
90
|
+
part: { type: "summary_text", text: "" },
|
|
91
|
+
}),
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
// image / document / tool_result / refusal: 流式上下文很少出现,跳
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case "content_block_delta": {
|
|
99
|
+
if (chunk.delta.type === "text_delta") {
|
|
100
|
+
return [event({
|
|
101
|
+
type: "response.output_text.delta",
|
|
102
|
+
output_index: chunk.index,
|
|
103
|
+
delta: chunk.delta.text,
|
|
104
|
+
})];
|
|
105
|
+
}
|
|
106
|
+
if (chunk.delta.type === "input_json_delta") {
|
|
107
|
+
return [event({
|
|
108
|
+
type: "response.function_call_arguments.delta",
|
|
109
|
+
output_index: chunk.index,
|
|
110
|
+
delta: chunk.delta.partial_json,
|
|
111
|
+
})];
|
|
112
|
+
}
|
|
113
|
+
if (chunk.delta.type === "thinking_delta") {
|
|
114
|
+
return [event({
|
|
115
|
+
type: "response.reasoning_summary_text.delta",
|
|
116
|
+
output_index: chunk.index,
|
|
117
|
+
summary_index: 0,
|
|
118
|
+
delta: chunk.delta.thinking,
|
|
119
|
+
})];
|
|
120
|
+
}
|
|
121
|
+
// signature_delta → Responses 无对应,丢
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case "content_block_stop": {
|
|
126
|
+
const meta = this.blocks.get(chunk.index);
|
|
127
|
+
this.blocks.delete(chunk.index);
|
|
128
|
+
if (!meta) {
|
|
129
|
+
return [event({ type: "response.output_item.done", output_index: chunk.index })];
|
|
130
|
+
}
|
|
131
|
+
if (meta.kind === "tool_use") {
|
|
132
|
+
return [
|
|
133
|
+
event({
|
|
134
|
+
type: "response.function_call_arguments.done",
|
|
135
|
+
output_index: chunk.index,
|
|
136
|
+
}),
|
|
137
|
+
event({ type: "response.output_item.done", output_index: chunk.index }),
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
if (meta.kind === "thinking") {
|
|
141
|
+
return [
|
|
142
|
+
event({
|
|
143
|
+
type: "response.reasoning_summary_part.done",
|
|
144
|
+
output_index: chunk.index,
|
|
145
|
+
summary_index: 0,
|
|
146
|
+
}),
|
|
147
|
+
event({ type: "response.output_item.done", output_index: chunk.index }),
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
// text
|
|
151
|
+
return [event({ type: "response.output_item.done", output_index: chunk.index })];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case "message_delta":
|
|
155
|
+
// Responses 在 completed 一次性发 usage,中间不需要
|
|
156
|
+
return [];
|
|
157
|
+
|
|
158
|
+
case "message_stop": {
|
|
159
|
+
// 流中错时抑制 response.completed + [DONE](cc-switch 二元化约束)
|
|
160
|
+
if (this._streamEndedWithError) return [];
|
|
161
|
+
return [
|
|
162
|
+
event({
|
|
163
|
+
type: "response.completed",
|
|
164
|
+
response: { id: this.id, model: this.model, status: "completed" },
|
|
165
|
+
}),
|
|
166
|
+
"data: [DONE]\n\n",
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case "ping":
|
|
171
|
+
return [];
|
|
172
|
+
|
|
173
|
+
case "error": {
|
|
174
|
+
// 流中错:发 error event + 设抑制标志
|
|
175
|
+
this._streamEndedWithError = true;
|
|
176
|
+
return [event({ type: "response.error", error: { message: chunk.error } })];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function event(payload: Record<string, unknown>): string {
|
|
183
|
+
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
184
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Canonical → Anthropic Messages 请求(发给 Anthropic 上游用)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import type { CanonicalRequest, CanonicalContentBlock } from "../../canonical/types";
|
|
5
|
+
import { mergeExtras } from "../common/extras";
|
|
6
|
+
|
|
7
|
+
interface AnthropicUpstreamRequest {
|
|
8
|
+
model: string;
|
|
9
|
+
system?: string;
|
|
10
|
+
messages: Array<{
|
|
11
|
+
role: "user" | "assistant";
|
|
12
|
+
content: string | Array<AnthropicContentBlock>;
|
|
13
|
+
}>;
|
|
14
|
+
tools?: Array<{ name: string; description?: string; input_schema: Record<string, unknown> }>;
|
|
15
|
+
max_tokens?: number;
|
|
16
|
+
temperature?: number;
|
|
17
|
+
top_p?: number;
|
|
18
|
+
stop_sequences?: string[];
|
|
19
|
+
stream: boolean;
|
|
20
|
+
// 让 Anthropic thinking 模型按 effort 估算预算(粗略映射)
|
|
21
|
+
thinking?: { type: "enabled"; budget_tokens: number };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type AnthropicContentBlock =
|
|
25
|
+
| { type: "text"; text: string }
|
|
26
|
+
| { type: "image"; source: { type: "base64" | "url"; media_type: string; data?: string; url?: string } }
|
|
27
|
+
| { type: "document"; source: { type: "base64" | "url"; media_type: string; data?: string; url?: string } }
|
|
28
|
+
| { type: "tool_use"; id: string; name: string; input: unknown }
|
|
29
|
+
| { type: "tool_result"; tool_use_id: string; content: string; is_error?: boolean }
|
|
30
|
+
| { type: "thinking"; thinking: string; signature?: string };
|
|
31
|
+
|
|
32
|
+
export function canonicalToAnthropicUpstream(req: CanonicalRequest): AnthropicUpstreamRequest {
|
|
33
|
+
const messages: AnthropicUpstreamRequest["messages"] = req.messages.map((m) => {
|
|
34
|
+
const msgContent = m.content.length === 1 && m.content[0]?.type === "text"
|
|
35
|
+
? m.content[0].text
|
|
36
|
+
: m.content.map(blockToAnthropic);
|
|
37
|
+
return {
|
|
38
|
+
role: m.role,
|
|
39
|
+
content: msgContent,
|
|
40
|
+
// 透传 message 级别未识别字段
|
|
41
|
+
...(m.extras?.anthropic ?? {}),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let system: string | undefined;
|
|
46
|
+
if (typeof req.system === "string") system = req.system;
|
|
47
|
+
else if (Array.isArray(req.system)) system = req.system.map((b) => b.type === "text" ? b.text : "").join("");
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
model: req.model,
|
|
51
|
+
system,
|
|
52
|
+
messages,
|
|
53
|
+
tools: req.tools?.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema })),
|
|
54
|
+
max_tokens: req.maxTokens,
|
|
55
|
+
temperature: req.temperature,
|
|
56
|
+
top_p: req.topP,
|
|
57
|
+
stop_sequences: req.stopSequences,
|
|
58
|
+
stream: req.stream,
|
|
59
|
+
thinking: req.reasoning?.effort ? { type: "enabled", budget_tokens: effortToBudget(req.reasoning.effort) } : undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function effortToBudget(effort: "low" | "medium" | "high"): number {
|
|
64
|
+
switch (effort) {
|
|
65
|
+
case "low": return 1024;
|
|
66
|
+
case "medium": return 4096;
|
|
67
|
+
case "high": return 16_384;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function blockToAnthropic(b: CanonicalContentBlock): AnthropicContentBlock {
|
|
72
|
+
switch (b.type) {
|
|
73
|
+
case "text":
|
|
74
|
+
return mergeExtras({ type: "text", text: b.text } as AnthropicContentBlock, b.extras, "anthropic");
|
|
75
|
+
case "image": {
|
|
76
|
+
const src = b.source;
|
|
77
|
+
const built: AnthropicContentBlock = src.kind === "base64"
|
|
78
|
+
? { type: "image", source: { type: "base64", media_type: src.mediaType, data: src.data } }
|
|
79
|
+
: { type: "image", source: { type: "url", media_type: src.mediaType, url: src.data } };
|
|
80
|
+
return mergeExtras(built, b.extras, "anthropic");
|
|
81
|
+
}
|
|
82
|
+
case "document": {
|
|
83
|
+
const src = b.source;
|
|
84
|
+
const built: AnthropicContentBlock = src.kind === "base64"
|
|
85
|
+
? { type: "document", source: { type: "base64", media_type: src.mediaType, data: src.data } }
|
|
86
|
+
: { type: "document", source: { type: "url", media_type: src.mediaType, url: src.data } };
|
|
87
|
+
return mergeExtras(built, b.extras, "anthropic");
|
|
88
|
+
}
|
|
89
|
+
case "tool_use": {
|
|
90
|
+
const built: AnthropicContentBlock = { type: "tool_use", id: b.id, name: b.name, input: b.input };
|
|
91
|
+
return mergeExtras(built, b.extras, "anthropic");
|
|
92
|
+
}
|
|
93
|
+
case "tool_result": {
|
|
94
|
+
const built: AnthropicContentBlock = {
|
|
95
|
+
type: "tool_result",
|
|
96
|
+
tool_use_id: b.toolUseId,
|
|
97
|
+
content: typeof b.content === "string" ? b.content : b.content.map((cb) => cb.type === "text" ? cb.text : "").join(""),
|
|
98
|
+
is_error: b.isError,
|
|
99
|
+
};
|
|
100
|
+
return mergeExtras(built, b.extras, "anthropic");
|
|
101
|
+
}
|
|
102
|
+
case "thinking": {
|
|
103
|
+
// 原生 thinking block + 透传 signature(如果有)+ extras
|
|
104
|
+
const built: AnthropicContentBlock = { type: "thinking", thinking: b.thinking, ...(b.signature ? { signature: b.signature } : {}) };
|
|
105
|
+
return mergeExtras(built, b.extras, "anthropic");
|
|
106
|
+
}
|
|
107
|
+
case "refusal":
|
|
108
|
+
// Anthropic 无 refusal block;降级为加前缀的 text
|
|
109
|
+
return mergeExtras({ type: "text", text: `[refusal] ${b.refusal}` } as AnthropicContentBlock, b.extras, "anthropic");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Canonical → OpenAI Chat Completions 请求(发给上游用)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import type { CanonicalRequest, CanonicalContentBlock, ImageSource } from "../../canonical/types";
|
|
5
|
+
import { systemToString } from "../common/system-prompt";
|
|
6
|
+
import { mergeExtras } from "../common/extras";
|
|
7
|
+
|
|
8
|
+
type ChatContentPart =
|
|
9
|
+
| { type: "text"; text: string }
|
|
10
|
+
| { type: "image_url"; image_url: { url: string } };
|
|
11
|
+
|
|
12
|
+
interface ChatUpstreamRequest {
|
|
13
|
+
model: string;
|
|
14
|
+
messages: Array<{
|
|
15
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
16
|
+
content: string | null | ChatContentPart[];
|
|
17
|
+
tool_call_id?: string;
|
|
18
|
+
tool_calls?: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }>;
|
|
19
|
+
}>;
|
|
20
|
+
tools?: Array<{ type: "function"; function: { name: string; description?: string; parameters?: Record<string, unknown> } }>;
|
|
21
|
+
max_tokens?: number;
|
|
22
|
+
temperature?: number;
|
|
23
|
+
top_p?: number;
|
|
24
|
+
stop?: string[];
|
|
25
|
+
stream: boolean;
|
|
26
|
+
// 兼容 reasoning_effort(OpenAI o-series / 兼容上游可识别)
|
|
27
|
+
reasoning_effort?: "low" | "medium" | "high";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function canonicalToChatUpstream(req: CanonicalRequest): ChatUpstreamRequest {
|
|
31
|
+
const messages: ChatUpstreamRequest["messages"] = [];
|
|
32
|
+
|
|
33
|
+
// 顶级 system → 第一条 system message
|
|
34
|
+
const sysText = systemToString(req.system);
|
|
35
|
+
if (sysText) messages.push({ role: "system", content: sysText });
|
|
36
|
+
|
|
37
|
+
for (const m of req.messages) {
|
|
38
|
+
if (m.role === "user") {
|
|
39
|
+
const userBlocks = m.content;
|
|
40
|
+
// 检查是否全是 tool_result
|
|
41
|
+
const toolResults = userBlocks.filter((b) => b.type === "tool_result");
|
|
42
|
+
if (toolResults.length > 0 && toolResults.length === userBlocks.length) {
|
|
43
|
+
for (const tr of toolResults) {
|
|
44
|
+
if (tr.type === "tool_result") {
|
|
45
|
+
messages.push({
|
|
46
|
+
role: "tool",
|
|
47
|
+
content: typeof tr.content === "string" ? tr.content : "",
|
|
48
|
+
tool_call_id: tr.toolUseId,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} else if (userBlocks.some((b) => b.type === "image")) {
|
|
53
|
+
// 多模态:构造 OpenAI Chat content parts 数组
|
|
54
|
+
const parts: ChatContentPart[] = [];
|
|
55
|
+
for (const b of userBlocks) {
|
|
56
|
+
if (b.type === "text") {
|
|
57
|
+
parts.push({ type: "text", text: b.text });
|
|
58
|
+
} else if (b.type === "image") {
|
|
59
|
+
parts.push({ type: "image_url", image_url: { url: imageToDataUrl(b.source) } });
|
|
60
|
+
}
|
|
61
|
+
// document → OpenAI 兼容端点一般不支持,丢
|
|
62
|
+
}
|
|
63
|
+
messages.push({ role: "user", content: parts });
|
|
64
|
+
} else {
|
|
65
|
+
// 纯文本:合并成字符串
|
|
66
|
+
const text = extractText(userBlocks);
|
|
67
|
+
messages.push({ role: "user", content: text });
|
|
68
|
+
}
|
|
69
|
+
} else if (m.role === "assistant") {
|
|
70
|
+
const text = extractText(m.content);
|
|
71
|
+
const toolUses = m.content.filter((b): b is { type: "tool_use"; id: string; name: string; input: unknown } => b.type === "tool_use");
|
|
72
|
+
const msg: ChatUpstreamRequest["messages"][number] = { role: "assistant", content: text || null };
|
|
73
|
+
if (toolUses.length > 0) {
|
|
74
|
+
msg.tool_calls = toolUses.map((u) => ({
|
|
75
|
+
id: u.id,
|
|
76
|
+
type: "function",
|
|
77
|
+
function: {
|
|
78
|
+
name: u.name,
|
|
79
|
+
arguments: typeof u.input === "string" ? u.input : JSON.stringify(u.input ?? {}),
|
|
80
|
+
},
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
// 透传 assistant message 级别 extras(openaiChat 桶)
|
|
84
|
+
const merged = mergeExtras(msg as unknown as Record<string, unknown>, m.extras, "openaiChat");
|
|
85
|
+
messages.push(merged as unknown as ChatUpstreamRequest["messages"][number]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
model: req.model,
|
|
91
|
+
messages,
|
|
92
|
+
tools: req.tools?.map((t) => ({
|
|
93
|
+
type: "function",
|
|
94
|
+
function: { name: t.name, description: t.description, parameters: t.inputSchema },
|
|
95
|
+
})),
|
|
96
|
+
max_tokens: req.maxTokens,
|
|
97
|
+
temperature: req.temperature,
|
|
98
|
+
top_p: req.topP,
|
|
99
|
+
stop: req.stopSequences,
|
|
100
|
+
stream: req.stream,
|
|
101
|
+
reasoning_effort: req.reasoning?.effort,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function imageToDataUrl(src: ImageSource): string {
|
|
106
|
+
if (src.kind === "base64") return `data:${src.mediaType};base64,${src.data}`;
|
|
107
|
+
return src.data;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractText(blocks: CanonicalContentBlock[]): string {
|
|
111
|
+
return blocks
|
|
112
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
113
|
+
.map((b) => b.text)
|
|
114
|
+
.join("");
|
|
115
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Canonical → OpenAI Responses 请求(发给上游用)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import type { CanonicalRequest, CanonicalContentBlock, ImageSource } from "../../canonical/types";
|
|
5
|
+
import { systemToString } from "../common/system-prompt";
|
|
6
|
+
|
|
7
|
+
type ResponsesInputItem =
|
|
8
|
+
| { role: "user"; content: Array<{ type: "input_text"; text: string } | { type: "input_image"; image_url: string }> }
|
|
9
|
+
| { role: "assistant"; content: Array<{ type: "output_text"; text: string } | { type: "refusal"; refusal: string }> }
|
|
10
|
+
| { type: "function_call"; call_id: string; name: string; arguments: string }
|
|
11
|
+
| { type: "function_call_output"; call_id: string; output: string };
|
|
12
|
+
|
|
13
|
+
interface ResponsesTool {
|
|
14
|
+
type: "function";
|
|
15
|
+
name: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
parameters: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ResponsesUpstreamRequest {
|
|
21
|
+
model: string;
|
|
22
|
+
input: ResponsesInputItem[];
|
|
23
|
+
instructions?: string;
|
|
24
|
+
tools?: ResponsesTool[];
|
|
25
|
+
max_output_tokens?: number;
|
|
26
|
+
temperature?: number;
|
|
27
|
+
top_p?: number;
|
|
28
|
+
stream: boolean;
|
|
29
|
+
reasoning?: { effort: "low" | "medium" | "high" };
|
|
30
|
+
previous_response_id?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function canonicalToResponses(req: CanonicalRequest): ResponsesUpstreamRequest {
|
|
34
|
+
const input: ResponsesInputItem[] = [];
|
|
35
|
+
const instructions = systemToString(req.system);
|
|
36
|
+
|
|
37
|
+
for (const m of req.messages) {
|
|
38
|
+
// 抽 assistant tool_use 块 → 平级 function_call item(不是 message)
|
|
39
|
+
if (m.role === "assistant") {
|
|
40
|
+
const toolUses = m.content.filter((b): b is { type: "tool_use"; id: string; name: string; input: unknown } => b.type === "tool_use");
|
|
41
|
+
const otherBlocks = m.content.filter((b) => b.type !== "tool_use");
|
|
42
|
+
|
|
43
|
+
// assistant message(含 text / refusal)
|
|
44
|
+
if (otherBlocks.length > 0) {
|
|
45
|
+
const content: Array<{ type: "output_text"; text: string } | { type: "refusal"; refusal: string }> = [];
|
|
46
|
+
for (const b of otherBlocks) {
|
|
47
|
+
if (b.type === "text") content.push({ type: "output_text", text: b.text });
|
|
48
|
+
else if (b.type === "refusal") content.push({ type: "refusal", refusal: b.refusal });
|
|
49
|
+
// 忽略 thinking / image / document / tool_result 在 assistant message
|
|
50
|
+
}
|
|
51
|
+
if (content.length > 0) {
|
|
52
|
+
input.push({ role: "assistant", content });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// function_call item(每个 tool_use 一个)
|
|
57
|
+
for (const u of toolUses) {
|
|
58
|
+
input.push({
|
|
59
|
+
type: "function_call",
|
|
60
|
+
call_id: u.id,
|
|
61
|
+
name: u.name,
|
|
62
|
+
arguments: typeof u.input === "string" ? u.input : JSON.stringify(u.input ?? {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
// user 消息
|
|
67
|
+
const toolResults = m.content.filter((b): b is { type: "tool_result"; toolUseId: string; content: string | CanonicalContentBlock[]; isError?: boolean } => b.type === "tool_result");
|
|
68
|
+
const otherBlocks = m.content.filter((b) => b.type !== "tool_result");
|
|
69
|
+
|
|
70
|
+
// function_call_output item(每个 tool_result 一个)
|
|
71
|
+
for (const tr of toolResults) {
|
|
72
|
+
input.push({
|
|
73
|
+
type: "function_call_output",
|
|
74
|
+
call_id: tr.toolUseId,
|
|
75
|
+
output: typeof tr.content === "string" ? tr.content : extractTextFromBlocks(tr.content),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// user message(含 text / image)
|
|
80
|
+
if (otherBlocks.length > 0) {
|
|
81
|
+
const content: Array<{ type: "input_text"; text: string } | { type: "input_image"; image_url: string }> = [];
|
|
82
|
+
for (const b of otherBlocks) {
|
|
83
|
+
if (b.type === "text") content.push({ type: "input_text", text: b.text });
|
|
84
|
+
else if (b.type === "image") content.push({ type: "input_image", image_url: imageToDataUrl(b.source) });
|
|
85
|
+
// 忽略 document / thinking / refusal 在 user message
|
|
86
|
+
}
|
|
87
|
+
if (content.length > 0) {
|
|
88
|
+
input.push({ role: "user", content });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
model: req.model,
|
|
96
|
+
input,
|
|
97
|
+
...(instructions ? { instructions } : {}),
|
|
98
|
+
tools: req.tools?.map((t) => ({
|
|
99
|
+
type: "function" as const,
|
|
100
|
+
name: t.name,
|
|
101
|
+
description: t.description,
|
|
102
|
+
parameters: t.inputSchema,
|
|
103
|
+
})),
|
|
104
|
+
max_output_tokens: req.maxTokens,
|
|
105
|
+
temperature: req.temperature,
|
|
106
|
+
top_p: req.topP,
|
|
107
|
+
stream: req.stream,
|
|
108
|
+
...(req.reasoning?.effort ? { reasoning: { effort: req.reasoning.effort } } : {}),
|
|
109
|
+
...(req.previousResponseId ? { previous_response_id: req.previousResponseId } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function imageToDataUrl(src: ImageSource): string {
|
|
114
|
+
if (src.kind === "base64") return `data:${src.mediaType};base64,${src.data}`;
|
|
115
|
+
return src.data;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function extractTextFromBlocks(blocks: CanonicalContentBlock[]): string {
|
|
119
|
+
return blocks
|
|
120
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
121
|
+
.map((b) => b.text)
|
|
122
|
+
.join("");
|
|
123
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { parseTOML, stringifyTOML } from "confbox";
|
|
3
|
+
import { configTomlPath, ensureCctraDir } from "../utils/paths";
|
|
4
|
+
import { DEFAULT_CONFIG, type Config, type Subscription, type PluginConfig, type Tier } from "../types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 从 ~/.cctra/config.toml 加载配置
|
|
8
|
+
* 如果文件不存在或损坏,返回默认配置
|
|
9
|
+
*/
|
|
10
|
+
export function loadConfigFile(): Config {
|
|
11
|
+
const path = configTomlPath();
|
|
12
|
+
if (!existsSync(path)) {
|
|
13
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const content = readFileSync(path, "utf-8");
|
|
17
|
+
let data: Partial<Config>;
|
|
18
|
+
try {
|
|
19
|
+
data = parseTOML(content) as Partial<Config>;
|
|
20
|
+
} catch {
|
|
21
|
+
console.warn("⚠ ~/.cctra/config.toml 格式损坏,将按空配置处理");
|
|
22
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 合并默认结构
|
|
26
|
+
const config: Config = {
|
|
27
|
+
port: data.port ?? DEFAULT_CONFIG.port,
|
|
28
|
+
subscriptions: data.subscriptions ?? {},
|
|
29
|
+
plugins: data.plugins ?? {},
|
|
30
|
+
tiers: data.tiers ?? {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// 补回 4 个预定义 tier(如果用户删了)
|
|
34
|
+
for (const t of ["cctra", "cctra-pro", "cctra-flash", "cctra-vision"] as const) {
|
|
35
|
+
if (!config.tiers[t]) {
|
|
36
|
+
config.tiers[t] = {
|
|
37
|
+
name: t,
|
|
38
|
+
target: "",
|
|
39
|
+
description: DEFAULT_CONFIG.tiers[t]?.description,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 兜底:补 kind 字段(手动写的 config 可能漏了)
|
|
45
|
+
for (const sub of Object.values(config.subscriptions)) {
|
|
46
|
+
if (!sub.kind) sub.kind = "subscription";
|
|
47
|
+
}
|
|
48
|
+
for (const p of Object.values(config.plugins)) {
|
|
49
|
+
if (!p.kind) p.kind = "plugin";
|
|
50
|
+
if (p.enabled === undefined) p.enabled = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return config;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 把配置写到 ~/.cctra/config.toml */
|
|
57
|
+
export function saveConfigFile(config: Config): void {
|
|
58
|
+
ensureCctraDir();
|
|
59
|
+
const content = stringifyTOML(config as unknown as Record<string, unknown>);
|
|
60
|
+
writeFileSync(configTomlPath(), content, "utf-8");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Subscription CRUD
|
|
65
|
+
// ============================================================================
|
|
66
|
+
|
|
67
|
+
export function getAllSubscriptions(config: Config): Array<[string, Subscription]> {
|
|
68
|
+
return Object.entries(config.subscriptions);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getSubscription(config: Config, name: string): Subscription | null {
|
|
72
|
+
return config.subscriptions[name] ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function addSubscription(config: Config, sub: Subscription): void {
|
|
76
|
+
if (config.subscriptions[sub.name]) {
|
|
77
|
+
throw new Error(`Subscription "${sub.name}" already exists.`);
|
|
78
|
+
}
|
|
79
|
+
config.subscriptions[sub.name] = sub;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function updateSubscription(config: Config, sub: Subscription): void {
|
|
83
|
+
if (!config.subscriptions[sub.name]) {
|
|
84
|
+
throw new Error(`Subscription "${sub.name}" not found.`);
|
|
85
|
+
}
|
|
86
|
+
config.subscriptions[sub.name] = sub;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function removeSubscription(config: Config, name: string): void {
|
|
90
|
+
if (!config.subscriptions[name]) {
|
|
91
|
+
throw new Error(`Subscription "${name}" not found.`);
|
|
92
|
+
}
|
|
93
|
+
delete config.subscriptions[name];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// Plugin CRUD
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
export function getAllPlugins(config: Config): Array<[string, PluginConfig]> {
|
|
101
|
+
return Object.entries(config.plugins);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getPlugin(config: Config, name: string): PluginConfig | null {
|
|
105
|
+
return config.plugins[name] ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function addPlugin(config: Config, plugin: PluginConfig): void {
|
|
109
|
+
if (config.plugins[plugin.name]) {
|
|
110
|
+
throw new Error(`Plugin "${plugin.name}" already exists.`);
|
|
111
|
+
}
|
|
112
|
+
config.plugins[plugin.name] = plugin;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function updatePlugin(config: Config, plugin: PluginConfig): void {
|
|
116
|
+
if (!config.plugins[plugin.name]) {
|
|
117
|
+
throw new Error(`Plugin "${plugin.name}" not found.`);
|
|
118
|
+
}
|
|
119
|
+
config.plugins[plugin.name] = plugin;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function removePlugin(config: Config, name: string): void {
|
|
123
|
+
if (!config.plugins[name]) {
|
|
124
|
+
throw new Error(`Plugin "${name}" not found.`);
|
|
125
|
+
}
|
|
126
|
+
delete config.plugins[name];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// Tier CRUD
|
|
131
|
+
// ============================================================================
|
|
132
|
+
|
|
133
|
+
export function getAllTiers(config: Config): Array<[string, Tier]> {
|
|
134
|
+
return Object.entries(config.tiers);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function getTier(config: Config, name: string): Tier | null {
|
|
138
|
+
return config.tiers[name] ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function setTier(config: Config, tier: Tier): void {
|
|
142
|
+
config.tiers[tier.name] = tier;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function removeTier(config: Config, name: string): void {
|
|
146
|
+
// 预定义 tier 不允许删除,只能清空 target
|
|
147
|
+
if (["cctra", "cctra-pro", "cctra-flash", "cctra-vision"].includes(name)) {
|
|
148
|
+
config.tiers[name] = {
|
|
149
|
+
name,
|
|
150
|
+
target: "",
|
|
151
|
+
description: DEFAULT_CONFIG.tiers[name]?.description,
|
|
152
|
+
};
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
delete config.tiers[name];
|
|
156
|
+
}
|