@codehz/ai 0.2.4 → 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.4",
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") {
@@ -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) {
@@ -626,7 +607,7 @@ export class MessagesAdapter extends AdapterBase {
626
607
  type: "tool_use",
627
608
  id: currentItemId,
628
609
  name: currentToolName,
629
- input: parseToolUseInput(currentArgsText || argsBuffer),
610
+ input: parseProviderToolUseInput(currentArgsText || argsBuffer),
630
611
  });
631
612
  }
632
613
 
@@ -120,7 +120,6 @@ export type MockToolCallStep = {
120
120
  id: string;
121
121
  name: string;
122
122
  argumentsText: string;
123
- argumentsJson?: unknown;
124
123
  streamArguments?: boolean;
125
124
  stream?: MockTextStreamOptions | false;
126
125
  };
@@ -266,14 +265,7 @@ export function assertMockRequest(
266
265
 
267
266
  export class MockAdapter extends AdapterBase {
268
267
  readonly kind = "mock" as const;
269
- readonly capabilities = {
270
- textStreaming: "synthetic",
271
- reasoningStreaming: "synthetic",
272
- toolCallStreaming: "synthetic",
273
- replay: "canonical",
274
- usage: "final",
275
- toolResultOutcomes: ["success", "error", "rejected"],
276
- } as const;
268
+ readonly isSyntheticStream = true;
277
269
 
278
270
  private readonly handler: MockHandler;
279
271
  private readonly providerMetadata?: Record<string, unknown>;
@@ -572,7 +564,6 @@ function createToolCallFromStep(step: MockToolCallStep): ToolCallItem {
572
564
  id: step.id,
573
565
  name: step.name,
574
566
  argumentsText: step.argumentsText,
575
- argumentsJson: step.argumentsJson,
576
567
  };
577
568
  }
578
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
  }
@@ -391,7 +364,7 @@ export class OllamaAdapter extends AdapterBase {
391
364
  let hasMessageStarted = false;
392
365
 
393
366
  // tool_calls 累积(于 final chunk 到达)
394
- let pendingToolCalls: Array<{ id: string; name: string; argumentsText: string; argumentsJson?: unknown }> = [];
367
+ let pendingToolCalls: Array<{ id: string; name: string; argumentsText: string }> = [];
395
368
  let toolCallIndex = 0;
396
369
  const buildResponse = this.buildResponse.bind(this);
397
370
 
@@ -415,7 +388,7 @@ export class OllamaAdapter extends AdapterBase {
415
388
  content: accumulatedContent,
416
389
  tool_calls: pendingToolCalls.map((tc) => ({
417
390
  id: tc.id,
418
- function: { name: tc.name, arguments: tc.argumentsJson },
391
+ function: { name: tc.name, arguments: JSON.parse(tc.argumentsText) as Record<string, unknown> },
419
392
  })),
420
393
  }),
421
394
  );
@@ -504,7 +477,6 @@ export class OllamaAdapter extends AdapterBase {
504
477
  id: tcId,
505
478
  name: tc.function.name,
506
479
  argumentsText: argsText,
507
- argumentsJson: tc.function.arguments,
508
480
  });
509
481
  }
510
482
  }
@@ -536,7 +508,7 @@ export class OllamaAdapter extends AdapterBase {
536
508
 
537
509
  // 发出 tool_call 完成事件
538
510
  for (const pending of pendingToolCalls) {
539
- const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
511
+ const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText);
540
512
  yield factory.toolCallStarted(pending.id, pending.name);
541
513
  yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
542
514
  yield factory.toolCallCompleted(pending.id);
@@ -611,7 +583,7 @@ export class OllamaAdapter extends AdapterBase {
611
583
  }
612
584
 
613
585
  for (const pending of pendingToolCalls) {
614
- const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
586
+ const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText);
615
587
  yield factory.toolCallStarted(pending.id, pending.name);
616
588
  yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
617
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)
@@ -370,6 +349,7 @@ export class ResponsesAdapter extends AdapterBase {
370
349
  let completedEmitted = false;
371
350
  let unknownEventsWarned = false;
372
351
  const messageItemsWithDelta = new Set<string>();
352
+ const toolCallNames = new Map<string, string>();
373
353
 
374
354
  try {
375
355
  while (true) {
@@ -408,9 +388,12 @@ export class ResponsesAdapter extends AdapterBase {
408
388
  case "reasoning":
409
389
  yield factory.reasoningStarted(item.id, "full");
410
390
  break;
411
- case "function_call":
412
- 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);
413
395
  break;
396
+ }
414
397
  }
415
398
  continue;
416
399
  }
@@ -445,17 +428,15 @@ export class ResponsesAdapter extends AdapterBase {
445
428
  continue;
446
429
  }
447
430
 
448
- if (sseEvent.type === "response.tool_call.delta") {
449
- const data = sseEvent.data as { item_id: string; delta: { arguments?: string } };
450
- if (data.delta.arguments) {
451
- yield factory.toolCallDelta(data.item_id, { argumentsText: data.delta.arguments });
452
- }
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 });
453
434
  continue;
