pi-deepseek-web-search 0.1.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/src/config.ts ADDED
@@ -0,0 +1,289 @@
1
+ import type { ConfigFileShape } from "./config-schema.js";
2
+ import type { PriceConfig, ReasoningEffort } from "./provider.js";
3
+ import { readFile as defaultReadFile } from "node:fs/promises";
4
+ import { homedir as defaultHomedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import process from "node:process";
7
+ // 配置层:读取顺序 环境变量 → 项目 .pi/ 配置文件 → 全局配置文件 → 默认值。
8
+ // 字段级 merge:高优先级来源只覆盖其提供的字段。
9
+ // 文件结构定义与校验的单一来源在 config-schema.ts(TypeBox),此处只做合并与错误包装。
10
+ // 设计为纯函数(注入 env / readFile / 路径),便于单元测试。
11
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
12
+ import { Check, Errors } from "typebox/value";
13
+ import { configFileSchema } from "./config-schema.js";
14
+
15
+ export const CONFIG_FILE_NAME = "deepseek-web-search.json";
16
+ export const GLOBAL_CONFIG_DIR = join(".pi", "agent");
17
+
18
+ export interface SearchConfig {
19
+ /** DeepSeek API key(来自环境变量或配置文件) */
20
+ apiKey: string;
21
+ /** API 基础地址(自建代理时修改) */
22
+ baseUrl: string;
23
+ /** 模型名(Responses API 目前仅 deepseek-v4-flash;v4-pro 适配后改这里即切换) */
24
+ model: string;
25
+ /** 推理强度(off 关闭思考以省 token) */
26
+ reasoningEffort: ReasoningEffort;
27
+ /** 回答最大输出 token 数 */
28
+ maxOutputTokens: number;
29
+ /** 单次请求超时(毫秒) */
30
+ timeoutMs: number;
31
+ /** 搜索结果缓存 TTL(毫秒) */
32
+ cacheTtlMs: number;
33
+ /** 返回给 LLM 的文本最大字符数(超出截断) */
34
+ maxResultChars: number;
35
+ /** 价格配置(元/百万 tokens,默认官方平峰价) */
36
+ prices: PriceConfig;
37
+ }
38
+
39
+ /** 配置文件结构(所有字段可选,按优先级与默认值合并) */
40
+ export type { ConfigFileShape } from "./config-schema.js";
41
+
42
+ /** 除 apiKey 外的默认值 */
43
+ export const DEFAULT_CONFIG: Omit<SearchConfig, "apiKey"> = {
44
+ baseUrl: "https://api.deepseek.com",
45
+ model: "deepseek-v4-flash",
46
+ reasoningEffort: "low",
47
+ maxOutputTokens: 4096,
48
+ timeoutMs: 30_000,
49
+ cacheTtlMs: 300_000,
50
+ maxResultChars: 50_000,
51
+ prices: {
52
+ inputPerMillion: 1,
53
+ cachedInputPerMillion: 0.02,
54
+ outputPerMillion: 2,
55
+ // v4-pro 官方价预置:Responses API 适配后切 model 即用
56
+ models: {
57
+ "deepseek-v4-pro": {
58
+ inputPerMillion: 3,
59
+ cachedInputPerMillion: 0.025,
60
+ outputPerMillion: 6,
61
+ },
62
+ },
63
+ },
64
+ };
65
+
66
+ /** 配置缺失/非法时抛出的错误(消息为英文 UI 文本) */
67
+ export class ConfigError extends Error {
68
+ constructor(message: string) {
69
+ super(message);
70
+ this.name = "ConfigError";
71
+ }
72
+ }
73
+
74
+ export interface LoadConfigOptions {
75
+ /** 项目工作目录(用于定位 .pi/deepseek-web-search.json),缺省时跳过项目配置 */
76
+ cwd?: string;
77
+ /** 环境变量表,默认 process.env */
78
+ env?: NodeJS.ProcessEnv;
79
+ /** 文件读取函数(可注入 mock),默认 node:fs/promises readFile(utf8) */
80
+ readFile?: (path: string) => Promise<string>;
81
+ /** 用户主目录,默认 os.homedir() */
82
+ homeDir?: string;
83
+ }
84
+
85
+ const REASONING_EFFORTS: readonly ReasoningEffort[] = ["off", "low", "high", "auto"];
86
+
87
+ /** 数字字段的环境变量名 → 配置字段名映射 */
88
+ const NUMBER_FIELD_ENV_NAMES: Record<string, keyof Pick<SearchConfig, "maxOutputTokens" | "timeoutMs" | "cacheTtlMs" | "maxResultChars">> = {
89
+ DEEPSEEK_MAX_OUTPUT_TOKENS: "maxOutputTokens",
90
+ DEEPSEEK_TIMEOUT_MS: "timeoutMs",
91
+ DEEPSEEK_CACHE_TTL_MS: "cacheTtlMs",
92
+ DEEPSEEK_MAX_RESULT_CHARS: "maxResultChars",
93
+ };
94
+
95
+ /** 校验 schema 表达力之外的字段间约束(JSON Schema 无法表达的例外):peak.hours 的 start < end、models 空 key */
96
+ function validateBeyondSchema(body: Record<string, unknown>, source: string): void {
97
+ const prices = body.prices;
98
+ if (prices == null || typeof prices !== "object")
99
+ return;
100
+ const record = prices as Record<string, unknown>;
101
+ const peak = record.peak;
102
+ if (peak != null && typeof peak === "object") {
103
+ const hours = (peak as Record<string, unknown>).hours;
104
+ if (Array.isArray(hours)) {
105
+ for (const item of hours) {
106
+ if (!Array.isArray(item) || item.length !== 2)
107
+ continue;
108
+ const [start, end] = item as [string, string];
109
+ if (start >= end) {
110
+ throw new ConfigError(`Invalid prices.peak.hours in ${source}: start must be before end`);
111
+ }
112
+ }
113
+ }
114
+ }
115
+ const models = record.models;
116
+ if (models != null && typeof models === "object" && !Array.isArray(models)) {
117
+ for (const key of Object.keys(models)) {
118
+ if (key.trim() === "") {
119
+ throw new ConfigError(`Invalid prices.models in ${source}: empty model name`);
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ /** 把 TypeBox 校验错误格式化为可读消息:instancePath 的 /a/b 转 a.b */
126
+ export function formatSchemaError(errors: Array<{ instancePath: string; message?: string }>): string {
127
+ const first = errors[0];
128
+ if (first == null)
129
+ return "Invalid config";
130
+ const path = first.instancePath.split("/").filter(Boolean).join(".") || "/";
131
+ return `Invalid ${path}: ${first.message ?? "invalid value"}`;
132
+ }
133
+
134
+ /** 从 JSON 字符串解析配置文件:语法检查 + TypeBox 校验($schema 仅供编辑器关联,运行时剥离) */
135
+ export function parseConfigFile(raw: string, source: string): ConfigFileShape {
136
+ let json: unknown;
137
+ try {
138
+ json = JSON.parse(raw);
139
+ } catch {
140
+ throw new ConfigError(`Invalid JSON in ${source}`);
141
+ }
142
+ if (json == null || typeof json !== "object" || Array.isArray(json)) {
143
+ throw new ConfigError(`Invalid config file ${source}: expected a JSON object`);
144
+ }
145
+ const record = json as Record<string, unknown>;
146
+ const { $schema: _ignored, ...body } = record;
147
+ if (!Check(configFileSchema, body)) {
148
+ throw new ConfigError(formatSchemaError(Errors(configFileSchema, body)));
149
+ }
150
+ validateBeyondSchema(body, source);
151
+ return body;
152
+ }
153
+
154
+ /** 尝试读取配置文件,文件不存在时返回 undefined(其他读取错误抛出) */
155
+ async function tryReadConfigFile(
156
+ path: string,
157
+ readFile: (path: string) => Promise<string>,
158
+ ): Promise<ConfigFileShape | undefined> {
159
+ let raw: string;
160
+ try {
161
+ raw = await readFile(path);
162
+ } catch (error) {
163
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
164
+ return undefined;
165
+ throw error;
166
+ }
167
+ return parseConfigFile(raw, path);
168
+ }
169
+
170
+ /** 价格类环境变量名 → PriceConfig 字段名映射 */
171
+ const PRICE_ENV_NAMES: Record<string, keyof Pick<PriceConfig, "inputPerMillion" | "cachedInputPerMillion" | "outputPerMillion">> = {
172
+ DEEPSEEK_PRICE_INPUT: "inputPerMillion",
173
+ DEEPSEEK_PRICE_CACHED_INPUT: "cachedInputPerMillion",
174
+ DEEPSEEK_PRICE_OUTPUT: "outputPerMillion",
175
+ };
176
+
177
+ /** 从环境变量读取配置(非法值抛错,未设置返回 undefined 字段) */
178
+ function configFromEnv(env: NodeJS.ProcessEnv): ConfigFileShape {
179
+ const shape: ConfigFileShape = {};
180
+ if (env.DEEPSEEK_API_KEY !== undefined)
181
+ shape.apiKey = env.DEEPSEEK_API_KEY;
182
+ if (env.DEEPSEEK_BASE_URL !== undefined)
183
+ shape.baseUrl = env.DEEPSEEK_BASE_URL;
184
+ if (env.DEEPSEEK_MODEL !== undefined) {
185
+ if (env.DEEPSEEK_MODEL.trim() === "") {
186
+ throw new ConfigError("Invalid DEEPSEEK_MODEL: expected a non-empty model name");
187
+ }
188
+ shape.model = env.DEEPSEEK_MODEL;
189
+ }
190
+ if (env.DEEPSEEK_REASONING_EFFORT !== undefined) {
191
+ if (!REASONING_EFFORTS.includes(env.DEEPSEEK_REASONING_EFFORT as ReasoningEffort)) {
192
+ throw new ConfigError(`Invalid DEEPSEEK_REASONING_EFFORT: expected ${REASONING_EFFORTS.join("/")}`);
193
+ }
194
+ shape.reasoningEffort = env.DEEPSEEK_REASONING_EFFORT as ReasoningEffort;
195
+ }
196
+ for (const [envName, field] of Object.entries(NUMBER_FIELD_ENV_NAMES)) {
197
+ const value = env[envName];
198
+ if (value === undefined)
199
+ continue;
200
+ const num = Number.parseInt(value, 10);
201
+ if (!Number.isInteger(num) || num <= 0) {
202
+ throw new ConfigError(`Invalid ${envName}: expected a positive integer`);
203
+ }
204
+ shape[field] = num;
205
+ }
206
+ const envPrices: Partial<PriceConfig> = {};
207
+ for (const [envName, field] of Object.entries(PRICE_ENV_NAMES)) {
208
+ const value = env[envName];
209
+ if (value === undefined)
210
+ continue;
211
+ const num = Number.parseFloat(value);
212
+ if (!Number.isFinite(num) || num <= 0) {
213
+ throw new ConfigError(`Invalid ${envName}: expected a positive number`);
214
+ }
215
+ envPrices[field] = num;
216
+ }
217
+ if (Object.keys(envPrices).length > 0) {
218
+ shape.prices = envPrices;
219
+ }
220
+ return shape;
221
+ }
222
+
223
+ /** 字段级合并(prices 需一层深合并:先从 shape 中解构出 prices,避免 Object.assign 整块覆盖默认/其他来源) */
224
+ function mergeShape(merged: SearchConfig, shape: ConfigFileShape): void {
225
+ const { prices: shapePrices, ...rest } = shape;
226
+ Object.assign(merged, rest);
227
+ if (shapePrices != null) {
228
+ merged.prices = { ...merged.prices, ...shapePrices };
229
+ }
230
+ }
231
+
232
+ /**
233
+ * 合并多来源配置:默认值 < 全局文件 < 项目文件 < 环境变量。
234
+ * 不校验 apiKey(调用方按需调用 validateConfig)。
235
+ */
236
+ export async function loadConfig(options: LoadConfigOptions = {}): Promise<SearchConfig> {
237
+ const env = options.env ?? process.env;
238
+ const readFile = options.readFile ?? (async (path: string) => defaultReadFile(path, "utf8"));
239
+ const homeDir = options.homeDir ?? defaultHomedir();
240
+
241
+ const merged: SearchConfig = { apiKey: "", ...DEFAULT_CONFIG };
242
+
243
+ // 全局配置文件:~/.pi/agent/deepseek-web-search.json
244
+ const globalFile = await tryReadConfigFile(join(homeDir, GLOBAL_CONFIG_DIR, CONFIG_FILE_NAME), readFile);
245
+ if (globalFile != null)
246
+ mergeShape(merged, globalFile);
247
+
248
+ // 项目配置文件:<cwd>/.pi/deepseek-web-search.json
249
+ if (options.cwd != null) {
250
+ const projectFile = await tryReadConfigFile(join(options.cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME), readFile);
251
+ if (projectFile != null)
252
+ mergeShape(merged, projectFile);
253
+ }
254
+
255
+ // 环境变量:字段级最高优先级
256
+ mergeShape(merged, configFromEnv(env));
257
+
258
+ return merged;
259
+ }
260
+
261
+ /** 校验配置是否可用(目前仅要求 apiKey 非空),返回可读错误信息 */
262
+ export function validateConfig(config: SearchConfig): string | undefined {
263
+ if (config.apiKey.trim() === "") {
264
+ return "DeepSeek API key is not configured. Set DEEPSEEK_API_KEY or add apiKey to the config file.";
265
+ }
266
+ return undefined;
267
+ }
268
+
269
+ /** 带进程内缓存的配置提供者(扩展内复用;reload 用于 /deepseek-search 命令刷新) */
270
+ export class ConfigProvider {
271
+ private cached: SearchConfig | undefined;
272
+ private readonly options: LoadConfigOptions;
273
+
274
+ constructor(options: LoadConfigOptions = {}) {
275
+ this.options = options;
276
+ }
277
+
278
+ async get(): Promise<SearchConfig> {
279
+ if (this.cached === undefined) {
280
+ this.cached = await loadConfig(this.options);
281
+ }
282
+ return this.cached;
283
+ }
284
+
285
+ async reload(): Promise<SearchConfig> {
286
+ this.cached = await loadConfig(this.options);
287
+ return this.cached;
288
+ }
289
+ }
@@ -0,0 +1,272 @@
1
+ import type { ReasoningEffort, SearchErrorCode, SearchOptions, SearchProvider, SearchResult, SearchUsage, WebSearchAction } from "./provider.js";
2
+ import { SearchError } from "./provider.js";
3
+ // deepseek.ts —— DeepSeek /responses API 适配器:实现 SearchProvider 端口。
4
+ // 唯一知道 DeepSeek 协议/HTTP 细节的文件;领域类型与错误定义见 provider.ts。
5
+ // 实测要点(2026-08-01,见 docs/api/Search.md):
6
+ // - web_search_call.action.queries 是数组且混入 "ws_call_id=..." 内部参数,需过滤
7
+ // - open_page 的 url 带 "#ws_call_id=..." 后缀,需剥离
8
+ // - message 有 phase 字段:只取 "final_answer",跳过 "commentary"(思考外显文本)
9
+
10
+ export interface DeepSeekClientOptions {
11
+ /** 默认 https://api.deepseek.com */
12
+ baseUrl?: string;
13
+ apiKey: string;
14
+ /** 注入 fetch 实现以便测试(默认全局 fetch) */
15
+ fetchFn?: typeof fetch;
16
+ /** 默认模型,仅 deepseek-v4-flash 支持 /responses */
17
+ model?: string;
18
+ }
19
+
20
+ const DEFAULT_BASE_URL = "https://api.deepseek.com";
21
+ const DEFAULT_MODEL = "deepseek-v4-flash";
22
+ const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
23
+ const DEFAULT_TIMEOUT_MS = 30_000;
24
+ const MAX_RETRIES = 2;
25
+ const RETRY_BASE_DELAY_MS = 500;
26
+
27
+ /** 过滤查询数组中的 ws_call_id= 噪声条目 */
28
+ function cleanQueries(queries: unknown): string[] | undefined {
29
+ if (!Array.isArray(queries))
30
+ return undefined;
31
+ const cleaned = queries
32
+ .filter((q): q is string => typeof q === "string")
33
+ .filter(q => !q.startsWith("ws_call_id="));
34
+ return cleaned.length > 0 ? cleaned : undefined;
35
+ }
36
+
37
+ /** 剥离 URL 上的 #ws_call_id= 后缀 */
38
+ function cleanUrl(url: unknown): string | undefined {
39
+ if (typeof url !== "string")
40
+ return undefined;
41
+ return url.split("#ws_call_id=")[0] ?? undefined;
42
+ }
43
+
44
+ /** reasoningEffort → 请求体 reasoning 字段(auto 时返回 undefined,不传该字段) */
45
+ export function buildReasoningField(effort?: ReasoningEffort): { effort: string } | undefined {
46
+ switch (effort) {
47
+ case "off":
48
+ return { effort: "none" };
49
+ case "low":
50
+ return { effort: "low" };
51
+ case "high":
52
+ return { effort: "high" };
53
+ case "auto":
54
+ case undefined:
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ /** 解析 web_search_call item(容错:字段缺失不抛错) */
60
+ function parseWebSearchCall(item: { action?: unknown }): WebSearchAction | undefined {
61
+ if (item.action == null || typeof item.action !== "object")
62
+ return undefined;
63
+ const action = item.action as { type?: unknown; queries?: unknown; url?: unknown };
64
+ if (typeof action.type !== "string")
65
+ return undefined;
66
+ if (action.type === "search") {
67
+ return { type: "search", queries: cleanQueries(action.queries) };
68
+ }
69
+ if (action.type === "open_page" || action.type === "find_in_page") {
70
+ return { type: action.type, url: cleanUrl(action.url) };
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ /** 从响应 output 中提取最终回答(只取 phase=final_answer 的 message) */
76
+ function extractFinalAnswer(output: unknown[]): string {
77
+ const parts: string[] = [];
78
+ for (const item of output) {
79
+ if (item == null || typeof item !== "object")
80
+ continue;
81
+ const it = item as { type?: unknown; phase?: unknown; content?: unknown };
82
+ if (it.type !== "message" || it.phase !== "final_answer")
83
+ continue;
84
+ if (!Array.isArray(it.content))
85
+ continue;
86
+ for (const part of it.content) {
87
+ if (part == null || typeof part !== "object")
88
+ continue;
89
+ const p = part as { type?: unknown; text?: unknown };
90
+ if (p.type === "output_text" && typeof p.text === "string") {
91
+ parts.push(p.text);
92
+ }
93
+ }
94
+ }
95
+ return parts.join("");
96
+ }
97
+
98
+ /** 解析 usage(容错:字段缺失时返回 undefined) */
99
+ function parseUsage(usage: unknown): SearchUsage | undefined {
100
+ if (usage == null || typeof usage !== "object")
101
+ return undefined;
102
+ const u = usage as {
103
+ input_tokens?: unknown;
104
+ output_tokens?: unknown;
105
+ total_tokens?: unknown;
106
+ input_tokens_details?: { cached_tokens?: unknown };
107
+ output_tokens_details?: { reasoning_tokens?: unknown };
108
+ };
109
+ if (typeof u.input_tokens !== "number" || typeof u.output_tokens !== "number") {
110
+ return undefined;
111
+ }
112
+ return {
113
+ inputTokens: u.input_tokens,
114
+ outputTokens: u.output_tokens,
115
+ reasoningTokens: typeof u.output_tokens_details?.reasoning_tokens === "number" ? u.output_tokens_details.reasoning_tokens : 0,
116
+ cachedTokens: typeof u.input_tokens_details?.cached_tokens === "number" ? u.input_tokens_details.cached_tokens : 0,
117
+ totalTokens: typeof u.total_tokens === "number" ? u.total_tokens : u.input_tokens + u.output_tokens,
118
+ };
119
+ }
120
+
121
+ /** HTTP 状态码 → 错误码映射 */
122
+ function mapHttpError(status: number): SearchErrorCode {
123
+ switch (status) {
124
+ case 401:
125
+ return "invalid_api_key";
126
+ case 402:
127
+ return "insufficient_balance";
128
+ case 429:
129
+ return "rate_limited";
130
+ case 400:
131
+ case 422:
132
+ return "invalid_request";
133
+ default:
134
+ return status >= 500 ? "server_error" : "unknown";
135
+ }
136
+ }
137
+
138
+ /** 从错误响应 body 提取 message(DeepSeek 格式 {"error": {"message": ...}}) */
139
+ function extractErrorMessage(body: unknown): string | undefined {
140
+ if (body == null || typeof body !== "object")
141
+ return undefined;
142
+ const err = (body as { error?: unknown }).error;
143
+ if (err == null || typeof err !== "object")
144
+ return undefined;
145
+ const msg = (err as { message?: unknown }).message;
146
+ return typeof msg === "string" ? msg : undefined;
147
+ }
148
+
149
+ /** 可取消的延时(用于重试退避) */
150
+ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
151
+ return new Promise((resolve, reject) => {
152
+ const timer = setTimeout(resolve, ms);
153
+ signal?.addEventListener("abort", () => {
154
+ clearTimeout(timer);
155
+ reject(new SearchError("Request aborted", "aborted"));
156
+ }, { once: true });
157
+ });
158
+ }
159
+
160
+ export class DeepSeekClient implements SearchProvider {
161
+ readonly id = "deepseek-responses";
162
+
163
+ private readonly baseUrl: string;
164
+ private readonly apiKey: string;
165
+ private readonly fetchFn: typeof fetch;
166
+ private readonly model: string;
167
+
168
+ constructor(options: DeepSeekClientOptions) {
169
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
170
+ this.apiKey = options.apiKey;
171
+ this.fetchFn = options.fetchFn ?? fetch;
172
+ this.model = options.model ?? DEFAULT_MODEL;
173
+ }
174
+
175
+ /**
176
+ * 执行一次联网搜索。
177
+ * 服务端执行 search/open_page 等动作并生成综合回答,返回回答文本与动作记录。
178
+ */
179
+ async search(query: string, options: SearchOptions = {}): Promise<SearchResult> {
180
+ const body = this.buildRequestBody(query, options);
181
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
182
+
183
+ // 合并外部 signal 与内部超时,并区分取消来源
184
+ const controller = new AbortController();
185
+ const onExternalAbort = () => controller.abort();
186
+ options.signal?.addEventListener("abort", onExternalAbort);
187
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
188
+
189
+ try {
190
+ for (let attempt = 0; ; attempt++) {
191
+ try {
192
+ const response = await this.fetchFn(`${this.baseUrl}/responses`, {
193
+ method: "POST",
194
+ headers: {
195
+ "Content-Type": "application/json",
196
+ "Authorization": `Bearer ${this.apiKey}`,
197
+ },
198
+ body: JSON.stringify(body),
199
+ signal: controller.signal,
200
+ });
201
+
202
+ if (response.status === 200) {
203
+ const json = (await response.json()) as Record<string, unknown>;
204
+ return this.parseResponse(json);
205
+ }
206
+
207
+ // 5xx 且未用尽重试次数时退避重试;其余状态直接映射错误
208
+ if (response.status >= 500 && attempt < MAX_RETRIES) {
209
+ await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt, controller.signal);
210
+ continue;
211
+ }
212
+
213
+ let rawBody: unknown;
214
+ try {
215
+ rawBody = await response.json();
216
+ } catch {
217
+ rawBody = undefined;
218
+ }
219
+ const message = extractErrorMessage(rawBody) ?? `HTTP ${response.status}`;
220
+ throw new SearchError(message, mapHttpError(response.status));
221
+ } catch (error) {
222
+ if (error instanceof SearchError)
223
+ throw error;
224
+ // 区分取消来源:外部 signal → aborted;内部超时 → timeout
225
+ if (options.signal?.aborted)
226
+ throw new SearchError("Request aborted", "aborted");
227
+ if (controller.signal.aborted)
228
+ throw new SearchError(`Request timed out after ${timeoutMs}ms`, "timeout");
229
+ throw new SearchError(`Network error: ${(error as Error).message}`, "network_error");
230
+ }
231
+ }
232
+ } finally {
233
+ clearTimeout(timer);
234
+ options.signal?.removeEventListener("abort", onExternalAbort);
235
+ }
236
+ }
237
+
238
+ /** 构造 /responses 请求体:搜索恒为强制(插件职责即搜索,不让模型跳过) */
239
+ private buildRequestBody(query: string, options: SearchOptions): Record<string, unknown> {
240
+ const body: Record<string, unknown> = {
241
+ model: this.model,
242
+ input: query,
243
+ tools: [{ type: "web_search" }],
244
+ tool_choice: { type: "web_search" },
245
+ max_output_tokens: options.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
246
+ };
247
+ const reasoning = buildReasoningField(options.reasoningEffort);
248
+ if (reasoning)
249
+ body.reasoning = reasoning;
250
+ return body;
251
+ }
252
+
253
+ /** 解析 /responses 成功响应 */
254
+ private parseResponse(json: Record<string, unknown>): SearchResult {
255
+ const output = Array.isArray(json.output) ? json.output : [];
256
+ const actions: WebSearchAction[] = [];
257
+ for (const item of output) {
258
+ if (item == null || typeof item !== "object")
259
+ continue;
260
+ const action = parseWebSearchCall(item as { action?: unknown });
261
+ if (action != null)
262
+ actions.push(action);
263
+ }
264
+ return {
265
+ answer: extractFinalAnswer(output),
266
+ actions,
267
+ usage: parseUsage(json.usage),
268
+ model: typeof json.model === "string" ? json.model : this.model,
269
+ id: typeof json.id === "string" ? json.id : undefined,
270
+ };
271
+ }
272
+ }