@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.
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { AdapterBase } from "../helpers/adapter-base.js";
13
- import { AIRequestError } from "../core/errors.js";
13
+ import { AIProviderError, AIRequestError, AIStreamError } from "../core/errors.js";
14
14
  import {
15
15
  textBlock,
16
16
  messageItem,
@@ -22,9 +22,10 @@ import {
22
22
  contentBlocksToText,
23
23
  } from "../helpers/mapping.js";
24
24
  import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
25
+ import { assertOpaqueReplayEnvelope, providerHttpError } from "../helpers/adapter-security.js";
25
26
  import { usageFromOpenAIResponses } from "../helpers/usage-mapping.js";
26
-
27
- import { parseSSEEvents } from "../helpers/sse-parser.js";
27
+ import { NormalizedRequestMapper, splitSSEFrames, IncrementalStreamParser } from "../helpers/index.js";
28
+ import type { ProviderProfile } from "../helpers/index.js";
28
29
 
29
30
  import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
30
31
 
@@ -71,54 +72,24 @@ type ResponsesTool = {
71
72
  input_schema: Record<string, unknown>;
72
73
  };
73
74
 
74
- function ensureResponsesTextBlocks(
75
- blocks: import("../index.js").ContentBlock[],
76
- field: string,
77
- ): import("../index.js").ContentBlock[] {
78
- for (let i = 0; i < blocks.length; i++) {
79
- const block = blocks[i];
80
- if (!block) continue;
81
- if (block.type !== "text" && block.type !== "json") {
82
- throw new AIRequestError(
83
- `responses does not support ${field}[${i}] of type "${block.type}"; only text/json blocks are supported`,
84
- "UNSUPPORTED_CONTENT_BLOCK",
85
- );
86
- }
87
- }
88
-
89
- return blocks;
90
- }
91
-
92
- function ensureResponsesReasoningBlocks(
93
- blocks: import("../index.js").ContentBlock[],
94
- field: string,
95
- ): Array<Extract<import("../index.js").ContentBlock, { type: "text" }>> {
96
- return blocks.map((block, index) => {
97
- if (block.type !== "text") {
98
- throw new AIRequestError(
99
- `responses does not support ${field}[${index}] of type "${block.type}"; reasoning only supports text blocks`,
100
- "UNSUPPORTED_CONTENT_BLOCK",
101
- );
102
- }
103
-
104
- return block;
105
- });
106
- }
107
-
108
- function instructionsToResponsesText(instructions: string | import("../index.js").InstructionBlock[]): string {
109
- return typeof instructions === "string"
110
- ? instructions
111
- : contentBlocksToText(ensureResponsesTextBlocks(instructions, "instructions"));
112
- }
75
+ // ── ProviderProfile & Mapper ────────────────────────────────────
76
+
77
+ const profile: ProviderProfile = {
78
+ kind: "responses",
79
+ instructionsMode: "instructions_field",
80
+ supportedBlockTypes: ["text", "json"] as const,
81
+ reasoningBlockTypes: ["text"] as const,
82
+ capabilities: {
83
+ textStreaming: "native",
84
+ reasoningStreaming: "native",
85
+ toolCallStreaming: "native",
86
+ replay: "opaque",
87
+ usage: "final",
88
+ toolResultOutcomes: ["success"],
89
+ },
90
+ };
113
91
 
114
- function assertResponsesToolResultOutcome(outcome: import("../index.js").ToolResultItem["outcome"]): void {
115
- if (outcome !== "success") {
116
- throw new AIRequestError(
117
- `responses does not preserve tool_result outcome "${outcome}"; only "success" is supported`,
118
- "UNSUPPORTED_TOOL_RESULT_OUTCOME",
119
- );
120
- }
121
- }
92
+ const mapper = new NormalizedRequestMapper(profile);
122
93
 
123
94
  // ── SSE 事件类型 ──────────────────────────────────────────────
124
95
 
@@ -131,12 +102,43 @@ type ResponsesSSEEvent =
131
102
  | { type: "response.tool_call.delta"; data: { item_id: string; delta: { arguments?: string } } }
132
103
  | { type: "response.tool_call.done"; data: { item_id: string; arguments?: string; name?: string } }
133
104
  | { type: "response.completed"; data: { response: ResponsesAPIResponse } }
134
- | { type: "error"; data: { message: string; code?: string } };
105
+ | { type: "response.failed"; data: { response: ResponsesAPIResponse } }
106
+ | { type: "response.incomplete"; data: { response: ResponsesAPIResponse } }
107
+ | { type: "error"; data: { message: string; code?: string } }
108
+ | { type: string; data: Record<string, unknown> };
109
+
110
+ /** 已处理或可安全忽略的 Responses SSE 类型(未知类型会 warning 一次)。 */
111
+ const KNOWN_RESPONSES_SSE_TYPES = new Set([
112
+ "response.output_item.added",
113
+ "response.output_item.done",
114
+ "response.output_text.delta",
115
+ "response.output_text.done",
116
+ "response.reasoning.delta",
117
+ "response.reasoning.done",
118
+ "response.tool_call.delta",
119
+ "response.tool_call.done",
120
+ "response.function_call_arguments.delta",
121
+ "response.function_call_arguments.done",
122
+ "response.content_part.added",
123
+ "response.content_part.done",
124
+ "response.refusal.delta",
125
+ "response.refusal.done",
126
+ "response.in_progress",
127
+ "response.created",
128
+ "response.completed",
129
+ "response.failed",
130
+ "response.incomplete",
131
+ "error",
132
+ ]);
135
133
 
136
134
  type ResponsesAPIResponse = {
137
135
  id: string;
138
136
  model: string;
139
137
  output: ResponsesAPIOutputItem[];
138
+ status?: "completed" | "failed" | "in_progress" | "cancelled" | "queued" | "incomplete" | string;
139
+ incomplete_details?: { reason?: string | null } | null;
140
+ error?: { message?: string; code?: string } | null;
141
+ failure?: { message?: string; code?: string } | null;
140
142
  usage?: {
141
143
  input_tokens: number;
142
144
  output_tokens: number;
@@ -156,25 +158,18 @@ type ResponsesAPIOutputItem = {
156
158
  status?: string;
157
159
  };
158
160
 
159
- // ── SSE 解析 ──────────────────────────────────────────────────
160
-
161
- function parseSSE(chunk: string): { events: ResponsesSSEEvent[]; rest: string; malformedEvents: number } {
162
- const result = parseSSEEvents(chunk);
163
- return { events: result.events as ResponsesSSEEvent[], rest: result.rest, malformedEvents: result.malformedEvents };
164
- }
165
-
166
161
  function isReplayCanonicalInput(item: ResponsesInputItem): boolean {
167
162
  return (
168
163
  (item.type === "message" && item.role === "assistant") || item.type === "reasoning" || item.type === "function_call"
169
164
  );
170
165
  }
171
166
 
172
- function rollbackTrailingReplayCanonicalItems(input: ResponsesInputItem[]): void {
173
- while (input.length > 0) {
174
- const last = input[input.length - 1];
175
- if (!last || !isReplayCanonicalInput(last)) break;
176
- input.pop();
177
- }
167
+ function hasReplayCanonicalInput(input: ResponsesInputItem[]): boolean {
168
+ return input.some(isReplayCanonicalInput);
169
+ }
170
+
171
+ function extractFailureMessage(response: ResponsesAPIResponse): string {
172
+ return response.error?.message ?? response.failure?.message ?? "unknown";
178
173
  }
179
174
 
180
175
  // ── Content block 映射 ─────────────────────────────────────────
@@ -192,7 +187,7 @@ function canonicalToResponsesBlock(b: import("../index.js").ContentBlock): Respo
192
187
 
193
188
  export class ResponsesAdapter extends AdapterBase {
194
189
  readonly kind = "responses" as const;
195
- readonly nativeStreaming = true;
190
+ readonly capabilities = profile.capabilities;
196
191
 
197
192
  private apiKey: string;
198
193
  private baseUrl: string;
@@ -215,25 +210,25 @@ export class ResponsesAdapter extends AdapterBase {
215
210
  case "message": {
216
211
  // Responses API 中只有 assistant 角色支持 content blocks
217
212
  if (item.role === "assistant") {
218
- const blocks = ensureResponsesTextBlocks(item.content, `assistant message (${item.role}) content`).map(
219
- canonicalToResponsesBlock,
220
- );
213
+ const blocks = mapper
214
+ .ensureTextBlocks(item.content, `assistant message (${item.role}) content`)
215
+ .map(canonicalToResponsesBlock);
221
216
  input.push({ type: "message", role: item.role, content: blocks });
222
217
  } else {
223
218
  input.push({
224
219
  type: "message",
225
220
  role: item.role,
226
221
  content: contentBlocksToText(
227
- ensureResponsesTextBlocks(item.content, `input message (${item.role}) content`),
222
+ mapper.ensureTextBlocks(item.content, `input message (${item.role}) content`),
228
223
  ),
229
224
  });
230
225
  }
231
226
  break;
232
227
  }
233
228
  case "reasoning": {
234
- const blocks = ensureResponsesReasoningBlocks(item.content, "reasoning content").map(
235
- (b): ResponsesContentBlock => ({ type: "reasoning", text: b.text }),
236
- );
229
+ const blocks = mapper
230
+ .ensureReasoningBlocks(item.content, "reasoning content")
231
+ .map((b): ResponsesContentBlock => ({ type: "reasoning", text: b.text }));
237
232
  input.push({ type: "reasoning", content: blocks });
238
233
  break;
239
234
  }
@@ -247,8 +242,9 @@ export class ResponsesAdapter extends AdapterBase {
247
242
  break;
248
243
  }
249
244
  case "tool_result": {
250
- assertResponsesToolResultOutcome(item.outcome);
251
- const output = ensureResponsesTextBlocks(item.content, `tool_result ${item.callId} content`)
245
+ mapper.assertToolResultOutcome(item.outcome);
246
+ const output = mapper
247
+ .ensureTextBlocks(item.content, `tool_result ${item.callId} content`)
252
248
  .map(blockToText)
253
249
  .join("\n");
254
250
  input.push({
@@ -259,18 +255,20 @@ export class ResponsesAdapter extends AdapterBase {
259
255
  break;
260
256
  }
261
257
  case "opaque": {
262
- // opaque items with item_reference purpose can be passed through
263
- if (
264
- item.source === "responses" &&
265
- item.purpose === "replay" &&
266
- typeof item.payload === "object" &&
267
- item.payload !== null &&
268
- "id" in (item.payload as Record<string, unknown>)
269
- ) {
270
- const { id } = item.payload as Record<string, unknown>;
271
- if (typeof id === "string") {
272
- rollbackTrailingReplayCanonicalItems(input);
273
- input.push({ type: "item_reference", id });
258
+ // Canonical replay items take priority; item_reference is only a fallback
259
+ // when the consumer kept only the provider continuation id.
260
+ if (item.source !== "responses" || item.purpose !== "replay") break;
261
+ assertOpaqueReplayEnvelope(item.payload);
262
+ const payload = item.payload as Record<string, unknown>;
263
+ if ("id" in payload) {
264
+ if (typeof payload.id !== "string" || payload.id.length === 0 || payload.id.length > 256) {
265
+ throw new AIRequestError(
266
+ "Invalid opaque replay payload: id must be a non-empty string (max 256)",
267
+ "INVALID_OPAQUE_REPLAY",
268
+ );
269
+ }
270
+ if (!hasReplayCanonicalInput(input)) {
271
+ input.push({ type: "item_reference", id: payload.id });
274
272
  }
275
273
  }
276
274
  break;
@@ -285,7 +283,7 @@ export class ResponsesAdapter extends AdapterBase {
285
283
  };
286
284
 
287
285
  if (request.instructions) {
288
- body.instructions = instructionsToResponsesText(request.instructions);
286
+ body.instructions = mapper.mapInstructions(request.instructions);
289
287
  }
290
288
 
291
289
  if (request.tools && request.tools.length > 0) {
@@ -322,39 +320,66 @@ export class ResponsesAdapter extends AdapterBase {
322
320
  request: NormalizedRequest,
323
321
  ): AsyncIterable<AIStreamEvent> {
324
322
  const auxiliary = this.createAuxiliaryState(request);
325
- const response = await this.fetchFn(`${this.baseUrl}/responses`, {
326
- method: "POST",
327
- headers: {
328
- "Content-Type": "application/json",
329
- Authorization: `Bearer ${this.apiKey}`,
330
- },
331
- body: JSON.stringify(providerRequest),
332
- });
323
+ let response: Response;
324
+
325
+ try {
326
+ response = await this.fetchFn(`${this.baseUrl}/responses`, {
327
+ method: "POST",
328
+ headers: {
329
+ "Content-Type": "application/json",
330
+ Authorization: `Bearer ${this.apiKey}`,
331
+ },
332
+ body: JSON.stringify(providerRequest),
333
+ });
334
+ } catch (err) {
335
+ throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
336
+ }
333
337
 
334
338
  if (!response.ok) {
335
- const errorText = await response.text().catch(() => "unknown error");
336
- throw new Error(`Responses API error ${response.status}: ${errorText}`);
339
+ const errorBody = await response.text().catch(() => "");
340
+ throw providerHttpError(response.status, errorBody);
337
341
  }
338
342
 
339
343
  const reader = response.body?.getReader();
340
344
  if (!reader) {
341
- throw new Error("Response body is not readable");
345
+ throw new AIStreamError("Response body is not readable", "STREAM_ERROR");
342
346
  }
343
347
 
344
348
  // 流式累积状态
349
+ const parser = new IncrementalStreamParser<ResponsesSSEEvent>(splitSSEFrames, (frame: string) => {
350
+ let eventType = "";
351
+ let dataStr = "";
352
+ for (const rawLine of frame.split("\n")) {
353
+ const line = rawLine.trim();
354
+ if (line.startsWith("event: ")) eventType = line.slice(7).trim();
355
+ else if (line.startsWith("data: ")) dataStr += line.slice(6);
356
+ }
357
+ if (!eventType) return { status: "ignored" };
358
+ try {
359
+ const data = JSON.parse(dataStr);
360
+ return { status: "parsed", value: { type: eventType, data } as ResponsesSSEEvent };
361
+ } catch {
362
+ return { status: "malformed" };
363
+ }
364
+ });
365
+
345
366
  const output: OutputItem[] = [];
346
- const decoder = new TextDecoder();
347
- let buffer = "";
367
+ let streamDone = false;
348
368
  let completedResponse: ResponsesAPIResponse | undefined;
369
+ let completedEmitted = false;
370
+ let unknownEventsWarned = false;
371
+ const messageItemsWithDelta = new Set<string>();
349
372
 
350
373
  try {
351
374
  while (true) {
352
- const { done, value } = await reader.read();
353
- if (done) break;
354
-
355
- buffer += decoder.decode(value, { stream: true });
356
- const { events, rest, malformedEvents } = parseSSE(buffer);
357
- buffer = rest;
375
+ const readResult = await reader.read().catch((err: unknown) => {
376
+ throw new AIStreamError(
377
+ `Failed to read response stream: ${err instanceof Error ? err.message : String(err)}`,
378
+ "STREAM_ERROR",
379
+ );
380
+ });
381
+ const { done, value } = readResult;
382
+ const { items: events, malformed: malformedEvents } = done ? parser.flush() : parser.feed(value as Uint8Array);
358
383
 
359
384
  const malformedWarning = emitMalformedStreamWarning(factory, {
360
385
  count: malformedEvents,
@@ -367,13 +392,14 @@ export class ResponsesAdapter extends AdapterBase {
367
392
 
368
393
  for (const sseEvent of events) {
369
394
  if (sseEvent.type === "error") {
370
- yield factory.responseWarning(sseEvent.data.message, sseEvent.data.code);
395
+ const data = sseEvent.data as { message?: string; code?: string };
396
+ yield factory.responseWarning(data.message ?? "Provider error event", data.code);
371
397
  continue;
372
398
  }
373
399
 
374
400
  // item 级事件
375
401
  if (sseEvent.type === "response.output_item.added") {
376
- const item = sseEvent.data.item;
402
+ const item = (sseEvent.data as { item: { id: string; type: string; [key: string]: unknown } }).item;
377
403
  switch (item.type) {
378
404
  case "message":
379
405
  yield factory.messageStarted(item.id);
@@ -389,57 +415,96 @@ export class ResponsesAdapter extends AdapterBase {
389
415
  }
390
416
 
391
417
  if (sseEvent.type === "response.output_text.delta") {
392
- yield factory.messageDelta(sseEvent.data.item_id, sseEvent.data.delta);
418
+ const data = sseEvent.data as { item_id: string; delta: string };
419
+ yield factory.messageDelta(data.item_id, textBlock(data.delta));
420
+ messageItemsWithDelta.add(data.item_id);
393
421
  continue;
394
422
  }
395
423
 
396
424
  if (sseEvent.type === "response.output_text.done") {
397
- yield factory.messageCompleted(messageItem([textBlock(sseEvent.data.text)], { id: sseEvent.data.item_id }));
398
- output.push(messageItem([textBlock(sseEvent.data.text)], { id: sseEvent.data.item_id }));
425
+ const data = sseEvent.data as { item_id: string; text: string };
426
+ if (!messageItemsWithDelta.has(data.item_id) && data.text) {
427
+ yield factory.messageDelta(data.item_id, textBlock(data.text));
428
+ }
429
+ yield factory.messageCompleted(data.item_id);
430
+ output.push(messageItem([textBlock(data.text)], { id: data.item_id }));
399
431
  continue;
400
432
  }
401
433
 
402
434
  if (sseEvent.type === "response.reasoning.delta") {
403
- yield factory.reasoningDelta(sseEvent.data.item_id, textBlock(sseEvent.data.delta));
435
+ const data = sseEvent.data as { item_id: string; delta: string };
436
+ yield factory.reasoningDelta(data.item_id, textBlock(data.delta));
404
437
  continue;
405
438
  }
406
439
 
407
440
  if (sseEvent.type === "response.reasoning.done") {
408
- yield factory.reasoningCompleted(
409
- reasoningItem([textBlock(sseEvent.data.text)], "full", sseEvent.data.item_id),
410
- );
411
- output.push(reasoningItem([textBlock(sseEvent.data.text)], "full", sseEvent.data.item_id));
441
+ const data = sseEvent.data as { item_id: string; text: string };
442
+ yield factory.reasoningCompleted(data.item_id);
443
+ output.push(reasoningItem([textBlock(data.text)], "full", data.item_id));
412
444
  continue;
413
445
  }
414
446
 
415
447
  if (sseEvent.type === "response.tool_call.delta") {
416
- if (sseEvent.data.delta.arguments) {
417
- yield factory.toolCallDelta(sseEvent.data.item_id, { argumentsText: sseEvent.data.delta.arguments });
448
+ const data = sseEvent.data as { item_id: string; delta: { arguments?: string } };
449
+ if (data.delta.arguments) {
450
+ yield factory.toolCallDelta(data.item_id, { argumentsText: data.delta.arguments });
418
451
  }
419
452
  continue;
420
453
  }
421
454
 
422
455
  if (sseEvent.type === "response.tool_call.done") {
423
- const tcItem = toolCallItem(
424
- sseEvent.data.item_id,
425
- sseEvent.data.name ?? "unknown",
426
- sseEvent.data.arguments ?? "",
427
- );
428
- yield factory.toolCallCompleted(tcItem);
456
+ const data = sseEvent.data as { item_id: string; arguments?: string; name?: string };
457
+ const tcItem = toolCallItem(data.item_id, data.name ?? "unknown", data.arguments ?? "");
458
+ yield factory.toolCallCompleted(data.item_id);
429
459
  output.push(tcItem);
430
460
  continue;
431
461
  }
432
462
 
433
- if (sseEvent.type === "response.completed") {
434
- completedResponse = sseEvent.data.response;
463
+ if (
464
+ sseEvent.type === "response.completed" ||
465
+ sseEvent.type === "response.failed" ||
466
+ sseEvent.type === "response.incomplete"
467
+ ) {
468
+ const data = sseEvent.data as { response: ResponsesAPIResponse };
469
+ if (completedResponse) {
470
+ yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
471
+ continue;
472
+ }
473
+
474
+ completedResponse = data.response;
475
+
476
+ if (sseEvent.type === "response.failed") {
477
+ yield factory.responseWarning(
478
+ `Response failed: ${extractFailureMessage(data.response)}`,
479
+ "PROVIDER_FAILURE",
480
+ );
481
+ }
482
+ continue;
483
+ }
484
+
485
+ if (!KNOWN_RESPONSES_SSE_TYPES.has(sseEvent.type) && !unknownEventsWarned) {
486
+ unknownEventsWarned = true;
487
+ yield factory.responseWarning(
488
+ `Responses API sent unknown event type "${sseEvent.type}"; this may indicate an incomplete integration`,
489
+ "UNKNOWN_PROVIDER_EVENT",
490
+ );
435
491
  }
436
492
  }
493
+
494
+ if (done) {
495
+ streamDone = true;
496
+ break;
497
+ }
437
498
  }
438
499
  } finally {
439
- reader.releaseLock();
500
+ try {
501
+ if (!streamDone) await reader.cancel().catch(() => undefined);
502
+ } finally {
503
+ reader.releaseLock();
504
+ }
440
505
  }
441
506
 
442
- if (buffer.trim().length > 0) {
507
+ if (parser.getRemaining().trim().length > 0) {
443
508
  yield factory.responseWarning("Stream ended with an incomplete Responses SSE frame", "STREAM_ERROR");
444
509
  }
445
510
 
@@ -469,8 +534,9 @@ export class ResponsesAdapter extends AdapterBase {
469
534
  yield event;
470
535
  }
471
536
 
472
- yield factory.responseCompleted(
473
- this.buildResponse(
537
+ if (!completedEmitted) {
538
+ completedEmitted = true;
539
+ const finalResponse = this.buildResponse(
474
540
  request,
475
541
  {
476
542
  output,
@@ -484,23 +550,42 @@ export class ResponsesAdapter extends AdapterBase {
484
550
  rawResponseId,
485
551
  },
486
552
  factory,
487
- ),
488
- );
553
+ );
554
+ yield factory.responseCompleted({
555
+ replay: finalResponse.replay,
556
+ stopReason: finalResponse.stopReason,
557
+ trace: finalResponse.backend,
558
+ usage: finalResponse.usage,
559
+ billing: finalResponse.billing,
560
+ auxiliary: finalResponse.auxiliary,
561
+ warnings: finalResponse.warnings,
562
+ });
563
+ }
489
564
  }
490
565
 
491
566
  // ── 辅助方法 ──────────────────────────────────────────────
492
567
 
493
568
  private inferStopReason(response: ResponsesAPIResponse): import("../index.js").StopReason {
569
+ if (response.status === "failed") return "error";
570
+
571
+ if (response.status === "incomplete") {
572
+ const reason = response.incomplete_details?.reason;
573
+ if (reason === "content_filter") return "content_filter";
574
+ if (reason === "max_output_tokens") return "max_output_tokens";
575
+ return "max_output_tokens";
576
+ }
577
+
494
578
  const output = response.output;
495
- if (!output || output.length === 0) return "unknown";
579
+ if (!output || output.length === 0) {
580
+ return response.status === "completed" ? "end_turn" : "unknown";
581
+ }
496
582
 
497
- // 检查是否有未完成的 function_call
498
583
  const hasFunctionCall = output.some((item) => item.type === "function_call");
499
584
  if (hasFunctionCall) return "tool_call";
500
585
 
501
- // 检查最后一条 message status
502
- const lastMsg = output[output.length - 1];
503
- if (lastMsg?.status === "incomplete") return "max_output_tokens";
586
+ const lastItem = output[output.length - 1];
587
+ if (lastItem?.status === "failed") return "error";
588
+ if (lastItem?.status === "incomplete") return "max_output_tokens";
504
589
 
505
590
  return "end_turn";
506
591
  }