config-loader.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import { readFile, access } from "node:fs/promises";
  2. import { homedir } from "node:os";
  3. import { join } from "node:path";
  4. import { ProjectConfigSchema, type ProjectConfig } from "../models/project.js";
  5. import { getServiceApiKey } from "../llm/secrets.js";
  6. import { resolveServicePreset, resolveServiceProviderFamily } from "../llm/service-presets.js";
  7. import { getEndpoint } from "../llm/providers/index.js";
  8. export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
  9. export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
  10. interface ServiceConfigEntry {
  11. readonly service: string;
  12. readonly name?: string;
  13. readonly baseUrl?: string;
  14. readonly temperature?: number;
  15. readonly maxTokens?: number;
  16. readonly apiFormat?: "chat" | "responses";
  17. readonly stream?: boolean;
  18. }
  19. type LLMConfigSource = "env" | "studio";
  20. export function isApiKeyOptionalForEndpoint(params: {
  21. readonly provider?: string | undefined;
  22. readonly baseUrl?: string | undefined;
  23. }): boolean {
  24. if (params.provider === "anthropic") {
  25. return false;
  26. }
  27. if (!params.baseUrl) {
  28. return false;
  29. }
  30. try {
  31. const url = new URL(params.baseUrl);
  32. const hostname = url.hostname.toLowerCase();
  33. return (
  34. hostname === "localhost"
  35. || hostname === "127.0.0.1"
  36. || hostname === "::1"
  37. || hostname === "0.0.0.0"
  38. || hostname === "host.docker.internal"
  39. || hostname.endsWith(".local")
  40. || isPrivateIpv4(hostname)
  41. );
  42. } catch {
  43. return false;
  44. }
  45. }
  46. /**
  47. * Load project config from inkos.json with .env overrides.
  48. * Shared by CLI and Studio — single source of truth for config loading.
  49. */
  50. export async function loadProjectConfig(
  51. root: string,
  52. options?: { readonly requireApiKey?: boolean },
  53. ): Promise<ProjectConfig> {
  54. // Load global ~/.inkos/.env first, then project .env overrides
  55. const { config: loadEnv } = await import("dotenv");
  56. loadEnv({ path: GLOBAL_ENV_PATH });
  57. loadEnv({ path: join(root, ".env"), override: true });
  58. const configPath = join(root, "inkos.json");
  59. try {
  60. await access(configPath);
  61. } catch {
  62. throw new Error(
  63. `inkos.json not found in ${root}.\nMake sure you are inside an InkOS project directory (cd into the project created by 'inkos init').`,
  64. );
  65. }
  66. const raw = await readFile(configPath, "utf-8");
  67. let config: Record<string, unknown>;
  68. try {
  69. config = JSON.parse(raw);
  70. } catch {
  71. throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`);
  72. }
  73. // llm.configSource controls whether INKOS_LLM_* env vars override project config
  74. const env = process.env;
  75. const llm = (config.llm ?? {}) as Record<string, unknown>;
  76. const configSource = resolveConfigSource(llm.configSource);
  77. llm.configSource = configSource;
  78. const normalizedServices = normalizeServiceEntries(llm.services);
  79. if (normalizedServices.length > 0) {
  80. llm.services = normalizedServices;
  81. const selectedEntry = selectServiceEntry(normalizedServices, llm.service);
  82. const selectedServiceId = selectedEntry ? serviceEntryKey(selectedEntry) : undefined;
  83. if (selectedEntry) {
  84. llm.service = selectedEntry.service;
  85. if (configSource === "studio") {
  86. llm.model = resolveStudioServiceModel(
  87. selectedEntry,
  88. typeof llm.model === "string" ? llm.model : undefined,
  89. typeof llm.defaultModel === "string" ? llm.defaultModel : undefined,
  90. );
  91. } else if (!(typeof llm.model === "string" && llm.model.length > 0) && typeof llm.defaultModel === "string" && llm.defaultModel.length > 0) {
  92. llm.model = llm.defaultModel;
  93. }
  94. if (configSource === "studio") {
  95. llm.baseUrl = selectedEntry.baseUrl ?? resolveServicePreset(selectedEntry.service)?.baseUrl ?? "";
  96. } else if (!(typeof llm.baseUrl === "string" && llm.baseUrl.length > 0)) {
  97. llm.baseUrl = selectedEntry.baseUrl ?? resolveServicePreset(selectedEntry.service)?.baseUrl ?? "";
  98. }
  99. if (configSource === "studio") {
  100. llm.provider = deriveProviderFromService(selectedEntry.service);
  101. } else if (!(typeof llm.provider === "string" && llm.provider.length > 0)) {
  102. llm.provider = deriveProviderFromService(selectedEntry.service);
  103. }
  104. if (llm.temperature === undefined && selectedEntry.temperature !== undefined) {
  105. llm.temperature = selectedEntry.temperature;
  106. }
  107. if (llm.maxTokens === undefined && selectedEntry.maxTokens !== undefined) {
  108. llm.maxTokens = selectedEntry.maxTokens;
  109. }
  110. if (selectedEntry.apiFormat !== undefined) {
  111. llm.apiFormat = selectedEntry.apiFormat;
  112. }
  113. if (selectedEntry.stream !== undefined) {
  114. llm.stream = selectedEntry.stream;
  115. }
  116. if (selectedServiceId && (configSource !== "env" || !env.INKOS_LLM_API_KEY)) {
  117. const secretApiKey = await getServiceApiKey(root, selectedServiceId);
  118. if (secretApiKey) {
  119. llm.apiKey = secretApiKey;
  120. }
  121. }
  122. }
  123. }
  124. const shouldBootstrapStudioFallbackToEnv = configSource === "studio"
  125. && normalizedServices.length === 0
  126. && !(typeof llm.apiKey === "string" && llm.apiKey.length > 0)
  127. && !(typeof llm.model === "string" && llm.model.length > 0)
  128. && !(typeof llm.baseUrl === "string" && llm.baseUrl.length > 0);
  129. if (configSource === "env" || shouldBootstrapStudioFallbackToEnv) {
  130. if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
  131. if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
  132. if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
  133. if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
  134. // C3 (v2.0.0): 删除 INKOS_LLM_MAX_TOKENS 读取 — maxTokens 已由 providers bank 接管。
  135. if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
  136. }
  137. // Extra params from env: INKOS_LLM_EXTRA_<key>=<value>
  138. const extraFromEnv: Record<string, unknown> = {};
  139. if (configSource === "env" || shouldBootstrapStudioFallbackToEnv) {
  140. for (const [key, value] of Object.entries(env)) {
  141. if (key.startsWith("INKOS_LLM_EXTRA_") && value) {
  142. const paramName = key.slice("INKOS_LLM_EXTRA_".length);
  143. // Auto-coerce: numbers, booleans, JSON objects
  144. if (/^\d+(\.\d+)?$/.test(value)) extraFromEnv[paramName] = parseFloat(value);
  145. else if (value === "true") extraFromEnv[paramName] = true;
  146. else if (value === "false") extraFromEnv[paramName] = false;
  147. else if (value.startsWith("{") || value.startsWith("[")) {
  148. try { extraFromEnv[paramName] = JSON.parse(value); } catch { extraFromEnv[paramName] = value; }
  149. }
  150. else extraFromEnv[paramName] = value;
  151. }
  152. }
  153. }
  154. if (Object.keys(extraFromEnv).length > 0) {
  155. llm.extra = { ...(llm.extra as Record<string, unknown> ?? {}), ...extraFromEnv };
  156. }
  157. if ((configSource === "env" || shouldBootstrapStudioFallbackToEnv) && env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
  158. config.llm = llm;
  159. // Global language override
  160. if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
  161. // API key ONLY from env — never stored in inkos.json
  162. const apiKey = (configSource === "env" || shouldBootstrapStudioFallbackToEnv)
  163. ? env.INKOS_LLM_API_KEY || (typeof llm.apiKey === "string" ? llm.apiKey : "")
  164. : (typeof llm.apiKey === "string" ? llm.apiKey : "");
  165. const provider = typeof llm.provider === "string" ? llm.provider : undefined;
  166. const baseUrl = typeof llm.baseUrl === "string" ? llm.baseUrl : undefined;
  167. const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl });
  168. if (!apiKey && options?.requireApiKey !== false && !apiKeyOptional) {
  169. throw new Error(
  170. "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
  171. );
  172. }
  173. if (options?.requireApiKey === false) {
  174. llm.provider = typeof llm.provider === "string" && llm.provider.length > 0
  175. ? llm.provider
  176. : "openai";
  177. llm.baseUrl = typeof llm.baseUrl === "string" && llm.baseUrl.length > 0
  178. ? llm.baseUrl
  179. : "https://example.invalid/v1";
  180. llm.model = typeof llm.model === "string" && llm.model.length > 0
  181. ? llm.model
  182. : "noop-model";
  183. }
  184. llm.apiKey = apiKey ?? "";
  185. return ProjectConfigSchema.parse(config);
  186. }
  187. function resolveConfigSource(value: unknown): LLMConfigSource {
  188. return value === "studio" ? "studio" : "env";
  189. }
  190. function normalizeServiceEntries(raw: unknown): ServiceConfigEntry[] {
  191. if (Array.isArray(raw)) {
  192. return raw
  193. .filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
  194. .map((entry) => ({
  195. service: typeof entry.service === "string" && entry.service.length > 0 ? entry.service : "custom",
  196. ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
  197. ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
  198. ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
  199. ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
  200. ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
  201. ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
  202. }));
  203. }
  204. if (raw && typeof raw === "object") {
  205. return Object.entries(raw as Record<string, unknown>)
  206. .filter(([, value]) => value && typeof value === "object")
  207. .map(([serviceId, value]) => normalizeServiceEntryFromPatch(serviceId, value as Record<string, unknown>));
  208. }
  209. return [];
  210. }
  211. function normalizeServiceEntryFromPatch(serviceId: string, value: Record<string, unknown>): ServiceConfigEntry {
  212. if (serviceId.startsWith("custom:")) {
  213. return {
  214. service: "custom",
  215. name: decodeURIComponent(serviceId.slice("custom:".length)),
  216. ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
  217. ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
  218. ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
  219. ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
  220. ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
  221. };
  222. }
  223. if (serviceId === "custom") {
  224. return {
  225. service: "custom",
  226. ...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
  227. ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
  228. ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
  229. ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
  230. ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
  231. ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
  232. };
  233. }
  234. return {
  235. service: serviceId,
  236. ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
  237. ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
  238. ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
  239. ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
  240. };
  241. }
  242. function selectServiceEntry(
  243. services: readonly ServiceConfigEntry[],
  244. configuredService: unknown,
  245. ): ServiceConfigEntry | undefined {
  246. if (typeof configuredService === "string" && configuredService.length > 0) {
  247. return services.find((entry) => entry.service === configuredService || serviceEntryKey(entry) === configuredService) ?? services[0];
  248. }
  249. return services[0];
  250. }
  251. function resolveStudioServiceModel(
  252. entry: ServiceConfigEntry,
  253. currentModel: string | undefined,
  254. defaultModel: string | undefined,
  255. ): string {
  256. if (entry.service === "custom") {
  257. return defaultModel || currentModel || "noop-model";
  258. }
  259. const endpoint = getEndpoint(entry.service);
  260. const candidate = [defaultModel, currentModel]
  261. .find((model): model is string => Boolean(
  262. model
  263. && endpoint?.models.some((knownModel) => knownModel.enabled !== false && knownModel.id.toLowerCase() === model.toLowerCase()),
  264. ));
  265. if (candidate) return candidate;
  266. return endpoint?.checkModel
  267. ?? endpoint?.models.find((model) => model.enabled !== false)?.id
  268. ?? defaultModel
  269. ?? currentModel
  270. ?? "noop-model";
  271. }
  272. function serviceEntryKey(entry: ServiceConfigEntry): string {
  273. return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
  274. }
  275. function deriveProviderFromService(service: string): "anthropic" | "openai" | "custom" {
  276. if (service === "custom") return "custom";
  277. return resolveServiceProviderFamily(service) ?? "openai";
  278. }
  279. function isPrivateIpv4(hostname: string): boolean {
  280. const parts = hostname.split(".").map((segment) => Number.parseInt(segment, 10));
  281. if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
  282. return false;
  283. }
  284. if (parts[0] === 10) return true;
  285. if (parts[0] === 192 && parts[1] === 168) return true;
  286. if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
  287. return false;
  288. }