import { createOpenAICompatible, type OpenAICompatibleProvider, } from '@ai-sdk/openai-compatible'; import { transferableAbortController } from 'node:util'; import { APICallError, generateObject, NoObjectGeneratedError, RetryError, } from 'ai'; import { AiApiError, AiErrorCodes, isAbortError } from './AiError'; import type { AiRuntimeConfig } from './AiRuntimeConfig'; import { readAiApiKey } from './AiRuntimeConfig'; export interface TokenUsage { inputTokens: number; outputTokens: number; } export type JsonSchemaObject = Record & { type: 'object'; }; export interface GenerateObjectRequest { system: string; user: string; schema: JsonSchemaObject; model: string; maxOutputTokens: number; timeoutMs: number; signal?: AbortSignal; } export class AiStructuredOutputClient { private readonly provider: OpenAICompatibleProvider; constructor(config: AiRuntimeConfig) { this.provider = createOpenAICompatible({ apiKey: readAiApiKey(config), baseURL: config.baseUrl, name: 'openai-compatible', supportsStructuredOutputs: false, }); } async generateObject(request: GenerateObjectRequest): Promise<{ object: Record; usage: TokenUsage; model: string; /** true = 结构化输出失败后从原始文本抢救出的 JSON(容错解析兜底触发) */ salvaged?: boolean; }> { try { const response = await withRequestTimeout(request.timeoutMs, request.signal, (signal) => generateObject({ model: this.provider(request.model), system: [ request.system, '必须输出符合以下 JSON Schema 的 JSON 对象:', JSON.stringify(request.schema), ].join('\n'), prompt: request.user, output: 'no-schema', maxOutputTokens: request.maxOutputTokens, // 网络/SDK 层零重试:重试统一归 PageGenerator 的简单重试循环(最多请求三次), // 避免 SDK 内层重试 × 外层轮次把上游请求数放大到 3×3 maxRetries: 0, abortSignal: signal, })); if (response.finishReason !== 'stop') { // 截断(length)与其他中断分开报:截断是"预算不够"的可诊断信号, // 上层据此回喂"精简输出"而不是盲目重试(实弹里 7/12 失败源于此) const reason = response.finishReason === 'length' ? '输出超出 token 预算被截断(finish_reason=length)——思考模型的推理 token 同样计入预算' : `输出未完整生成(finish_reason=${response.finishReason})`; throw new AiApiError(AiErrorCodes.AI_OUTPUT_INVALID, `AI ${reason}`); } if (!response.object || typeof response.object !== 'object' || Array.isArray(response.object)) { throw new AiApiError(AiErrorCodes.AI_OUTPUT_INVALID, 'AI 输出不是对象'); } return { object: response.object as Record, usage: { inputTokens: response.usage.inputTokens ?? 0, outputTokens: response.usage.outputTokens ?? 0, }, model: response.response.modelId, }; } catch (error) { // 容错解析:真实模型常把 JSON 包进 markdown 栅栏或夹带解释文字, // SDK 直接判为"未生成对象"。这里从原始 content 抢救首个完整 JSON 对象, // 抢救成功不算失败(不消耗生成器的回喂重试预算)。 if (NoObjectGeneratedError.isInstance(error)) { const salvaged = extractFirstJsonObject(error.text); if (!salvaged && isBlank(error.text)) { // 上游**什么都没给**(实测:三次尝试连续 outputTokens=0)。这与"模型写了 JSON // 但不合契约"是两种病:前者该看上游/配额/模型可用性,后者才该回头改提示词与文档。 // 先前两者共用一句"AI 输出不符合函数契约",报表里根本分不开—— // 而本项目的纪律是"生成不出来先查我们的文档是否没写好",错误归因会把人带偏。 throw new AiApiError( AiErrorCodes.AI_OUTPUT_INVALID, `AI 返回空输出——上游未产生任何内容(finish_reason=${error.finishReason ?? 'unknown'})`, ); } if (salvaged) { return { object: salvaged, usage: { inputTokens: error.usage?.inputTokens ?? 0, outputTokens: error.usage?.outputTokens ?? 0, }, model: error.response?.modelId ?? request.model, salvaged: true, }; } } throw mapOpenAiCompatibleError(error); } } } /** * SDK 的 abortSignal 是正常路径的中止闸;这里再加一层 Promise 硬超时。 * * 某些 OpenAI 兼容网关在连接已建立但迟迟不结束响应时,不会及时把 fetch * 的 abort 传回 generateObject。只依赖 signal 会让一次页面生成永久占住 * AI 信号量,后续请求也只能一直排队。Promise 竞速保证调用方按预算返回, * controller 仍会通知底层请求尽快释放连接。 */ async function withRequestTimeout( timeoutMs: number, parentSignal: AbortSignal | undefined, operation: (signal: AbortSignal) => Promise, ): Promise { // This is server-side fetch. A temporary DOM realm may replace the global // controller during rendering checks, but Node fetch requires a Node-native // AbortSignal rather than a DOM-realm lookalike. const controller = transferableAbortController(); const timeout = Math.max(1, Math.ceil(timeoutMs)); const abortReason = new DOMException('AI request timed out', 'TimeoutError'); const onParentAbort = (): void => { controller.abort(parentSignal?.reason ?? new DOMException('AI request aborted', 'AbortError')); }; if (parentSignal?.aborted) { onParentAbort(); } else { parentSignal?.addEventListener('abort', onParentAbort, { once: true }); } let timer: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(abortReason); reject(new AiApiError(AiErrorCodes.AI_UPSTREAM_TIMEOUT, 'AI 服务调用超时')); }, timeout); }); try { const requestPromise = operation(controller.signal); return await Promise.race([requestPromise, timeoutPromise]); } finally { if (timer) clearTimeout(timer); parentSignal?.removeEventListener('abort', onParentAbort); } } /** 空输出判定:undefined / 空串 / 纯空白都算"上游什么都没给" */ function isBlank(text: string | undefined): boolean { return text === undefined || text.trim() === ''; } /** * 从模型原始文本中抢救 JSON 对象(容错解析): * 1. 优先尝试各个 markdown 代码栅栏(```json / ```yaml / 无语言标注)内的内容; * 2. 兜底扫描全文; * 3. 用括号配对(跳过字符串字面量内的花括号)切出首个完整对象再 JSON.parse。 * 全部候选都解析失败返回 null——这才算一次真正的解析失败。 */ export function extractFirstJsonObject(raw: string | undefined): Record | null { if (!raw) { return null; } const candidates: string[] = []; for (const match of raw.matchAll(/```[a-zA-Z]*[ \t]*\r?\n?([\s\S]*?)```/g)) { candidates.push(match[1]); } candidates.push(raw); for (const candidate of candidates) { const sliced = sliceBalancedJsonObject(candidate); if (!sliced) { continue; } try { const parsed = JSON.parse(sliced) as unknown; if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record; } } catch { // 该候选不是合法 JSON,继续尝试下一个 } } return null; } /** 括号配对切出首个完整 JSON 对象子串(字符串内的 { } 与转义符不计入配对) */ function sliceBalancedJsonObject(text: string): string | null { const start = text.indexOf('{'); if (start < 0) { return null; } let depth = 0; let inString = false; let escaped = false; for (let index = start; index < text.length; index++) { const char = text[index]; if (inString) { if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; } else if (char === '{') { depth += 1; } else if (char === '}') { depth -= 1; if (depth === 0) { return text.slice(start, index + 1); } } } return null; } function mapOpenAiCompatibleError(error: unknown): AiApiError { if (error instanceof AiApiError) { return error; } if (NoObjectGeneratedError.isInstance(error)) { return new AiApiError(AiErrorCodes.AI_OUTPUT_INVALID, 'AI 输出不符合函数契约'); } const upstream = RetryError.isInstance(error) ? error.lastError : error; if (isAbortError(upstream) || (RetryError.isInstance(error) && error.reason === 'abort')) { return new AiApiError(AiErrorCodes.AI_UPSTREAM_TIMEOUT, 'AI 服务调用超时'); } if (APICallError.isInstance(upstream)) { if (upstream.statusCode === 429) { return new AiApiError(AiErrorCodes.AI_RATE_LIMITED, 'AI 服务繁忙,请稍后重试'); } if (upstream.statusCode === 401 || upstream.statusCode === 403) { return new AiApiError(AiErrorCodes.AI_CONFIG_MISSING, 'AI 服务未配置'); } return new AiApiError( AiErrorCodes.AI_UPSTREAM_ERROR, `AI 上游服务异常(HTTP ${upstream.statusCode ?? 'unknown'})`, ); } return new AiApiError(AiErrorCodes.AI_UPSTREAM_ERROR, 'AI 上游服务异常'); }