@codehz/ai 0.2.0 → 0.2.1
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/README.md +9 -3
- package/dist/index.d.mts +151 -27
- package/dist/index.mjs +1284 -702
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/adapters/chat-completions.ts +243 -196
- package/src/adapters/messages.ts +150 -124
- package/src/adapters/mock.ts +44 -11
- package/src/adapters/ollama.ts +219 -191
- package/src/adapters/responses.ts +222 -137
- package/src/core/aggregator.ts +218 -61
- package/src/core/errors.ts +7 -1
- package/src/core/event-factory.ts +24 -14
- package/src/core/merge-auxiliary.ts +22 -0
- package/src/core/normalize.ts +15 -1
- package/src/core/validation.ts +29 -21
- package/src/helpers/adapter-base.ts +23 -25
- package/src/helpers/adapter-security.ts +126 -0
- package/src/helpers/incremental-stream-parser.ts +84 -0
- package/src/helpers/index.ts +19 -0
- package/src/helpers/request-mapper.ts +72 -0
- package/src/helpers/sse-parser.ts +51 -25
- package/src/helpers/synthetic-stream.ts +13 -21
- package/src/helpers/usage-mapping.ts +4 -9
- package/src/types/adapter.ts +12 -1
- package/src/types/events.ts +14 -10
- package/src/types/index.ts +9 -1
- package/src/types/response.ts +0 -1
|
@@ -25,10 +25,11 @@ import type {
|
|
|
25
25
|
ToolCallItem,
|
|
26
26
|
} from "../types/index.js";
|
|
27
27
|
import { createEventFactory } from "../core/event-factory.js";
|
|
28
|
-
import { AIMappingError, AIRequestError, AIStreamError } from "../core/errors.js";
|
|
28
|
+
import { AIMappingError, AIProviderError, AIRequestError, AIStreamError } from "../core/errors.js";
|
|
29
29
|
import type { EventFactory } from "../core/event-factory.js";
|
|
30
30
|
import { extractText } from "./mapping.js";
|
|
31
31
|
import { AdapterAuxiliaryState } from "./adapter-auxiliary.js";
|
|
32
|
+
import { mergeAuxiliary } from "../core/merge-auxiliary.js";
|
|
32
33
|
|
|
33
34
|
// ── Adapter 解析中间结果 ──────────────────────────────────────
|
|
34
35
|
|
|
@@ -55,7 +56,7 @@ export type StreamResult = {
|
|
|
55
56
|
|
|
56
57
|
export abstract class AdapterBase implements BackendAdapter {
|
|
57
58
|
abstract readonly kind: "chat-completions" | "messages" | "responses" | "ollama" | "mock";
|
|
58
|
-
abstract readonly
|
|
59
|
+
abstract readonly capabilities: import("../types/index.js").AdapterCapabilities;
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
62
|
* stream 模板方法:
|
|
@@ -66,7 +67,7 @@ export abstract class AdapterBase implements BackendAdapter {
|
|
|
66
67
|
async *stream(request: NormalizedRequest): AsyncIterable<AIStreamEvent> {
|
|
67
68
|
const factory = createEventFactory({
|
|
68
69
|
responseId: request.requestId,
|
|
69
|
-
backend: { kind: this.kind, isSynthetic:
|
|
70
|
+
backend: { kind: this.kind, isSynthetic: this.capabilities.textStreaming === "synthetic" },
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
yield factory.responseStarted(request.model);
|
|
@@ -75,11 +76,26 @@ export abstract class AdapterBase implements BackendAdapter {
|
|
|
75
76
|
const providerRequest = await this.buildRequest(request);
|
|
76
77
|
yield* this.runStream(providerRequest, factory, request);
|
|
77
78
|
} catch (err) {
|
|
78
|
-
if (err instanceof AIRequestError || err instanceof
|
|
79
|
+
if (err instanceof AIRequestError || err instanceof AIProviderError || err instanceof AIStreamError) {
|
|
79
80
|
throw err;
|
|
80
81
|
}
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
|
|
83
|
+
if (err instanceof AIMappingError) {
|
|
84
|
+
yield factory.responseWarning(err.message, "MAPPING_ERROR");
|
|
85
|
+
const errorResp = this.buildResponse(request, { output: [], replay: [] }, factory);
|
|
86
|
+
yield factory.responseCompleted({
|
|
87
|
+
replay: errorResp.replay,
|
|
88
|
+
stopReason: errorResp.stopReason,
|
|
89
|
+
trace: errorResp.backend,
|
|
90
|
+
usage: errorResp.usage,
|
|
91
|
+
billing: errorResp.billing,
|
|
92
|
+
auxiliary: errorResp.auxiliary,
|
|
93
|
+
warnings: errorResp.warnings,
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
throw err;
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
101
|
|
|
@@ -132,7 +148,7 @@ export abstract class AdapterBase implements BackendAdapter {
|
|
|
132
148
|
requestId: request.requestId,
|
|
133
149
|
rawResponseId: result.rawResponseId,
|
|
134
150
|
adapter: this.kind,
|
|
135
|
-
isSyntheticStream:
|
|
151
|
+
isSyntheticStream: this.capabilities.textStreaming === "synthetic",
|
|
136
152
|
metadataSources: result.metadataSources,
|
|
137
153
|
warnings,
|
|
138
154
|
},
|
|
@@ -149,24 +165,6 @@ export abstract class AdapterBase implements BackendAdapter {
|
|
|
149
165
|
}
|
|
150
166
|
}
|
|
151
167
|
|
|
152
|
-
function mergeAuxiliary(base?: Partial<AuxiliaryInfo>, patch?: Partial<AuxiliaryInfo>): AuxiliaryInfo | undefined {
|
|
153
|
-
if (!base && !patch) return undefined;
|
|
154
|
-
|
|
155
|
-
const merged: AuxiliaryInfo = {
|
|
156
|
-
...base,
|
|
157
|
-
...patch,
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
if (base?.providerMetadata || patch?.providerMetadata) {
|
|
161
|
-
merged.providerMetadata = {
|
|
162
|
-
...base?.providerMetadata,
|
|
163
|
-
...patch?.providerMetadata,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return merged;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
168
|
function mergeWarnings(...groups: Array<string[] | undefined>): string[] | undefined {
|
|
171
169
|
const merged: string[] = [];
|
|
172
170
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter 边界安全辅助
|
|
3
|
+
*
|
|
4
|
+
* - opaque replay 入站 envelope(大小 / 深度)
|
|
5
|
+
* - provider HTTP 错误 body 出站脱敏
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { AIProviderError, AIRequestError } from "../core/errors.js";
|
|
9
|
+
|
|
10
|
+
export const MAX_OPAQUE_PAYLOAD_BYTES = 65536;
|
|
11
|
+
export const MAX_OPAQUE_JSON_DEPTH = 8;
|
|
12
|
+
export const PROVIDER_ERROR_MESSAGE_MAX_LEN = 500;
|
|
13
|
+
export const PROVIDER_ERROR_RAW_BODY_THRESHOLD = 200;
|
|
14
|
+
|
|
15
|
+
export type OpaqueEnvelopeResult = { ok: true } | { ok: false; reason: string };
|
|
16
|
+
|
|
17
|
+
/** 测量 JSON 值嵌套深度(对象/数组);循环引用按已访问节点深度计。 */
|
|
18
|
+
export function measureJsonDepth(value: unknown, seen = new WeakSet<object>()): number {
|
|
19
|
+
if (value === null || typeof value !== "object") {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (seen.has(value)) {
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
seen.add(value);
|
|
27
|
+
|
|
28
|
+
let maxChild = 0;
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
for (const item of value) {
|
|
31
|
+
maxChild = Math.max(maxChild, measureJsonDepth(item, seen));
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
35
|
+
maxChild = Math.max(maxChild, measureJsonDepth((value as Record<string, unknown>)[key], seen));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return 1 + maxChild;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Opaque replay 通用 envelope:必须是 object、体积 ≤ 64KB、深度 ≤ 8。
|
|
44
|
+
* 不校验 adapter 专用字段形状。
|
|
45
|
+
*/
|
|
46
|
+
export function validateOpaqueReplayEnvelope(payload: unknown): OpaqueEnvelopeResult {
|
|
47
|
+
if (typeof payload !== "object" || payload === null) {
|
|
48
|
+
return { ok: false, reason: "payload must be an object" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let raw: string;
|
|
52
|
+
try {
|
|
53
|
+
raw = JSON.stringify(payload);
|
|
54
|
+
} catch {
|
|
55
|
+
return { ok: false, reason: "payload is not JSON-serializable" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (raw === undefined) {
|
|
59
|
+
return { ok: false, reason: "payload is not JSON-serializable" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (raw.length > MAX_OPAQUE_PAYLOAD_BYTES) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: `opaque payload exceeds max size (${raw.length} > ${MAX_OPAQUE_PAYLOAD_BYTES})`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const depth = measureJsonDepth(payload);
|
|
70
|
+
if (depth > MAX_OPAQUE_JSON_DEPTH) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: `opaque payload nesting depth (${depth}) exceeds max (${MAX_OPAQUE_JSON_DEPTH})`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { ok: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** envelope 失败时抛 AIRequestError。 */
|
|
81
|
+
export function assertOpaqueReplayEnvelope(payload: unknown): void {
|
|
82
|
+
const result = validateOpaqueReplayEnvelope(payload);
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
throw new AIRequestError(`Invalid opaque replay payload: ${result.reason}`, "INVALID_OPAQUE_REPLAY");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 从 provider HTTP 错误 body 提取可对外暴露的短消息,避免泄漏 HTML / 内部路径等。
|
|
90
|
+
*/
|
|
91
|
+
export function extractProviderErrorMessage(body: string, status: number): string {
|
|
92
|
+
if (!body) return `HTTP ${status}`;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const parsed: unknown = JSON.parse(body);
|
|
96
|
+
if (parsed && typeof parsed === "object") {
|
|
97
|
+
const record = parsed as Record<string, unknown>;
|
|
98
|
+
const errorField = record.error;
|
|
99
|
+
let msg: unknown;
|
|
100
|
+
if (errorField && typeof errorField === "object" && errorField !== null) {
|
|
101
|
+
msg = (errorField as Record<string, unknown>).message;
|
|
102
|
+
}
|
|
103
|
+
if (typeof msg !== "string") {
|
|
104
|
+
msg = typeof errorField === "string" ? errorField : record.message;
|
|
105
|
+
}
|
|
106
|
+
if (typeof msg === "string" && msg.length > 0) {
|
|
107
|
+
return msg.slice(0, PROVIDER_ERROR_MESSAGE_MAX_LEN);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// not JSON
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const trimmed = body.trimStart();
|
|
115
|
+
if (trimmed.startsWith("<!") || trimmed.startsWith("<html") || body.length > PROVIDER_ERROR_RAW_BODY_THRESHOLD) {
|
|
116
|
+
return `HTTP ${status}. Body omitted (${body.length} bytes)`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return body.slice(0, PROVIDER_ERROR_MESSAGE_MAX_LEN);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 统一构造脱敏后的 AIProviderError。 */
|
|
123
|
+
export function providerHttpError(status: number, body: string): AIProviderError {
|
|
124
|
+
const safe = extractProviderErrorMessage(body, status);
|
|
125
|
+
return new AIProviderError(`Provider returned ${status}: ${safe}`, "PROVIDER_ERROR", status, safe);
|
|
126
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export type StreamSplitResult = {
|
|
2
|
+
items: string[];
|
|
3
|
+
rest: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type StreamParseResult<T> = { status: "parsed"; value: T } | { status: "ignored" } | { status: "malformed" };
|
|
7
|
+
|
|
8
|
+
export class IncrementalStreamParser<T> {
|
|
9
|
+
private buffer = "";
|
|
10
|
+
private readonly decoder = new TextDecoder();
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly split: (buffer: string, allowEOF: boolean) => StreamSplitResult,
|
|
14
|
+
private readonly parse: (item: string) => StreamParseResult<T>,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
feed(value: Uint8Array): { items: T[]; malformed: number } {
|
|
18
|
+
this.buffer += this.decoder.decode(value, { stream: true });
|
|
19
|
+
return this.consume(false);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
flush(): { items: T[]; malformed: number } {
|
|
23
|
+
this.buffer += this.decoder.decode();
|
|
24
|
+
return this.consume(true);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getRemaining(): string {
|
|
28
|
+
return this.buffer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private consume(allowEOF: boolean): { items: T[]; malformed: number } {
|
|
32
|
+
const split = this.split(this.buffer, allowEOF);
|
|
33
|
+
this.buffer = split.rest;
|
|
34
|
+
const items: T[] = [];
|
|
35
|
+
let malformed = 0;
|
|
36
|
+
|
|
37
|
+
for (const rawItem of split.items) {
|
|
38
|
+
const result = this.parse(rawItem);
|
|
39
|
+
if (result.status === "parsed") items.push(result.value);
|
|
40
|
+
else if (result.status === "malformed") malformed++;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { items, malformed };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function splitLines(buffer: string, allowEOF: boolean): StreamSplitResult {
|
|
48
|
+
const items: string[] = [];
|
|
49
|
+
let cursor = 0;
|
|
50
|
+
|
|
51
|
+
while (true) {
|
|
52
|
+
const lineEnd = buffer.indexOf("\n", cursor);
|
|
53
|
+
if (lineEnd === -1) break;
|
|
54
|
+
items.push(buffer.slice(cursor, lineEnd));
|
|
55
|
+
cursor = lineEnd + 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (allowEOF && cursor < buffer.length) {
|
|
59
|
+
items.push(buffer.slice(cursor));
|
|
60
|
+
cursor = buffer.length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { items, rest: buffer.slice(cursor) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function splitSSEFrames(buffer: string, allowEOF: boolean): StreamSplitResult {
|
|
67
|
+
const normalized = buffer.replaceAll("\r\n", "\n");
|
|
68
|
+
const items: string[] = [];
|
|
69
|
+
let cursor = 0;
|
|
70
|
+
|
|
71
|
+
while (true) {
|
|
72
|
+
const frameEnd = normalized.indexOf("\n\n", cursor);
|
|
73
|
+
if (frameEnd === -1) break;
|
|
74
|
+
items.push(normalized.slice(cursor, frameEnd));
|
|
75
|
+
cursor = frameEnd + 2;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (allowEOF && cursor < normalized.length) {
|
|
79
|
+
items.push(normalized.slice(cursor));
|
|
80
|
+
cursor = normalized.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { items, rest: normalized.slice(cursor) };
|
|
84
|
+
}
|
package/src/helpers/index.ts
CHANGED
|
@@ -40,3 +40,22 @@ export {
|
|
|
40
40
|
usageFromOllama,
|
|
41
41
|
usageFromOpenAIResponses,
|
|
42
42
|
} from "./usage-mapping.js";
|
|
43
|
+
|
|
44
|
+
export {
|
|
45
|
+
assertOpaqueReplayEnvelope,
|
|
46
|
+
extractProviderErrorMessage,
|
|
47
|
+
measureJsonDepth,
|
|
48
|
+
providerHttpError,
|
|
49
|
+
validateOpaqueReplayEnvelope,
|
|
50
|
+
MAX_OPAQUE_JSON_DEPTH,
|
|
51
|
+
MAX_OPAQUE_PAYLOAD_BYTES,
|
|
52
|
+
PROVIDER_ERROR_MESSAGE_MAX_LEN,
|
|
53
|
+
PROVIDER_ERROR_RAW_BODY_THRESHOLD,
|
|
54
|
+
} from "./adapter-security.js";
|
|
55
|
+
export type { OpaqueEnvelopeResult } from "./adapter-security.js";
|
|
56
|
+
|
|
57
|
+
export { IncrementalStreamParser, splitLines, splitSSEFrames } from "./incremental-stream-parser.js";
|
|
58
|
+
export type { StreamSplitResult, StreamParseResult } from "./incremental-stream-parser.js";
|
|
59
|
+
|
|
60
|
+
export { NormalizedRequestMapper } from "./request-mapper.js";
|
|
61
|
+
export type { ProviderProfile } from "./request-mapper.js";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { AIRequestError } from "../core/errors.js";
|
|
2
|
+
import { contentBlocksToText } from "./mapping.js";
|
|
3
|
+
|
|
4
|
+
import type { AdapterCapabilities, ContentBlock, InstructionBlock, ToolResultItem } from "../types/index.js";
|
|
5
|
+
|
|
6
|
+
export type ProviderProfile = {
|
|
7
|
+
readonly kind: string;
|
|
8
|
+
readonly instructionsMode: "system_message" | "instructions_field" | "none";
|
|
9
|
+
readonly supportedBlockTypes: ReadonlyArray<ContentBlock["type"]>;
|
|
10
|
+
readonly reasoningBlockTypes: ReadonlyArray<ContentBlock["type"]>;
|
|
11
|
+
readonly capabilities: AdapterCapabilities;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class NormalizedRequestMapper {
|
|
15
|
+
constructor(readonly profile: ProviderProfile) {}
|
|
16
|
+
|
|
17
|
+
mapInstructions(instructions: string | InstructionBlock[]): string {
|
|
18
|
+
return typeof instructions === "string"
|
|
19
|
+
? instructions
|
|
20
|
+
: contentBlocksToText(this.ensureTextBlocks(instructions, "instructions"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
ensureTextBlocks(blocks: ContentBlock[], field: string): ContentBlock[] {
|
|
24
|
+
return this.ensureBlocks(blocks, field, this.profile.supportedBlockTypes, "only text/json blocks are supported");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ensureReasoningBlocks(blocks: ContentBlock[], field: string): Array<Extract<ContentBlock, { type: "text" }>> {
|
|
28
|
+
return this.ensureBlocks(
|
|
29
|
+
blocks,
|
|
30
|
+
field,
|
|
31
|
+
this.profile.reasoningBlockTypes,
|
|
32
|
+
"reasoning only supports text blocks",
|
|
33
|
+
) as Array<Extract<ContentBlock, { type: "text" }>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
assertToolResultOutcome(outcome: ToolResultItem["outcome"]): void {
|
|
37
|
+
if (this.profile.capabilities.toolResultOutcomes.includes(outcome)) return;
|
|
38
|
+
|
|
39
|
+
const outcomes = this.profile.capabilities.toolResultOutcomes;
|
|
40
|
+
const supported = outcomes.map((value) => `"${value}"`).join(" and ");
|
|
41
|
+
const verb = outcomes.length > 1 ? "are" : "is";
|
|
42
|
+
throw new AIRequestError(
|
|
43
|
+
`${this.profile.kind} does not preserve tool_result outcome "${outcome}"; only ${supported} ${verb} supported`,
|
|
44
|
+
"UNSUPPORTED_TOOL_RESULT_OUTCOME",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
rollbackTrailingAssistantMessages<T extends { role: string }>(messages: T[]): void {
|
|
49
|
+
while (messages.length > 0 && messages[messages.length - 1]?.role === "assistant") {
|
|
50
|
+
messages.pop();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private ensureBlocks(
|
|
55
|
+
blocks: ContentBlock[],
|
|
56
|
+
field: string,
|
|
57
|
+
supportedTypes: ReadonlyArray<ContentBlock["type"]>,
|
|
58
|
+
description: string,
|
|
59
|
+
): ContentBlock[] {
|
|
60
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
61
|
+
const block = blocks[i];
|
|
62
|
+
if (block && !supportedTypes.includes(block.type)) {
|
|
63
|
+
throw new AIRequestError(
|
|
64
|
+
`${this.profile.kind} does not support ${field}[${i}] of type "${block.type}"; ${description}`,
|
|
65
|
+
"UNSUPPORTED_CONTENT_BLOCK",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return blocks;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -27,6 +27,10 @@ export type SSEParseResult = {
|
|
|
27
27
|
malformedEvents: number;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
export type ParseSSEOptions = {
|
|
31
|
+
allowEOF?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
30
34
|
/**
|
|
31
35
|
* 将 SSE 文本块解析为事件数组。
|
|
32
36
|
* 累积事件行直到遇到空行,支持 [DONE] 标记。
|
|
@@ -37,7 +41,7 @@ export type SSEParseResult = {
|
|
|
37
41
|
* - 未完成的行保留在 rest 中,等待下次 chunk 补全
|
|
38
42
|
* - 支持跨 chunk 的 event 分片
|
|
39
43
|
*/
|
|
40
|
-
export function parseSSEEvents(chunk: string): SSEParseResult {
|
|
44
|
+
export function parseSSEEvents(chunk: string, options: ParseSSEOptions = {}): SSEParseResult {
|
|
41
45
|
const events: SSEEvent[] = [];
|
|
42
46
|
let eventType = "";
|
|
43
47
|
let dataLines: string[] = [];
|
|
@@ -45,6 +49,39 @@ export function parseSSEEvents(chunk: string): SSEParseResult {
|
|
|
45
49
|
let cursor = 0;
|
|
46
50
|
let malformedEvents = 0;
|
|
47
51
|
|
|
52
|
+
const emitEvent = (consumedCursor: number): void => {
|
|
53
|
+
const dataStr = dataLines.join("\n");
|
|
54
|
+
if (dataStr === "[DONE]") {
|
|
55
|
+
eventType = "";
|
|
56
|
+
dataLines = [];
|
|
57
|
+
consumedUntil = consumedCursor;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const data = JSON.parse(dataStr);
|
|
63
|
+
events.push({ type: eventType, data });
|
|
64
|
+
} catch {
|
|
65
|
+
malformedEvents++;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
eventType = "";
|
|
69
|
+
dataLines = [];
|
|
70
|
+
consumedUntil = consumedCursor;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const consumeLine = (line: string, consumedCursor: number): void => {
|
|
74
|
+
if (line.startsWith("event: ")) {
|
|
75
|
+
eventType = line.slice(7).trim();
|
|
76
|
+
} else if (line.startsWith("data: ")) {
|
|
77
|
+
dataLines.push(line.slice(6));
|
|
78
|
+
} else if (line === "" && eventType && dataLines.length > 0) {
|
|
79
|
+
emitEvent(consumedCursor);
|
|
80
|
+
} else if (line === "" && !eventType && dataLines.length === 0) {
|
|
81
|
+
consumedUntil = consumedCursor;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
48
85
|
while (cursor < chunk.length) {
|
|
49
86
|
const lineEnd = chunk.indexOf("\n", cursor);
|
|
50
87
|
if (lineEnd === -1) break;
|
|
@@ -56,31 +93,20 @@ export function parseSSEEvents(chunk: string): SSEParseResult {
|
|
|
56
93
|
line = line.slice(0, -1);
|
|
57
94
|
}
|
|
58
95
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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;
|
|
96
|
+
consumeLine(line, cursor);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (options.allowEOF && cursor < chunk.length) {
|
|
100
|
+
let line = chunk.slice(cursor);
|
|
101
|
+
if (line.endsWith("\r")) {
|
|
102
|
+
line = line.slice(0, -1);
|
|
83
103
|
}
|
|
104
|
+
consumeLine(line, chunk.length);
|
|
105
|
+
cursor = chunk.length;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (options.allowEOF && eventType && dataLines.length > 0) {
|
|
109
|
+
emitEvent(chunk.length);
|
|
84
110
|
}
|
|
85
111
|
|
|
86
112
|
return { events, rest: chunk.slice(consumedUntil), malformedEvents };
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { createEventFactory } from "../core/event-factory.js";
|
|
17
|
-
import { replayFromOutput
|
|
17
|
+
import { replayFromOutput } from "./mapping.js";
|
|
18
18
|
|
|
19
19
|
import type {
|
|
20
20
|
OutputItem,
|
|
@@ -23,10 +23,10 @@ import type {
|
|
|
23
23
|
Usage,
|
|
24
24
|
BillingInfo,
|
|
25
25
|
AIStreamEvent,
|
|
26
|
-
AIResponse,
|
|
27
26
|
MessageItem,
|
|
28
27
|
ReasoningItem,
|
|
29
28
|
ToolCallItem,
|
|
29
|
+
OpaqueItem,
|
|
30
30
|
} from "../types/index.js";
|
|
31
31
|
|
|
32
32
|
// ── 输入参数 ──────────────────────────────────────────────────
|
|
@@ -99,7 +99,7 @@ export async function* syntheticStream(options: SyntheticStreamOptions): AsyncIt
|
|
|
99
99
|
yield factory.responseAuxiliary({ usage, billing });
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// 4. 构建最终
|
|
102
|
+
// 4. 构建最终 completion 并发射
|
|
103
103
|
const finalReplay = replay ?? replayFromOutput(output);
|
|
104
104
|
|
|
105
105
|
// 收集警告
|
|
@@ -107,26 +107,22 @@ export async function* syntheticStream(options: SyntheticStreamOptions): AsyncIt
|
|
|
107
107
|
allWarnings.push("Response is synthetically streamed; delta granularity may differ from native streaming");
|
|
108
108
|
if (extraWarnings) allWarnings.push(...extraWarnings);
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
id: responseId,
|
|
112
|
-
output,
|
|
110
|
+
yield factory.responseCompleted({
|
|
113
111
|
replay: finalReplay,
|
|
114
|
-
text: extractText(output),
|
|
115
|
-
toolCalls: output.filter((item): item is ToolCallItem => item.type === "tool_call"),
|
|
116
112
|
stopReason,
|
|
117
113
|
usage,
|
|
118
114
|
billing,
|
|
119
115
|
auxiliary: providerMetadata ? { providerMetadata } : undefined,
|
|
116
|
+
opaqueOutput: output.filter((item): item is OpaqueItem => item.type === "opaque"),
|
|
120
117
|
warnings: allWarnings.length > 0 ? allWarnings : undefined,
|
|
121
|
-
|
|
118
|
+
trace: {
|
|
122
119
|
requestId: responseId,
|
|
123
120
|
rawResponseId,
|
|
124
121
|
adapter: backend.kind,
|
|
125
122
|
isSyntheticStream: true,
|
|
123
|
+
warnings: allWarnings.length > 0 ? allWarnings : undefined,
|
|
126
124
|
},
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
yield factory.responseCompleted(response);
|
|
125
|
+
});
|
|
130
126
|
}
|
|
131
127
|
|
|
132
128
|
// ── Item 事件发射 ─────────────────────────────────────────────
|
|
@@ -156,12 +152,10 @@ function* emitMessageEvents(
|
|
|
156
152
|
yield factory.messageStarted(id);
|
|
157
153
|
|
|
158
154
|
for (const block of item.content) {
|
|
159
|
-
|
|
160
|
-
yield factory.messageDelta(id, block.text);
|
|
161
|
-
}
|
|
155
|
+
yield factory.messageDelta(id, block);
|
|
162
156
|
}
|
|
163
157
|
|
|
164
|
-
yield factory.messageCompleted(
|
|
158
|
+
yield factory.messageCompleted(id);
|
|
165
159
|
}
|
|
166
160
|
|
|
167
161
|
function* emitReasoningEvents(
|
|
@@ -172,12 +166,10 @@ function* emitReasoningEvents(
|
|
|
172
166
|
yield factory.reasoningStarted(id, item.visibility);
|
|
173
167
|
|
|
174
168
|
for (const block of item.content) {
|
|
175
|
-
|
|
176
|
-
yield factory.reasoningDelta(id, block);
|
|
177
|
-
}
|
|
169
|
+
yield factory.reasoningDelta(id, block);
|
|
178
170
|
}
|
|
179
171
|
|
|
180
|
-
yield factory.reasoningCompleted(
|
|
172
|
+
yield factory.reasoningCompleted(id);
|
|
181
173
|
}
|
|
182
174
|
|
|
183
175
|
function* emitToolCallEvents(
|
|
@@ -190,7 +182,7 @@ function* emitToolCallEvents(
|
|
|
190
182
|
yield factory.toolCallDelta(item.id, { argumentsText: item.argumentsText });
|
|
191
183
|
}
|
|
192
184
|
|
|
193
|
-
yield factory.toolCallCompleted(item);
|
|
185
|
+
yield factory.toolCallCompleted(item.id);
|
|
194
186
|
}
|
|
195
187
|
|
|
196
188
|
// ── Helper ────────────────────────────────────────────────────
|
|
@@ -88,8 +88,7 @@ export function usageFromAnthropicMessages(raw: {
|
|
|
88
88
|
(n): n is number => n !== undefined,
|
|
89
89
|
);
|
|
90
90
|
const inputTokens = inputParts.length > 0 ? inputParts.reduce((sum, n) => sum + n, 0) : undefined;
|
|
91
|
-
const totalTokens =
|
|
92
|
-
inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined;
|
|
91
|
+
const totalTokens = inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined;
|
|
93
92
|
|
|
94
93
|
return record({
|
|
95
94
|
inputTokens,
|
|
@@ -101,18 +100,14 @@ export function usageFromAnthropicMessages(raw: {
|
|
|
101
100
|
}
|
|
102
101
|
|
|
103
102
|
/** Ollama 流式 chunk */
|
|
104
|
-
export function usageFromOllama(raw: {
|
|
105
|
-
prompt_eval_count?: number;
|
|
106
|
-
eval_count?: number;
|
|
107
|
-
}): Partial<Usage> {
|
|
103
|
+
export function usageFromOllama(raw: { prompt_eval_count?: number; eval_count?: number }): Partial<Usage> {
|
|
108
104
|
const inputTokens = num(raw.prompt_eval_count);
|
|
109
105
|
const outputTokens = num(raw.eval_count);
|
|
110
|
-
const totalTokens =
|
|
111
|
-
inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined;
|
|
106
|
+
const totalTokens = inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined;
|
|
112
107
|
|
|
113
108
|
return record({
|
|
114
109
|
inputTokens,
|
|
115
110
|
outputTokens,
|
|
116
111
|
totalTokens,
|
|
117
112
|
});
|
|
118
|
-
}
|
|
113
|
+
}
|
package/src/types/adapter.ts
CHANGED
|
@@ -21,9 +21,20 @@ export type NormalizedRequest = AIRequest & {
|
|
|
21
21
|
|
|
22
22
|
// ── Adapter 接口 ──────────────────────────────────────────────
|
|
23
23
|
|
|
24
|
+
export type StreamingCapability = "native" | "synthetic" | "none";
|
|
25
|
+
|
|
26
|
+
export type AdapterCapabilities = {
|
|
27
|
+
readonly textStreaming: StreamingCapability;
|
|
28
|
+
readonly reasoningStreaming: StreamingCapability;
|
|
29
|
+
readonly toolCallStreaming: StreamingCapability;
|
|
30
|
+
readonly replay: "canonical" | "opaque" | "none";
|
|
31
|
+
readonly usage: "stream" | "final" | "none";
|
|
32
|
+
readonly toolResultOutcomes: ReadonlyArray<"success" | "error" | "rejected">;
|
|
33
|
+
};
|
|
34
|
+
|
|
24
35
|
export interface BackendAdapter {
|
|
25
36
|
readonly kind: "chat-completions" | "messages" | "responses" | "ollama" | "mock";
|
|
26
|
-
readonly
|
|
37
|
+
readonly capabilities: AdapterCapabilities;
|
|
27
38
|
stream(request: NormalizedRequest): AsyncIterable<AIStreamEvent>;
|
|
28
39
|
}
|
|
29
40
|
|