providerService.ts 13 KB

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