index.js 9.82 KB
/**
 * @drama/llm —— 多厂商 LLM 网关公共面(唯一对外入口)。
 *
 * 只导出契约 §3 声明的符号:createLLM / createStaticKeyProvider / LLMError / registeredProviders。
 * 内部实现(base/providers/ports/retry/map-error)不外泄;深导入由 ESLint 拦截。
 *
 * 设计红线(见 docs/architecture/README.md §2):包内不读 process.env、不建顶层连接、不写盘;
 * apiKey/baseUrl 经注入的 keyProvider 提供;成本、审计、结算端口由 composition root 成组注入。
 *
 * **无假 provider、无 fetch 注入缝**:网关只会对着真实 socket 说话。E2E 打的是 `.env` 里的真厂商
 * (`pnpm llm:e2e`),不是本地假上游——假上游一旦存在,"代表性的那条成功调用"就会被它顶掉。
 */

export { createLLM, createStaticKeyProvider } from './llm.js'
export { LLMError } from './errors.js'
// 适配器名单是网关的知识:`model_provider_capability.adapter` 的合法取值即注册表键集。装配 / seed 侧据此在
// 启动期校验配置(未注册的 adapter 不得起服务),不再各自抄一份「厂商名 → 协议」的猜测映射。
export { registeredProviders } from './registry.js'

/* ===== 端口与数据形态契约(JSDoc @typedef,checkJs 友好,0 个 .ts 文件) ===== */

/**
 * KeyProvider 端口(由 @drama/keyrouter 或 createStaticKeyProvider 提供,包内只调)。
 * @typedef {object} KeyProvider
 * @property {(q:{ vendor:string, model?:string, affinity_key?:string, lease_ttl_ms?:number }) => Promise<KeyLease>} acquire
 * @property {(key_id:string, usage:object) => void} report_usage
 * @property {(key_id:string, retry_after_ms?:number) => void} report_rate_limited
 * @property {(key_id:string, kind:string) => void} report_error
 */

/**
 * @typedef {object} KeyLease
 * @property {string} api_key
 * @property {string|null} account_id
 * @property {string|null} base_url
 * @property {string|null} key_id
 *   网关只把它原样透传给 keyProvider 的回报方法与 AuditPort,不解释它的来源。
 * @property {() => void} release   释放并发槽;务必 finally 调用。
 */

/**
 * @typedef {object} ModelCostPort
 * @property {(input:{provider:string, model:string, usage:object}) => Promise<{pricing_snapshot:object, supplier_cost_micros:number, currency:'CNY', breakdown:object}>} calculate
 */

/**
 * @typedef {object} BillingPort
 * @property {(input:{call_id:string,user_id?:string, call_ref:{type:string,id:string}, supplier_cost:{supplier_cost_micros:number,currency:'CNY'}, policy:object, idempotency_key:string}) => Promise<{billing_mode:string,billing_policy_snapshot:object,charged_points:number,ledger_id:string|null}>} settle
 */

/**
 * @typedef {object} NotifyPort
 * @property {(event:object, ctx?:object) => Promise<void>} notify
 */

/**
 * AuditPort 端口(由 @drama/llm-audit 提供)。失败记录异步入队;供应商成功事实与结算结果必须 await 落库。
 * @typedef {object} AuditPort
 * @property {(entry: AuditEntry) => void} record
 * @property {(entry: AuditEntry) => Promise<void>} recordProviderSuccess
 */

/**
 * 一次 chat 的审计条目——在 chat 出口一次性拼齐的完整单一形态(含 null 显式化)。
 * 三出口(成功 / 失败 / abort)都拼齐全部字段:**失败也有 messages 与 key_id**——认证失败正是最需要
 * 知道"用户问了什么、哪把 key 挂了"的时候。
 * @typedef {object} AuditEntry
 * @property {string} provider
 * @property {string} model
 * @property {'ok'|'error'|'aborted'} status
 * @property {number|null} ttft_ms
 * @property {number} duration_ms
 * @property {ChatUsage} usage
 * @property {string} request_id
 * @property {string} billing_operation_id  业务操作幂等标识;同一业务重试保持不变,和 request_id 分离。
 * @property {string|null} session_id
 * @property {string|null} user_id           计费主体(← billing_ctx.user_id);缺省 null。
 * @property {string|null} provider_request_id
 * @property {string|null} service_tier
 * @property {object|null} pricing_snapshot
 * @property {number|null} supplier_cost_micros
 * @property {'CNY'|null} currency
 * @property {null} billing_mode             provider 成功落库时尚未结算,固定为 null。
 * @property {null} billing_policy_snapshot  provider 成功落库时尚未结算,固定为 null。
 * @property {null} charged_points
 * @property {null} ledger_id
 * @property {string|null} error_reason      映射后的 LLMError.reason(成功为 null)。
 * @property {string|null} error_message     可读错误消息(成功为 null)。
 * @property {Record<string, any>|null} error_detail 第三方边界构造的结构化错误详情(成功为 null)。
 * @property {Date} created_at
 * @property {ChatMessage[]} messages       发出去的消息(valibot 校验后的请求),三出口都有。
 * @property {string|null} response_text     回来的正文(← ChatResult.content);失败/abort 为 null。
 * @property {string|null} reasoning_text    回来的推理;无推理或失败为 null。
 * @property {string|null} key_id            服务这次调用的 key(← KeyLease.key_id = llm_key.id);static provider 为 null。
 * @property {string|null} mode             场景名(← ChatOpts.mode,由 mode-router 透传);直接 chat 时 null。
 * @property {string|null} correlation_id    同一次 forMode 调用的所有 attempt 共用(← ChatOpts.correlation_id)。
 * @property {number|null} attempt_index     fallback 链上第几步,0 = 主力(← ChatOpts.attempt_index)。
 */

