cctra 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +135 -0
  3. package/bin/cctra +2 -0
  4. package/bin/cctra-daemon.exe +0 -0
  5. package/bin/cctra.js +2 -0
  6. package/examples/plugins/oauth-internal.js +46 -0
  7. package/examples/plugins/openai-compatible.js +27 -0
  8. package/package.json +53 -0
  9. package/src/canonical/types.ts +132 -0
  10. package/src/commands/add.ts +159 -0
  11. package/src/commands/daemon.ts +102 -0
  12. package/src/commands/ls.ts +49 -0
  13. package/src/commands/model.ts +95 -0
  14. package/src/commands/plugin.ts +167 -0
  15. package/src/commands/rename.ts +33 -0
  16. package/src/commands/rm.ts +37 -0
  17. package/src/commands/serve.ts +29 -0
  18. package/src/commands/shared.ts +14 -0
  19. package/src/commands/show.ts +46 -0
  20. package/src/commands/tier.ts +91 -0
  21. package/src/convert/common/content-blocks.ts +29 -0
  22. package/src/convert/common/extras.ts +38 -0
  23. package/src/convert/common/reasoning.ts +19 -0
  24. package/src/convert/common/system-prompt.ts +15 -0
  25. package/src/convert/common/tool-calls.ts +29 -0
  26. package/src/convert/common/usage.ts +19 -0
  27. package/src/convert/inbound/anthropic-to-canonical.ts +106 -0
  28. package/src/convert/inbound/chat-to-canonical.ts +132 -0
  29. package/src/convert/inbound/responses-to-canonical.ts +92 -0
  30. package/src/convert/outbound/canonical-to-anthropic.ts +62 -0
  31. package/src/convert/outbound/canonical-to-chat.ts +101 -0
  32. package/src/convert/outbound/canonical-to-responses.ts +105 -0
  33. package/src/convert/streaming/inbound/anthropic-stream.ts +14 -0
  34. package/src/convert/streaming/inbound/chat-stream.ts +219 -0
  35. package/src/convert/streaming/inbound/pick.ts +21 -0
  36. package/src/convert/streaming/inbound/responses-stream.ts +276 -0
  37. package/src/convert/streaming/outbound/format-anthropic.ts +19 -0
  38. package/src/convert/streaming/outbound/format-chat.ts +133 -0
  39. package/src/convert/streaming/outbound/format-responses.ts +184 -0
  40. package/src/convert/upstream/canonical-to-anthropic.ts +111 -0
  41. package/src/convert/upstream/canonical-to-chat.ts +115 -0
  42. package/src/convert/upstream/canonical-to-responses.ts +123 -0
  43. package/src/core/config.ts +156 -0
  44. package/src/core/model-fetch.ts +124 -0
  45. package/src/core/resolve.ts +73 -0
  46. package/src/core/routing.ts +31 -0
  47. package/src/core/source.ts +28 -0
  48. package/src/daemon/install.ts +47 -0
  49. package/src/daemon/platform/linux.ts +65 -0
  50. package/src/daemon/platform/macos.ts +71 -0
  51. package/src/daemon/platform/windows.ts +70 -0
  52. package/src/daemon/start.ts +22 -0
  53. package/src/daemon/status.ts +19 -0
  54. package/src/daemon/stop.ts +58 -0
  55. package/src/index.ts +34 -0
  56. package/src/plugin/contract.ts +51 -0
  57. package/src/plugin/host.ts +27 -0
  58. package/src/plugin/loader.ts +55 -0
  59. package/src/plugin/sandbox.ts +3 -0
  60. package/src/providers/presets.ts +167 -0
  61. package/src/server/anthropic-parser.ts +44 -0
  62. package/src/server/cancelable-fetch.ts +21 -0
  63. package/src/server/chat-parser.ts +81 -0
  64. package/src/server/error-status.ts +18 -0
  65. package/src/server/error.ts +16 -0
  66. package/src/server/handlers/chat-completions.ts +94 -0
  67. package/src/server/handlers/messages.ts +89 -0
  68. package/src/server/handlers/models.ts +35 -0
  69. package/src/server/handlers/responses.ts +89 -0
  70. package/src/server/keepalive.ts +63 -0
  71. package/src/server/responses-parser.ts +62 -0
  72. package/src/server/serve.ts +79 -0
  73. package/src/server/sse.ts +61 -0
  74. package/src/server/upstream.ts +251 -0
  75. package/src/tier/builtin.ts +9 -0
  76. package/src/tier/resolve.ts +33 -0
  77. package/src/tier/store.ts +3 -0
  78. package/src/types.ts +94 -0
  79. package/src/ui/format.ts +44 -0
  80. package/src/ui/prompts.ts +34 -0
  81. package/src/utils/fuzzy.ts +48 -0
  82. package/src/utils/logger.ts +32 -0
  83. package/src/utils/paths.ts +48 -0
@@ -0,0 +1,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
+ }