@codehz/ai 0.4.3 → 0.4.5

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.
@@ -27,6 +27,9 @@ import {
27
27
  openProviderJsonStream,
28
28
  iterateProviderStreamBatches,
29
29
  createCompletionGate,
30
+ mergeProviderHeaders,
31
+ applyExtraBody,
32
+ mapResponsesReasoning,
30
33
  } from "../helpers/index.js";
31
34
 
32
35
  import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
@@ -38,9 +41,17 @@ export type ResponsesAdapterOptions = {
38
41
  baseUrl?: string;
39
42
  /** 可注入自定义 fetch 实现(用于测试/代理) */
40
43
  fetch?: FetchFn;
44
+ /** 额外请求头;后写覆盖内置 Authorization / Content-Type */
45
+ headers?: Record<string, string>;
46
+ /** 额外 body 顶层字段;浅层合并,同名键可覆盖 */
47
+ extraBody?: Record<string, unknown>;
41
48
  };
42
49
 
43
- // ── Responses API 请求类型 ────────────────────────────────────
50
+ // ── Responses API 请求类型(对齐 OpenAI Responses schema)────
51
+ //
52
+ // input 是 untagged enum ModelInput = string | InputItem[]。
53
+ // 每个 InputItem 也必须命中官方 variant,否则会 422:
54
+ // "data did not match any variant of untagged enum ModelInput"
44
55
 
45
56
  type ResponsesAPIRequest = {
46
57
  model: string;
@@ -51,27 +62,72 @@ type ResponsesAPIRequest = {
51
62
  metadata?: Record<string, string>;
52
63
  temperature?: number;
53
64
  max_output_tokens?: number;
65
+ /** Portable reasoningLevel → effort;summary 等特化字段不在此层 */
66
+ reasoning?: { effort: string };
67
+ /** 服务端多轮续写;opaque replay 的 response id 映射到此字段,而非 item_reference */
68
+ previous_response_id?: string;
54
69
  stream: true;
55
70
  };
56
71
 
72
+ /** EasyInputMessage:content 可为 string,或 input_* content parts */
73
+ type ResponsesEasyMessage = {
74
+ type: "message";
75
+ role: "user" | "assistant" | "system" | "developer";
76
+ content: string | ResponsesInputContentPart[];
77
+ };
78
+
79
+ type ResponsesInputContentPart =
80
+ | { type: "input_text"; text: string }
81
+ | { type: "input_image"; image_url: string; detail?: "auto" | "low" | "high" }
82
+ | { type: "input_file"; file_url?: string; file_id?: string; filename?: string };
83
+
84
+ /** function_call:call_id 必填;id 是可选的 item id */
85
+ type ResponsesFunctionCall = {
86
+ type: "function_call";
87
+ call_id: string;
88
+ name: string;
89
+ arguments: string;
90
+ id?: string;
91
+ status?: "in_progress" | "completed" | "incomplete";
92
+ };
93
+
94
+ type ResponsesFunctionCallOutput = {
95
+ type: "function_call_output";
96
+ call_id: string;
97
+ output: string;
98
+ id?: string;
99
+ status?: "in_progress" | "completed" | "incomplete";
100
+ };
101
+
102
+ /** reasoning:id + summary/content/encrypted_content,不是任意 content blocks */
103
+ type ResponsesReasoningInput = {
104
+ type: "reasoning";
105
+ id: string;
106
+ summary: Array<{ type: "summary_text"; text: string }>;
107
+ content?: Array<{ type: "reasoning_text"; text: string }>;
108
+ encrypted_content?: string | null;
109
+ status?: "in_progress" | "completed" | "incomplete";
110
+ };
111
+
112
+ /** 引用既有 item(不是 response id) */
113
+ type ResponsesItemReference = {
114
+ type: "item_reference";
115
+ id: string;
116
+ };
117
+
57
118
  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 };
119
+ | ResponsesEasyMessage
120
+ | ResponsesFunctionCall
121
+ | ResponsesFunctionCallOutput
122
+ | ResponsesReasoningInput
123
+ | ResponsesItemReference;
69
124
 
70
125
  type ResponsesTool = {
71
126
  type: "function";
72
127
  name: string;
73
128
  description?: string;
74
129
  parameters: Record<string, unknown>;
130
+ strict?: boolean | null;
75
131
  };
76
132
 
77
133
  const mapper = new NormalizedRequestMapper("responses");
