@codehz/ai 0.2.3 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codehz/ai",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "module": "dist/index.mjs",
6
6
  "exports": {
@@ -24,7 +24,6 @@ import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
24
24
  import { assertOpaqueReplayEnvelope, providerHttpError } from "../helpers/adapter-security.js";
25
25
  import { usageFromChatCompletions } from "../helpers/usage-mapping.js";
26
26
  import { NormalizedRequestMapper, splitLines, IncrementalStreamParser } from "../helpers/index.js";
27
- import type { ProviderProfile } from "../helpers/index.js";
28
27
 
29
28
  import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
30
29
 
@@ -118,24 +117,7 @@ type ReasoningFieldName = "reasoning" | "reasoning_content";
118
117
 
119
118
  const REASONING_FIELDS: readonly ReasoningFieldName[] = ["reasoning_content", "reasoning"];
120
119
 
121
- // ── ProviderProfile & Mapper ────────────────────────────────────
122
-
123
- const profile: ProviderProfile = {
124
- kind: "chat-completions",
125
- instructionsMode: "system_message",
126
- supportedBlockTypes: ["text", "json"] as const,
127
- reasoningBlockTypes: ["text"] as const,
128
- capabilities: {
129
- textStreaming: "native",
130
- reasoningStreaming: "native",
131
- toolCallStreaming: "native",
132
- replay: "opaque",
133
- usage: "final",
134
- toolResultOutcomes: ["success"],
135
- },
136
- };
137
-
138
- const mapper = new NormalizedRequestMapper(profile);
120
+ const mapper = new NormalizedRequestMapper("chat-completions");
139
121
 
140
122
  function extractReasoningText(value: unknown): string {
141
123
  if (typeof value === "string") return value;
@@ -251,7 +233,7 @@ function buildAssistantReplayMessage(params: {
251
233
 
252
234
  export class ChatCompletionsAdapter extends AdapterBase {
253
235
  readonly kind = "chat-completions" as const;
254
- readonly capabilities = profile.capabilities;
236
+ readonly isSyntheticStream = false;
255
237
 
256
238
  private apiKey: string;
257
239
  private baseUrl: string;
@@ -303,7 +285,6 @@ export class ChatCompletionsAdapter extends AdapterBase {
303
285
  break;
304
286
  }
305
287
  case "tool_result": {
306
- mapper.assertToolResultOutcome(item.outcome);
307
288
  messages.push({
308
289
  role: "tool",
309
290
  tool_call_id: item.callId,
@@ -323,7 +304,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
323
304
  }
324
305
  case "opaque": {
325
306
  // Try to restore from opaque replay
326
- if (item.purpose !== "replay") break;
307
+ if (item.source !== "chat.completions" || item.purpose !== "replay") break;
327
308
  assertOpaqueReplayEnvelope(item.payload);
328
309
  const payload = item.payload as Record<string, unknown>;
329
310
  if (payload.role === "assistant" && typeof payload.content === "string") {
@@ -398,6 +379,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
398
379
  Authorization: `Bearer ${this.apiKey}`,
399
380
  },
400
381
  body: JSON.stringify(providerRequest),
382
+ signal: request.signal,
401
383
  });
402
384
  } catch (err) {
403
385
  throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
@@ -27,7 +27,6 @@ import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
27
27
  import { assertOpaqueReplayEnvelope, providerHttpError } from "../helpers/adapter-security.js";
28
28
  import { usageFromAnthropicMessages } from "../helpers/usage-mapping.js";
29
29
  import { NormalizedRequestMapper, splitSSEFrames, IncrementalStreamParser } from "../helpers/index.js";
30
- import type { ProviderProfile } from "../helpers/index.js";
31
30
 
32
31
  import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
33
32
 
@@ -73,24 +72,7 @@ type MessagesAPITool = {
73
72
  input_schema: Record<string, unknown>;
74
73
  };
75
74
 
76
- // ── ProviderProfile & Mapper ────────────────────────────────────
77
-
78
- const profile: ProviderProfile = {
79
- kind: "messages",
80
- instructionsMode: "system_message",
81
- supportedBlockTypes: ["text", "json"] as const,
82
- reasoningBlockTypes: ["text"] as const,
83
- capabilities: {
84
- textStreaming: "native",
85
- reasoningStreaming: "native",
86
- toolCallStreaming: "synthetic",
87
- replay: "opaque",
88
- usage: "stream",
89
- toolResultOutcomes: ["success", "error"],
90
- },
91
- };
92
-
93
- const mapper = new NormalizedRequestMapper(profile);
75
+ const mapper = new NormalizedRequestMapper("messages");
94
76
 
95
77
  function isMessagesReplayContentBlock(value: unknown): value is MessagesAPIContentBlock {
96
78
  if (!value || typeof value !== "object" || !("type" in value)) return false;
@@ -177,10 +159,10 @@ function synthesizeItemId(kind: "msg" | "reason" | "reason-redacted", blockIndex
177
159
  return `${kind}-${blockIndex}-${responseId}`;
178
160
  }
179
161
 
180
- function parseToolUseInput(input: string): Record<string, unknown> {
162
+ function parseProviderToolUseInput(input: string): Record<string, unknown> {
181
163
  try {
182
- const parsed = JSON.parse(input);
183
- return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
164
+ const parsed: unknown = JSON.parse(input);
165
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
184
166
  } catch {
185
167
  return {};
186
168
  }
@@ -251,7 +233,7 @@ function buildStreamMetadata(options: {
251
233
 
252
234
  export class MessagesAdapter extends AdapterBase {
253
235
  readonly kind = "messages" as const;
254
- readonly capabilities = profile.capabilities;
236
+ readonly isSyntheticStream = false;
255
237
 
256
238
  private apiKey: string;
257
239
  private apiVersion: string;
@@ -302,7 +284,7 @@ export class MessagesAdapter extends AdapterBase {
302
284
  type: "tool_use",
303
285
  id: item.id,
304
286
  name: item.name,
305
- input: (item.argumentsJson as Record<string, unknown> | undefined) ?? parseToolUseInput(item.argumentsText),
287
+ input: mapper.parseToolArguments(item),
306
288
  };
307
289
 
308
290
  if (lastMsg && lastMsg.role === "assistant" && typeof lastMsg.content !== "string") {
@@ -313,7 +295,6 @@ export class MessagesAdapter extends AdapterBase {
313
295
  break;
314
296
  }
315
297
  case "tool_result": {
316
- mapper.assertToolResultOutcome(item.outcome);
317
298
  const content = mapper
318
299
  .ensureTextBlocks(item.content, `tool_result ${item.callId} content`)
319
300
  .map(blockToText)
@@ -322,7 +303,7 @@ export class MessagesAdapter extends AdapterBase {
322
303
  type: "tool_result",
323
304
  tool_use_id: item.callId,
324
305
  content,
325
- is_error: item.outcome === "error",
306
+ is_error: item.outcome !== "success",
326
307
  };
327
308
  if (pendingToolResultMessage && typeof pendingToolResultMessage.content !== "string") {
328
309
  pendingToolResultMessage.content.push(block);
@@ -346,7 +327,7 @@ export class MessagesAdapter extends AdapterBase {
346
327
  }
347
328
  case "opaque": {
348
329
  // 尝试从 opaque replay item 中提取 assistant message
349
- if (item.purpose !== "replay") break;
330
+ if (item.source !== "messages" || item.purpose !== "replay") break;
350
331
  assertOpaqueReplayEnvelope(item.payload);
351
332
  const payload = item.payload as Record<string, unknown>;
352
333
  if (payload.role === "assistant" && "content" in payload) {
@@ -422,6 +403,7 @@ export class MessagesAdapter extends AdapterBase {
422
403
  "anthropic-version": this.apiVersion,
423
404
  },
424
405
  body: JSON.stringify(providerRequest),
406
+ signal: request.signal,
425
407
  });
426
408
  } catch (err) {
427
409
  throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
@@ -625,7 +607,7 @@ export class MessagesAdapter extends AdapterBase {
625
607
  type: "tool_use",
626
608
  id: currentItemId,
627
609
  name: currentToolName,
628
- input: parseToolUseInput(currentArgsText || argsBuffer),
610
+ input: parseProviderToolUseInput(currentArgsText || argsBuffer),
629
611
  });
630
612
  }
631
613
 
@@ -68,6 +68,8 @@ export type MockHandlerContext = {
68
68
  previousReplay: ReplayItem[];
69
69
  pendingToolCalls: readonly ToolCallItem[];
70
70
  history: readonly MockHistoryRecord[];
71
+ /** 请求的 AbortSignal,handler 可检查 signal.aborted 提前退出。 */
72
+ signal?: AbortSignal;
71
73
  };
72
74
 
73
75
  export type MockWarningStep = {
@@ -118,7 +120,6 @@ export type MockToolCallStep = {
118
120
  id: string;
119
121
  name: string;
120
122
  argumentsText: string;
121
- argumentsJson?: unknown;
122
123
  streamArguments?: boolean;
123
124
  stream?: MockTextStreamOptions | false;
124
125
  };
@@ -264,14 +265,7 @@ export function assertMockRequest(
264
265
 
265
266
  export class MockAdapter extends AdapterBase {
266
267
  readonly kind = "mock" as const;
267
- readonly capabilities = {
268
- textStreaming: "synthetic",
269
- reasoningStreaming: "synthetic",
270
- toolCallStreaming: "synthetic",
271
- replay: "canonical",
272
- usage: "final",
273
- toolResultOutcomes: ["success", "error", "rejected"],
274
- } as const;
268
+ readonly isSyntheticStream = true;
275
269
 
276
270
  private readonly handler: MockHandler;
277
271
  private readonly providerMetadata?: Record<string, unknown>;
@@ -290,7 +284,7 @@ export class MockAdapter extends AdapterBase {
290
284
 
291
285
  protected async buildRequest(request: NormalizedRequest): Promise<MockProviderRequest> {
292
286
  const turnIndex = this.cursor;
293
- const context = this.buildHandlerContext(turnIndex);
287
+ const context = this.buildHandlerContext(turnIndex, request.signal);
294
288
  const remainingPendingToolCalls = consumePendingToolCalls(this.pendingToolCalls, request.input);
295
289
  const handlerResult = this.handler(request, context);
296
290
 
@@ -321,6 +315,9 @@ export class MockAdapter extends AdapterBase {
321
315
  let stepCount = 0;
322
316
 
323
317
  for await (const step of mockRequest.handlerResult) {
318
+ // 若 signal 已 abort,停止消费 handler 并返回
319
+ if (request.signal?.aborted) return;
320
+
324
321
  stepCount += 1;
325
322
 
326
323
  switch (step.type) {
@@ -479,7 +476,7 @@ export class MockAdapter extends AdapterBase {
479
476
  );
480
477
  }
481
478
 
482
- private buildHandlerContext(turnIndex: number): MockHandlerContext {
479
+ private buildHandlerContext(turnIndex: number, signal?: AbortSignal): MockHandlerContext {
483
480
  return {
484
481
  turnIndex,
485
482
  previousReplay: this.previousReplay.map(cloneItem),
@@ -489,6 +486,7 @@ export class MockAdapter extends AdapterBase {
489
486
  replay: record.replay.map(cloneItem),
490
487
  toolCalls: record.toolCalls.map(cloneItem),
491
488
  })),
489
+ signal,
492
490
  };
493
491
  }
494
492
  }
@@ -566,7 +564,6 @@ function createToolCallFromStep(step: MockToolCallStep): ToolCallItem {
566
564
  id: step.id,
567
565
  name: step.name,
568
566
  argumentsText: step.argumentsText,
569
- argumentsJson: step.argumentsJson,
570
567
  };
571
568
  }
572
569
 
@@ -30,7 +30,6 @@ import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
30
30
  import { assertOpaqueReplayEnvelope, providerHttpError } from "../helpers/adapter-security.js";
31
31
  import { usageFromOllama } from "../helpers/usage-mapping.js";
32
32
  import { NormalizedRequestMapper, splitLines, IncrementalStreamParser } from "../helpers/index.js";
33
- import type { ProviderProfile } from "../helpers/index.js";
34
33
 
35
34
  import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
36
35
 
@@ -82,44 +81,7 @@ type OllamaTool = {
82
81
  };
83
82
  };
84
83
 
85
- // ── ProviderProfile & Mapper ────────────────────────────────────
86
-
87
- const profile: ProviderProfile = {
88
- kind: "ollama",
89
- instructionsMode: "system_message",
90
- supportedBlockTypes: ["text", "json"] as const,
91
- reasoningBlockTypes: ["text"] as const,
92
- capabilities: {
93
- textStreaming: "native",
94
- reasoningStreaming: "none",
95
- toolCallStreaming: "synthetic",
96
- replay: "opaque",
97
- usage: "final",
98
- toolResultOutcomes: ["success"],
99
- },
100
- };
101
-
102
- const mapper = new NormalizedRequestMapper(profile);
103
-
104
- function parseOllamaToolArguments(item: import("../index.js").ToolCallItem): Record<string, unknown> {
105
- if (item.argumentsJson && typeof item.argumentsJson === "object" && item.argumentsJson !== null) {
106
- return item.argumentsJson as Record<string, unknown>;
107
- }
108
-
109
- try {
110
- const parsed = JSON.parse(item.argumentsText);
111
- if (parsed && typeof parsed === "object") {
112
- return parsed as Record<string, unknown>;
113
- }
114
- } catch {
115
- // fall through
116
- }
117
-
118
- throw new AIRequestError(
119
- "ollama tool_call argumentsText must be valid JSON object when argumentsJson is absent",
120
- "TOOL_CALL_ARGUMENTS_INVALID",
121
- );
122
- }
84
+ const mapper = new NormalizedRequestMapper("ollama");
123
85
 
124
86
  // ── Ollama 流式 chunk ─────────────────────────────────────────
125
87
 
@@ -179,7 +141,7 @@ function toWireOllamaToolCalls(toolCalls: OllamaReplayToolCall[]): OllamaToolCal
179
141
 
180
142
  export class OllamaAdapter extends AdapterBase {
181
143
  readonly kind = "ollama" as const;
182
- readonly capabilities = profile.capabilities;
144
+ readonly isSyntheticStream = false;
183
145
 
184
146
  private baseUrl: string;
185
147
  private apiKey: string | undefined;
@@ -195,10 +157,6 @@ export class OllamaAdapter extends AdapterBase {
195
157
  // ── buildRequest ──────────────────────────────────────────
196
158
 
197
159
  protected buildRequest(request: NormalizedRequest): OllamaChatRequest {
198
- if (request.toolChoice && request.toolChoice !== "auto") {
199
- throw new AIRequestError("ollama does not support explicit toolChoice", "UNSUPPORTED_TOOL_CHOICE");
200
- }
201
-
202
160
  const messages: OllamaMessage[] = [];
203
161
  /** Local-only name → call id queue for best-effort tool_result association (not sent to Ollama). */
204
162
  const callIdsByName = new Map<string, string[]>();
@@ -224,7 +182,7 @@ export class OllamaAdapter extends AdapterBase {
224
182
  const tc: OllamaToolCall = {
225
183
  function: {
226
184
  name: item.name,
227
- arguments: parseOllamaToolArguments(item),
185
+ arguments: mapper.parseToolArguments(item),
228
186
  },
229
187
  };
230
188
  const queue = callIdsByName.get(item.name) ?? [];
@@ -238,7 +196,6 @@ export class OllamaAdapter extends AdapterBase {
238
196
  break;
239
197
  }
240
198
  case "tool_result": {
241
- mapper.assertToolResultOutcome(item.outcome);
242
199
  // Best-effort: consume matching id from name queue when present (no wire call_id)
243
200
  const queue = callIdsByName.get(item.toolName);
244
201
  if (queue && queue.length > 0) {
@@ -302,8 +259,16 @@ export class OllamaAdapter extends AdapterBase {
302
259
  stream: true,
303
260
  };
304
261
 
305
- if (request.tools && request.tools.length > 0) {
306
- body.tools = request.tools.map(
262
+ const toolChoice = request.toolChoice;
263
+ const selectedTools =
264
+ toolChoice === "none"
265
+ ? []
266
+ : toolChoice && typeof toolChoice === "object"
267
+ ? request.tools?.filter((tool) => tool.name === toolChoice.name)
268
+ : request.tools;
269
+
270
+ if (selectedTools && selectedTools.length > 0) {
271
+ body.tools = selectedTools.map(
307
272
  (t): OllamaTool => ({
308
273
  type: "function",
309
274
  function: {
@@ -333,6 +298,14 @@ export class OllamaAdapter extends AdapterBase {
333
298
  ): AsyncIterable<AIStreamEvent> {
334
299
  const auxiliary = this.createAuxiliaryState(request);
335
300
  let completedEmitted = false;
301
+ if (request.toolChoice && request.toolChoice !== "auto") {
302
+ yield factory.responseWarning(
303
+ request.toolChoice === "none"
304
+ ? "Ollama toolChoice none was mapped by omitting tools"
305
+ : `Ollama cannot force tool choice; only tool "${request.toolChoice.name}" was provided as a best-effort constraint`,
306
+ WarningCode.CAPABILITY_DOWNGRADE,
307
+ );
308
+ }
336
309
  if (request.metadata) {
337
310
  yield factory.responseWarning("Request metadata is not supported by the Ollama adapter", "UNSUPPORTED_METADATA");
338
311
  }
@@ -351,6 +324,7 @@ export class OllamaAdapter extends AdapterBase {
351
324
  method: "POST",
352
325
  headers,
353
326
  body: JSON.stringify(providerRequest),
327
+ signal: request.signal,
354
328
  });
355
329
  } catch (err) {
356
330
  throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
@@ -390,7 +364,7 @@ export class OllamaAdapter extends AdapterBase {
390
364
  let hasMessageStarted = false;
391
365
 
392
366
  // tool_calls 累积(于 final chunk 到达)
393
- let pendingToolCalls: Array<{ id: string; name: string; argumentsText: string; argumentsJson?: unknown }> = [];
367
+ let pendingToolCalls: Array<{ id: string; name: string; argumentsText: string }> = [];
394
368
  let toolCallIndex = 0;
395
369
  const buildResponse = this.buildResponse.bind(this);
396
370
 
@@ -414,7 +388,7 @@ export class OllamaAdapter extends AdapterBase {
414
388
  content: accumulatedContent,
415
389
  tool_calls: pendingToolCalls.map((tc) => ({
416
390
  id: tc.id,
417
- function: { name: tc.name, arguments: tc.argumentsJson },
391
+ function: { name: tc.name, arguments: JSON.parse(tc.argumentsText) as Record<string, unknown> },
418
392
  })),
419
393
  }),
420
394
  );
@@ -503,7 +477,6 @@ export class OllamaAdapter extends AdapterBase {
503
477
  id: tcId,
504
478
  name: tc.function.name,
505
479
  argumentsText: argsText,
506
- argumentsJson: tc.function.arguments,
507
480
  });
508
481
  }
509
482
  }
@@ -535,7 +508,7 @@ export class OllamaAdapter extends AdapterBase {
535
508
 
536
509
  // 发出 tool_call 完成事件
537
510
  for (const pending of pendingToolCalls) {
538
- const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
511
+ const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText);
539
512
  yield factory.toolCallStarted(pending.id, pending.name);
540
513
  yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
541
514
  yield factory.toolCallCompleted(pending.id);
@@ -610,7 +583,7 @@ export class OllamaAdapter extends AdapterBase {
610
583
  }
611
584
 
612
585
  for (const pending of pendingToolCalls) {
613
- const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
586
+ const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText);
614
587
  yield factory.toolCallStarted(pending.id, pending.name);
615
588
  yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
616
589
  yield factory.toolCallCompleted(pending.id);
@@ -25,7 +25,6 @@ import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
25
25
  import { assertOpaqueReplayEnvelope, providerHttpError } from "../helpers/adapter-security.js";
26
26
  import { usageFromOpenAIResponses } from "../helpers/usage-mapping.js";
27
27
  import { NormalizedRequestMapper, splitSSEFrames, IncrementalStreamParser } from "../helpers/index.js";
28
- import type { ProviderProfile } from "../helpers/index.js";
29
28
 
30
29
  import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
31
30
 
@@ -72,24 +71,7 @@ type ResponsesTool = {
72
71
  input_schema: Record<string, unknown>;
73
72
  };
74
73
 
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
- };
91
-
92
- const mapper = new NormalizedRequestMapper(profile);
74
+ const mapper = new NormalizedRequestMapper("responses");
93
75
 
94
76
  // ── SSE 事件类型 ──────────────────────────────────────────────
95
77
 
@@ -99,8 +81,8 @@ type ResponsesSSEEvent =
99
81
  | { type: "response.output_text.done"; data: { item_id: string; text: string } }
100
82
  | { type: "response.reasoning.delta"; data: { item_id: string; delta: string } }
101
83
  | { type: "response.reasoning.done"; data: { item_id: string; text: string } }
102
- | { type: "response.tool_call.delta"; data: { item_id: string; delta: { arguments?: string } } }
103
- | { type: "response.tool_call.done"; data: { item_id: string; arguments?: string; name?: string } }
84
+ | { type: "response.function_call_arguments.delta"; data: { item_id: string; delta: string } }
85
+ | { type: "response.function_call_arguments.done"; data: { item_id: string; arguments: string } }
104
86
  | { type: "response.completed"; data: { response: ResponsesAPIResponse } }
105
87
  | { type: "response.failed"; data: { response: ResponsesAPIResponse } }
106
88
  | { type: "response.incomplete"; data: { response: ResponsesAPIResponse } }
@@ -115,8 +97,6 @@ const KNOWN_RESPONSES_SSE_TYPES = new Set([
115
97
  "response.output_text.done",
116
98
  "response.reasoning.delta",
117
99
  "response.reasoning.done",
118
- "response.tool_call.delta",
119
- "response.tool_call.done",
120
100
  "response.function_call_arguments.delta",
121
101
  "response.function_call_arguments.done",
122
102
  "response.content_part.added",
@@ -187,7 +167,7 @@ function canonicalToResponsesBlock(b: import("../index.js").ContentBlock): Respo
187
167
 
188
168
  export class ResponsesAdapter extends AdapterBase {
189
169
  readonly kind = "responses" as const;
190
- readonly capabilities = profile.capabilities;
170
+ readonly isSyntheticStream = false;
191
171
 
192
172
  private apiKey: string;
193
173
  private baseUrl: string;
@@ -242,7 +222,6 @@ export class ResponsesAdapter extends AdapterBase {
242
222
  break;
243
223
  }
244
224
  case "tool_result": {
245
- mapper.assertToolResultOutcome(item.outcome);
246
225
  const output = mapper
247
226
  .ensureTextBlocks(item.content, `tool_result ${item.callId} content`)
248
227
  .map(blockToText)
@@ -330,6 +309,7 @@ export class ResponsesAdapter extends AdapterBase {
330
309
  Authorization: `Bearer ${this.apiKey}`,
331
310
  },
332
311
  body: JSON.stringify(providerRequest),
312
+ signal: request.signal,
333
313
  });
334
314
  } catch (err) {
335
315
  throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
@@ -369,6 +349,7 @@ export class ResponsesAdapter extends AdapterBase {
369
349
  let completedEmitted = false;
370
350
  let unknownEventsWarned = false;
371
351
  const messageItemsWithDelta = new Set<string>();
352
+ const toolCallNames = new Map<string, string>();
372
353
 
373
354
  try {
374
355
  while (true) {
@@ -407,9 +388,12 @@ export class ResponsesAdapter extends AdapterBase {
407
388
  case "reasoning":
408
389
  yield factory.reasoningStarted(item.id, "full");
409
390
  break;
410
- case "function_call":
411
- yield factory.toolCallStarted(item.id, ((item as Record<string, unknown>).name as string) ?? "unknown");
391
+ case "function_call": {
392
+ const name = typeof item.name === "string" ? item.name : "unknown";
393
+ toolCallNames.set(item.id, name);
394
+ yield factory.toolCallStarted(item.id, name);
412
395
  break;
396
+ }
413
397
  }
414
398
  continue;
415
399
  }
@@ -444,17 +428,15 @@ export class ResponsesAdapter extends AdapterBase {
444
428
  continue;
445
429
  }
446
430
 
447
- if (sseEvent.type === "response.tool_call.delta") {
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 });
451
- }
431
+ if (sseEvent.type === "response.function_call_arguments.delta") {
432
+ const data = sseEvent.data as { item_id: string; delta: string };
433
+ if (data.delta) yield factory.toolCallDelta(data.item_id, { argumentsText: data.delta });
452
434
  continue;
453
435
  }
454
436
 
455
- if (sseEvent.type === "response.tool_call.done") {
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 ?? "");
437
+ if (sseEvent.type === "response.function_call_arguments.done") {
438
+ const data = sseEvent.data as { item_id: string; arguments: string };
439
+ const tcItem = toolCallItem(data.item_id, toolCallNames.get(data.item_id) ?? "unknown", data.arguments);
458
440
  yield factory.toolCallCompleted(data.item_id);
459
441
  output.push(tcItem);
460
442
  continue;
@@ -8,11 +8,13 @@ import type { AIRequest, AIStreamEvent, AIClient, CreateAIClientOptions } from "
8
8
  import { normalizeRequest } from "./normalize.js";
9
9
 
10
10
  export function createAIClient(options: CreateAIClientOptions): AIClient {
11
- const { adapter, model, defaults } = options;
11
+ const { adapter, model, defaults, signal: defaultSignal } = options;
12
12
 
13
13
  const client: AIClient = {
14
14
  stream(request: AIRequest): AsyncIterable<AIStreamEvent> {
15
- const normalized = normalizeRequest(request, { model, defaults });
15
+ // 合并 client 级别的默认 signal 和请求级别的 signal
16
+ const signal = mergeAbortSignals(defaultSignal, request.signal);
17
+ const normalized = normalizeRequest({ ...request, signal }, { model, defaults });
16
18
  return adapter.stream(normalized);
17
19
  },
18
20
  };
@@ -20,4 +22,15 @@ export function createAIClient(options: CreateAIClientOptions): AIClient {
20
22
  return client;
21
23
  }
22
24
 
25
+ /**
26
+ * 合并多个 AbortSignal:任一 signal abort 即触发。
27
+ * 如果没有 signal 需要合并则返回 undefined。
28
+ */
29
+ function mergeAbortSignals(...signals: (AbortSignal | undefined)[]): AbortSignal | undefined {
30
+ const valid = signals.filter((s): s is AbortSignal => s != null);
31
+ if (valid.length === 0) return undefined;
32
+ if (valid.length === 1) return valid[0];
33
+ return AbortSignal.any(valid);
34
+ }
35
+
23
36
  export type { AIClient, CreateAIClientOptions } from "../types/index.js";
@@ -136,6 +136,25 @@ function validateInputItem(item: unknown, field: string, issues: ValidationIssue
136
136
  "TOOL_CALL_ARGUMENTS_INVALID",
137
137
  `${field}.argumentsText must be a string`,
138
138
  );
139
+ } else {
140
+ try {
141
+ const parsed: unknown = JSON.parse(item.argumentsText);
142
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
143
+ pushIssue(
144
+ issues,
145
+ `${field}.argumentsText`,
146
+ "TOOL_CALL_ARGUMENTS_INVALID",
147
+ `${field}.argumentsText must encode a JSON object`,
148
+ );
149
+ }
150
+ } catch {
151
+ pushIssue(
152
+ issues,
153
+ `${field}.argumentsText`,
154
+ "TOOL_CALL_ARGUMENTS_INVALID",
155
+ `${field}.argumentsText must encode a JSON object`,
156
+ );
157
+ }
139
158
  }
140
159
  return;
141
160
  case "tool_result":
@@ -56,7 +56,7 @@ export type StreamResult = {
56
56
 
57
57
  export abstract class AdapterBase implements BackendAdapter {
58
58
  abstract readonly kind: "chat-completions" | "messages" | "responses" | "ollama" | "mock";
59
- abstract readonly capabilities: import("../types/index.js").AdapterCapabilities;
59
+ abstract readonly isSyntheticStream: boolean;
60
60
 
61
61
  /**
62
62
  * stream 模板方法:
@@ -65,9 +65,12 @@ export abstract class AdapterBase implements BackendAdapter {
65
65
  * 3. 委托 runStream 发射全部流事件(含 response.completed)
66
66
  */
67
67
  async *stream(request: NormalizedRequest): AsyncIterable<AIStreamEvent> {
68
+ // 若请求已被 abort,不发出任何事件
69
+ request.signal?.throwIfAborted();
70
+
68
71
  const factory = createEventFactory({
69
72
  responseId: request.requestId,
70
- backend: { kind: this.kind, isSynthetic: this.capabilities.textStreaming === "synthetic" },
73
+ backend: { kind: this.kind, isSynthetic: this.isSyntheticStream },
71
74
  });
72
75
 
73
76
  yield factory.responseStarted(request.model);
@@ -148,7 +151,7 @@ export abstract class AdapterBase implements BackendAdapter {
148
151
  requestId: request.requestId,
149
152
  rawResponseId: result.rawResponseId,
150
153
  adapter: this.kind,
151
- isSyntheticStream: this.capabilities.textStreaming === "synthetic",
154
+ isSyntheticStream: this.isSyntheticStream,
152
155
  metadataSources: result.metadataSources,
153
156
  warnings,
154
157
  },
@@ -58,4 +58,3 @@ export { IncrementalStreamParser, splitLines, splitSSEFrames } from "./increment
58
58
  export type { StreamSplitResult, StreamParseResult } from "./incremental-stream-parser.js";
59
59
 
60
60
  export { NormalizedRequestMapper } from "./request-mapper.js";
61
- export type { ProviderProfile } from "./request-mapper.js";
@@ -100,13 +100,12 @@ export function reasoningItem(
100
100
  };
101
101
  }
102
102
 
103
- export function toolCallItem(id: string, name: string, argumentsText: string, argumentsJson?: unknown): ToolCallItem {
103
+ export function toolCallItem(id: string, name: string, argumentsText: string): ToolCallItem {
104
104
  return {
105
105
  type: "tool_call",
106
106
  id,
107
107
  name,
108
108
  argumentsText,
109
- argumentsJson,
110
109
  };
111
110
  }
112
111