providerService.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  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. /**
  74. * 后端「模型管理」的一条记录一律翻译成自定义 provider:Codex 0.155.1 把 ollama / lmstudio
  75. * 等内置 id 列为保留字,覆盖 model_providers.<内置 id> 会让 app-server 启动即退出。
  76. * 地址缺失就是配置缺失,直接报错让页面提示,不做 localhost 兜底。
  77. */
  78. export function toProviderSpec(input: ApplyProviderInput): CodexProviderSpec {
  79. const model = `${input.modelId ?? ''}`.trim();
  80. if (!model) throw new Error('缺少 modelId');
  81. const baseUrl = normalizeBaseUrl(input.baseUrl);
  82. if (!baseUrl) {
  83. throw new Error(`模型「${input.name || model}」没有配置 base_url,请到「模型管理」补全(形如 https://host:port/v1)`);
  84. }
  85. const config = parseJsonRecord(input.config);
  86. const extra: Record<string, JsonValue> = {};
  87. for (const [key, value] of Object.entries(config ?? {})) {
  88. if (EXTRA_ALLOWLIST.includes(key)) extra[key] = value as JsonValue;
  89. }
  90. return {
  91. id: PROVIDER_ID,
  92. name: input.name ?? model,
  93. baseUrl,
  94. model,
  95. apiKey: input.apiKey ?? null,
  96. envKey: DEFAULT_API_KEY_ENV,
  97. httpHeaders: readStringMap(input.headersJson),
  98. extra: Object.keys(extra).length ? extra : null,
  99. };
  100. }
  101. /** config 里被丢弃的非白名单键,用于页面提示 */
  102. export function describeDroppedKeys(input: ApplyProviderInput): string[] {
  103. const config = parseJsonRecord(input.config);
  104. return Object.keys(config ?? {}).filter((key) => !EXTRA_ALLOWLIST.includes(key));
  105. }
  106. /** Ollama 自 0.13.3 起提供非状态化 /v1/responses(流式 / 工具调用 / reasoning summaries) */
  107. const OLLAMA_RESPONSES_MIN_VERSION: readonly number[] = [0, 13, 3];
  108. interface ProbeAttempt {
  109. status: number | null;
  110. error: string | null;
  111. }
  112. async function postProbe(url: string, body: Record<string, unknown>, apiKey?: string | null): Promise<ProbeAttempt> {
  113. try {
  114. const response = await fetch(url, {
  115. method: 'POST',
  116. headers: {
  117. 'content-type': 'application/json',
  118. ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
  119. },
  120. body: JSON.stringify(body),
  121. signal: AbortSignal.timeout(8_000),
  122. });
  123. return { status: response.status, error: null };
  124. } catch (error) {
  125. return { status: null, error: error instanceof Error ? error.message : String(error) };
  126. }
  127. }
  128. /** Ollama 的 /api/version 挂在 API 根之外(去掉 /v1 尾段);非 Ollama 端点返回 null */
  129. async function probeOllamaVersion(baseUrl: string): Promise<string | null> {
  130. const origin = baseUrl.replace(/\/v\d+$/u, '');
  131. try {
  132. const response = await fetch(`${origin}/api/version`, { signal: AbortSignal.timeout(5_000) });
  133. if (response.status !== 200) return null;
  134. const parsed = (await response.json()) as { version?: unknown };
  135. return typeof parsed.version === 'string' && parsed.version ? parsed.version : null;
  136. } catch {
  137. return null;
  138. }
  139. }
  140. function parseVersion(raw: string): number[] | null {
  141. const match = raw.trim().match(/^(\d+)\.(\d+)(?:\.(\d+))?/u);
  142. if (!match) return null;
  143. return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
  144. }
  145. function versionLt(a: number[], b: readonly number[]): boolean {
  146. for (let index = 0; index < 3; index += 1) {
  147. if (a[index] !== b[index]) return a[index] < b[index];
  148. }
  149. return false;
  150. }
  151. function probeResult(partial: Partial<EndpointProbeResult> & { protocol: EndpointProtocol; detail: string }): EndpointProbeResult {
  152. return {
  153. responsesStatus: null,
  154. chatStatus: null,
  155. ollamaVersion: null,
  156. ollamaNeedsUpgrade: false,
  157. ...partial,
  158. };
  159. }
  160. /**
  161. * 探测端点协议能力(四态判定)。
  162. *
  163. * Codex 0.155+ 只会发 Responses API:responses → 直通;chat-only → 内置桥接转换;
  164. * 其余两种拒绝。判定顺序:
  165. * 1. POST /responses(用**真实模型名**——Ollama 对未拉取的模型返回 404,不能直接判端点不支持);
  166. * 2. 404/405 且是 Ollama → 查 /api/version 消歧:≥0.13.3 说明 404 只是模型没拉(判 responses),
  167. * 低于 0.13.3 直接判 chat-only(/chat/completions 必有);
  168. * 3. 否则再 POST /chat/completions:路由存在判 chat-only,也不存在判 unsupported。
  169. */
  170. export async function probeEndpoint(params: {
  171. baseUrl: string;
  172. modelId?: string | null;
  173. apiKey?: string | null;
  174. providerType?: string | null;
  175. }): Promise<EndpointProbeResult> {
  176. const baseUrl = params.baseUrl.replace(/\/+$/u, '');
  177. const model = params.modelId?.trim() || 'probe';
  178. const isOllama = `${params.providerType ?? ''}`.trim().toUpperCase() === 'OLLAMA';
  179. const responses = await postProbe(
  180. `${baseUrl}/responses`,
  181. { model, input: 'ping', max_output_tokens: 16, stream: false },
  182. params.apiKey,
  183. );
  184. if (responses.status === null) {
  185. return probeResult({ protocol: 'unreachable', detail: `端点不可达:${responses.error ?? '网络错误'}` });
  186. }
  187. if (responses.status !== 404 && responses.status !== 405) {
  188. return probeResult({
  189. protocol: 'responses',
  190. responsesStatus: responses.status,
  191. detail: `端点已实现 Responses API(HTTP ${responses.status})`,
  192. });
  193. }
  194. if (isOllama) {
  195. const version = await probeOllamaVersion(baseUrl);
  196. if (version) {
  197. const parsed = parseVersion(version);
  198. if (parsed && !versionLt(parsed, OLLAMA_RESPONSES_MIN_VERSION)) {
  199. return probeResult({
  200. protocol: 'responses',
  201. responsesStatus: responses.status,
  202. ollamaVersion: version,
  203. detail: `Ollama ${version} 支持 Responses API;/responses 返回 404 通常只是模型「${model}」未拉取,请先 ollama pull ${model}`,
  204. });
  205. }
  206. return probeResult({
  207. protocol: 'chat-only',
  208. responsesStatus: responses.status,
  209. ollamaVersion: version,
  210. ollamaNeedsUpgrade: true,
  211. detail: `Ollama ${version} 低于 0.13.3,只提供 /chat/completions(建议升级 Ollama 以直连)`,
  212. });
  213. }
  214. }
  215. const chat = await postProbe(
  216. `${baseUrl}/chat/completions`,
  217. { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, stream: false },
  218. params.apiKey,
  219. );
  220. if (chat.status !== null && chat.status !== 404 && chat.status !== 405) {
  221. return probeResult({
  222. protocol: 'chat-only',
  223. responsesStatus: responses.status,
  224. chatStatus: chat.status,
  225. detail: `端点只提供 /chat/completions(/responses 返回 HTTP ${responses.status})`,
  226. });
  227. }
  228. return probeResult({
  229. protocol: 'unsupported',
  230. responsesStatus: responses.status,
  231. chatStatus: chat.status,
  232. detail: `端点 Responses 与 Chat Completions 均不存在(HTTP ${responses.status}/${chat.status ?? '网络错误'}),无法对接`,
  233. });
  234. }
  235. export async function readAppliedProvider(): Promise<AppliedProvider | null> {
  236. try {
  237. const raw = JSON.parse(await readFile(providerFile(), 'utf8')) as Partial<AppliedProvider>;
  238. return raw.model ? (raw as AppliedProvider) : null;
  239. } catch {
  240. return null;
  241. }
  242. }
  243. async function writeAppliedProvider(value: AppliedProvider | null): Promise<void> {
  244. await mkdir(getCodexDataDir(), { recursive: true });
  245. await writeFile(providerFile(), JSON.stringify(value ?? {}, null, 2), 'utf8');
  246. }
  247. /** 应用一条模型配置:先探端点,再重启子进程(api_key 走 env,换 key 必须重启) */
  248. export async function applyProvider(
  249. runtime: CodexRuntime,
  250. input: ApplyProviderInput & { modelRecordId?: string | number | null },
  251. ): Promise<AppliedProvider> {
  252. const spec = toProviderSpec(input);
  253. /** 桥接时落盘存上游真实地址(展示用);桥接 URL 每次应用临时分配,不落盘 */
  254. const displayBaseUrl = spec.baseUrl;
  255. let bridged = false;
  256. // 一定要先探端点:应用成功后才发现连不上,比这里直接报错更难排查
  257. const probe = await probeEndpoint({
  258. baseUrl: spec.baseUrl,
  259. modelId: spec.model,
  260. apiKey: spec.apiKey,
  261. providerType: input.providerType,
  262. });
  263. if (probe.protocol !== 'responses' && probe.protocol !== 'chat-only') {
  264. throw new Error(probe.detail);
  265. }
  266. if (probe.protocol === 'chat-only') {
  267. // chat-only 端点:启动内置桥接,Codex 改连本地代理的 /v1/responses
  268. const bridge = await chatBridge.start({ upstreamBaseUrl: spec.baseUrl, headers: spec.httpHeaders ?? null });
  269. spec.baseUrl = bridge.url;
  270. bridged = true;
  271. }
  272. // 直通:确保上一轮的桥接已停
  273. if (!bridged) await chatBridge.stop();
  274. try {
  275. await runtime.applyProvider(spec);
  276. } catch (error) {
  277. // 应用失败回滚桥接,避免留下一个指着旧上游的孤儿代理
  278. if (bridged) await chatBridge.stop();
  279. throw error;
  280. }
  281. const applied: AppliedProvider = {
  282. model: spec.model,
  283. modelRecordId: input.modelRecordId === undefined || input.modelRecordId === null
  284. ? null
  285. : String(input.modelRecordId),
  286. baseUrl: displayBaseUrl,
  287. providerId: spec.id,
  288. name: spec.name ?? null,
  289. appliedAt: new Date().toISOString(),
  290. ...(bridged ? { bridged: true } : {}),
  291. };
  292. await writeAppliedProvider(applied);
  293. return applied;
  294. }
  295. export async function clearProvider(runtime: CodexRuntime): Promise<void> {
  296. await chatBridge.stop();
  297. await runtime.applyProvider(null);
  298. await writeAppliedProvider(null);
  299. }