@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/README.md +34 -38
- package/dist/index.d.mts +18 -40
- package/dist/index.mjs +88 -139
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/adapters/chat-completions.ts +4 -22
- package/src/adapters/messages.ts +10 -28
- package/src/adapters/mock.ts +9 -12
- package/src/adapters/ollama.ts +26 -53
- package/src/adapters/responses.ts +17 -35
- package/src/core/client.ts +15 -2
- package/src/core/validation.ts +19 -0
- package/src/helpers/adapter-base.ts +6 -3
- package/src/helpers/index.ts +0 -1
- package/src/helpers/mapping.ts +1 -2
- package/src/helpers/request-mapper.ts +18 -25
- package/src/index.ts +1 -1
- package/src/types/adapter.ts +3 -12
- package/src/types/index.ts +1 -9
- package/src/types/items.ts +0 -1
- package/src/types/request.ts +2 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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");
|
package/src/adapters/messages.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
610
|
+
input: parseProviderToolUseInput(currentArgsText || argsBuffer),
|
|
629
611
|
});
|
|
630
612
|
}
|
|
631
613
|
|
package/src/adapters/mock.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/adapters/ollama.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
306
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
103
|
-
| { type: "response.
|
|
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
|
|
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
|
-
|
|
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.
|
|
448
|
-
const data = sseEvent.data as { item_id: string; delta:
|
|
449
|
-
if (data.delta
|
|
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.
|
|
456
|
-
const data = sseEvent.data as { item_id: string; arguments
|
|
457
|
-
const tcItem = toolCallItem(data.item_id, data.
|
|
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;
|
package/src/core/client.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/src/core/validation.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
154
|
+
isSyntheticStream: this.isSyntheticStream,
|
|
152
155
|
metadataSources: result.metadataSources,
|
|
153
156
|
warnings,
|
|
154
157
|
},
|
package/src/helpers/index.ts
CHANGED
|
@@ -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";
|
package/src/helpers/mapping.ts
CHANGED
|
@@ -100,13 +100,12 @@ export function reasoningItem(
|
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
export function toolCallItem(id: string, name: string, argumentsText: string
|
|
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
|
|