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,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
|
+
}
|