/**
 * @typedef {object} Span
 * @property {(k:string, v:any) => void} setAttr
 * @property {(n:string, d?:object) => void} addEvent
 * @property {() => void} end
 */

/**
 * @typedef {object} TracePort
 * @property {(name:string) => Span} startSpan
 */

/**
 * @typedef {object} ChatMessage
 * @property {'system'|'user'|'assistant'|'tool'} role
 * @property {any} content
 * @property {boolean} [prompt_cache_breakpoint] OpenAI Responses 静态前缀终点。
 */

/**
 * @typedef {object} ChatRequest
 * @property {ChatMessage[]} messages
 * @property {string} [model]
 * @property {number} [temperature]
 * @property {number} [max_tokens]
 * @property {{ type:'json_object' } | { type?:'json_schema', name?:string, schema:object }} [response_format]
 *   网关规范化 structured output:两种归一形态——json_object(要求合法 JSON,无 schema)
 *   与 json_schema(带 schema,name 可选)。入口一次归一,各 provider 按支持度映射/拒绝。
 * @property {Array<object>} [tools]
 * @property {object} [thinking]
 * @property {Array<Buffer|string>} [images]
 * @property {Array<Buffer|string>} [videos]
 * @property {{ mode:'implicit'|'explicit', ttl?:'30m' }} [prompt_cache_options]
 */

/**
 * @typedef {object} ChatOpts
 * @property {boolean} [cache]                       请求级 prompt-caching(支持的厂商生效,否则忽略)。
 * @property {number} [timeout_ms]                    每次 attempt 独立计时的内部超时毫秒(timeout 可重试)。
 * @property {(chunk:string) => void} [on_content]
 * @property {(chunk:string) => void} [on_thinking]
 * @property {(calls:Array<object>) => void} [on_tool_call]
 * @property {(frame:object) => void} [on_raw_frame]            真实上游 SSE JSON 帧;诊断脚本用于核对协议解析。
 * @property {AbortSignal} [signal]
 * @property {{ user_id?:string, idempotency_key?:string, policy?:object }} [billing_ctx]
 *   `policy` 由 composition root 注入;付费模式下业务方提供 `user_id`,需要跨请求防重时提供稳定的
 *   `idempotency_key`。provider、model 和 request_id 均由网关自己的调用事实确定。
 * @property {{ session_id?:string, [k:string]:any }} [trace_ctx]
 * @property {string} [prompt_cache_key] OpenAI Prompt Caching 路由键;与 trace session 和 key affinity 独立。
 * @property {string} [mode]            场景名(`mode_model_config.mode`),由 `@drama/llm-router` 的 mode-router 透传;原样落审计。
 * @property {string} [correlation_id]   一次 forMode 调用的所有 attempt 共用的 id,由 `withFallback` 生成;原样落审计。
 * @property {number} [attempt_index]    fallback 链上第几步(0 = 主力),由 `withFallback` 给出;原样落审计。
 * @property {(key_id: string|null) => void} [on_key_lease]
 *   **网关内部注入,调用方不要设置(设了也会被覆盖)**:base 每次 attempt acquire 到 key 后回调,
 *   把 `KeyLease.key_id` 交回 createLLM 出口写进 AuditEntry.key_id。
 */

/**
 * @typedef {object} ChatUsage
 * @property {number} prompt_tokens
 * @property {number} completion_tokens
 * @property {number} total_tokens
 * @property {number} cached_tokens
 * @property {number} cache_write_tokens
 * @property {number} reasoning_tokens
 */

/**
 * @typedef {object} ChatResult
 * @property {string} content
 * @property {string} [reasoning]
 * @property {Array<object>} [tool_calls]
 * @property {ChatUsage} usage
 * @property {string} provider
 * @property {string} model
 * @property {{ ttft_ms:number|null, duration_ms:number }} [timings]  成功路径的计时(透传自网关)。
 * @property {string} [request_id]  本次真实厂商调用的全局唯一标识(= llm_call_log.request_id),对账/回查用。
 * @property {string} [billing_operation_id] 稳定业务操作标识;同一业务重试保持不变。
 * @property {ChatUsage & {kind:'token'}} metered_usage
 * @property {string|null} provider_request_id
 * @property {string|null} service_tier
 * @property {{ call_ref:{type:string,id:string}, usage:object, pricing_snapshot:object, supplier_cost_micros:number, currency:'CNY', billing_mode:string, charged_points:number, ledger_id:string|null }} [accounting]
 */

/**
 * @typedef {object} Llm
 * @property {(req:ChatRequest, opts?:ChatOpts) => Promise<ChatResult>} chat
 */