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,219 @@
1
+ // ============================================================================
2
+ // OpenAI Chat Completions SSE → CanonicalChunk 流式状态机
3
+ // ---------------------------------------------------------------------------
4
+ // 处理:
5
+ // - delta.role 首次出现 → message_start
6
+ // - delta.content 首次/累积 → content_block_start(text) + content_block_delta(text_delta)
7
+ // - delta.tool_calls[i] 多 chunk 拼接 → content_block_start(tool_use) + input_json_delta
8
+ // - finish_reason 非 null → 关掉所有打开的 block + message_delta + message_stop
9
+ // - [DONE] → 安全收尾(idempotent)
10
+ // ============================================================================
11
+
12
+ import type { CanonicalChunk, StopReason } from "../../../canonical/types";
13
+ import { parseSseStream } from "../../../server/sse";
14
+
15
+ interface PendingTool {
16
+ id: string;
17
+ name: string;
18
+ blockIndex: number;
19
+ emittedStart: boolean;
20
+ }
21
+
22
+ interface ChatStreamChunk {
23
+ id?: string;
24
+ model?: string;
25
+ choices?: Array<{
26
+ delta?: {
27
+ role?: string;
28
+ content?: string | null;
29
+ tool_calls?: Array<{
30
+ index: number;
31
+ id?: string;
32
+ type?: string;
33
+ function?: { name?: string; arguments?: string };
34
+ }>;
35
+ };
36
+ finish_reason?: string | null;
37
+ }>;
38
+ usage?: { prompt_tokens?: number; completion_tokens?: number };
39
+ }
40
+
41
+ export async function* chatStreamToCanonical(
42
+ rawStream: ReadableStream<Uint8Array>,
43
+ ): AsyncGenerator<CanonicalChunk> {
44
+ // ---------- 状态 ----------
45
+ const pendingTools = new Map<number, PendingTool>(); // key = OpenAI tool_call index
46
+ let nextBlockIndex = 0;
47
+ let textBlockIndex: number | null = null; // 0 通常;null 表示还没开 text block
48
+ let messageStarted = false;
49
+ let messageStopped = false;
50
+ let upstreamModel = "";
51
+ let upstreamId = "";
52
+
53
+ // ---------- 主循环 ----------
54
+ for await (const ev of parseSseStream(rawStream)) {
55
+ if (ev.data === "[DONE]") {
56
+ if (!messageStopped) {
57
+ yield* closeAll();
58
+ }
59
+ continue;
60
+ }
61
+
62
+ let parsed: ChatStreamChunk & { error?: { message?: unknown } };
63
+ try {
64
+ parsed = JSON.parse(ev.data) as ChatStreamChunk & { error?: { message?: unknown } };
65
+ } catch {
66
+ continue;
67
+ }
68
+
69
+ // 流中错:上游 SSE 内嵌 {error: {message}} 时透传为 canonical error chunk
70
+ if (parsed.error && typeof parsed.error.message === "string") {
71
+ yield { type: "error", error: parsed.error.message };
72
+ continue;
73
+ }
74
+
75
+ if (parsed.id) upstreamId = parsed.id;
76
+ if (parsed.model) upstreamModel = parsed.model;
77
+
78
+ const choice = parsed.choices?.[0];
79
+ if (!choice) {
80
+ // 可能是 usage-only chunk
81
+ if (parsed.usage && messageStarted && !messageStopped) {
82
+ yield {
83
+ type: "message_delta",
84
+ delta: {},
85
+ usage: {
86
+ inputTokens: parsed.usage.prompt_tokens ?? 0,
87
+ outputTokens: parsed.usage.completion_tokens ?? 0,
88
+ },
89
+ };
90
+ }
91
+ continue;
92
+ }
93
+
94
+ const delta = choice.delta;
95
+
96
+ // (1) delta.role 首次 → message_start
97
+ if (delta?.role && !messageStarted) {
98
+ yield* emitMessageStart();
99
+ }
100
+
101
+ // (2) text 增量
102
+ if (typeof delta?.content === "string" && delta.content.length > 0) {
103
+ if (!messageStarted) yield* emitMessageStart();
104
+ if (textBlockIndex === null) {
105
+ textBlockIndex = nextBlockIndex++;
106
+ yield {
107
+ type: "content_block_start",
108
+ index: textBlockIndex,
109
+ content_block: { type: "text", text: "" },
110
+ };
111
+ }
112
+ yield {
113
+ type: "content_block_delta",
114
+ index: textBlockIndex,
115
+ delta: { type: "text_delta", text: delta.content },
116
+ };
117
+ }
118
+
119
+ // (3) tool_calls 增量
120
+ if (delta?.tool_calls) {
121
+ if (!messageStarted) yield* emitMessageStart();
122
+ for (const tc of delta.tool_calls) {
123
+ let pending = pendingTools.get(tc.index);
124
+ if (!pending) {
125
+ // 第一次见这个 index:分配 blockIndex
126
+ pending = {
127
+ id: tc.id ?? "",
128
+ name: tc.function?.name ?? "",
129
+ blockIndex: nextBlockIndex++,
130
+ emittedStart: false,
131
+ };
132
+ pendingTools.set(tc.index, pending);
133
+ } else {
134
+ // 后续可能补 id 或 name(少见但要兼容)
135
+ if (tc.id && !pending.id) pending.id = tc.id;
136
+ if (tc.function?.name && !pending.name) pending.name = tc.function.name;
137
+ }
138
+
139
+ // 当 id 已知就可以 emit start(OpenAI 通常第一条 delta 同时有 id 和 name)
140
+ if (!pending.emittedStart && pending.id) {
141
+ pending.emittedStart = true;
142
+ yield {
143
+ type: "content_block_start",
144
+ index: pending.blockIndex,
145
+ content_block: { type: "tool_use", id: pending.id, name: pending.name, input: {} },
146
+ };
147
+ }
148
+
149
+ // arguments 增量
150
+ const argsPartial = tc.function?.arguments;
151
+ if (typeof argsPartial === "string" && argsPartial.length > 0 && pending.emittedStart) {
152
+ yield {
153
+ type: "content_block_delta",
154
+ index: pending.blockIndex,
155
+ delta: { type: "input_json_delta", partial_json: argsPartial },
156
+ };
157
+ }
158
+ }
159
+ }
160
+
161
+ // (4) finish_reason → 关掉所有 block + message_delta + message_stop
162
+ if (choice.finish_reason) {
163
+ yield* closeAll(choice.finish_reason);
164
+ }
165
+ }
166
+
167
+ // 流自然结束但没收到 [DONE] / finish_reason
168
+ if (!messageStopped) yield* closeAll();
169
+
170
+ // ---------- helpers (内嵌 generator,共享闭包状态) ----------
171
+
172
+ function* emitMessageStart(): Generator<CanonicalChunk> {
173
+ if (messageStarted) return;
174
+ messageStarted = true;
175
+ yield {
176
+ type: "message_start",
177
+ message: {
178
+ id: upstreamId || `msg_${Date.now()}`,
179
+ model: upstreamModel || "unknown",
180
+ content: [],
181
+ stopReason: "end_turn",
182
+ usage: { inputTokens: 0, outputTokens: 0 },
183
+ },
184
+ };
185
+ }
186
+
187
+ function* closeAll(finishReason?: string): Generator<CanonicalChunk> {
188
+ if (messageStopped) return;
189
+ // 关 text block
190
+ if (textBlockIndex !== null) {
191
+ yield { type: "content_block_stop", index: textBlockIndex };
192
+ textBlockIndex = null;
193
+ }
194
+ // 关所有 pending tool_use block
195
+ for (const t of pendingTools.values()) {
196
+ if (t.emittedStart) {
197
+ yield { type: "content_block_stop", index: t.blockIndex };
198
+ }
199
+ }
200
+ pendingTools.clear();
201
+
202
+ yield {
203
+ type: "message_delta",
204
+ delta: { stop_reason: mapStopReason(finishReason) },
205
+ };
206
+ yield { type: "message_stop" };
207
+ messageStopped = true;
208
+ }
209
+ }
210
+
211
+ function mapStopReason(r: string | undefined): StopReason {
212
+ switch (r) {
213
+ case "stop": return "end_turn";
214
+ case "length": return "max_tokens";
215
+ case "tool_calls": return "tool_use";
216
+ case "content_filter": return "error";
217
+ default: return "end_turn";
218
+ }
219
+ }
@@ -0,0 +1,21 @@
1
+ // ============================================================================
2
+ // 按上游 apiFormat 选择 inbound stream parser
3
+ // 关键:必须用 ready.apiFormat(plugin 真实返回的),而不是 route.apiFormat(plugin 占位)
4
+ // ============================================================================
5
+ import type { ApiFormat } from "../../../types";
6
+ import type { CanonicalChunk } from "../../../canonical/types";
7
+ import { chatStreamToCanonical } from "./chat-stream";
8
+ import { anthropicStreamToCanonical } from "./anthropic-stream";
9
+ import { responsesStreamToCanonical } from "./responses-stream";
10
+
11
+ export type InboundStreamParser = (
12
+ raw: ReadableStream<Uint8Array>,
13
+ ) => AsyncGenerator<CanonicalChunk>;
14
+
15
+ export function pickInboundStreamParser(apiFormat: ApiFormat): InboundStreamParser {
16
+ switch (apiFormat) {
17
+ case "anthropic-messages": return anthropicStreamToCanonical;
18
+ case "openai-responses": return responsesStreamToCanonical;
19
+ case "openai-chat": return chatStreamToCanonical;
20
+ }
21
+ }
@@ -0,0 +1,276 @@
1
+ // ============================================================================
2
+ // OpenAI Responses SSE → CanonicalChunk 流式状态机
3
+ // ---------------------------------------------------------------------------
4
+ // 处理 12+ 种 response.* 事件:
5
+ // - response.created → message_start
6
+ // - response.output_item.added (message) → content_block_start(text)
7
+ // - response.output_item.added (function_call) → content_block_start(tool_use)
8
+ // - response.output_item.added (reasoning) → content_block_start(thinking)
9
+ // - response.output_text.delta → content_block_delta(text_delta)
10
+ // - response.function_call_arguments.delta → content_block_delta(input_json_delta)
11
+ // - response.reasoning_summary_text.delta → content_block_delta(thinking_delta)
12
+ // - response.refusal.delta → content_block_delta(text_delta with prefix)
13
+ // - response.output_item.done → content_block_stop
14
+ // - response.error → error
15
+ // - response.completed → message_delta + message_stop
16
+ //
17
+ // 内置工具(web_search/code_interpreter/file_search/mcp_call/computer_use_call)
18
+ // 在 v1 跳过:Canonical 不承载这些 block 类型。
19
+ // ============================================================================
20
+
21
+ import type { CanonicalChunk, CanonicalContentBlock, StopReason } from "../../../canonical/types";
22
+ import { parseSseStream } from "../../../server/sse";
23
+
24
+ interface ResponsesEvent {
25
+ type?: string;
26
+ output_index?: number;
27
+ item_id?: string;
28
+ delta?: string;
29
+ item?: {
30
+ id?: string;
31
+ type?: string; // "message" | "function_call" | "reasoning" | "refusal" | builtins
32
+ name?: string;
33
+ arguments?: string;
34
+ call_id?: string;
35
+ role?: string;
36
+ };
37
+ response?: {
38
+ id?: string;
39
+ model?: string;
40
+ status?: string;
41
+ incomplete_details?: { reason?: string };
42
+ usage?: { input_tokens?: number; output_tokens?: number };
43
+ };
44
+ error?: { message?: string; code?: string };
45
+ }
46
+
47
+ export async function* responsesStreamToCanonical(
48
+ rawStream: ReadableStream<Uint8Array>,
49
+ ): AsyncGenerator<CanonicalChunk> {
50
+ // 状态:output_index → Canonical block_index(直接 1:1 用 output_index)
51
+ // 同时记住每个 output_index 对应的 block kind(关掉时无需查)
52
+ const openBlocks = new Set<number>();
53
+ let messageStarted = false;
54
+ let messageStopped = false;
55
+ let upstreamModel = "";
56
+ let upstreamId = "";
57
+
58
+ for await (const ev of parseSseStream(rawStream)) {
59
+ if (ev.data === "[DONE]") {
60
+ if (!messageStopped) yield* finalize("end_turn");
61
+ continue;
62
+ }
63
+
64
+ let parsed: ResponsesEvent;
65
+ try {
66
+ parsed = JSON.parse(ev.data) as ResponsesEvent;
67
+ } catch {
68
+ continue;
69
+ }
70
+
71
+ switch (parsed.type) {
72
+ case "response.created": {
73
+ if (parsed.response?.id) upstreamId = parsed.response.id;
74
+ if (parsed.response?.model) upstreamModel = parsed.response.model;
75
+ if (!messageStarted) {
76
+ messageStarted = true;
77
+ yield {
78
+ type: "message_start",
79
+ message: {
80
+ id: upstreamId || `resp_${Date.now()}`,
81
+ model: upstreamModel || "unknown",
82
+ content: [],
83
+ stopReason: "end_turn",
84
+ usage: { inputTokens: 0, outputTokens: 0 },
85
+ },
86
+ };
87
+ }
88
+ break;
89
+ }
90
+
91
+ case "response.in_progress":
92
+ // 状态信号,无需转发
93
+ break;
94
+
95
+ case "response.output_item.added": {
96
+ const idx = parsed.output_index;
97
+ if (idx === undefined || openBlocks.has(idx)) break;
98
+ const block = itemToBlock(parsed.item);
99
+ if (!block) break;
100
+ openBlocks.add(idx);
101
+ yield { type: "content_block_start", index: idx, content_block: block };
102
+ break;
103
+ }
104
+
105
+ case "response.output_text.delta": {
106
+ const idx = parsed.output_index;
107
+ if (idx === undefined || typeof parsed.delta !== "string") break;
108
+ yield {
109
+ type: "content_block_delta",
110
+ index: idx,
111
+ delta: { type: "text_delta", text: parsed.delta },
112
+ };
113
+ break;
114
+ }
115
+
116
+ case "response.function_call_arguments.delta": {
117
+ const idx = parsed.output_index;
118
+ if (idx === undefined || typeof parsed.delta !== "string") break;
119
+ yield {
120
+ type: "content_block_delta",
121
+ index: idx,
122
+ delta: { type: "input_json_delta", partial_json: parsed.delta },
123
+ };
124
+ break;
125
+ }
126
+
127
+ case "response.function_call_arguments.done": {
128
+ // 单独的 done 事件不发;统一由 output_item.done 关 block
129
+ break;
130
+ }
131
+
132
+ case "response.reasoning_summary_part.added": {
133
+ const idx = parsed.output_index;
134
+ if (idx === undefined || openBlocks.has(idx)) break;
135
+ openBlocks.add(idx);
136
+ yield {
137
+ type: "content_block_start",
138
+ index: idx,
139
+ content_block: { type: "thinking", thinking: "" },
140
+ };
141
+ break;
142
+ }
143
+
144
+ case "response.reasoning_summary_text.delta":
145
+ case "response.reasoning.delta": {
146
+ const idx = parsed.output_index;
147
+ if (idx === undefined || typeof parsed.delta !== "string") break;
148
+ yield {
149
+ type: "content_block_delta",
150
+ index: idx,
151
+ delta: { type: "thinking_delta", thinking: parsed.delta },
152
+ };
153
+ break;
154
+ }
155
+
156
+ case "response.reasoning_summary_part.done": {
157
+ const idx = parsed.output_index;
158
+ if (idx === undefined) break;
159
+ if (openBlocks.has(idx)) {
160
+ openBlocks.delete(idx);
161
+ yield { type: "content_block_stop", index: idx };
162
+ }
163
+ break;
164
+ }
165
+
166
+ case "response.refusal.delta": {
167
+ // ContentBlockDelta 联合没有 refusal_delta;用 text_delta 加前缀承载
168
+ const idx = parsed.output_index;
169
+ if (idx === undefined || typeof parsed.delta !== "string") break;
170
+ if (!openBlocks.has(idx)) {
171
+ openBlocks.add(idx);
172
+ yield {
173
+ type: "content_block_start",
174
+ index: idx,
175
+ content_block: { type: "text", text: "" },
176
+ };
177
+ }
178
+ yield {
179
+ type: "content_block_delta",
180
+ index: idx,
181
+ delta: { type: "text_delta", text: `[refusal] ${parsed.delta}` },
182
+ };
183
+ break;
184
+ }
185
+
186
+ case "response.output_item.done": {
187
+ const idx = parsed.output_index;
188
+ if (idx === undefined) break;
189
+ if (openBlocks.has(idx)) {
190
+ openBlocks.delete(idx);
191
+ yield { type: "content_block_stop", index: idx };
192
+ }
193
+ break;
194
+ }
195
+
196
+ case "response.error": {
197
+ yield { type: "error", error: parsed.error?.message ?? "upstream_error" };
198
+ break;
199
+ }
200
+
201
+ case "response.failed":
202
+ case "response.incomplete": {
203
+ if (!messageStopped) {
204
+ yield* finalize(parsed.response?.incomplete_details?.reason === "max_output_tokens" ? "max_tokens" : "error");
205
+ }
206
+ break;
207
+ }
208
+
209
+ case "response.completed": {
210
+ if (!messageStopped) {
211
+ const usage = parsed.response?.usage;
212
+ // 一次性把 usage 一起带在 message_delta 里
213
+ if (usage) {
214
+ yield* finalizeWithUsage("end_turn", usage.input_tokens ?? 0, usage.output_tokens ?? 0);
215
+ } else {
216
+ yield* finalize("end_turn");
217
+ }
218
+ }
219
+ break;
220
+ }
221
+
222
+ default:
223
+ // 内置工具事件(response.web_search_call.* / code_interpreter.* / ...)
224
+ // v1 跳过:Canonical 不承载
225
+ break;
226
+ }
227
+ }
228
+
229
+ if (!messageStopped) yield* finalize("end_turn");
230
+
231
+ // ---------- helpers ----------
232
+
233
+ function itemToBlock(item: ResponsesEvent["item"]): CanonicalContentBlock | null {
234
+ if (!item) return null;
235
+ switch (item.type) {
236
+ case "message":
237
+ return { type: "text", text: "" };
238
+ case "function_call":
239
+ return { type: "tool_use", id: item.call_id ?? item.id ?? "", name: item.name ?? "", input: {} };
240
+ case "reasoning":
241
+ return { type: "thinking", thinking: "" };
242
+ default:
243
+ // 内置工具 / 未知类型:跳
244
+ return null;
245
+ }
246
+ }
247
+
248
+ function* finalize(stopReason: StopReason): Generator<CanonicalChunk> {
249
+ // 关掉所有还开着的 block
250
+ for (const idx of openBlocks) {
251
+ yield { type: "content_block_stop", index: idx };
252
+ }
253
+ openBlocks.clear();
254
+ yield { type: "message_delta", delta: { stop_reason: stopReason } };
255
+ yield { type: "message_stop" };
256
+ messageStopped = true;
257
+ }
258
+
259
+ function* finalizeWithUsage(
260
+ stopReason: StopReason,
261
+ inputTokens: number,
262
+ outputTokens: number,
263
+ ): Generator<CanonicalChunk> {
264
+ for (const idx of openBlocks) {
265
+ yield { type: "content_block_stop", index: idx };
266
+ }
267
+ openBlocks.clear();
268
+ yield {
269
+ type: "message_delta",
270
+ delta: { stop_reason: stopReason },
271
+ usage: { inputTokens, outputTokens },
272
+ };
273
+ yield { type: "message_stop" };
274
+ messageStopped = true;
275
+ }
276
+ }
@@ -0,0 +1,19 @@
1
+ // ============================================================================
2
+ // CanonicalChunk → Anthropic Messages SSE 流式输出格式化
3
+ // ---------------------------------------------------------------------------
4
+ // CanonicalChunk 形状几乎对齐 Anthropic SSE,所以基本直接 JSON.stringify 透传。
5
+ // 唯一精细化:发严格的双行格式 `event: <name>\ndata: <json>\n\n`(Anthropic 客户端通常按 event 名分类)
6
+ // ============================================================================
7
+
8
+ import type { CanonicalChunk } from "../../../canonical/types";
9
+
10
+ export class AnthropicStreamFormatter {
11
+ // 流中错已发 error event:抑制 message_stop(避免"错 + 完成"矛盾信号)
12
+ private _streamEndedWithError = false;
13
+
14
+ format(chunk: CanonicalChunk): string[] {
15
+ if (chunk.type === "error") this._streamEndedWithError = true;
16
+ if (chunk.type === "message_stop" && this._streamEndedWithError) return [];
17
+ return [`event: ${chunk.type}\ndata: ${JSON.stringify(chunk)}\n\n`];
18
+ }
19
+ }
@@ -0,0 +1,133 @@
1
+ // ============================================================================
2
+ // CanonicalChunk → OpenAI Chat Completions SSE 流式输出格式化
3
+ // ---------------------------------------------------------------------------
4
+ // 关键状态:
5
+ // - 每个 tool_use Canonical block 在 OpenAI Chat 里对应一个 tool_calls[i] 槽位
6
+ // - 第一次见到 tool_use 时必须发完整 skeleton(id+name+type+空 arguments)
7
+ // - 后续 arguments 增量只发 {tool_calls:[{index,function:{arguments:partial}}]}
8
+ // - thinking/signature delta 在 OpenAI Chat 协议无对应 → 静默丢弃
9
+ // ============================================================================
10
+
11
+ import type { CanonicalChunk, StopReason } from "../../../canonical/types";
12
+
13
+ interface ToolSlot {
14
+ toolIndex: number;
15
+ id: string;
16
+ name: string;
17
+ }
18
+
19
+ export class ChatStreamFormatter {
20
+ private id = `chatcmpl-${Date.now()}`;
21
+ private created = Math.floor(Date.now() / 1000);
22
+ private model = "";
23
+ private nextToolIndex = 0;
24
+ // Canonical block_index → OpenAI tool_calls 槽位
25
+ private blockToToolSlot = new Map<number, ToolSlot>();
26
+ // 流中错已发 error event:抑制 [DONE](避免"错 + 完成"矛盾信号)
27
+ private _streamEndedWithError = false;
28
+
29
+ format(chunk: CanonicalChunk): string[] {
30
+ switch (chunk.type) {
31
+ case "message_start": {
32
+ if (chunk.message.id) this.id = chunk.message.id;
33
+ if (chunk.message.model) this.model = chunk.message.model;
34
+ return [];
35
+ }
36
+
37
+ case "content_block_start": {
38
+ if (chunk.content_block.type === "tool_use") {
39
+ const slot: ToolSlot = {
40
+ toolIndex: this.nextToolIndex++,
41
+ id: chunk.content_block.id,
42
+ name: chunk.content_block.name,
43
+ };
44
+ this.blockToToolSlot.set(chunk.index, slot);
45
+ return [this.makeChunk({
46
+ tool_calls: [{
47
+ index: slot.toolIndex,
48
+ id: slot.id,
49
+ type: "function",
50
+ function: { name: slot.name, arguments: "" },
51
+ }],
52
+ })];
53
+ }
54
+ // text / thinking block_start 不发(OpenAI Chat 不预声明 text block)
55
+ return [];
56
+ }
57
+
58
+ case "content_block_delta": {
59
+ if (chunk.delta.type === "text_delta") {
60
+ return [this.makeChunk({ content: chunk.delta.text })];
61
+ }
62
+ if (chunk.delta.type === "input_json_delta") {
63
+ const slot = this.blockToToolSlot.get(chunk.index);
64
+ if (!slot) return [];
65
+ return [this.makeChunk({
66
+ tool_calls: [{
67
+ index: slot.toolIndex,
68
+ function: { arguments: chunk.delta.partial_json },
69
+ }],
70
+ })];
71
+ }
72
+ // thinking_delta / signature_delta → OpenAI Chat 无对应,丢
73
+ return [];
74
+ }
75
+
76
+ case "content_block_stop":
77
+ // OpenAI Chat 不显式 stop content block
78
+ return [];
79
+
80
+ case "message_delta": {
81
+ const stop = chunk.delta.stop_reason;
82
+ if (!stop) return [];
83
+ return [this.makeFinishChunk(stop)];
84
+ }
85
+
86
+ case "message_stop":
87
+ // 流中错时抑制 [DONE](cc-switch 二元化约束)
88
+ if (this._streamEndedWithError) return [];
89
+ return ["data: [DONE]\n\n"];
90
+
91
+ case "ping":
92
+ return [];
93
+
94
+ case "error": {
95
+ // 流中错:发 error SSE event + 设抑制标志
96
+ this._streamEndedWithError = true;
97
+ return [`data: ${JSON.stringify({
98
+ error: { message: chunk.error, type: "upstream_error" },
99
+ })}\n\n`];
100
+ }
101
+ }
102
+ }
103
+
104
+ private makeChunk(delta: Record<string, unknown>): string {
105
+ return `data: ${JSON.stringify({
106
+ id: this.id,
107
+ object: "chat.completion.chunk",
108
+ created: this.created,
109
+ model: this.model,
110
+ choices: [{ index: 0, delta, finish_reason: null }],
111
+ })}\n\n`;
112
+ }
113
+
114
+ private makeFinishChunk(stop: StopReason): string {
115
+ return `data: ${JSON.stringify({
116
+ id: this.id,
117
+ object: "chat.completion.chunk",
118
+ created: this.created,
119
+ model: this.model,
120
+ choices: [{ index: 0, delta: {}, finish_reason: mapStopReason(stop) }],
121
+ })}\n\n`;
122
+ }
123
+ }
124
+
125
+ function mapStopReason(r: StopReason): string {
126
+ switch (r) {
127
+ case "end_turn": return "stop";
128
+ case "max_tokens": return "length";
129
+ case "stop_sequence": return "stop";
130
+ case "tool_use": return "tool_calls";
131
+ case "error": return "content_filter";
132
+ }
133
+ }