@codehz/ai 0.1.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/.oxfmtrc.json +12 -0
- package/.oxlintrc.json +49 -0
- package/README.md +225 -0
- package/bun.lock +231 -0
- package/dist/index.d.mts +978 -0
- package/dist/index.mjs +2758 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +30 -0
- package/src/adapters/chat-completions.ts +707 -0
- package/src/adapters/index.ts +20 -0
- package/src/adapters/messages.ts +700 -0
- package/src/adapters/ollama.ts +604 -0
- package/src/adapters/responses.ts +516 -0
- package/src/core/aggregator.ts +349 -0
- package/src/core/client.ts +23 -0
- package/src/core/collect-stream.ts +19 -0
- package/src/core/errors.ts +99 -0
- package/src/core/event-factory.ts +141 -0
- package/src/core/index.ts +18 -0
- package/src/core/normalize.ts +51 -0
- package/src/core/validation.ts +341 -0
- package/src/helpers/adapter-auxiliary.ts +181 -0
- package/src/helpers/adapter-base.ts +184 -0
- package/src/helpers/auxiliary-collector.ts +166 -0
- package/src/helpers/index.ts +40 -0
- package/src/helpers/mapping.ts +200 -0
- package/src/helpers/sse-parser.ts +87 -0
- package/src/helpers/synthetic-stream.ts +196 -0
- package/src/index.ts +17 -0
- package/src/types/adapter.ts +109 -0
- package/src/types/content.ts +12 -0
- package/src/types/events.ts +134 -0
- package/src/types/index.ts +59 -0
- package/src/types/items.ts +58 -0
- package/src/types/request.ts +39 -0
- package/src/types/response.ts +65 -0
- package/tsdown.config.ts +10 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 辅助信息采集器 (AuxiliaryCollector)
|
|
3
|
+
*
|
|
4
|
+
* 为 usage、billing、providerMetadata 提供统一的 best-effort 采集。
|
|
5
|
+
*
|
|
6
|
+
* 采集优先级(分层):
|
|
7
|
+
* 1. 主响应 body / terminal event
|
|
8
|
+
* 2. headers / trailers
|
|
9
|
+
* 3. SDK metadata
|
|
10
|
+
* 4. 一次 follow-up lookup
|
|
11
|
+
* 5. derived estimate
|
|
12
|
+
*
|
|
13
|
+
* 约束:
|
|
14
|
+
* - lookup 最多一次有界补查
|
|
15
|
+
* - lookup 失败只记录 warning
|
|
16
|
+
* - 不阻断主生成链路
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Usage, BillingInfo, AuxiliaryInfo } from "../types/index.js";
|
|
20
|
+
|
|
21
|
+
// ── 来源类型 ──────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export type UsageSource = NonNullable<AuxiliaryInfo["usageSource"]>;
|
|
24
|
+
export type BillingSource = NonNullable<AuxiliaryInfo["billingSource"]>;
|
|
25
|
+
|
|
26
|
+
export type LookupResult = {
|
|
27
|
+
usage?: Partial<Usage>;
|
|
28
|
+
billing?: Partial<BillingInfo>;
|
|
29
|
+
providerMetadata?: Record<string, unknown>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ── Collector ─────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export class AuxiliaryCollector {
|
|
35
|
+
private usage: Partial<Usage> = {};
|
|
36
|
+
private usageSource: UsageSource | undefined;
|
|
37
|
+
private billing: Partial<BillingInfo> | undefined;
|
|
38
|
+
private billingSource: BillingSource | undefined;
|
|
39
|
+
private providerMetadata: Record<string, unknown> = {};
|
|
40
|
+
private providerUsage: unknown;
|
|
41
|
+
private providerBilling: unknown;
|
|
42
|
+
private warnings: string[] = [];
|
|
43
|
+
private lookupAttempted = false;
|
|
44
|
+
|
|
45
|
+
// ── 记录方法 ──────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 记录 usage 信息。
|
|
49
|
+
* 后调用的覆盖先调用的(优先级由调用方控制)。
|
|
50
|
+
*/
|
|
51
|
+
recordUsage(usage: Partial<Usage>, source: UsageSource, raw?: unknown): this {
|
|
52
|
+
this.usage = { ...this.usage, ...usage };
|
|
53
|
+
this.usageSource = source;
|
|
54
|
+
if (raw !== undefined) this.providerUsage = raw;
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 记录 billing 信息。
|
|
60
|
+
* 后调用的覆盖先调用的。
|
|
61
|
+
*/
|
|
62
|
+
recordBilling(billing: Partial<BillingInfo>, source: BillingSource, raw?: unknown): this {
|
|
63
|
+
this.billing = { ...this.billing, ...billing };
|
|
64
|
+
this.billingSource = source;
|
|
65
|
+
if (raw !== undefined) this.providerBilling = raw;
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 记录 provider 元数据(非 canonical 的 key-value 信息)。
|
|
71
|
+
*/
|
|
72
|
+
recordMetadata(metadata: Record<string, unknown>): this {
|
|
73
|
+
this.providerMetadata = { ...this.providerMetadata, ...metadata };
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 记录一条 warning。
|
|
79
|
+
*/
|
|
80
|
+
recordWarning(message: string): this {
|
|
81
|
+
this.warnings.push(message);
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── 有界 Lookup ───────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 执行一次有界 follow-up lookup。
|
|
89
|
+
* 最多调用一次;后续调用被忽略。
|
|
90
|
+
* lookup 失败(抛错)仅记录 warning,不传播异常。
|
|
91
|
+
*/
|
|
92
|
+
async tryLookup(lookupFn: () => Promise<LookupResult>, timeoutMs = 5_000): Promise<void> {
|
|
93
|
+
if (this.lookupAttempted) return;
|
|
94
|
+
this.lookupAttempted = true;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const result = await withTimeout(lookupFn(), timeoutMs);
|
|
98
|
+
if (result.usage) {
|
|
99
|
+
this.recordUsage(result.usage, "lookup", result.usage);
|
|
100
|
+
}
|
|
101
|
+
if (result.billing) {
|
|
102
|
+
const bill: Partial<BillingInfo> = {
|
|
103
|
+
...result.billing,
|
|
104
|
+
source: result.billing?.source ?? "lookup",
|
|
105
|
+
};
|
|
106
|
+
this.recordBilling(bill, "lookup", result.billing);
|
|
107
|
+
}
|
|
108
|
+
if (result.providerMetadata) {
|
|
109
|
+
this.recordMetadata(result.providerMetadata);
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
this.recordWarning(`Auxiliary lookup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── 构建最终结果 ──────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 构建最终的 usage / billing / auxiliary。
|
|
120
|
+
* 所有字段均为可选的 — 拿不到就不给。
|
|
121
|
+
*/
|
|
122
|
+
build(): { usage?: Usage; billing?: BillingInfo; auxiliary?: AuxiliaryInfo; warnings?: string[] } {
|
|
123
|
+
const result: { usage?: Usage; billing?: BillingInfo; auxiliary?: AuxiliaryInfo; warnings?: string[] } = {};
|
|
124
|
+
|
|
125
|
+
if (Object.keys(this.usage).length > 0) {
|
|
126
|
+
result.usage = this.usage as Usage;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (this.billing) {
|
|
130
|
+
result.billing = this.billing as BillingInfo;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const aux: AuxiliaryInfo = {};
|
|
134
|
+
if (this.usageSource) aux.usageSource = this.usageSource;
|
|
135
|
+
if (this.billingSource) aux.billingSource = this.billingSource;
|
|
136
|
+
if (this.providerUsage !== undefined) aux.providerUsage = this.providerUsage;
|
|
137
|
+
if (this.providerBilling !== undefined) aux.providerBilling = this.providerBilling;
|
|
138
|
+
if (Object.keys(this.providerMetadata).length > 0) aux.providerMetadata = this.providerMetadata;
|
|
139
|
+
|
|
140
|
+
if (Object.keys(aux).length > 0) {
|
|
141
|
+
result.auxiliary = aux;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (this.warnings.length > 0) {
|
|
145
|
+
result.warnings = [...this.warnings];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 已使用的来源列表(用于 debugging)。
|
|
153
|
+
*/
|
|
154
|
+
get sources(): { usage?: UsageSource; billing?: BillingSource } {
|
|
155
|
+
return { usage: this.usageSource, billing: this.billingSource };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Helper ────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
162
|
+
return Promise.race([
|
|
163
|
+
promise,
|
|
164
|
+
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`Lookup timed out after ${ms}ms`)), ms)),
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享工具模块
|
|
3
|
+
*
|
|
4
|
+
* 模块边界:adapter 间共享的映射 helper、adapter 基类、模拟流式、辅助信息采集等。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
mapStopReason,
|
|
9
|
+
mapReasoningVisibility,
|
|
10
|
+
textBlock,
|
|
11
|
+
jsonBlock,
|
|
12
|
+
imageBlock,
|
|
13
|
+
opaqueBlock,
|
|
14
|
+
blockToText,
|
|
15
|
+
contentBlocksToText,
|
|
16
|
+
instructionsToText,
|
|
17
|
+
extractText,
|
|
18
|
+
messageItem,
|
|
19
|
+
reasoningItem,
|
|
20
|
+
toolCallItem,
|
|
21
|
+
toolResultItem,
|
|
22
|
+
opaqueItem,
|
|
23
|
+
replayFromOutput,
|
|
24
|
+
} from "./mapping.js";
|
|
25
|
+
|
|
26
|
+
export { parseSSEEvents } from "./sse-parser.js";
|
|
27
|
+
export type { SSEEvent } from "./sse-parser.js";
|
|
28
|
+
|
|
29
|
+
export { AdapterBase } from "./adapter-base.js";
|
|
30
|
+
export type { StreamResult } from "./adapter-base.js";
|
|
31
|
+
export {
|
|
32
|
+
AdapterAuxiliaryState,
|
|
33
|
+
emitMalformedStreamWarning,
|
|
34
|
+
metadataSourceList,
|
|
35
|
+
} from "./adapter-auxiliary.js";
|
|
36
|
+
export type { AuxiliaryFinalizeOptions, AuxiliaryFinalizeResult, BillingPostprocessHook } from "./adapter-auxiliary.js";
|
|
37
|
+
export { syntheticStream } from "./synthetic-stream.js";
|
|
38
|
+
export type { SyntheticStreamOptions } from "./synthetic-stream.js";
|
|
39
|
+
export { AuxiliaryCollector } from "./auxiliary-collector.js";
|
|
40
|
+
export type { UsageSource, BillingSource, LookupResult } from "./auxiliary-collector.js";
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter 共享映射 helper
|
|
3
|
+
*
|
|
4
|
+
* 提供 adapter 间通用的类型映射函数:
|
|
5
|
+
* - stop reason 映射
|
|
6
|
+
* - content block 映射
|
|
7
|
+
* - item 映射
|
|
8
|
+
* - warning 记录
|
|
9
|
+
* - replay 构造工具
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
StopReason,
|
|
14
|
+
ContentBlock,
|
|
15
|
+
MessageItem,
|
|
16
|
+
ReasoningItem,
|
|
17
|
+
ToolCallItem,
|
|
18
|
+
ToolResultItem,
|
|
19
|
+
OpaqueItem,
|
|
20
|
+
InputItem,
|
|
21
|
+
OutputItem,
|
|
22
|
+
ReplayItem,
|
|
23
|
+
} from "../types/index.js";
|
|
24
|
+
|
|
25
|
+
// ── Stop reason 映射 ──────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 常见 provider stop_reason / finish_reason 到 canonical StopReason 的映射表。
|
|
29
|
+
* adapter 可先查此表,未覆盖时走 fallback 规则。
|
|
30
|
+
*/
|
|
31
|
+
const STOP_REASON_MAP: Record<string, StopReason> = {
|
|
32
|
+
// OpenAI / Azure
|
|
33
|
+
stop: "end_turn",
|
|
34
|
+
length: "max_output_tokens",
|
|
35
|
+
content_filter: "content_filter",
|
|
36
|
+
tool_calls: "tool_call",
|
|
37
|
+
// Anthropic
|
|
38
|
+
end_turn: "end_turn",
|
|
39
|
+
max_tokens: "max_output_tokens",
|
|
40
|
+
tool_use: "tool_call",
|
|
41
|
+
// Generic
|
|
42
|
+
error: "error",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function mapStopReason(providerReason: string): StopReason {
|
|
46
|
+
return STOP_REASON_MAP[providerReason] ?? "unknown";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Reasoning visibility 映射 ──────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export function mapReasoningVisibility(hasThinking: boolean, hasRedacted: boolean): ReasoningItem["visibility"] {
|
|
52
|
+
if (hasRedacted) return "redacted";
|
|
53
|
+
if (hasThinking) return "full";
|
|
54
|
+
return "opaque";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Content block 构造 helper ─────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export function textBlock(text: string): ContentBlock & { type: "text" } {
|
|
60
|
+
return { type: "text", text };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function jsonBlock(json: unknown): ContentBlock & { type: "json" } {
|
|
64
|
+
return { type: "json", json };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function imageBlock(imageUrl: string): ContentBlock & { type: "image" } {
|
|
68
|
+
return { type: "image", imageUrl };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function opaqueBlock(payload: unknown): ContentBlock & { type: "opaque" } {
|
|
72
|
+
return { type: "opaque", payload };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Item 构造 helper ──────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export function messageItem(
|
|
78
|
+
content: ContentBlock[],
|
|
79
|
+
overrides?: Partial<Omit<MessageItem, "type" | "content">>,
|
|
80
|
+
): MessageItem {
|
|
81
|
+
return {
|
|
82
|
+
type: "message",
|
|
83
|
+
role: "assistant",
|
|
84
|
+
...overrides,
|
|
85
|
+
content,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function reasoningItem(
|
|
90
|
+
content: ContentBlock[],
|
|
91
|
+
visibility: ReasoningItem["visibility"] = "full",
|
|
92
|
+
id?: string,
|
|
93
|
+
): ReasoningItem {
|
|
94
|
+
return {
|
|
95
|
+
type: "reasoning",
|
|
96
|
+
id,
|
|
97
|
+
visibility,
|
|
98
|
+
content,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function toolCallItem(id: string, name: string, argumentsText: string, argumentsJson?: unknown): ToolCallItem {
|
|
103
|
+
return {
|
|
104
|
+
type: "tool_call",
|
|
105
|
+
id,
|
|
106
|
+
name,
|
|
107
|
+
argumentsText,
|
|
108
|
+
argumentsJson,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function toolResultItem(
|
|
113
|
+
callId: string,
|
|
114
|
+
toolName: string,
|
|
115
|
+
outcome: ToolResultItem["outcome"],
|
|
116
|
+
content: ContentBlock[],
|
|
117
|
+
): ToolResultItem {
|
|
118
|
+
return {
|
|
119
|
+
type: "tool_result",
|
|
120
|
+
callId,
|
|
121
|
+
toolName,
|
|
122
|
+
outcome,
|
|
123
|
+
content,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function opaqueItem(
|
|
128
|
+
source: OpaqueItem["source"],
|
|
129
|
+
purpose: OpaqueItem["purpose"],
|
|
130
|
+
payload: unknown,
|
|
131
|
+
id?: string,
|
|
132
|
+
): OpaqueItem {
|
|
133
|
+
return {
|
|
134
|
+
type: "opaque",
|
|
135
|
+
id,
|
|
136
|
+
source,
|
|
137
|
+
purpose,
|
|
138
|
+
payload,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Replay 构造工具 ──────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 从 output items 构建标准 replay items。
|
|
146
|
+
* 简单场景下 replay 与 output 一致。
|
|
147
|
+
* 复杂场景(需要 opaque continuation)由 adapter 自行扩展。
|
|
148
|
+
*/
|
|
149
|
+
export function replayFromOutput(output: readonly OutputItem[]): ReplayItem[] {
|
|
150
|
+
return output.map((item): InputItem => {
|
|
151
|
+
switch (item.type) {
|
|
152
|
+
case "message":
|
|
153
|
+
case "reasoning":
|
|
154
|
+
case "tool_call":
|
|
155
|
+
return item as InputItem;
|
|
156
|
+
case "opaque":
|
|
157
|
+
return item;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Content block 提取 helper ──────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 将单个 ContentBlock 转为纯文本。
|
|
166
|
+
* text 块直接返回文本,json 块序列化,其余返回空串。
|
|
167
|
+
*/
|
|
168
|
+
export function blockToText(b: ContentBlock): string {
|
|
169
|
+
if (b.type === "text") return b.text;
|
|
170
|
+
if (b.type === "json") return JSON.stringify(b.json);
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 将 ContentBlock 数组拼接为纯文本,块间以换行符分隔。
|
|
176
|
+
*/
|
|
177
|
+
export function contentBlocksToText(blocks: ContentBlock[]): string {
|
|
178
|
+
return blocks.map(blockToText).join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 将 instructions(string | ContentBlock[])归一化为纯文本。
|
|
183
|
+
*/
|
|
184
|
+
export function instructionsToText(instructions: string | ContentBlock[]): string {
|
|
185
|
+
return typeof instructions === "string" ? instructions : contentBlocksToText(instructions);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Output 文本提取 ───────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 从 OutputItem 数组中提取所有 message 类型 item 的文本内容。
|
|
192
|
+
*/
|
|
193
|
+
export function extractText(output: OutputItem[]): string {
|
|
194
|
+
return output
|
|
195
|
+
.filter((item): item is MessageItem => item.type === "message")
|
|
196
|
+
.flatMap((m) => m.content)
|
|
197
|
+
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
|
|
198
|
+
.map((b) => b.text)
|
|
199
|
+
.join("");
|
|
200
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用 SSE (Server-Sent Events) 解析器
|
|
3
|
+
*
|
|
4
|
+
* 解析标准 SSE 格式(event: + data: 行),适用于:
|
|
5
|
+
* - Anthropic Messages API (messages.ts)
|
|
6
|
+
* - OpenAI Responses API (responses.ts)
|
|
7
|
+
*
|
|
8
|
+
* 注意:OpenAI Chat Completions API 使用简化 SSE(仅有 data: 行),
|
|
9
|
+
* 由 chat-completions.ts 中的 parseChatSSE 处理。
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* ```ts
|
|
13
|
+
* const { events, rest } = parseSSEEvents(buffer);
|
|
14
|
+
* for (const ev of events) {
|
|
15
|
+
* // ev.type — 事件类型字符串
|
|
16
|
+
* // ev.data — 已解析的 JSON 数据
|
|
17
|
+
* }
|
|
18
|
+
* // rest 是未处理的剩余 buffer,需要累积到下次调用
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type SSEEvent = { type: string; data: unknown };
|
|
23
|
+
|
|
24
|
+
export type SSEParseResult = {
|
|
25
|
+
events: SSEEvent[];
|
|
26
|
+
rest: string;
|
|
27
|
+
malformedEvents: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 将 SSE 文本块解析为事件数组。
|
|
32
|
+
* 累积事件行直到遇到空行,支持 [DONE] 标记。
|
|
33
|
+
* 返回已解析的事件和未处理的剩余 buffer(用于增量解析)。
|
|
34
|
+
*
|
|
35
|
+
* 关键行为:
|
|
36
|
+
* - 只解析完整的 event(以空行结尾)
|
|
37
|
+
* - 未完成的行保留在 rest 中,等待下次 chunk 补全
|
|
38
|
+
* - 支持跨 chunk 的 event 分片
|
|
39
|
+
*/
|
|
40
|
+
export function parseSSEEvents(chunk: string): SSEParseResult {
|
|
41
|
+
const events: SSEEvent[] = [];
|
|
42
|
+
let eventType = "";
|
|
43
|
+
let dataLines: string[] = [];
|
|
44
|
+
let consumedUntil = 0;
|
|
45
|
+
let cursor = 0;
|
|
46
|
+
let malformedEvents = 0;
|
|
47
|
+
|
|
48
|
+
while (cursor < chunk.length) {
|
|
49
|
+
const lineEnd = chunk.indexOf("\n", cursor);
|
|
50
|
+
if (lineEnd === -1) break;
|
|
51
|
+
|
|
52
|
+
let line = chunk.slice(cursor, lineEnd);
|
|
53
|
+
cursor = lineEnd + 1;
|
|
54
|
+
|
|
55
|
+
if (line.endsWith("\r")) {
|
|
56
|
+
line = line.slice(0, -1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (line.startsWith("event: ")) {
|
|
60
|
+
eventType = line.slice(7).trim();
|
|
61
|
+
} else if (line.startsWith("data: ")) {
|
|
62
|
+
dataLines.push(line.slice(6));
|
|
63
|
+
} else if (line === "" && eventType && dataLines.length > 0) {
|
|
64
|
+
// 完整的 event(以空行结尾)
|
|
65
|
+
const dataStr = dataLines.join("\n");
|
|
66
|
+
if (dataStr === "[DONE]") {
|
|
67
|
+
eventType = "";
|
|
68
|
+
dataLines = [];
|
|
69
|
+
consumedUntil = cursor;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const data = JSON.parse(dataStr);
|
|
74
|
+
events.push({ type: eventType, data });
|
|
75
|
+
} catch {
|
|
76
|
+
malformedEvents++;
|
|
77
|
+
}
|
|
78
|
+
eventType = "";
|
|
79
|
+
dataLines = [];
|
|
80
|
+
consumedUntil = cursor;
|
|
81
|
+
} else if (line === "" && !eventType && dataLines.length === 0) {
|
|
82
|
+
consumedUntil = cursor;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { events, rest: chunk.slice(consumedUntil), malformedEvents };
|
|
87
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模拟流式 (Synthetic Streaming)
|
|
3
|
+
*
|
|
4
|
+
* 将一组已解析的 canonical OutputItem 包装为规范事件流。
|
|
5
|
+
* 适用于非原生流式后端:adapter 拿到完整响应后,调用此函数
|
|
6
|
+
* 即可产出一致的事件序列,无需自己逐事件组装。
|
|
7
|
+
*
|
|
8
|
+
* 约束:
|
|
9
|
+
* - 每个 item 只发一块完整 delta(不模拟逐 token)
|
|
10
|
+
* - 保持 item 边界
|
|
11
|
+
* - 保持后端原始顺序
|
|
12
|
+
* - 不发明 reasoning
|
|
13
|
+
* - 不改写工具参数
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createEventFactory } from "../core/event-factory.js";
|
|
17
|
+
import { replayFromOutput, extractText } from "./mapping.js";
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
OutputItem,
|
|
21
|
+
ReplayItem,
|
|
22
|
+
StopReason,
|
|
23
|
+
Usage,
|
|
24
|
+
BillingInfo,
|
|
25
|
+
AIStreamEvent,
|
|
26
|
+
AIResponse,
|
|
27
|
+
MessageItem,
|
|
28
|
+
ReasoningItem,
|
|
29
|
+
ToolCallItem,
|
|
30
|
+
} from "../types/index.js";
|
|
31
|
+
|
|
32
|
+
// ── 输入参数 ──────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export type SyntheticStreamOptions = {
|
|
35
|
+
model: string;
|
|
36
|
+
responseId: string;
|
|
37
|
+
backend: {
|
|
38
|
+
kind: "chat-completions" | "messages" | "responses";
|
|
39
|
+
/** syntheticStream 强制设为 true */
|
|
40
|
+
};
|
|
41
|
+
output: OutputItem[];
|
|
42
|
+
replay?: ReplayItem[];
|
|
43
|
+
stopReason?: StopReason;
|
|
44
|
+
usage?: Usage;
|
|
45
|
+
billing?: BillingInfo;
|
|
46
|
+
providerMetadata?: Record<string, unknown>;
|
|
47
|
+
rawResponseId?: string;
|
|
48
|
+
warnings?: string[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ── Synthetic Stream ──────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 将已解析的 output items 包装为完整规范事件流。
|
|
55
|
+
*
|
|
56
|
+
* 用法示例(在 adapter 的 runStream 中):
|
|
57
|
+
* ```ts
|
|
58
|
+
* const result = parseNonStreamingResponse(data);
|
|
59
|
+
* yield* syntheticStream({
|
|
60
|
+
* model: request.model,
|
|
61
|
+
* responseId: request.requestId,
|
|
62
|
+
* backend: { kind: "chat-completions" },
|
|
63
|
+
* output: result.output,
|
|
64
|
+
* stopReason: result.stopReason,
|
|
65
|
+
* usage: result.usage,
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export async function* syntheticStream(options: SyntheticStreamOptions): AsyncIterable<AIStreamEvent> {
|
|
70
|
+
const {
|
|
71
|
+
model,
|
|
72
|
+
responseId,
|
|
73
|
+
backend,
|
|
74
|
+
output,
|
|
75
|
+
replay,
|
|
76
|
+
stopReason,
|
|
77
|
+
usage,
|
|
78
|
+
billing,
|
|
79
|
+
providerMetadata,
|
|
80
|
+
rawResponseId,
|
|
81
|
+
warnings: extraWarnings,
|
|
82
|
+
} = options;
|
|
83
|
+
|
|
84
|
+
const factory = createEventFactory({
|
|
85
|
+
responseId,
|
|
86
|
+
backend: { kind: backend.kind, isSynthetic: true },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// 1. 响应开始
|
|
90
|
+
yield factory.responseStarted(model);
|
|
91
|
+
|
|
92
|
+
// 2. item 级事件 — 每个 item 只发一块完整 delta
|
|
93
|
+
for (const item of output) {
|
|
94
|
+
yield* emitItemEvents(item, factory);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. auxiliary 事件(如有)
|
|
98
|
+
if (usage || billing) {
|
|
99
|
+
yield factory.responseAuxiliary({ usage, billing });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 4. 构建最终 response
|
|
103
|
+
const finalReplay = replay ?? replayFromOutput(output);
|
|
104
|
+
|
|
105
|
+
// 收集警告
|
|
106
|
+
const allWarnings: string[] = [];
|
|
107
|
+
allWarnings.push("Response is synthetically streamed; delta granularity may differ from native streaming");
|
|
108
|
+
if (extraWarnings) allWarnings.push(...extraWarnings);
|
|
109
|
+
|
|
110
|
+
const response: AIResponse = {
|
|
111
|
+
id: responseId,
|
|
112
|
+
output,
|
|
113
|
+
replay: finalReplay,
|
|
114
|
+
text: extractText(output),
|
|
115
|
+
toolCalls: output.filter((item): item is ToolCallItem => item.type === "tool_call"),
|
|
116
|
+
stopReason,
|
|
117
|
+
usage,
|
|
118
|
+
billing,
|
|
119
|
+
auxiliary: providerMetadata ? { providerMetadata } : undefined,
|
|
120
|
+
warnings: allWarnings.length > 0 ? allWarnings : undefined,
|
|
121
|
+
backend: {
|
|
122
|
+
requestId: responseId,
|
|
123
|
+
rawResponseId,
|
|
124
|
+
adapter: backend.kind,
|
|
125
|
+
isSyntheticStream: true,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
yield factory.responseCompleted(response);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Item 事件发射 ─────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function* emitItemEvents(item: OutputItem, factory: ReturnType<typeof createEventFactory>): Generator<AIStreamEvent> {
|
|
135
|
+
switch (item.type) {
|
|
136
|
+
case "message":
|
|
137
|
+
yield* emitMessageEvents(item, factory);
|
|
138
|
+
break;
|
|
139
|
+
case "reasoning":
|
|
140
|
+
yield* emitReasoningEvents(item, factory);
|
|
141
|
+
break;
|
|
142
|
+
case "tool_call":
|
|
143
|
+
yield* emitToolCallEvents(item, factory);
|
|
144
|
+
break;
|
|
145
|
+
case "opaque":
|
|
146
|
+
// Opaque items in output have no streaming events
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function* emitMessageEvents(
|
|
152
|
+
item: MessageItem,
|
|
153
|
+
factory: ReturnType<typeof createEventFactory>,
|
|
154
|
+
): Generator<AIStreamEvent> {
|
|
155
|
+
const id = item.id ?? `syn-msg-${crypto.randomUUID()}`;
|
|
156
|
+
yield factory.messageStarted(id);
|
|
157
|
+
|
|
158
|
+
for (const block of item.content) {
|
|
159
|
+
if (block.type === "text") {
|
|
160
|
+
yield factory.messageDelta(id, block.text);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
yield factory.messageCompleted(item);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function* emitReasoningEvents(
|
|
168
|
+
item: ReasoningItem,
|
|
169
|
+
factory: ReturnType<typeof createEventFactory>,
|
|
170
|
+
): Generator<AIStreamEvent> {
|
|
171
|
+
const id = item.id ?? `syn-reason-${crypto.randomUUID()}`;
|
|
172
|
+
yield factory.reasoningStarted(id, item.visibility);
|
|
173
|
+
|
|
174
|
+
for (const block of item.content) {
|
|
175
|
+
if (block.type === "text") {
|
|
176
|
+
yield factory.reasoningDelta(id, block);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
yield factory.reasoningCompleted(item);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function* emitToolCallEvents(
|
|
184
|
+
item: ToolCallItem,
|
|
185
|
+
factory: ReturnType<typeof createEventFactory>,
|
|
186
|
+
): Generator<AIStreamEvent> {
|
|
187
|
+
yield factory.toolCallStarted(item.id, item.name);
|
|
188
|
+
|
|
189
|
+
if (item.argumentsText) {
|
|
190
|
+
yield factory.toolCallDelta(item.id, { argumentsText: item.argumentsText });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
yield factory.toolCallCompleted(item);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Helper ────────────────────────────────────────────────────
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nano-ai — 统一流式 AI 客户端
|
|
3
|
+
*
|
|
4
|
+
* 对外只暴露一个 canonical 主入口:client.stream()
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Re-export all canonical types
|
|
8
|
+
export * from "./types/index.js";
|
|
9
|
+
|
|
10
|
+
// Re-export core client API
|
|
11
|
+
export * from "./core/index.js";
|
|
12
|
+
|
|
13
|
+
// Re-export adapters
|
|
14
|
+
export * from "./adapters/index.js";
|
|
15
|
+
|
|
16
|
+
// Re-export helpers
|
|
17
|
+
export * from "./helpers/index.js";
|