providerService.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import { mkdir, readFile, writeFile } from 'node:fs/promises';
  2. import { join } from 'node:path';
  3. import { chatBridge } from './chatBridgeService';
  4. import { getCodexDataDir } from './codexHome';
  5. import { DEFAULT_API_KEY_ENV, type CodexProviderSpec, type CodexRuntime, type JsonValue } from './codexRuntime';
  6. import type { AppliedProviderInfo, ApplyProviderInput } from './types';
  7. /**
  8. * 把后端「模型管理」里的一条记录翻译成 Codex 的 model provider。
  9. * 全程不做任何 Codex/OpenAI 账号登录:靠 wire_api="responses" + requires_openai_auth=false + env_key。
  10. * apiKey 只存在于内存与子进程环境变量,落盘的 AppliedProvider 不含任何密钥。
  11. */
  12. export const PROVIDER_ID = 'zsjz';
  13. export type AppliedProvider = AppliedProviderInfo;
  14. /**
  15. * 端点判定。后端「模型管理」里的记录一律是 Chat 协议(没有 /responses),
  16. * 所以这里只关心「能不能用 Chat」:chat → 走内置桥接;其余两种拒绝。
  17. */
  18. export type EndpointProtocol = 'chat' | 'unsupported' | 'unreachable';
  19. export interface EndpointProbeResult {
  20. protocol: EndpointProtocol;
  21. /** /chat/completions 的 HTTP 状态;未探或网络失败为 null */
  22. chatStatus: number | null;
  23. detail: string;
  24. }
  25. const EXTRA_ALLOWLIST: readonly string[] = [
  26. 'request_max_retries',
  27. 'stream_max_retries',
  28. 'stream_idle_timeout_ms',
  29. 'supports_websockets',
  30. 'query_params',
  31. ];
  32. /**
  33. * 自部署端点多是单槽推理:实测一轮 Codex 请求的 prompt 有 4.6 万 token,
  34. * 而这台机器 prefill 只有 ~29 token/s —— Codex 默认每 15 秒掐一次流并重试 5 次,
  35. * 等于把同样几分钟的活儿重复五遍,还会把前缀缓存挤掉。所以默认不重试、空闲超时放宽,
  36. * 并声明不走 websocket(Codex 对本地 provider 本来也没发 upgrade,这里显式关掉免得哪天再试)。
  37. * 模型管理 config 里显式写的值优先。
  38. */
  39. const PROVIDER_DEFAULTS: Readonly<Record<string, JsonValue>> = Object.freeze({
  40. request_max_retries: 0,
  41. stream_max_retries: 0,
  42. stream_idle_timeout_ms: 300_000,
  43. supports_websockets: false,
  44. });
  45. function providerFile(): string {
  46. return join(getCodexDataDir(), 'provider.json');
  47. }
  48. /** Codex 要求 base_url 是 API 根(形如 https://api.openai.com/v1),它自己再拼路由 */
  49. export function normalizeBaseUrl(raw: string | null | undefined): string | null {
  50. const value = (raw ?? '').trim().replace(/\/+$/u, '');
  51. return value || null;
  52. }
  53. /** 给页面的非阻断提示;返回 null 表示没问题 */
  54. export function baseUrlHint(baseUrl: string | null): string | null {
  55. if (!baseUrl) return '未配置 base_url';
  56. if (!/^https?:\/\//u.test(baseUrl)) return 'base_url 必须以 http:// 或 https:// 开头';
  57. if (!/\/v\d+$/u.test(baseUrl)) {
  58. return 'base_url 通常应写到版本段(如 .../v1),否则拼出的 /chat/completions 可能 404';
  59. }
  60. return null;
  61. }
  62. function parseJsonRecord(raw: unknown): Record<string, unknown> | null {
  63. if (!raw) return null;
  64. if (typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>;
  65. if (typeof raw !== 'string') return null;
  66. try {
  67. const parsed = JSON.parse(raw) as unknown;
  68. return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
  69. ? (parsed as Record<string, unknown>)
  70. : null;
  71. } catch {
  72. return null;
  73. }
  74. }
  75. function readStringMap(raw: unknown): Record<string, string> | null {
  76. const record = parseJsonRecord(raw);
  77. if (!record) return null;
  78. const result: Record<string, string> = {};
  79. for (const [key, value] of Object.entries(record)) {
  80. if (typeof value === 'string' && value) result[key] = value;
  81. }
  82. return Object.keys(result).length ? result : null;
  83. }
  84. /**
  85. * 后端「模型管理」的一条记录一律翻译成自定义 provider:Codex 0.155.1 把 ollama / lmstudio
  86. * 等内置 id 列为保留字,覆盖 model_providers.<内置 id> 会让 app-server 启动即退出。
  87. * 地址缺失就是配置缺失,直接报错让页面提示,不做 localhost 兜底。
  88. */
  89. export function toProviderSpec(input: ApplyProviderInput): CodexProviderSpec {
  90. const model = `${input.modelId ?? ''}`.trim();
  91. if (!model) throw new Error('缺少 modelId');
  92. const baseUrl = normalizeBaseUrl(input.baseUrl);
  93. if (!baseUrl) {
  94. throw new Error(`模型「${input.name || model}」没有配置 base_url,请到「模型管理」补全(形如 https://host:port/v1)`);
  95. }
  96. const config = parseJsonRecord(input.config);
  97. const extra: Record<string, JsonValue> = { ...PROVIDER_DEFAULTS };
  98. for (const [key, value] of Object.entries(config ?? {})) {
  99. if (EXTRA_ALLOWLIST.includes(key)) extra[key] = value as JsonValue;
  100. }
  101. return {
  102. id: PROVIDER_ID,
  103. name: input.name ?? model,
  104. baseUrl,
  105. model,
  106. apiKey: input.apiKey ?? null,
  107. envKey: DEFAULT_API_KEY_ENV,
  108. httpHeaders: readStringMap(input.headersJson),
  109. extra: Object.keys(extra).length ? extra : null,
  110. };
  111. }
  112. /** config 里被丢弃的非白名单键,用于页面提示 */
  113. export function describeDroppedKeys(input: ApplyProviderInput): string[] {
  114. const config = parseJsonRecord(input.config);
  115. return Object.keys(config ?? {}).filter((key) => !EXTRA_ALLOWLIST.includes(key));
  116. }
  117. /**
  118. * 探测只问「路由在不在」,绝不让端点真去生成:本地服务多是单槽推理,
  119. * 一次生成能占住整个 HTTP 服务几十秒(实测 max_tokens:1 也要 14 秒,期间连 /models 都不应答)。
  120. * 15 秒是留给排队的余量。
  121. */
  122. const PROBE_TIMEOUT_MS = 15_000;
  123. interface ProbeAttempt {
  124. status: number | null;
  125. error: string | null;
  126. ms: number;
  127. timedOut: boolean;
  128. }
  129. function describeFetchError(error: unknown): { message: string; timedOut: boolean } {
  130. const err = error instanceof Error ? error : new Error(String(error));
  131. return {
  132. message: err.message,
  133. timedOut: err.name === 'TimeoutError' || err.name === 'AbortError' || /timeout/iu.test(err.message),
  134. };
  135. }
  136. async function probeFetch(url: string, init: RequestInit, timeoutMs: number): Promise<ProbeAttempt> {
  137. const startedAt = Date.now();
  138. try {
  139. const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
  140. return { status: response.status, error: null, ms: Date.now() - startedAt, timedOut: false };
  141. } catch (error) {
  142. const { message, timedOut } = describeFetchError(error);
  143. return { status: null, error: message, ms: Date.now() - startedAt, timedOut };
  144. }
  145. }
  146. async function postProbe(url: string, body: Record<string, unknown>, apiKey?: string | null): Promise<ProbeAttempt> {
  147. return probeFetch(
  148. url,
  149. {
  150. method: 'POST',
  151. headers: {
  152. 'content-type': 'application/json',
  153. ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
  154. },
  155. body: JSON.stringify(body),
  156. },
  157. PROBE_TIMEOUT_MS,
  158. );
  159. }
  160. /**
  161. * 不可达的提示必须带上是哪个地址、等了多久——只说「端点不可达」时,
  162. * 用户既不知道配错在哪,也无从判断是没起服务还是被防火墙慢慢吞掉。
  163. */
  164. function unreachableDetail(url: string, attempt: ProbeAttempt): string {
  165. if (attempt.timedOut) {
  166. return `端点不可达:${url} 在 ${(attempt.ms / 1000).toFixed(1)} 秒内没有响应,请确认服务已启动、地址与端口正确`;
  167. }
  168. return `端点不可达:${url} —— ${attempt.error ?? '网络错误'}`;
  169. }
  170. function probeResult(partial: Partial<EndpointProbeResult> & { protocol: EndpointProtocol; detail: string }): EndpointProbeResult {
  171. return { chatStatus: null, ...partial };
  172. }
  173. /**
  174. * 判定端点能不能用 Chat 协议——后端「模型管理」里的记录都是 Chat 端点,
  175. * 有的就交给内置桥接。
  176. *
  177. * 只看 /chat/completions 这条路由在不在:请求体故意不带 messages,
  178. * 端点会在校验阶段就回 400(实测 15ms),不会真的开始生成。
  179. * 代价是模型名写错要到真正提问时才暴露。
  180. */
  181. export async function probeEndpoint(params: {
  182. baseUrl: string;
  183. modelId?: string | null;
  184. apiKey?: string | null;
  185. }): Promise<EndpointProbeResult> {
  186. const baseUrl = params.baseUrl.replace(/\/+$/u, '');
  187. const chatUrl = `${baseUrl}/chat/completions`;
  188. const model = params.modelId?.trim() || 'probe';
  189. const chat = await postProbe(chatUrl, { model }, params.apiKey);
  190. if (chat.status === null) {
  191. return probeResult({ protocol: 'unreachable', detail: unreachableDetail(chatUrl, chat) });
  192. }
  193. if (chat.status !== 404 && chat.status !== 405) {
  194. return probeResult({
  195. protocol: 'chat',
  196. chatStatus: chat.status,
  197. detail: `端点支持 Chat 协议(HTTP ${chat.status}),应用时经内置桥接转换为 Responses`,
  198. });
  199. }
  200. return probeResult({
  201. protocol: 'unsupported',
  202. chatStatus: chat.status,
  203. detail: `端点没有 /chat/completions 路由(HTTP ${chat.status}),本客户端只对接 Chat 协议端点`,
  204. });
  205. }
  206. export async function readAppliedProvider(): Promise<AppliedProvider | null> {
  207. try {
  208. const raw = JSON.parse(await readFile(providerFile(), 'utf8')) as Partial<AppliedProvider>;
  209. return raw.model ? (raw as AppliedProvider) : null;
  210. } catch {
  211. return null;
  212. }
  213. }
  214. async function writeAppliedProvider(value: AppliedProvider | null): Promise<void> {
  215. await mkdir(getCodexDataDir(), { recursive: true });
  216. await writeFile(providerFile(), JSON.stringify(value ?? {}, null, 2), 'utf8');
  217. }
  218. /** 应用一条模型配置:先探端点,再重启子进程(api_key 走 env,换 key 必须重启) */
  219. export async function applyProvider(
  220. runtime: CodexRuntime,
  221. input: ApplyProviderInput & { modelRecordId?: string | number | null },
  222. ): Promise<AppliedProvider> {
  223. const spec = toProviderSpec(input);
  224. /** 落盘存上游真实地址(展示用);桥接 URL 每次应用临时分配,不落盘 */
  225. const displayBaseUrl = spec.baseUrl;
  226. // 一定要先探端点:应用成功后才发现连不上,比这里直接报错更难排查
  227. const probe = await probeEndpoint({
  228. baseUrl: spec.baseUrl,
  229. modelId: spec.model,
  230. apiKey: spec.apiKey,
  231. });
  232. if (probe.protocol !== 'chat') throw new Error(probe.detail);
  233. // Codex 只会发 Responses,所以 Chat 端点一律经内置桥接:Codex 改连本地代理的 /v1/responses
  234. const bridge = await chatBridge.start({ upstreamBaseUrl: spec.baseUrl, headers: spec.httpHeaders ?? null });
  235. spec.baseUrl = bridge.url;
  236. try {
  237. await runtime.applyProvider(spec);
  238. } catch (error) {
  239. // 应用失败回滚桥接,避免留下一个指着旧上游的孤儿代理
  240. await chatBridge.stop();
  241. throw error;
  242. }
  243. const applied: AppliedProvider = {
  244. model: spec.model,
  245. modelRecordId: input.modelRecordId === undefined || input.modelRecordId === null
  246. ? null
  247. : String(input.modelRecordId),
  248. baseUrl: displayBaseUrl,
  249. providerId: spec.id,
  250. name: spec.name ?? null,
  251. appliedAt: new Date().toISOString(),
  252. bridged: true,
  253. };
  254. await writeAppliedProvider(applied);
  255. return applied;
  256. }
  257. export async function clearProvider(runtime: CodexRuntime): Promise<void> {
  258. await chatBridge.stop();
  259. await runtime.applyProvider(null);
  260. await writeAppliedProvider(null);
  261. }