@codehz/ai 0.4.3 → 0.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codehz/ai",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "module": "dist/index.mjs",
6
6
  "exports": {
@@ -40,7 +40,11 @@ export type ResponsesAdapterOptions = {
40
40
  fetch?: FetchFn;
41
41
  };
42
42
 
43
- // ── Responses API 请求类型 ────────────────────────────────────
43
+ // ── Responses API 请求类型(对齐 OpenAI Responses schema)────
44
+ //
45
+ // input 是 untagged enum ModelInput = string | InputItem[]。
46
+ // 每个 InputItem 也必须命中官方 variant,否则会 422:
47
+ // "data did not match any variant of untagged enum ModelInput"
44
48
 
45
49
  type ResponsesAPIRequest = {
46
50
  model: string;
@@ -51,27 +55,70 @@ type ResponsesAPIRequest = {
51
55
  metadata?: Record<string, string>;
52
56
  temperature?: number;
53
57
  max_output_tokens?: number;
58
+ /** 服务端多轮续写;opaque replay 的 response id 映射到此字段,而非 item_reference */
59
+ previous_response_id?: string;
54
60
  stream: true;
55
61
  };
56
62
 
63
+ /** EasyInputMessage:content 可为 string,或 input_* content parts */
64
+ type ResponsesEasyMessage = {
65
+ type: "message";
66
+ role: "user" | "assistant" | "system" | "developer";
67
+ content: string | ResponsesInputContentPart[];
68
+ };
69
+
70
+ type ResponsesInputContentPart =
71
+ | { type: "input_text"; text: string }
72
+ | { type: "input_image"; image_url: string; detail?: "auto" | "low" | "high" }
73
+ | { type: "input_file"; file_url?: string; file_id?: string; filename?: string };
74
+
75
+ /** function_call:call_id 必填;id 是可选的 item id */
76
+ type ResponsesFunctionCall = {
77
+ type: "function_call";
78
+ call_id: string;
79
+ name: string;
80
+ arguments: string;
81
+ id?: string;
82
+ status?: "in_progress" | "completed" | "incomplete";
83
+ };
84
+
85
+ type ResponsesFunctionCallOutput = {
86
+ type: "function_call_output";
87
+ call_id: string;
88
+ output: string;
89
+ id?: string;
90
+ status?: "in_progress" | "completed" | "incomplete";
91
+ };
92
+
93
+ /** reasoning:id + summary/content/encrypted_content,不是任意 content blocks */
94
+ type ResponsesReasoningInput = {
95
+ type: "reasoning";
96
+ id: string;
97
+ summary: Array<{ type: "summary_text"; text: string }>;
98
+ content?: Array<{ type: "reasoning_text"; text: string }>;
99
+ encrypted_content?: string | null;
100
+ status?: "in_progress" | "completed" | "incomplete";
101
+ };
102
+
103
+ /** 引用既有 item(不是 response id) */
104
+ type ResponsesItemReference = {
105
+ type: "item_reference";
106
+ id: string;
107
+ };
108
+
57
109
  type ResponsesInputItem =
58
- | { type: "message"; role: "user" | "assistant"; content: string }
59
- | { type: "message"; role: "assistant"; content: ResponsesContentBlock[] }
60
- | { type: "function_call"; id: string; name: string; arguments: string; call_id?: string }
61
- | { type: "function_call_output"; call_id: string; output: string }
62
- | { type: "reasoning"; content: ResponsesContentBlock[] }
63
- | { type: "item_reference"; id: string };
64
-
65
- type ResponsesContentBlock =
66
- | { type: "text"; text: string }
67
- | { type: "reasoning"; text: string }
68
- | { type: "refusal"; refusal: string };
110
+ | ResponsesEasyMessage
111
+ | ResponsesFunctionCall
112
+ | ResponsesFunctionCallOutput
113
+ | ResponsesReasoningInput
114
+ | ResponsesItemReference;
69
115
 
70
116
  type ResponsesTool = {
71
117
  type: "function";
72
118
  name: string;
73
119
  description?: string;
74
120
  parameters: Record<string, unknown>;
121
+ strict?: boolean | null;
75
122
  };
76
123
 
77
124
  const mapper = new NormalizedRequestMapper("responses");
@@ -209,11 +256,12 @@ type ResponsesAPIOutputItem = {
209
256
  id: string;
210
257
  type: "message" | "reasoning" | "function_call" | string;
211
258
  role?: string;
212
- content?: ResponsesContentBlock[] | Array<{ type: string; text?: string; [key: string]: unknown }>;
259
+ content?: Array<{ type: string; text?: string; [key: string]: unknown }>;
213
260
  summary?: Array<{ type: string; text?: string; [key: string]: unknown }>;
214
261
  encrypted_content?: string | null;
215
262
  name?: string;
216
263
  arguments?: string;
264
+ call_id?: string;
217
265
  status?: string;
218
266
  [key: string]: unknown;
219
267
  };
@@ -232,15 +280,56 @@ function extractFailureMessage(response: ResponsesAPIResponse): string {
232
280
  return response.error?.message ?? response.failure?.message ?? "unknown";
233
281
  }
234
282
 
235
- // ── Content block 映射 ─────────────────────────────────────────
283
+ function readNonEmptyString(value: unknown, maxLen = 256): string | undefined {
284
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLen) return undefined;
285
+ return value;
286
+ }
287
+
288
+ /** 将 canonical text/json blocks 压成 EasyInputMessage 的 string content。 */
289
+ function messageContentAsString(
290
+ blocks: import("../index.js").ContentBlock[],
291
+ field: string,
292
+ ): string {
293
+ return mapper.textFromBlocks(blocks, field);
294
+ }
236
295
 
237
- function canonicalToResponsesBlock(b: import("../index.js").ContentBlock): ResponsesContentBlock {
238
- if (b.type === "text") return { type: "text", text: b.text };
239
- if (b.type === "json") return { type: "text", text: JSON.stringify(b.json) };
240
- throw new AIRequestError(
241
- `responses does not support content block type "${b.type}" in canonical mapping`,
242
- "UNSUPPORTED_CONTENT_BLOCK",
296
+ function mapReasoningInput(item: import("../index.js").ReasoningItem, index: number): ResponsesReasoningInput {
297
+ const text = mapper.textFromBlocks(
298
+ mapper.ensureReasoningBlocks(item.content, "reasoning content"),
299
+ "reasoning content",
243
300
  );
301
+ const id = item.id && item.id.length > 0 ? item.id : `reasoning_replay_${index}`;
302
+
303
+ if (item.visibility === "full") {
304
+ return {
305
+ type: "reasoning",
306
+ id,
307
+ summary: [],
308
+ content: text ? [{ type: "reasoning_text", text }] : undefined,
309
+ };
310
+ }
311
+
312
+ // summary / redacted / opaque:公开可回传的是 summary_text
313
+ return {
314
+ type: "reasoning",
315
+ id,
316
+ summary: text ? [{ type: "summary_text", text }] : [],
317
+ };
318
+ }
319
+
320
+ function extractOpaqueContinuationId(payload: Record<string, unknown>): {
321
+ previousResponseId?: string;
322
+ itemReferenceId?: string;
323
+ } {
324
+ // 优先显式 previous_response_id;历史 payload 用 id 存 response 续写句柄
325
+ const previousResponseId =
326
+ readNonEmptyString(payload.previous_response_id) ??
327
+ (typeof payload.item_id === "string" ? undefined : readNonEmptyString(payload.id));
328
+
329
+ // 仅在显式给出 item_id 时使用 item_reference(引用的是 item,不是 response)
330
+ const itemReferenceId = readNonEmptyString(payload.item_id);
331
+
332
+ return { previousResponseId, itemReferenceId };
244
333
  }
245
334
 
246
335
  // ── Adapter ───────────────────────────────────────────────────
@@ -264,36 +353,30 @@ export class ResponsesAdapter extends AdapterBase {
264
353
 
265
354
  protected buildRequest(request: NormalizedRequest): ResponsesAPIRequest {
266
355
  const input: ResponsesInputItem[] = [];
356
+ let previousResponseId: string | undefined;
357
+ let reasoningIndex = 0;
267
358
 
268
359
  for (const item of request.input) {
269
360
  switch (item.type) {
270
361
  case "message": {
271
- // Responses API 中只有 assistant 角色支持 content blocks
272
- if (item.role === "assistant") {
273
- const blocks = mapper
274
- .ensureTextBlocks(item.content, `assistant message (${item.role}) content`)
275
- .map(canonicalToResponsesBlock);
276
- input.push({ type: "message", role: item.role, content: blocks });
277
- } else {
278
- input.push({
279
- type: "message",
280
- role: item.role,
281
- content: mapper.textFromBlocks(item.content, `input message (${item.role}) content`),
282
- });
283
- }
362
+ // EasyInputMessage:string content user/assistant 都合法,且最不易触发 ModelInput 反序列化失败。
363
+ // 切勿发送 { type: "text" } —— 官方 content part 是 input_text / output_text。
364
+ input.push({
365
+ type: "message",
366
+ role: item.role,
367
+ content: messageContentAsString(item.content, `input message (${item.role}) content`),
368
+ });
284
369
  break;
285
370
  }
286
371
  case "reasoning": {
287
- const blocks = mapper
288
- .ensureReasoningBlocks(item.content, "reasoning content")
289
- .map((b): ResponsesContentBlock => ({ type: "reasoning", text: b.text }));
290
- input.push({ type: "reasoning", content: blocks });
372
+ input.push(mapReasoningInput(item, reasoningIndex++));
291
373
  break;
292
374
  }
293
375
  case "tool_call": {
376
+ // call_id 必填;canonical ToolCallItem.id 即 call_id(流里会优先取 call_id)
294
377
  input.push({
295
378
  type: "function_call",
296
- id: item.id,
379
+ call_id: item.id,
297
380
  name: item.name,
298
381
  arguments: item.argumentsText,
299
382
  });
@@ -309,20 +392,28 @@ export class ResponsesAdapter extends AdapterBase {
309
392
  break;
310
393
  }
311
394
  case "opaque": {
312
- // Canonical replay items take priority; item_reference is only a fallback
313
- // when the consumer kept only the provider continuation id.
395
+ // Canonical replay 优先;否则用 previous_response_id 做服务端续写。
396
+ // 注意:response id 不能塞进 item_reference(那是 item id)。
314
397
  if (item.source !== "responses" || item.purpose !== "replay") break;
315
398
  assertOpaqueReplayEnvelope(item.payload);
316
399
  const payload = item.payload as Record<string, unknown>;
317
- if ("id" in payload) {
318
- if (typeof payload.id !== "string" || payload.id.length === 0 || payload.id.length > 256) {
400
+
401
+ // 显式字段校验:id / previous_response_id / item_id 若存在必须是合法 string
402
+ for (const key of ["id", "previous_response_id", "item_id"] as const) {
403
+ if (key in payload && (typeof payload[key] !== "string" || payload[key].length === 0 || payload[key].length > 256)) {
319
404
  throw new AIRequestError(
320
- "Invalid opaque replay payload: id must be a non-empty string (max 256)",
405
+ `Invalid opaque replay payload: ${key} must be a non-empty string (max 256)`,
321
406
  "INVALID_OPAQUE_REPLAY",
322
407
  );
323
408
  }
324
- if (!hasReplayCanonicalInput(input)) {
325
- input.push({ type: "item_reference", id: payload.id });
409
+ }
410
+
411
+ const { previousResponseId: prevId, itemReferenceId } = extractOpaqueContinuationId(payload);
412
+ if (!hasReplayCanonicalInput(input)) {
413
+ if (prevId && !previousResponseId) {
414
+ previousResponseId = prevId;
415
+ } else if (itemReferenceId) {
416
+ input.push({ type: "item_reference", id: itemReferenceId });
326
417
  }
327
418
  }
328
419
  break;
@@ -336,6 +427,10 @@ export class ResponsesAdapter extends AdapterBase {
336
427
  stream: true,
337
428
  };
338
429
 
430
+ if (previousResponseId) {
431
+ body.previous_response_id = previousResponseId;
432
+ }
433
+
339
434
  if (request.instructions) {
340
435
  body.instructions = mapper.mapInstructions(request.instructions);
341
436
  }
@@ -392,9 +487,14 @@ export class ResponsesAdapter extends AdapterBase {
392
487
  let completedResponse: ResponsesAPIResponse | undefined;
393
488
  let unknownEventsWarned = false;
394
489
  const messageItemsWithDelta = new Set<string>();
490
+ /** item_id → function name */
395
491
  const toolCallNames = new Map<string, string>();
492
+ /** item_id → call_id(canonical ToolCallItem.id / function_call_output.call_id) */
493
+ const toolCallIds = new Map<string, string>();
396
494
  const reasoningStates = new Map<string, ReasoningStreamState>();
397
495
 
496
+ const resolveToolCallId = (itemId: string): string => toolCallIds.get(itemId) ?? itemId;
497
+
398
498
  const ensureReasoningState = (itemId: string, visibility: ReasoningVisibility = "summary"): ReasoningStreamState => {
399
499
  let state = reasoningStates.get(itemId);
400
500
  if (!state) {
@@ -449,8 +549,12 @@ export class ResponsesAdapter extends AdapterBase {
449
549
  }
450
550
  case "function_call": {
451
551
  const name = typeof item.name === "string" ? item.name : "unknown";
552
+ // Responses 用 call_id 关联 function_call_output;item.id 是 fc_* item id
553
+ const callId =
554
+ typeof item.call_id === "string" && item.call_id.length > 0 ? item.call_id : item.id;
452
555
  toolCallNames.set(item.id, name);
453
- yield factory.toolCallStarted(item.id, name);
556
+ toolCallIds.set(item.id, callId);
557
+ yield factory.toolCallStarted(callId, name);
454
558
  break;
455
559
  }
456
560
  }
@@ -569,14 +673,19 @@ export class ResponsesAdapter extends AdapterBase {
569
673
 
570
674
  if (sseEvent.type === "response.function_call_arguments.delta") {
571
675
  const data = sseEvent.data as { item_id: string; delta: string };
572
- if (data.delta) yield factory.toolCallDelta(data.item_id, { argumentsText: data.delta });
676
+ if (data.delta) {
677
+ yield factory.toolCallDelta(resolveToolCallId(data.item_id), { argumentsText: data.delta });
678
+ }
573
679
  continue;
574
680
  }
575
681
 
576
682
  if (sseEvent.type === "response.function_call_arguments.done") {
577
683
  const data = sseEvent.data as { item_id: string; arguments: string };
578
- const tcItem = toolCallItem(data.item_id, toolCallNames.get(data.item_id) ?? "unknown", data.arguments);
579
- yield factory.toolCallCompleted(data.item_id);
684
+ const callId = resolveToolCallId(data.item_id);
685
+ // 若 added 事件缺失,done 时仍尽量从 completed payload 之外兜底 call_id
686
+ if (!toolCallIds.has(data.item_id)) toolCallIds.set(data.item_id, callId);
687
+ const tcItem = toolCallItem(callId, toolCallNames.get(data.item_id) ?? "unknown", data.arguments);
688
+ yield factory.toolCallCompleted(callId);
580
689
  output.push(tcItem);
581
690
  continue;
582
691
  }
@@ -640,7 +749,13 @@ export class ResponsesAdapter extends AdapterBase {
640
749
 
641
750
  const replay = [...replayFromOutput(output)];
642
751
  if (completedResponse?.id) {
643
- replay.push(opaqueItem("responses", "replay", { id: completedResponse.id }));
752
+ // 同时保留 id(向后兼容)与 previous_response_id(语义明确)
753
+ replay.push(
754
+ opaqueItem("responses", "replay", {
755
+ id: completedResponse.id,
756
+ previous_response_id: completedResponse.id,
757
+ }),
758
+ );
644
759
  }
645
760
 
646
761
  const stopReason = completedResponse ? this.inferStopReason(completedResponse) : undefined;