@@ -209,11 +265,12 @@ type ResponsesAPIOutputItem = {
209
265
  id: string;
210
266
  type: "message" | "reasoning" | "function_call" | string;
211
267
  role?: string;
212
- content?: ResponsesContentBlock[] | Array<{ type: string; text?: string; [key: string]: unknown }>;
268
+ content?: Array<{ type: string; text?: string; [key: string]: unknown }>;
213
269
  summary?: Array<{ type: string; text?: string; [key: string]: unknown }>;
214
270
  encrypted_content?: string | null;
215
271
  name?: string;
216
272
  arguments?: string;
273
+ call_id?: string;
217
274
  status?: string;
218
275
  [key: string]: unknown;
219
276
  };
@@ -232,15 +289,56 @@ function extractFailureMessage(response: ResponsesAPIResponse): string {
232
289
  return response.error?.message ?? response.failure?.message ?? "unknown";
233
290
  }
234
291
 
235
- // ── Content block 映射 ─────────────────────────────────────────
292
+ function readNonEmptyString(value: unknown, maxLen = 256): string | undefined {
293
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLen) return undefined;
294
+ return value;
295
+ }
296
+
297
+ /** 将 canonical text/json blocks 压成 EasyInputMessage 的 string content。 */
298
+ function messageContentAsString(
299
+ blocks: import("../index.js").ContentBlock[],
300
+ field: string,
301
+ ): string {
302
+ return mapper.textFromBlocks(blocks, field);
303
+ }
236
304
 
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",
305
+ function mapReasoningInput(item: import("../index.js").ReasoningItem, index: number): ResponsesReasoningInput {
306
+ const text = mapper.textFromBlocks(
307
+ mapper.ensureReasoningBlocks(item.content, "reasoning content"),
308
+ "reasoning content",
243
309
  );
310
+ const id = item.id && item.id.length > 0 ? item.id : `reasoning_replay_${index}`;
311
+
312
+ if (item.visibility === "full") {
313
+ return {
314
+ type: "reasoning",
315
+ id,
316
+ summary: [],
317
+ content: text ? [{ type: "reasoning_text", text }] : undefined,
318
+ };
319
+ }
320
+
321
+ // summary / redacted / opaque:公开可回传的是 summary_text
322
+ return {
323
+ type: "reasoning",
324
+ id,
325
+ summary: text ? [{ type: "summary_text", text }] : [],
326
+ };
327
+ }
328
+
329
+ function extractOpaqueContinuationId(payload: Record<string, unknown>): {
330
+ previousResponseId?: string;
331
+ itemReferenceId?: string;
332
+ } {
333
+ // 优先显式 previous_response_id;历史 payload 用 id 存 response 续写句柄
334
+ const previousResponseId =
335
+ readNonEmptyString(payload.previous_response_id) ??
336
+ (typeof payload.item_id === "string" ? undefined : readNonEmptyString(payload.id));
337
+
338
+ // 仅在显式给出 item_id 时使用 item_reference(引用的是 item,不是 response)
339
+ const itemReferenceId = readNonEmptyString(payload.item_id);
340
+
341
+ return { previousResponseId, itemReferenceId };
244
342
  }
245
343
 
246
344
  // ── Adapter ───────────────────────────────────────────────────
@@ -252,48 +350,46 @@ export class ResponsesAdapter extends AdapterBase {
252
350
  private apiKey: string;
253
351
  private baseUrl: string;
254
352
  private fetchFn: FetchFn;
353
+ private headers: Record<string, string> | undefined;
354
+ private extraBody: Record<string, unknown> | undefined;
255
355
 
256
356
  constructor(options: ResponsesAdapterOptions) {
257
357
  super();
258
358
  this.apiKey = options.apiKey;
259
359
  this.baseUrl = options.baseUrl ?? "https://api.openai.com/v1";
260
360
  this.fetchFn = options.fetch ?? globalThis.fetch;
361
+ this.headers = options.headers;
362
+ this.extraBody = options.extraBody;
261
363
  }
262
364
 
263
365
  // ── buildRequest ──────────────────────────────────────────
264
366
 
265
367
  protected buildRequest(request: NormalizedRequest): ResponsesAPIRequest {
266
368
  const input: ResponsesInputItem[] = [];
369
+ let previousResponseId: string | undefined;
370
+ let reasoningIndex = 0;
267
371
 
268
372
  for (const item of request.input) {
269
373
  switch (item.type) {
270
374
  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
- }
375
+ // EasyInputMessage:string content user/assistant 都合法,且最不易触发 ModelInput 反序列化失败。
376
+ // 切勿发送 { type: "text" } —— 官方 content part 是 input_text / output_text。
377
+ input.push({
378
+ type: "message",
379
+ role: item.role,
380
+ content: messageContentAsString(item.content, `input message (${item.role}) content`),
381
+ });
284
382
  break;
285
383
  }
286
384
  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 });