454
435
  }
455
436
 
456
- if (sseEvent.type === "response.tool_call.done") {
457
- const data = sseEvent.data as { item_id: string; arguments?: string; name?: string };
458
- 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);
459
440
  yield factory.toolCallCompleted(data.item_id);
460
441
  output.push(tcItem);
461
442
  continue;
@@ -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 模板方法:
@@ -70,7 +70,7 @@ export abstract class AdapterBase implements BackendAdapter {
70
70
 
71
71
  const factory = createEventFactory({
72
72
  responseId: request.requestId,
73
- backend: { kind: this.kind, isSynthetic: this.capabilities.textStreaming === "synthetic" },
73
+ backend: { kind: this.kind, isSynthetic: this.isSyntheticStream },
74
74
  });
75
75
 
76
76
  yield factory.responseStarted(request.model);
@@ -151,7 +151,7 @@ export abstract class AdapterBase implements BackendAdapter {
151
151
  requestId: request.requestId,
152
152
  rawResponseId: result.rawResponseId,
153
153
  adapter: this.kind,
154
- isSyntheticStream: this.capabilities.textStreaming === "synthetic",
154
+ isSyntheticStream: this.isSyntheticStream,
155
155
  metadataSources: result.metadataSources,
156
156
  warnings,
157
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
 
@@ -1,18 +1,10 @@
1
1
  import { AIRequestError } from "../core/errors.js";
2
2
  import { contentBlocksToText } from "./mapping.js";
3
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
- };
4
+ import type { ContentBlock, InstructionBlock, ToolCallItem } from "../types/index.js";
13
5
 
14
6
  export class NormalizedRequestMapper {
15
- constructor(readonly profile: ProviderProfile) {}
7
+ constructor(readonly kind: string) {}
16
8
 
17
9
  mapInstructions(instructions: string | InstructionBlock[]): string {
18
10
  return typeof instructions === "string"
@@ -21,27 +13,28 @@ export class NormalizedRequestMapper {
21
13
  }
22
14
 
23
15
  ensureTextBlocks(blocks: ContentBlock[], field: string): ContentBlock[] {
24
- return this.ensureBlocks(blocks, field, this.profile.supportedBlockTypes, "only text/json blocks are supported");
16
+ return this.ensureBlocks(blocks, field, ["text", "json"], "only text/json blocks are supported");
25
17
  }
26
18
 
27
19
  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" }>>;
20
+ return this.ensureBlocks(blocks, field, ["text"], "reasoning only supports text blocks") as Array<
21
+ Extract<ContentBlock, { type: "text" }>
22
+ >;
34
23
  }
35
24
 
36
- assertToolResultOutcome(outcome: ToolResultItem["outcome"]): void {
37
- if (this.profile.capabilities.toolResultOutcomes.includes(outcome)) return;
25
+ parseToolArguments(item: ToolCallItem): Record<string, unknown> {
26
+ try {
27
+ const parsed: unknown = JSON.parse(item.argumentsText);
28
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
29
+ return parsed as Record<string, unknown>;
30
+ }
31
+ } catch {
32
+ // handled below
33
+ }
38
34
 
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
35
  throw new AIRequestError(
43
- `${this.profile.kind} does not preserve tool_result outcome "${outcome}"; only ${supported} ${verb} supported`,
44
- "UNSUPPORTED_TOOL_RESULT_OUTCOME",
36
+ `${this.kind} requires tool_call argumentsText to be a valid JSON object`,
37
+ "TOOL_CALL_ARGUMENTS_INVALID",
45
38
  );
46
39
  }
47
40
 
@@ -61,7 +54,7 @@ export class NormalizedRequestMapper {
61
54
  const block = blocks[i];
62
55
  if (block && !supportedTypes.includes(block.type)) {
63
56
  throw new AIRequestError(
64
- `${this.profile.kind} does not support ${field}[${i}] of type "${block.type}"; ${description}`,
57
+ `${this.kind} does not support ${field}[${i}] of type "${block.type}"; ${description}`,
65
58
  "UNSUPPORTED_CONTENT_BLOCK",
66
59
  );
67
60
  }
@@ -21,20 +21,9 @@ 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
-
35
24
  export interface BackendAdapter {
36
25
  readonly kind: "chat-completions" | "messages" | "responses" | "ollama" | "mock";
37
- readonly capabilities: AdapterCapabilities;
26
+ readonly isSyntheticStream: boolean;
38
27
  stream(request: NormalizedRequest): AsyncIterable<AIStreamEvent>;
39
28
  }
40
29
 
@@ -46,12 +46,4 @@ export type {
46
46
  } from "./events.js";
47
47
 
48
48
  // Adapter 协议和 client 类型
49
- export type {
50
- AdapterCapabilities,
51
- StreamingCapability,
52
- BackendAdapter,
53
- FetchFn,
54
- NormalizedRequest,
55
- CreateAIClientOptions,
56
- AIClient,
57
- } from "./adapter.js";
49
+ export type { BackendAdapter, FetchFn, NormalizedRequest, CreateAIClientOptions, AIClient } from "./adapter.js";