providerService.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  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. /** 端点协议判定:responses 直通;chat-only 走内置桥接;unsupported 双协议都没有;unreachable 网络不可达 */
  15. export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable';
  16. export interface EndpointProbeResult {
  17. protocol: EndpointProtocol;
  18. /** /responses 的 HTTP 状态;未探或网络失败为 null */
  19. responsesStatus: number | null;
  20. /** /chat/completions 的 HTTP 状态;未探为 null */
  21. chatStatus: number | null;
  22. /** /api/version 探到的 Ollama 版本;非 Ollama 或未探为 null */
  23. ollamaVersion: string | null;
  24. /** Ollama 低于 0.13.3(没有非状态化 /v1/responses):提示升级,但允许走桥接 */
  25. ollamaNeedsUpgrade: boolean;
  26. detail: string;
  27. }
  28. const EXTRA_ALLOWLIST: readonly string[] = [
  29. 'request_max_retries',
  30. 'stream_max_retries',
  31. 'stream_idle_timeout_ms',
  32. 'query_params',
  33. ];
  34. function providerFile(): string {
  35. return join(getCodexDataDir(), 'provider.json');
  36. }
  37. /** Codex 要求 base_url 是 API 根(形如 https://api.openai.com/v1),它自己再拼 /responses */
  38. export function normalizeBaseUrl(raw: string | null | undefined): string | null {
  39. const value = (raw ?? '').trim().replace(/\/+$/u, '');
  40. return value || null;
  41. }
  42. /** 给页面的非阻断提示;返回 null 表示没问题 */
  43. export function baseUrlHint(baseUrl: string | null): string | null {
  44. if (!baseUrl) return '未配置 base_url';
  45. if (!/^https?:\/\//u.test(baseUrl)) return 'base_url 必须以 http:// 或 https:// 开头';
  46. if (!/\/v\d+$/u.test(baseUrl)) {
  47. return 'base_url 通常应写到版本段(如 .../v1),否则 Codex 拼出的 /responses 可能 404';
  48. }
  49. return null;
  50. }
  51. function parseJsonRecord(raw: unknown): Record<string, unknown> | null {
  52. if (!raw) return null;
  53. if (typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>;
  54. if (typeof raw !== 'string') return null;
  55. try {
  56. const parsed = JSON.parse(raw) as unknown;
  57. return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
  58. ? (parsed as Record<string, unknown>)
  59. : null;
  60. } catch {
  61. return null;
  62. }
  63. }
  64. function readStringMap(raw: unknown): Record<string, string> | null {
  65. const record = parseJsonRecord(raw);
  66. if (!record) return null;
  67. const result: Record<string, string> = {};
  68. for (const [key, value] of Object.entries(record)) {
  69. if (typeof value === 'string' && value) result[key] = value;
  70. }
  71. return Object.keys(result).length ? result : null;
  72. }
  73. /** Codex 内置 ollama/lmstudio 走 OpenAI 兼容根(.../v1),而库里常只存到 host:port */
  74. function normalizeOssBaseUrl(raw: string | null | undefined): string | null {
  75. const base = normalizeBaseUrl(raw);
  76. if (!base) return null;
  77. return /\/v\d+$/u.test(base) ? base : `${base}/v1`;
  78. }
  79. /** OLLAMA 用 Codex 内置 provider(Responses API,默认端口 11434),不需要 api key */
  80. function resolveBuiltinProvider(input: ApplyProviderInput): string | null {
  81. if (input.builtinProvider) return input.builtinProvider;
  82. const type = `${input.providerType ?? ''}`.trim().toUpperCase();
  83. if (type === 'OLLAMA') return 'ollama';
  84. if (type === 'LMSTUDIO') return 'lmstudio';
  85. return null;
  86. }
  87. export function toProviderSpec(input: ApplyProviderInput): CodexProviderSpec {
  88. const model = `${input.modelId ?? ''}`.trim();
  89. if (!model) throw new Error('缺少 modelId');
  90. const builtinProvider = resolveBuiltinProvider(input);
  91. if (builtinProvider) {
  92. return {
  93. id: null,
  94. builtinProvider,
  95. model,
  96. name: input.name ?? `${builtinProvider} 本地模型`,
  97. // 内置 provider 的默认地址是 localhost,必须把库里的真实地址带上,否则连不到远端 Ollama
  98. baseUrl: normalizeOssBaseUrl(input.baseUrl),
  99. };
  100. }
  101. const baseUrl = normalizeBaseUrl(input.baseUrl);
  102. if (!baseUrl) throw new Error('缺少 baseUrl');
  103. const config = parseJsonRecord(input.config);
  104. const extra: Record<string, JsonValue> = {};
  105. for (const [key, value] of Object.entries(config ?? {})) {
  106. if (EXTRA_ALLOWLIST.includes(key)) extra[key] = value as JsonValue;
  107. }
  108. return {
  109. id: PROVIDER_ID,
  110. name: input.name ?? model,
  111. baseUrl,
  112. model,
  113. apiKey: input.apiKey ?? null,
  114. envKey: DEFAULT_API_KEY_ENV,
  115. httpHeaders: readStringMap(input.headersJson),
  116. extra: Object.keys(extra).length ? extra : null,
  117. };
  118. }
  119. /** config 里被丢弃的非白名单键,用于页面提示 */
  120. export function describeDroppedKeys(input: ApplyProviderInput): string[] {
  121. const config = parseJsonRecord(input.config);
  122. return Object.keys(config ?? {}).filter((key) => !EXTRA_ALLOWLIST.includes(key));
  123. }
  124. /** Ollama 自 0.13.3 起提供非状态化 /v1/responses(流式 / 工具调用 / reasoning summaries) */
  125. const OLLAMA_RESPONSES_MIN_VERSION: readonly number[] = [0, 13, 3];
  126. interface ProbeAttempt {
  127. status: number | null;
  128. error: string | null;
  129. }
  130. async function postProbe(url: string, body: Record<string, unknown>, apiKey?: string | null): Promise<ProbeAttempt> {
  131. try {
  132. const response = await fetch(url, {
  133. method: 'POST',
  134. headers: {
  135. 'content-type': 'application/json',
  136. ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
  137. },
  138. body: JSON.stringify(body),
  139. signal: AbortSignal.timeout(8_000),
  140. });
  141. return { status: response.status, error: null };
  142. } catch (error) {
  143. return { status: null, error: error instanceof Error ? error.message : String(error) };
  144. }
  145. }
  146. /** Ollama 的 /api/version 挂在 API 根之外(去掉 /v1 尾段);非 Ollama 端点返回 null */
  147. async function probeOllamaVersion(baseUrl: string): Promise<string | null> {
  148. const origin = baseUrl.replace(/\/v\d+$/u, '');
  149. try {
  150. const response = await fetch(`${origin}/api/version`, { signal: AbortSignal.timeout(5_000) });
  151. if (response.status !== 200) return null;
  152. const parsed = (await response.json()) as { version?: unknown };
  153. return typeof parsed.version === 'string' && parsed.version ? parsed.version : null;
  154. } catch {
  155. return null;
  156. }
  157. }
  158. function parseVersion(raw: string): number[] | null {
  159. const match = raw.trim().match(/^(\d+)\.(\d+)(?:\.(\d+))?/u);
  160. if (!match) return null;
  161. return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
  162. }
  163. function versionLt(a: number[], b: readonly number[]): boolean {
  164. for (let index = 0; index < 3; index += 1) {
  165. if (a[index] !== b[index]) return a[index] < b[index];
  166. }
  167. return false;
  168. }
  169. function probeResult(partial: Partial<EndpointProbeResult> & { protocol: EndpointProtocol; detail: string }): EndpointProbeResult {
  170. return {
  171. responsesStatus: null,
  172. chatStatus: null,
  173. ollamaVersion: null,
  174. ollamaNeedsUpgrade: false,
  175. ...partial,
  176. };
  177. }
  178. /**
  179. * 探测端点协议能力(四态判定)。
  180. *
  181. * Codex 0.155+ 只会发 Responses API:responses → 直通;chat-only → 内置桥接转换;
  182. * 其余两种拒绝。判定顺序:
  183. * 1. POST /responses(用**真实模型名**——Ollama 对未拉取的模型返回 404,不能直接判端点不支持);
  184. * 2. 404/405 且是 Ollama → 查 /api/version 消歧:≥0.13.3 说明 404 只是模型没拉(判 responses),
  185. * 低于 0.13.3 直接判 chat-only(/chat/completions 必有);
  186. * 3. 否则再 POST /chat/completions:路由存在判 chat-only,也不存在判 unsupported。
  187. */
  188. export async function probeEndpoint(params: {
  189. baseUrl: string;
  190. modelId?: string | null;
  191. apiKey?: string | null;
  192. providerType?: string | null;
  193. builtinProvider?: string | null;
  194. }): Promise<EndpointProbeResult> {
  195. const baseUrl = params.baseUrl.replace(/\/+$/u, '');
  196. const model = params.modelId?.trim() || 'probe';
  197. const isOllama =
  198. params.builtinProvider === 'ollama' ||
  199. `${params.providerType ?? ''}`.trim().toUpperCase() === 'OLLAMA';
  200. const responses = await postProbe(
  201. `${baseUrl}/responses`,
  202. { model, input: 'ping', max_output_tokens: 16, stream: false },
  203. params.apiKey,
  204. );
  205. if (responses.status === null) {
  206. return probeResult({ protocol: 'unreachable', detail: `端点不可达:${responses.error ?? '网络错误'}` });
  207. }
  208. if (responses.status !== 404 && responses.status !== 405) {
  209. return probeResult({
  210. protocol: 'responses',
  211. responsesStatus: responses.status,
  212. detail: `端点已实现 Responses API(HTTP ${responses.status})`,
  213. });
  214. }
  215. if (isOllama) {
  216. const version = await probeOllamaVersion(baseUrl);
  217. if (version) {
  218. const parsed = parseVersion(version);
  219. if (parsed && !versionLt(parsed, OLLAMA_RESPONSES_MIN_VERSION)) {
  220. return probeResult({
  221. protocol: 'responses',
  222. responsesStatus: responses.status,
  223. ollamaVersion: version,
  224. detail: `Ollama ${version} 支持 Responses API;/responses 返回 404 通常只是模型「${model}」未拉取,请先 ollama pull ${model}`,
  225. });
  226. }
  227. return probeResult({
  228. protocol: 'chat-only',
  229. responsesStatus: responses.status,
  230. ollamaVersion: version,
  231. ollamaNeedsUpgrade: true,
  232. detail: `Ollama ${version} 低于 0.13.3,只提供 /chat/completions(建议升级 Ollama 以直连)`,
  233. });
  234. }
  235. }
  236. const chat = await postProbe(
  237. `${baseUrl}/chat/completions`,
  238. { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, stream: false },
  239. params.apiKey,
  240. );
  241. if (chat.status !== null && chat.status !== 404 && chat.status !== 405) {
  242. return probeResult({
  243. protocol: 'chat-only',
  244. responsesStatus: responses.status,
  245. chatStatus: chat.status,
  246. detail: `端点只提供 /chat/completions(/responses 返回 HTTP ${responses.status})`,
  247. });
  248. }
  249. return probeResult({
  250. protocol: 'unsupported',
  251. responsesStatus: responses.status,
  252. chatStatus: chat.status,
  253. detail: `端点 Responses 与 Chat Completions 均不存在(HTTP ${responses.status}/${chat.status ?? '网络错误'}),无法对接`,
  254. });
  255. }
  256. export async function readAppliedProvider(): Promise<AppliedProvider | null> {
  257. try {
  258. const raw = JSON.parse(await readFile(providerFile(), 'utf8')) as Partial<AppliedProvider>;
  259. return raw.model ? (raw as AppliedProvider) : null;
  260. } catch {
  261. return null;
  262. }
  263. }
  264. async function writeAppliedProvider(value: AppliedProvider | null): Promise<void> {
  265. await mkdir(getCodexDataDir(), { recursive: true });
  266. await writeFile(providerFile(), JSON.stringify(value ?? {}, null, 2), 'utf8');
  267. }
  268. /** 应用一条模型配置:先探端点,再重启子进程(api_key 走 env,换 key 必须重启) */
  269. export async function applyProvider(
  270. runtime: CodexRuntime,
  271. input: ApplyProviderInput & { modelRecordId?: string | number | null },
  272. ): Promise<AppliedProvider> {
  273. const spec = toProviderSpec(input);
  274. /** 桥接时落盘存上游真实地址(展示用);桥接 URL 每次应用临时分配,不落盘 */
  275. const displayBaseUrl = spec.baseUrl ?? null;
  276. let bridged = false;
  277. // 只要显式配了地址就探(含远端 Ollama):应用成功后才发现连不上,比这里直接报错更难排查。
  278. // 内置 provider 没配地址时不探(默认 localhost,可能压根没起本地服务)。
  279. if (spec.baseUrl) {
  280. const probe = await probeEndpoint({
  281. baseUrl: spec.baseUrl,
  282. modelId: spec.model,
  283. apiKey: spec.apiKey,
  284. providerType: input.providerType,
  285. builtinProvider: spec.builtinProvider,
  286. });
  287. if (probe.protocol !== 'responses' && probe.protocol !== 'chat-only') {
  288. throw new Error(probe.detail);
  289. }
  290. if (probe.protocol === 'chat-only') {
  291. // chat-only 端点:启动内置桥接,Codex 改连本地代理的 /v1/responses。
  292. // Ollama 桥接特例:内置 provider 的 buildArgs 只允许覆盖 base_url,
  293. // 不降级为自定义 provider 的话 Codex 会绕过桥接直连上游。
  294. const bridge = await chatBridge.start({ upstreamBaseUrl: spec.baseUrl, headers: spec.httpHeaders ?? null });
  295. spec.id = PROVIDER_ID;
  296. spec.builtinProvider = null;
  297. spec.baseUrl = bridge.url;
  298. bridged = true;
  299. }
  300. }
  301. // 直通(或未配地址的内置 provider):确保上一轮的桥接已停
  302. if (!bridged) await chatBridge.stop();
  303. try {
  304. await runtime.applyProvider(spec);
  305. } catch (error) {
  306. // 应用失败回滚桥接,避免留下一个指着旧上游的孤儿代理
  307. if (bridged) await chatBridge.stop();
  308. throw error;
  309. }
  310. const applied: AppliedProvider = {
  311. model: spec.model,
  312. modelRecordId: input.modelRecordId === undefined || input.modelRecordId === null
  313. ? null
  314. : String(input.modelRecordId),
  315. baseUrl: displayBaseUrl,
  316. providerId: spec.id,
  317. builtinProvider: spec.builtinProvider ?? null,
  318. name: spec.name ?? null,
  319. appliedAt: new Date().toISOString(),
  320. ...(bridged ? { bridged: true } : {}),
  321. };
  322. await writeAppliedProvider(applied);
  323. return applied;
  324. }
  325. export async function clearProvider(runtime: CodexRuntime): Promise<void> {
  326. await chatBridge.stop();
  327. await runtime.applyProvider(null);
  328. await writeAppliedProvider(null);
  329. }