@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.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * 请求校验
3
+ *
4
+ * 在请求进入 adapter 前对参数合法性做基础检查。
5
+ * 校验失败时抛 AIRequestError。
6
+ */
7
+
8
+ import type { AIRequest } from "../types/index.js";
9
+ import { AIRequestError } from "./errors.js";
10
+
11
+ export type ValidationIssue = {
12
+ field: string;
13
+ code: string;
14
+ message: string;
15
+ };
16
+
17
+ const MESSAGE_ROLES = new Set(["user", "assistant", "system", "developer"]);
18
+ const REASONING_VISIBILITIES = new Set(["full", "summary", "redacted", "opaque"]);
19
+ const TOOL_RESULT_OUTCOMES = new Set(["success", "error", "rejected"]);
20
+ const INCLUDE_MODES = new Set(["off", "best_effort"]);
21
+
22
+ function isRecord(value: unknown): value is Record<string, unknown> {
23
+ return typeof value === "object" && value !== null;
24
+ }
25
+
26
+ function pushIssue(issues: ValidationIssue[], field: string, code: string, message: string): void {
27
+ issues.push({ field, code, message });
28
+ }
29
+
30
+ function validateContentBlock(block: unknown, field: string, issues: ValidationIssue[]): void {
31
+ if (!isRecord(block) || typeof block.type !== "string") {
32
+ pushIssue(issues, field, "CONTENT_BLOCK_INVALID", `${field} must be a valid ContentBlock`);
33
+ return;
34
+ }
35
+
36
+ switch (block.type) {
37
+ case "text":
38
+ if (typeof block.text !== "string") {
39
+ pushIssue(issues, field, "CONTENT_BLOCK_INVALID", `${field}.text must be a string`);
40
+ }
41
+ return;
42
+ case "json":
43
+ if (!("json" in block)) {
44
+ pushIssue(issues, field, "CONTENT_BLOCK_INVALID", `${field}.json must be present`);
45
+ }
46
+ return;
47
+ case "image":
48
+ if (typeof block.imageUrl !== "string" || block.imageUrl.length === 0) {
49
+ pushIssue(issues, field, "CONTENT_BLOCK_INVALID", `${field}.imageUrl must be a non-empty string`);
50
+ }
51
+ return;
52
+ case "binary_ref":
53
+ if (typeof block.ref !== "string" || block.ref.length === 0) {
54
+ pushIssue(issues, field, "CONTENT_BLOCK_INVALID", `${field}.ref must be a non-empty string`);
55
+ }
56
+ return;
57
+ case "opaque":
58
+ if (!("payload" in block)) {
59
+ pushIssue(issues, field, "CONTENT_BLOCK_INVALID", `${field}.payload must be present`);
60
+ }
61
+ return;
62
+ default:
63
+ pushIssue(issues, field, "CONTENT_BLOCK_INVALID", `${field}.type "${block.type}" is not supported`);
64
+ }
65
+ }
66
+
67
+ function validateContentArray(content: unknown, field: string, issues: ValidationIssue[], code: string): void {
68
+ if (!Array.isArray(content)) {
69
+ pushIssue(issues, field, code, `${field} must be a ContentBlock[]`);
70
+ return;
71
+ }
72
+
73
+ for (let i = 0; i < content.length; i++) {
74
+ validateContentBlock(content[i], `${field}[${i}]`, issues);
75
+ }
76
+ }
77
+
78
+ function validateInputItem(item: unknown, field: string, issues: ValidationIssue[]): void {
79
+ if (!isRecord(item)) {
80
+ pushIssue(issues, field, "INPUT_INVALID_ITEM", `${field} must be a valid InputItem`);
81
+ return;
82
+ }
83
+
84
+ if (typeof item.type !== "string") {
85
+ pushIssue(issues, field, "INPUT_ITEM_UNKNOWN_TYPE", `${field}.type must be a supported InputItem type`);
86
+ return;
87
+ }
88
+
89
+ switch (item.type) {
90
+ case "message":
91
+ if (typeof item.role !== "string" || !MESSAGE_ROLES.has(item.role)) {
92
+ pushIssue(issues, `${field}.role`, "MESSAGE_ROLE_INVALID", `${field}.role must be a valid message role`);
93
+ }
94
+ validateContentArray(item.content, `${field}.content`, issues, "MESSAGE_CONTENT_INVALID");
95
+ return;
96
+ case "reasoning":
97
+ if (typeof item.visibility !== "string" || !REASONING_VISIBILITIES.has(item.visibility)) {
98
+ pushIssue(
99
+ issues,
100
+ `${field}.visibility`,
101
+ "REASONING_VISIBILITY_INVALID",
102
+ `${field}.visibility must be a valid reasoning visibility`,
103
+ );
104
+ }
105
+ validateContentArray(item.content, `${field}.content`, issues, "REASONING_CONTENT_INVALID");
106
+ return;
107
+ case "tool_call":
108
+ if (typeof item.id !== "string" || item.id.length === 0) {
109
+ pushIssue(issues, `${field}.id`, "TOOL_CALL_ID_INVALID", `${field}.id must be a non-empty string`);
110
+ }
111
+ if (typeof item.name !== "string" || item.name.length === 0) {
112
+ pushIssue(issues, `${field}.name`, "TOOL_CALL_NAME_INVALID", `${field}.name must be a non-empty string`);
113
+ }
114
+ if (typeof item.argumentsText !== "string") {
115
+ pushIssue(
116
+ issues,
117
+ `${field}.argumentsText`,
118
+ "TOOL_CALL_ARGUMENTS_INVALID",
119
+ `${field}.argumentsText must be a string`,
120
+ );
121
+ }
122
+ return;
123
+ case "tool_result":
124
+ if (typeof item.callId !== "string" || item.callId.length === 0) {
125
+ pushIssue(issues, `${field}.callId`, "TOOL_RESULT_CALL_ID_INVALID", `${field}.callId must be a non-empty string`);
126
+ }
127
+ if (typeof item.toolName !== "string" || item.toolName.length === 0) {
128
+ pushIssue(issues, `${field}.toolName`, "TOOL_RESULT_NAME_INVALID", `${field}.toolName must be a non-empty string`);
129
+ }
130
+ if (typeof item.outcome !== "string" || !TOOL_RESULT_OUTCOMES.has(item.outcome)) {
131
+ pushIssue(
132
+ issues,
133
+ `${field}.outcome`,
134
+ "TOOL_RESULT_OUTCOME_INVALID",
135
+ `${field}.outcome must be success, error, or rejected`,
136
+ );
137
+ }
138
+ validateContentArray(item.content, `${field}.content`, issues, "TOOL_RESULT_CONTENT_INVALID");
139
+ return;
140
+ case "opaque":
141
+ if (typeof item.source !== "string" || item.source.length === 0) {
142
+ pushIssue(issues, `${field}.source`, "OPAQUE_SOURCE_INVALID", `${field}.source must be a non-empty string`);
143
+ }
144
+ if (typeof item.purpose !== "string" || item.purpose.length === 0) {
145
+ pushIssue(issues, `${field}.purpose`, "OPAQUE_PURPOSE_INVALID", `${field}.purpose must be a non-empty string`);
146
+ }
147
+ return;
148
+ default:
149
+ pushIssue(issues, `${field}.type`, "INPUT_ITEM_UNKNOWN_TYPE", `${field}.type "${item.type}" is not supported`);
150
+ }
151
+ }
152
+
153
+ function validateTools(tools: unknown, issues: ValidationIssue[]): void {
154
+ if (tools === undefined) return;
155
+ if (!Array.isArray(tools)) {
156
+ pushIssue(issues, "tools", "TOOLS_INVALID", "tools must be an array");
157
+ return;
158
+ }
159
+
160
+ const seenNames = new Set<string>();
161
+ for (let i = 0; i < tools.length; i++) {
162
+ const tool = tools[i];
163
+ const field = `tools[${i}]`;
164
+ if (!isRecord(tool)) {
165
+ pushIssue(issues, field, "TOOL_INVALID", `${field} must be a valid ToolDefinition`);
166
+ continue;
167
+ }
168
+
169
+ if (typeof tool.name !== "string" || tool.name.length === 0) {
170
+ pushIssue(issues, `${field}.name`, "TOOL_NAME_INVALID", `${field}.name must be a non-empty string`);
171
+ } else {
172
+ if (seenNames.has(tool.name)) {
173
+ pushIssue(issues, `${field}.name`, "TOOLS_DUPLICATE_NAME", `tool name "${tool.name}" is duplicated`);
174
+ }
175
+ seenNames.add(tool.name);
176
+ }
177
+
178
+ if (tool.description !== undefined && typeof tool.description !== "string") {
179
+ pushIssue(issues, `${field}.description`, "TOOL_DESCRIPTION_INVALID", `${field}.description must be a string`);
180
+ }
181
+
182
+ if (!isRecord(tool.inputSchema)) {
183
+ pushIssue(
184
+ issues,
185
+ `${field}.inputSchema`,
186
+ "TOOL_INPUT_SCHEMA_INVALID",
187
+ `${field}.inputSchema must be an object`,
188
+ );
189
+ }
190
+ }
191
+ }
192
+
193
+ function validateToolChoice(toolChoice: unknown, issues: ValidationIssue[]): void {
194
+ if (toolChoice === undefined) return;
195
+ if (toolChoice === "auto" || toolChoice === "none") return;
196
+ if (!isRecord(toolChoice) || toolChoice.type !== "tool" || typeof toolChoice.name !== "string" || toolChoice.name.length === 0) {
197
+ pushIssue(issues, "toolChoice", "TOOL_CHOICE_INVALID", "toolChoice must be auto, none, or { type: \"tool\", name }");
198
+ }
199
+ }
200
+
201
+ /**
202
+ * 校验 AIRequest,返回校验问题列表。
203
+ * 空数组表示无问题。
204
+ */
205
+ export function validateRequest(request: AIRequest): ValidationIssue[] {
206
+ const issues: ValidationIssue[] = [];
207
+
208
+ if (request.instructions !== undefined) {
209
+ if (typeof request.instructions === "string") {
210
+ // no-op
211
+ } else if (Array.isArray(request.instructions)) {
212
+ validateContentArray(request.instructions, "instructions", issues, "INSTRUCTIONS_INVALID");
213
+ } else {
214
+ pushIssue(
215
+ issues,
216
+ "instructions",
217
+ "INSTRUCTIONS_INVALID",
218
+ "instructions must be a string or ContentBlock[]",
219
+ );
220
+ }
221
+ }
222
+
223
+ // input 非空约束
224
+ if (!Array.isArray(request.input) || request.input.length === 0) {
225
+ pushIssue(issues, "input", "INPUT_EMPTY", "input must be a non-empty array");
226
+ }
227
+
228
+ // input 元素类型检查
229
+ if (Array.isArray(request.input)) {
230
+ for (let i = 0; i < request.input.length; i++) {
231
+ validateInputItem(request.input[i], `input[${i}]`, issues);
232
+ }
233
+ }
234
+
235
+ // temperature 范围
236
+ if (request.temperature !== undefined) {
237
+ if (typeof request.temperature !== "number" || isNaN(request.temperature)) {
238
+ issues.push({
239
+ field: "temperature",
240
+ code: "TEMPERATURE_NOT_NUMBER",
241
+ message: "temperature must be a number",
242
+ });
243
+ } else if (request.temperature < 0 || request.temperature > 2) {
244
+ issues.push({
245
+ field: "temperature",
246
+ code: "TEMPERATURE_OUT_OF_RANGE",
247
+ message: "temperature must be between 0 and 2",
248
+ });
249
+ }
250
+ }
251
+
252
+ // maxOutputTokens 合法性
253
+ if (request.maxOutputTokens !== undefined) {
254
+ if (typeof request.maxOutputTokens !== "number" || isNaN(request.maxOutputTokens)) {
255
+ issues.push({
256
+ field: "maxOutputTokens",
257
+ code: "MAX_OUTPUT_TOKENS_NOT_NUMBER",
258
+ message: "maxOutputTokens must be a number",
259
+ });
260
+ } else if (!Number.isInteger(request.maxOutputTokens) || request.maxOutputTokens < 1) {
261
+ issues.push({
262
+ field: "maxOutputTokens",
263
+ code: "MAX_OUTPUT_TOKENS_INVALID",
264
+ message: "maxOutputTokens must be a positive integer",
265
+ });
266
+ }
267
+ }
268
+
269
+ if (request.include !== undefined) {
270
+ if (!isRecord(request.include)) {
271
+ pushIssue(issues, "include", "INCLUDE_INVALID", "include must be an object");
272
+ } else {
273
+ if (request.include.usage !== undefined && !INCLUDE_MODES.has(request.include.usage)) {
274
+ pushIssue(issues, "include.usage", "INCLUDE_USAGE_INVALID", "include.usage must be off or best_effort");
275
+ }
276
+ if (request.include.billing !== undefined && !INCLUDE_MODES.has(request.include.billing)) {
277
+ pushIssue(issues, "include.billing", "INCLUDE_BILLING_INVALID", "include.billing must be off or best_effort");
278
+ }
279
+ if (request.include.providerMetadata !== undefined && !INCLUDE_MODES.has(request.include.providerMetadata)) {
280
+ pushIssue(
281
+ issues,
282
+ "include.providerMetadata",
283
+ "INCLUDE_PROVIDER_METADATA_INVALID",
284
+ "include.providerMetadata must be off or best_effort",
285
+ );
286
+ }
287
+ }
288
+ }
289
+
290
+ if (request.metadata !== undefined) {
291
+ if (!isRecord(request.metadata)) {
292
+ pushIssue(issues, "metadata", "METADATA_INVALID", "metadata must be an object");
293
+ } else {
294
+ for (const [key, value] of Object.entries(request.metadata)) {
295
+ if (typeof value !== "string") {
296
+ pushIssue(issues, `metadata.${key}`, "METADATA_VALUE_INVALID", `metadata.${key} must be a string`);
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ validateTools(request.tools, issues);
303
+ validateToolChoice(request.toolChoice, issues);
304
+
305
+ // toolChoice 与 tools 的一致性
306
+ if (
307
+ request.toolChoice &&
308
+ typeof request.toolChoice === "object" &&
309
+ "type" in request.toolChoice &&
310
+ request.toolChoice.type === "tool"
311
+ ) {
312
+ const chosenName = request.toolChoice.name;
313
+ if (!request.tools || request.tools.length === 0) {
314
+ issues.push({
315
+ field: "toolChoice",
316
+ code: "TOOL_CHOICE_NO_TOOLS",
317
+ message: `toolChoice specifies tool "${chosenName}" but no tools are defined`,
318
+ });
319
+ } else if (!request.tools.some((t) => t.name === chosenName)) {
320
+ issues.push({
321
+ field: "toolChoice",
322
+ code: "TOOL_CHOICE_UNKNOWN_TOOL",
323
+ message: `toolChoice specifies tool "${chosenName}" which is not in tools array`,
324
+ });
325
+ }
326
+ }
327
+
328
+ return issues;
329
+ }
330
+
331
+ /**
332
+ * 校验请求并抛出首个问题。
333
+ * 适用于客户端入口的快速失败检查。
334
+ */
335
+ export function assertValidRequest(request: AIRequest): void {
336
+ const issues = validateRequest(request);
337
+ const first = issues[0];
338
+ if (first) {
339
+ throw new AIRequestError(first.message, first.code);
340
+ }
341
+ }
@@ -0,0 +1,181 @@
1
+ import { WarningCode } from "../core/errors.js";
2
+ import type { EventFactory } from "../core/event-factory.js";
3
+ import type {
4
+ AdapterCapabilities,
5
+ AIStreamEvent,
6
+ BillingInfo,
7
+ NormalizedRequest,
8
+ Usage,
9
+ AuxiliaryInfo,
10
+ BackendTrace,
11
+ } from "../types/index.js";
12
+ import { AuxiliaryCollector, type BillingSource, type LookupResult, type UsageSource } from "./auxiliary-collector.js";
13
+
14
+ type MaybePromise<T> = T | Promise<T>;
15
+
16
+ export type BillingPostprocessHook = (context: {
17
+ request: NormalizedRequest;
18
+ usage?: Usage;
19
+ billing?: BillingInfo;
20
+ auxiliary?: AuxiliaryInfo;
21
+ capabilities: AdapterCapabilities;
22
+ }) => MaybePromise<Partial<BillingInfo> | undefined>;
23
+
24
+ export type AuxiliaryFinalizeOptions = {
25
+ lookup?: () => Promise<LookupResult>;
26
+ lookupTimeoutMs?: number;
27
+ postprocessBilling?: BillingPostprocessHook;
28
+ postprocessBillingSource?: BillingSource;
29
+ };
30
+
31
+ export type AuxiliaryFinalizeResult = {
32
+ events: AIStreamEvent[];
33
+ usage?: Usage;
34
+ billing?: BillingInfo;
35
+ auxiliary?: AuxiliaryInfo;
36
+ warnings?: string[];
37
+ metadataSources?: string[];
38
+ };
39
+
40
+ export class AdapterAuxiliaryState {
41
+ private readonly collector = new AuxiliaryCollector();
42
+ private readonly metadataSources = new Set<string>();
43
+
44
+ constructor(
45
+ private readonly request: NormalizedRequest,
46
+ private readonly capabilities: AdapterCapabilities,
47
+ ) {}
48
+
49
+ recordUsage(usage: Partial<Usage>, source: UsageSource, raw?: unknown): void {
50
+ if (this.request.include?.usage === "off" || isEmptyRecord(usage)) return;
51
+ this.collector.recordUsage(usage, source, raw);
52
+ }
53
+
54
+ recordBilling(billing: Partial<BillingInfo>, source: BillingSource, raw?: unknown): void {
55
+ if (this.request.include?.billing === "off" || isEmptyRecord(billing)) return;
56
+ this.collector.recordBilling(billing, source, raw);
57
+ }
58
+
59
+ recordProviderMetadata(source: string, metadata: Record<string, unknown> | undefined): void {
60
+ if (this.request.include?.providerMetadata === "off" || !metadata || isEmptyRecord(metadata)) return;
61
+ this.collector.recordMetadata(metadata);
62
+ this.metadataSources.add(source);
63
+ }
64
+
65
+ async finalize(factory: EventFactory, options: AuxiliaryFinalizeOptions = {}): Promise<AuxiliaryFinalizeResult> {
66
+ if (options.lookup && this.shouldAttemptLookup()) {
67
+ await this.collector.tryLookup(options.lookup, options.lookupTimeoutMs);
68
+ }
69
+
70
+ if (this.request.include?.billing !== "off" && options.postprocessBilling) {
71
+ const snapshot = this.collector.build();
72
+ if (!snapshot.billing) {
73
+ const derived = await options.postprocessBilling({
74
+ request: this.request,
75
+ usage: snapshot.usage,
76
+ billing: snapshot.billing,
77
+ auxiliary: snapshot.auxiliary,
78
+ capabilities: this.capabilities,
79
+ });
80
+ if (derived && !isEmptyRecord(derived)) {
81
+ this.collector.recordBilling(
82
+ {
83
+ ...derived,
84
+ isEstimated: derived.isEstimated ?? true,
85
+ source: derived.source ?? "derived",
86
+ },
87
+ options.postprocessBillingSource ?? "derived",
88
+ derived,
89
+ );
90
+ }
91
+ }
92
+ }
93
+
94
+ const built = this.collector.build();
95
+ const events: AIStreamEvent[] = [];
96
+
97
+ if (built.usage || built.billing || built.auxiliary) {
98
+ events.push(
99
+ factory.responseAuxiliary({
100
+ usage: built.usage,
101
+ billing: built.billing,
102
+ auxiliary: built.auxiliary,
103
+ }),
104
+ );
105
+ }
106
+
107
+ if (this.request.include?.usage !== "off" && !built.usage) {
108
+ events.push(factory.responseWarning("Usage information was not provided by the provider", WarningCode.USAGE_MISSING));
109
+ }
110
+
111
+ if (this.request.include?.billing !== "off") {
112
+ if (!built.billing) {
113
+ events.push(
114
+ factory.responseWarning("Billing information was not provided by the provider", WarningCode.BILLING_MISSING),
115
+ );
116
+ } else if (built.billing.isEstimated) {
117
+ events.push(factory.responseWarning("Billing amount is an estimate", WarningCode.BILLING_ESTIMATED));
118
+ }
119
+ }
120
+
121
+ return {
122
+ events,
123
+ usage: built.usage,
124
+ billing: built.billing,
125
+ auxiliary: built.auxiliary,
126
+ warnings: built.warnings,
127
+ metadataSources: this.metadataSources.size > 0 ? [...this.metadataSources] : undefined,
128
+ };
129
+ }
130
+
131
+ private shouldAttemptLookup(): boolean {
132
+ if (
133
+ this.request.include?.usage === "off" &&
134
+ this.request.include?.billing === "off" &&
135
+ this.request.include?.providerMetadata === "off"
136
+ ) {
137
+ return false;
138
+ }
139
+
140
+ const snapshot = this.collector.build();
141
+ return (
142
+ (this.request.include?.usage !== "off" && !snapshot.usage) ||
143
+ (this.request.include?.billing !== "off" && !snapshot.billing) ||
144
+ (this.request.include?.providerMetadata !== "off" && !snapshot.auxiliary?.providerMetadata)
145
+ );
146
+ }
147
+ }
148
+
149
+ export function emitMalformedStreamWarning(
150
+ factory: EventFactory,
151
+ options: {
152
+ count: number;
153
+ providerLabel: string;
154
+ transportLabel: string;
155
+ },
156
+ ): AIStreamEvent | undefined {
157
+ if (options.count < 1) return undefined;
158
+ return factory.responseWarning(
159
+ `Skipped ${options.count} malformed ${options.providerLabel} ${options.transportLabel}`,
160
+ "STREAM_ERROR",
161
+ );
162
+ }
163
+
164
+ export function metadataSourceList(
165
+ ...groups: Array<Array<NonNullable<BackendTrace["metadataSources"]>[number]> | undefined>
166
+ ): string[] | undefined {
167
+ const sources = new Set<string>();
168
+
169
+ for (const group of groups) {
170
+ if (!group) continue;
171
+ for (const source of group) {
172
+ sources.add(source);
173
+ }
174
+ }
175
+
176
+ return sources.size > 0 ? [...sources] : undefined;
177
+ }
178
+
179
+ function isEmptyRecord(value: object): boolean {
180
+ return Object.keys(value).length === 0;
181
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Adapter 抽象基类
3
+ *
4
+ * 约定 adapter 的内部职责分层(build / invoke / parse / emit):
5
+ * 1. buildRequest — 将 NormalizedRequest 转换为 provider 请求格式
6
+ * 2. invokeProvider — 调用 provider API
7
+ * 3. parseResponse — 解析 provider 响应为 canonical 中间态
8
+ * 4. emitEvents — 产出 canonical 事件流
9
+ *
10
+ * 子类实现 buildRequest() 和 runStream(),
11
+ * runStream 返回 AsyncIterable,事件实时发射给消费者。
12
+ */
13
+
14
+ import type {
15
+ NormalizedRequest,
16
+ BackendAdapter,
17
+ AdapterCapabilities,
18
+ AIStreamEvent,
19
+ AIResponse,
20
+ AuxiliaryInfo,
21
+ OutputItem,
22
+ ReplayItem,
23
+ StopReason,
24
+ Usage,
25
+ BillingInfo,
26
+ ToolCallItem,
27
+ } from "../types/index.js";
28
+ import { createEventFactory } from "../core/event-factory.js";
29
+ import { AIMappingError, AIRequestError, AIStreamError } from "../core/errors.js";
30
+ import type { EventFactory } from "../core/event-factory.js";
31
+ import { extractText } from "./mapping.js";
32
+ import { AdapterAuxiliaryState } from "./adapter-auxiliary.js";
33
+
34
+ // ── Adapter 解析中间结果 ──────────────────────────────────────
35
+
36
+ export type ProviderResponse = unknown;
37
+
38
+ /**
39
+ * adapter 完成一轮处理后返回的最终结果。
40
+ * 用于 buildResponse() 构建 AIResponse。
41
+ */
42
+ export type StreamResult = {
43
+ output: OutputItem[];
44
+ replay: ReplayItem[];
45
+ stopReason?: StopReason;
46
+ usage?: Usage;
47
+ billing?: BillingInfo;
48
+ providerMetadata?: Record<string, unknown>;
49
+ auxiliary?: Partial<AuxiliaryInfo>;
50
+ warnings?: string[];
51
+ metadataSources?: string[];
52
+ rawResponseId?: string;
53
+ };
54
+
55
+ // ── 抽象基类 ──────────────────────────────────────────────────
56
+
57
+ export abstract class AdapterBase implements BackendAdapter {
58
+ abstract readonly kind: "chat-completions" | "messages" | "responses" | "ollama";
59
+ abstract readonly capabilities: AdapterCapabilities;
60
+
61
+ /**
62
+ * stream 模板方法:
63
+ * 1. 创建事件工厂,发射 response.started
64
+ * 2. 构建 provider 请求
65
+ * 3. 委托 runStream 发射全部流事件(含 response.completed)
66
+ */
67
+ async *stream(request: NormalizedRequest): AsyncIterable<AIStreamEvent> {
68
+ const factory = createEventFactory({
69
+ responseId: request.requestId,
70
+ backend: { kind: this.kind, isSynthetic: !this.capabilities.nativeStreaming },
71
+ });
72
+
73
+ yield factory.responseStarted(request.model);
74
+
75
+ try {
76
+ const providerRequest = await this.buildRequest(request);
77
+ yield* this.runStream(providerRequest, factory, request);
78
+ } catch (err) {
79
+ if (err instanceof AIRequestError || err instanceof AIStreamError || err instanceof AIMappingError) {
80
+ throw err;
81
+ }
82
+ yield factory.responseWarning(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
83
+ yield factory.responseCompleted(this.buildResponse(request, { output: [], replay: [] }, factory));
84
+ }
85
+ }
86
+
87
+ // ── 子类必须实现 ──────────────────────────────────────────
88
+
89
+ /** 将 NormalizedRequest 转换为 provider 请求格式。 */
90
+ protected abstract buildRequest(request: NormalizedRequest): ProviderResponse | Promise<ProviderResponse>;
91
+
92
+ /**
93
+ * 执行流式请求,发射全部事件(含 response.completed)。
94
+ * 子类负责:
95
+ * - 调用 provider
96
+ * - 解析每个 chunk
97
+ * - 通过 factory 发射 item 事件
98
+ * - 构建 StreamResult
99
+ * - 发射 factory.responseCompleted(buildResponse(…))
100
+ */
101
+ protected abstract runStream(
102
+ providerRequest: ProviderResponse,
103
+ factory: EventFactory,
104
+ request: NormalizedRequest,
105
+ ): AsyncIterable<AIStreamEvent>;
106
+
107
+ // ── 共享构造方法 ──────────────────────────────────────────
108
+
109
+ /**
110
+ * 从 StreamResult 构建完整 AIResponse。
111
+ * 子类可在返回前自定义覆盖。
112
+ */
113
+ protected buildResponse(request: NormalizedRequest, result: StreamResult, _factory: EventFactory): AIResponse {
114
+ const text = this.extractText(result.output);
115
+ const warnings = mergeWarnings(result.warnings, _factory.warnings);
116
+ const auxiliary = mergeAuxiliary(result.auxiliary, result.providerMetadata ? { providerMetadata: result.providerMetadata } : undefined);
117
+
118
+ return {
119
+ id: request.requestId,
120
+ output: result.output,
121
+ replay: result.replay,
122
+ text,
123
+ toolCalls: result.output.filter((item): item is ToolCallItem => item.type === "tool_call"),
124
+ stopReason: result.stopReason,
125
+ usage: result.usage,
126
+ billing: result.billing,
127
+ auxiliary,
128
+ warnings,
129
+ backend: {
130
+ requestId: request.requestId,
131
+ rawResponseId: result.rawResponseId,
132
+ adapter: this.kind,
133
+ isSyntheticStream: !this.capabilities.nativeStreaming,
134
+ metadataSources: result.metadataSources,
135
+ warnings,
136
+ },
137
+ };
138
+ }
139
+
140
+ /** 从 output items 中提取文本内容。 */
141
+ protected extractText(output: OutputItem[]): string {
142
+ return extractText(output);
143
+ }
144
+
145
+ protected createAuxiliaryState(request: NormalizedRequest): AdapterAuxiliaryState {
146
+ return new AdapterAuxiliaryState(request, this.capabilities);
147
+ }
148
+ }
149
+
150
+ function mergeAuxiliary(
151
+ base?: Partial<AuxiliaryInfo>,
152
+ patch?: Partial<AuxiliaryInfo>,
153
+ ): AuxiliaryInfo | undefined {
154
+ if (!base && !patch) return undefined;
155
+
156
+ const merged: AuxiliaryInfo = {
157
+ ...(base ?? {}),
158
+ ...(patch ?? {}),
159
+ };
160
+
161
+ if (base?.providerMetadata || patch?.providerMetadata) {
162
+ merged.providerMetadata = {
163
+ ...(base?.providerMetadata ?? {}),
164
+ ...(patch?.providerMetadata ?? {}),
165
+ };
166
+ }
167
+
168
+ return merged;
169
+ }
170
+
171
+ function mergeWarnings(...groups: Array<string[] | undefined>): string[] | undefined {
172
+ const merged: string[] = [];
173
+
174
+ for (const group of groups) {
175
+ if (!group) continue;
176
+ for (const warning of group) {
177
+ if (!merged.includes(warning)) {
178
+ merged.push(warning);
179
+ }
180
+ }
181
+ }
182
+
183
+ return merged.length > 0 ? merged : undefined;
184
+ }