providerService.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  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. export interface ProviderCompatibility {
  14. compatible: boolean;
  15. /** null 表示网络不可达,无法判定 */
  16. status: number | null;
  17. detail: string;
  18. }
  19. const EXTRA_ALLOWLIST: readonly string[] = [
  20. 'request_max_retries',
  21. 'stream_max_retries',
  22. 'stream_idle_timeout_ms',
  23. 'query_params',
  24. ];
  25. function providerFile(): string {
  26. return join(getCodexDataDir(), 'provider.json');
  27. }
  28. /** Codex 要求 base_url 是 API 根(形如 https://api.openai.com/v1),它自己再拼 /responses */
  29. export function normalizeBaseUrl(raw: string | null | undefined): string | null {
  30. const value = (raw ?? '').trim().replace(/\/+$/u, '');
  31. return value || null;
  32. }
  33. /** 给页面的非阻断提示;返回 null 表示没问题 */
  34. export function baseUrlHint(baseUrl: string | null): string | null {
  35. if (!baseUrl) return '未配置 base_url';
  36. if (!/^https?:\/\//u.test(baseUrl)) return 'base_url 必须以 http:// 或 https:// 开头';
  37. if (!/\/v\d+$/u.test(baseUrl)) {
  38. return 'base_url 通常应写到版本段(如 .../v1),否则 Codex 拼出的 /responses 可能 404';
  39. }
  40. return null;
  41. }
  42. function parseJsonRecord(raw: unknown): Record<string, unknown> | null {
  43. if (!raw) return null;
  44. if (typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>;
  45. if (typeof raw !== 'string') return null;
  46. try {
  47. const parsed = JSON.parse(raw) as unknown;
  48. return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
  49. ? (parsed as Record<string, unknown>)
  50. : null;
  51. } catch {
  52. return null;
  53. }
  54. }
  55. function readStringMap(raw: unknown): Record<string, string> | null {
  56. const record = parseJsonRecord(raw);
  57. if (!record) return null;
  58. const result: Record<string, string> = {};
  59. for (const [key, value] of Object.entries(record)) {
  60. if (typeof value === 'string' && value) result[key] = value;
  61. }
  62. return Object.keys(result).length ? result : null;
  63. }
  64. /** Codex 内置 ollama/lmstudio 走 OpenAI 兼容根(.../v1),而库里常只存到 host:port */
  65. function normalizeOssBaseUrl(raw: string | null | undefined): string | null {
  66. const base = normalizeBaseUrl(raw);
  67. if (!base) return null;
  68. return /\/v\d+$/u.test(base) ? base : `${base}/v1`;
  69. }
  70. /** OLLAMA 用 Codex 内置 provider(Responses API,默认端口 11434),不需要 api key */
  71. function resolveBuiltinProvider(input: ApplyProviderInput): string | null {
  72. if (input.builtinProvider) return input.builtinProvider;
  73. const type = `${input.providerType ?? ''}`.trim().toUpperCase();
  74. if (type === 'OLLAMA') return 'ollama';
  75. if (type === 'LMSTUDIO') return 'lmstudio';
  76. return null;
  77. }
  78. export function toProviderSpec(input: ApplyProviderInput): CodexProviderSpec {
  79. const model = `${input.modelId ?? ''}`.trim();
  80. if (!model) throw new Error('缺少 modelId');
  81. const builtinProvider = resolveBuiltinProvider(input);
  82. if (builtinProvider) {
  83. return {
  84. id: null,
  85. builtinProvider,
  86. model,
  87. name: input.name ?? `${builtinProvider} 本地模型`,
  88. // 内置 provider 的默认地址是 localhost,必须把库里的真实地址带上,否则连不到远端 Ollama
  89. baseUrl: normalizeOssBaseUrl(input.baseUrl),
  90. };
  91. }
  92. const baseUrl = normalizeBaseUrl(input.baseUrl);
  93. if (!baseUrl) throw new Error('缺少 baseUrl');
  94. const config = parseJsonRecord(input.config);
  95. const extra: Record<string, JsonValue> = {};
  96. for (const [key, value] of Object.entries(config ?? {})) {
  97. if (EXTRA_ALLOWLIST.includes(key)) extra[key] = value as JsonValue;
  98. }
  99. return {
  100. id: PROVIDER_ID,
  101. name: input.name ?? model,
  102. baseUrl,
  103. model,
  104. apiKey: input.apiKey ?? null,
  105. envKey: DEFAULT_API_KEY_ENV,
  106. httpHeaders: readStringMap(input.headersJson),
  107. extra: Object.keys(extra).length ? extra : null,
  108. };
  109. }
  110. /** config 里被丢弃的非白名单键,用于页面提示 */
  111. export function describeDroppedKeys(input: ApplyProviderInput): string[] {
  112. const config = parseJsonRecord(input.config);
  113. return Object.keys(config ?? {}).filter((key) => !EXTRA_ALLOWLIST.includes(key));
  114. }
  115. /**
  116. * 探测端点是否实现了 Responses API。
  117. * 404/405 = 未实现(Codex 0.155 起已下线 wire_api="chat",chat-only 端点用不了);
  118. * 401/403/400/422/2xx = 路由存在,判为兼容。
  119. */
  120. export async function probeResponsesEndpoint(
  121. baseUrl: string,
  122. apiKey?: string | null,
  123. ): Promise<ProviderCompatibility> {
  124. const url = `${baseUrl.replace(/\/+$/u, '')}/responses`;
  125. let status: number;
  126. try {
  127. const response = await fetch(url, {
  128. method: 'POST',
  129. headers: {
  130. 'content-type': 'application/json',
  131. ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
  132. },
  133. body: JSON.stringify({ model: 'probe', input: 'probe' }),
  134. signal: AbortSignal.timeout(8_000),
  135. });
  136. status = response.status;
  137. } catch (error) {
  138. return {
  139. compatible: false,
  140. status: null,
  141. detail: `端点不可达:${error instanceof Error ? error.message : String(error)}`,
  142. };
  143. }
  144. if (status === 404 || status === 405) {
  145. return {
  146. compatible: false,
  147. status,
  148. detail: `该端点未实现 Responses API(HTTP ${status})。Codex 0.155 起已下线 wire_api="chat",只提供 /chat/completions 的服务需要额外网关转换。`,
  149. };
  150. }
  151. return { compatible: true, status, detail: `端点已实现 Responses API(HTTP ${status})` };
  152. }
  153. export async function readAppliedProvider(): Promise<AppliedProvider | null> {
  154. try {
  155. const raw = JSON.parse(await readFile(providerFile(), 'utf8')) as Partial<AppliedProvider>;
  156. return raw.model ? (raw as AppliedProvider) : null;
  157. } catch {
  158. return null;
  159. }
  160. }
  161. async function writeAppliedProvider(value: AppliedProvider | null): Promise<void> {
  162. await mkdir(getCodexDataDir(), { recursive: true });
  163. await writeFile(providerFile(), JSON.stringify(value ?? {}, null, 2), 'utf8');
  164. }
  165. /** 应用一条模型配置:先探端点,再重启子进程(api_key 走 env,换 key 必须重启) */
  166. export async function applyProvider(
  167. runtime: CodexRuntime,
  168. input: ApplyProviderInput & { modelRecordId?: string | number | null },
  169. ): Promise<AppliedProvider> {
  170. const spec = toProviderSpec(input);
  171. // 只要显式配了地址就探(含远端 Ollama):应用成功后才发现连不上,比这里直接报错更难排查。
  172. // 内置 provider 没配地址时不探(默认 localhost,可能压根没起本地服务)。
  173. if (spec.baseUrl) {
  174. const probe = await probeResponsesEndpoint(spec.baseUrl, spec.apiKey);
  175. if (!probe.compatible) throw new Error(probe.detail);
  176. }
  177. await runtime.applyProvider(spec);
  178. const applied: AppliedProvider = {
  179. model: spec.model,
  180. modelRecordId: input.modelRecordId === undefined || input.modelRecordId === null
  181. ? null
  182. : String(input.modelRecordId),
  183. baseUrl: spec.baseUrl ?? null,
  184. providerId: spec.id,
  185. builtinProvider: spec.builtinProvider ?? null,
  186. name: spec.name ?? null,
  187. appliedAt: new Date().toISOString(),
  188. };
  189. await writeAppliedProvider(applied);
  190. return applied;
  191. }
  192. export async function clearProvider(runtime: CodexRuntime): Promise<void> {
  193. await runtime.applyProvider(null);
  194. await writeAppliedProvider(null);
  195. }