385
+ input.push(mapReasoningInput(item, reasoningIndex++));
291
386
  break;
292
387
  }
293
388
  case "tool_call": {
389
+ // call_id 必填;canonical ToolCallItem.id 即 call_id(流里会优先取 call_id)
294
390
  input.push({
295
391
  type: "function_call",
296
- id: item.id,
392
+ call_id: item.id,
297
393
  name: item.name,
298
394
  arguments: item.argumentsText,
299
395
  });
@@ -309,20 +405,28 @@ export class ResponsesAdapter extends AdapterBase {
309
405
  break;
310
406
  }
311
407
  case "opaque": {
312
- // Canonical replay items take priority; item_reference is only a fallback
313
- // when the consumer kept only the provider continuation id.
408
+ // Canonical replay 优先;否则用 previous_response_id 做服务端续写。
409
+ // 注意:response id 不能塞进 item_reference(那是 item id)。
314
410
  if (item.source !== "responses" || item.purpose !== "replay") break;
315
411
  assertOpaqueReplayEnvelope(item.payload);
316
412
  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) {
413
+
414
+ // 显式字段校验:id / previous_response_id / item_id 若存在必须是合法 string
415
+ for (const key of ["id", "previous_response_id", "item_id"] as const) {
416
+ if (key in payload && (typeof payload[key] !== "string" || payload[key].length === 0 || payload[key].length > 256)) {
319
417
  throw new AIRequestError(
320
- "Invalid opaque replay payload: id must be a non-empty string (max 256)",
418
+ `Invalid opaque replay payload: ${key} must be a non-empty string (max 256)`,
321
419
  "INVALID_OPAQUE_REPLAY",
322
420
  );
323
421
  }
324
- if (!hasReplayCanonicalInput(input)) {
325
- input.push({ type: "item_reference", id: payload.id });
422
+ }
423
+
424
+ const { previousResponseId: prevId, itemReferenceId } = extractOpaqueContinuationId(payload);
425
+ if (!hasReplayCanonicalInput(input)) {
426
+ if (prevId && !previousResponseId) {
427
+ previousResponseId = prevId;
428
+ } else if (itemReferenceId) {
429
+ input.push({ type: "item_reference", id: itemReferenceId });
326
430
  }
327
431
  }
328
432
  break;
@@ -336,6 +440,10 @@ export class ResponsesAdapter extends AdapterBase {
336
440
  stream: true,
337
441
  };
338
442
 
443
+ if (previousResponseId) {
444
+ body.previous_response_id = previousResponseId;
445
+ }
446
+
339
447
  if (request.instructions) {
340
448
  body.instructions = mapper.mapInstructions(request.instructions);
341
449
  }
@@ -362,8 +470,11 @@ export class ResponsesAdapter extends AdapterBase {
362
470
  if (request.temperature !== undefined) body.temperature = request.temperature;
363
471
  if (request.maxOutputTokens !== undefined) body.max_output_tokens = request.maxOutputTokens;
364
472
  if (request.metadata) body.metadata = request.metadata;
473
+ if (request.reasoningLevel !== undefined) {
474
+ body.reasoning = mapResponsesReasoning(request.reasoningLevel);
475
+ }
365
476
 
366
- return body;
477
+ return applyExtraBody(body, this.extraBody);
367
478
  }
368
479
 
369
480
  // ── runStream ─────────────────────────────────────────────
@@ -379,10 +490,13 @@ export class ResponsesAdapter extends AdapterBase {
379
490
  const { reader } = await openProviderJsonStream({
380
491
  fetchFn: this.fetchFn,
381
492
  url: `${this.baseUrl}/responses`,
382
- headers: {
383
- "Content-Type": "application/json",
384
- Authorization: `Bearer ${this.apiKey}`,
385
- },
493
+ headers: mergeProviderHeaders(
494
+ {
495
+ "Content-Type": "application/json",
496
+ Authorization: `Bearer ${this.apiKey}`,
497
+ },
498
+ this.headers,
499
+ ),
386
500
  body: providerRequest,
387
501
  signal: request.signal,
388
502
  });
@@ -392,9 +506,14 @@ export class ResponsesAdapter extends AdapterBase {
392
506
  let completedResponse: ResponsesAPIResponse | undefined;
393
507
  let unknownEventsWarned = false;
394
508
  const messageItemsWithDelta = new Set<string>();
509
+ /** item_id → function name */
395
510
  const toolCallNames = new Map<string, string>();
511
+ /** item_id → call_id(canonical ToolCallItem.id / function_call_output.call_id) */
512
+ const toolCallIds = new Map<string, string>();
396
513
  const reasoningStates = new Map<string, ReasoningStreamState>();
397
514
 
515
+ const resolveToolCallId = (itemId: string): string => toolCallIds.get(itemId) ?? itemId;
516
+
398
517
  const ensureReasoningState = (itemId: string, visibility: ReasoningVisibility = "summary"): ReasoningStreamState => {
399
518
  let state = reasoningStates.get(itemId);
400
519
  if (!state) {
@@ -449,8 +568,12 @@ export class ResponsesAdapter extends AdapterBase {
449
568
  }
450
569
  case "function_call": {
451
570
  const name = typeof item.name === "string" ? item.name : "unknown";
571
+ // Responses 用 call_id 关联 function_call_output;item.id 是 fc_* item id
572
+ const callId =
573
+ typeof item.call_id === "string" && item.call_id.length > 0 ? item.call_id : item.id;
452
574
  toolCallNames.set(item.id, name);
453
- yield factory.toolCallStarted(item.id, name);
575
+ toolCallIds.set(item.id, callId);
576
+ yield factory.toolCallStarted(callId, name);
454
577
  break;
455
578
  }
456
579
  }
@@ -569,14 +692,19 @@ export class ResponsesAdapter extends AdapterBase {
569
692
 
570
693
  if (sseEvent.type === "response.function_call_arguments.delta") {
571
694
  const data = sseEvent.data as { item_id: string; delta: string };
572
- if (data.delta) yield factory.toolCallDelta(data.item_id, { argumentsText: data.delta });
695
+ if (data.delta) {
696
+ yield factory.toolCallDelta(resolveToolCallId(data.item_id), { argumentsText: data.delta });
697
+ }
573
698
  continue;
574
699
  }
575
700
 
576
701
  if (sseEvent.type === "response.function_call_arguments.done") {
577
702
  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);
703
+ const callId = resolveToolCallId(data.item_id);
704
+ // 若 added 事件缺失,done 时仍尽量从 completed payload 之外兜底 call_id
705
+ if (!toolCallIds.has(data.item_id)) toolCallIds.set(data.item_id, callId);
706
+ const tcItem = toolCallItem(callId, toolCallNames.get(data.item_id) ?? "unknown", data.arguments);
707
+ yield factory.toolCallCompleted(callId);
580
708
  output.push(tcItem);
581
709
  continue;
582
710
  }
@@ -640,7 +768,13 @@ export class ResponsesAdapter extends AdapterBase {
640
768
 
641
769
  const replay = [...replayFromOutput(output)];
642
770
  if (completedResponse?.id) {
643
- replay.push(opaqueItem("responses", "replay", { id: completedResponse.id }));
771
+ // 同时保留 id(向后兼容)与 previous_response_id(语义明确)
772
+ replay.push(
773
+ opaqueItem("responses", "replay", {
774
+ id: completedResponse.id,
775
+ previous_response_id: completedResponse.id,
776
+ }),
777
+ );
644
778
  }
645
779
 
646
780
  const stopReason = completedResponse ? this.inferStopReason(completedResponse) : undefined;
@@ -18,6 +18,7 @@ const MESSAGE_ROLES = new Set(["user", "assistant"]);
18
18
  const REASONING_VISIBILITIES = new Set(["full", "summary", "redacted", "opaque"]);
19
19
  const TOOL_RESULT_OUTCOMES = new Set(["success", "error", "rejected"]);
20
20
  const INCLUDE_MODES = new Set(["off", "best_effort"]);
21
+ const REASONING_LEVELS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]);
21
22
 
22
23
  function isRecord(value: unknown): value is Record<string, unknown> {
23
24
  return typeof value === "object" && value !== null;
@@ -333,6 +334,18 @@ export function validateRequest(request: AIRequest): ValidationIssue[] {
333
334
  }
334
335
  }
335
336
 
337
+ // reasoningLevel 枚举
338
+ if (request.reasoningLevel !== undefined) {
339
+ if (typeof request.reasoningLevel !== "string" || !REASONING_LEVELS.has(request.reasoningLevel)) {
340
+ pushIssue(
341
+ issues,
342
+ "reasoningLevel",
343
+ "REASONING_LEVEL_INVALID",
344
+ 'reasoningLevel must be one of: none, minimal, low, medium, high, xhigh',
345
+ );
346
+ }
347
+ }
348
+
336
349
  if (request.include !== undefined) {
337
350
  validateInclude(request.include, issues);
338
351
  }
@@ -70,4 +70,18 @@ export type {
70
70
  ProviderStreamBatchOptions,
71
71
  } from "./provider-stream.js";
72
72
 
73
+ export { mergeProviderHeaders, applyExtraBody } from "./provider-request-options.js";
74
+
75
+ export {
76
+ REASONING_LEVELS,
77
+ REASONING_LEVEL_SET,
78
+ assertSupportedReasoningLevel,
79
+ mapResponsesReasoning,
80
+ mapChatCompletionsReasoningEffort,
81
+ mapMessagesThinkingBudget,
82
+ mapMessagesThinking,
83
+ mapOllamaThink,
84
+ } from "./reasoning-level.js";
85
+ export type { OpenAIReasoningEffort, MessagesThinkingConfig, OllamaThinkValue } from "./reasoning-level.js";
86
+
73
87
  export { NormalizedRequestMapper } from "./request-mapper.js";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Provider 请求 headers / body 扩展合并
3
+ *
4
+ * 供真实后端 adapter 构造选项 `headers` / `extraBody` 使用:
5
+ * - headers:内置鉴权头为基,自定义后写覆盖
6
+ * - extraBody:已构建 body 为基,额外字段浅层 spread,同名顶层键可覆盖
7
+ */
8
+
9
+ /** 合并内置 headers 与自定义 headers;自定义后写覆盖同名键。 */
10
+ export function mergeProviderHeaders(
11
+ base: Record<string, string>,
12
+ custom?: Record<string, string>,
13
+ ): Record<string, string> {
14
+ if (!custom) return base;
15
+ return { ...base, ...custom };
16
+ }
17
+
18
+ /**
19
+ * 将构造期 extraBody 浅层合并到已构建的 provider body。
20
+ * 无 extraBody 时原样返回;有则允许覆盖同名顶层键。
21
+ */
22
+ export function applyExtraBody<T extends object>(body: T, extraBody?: Record<string, unknown>): T {
23
+ if (!extraBody) return body;
24
+ return { ...body, ...extraBody };
25
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Portable reasoningLevel → provider wire 字段映射
3
+ *
4
+ * 第一版只处理 level 枚举;budget/summary 等特化字段不在此层。
5
+ * 无法映射的 level 抛 AIRequestError(UNSUPPORTED_REASONING_LEVEL)。
6
+ */
7
+
8
+ import { AIRequestError } from "../core/errors.js";
9
+ import type { ReasoningLevel } from "../types/request.js";
10
+
11
+ export const REASONING_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly ReasoningLevel[];
12
+
13
+ export const REASONING_LEVEL_SET: ReadonlySet<string> = new Set(REASONING_LEVELS);
14
+
15
+ const MESSAGES_BUDGET_RATIOS: Record<Exclude<ReasoningLevel, "none">, number> = {
16
+ minimal: 0.02,
17
+ low: 0.1,
18
+ medium: 0.3,
19
+ high: 0.6,
20
+ xhigh: 0.9,
21
+ };
22
+
23
+ const OLLAMA_SUPPORTED = new Set<ReasoningLevel>(["none", "low", "medium", "high"]);
24
+
25
+ export type OpenAIReasoningEffort = ReasoningLevel;
26
+
27
+ export type MessagesThinkingConfig =
28
+ | { type: "disabled" }
29
+ | { type: "enabled"; budget_tokens: number };
30
+
31
+ export type OllamaThinkValue = false | "low" | "medium" | "high";
32
+
33
+ /** 若 level 不在 supported 集合内则抛 AIRequestError。 */
34
+ export function assertSupportedReasoningLevel(
35
+ level: ReasoningLevel,
36
+ supported: ReadonlySet<ReasoningLevel>,
37
+ adapterKind: string,
38
+ ): void {
39
+ if (supported.has(level)) return;
40
+ throw new AIRequestError(
41
+ `reasoningLevel "${level}" is not supported by the ${adapterKind} adapter`,
42
+ "UNSUPPORTED_REASONING_LEVEL",
43
+ );
44
+ }
45
+
46
+ /** Responses API:`reasoning: { effort }` */
47
+ export function mapResponsesReasoning(level: ReasoningLevel): { effort: OpenAIReasoningEffort } {
48
+ return { effort: level };
49
+ }
50
+
51
+ /** Chat Completions:顶层 `reasoning_effort` */
52
+ export function mapChatCompletionsReasoningEffort(level: ReasoningLevel): OpenAIReasoningEffort {
53
+ return level;
54
+ }
55
+
56
+ /**
57
+ * Messages thinking budget。
58
+ * 基于 maxTokens 按比例推导,clamp 到 [1024, max(1024, maxTokens - 1)],
59
+ * 满足 Anthropic budget_tokens < max_tokens。
60
+ */
61
+ export function mapMessagesThinkingBudget(level: Exclude<ReasoningLevel, "none">, maxTokens: number): number {
62
+ const ratio = MESSAGES_BUDGET_RATIOS[level];
63
+ const raw = Math.round(maxTokens * ratio);
64
+ const upper = Math.max(1024, maxTokens - 1);
65
+ return Math.min(Math.max(raw, 1024), upper);
66
+ }
67
+
68
+ /** Messages API:`thinking` 字段 */
69
+ export function mapMessagesThinking(level: ReasoningLevel, maxTokens: number): MessagesThinkingConfig {
70
+ if (level === "none") {
71
+ return { type: "disabled" };
72
+ }
73
+ return {
74
+ type: "enabled",
75
+ budget_tokens: mapMessagesThinkingBudget(level, maxTokens),
76
+ };
77
+ }
78
+
79
+ /** Ollama:`think` 字段;minimal/xhigh 不支持 */
80
+ export function mapOllamaThink(level: ReasoningLevel): OllamaThinkValue {
81
+ assertSupportedReasoningLevel(level, OLLAMA_SUPPORTED, "ollama");
82
+ if (level === "none") return false;
83
+ // narrow after assert: only low|medium|high remain
84
+ return level as Exclude<OllamaThinkValue, false>;
85
+ }
@@ -21,7 +21,7 @@ export type {
21
21
  } from "./items.js";
22
22
 
23
23
  // 请求模型
24
- export type { AIRequest, ToolDefinition, ToolChoice, IncludeSettings } from "./request.js";
24
+ export type { AIRequest, ToolDefinition, ToolChoice, IncludeSettings, ReasoningLevel } from "./request.js";
25
25
 
26
26
  // 响应模型
27
27
  export type { AIResponse, StopReason, Usage, BillingInfo, AuxiliaryInfo, BackendTrace } from "./response.js";
@@ -25,6 +25,11 @@ export type IncludeSettings = {
25
25
  providerMetadata?: "off" | "best_effort";
26
26
  };
27
27
 
28
+ // ── reasoning level ───────────────────────────────────────────
29
+
30
+ /** Portable reasoning / thinking effort. Mapped per-adapter to provider wire fields. */
31
+ export type ReasoningLevel = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
32
+
28
33
  // ── 统一请求 ──────────────────────────────────────────────────
29
34
 
30
35
  export type AIRequest = {
@@ -36,6 +41,12 @@ export type AIRequest = {
36
41
  metadata?: Record<string, string>;
37
42
  temperature?: number;
38
43
  maxOutputTokens?: number;
44
+ /**
45
+ * Portable reasoning effort. Adapters map this to provider-native fields
46
+ * (e.g. Responses `reasoning.effort`, Chat Completions `reasoning_effort`,
47
+ * Messages `thinking`, Ollama `think`). Unsupported levels throw.
48
+ */
49
+ reasoningLevel?: ReasoningLevel;
39
50
  /** AbortSignal 用于打断请求。abort 时 fetch 调用会被取消,流迭代器抛出 AbortError。 */
40
51
  signal?: AbortSignal;
41
52
  };