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,29 @@
|
|
|
1
|
+
import type { CanonicalContentBlock } from "../../canonical/types";
|
|
2
|
+
import { isToolUse, isToolResult } from "./content-blocks";
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// tool_use ↔ tool_calls 互转工具
|
|
6
|
+
// OpenAI Chat Completions 用 tool_calls / tool role messages
|
|
7
|
+
// OpenAI Responses / Anthropic 用结构化 content blocks
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
/** 提取消息里所有的 tool_use blocks */
|
|
11
|
+
export function extractToolUses(blocks: CanonicalContentBlock[]): Array<{ id: string; name: string; input: unknown }> {
|
|
12
|
+
return blocks.filter(isToolUse).map((b) => ({ id: b.id, name: b.name, input: b.input }));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 提取消息里所有的 tool_result blocks */
|
|
16
|
+
export function extractToolResults(blocks: CanonicalContentBlock[]): Array<{ toolUseId: string; content: string; isError?: boolean }> {
|
|
17
|
+
return blocks.filter(isToolResult).map((b) => ({
|
|
18
|
+
toolUseId: b.toolUseId,
|
|
19
|
+
content: typeof b.content === "string" ? b.content : extractTextFromBlocks(b.content),
|
|
20
|
+
isError: b.isError,
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extractTextFromBlocks(blocks: CanonicalContentBlock[]): string {
|
|
25
|
+
return blocks
|
|
26
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
27
|
+
.map((b) => b.text)
|
|
28
|
+
.join("");
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CanonicalUsage } from "../../canonical/types";
|
|
2
|
+
|
|
3
|
+
// 把 CanonicalUsage 拆成 OpenAI 风格的 input/output
|
|
4
|
+
export function splitUsage(u: CanonicalUsage): { prompt_tokens: number; completion_tokens: number } {
|
|
5
|
+
return {
|
|
6
|
+
prompt_tokens: u.inputTokens,
|
|
7
|
+
completion_tokens: u.outputTokens,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// 合并增量到 usage(流式用)
|
|
12
|
+
export function mergeUsage(base: CanonicalUsage, delta: Partial<CanonicalUsage>): CanonicalUsage {
|
|
13
|
+
return {
|
|
14
|
+
inputTokens: delta.inputTokens ?? base.inputTokens,
|
|
15
|
+
outputTokens: delta.outputTokens ?? base.outputTokens,
|
|
16
|
+
cacheReadTokens: delta.cacheReadTokens ?? base.cacheReadTokens,
|
|
17
|
+
cacheWriteTokens: delta.cacheWriteTokens ?? base.cacheWriteTokens,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Anthropic Messages → Canonical
|
|
3
|
+
// Anthropic 格式跟 Canonical 几乎一样,主要是字段名 / 类型微调
|
|
4
|
+
// ============================================================================
|
|
5
|
+
import type { CanonicalRequest, CanonicalMessage, CanonicalContentBlock, CanonicalTool } from "../../canonical/types";
|
|
6
|
+
import { splitKnownAndExtras } from "../common/extras";
|
|
7
|
+
|
|
8
|
+
interface AnthropicRequest {
|
|
9
|
+
model?: string;
|
|
10
|
+
system?: string | Array<{ type: "text"; text: string; cache_control?: unknown }>;
|
|
11
|
+
messages?: Array<{
|
|
12
|
+
role: "user" | "assistant";
|
|
13
|
+
content: string | Array<AnthropicContentBlock>;
|
|
14
|
+
}>;
|
|
15
|
+
tools?: Array<{
|
|
16
|
+
name: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
input_schema: Record<string, unknown>;
|
|
19
|
+
}>;
|
|
20
|
+
max_tokens?: number;
|
|
21
|
+
temperature?: number;
|
|
22
|
+
top_p?: number;
|
|
23
|
+
stop_sequences?: string[];
|
|
24
|
+
stream?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type AnthropicContentBlock =
|
|
28
|
+
| { type: "text"; text: string }
|
|
29
|
+
| { type: "image"; source: { type: "base64" | "url"; media_type: string; data?: string; url?: string } }
|
|
30
|
+
| { type: "document"; source: { type: "base64" | "url"; media_type: string; data?: string; url?: string } }
|
|
31
|
+
| { type: "tool_use"; id: string; name: string; input: unknown }
|
|
32
|
+
| { type: "tool_result"; tool_use_id: string; content: string | Array<{ type: "text"; text: string }>; is_error?: boolean }
|
|
33
|
+
| { type: "thinking"; thinking: string; signature?: string };
|
|
34
|
+
|
|
35
|
+
export function anthropicToCanonical(req: AnthropicRequest): CanonicalRequest {
|
|
36
|
+
const messages: CanonicalMessage[] = (req.messages ?? []).map((m) => {
|
|
37
|
+
const knownMsgKeys: ReadonlySet<keyof typeof m> = new Set(["role", "content"]);
|
|
38
|
+
const { known, extras } = splitKnownAndExtras(m as unknown as Record<string, unknown>, knownMsgKeys as ReadonlySet<keyof Record<string, unknown>>, "anthropic");
|
|
39
|
+
return {
|
|
40
|
+
...(known as { role: "user" | "assistant" }),
|
|
41
|
+
content: messageContentToBlocks(m.content),
|
|
42
|
+
...(Object.keys(extras).length > 0 ? { extras } : {}),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const tools: CanonicalTool[] | undefined = req.tools?.map((t) => ({
|
|
47
|
+
name: t.name,
|
|
48
|
+
description: t.description,
|
|
49
|
+
inputSchema: t.input_schema,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// system 可以是字符串或 text block 数组
|
|
53
|
+
let system: CanonicalRequest["system"];
|
|
54
|
+
if (typeof req.system === "string") {
|
|
55
|
+
system = req.system;
|
|
56
|
+
} else if (Array.isArray(req.system)) {
|
|
57
|
+
system = req.system.map((b) => ({ type: "text" as const, text: b.text }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
model: req.model ?? "",
|
|
62
|
+
messages,
|
|
63
|
+
system,
|
|
64
|
+
tools: tools && tools.length > 0 ? tools : undefined,
|
|
65
|
+
maxTokens: req.max_tokens,
|
|
66
|
+
temperature: req.temperature,
|
|
67
|
+
topP: req.top_p,
|
|
68
|
+
stopSequences: req.stop_sequences,
|
|
69
|
+
stream: !!req.stream,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function messageContentToBlocks(content: string | AnthropicContentBlock[]): CanonicalContentBlock[] {
|
|
74
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
75
|
+
return content.map((b): CanonicalContentBlock => {
|
|
76
|
+
switch (b.type) {
|
|
77
|
+
case "text":
|
|
78
|
+
return { type: "text", text: b.text };
|
|
79
|
+
case "image": {
|
|
80
|
+
const src = b.source;
|
|
81
|
+
if (src.type === "base64") {
|
|
82
|
+
return { type: "image", source: { kind: "base64", mediaType: src.media_type, data: src.data ?? "" } };
|
|
83
|
+
}
|
|
84
|
+
return { type: "image", source: { kind: "url", mediaType: src.media_type, data: src.url ?? "" } };
|
|
85
|
+
}
|
|
86
|
+
case "document": {
|
|
87
|
+
const src = b.source;
|
|
88
|
+
if (src.type === "base64") {
|
|
89
|
+
return { type: "document", source: { kind: "base64", mediaType: src.media_type, data: src.data ?? "" } };
|
|
90
|
+
}
|
|
91
|
+
return { type: "document", source: { kind: "url", mediaType: src.media_type, data: src.url ?? "" } };
|
|
92
|
+
}
|
|
93
|
+
case "tool_use":
|
|
94
|
+
return { type: "tool_use", id: b.id, name: b.name, input: b.input };
|
|
95
|
+
case "tool_result":
|
|
96
|
+
return {
|
|
97
|
+
type: "tool_result",
|
|
98
|
+
toolUseId: b.tool_use_id,
|
|
99
|
+
content: typeof b.content === "string" ? b.content : b.content.map((t) => ({ type: "text" as const, text: t.text })),
|
|
100
|
+
isError: b.is_error,
|
|
101
|
+
};
|
|
102
|
+
case "thinking":
|
|
103
|
+
return { type: "thinking", thinking: b.thinking, signature: b.signature };
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// OpenAI Chat Completions → Canonical
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import type { CanonicalRequest, CanonicalMessage, CanonicalContentBlock, CanonicalTool } from "../../canonical/types";
|
|
5
|
+
import { splitKnownAndExtras } from "../common/extras";
|
|
6
|
+
|
|
7
|
+
// OpenAI Chat Completions 的请求格式(我们只关心字段,不严格类型化)
|
|
8
|
+
type ChatContentPart = { type: string; text?: string; image_url?: { url: string } };
|
|
9
|
+
type ChatMessage = {
|
|
10
|
+
role: "system" | "user" | "assistant" | "tool" | "function";
|
|
11
|
+
content?: string | null | ChatContentPart[];
|
|
12
|
+
name?: string;
|
|
13
|
+
tool_call_id?: string;
|
|
14
|
+
tool_calls?: Array<{
|
|
15
|
+
id: string;
|
|
16
|
+
type: "function";
|
|
17
|
+
function: { name: string; arguments: string };
|
|
18
|
+
}>;
|
|
19
|
+
};
|
|
20
|
+
interface ChatRequest {
|
|
21
|
+
model?: string;
|
|
22
|
+
messages?: ChatMessage[];
|
|
23
|
+
tools?: Array<{
|
|
24
|
+
type: "function";
|
|
25
|
+
function: { name: string; description?: string; parameters?: Record<string, unknown> };
|
|
26
|
+
}>;
|
|
27
|
+
max_tokens?: number;
|
|
28
|
+
temperature?: number;
|
|
29
|
+
top_p?: number;
|
|
30
|
+
stop?: string | string[];
|
|
31
|
+
stream?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function chatToCanonical(req: ChatRequest): CanonicalRequest {
|
|
35
|
+
const messages: CanonicalMessage[] = [];
|
|
36
|
+
let system: string | CanonicalContentBlock[] | undefined;
|
|
37
|
+
|
|
38
|
+
for (const m of req.messages ?? []) {
|
|
39
|
+
// system 单独提到顶级
|
|
40
|
+
if (m.role === "system") {
|
|
41
|
+
const text = typeof m.content === "string" ? m.content : "";
|
|
42
|
+
if (text) {
|
|
43
|
+
if (typeof system === "string") system = system + "\n\n" + text;
|
|
44
|
+
else if (Array.isArray(system)) system.push({ type: "text", text });
|
|
45
|
+
else system = text;
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// tool 消息 → tool_result block,挂到上一条 assistant 的回复里
|
|
51
|
+
// 简化:把 tool 消息变成 user 消息的 tool_result
|
|
52
|
+
if (m.role === "tool") {
|
|
53
|
+
const blocks: CanonicalContentBlock[] = [
|
|
54
|
+
{
|
|
55
|
+
type: "tool_result",
|
|
56
|
+
toolUseId: m.tool_call_id ?? "",
|
|
57
|
+
content: typeof m.content === "string" ? m.content : "",
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
messages.push({ role: "user", content: blocks });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (m.role === "user") {
|
|
65
|
+
const blocks = contentToBlocks(m.content ?? null);
|
|
66
|
+
messages.push({ role: "user", content: blocks });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (m.role === "assistant") {
|
|
71
|
+
const blocks: CanonicalContentBlock[] = [];
|
|
72
|
+
if (m.content) blocks.push(...contentToBlocks(m.content));
|
|
73
|
+
// tool_calls → tool_use blocks
|
|
74
|
+
if (m.tool_calls) {
|
|
75
|
+
for (const tc of m.tool_calls) {
|
|
76
|
+
let input: unknown = {};
|
|
77
|
+
try { input = JSON.parse(tc.function.arguments); } catch { /* keep empty */ }
|
|
78
|
+
blocks.push({ type: "tool_use", id: tc.id, name: tc.function.name, input });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// extras:把原始 m 整体塞 openaiChat 桶
|
|
82
|
+
const knownMsgKeys = new Set(["role", "content", "tool_calls"]);
|
|
83
|
+
const { known: _k, extras } = splitKnownAndExtras(m as unknown as Record<string, unknown>, knownMsgKeys, "openaiChat");
|
|
84
|
+
void _k;
|
|
85
|
+
messages.push({
|
|
86
|
+
role: "assistant",
|
|
87
|
+
content: blocks,
|
|
88
|
+
...(Object.keys(extras).length > 0 ? { extras } : {}),
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const tools: CanonicalTool[] | undefined = req.tools?.map((t) => ({
|
|
95
|
+
name: t.function.name,
|
|
96
|
+
description: t.function.description,
|
|
97
|
+
inputSchema: t.function.parameters ?? { type: "object", properties: {} },
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
model: req.model ?? "",
|
|
102
|
+
messages,
|
|
103
|
+
system,
|
|
104
|
+
tools: tools && tools.length > 0 ? tools : undefined,
|
|
105
|
+
maxTokens: req.max_tokens,
|
|
106
|
+
temperature: req.temperature,
|
|
107
|
+
topP: req.top_p,
|
|
108
|
+
stopSequences: Array.isArray(req.stop) ? req.stop : req.stop ? [req.stop] : undefined,
|
|
109
|
+
stream: !!req.stream,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function contentToBlocks(content: string | null | ChatContentPart[]): CanonicalContentBlock[] {
|
|
114
|
+
if (content == null) return [];
|
|
115
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
116
|
+
const blocks: CanonicalContentBlock[] = [];
|
|
117
|
+
for (const part of content) {
|
|
118
|
+
if (part.type === "text" && part.text) {
|
|
119
|
+
blocks.push({ type: "text", text: part.text });
|
|
120
|
+
} else if (part.type === "image_url" && part.image_url) {
|
|
121
|
+
const url = part.image_url.url;
|
|
122
|
+
if (url.startsWith("data:")) {
|
|
123
|
+
// data:image/png;base64,xxxx
|
|
124
|
+
const match = url.match(/^data:([^;]+);base64,(.+)$/);
|
|
125
|
+
if (match) blocks.push({ type: "image", source: { kind: "base64", mediaType: match[1]!, data: match[2]! } });
|
|
126
|
+
} else {
|
|
127
|
+
blocks.push({ type: "image", source: { kind: "url", mediaType: "image/*", data: url } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return blocks;
|
|
132
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// OpenAI Responses API → Canonical
|
|
3
|
+
// 处理 input 字符串/数组、tools、stream、previous_response_id、reasoning
|
|
4
|
+
// 跳过:5 个内置工具(web_search/code_interpreter/file_search/mcp/computer_use)
|
|
5
|
+
// ============================================================================
|
|
6
|
+
import type { CanonicalRequest, CanonicalMessage, CanonicalContentBlock, CanonicalTool } from "../../canonical/types";
|
|
7
|
+
import { splitKnownAndExtras } from "../common/extras";
|
|
8
|
+
|
|
9
|
+
interface ResponsesRequest {
|
|
10
|
+
model?: string;
|
|
11
|
+
input?: string | Array<{
|
|
12
|
+
role?: "user" | "assistant" | "system" | "developer";
|
|
13
|
+
content: string | Array<{
|
|
14
|
+
type: string;
|
|
15
|
+
text?: string;
|
|
16
|
+
image_url?: string;
|
|
17
|
+
}>;
|
|
18
|
+
}>;
|
|
19
|
+
instructions?: string;
|
|
20
|
+
tools?: Array<{
|
|
21
|
+
type: "function";
|
|
22
|
+
name: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
parameters?: Record<string, unknown>;
|
|
25
|
+
}>;
|
|
26
|
+
max_output_tokens?: number;
|
|
27
|
+
temperature?: number;
|
|
28
|
+
top_p?: number;
|
|
29
|
+
stream?: boolean;
|
|
30
|
+
previous_response_id?: string;
|
|
31
|
+
reasoning?: { effort?: "low" | "medium" | "high" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function responsesToCanonical(req: ResponsesRequest): CanonicalRequest {
|
|
35
|
+
const messages: CanonicalMessage[] = [];
|
|
36
|
+
|
|
37
|
+
if (typeof req.input === "string") {
|
|
38
|
+
messages.push({ role: "user", content: [{ type: "text", text: req.input }] });
|
|
39
|
+
} else if (Array.isArray(req.input)) {
|
|
40
|
+
for (const m of req.input) {
|
|
41
|
+
const role = m.role === "assistant" ? "assistant" : "user";
|
|
42
|
+
const blocks: CanonicalContentBlock[] = [];
|
|
43
|
+
if (typeof m.content === "string") {
|
|
44
|
+
blocks.push({ type: "text", text: m.content });
|
|
45
|
+
} else {
|
|
46
|
+
for (const part of m.content) {
|
|
47
|
+
if (part.type === "input_text" && part.text) {
|
|
48
|
+
blocks.push({ type: "text", text: part.text });
|
|
49
|
+
} else if (part.type === "input_image" && part.image_url) {
|
|
50
|
+
const url = part.image_url;
|
|
51
|
+
if (url.startsWith("data:")) {
|
|
52
|
+
const match = url.match(/^data:([^;]+);base64,(.+)$/);
|
|
53
|
+
if (match) blocks.push({ type: "image", source: { kind: "base64", mediaType: match[1]!, data: match[2]! } });
|
|
54
|
+
} else {
|
|
55
|
+
blocks.push({ type: "image", source: { kind: "url", mediaType: "image/*", data: url } });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// extras:未识别字段塞 openaiResponses 桶
|
|
61
|
+
const knownMsgKeys = new Set(["role", "content"]);
|
|
62
|
+
const { known: _k, extras } = splitKnownAndExtras(m as unknown as Record<string, unknown>, knownMsgKeys, "openaiResponses");
|
|
63
|
+
void _k;
|
|
64
|
+
messages.push({
|
|
65
|
+
role,
|
|
66
|
+
content: blocks,
|
|
67
|
+
...(Object.keys(extras).length > 0 ? { extras } : {}),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const tools: CanonicalTool[] | undefined = req.tools
|
|
73
|
+
?.filter((t) => t.type === "function")
|
|
74
|
+
.map((t) => ({
|
|
75
|
+
name: t.name,
|
|
76
|
+
description: t.description,
|
|
77
|
+
inputSchema: t.parameters ?? { type: "object", properties: {} },
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
model: req.model ?? "",
|
|
82
|
+
messages,
|
|
83
|
+
system: req.instructions,
|
|
84
|
+
tools: tools && tools.length > 0 ? tools : undefined,
|
|
85
|
+
maxTokens: req.max_output_tokens,
|
|
86
|
+
temperature: req.temperature,
|
|
87
|
+
topP: req.top_p,
|
|
88
|
+
stream: !!req.stream,
|
|
89
|
+
previousResponseId: req.previous_response_id,
|
|
90
|
+
reasoning: req.reasoning,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Canonical → Anthropic Messages 响应
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import type { CanonicalResponse, StopReason } from "../../canonical/types";
|
|
5
|
+
|
|
6
|
+
interface AnthropicResponse {
|
|
7
|
+
id: string;
|
|
8
|
+
type: "message";
|
|
9
|
+
role: "assistant";
|
|
10
|
+
model: string;
|
|
11
|
+
content: Array<{ type: "text"; text: string } | { type: "tool_use"; id: string; name: string; input: unknown }>;
|
|
12
|
+
stop_reason: StopReason | null;
|
|
13
|
+
stop_sequence: string | null;
|
|
14
|
+
usage: {
|
|
15
|
+
input_tokens: number;
|
|
16
|
+
output_tokens: number;
|
|
17
|
+
cache_read_input_tokens?: number;
|
|
18
|
+
cache_creation_input_tokens?: number;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Anthropic 错误响应:{ type: "error", error: { type, message } } */
|
|
23
|
+
interface AnthropicErrorResponse {
|
|
24
|
+
type: "error";
|
|
25
|
+
error: { type: string; message: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function canonicalToAnthropicResponse(res: CanonicalResponse): AnthropicResponse | AnthropicErrorResponse {
|
|
29
|
+
// 错误响应:短路返回 Anthropic 错误 shape
|
|
30
|
+
if (res.error) {
|
|
31
|
+
return {
|
|
32
|
+
type: "error",
|
|
33
|
+
error: {
|
|
34
|
+
type: res.error.type ?? "api_error",
|
|
35
|
+
message: res.error.message,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const content: AnthropicResponse["content"] = [];
|
|
41
|
+
for (const b of res.content) {
|
|
42
|
+
if (b.type === "text") content.push({ type: "text", text: b.text });
|
|
43
|
+
else if (b.type === "tool_use") content.push({ type: "tool_use", id: b.id, name: b.name, input: b.input });
|
|
44
|
+
// 其他 block 类型(image / tool_result)不应出现在 assistant 消息里
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
id: res.id,
|
|
49
|
+
type: "message",
|
|
50
|
+
role: "assistant",
|
|
51
|
+
model: res.model,
|
|
52
|
+
content,
|
|
53
|
+
stop_reason: res.stopReason,
|
|
54
|
+
stop_sequence: null,
|
|
55
|
+
usage: {
|
|
56
|
+
input_tokens: res.usage.inputTokens,
|
|
57
|
+
output_tokens: res.usage.outputTokens,
|
|
58
|
+
...(res.usage.cacheReadTokens !== undefined && { cache_read_input_tokens: res.usage.cacheReadTokens }),
|
|
59
|
+
...(res.usage.cacheWriteTokens !== undefined && { cache_creation_input_tokens: res.usage.cacheWriteTokens }),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Canonical → OpenAI Chat Completions 响应
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import type { CanonicalResponse, CanonicalContentBlock } from "../../canonical/types";
|
|
5
|
+
|
|
6
|
+
interface ChatResponse {
|
|
7
|
+
id: string;
|
|
8
|
+
object: "chat.completion";
|
|
9
|
+
created: number;
|
|
10
|
+
model: string;
|
|
11
|
+
choices: Array<{
|
|
12
|
+
index: number;
|
|
13
|
+
message: {
|
|
14
|
+
role: "assistant";
|
|
15
|
+
content: string | null;
|
|
16
|
+
tool_calls?: Array<{
|
|
17
|
+
id: string;
|
|
18
|
+
type: "function";
|
|
19
|
+
function: { name: string; arguments: string };
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | null;
|
|
23
|
+
}>;
|
|
24
|
+
usage?: {
|
|
25
|
+
prompt_tokens: number;
|
|
26
|
+
completion_tokens: number;
|
|
27
|
+
total_tokens: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** OpenAI Chat 错误响应:{ error: { message, type, code? } },无 choices/usage */
|
|
32
|
+
interface ChatErrorResponse {
|
|
33
|
+
error: { message: string; type: string; code?: number };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function canonicalToChatResponse(res: CanonicalResponse): ChatResponse | ChatErrorResponse {
|
|
37
|
+
// 错误响应:短路返回 OpenAI Chat 错误 shape
|
|
38
|
+
if (res.error) {
|
|
39
|
+
return {
|
|
40
|
+
error: {
|
|
41
|
+
message: res.error.message,
|
|
42
|
+
type: res.error.type ?? "upstream_error",
|
|
43
|
+
...(res.error.status !== undefined && { code: res.error.status }),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const text = extractText(res.content);
|
|
49
|
+
const toolUses = res.content.filter((b): b is { type: "tool_use"; id: string; name: string; input: unknown } => b.type === "tool_use");
|
|
50
|
+
|
|
51
|
+
const message: ChatResponse["choices"][number]["message"] = {
|
|
52
|
+
role: "assistant",
|
|
53
|
+
content: text || null,
|
|
54
|
+
};
|
|
55
|
+
if (toolUses.length > 0) {
|
|
56
|
+
message.tool_calls = toolUses.map((u) => ({
|
|
57
|
+
id: u.id,
|
|
58
|
+
type: "function",
|
|
59
|
+
function: {
|
|
60
|
+
name: u.name,
|
|
61
|
+
arguments: typeof u.input === "string" ? u.input : JSON.stringify(u.input ?? {}),
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
id: res.id,
|
|
68
|
+
object: "chat.completion",
|
|
69
|
+
created: Math.floor(Date.now() / 1000),
|
|
70
|
+
model: res.model,
|
|
71
|
+
choices: [
|
|
72
|
+
{
|
|
73
|
+
index: 0,
|
|
74
|
+
message,
|
|
75
|
+
finish_reason: mapStopReason(res.stopReason),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
usage: {
|
|
79
|
+
prompt_tokens: res.usage.inputTokens,
|
|
80
|
+
completion_tokens: res.usage.outputTokens,
|
|
81
|
+
total_tokens: res.usage.inputTokens + res.usage.outputTokens,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function extractText(blocks: CanonicalContentBlock[]): string {
|
|
87
|
+
return blocks
|
|
88
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
89
|
+
.map((b) => b.text)
|
|
90
|
+
.join("");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mapStopReason(r: CanonicalResponse["stopReason"]): ChatResponse["choices"][number]["finish_reason"] {
|
|
94
|
+
switch (r) {
|
|
95
|
+
case "end_turn": return "stop";
|
|
96
|
+
case "max_tokens": return "length";
|
|
97
|
+
case "tool_use": return "tool_calls";
|
|
98
|
+
case "stop_sequence": return "stop";
|
|
99
|
+
case "error": return "content_filter";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Canonical → OpenAI Responses API 响应
|
|
3
|
+
// 输出 message (output_text/refusal) + function_call + reasoning 块
|
|
4
|
+
// 跳过:5 个内置工具调用(Canonical 不承载)
|
|
5
|
+
// ============================================================================
|
|
6
|
+
import type { CanonicalResponse, CanonicalContentBlock } from "../../canonical/types";
|
|
7
|
+
|
|
8
|
+
type MessageContent =
|
|
9
|
+
| { type: "output_text"; text: string }
|
|
10
|
+
| { type: "refusal"; refusal: string };
|
|
11
|
+
|
|
12
|
+
type ResponsesOutput =
|
|
13
|
+
| { type: "message"; id?: string; role: "assistant"; content: MessageContent[] }
|
|
14
|
+
| { type: "function_call"; id: string; name: string; arguments: string; call_id: string }
|
|
15
|
+
| { type: "reasoning"; id: string; summary: Array<{ type: "summary_text"; text: string }> };
|
|
16
|
+
|
|
17
|
+
interface ResponsesResponse {
|
|
18
|
+
id: string;
|
|
19
|
+
object: "response";
|
|
20
|
+
created_at: number;
|
|
21
|
+
model: string;
|
|
22
|
+
status: "completed" | "incomplete" | "failed";
|
|
23
|
+
output: ResponsesOutput[];
|
|
24
|
+
usage: {
|
|
25
|
+
input_tokens: number;
|
|
26
|
+
output_tokens: number;
|
|
27
|
+
total_tokens: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** OpenAI Responses 错误响应:{ error: { code, message } } */
|
|
32
|
+
interface ResponsesErrorResponse {
|
|
33
|
+
error: { code: string; message: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function canonicalToResponsesResponse(res: CanonicalResponse): ResponsesResponse | ResponsesErrorResponse {
|
|
37
|
+
// 错误响应:短路返回 Responses 错误 shape
|
|
38
|
+
if (res.error) {
|
|
39
|
+
return {
|
|
40
|
+
error: {
|
|
41
|
+
code: res.error.status?.toString() ?? "upstream_error",
|
|
42
|
+
message: res.error.message,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const output: ResponsesOutput[] = [];
|
|
48
|
+
|
|
49
|
+
// 把所有 text + refusal 合并成一个 message item
|
|
50
|
+
const messageContent: MessageContent[] = [];
|
|
51
|
+
for (const b of res.content) {
|
|
52
|
+
if (b.type === "text" && b.text) {
|
|
53
|
+
messageContent.push({ type: "output_text", text: b.text });
|
|
54
|
+
} else if (b.type === "refusal") {
|
|
55
|
+
messageContent.push({ type: "refusal", refusal: b.refusal });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (messageContent.length > 0) {
|
|
59
|
+
output.push({ type: "message", id: `msg_${res.id}`, role: "assistant", content: messageContent });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// tool_use → function_call
|
|
63
|
+
for (const b of res.content) {
|
|
64
|
+
if (b.type === "tool_use") {
|
|
65
|
+
output.push({
|
|
66
|
+
type: "function_call",
|
|
67
|
+
id: b.id,
|
|
68
|
+
name: b.name,
|
|
69
|
+
arguments: typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? {}),
|
|
70
|
+
call_id: b.id,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// thinking → reasoning summary
|
|
76
|
+
let reasoningSeq = 0;
|
|
77
|
+
for (const b of res.content) {
|
|
78
|
+
if (b.type === "thinking" && b.thinking) {
|
|
79
|
+
output.push({
|
|
80
|
+
type: "reasoning",
|
|
81
|
+
id: `rs_${res.id}_${reasoningSeq++}`,
|
|
82
|
+
summary: [{ type: "summary_text", text: b.thinking }],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id: res.id,
|
|
89
|
+
object: "response",
|
|
90
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
91
|
+
model: res.model,
|
|
92
|
+
status: res.stopReason === "error" ? "failed" : "completed",
|
|
93
|
+
output,
|
|
94
|
+
usage: {
|
|
95
|
+
input_tokens: res.usage.inputTokens,
|
|
96
|
+
output_tokens: res.usage.outputTokens,
|
|
97
|
+
total_tokens: res.usage.inputTokens + res.usage.outputTokens,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 现在不用了,但保留 reference(旧测试可能 import)
|
|
103
|
+
export function _extractText(blocks: CanonicalContentBlock[]): string {
|
|
104
|
+
return blocks.filter((b): b is { type: "text"; text: string } => b.type === "text").map((b) => b.text).join("");
|
|
105
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// v1 简化:Anthropic SSE 已经是 CanonicalChunk 的近亲,直接透传
|
|
2
|
+
import type { CanonicalChunk } from "../../../canonical/types";
|
|
3
|
+
import { parseSseStream } from "../../../server/sse";
|
|
4
|
+
|
|
5
|
+
export async function* anthropicStreamToCanonical(rawStream: ReadableStream<Uint8Array>): AsyncGenerator<CanonicalChunk> {
|
|
6
|
+
for await (const ev of parseSseStream(rawStream)) {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(ev.data) as CanonicalChunk;
|
|
9
|
+
yield parsed;
|
|
10
|
+
} catch {
|
|
11
|
+
// 跳过
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|