config-loader.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { afterEach, describe, expect, it } from "vitest";
  5. import { loadProjectConfig } from "../utils/config-loader.js";
  6. const ENV_KEYS = [
  7. "INKOS_LLM_SERVICE",
  8. "INKOS_LLM_PROVIDER",
  9. "INKOS_LLM_BASE_URL",
  10. "INKOS_LLM_MODEL",
  11. "INKOS_LLM_API_KEY",
  12. "INKOS_LLM_TEMPERATURE",
  13. "INKOS_LLM_THINKING_BUDGET",
  14. "INKOS_LLM_API_FORMAT",
  15. "INKOS_LLM_STREAM",
  16. "INKOS_LLM_EXTRA_top_p",
  17. "INKOS_DEFAULT_LANGUAGE",
  18. ] as const;
  19. describe("loadProjectConfig local provider auth", () => {
  20. let root = "";
  21. const previousEnv = new Map<string, string | undefined>();
  22. afterEach(async () => {
  23. for (const key of ENV_KEYS) {
  24. const previous = previousEnv.get(key);
  25. if (previous === undefined) delete process.env[key];
  26. else process.env[key] = previous;
  27. }
  28. previousEnv.clear();
  29. if (root) {
  30. await rm(root, { recursive: true, force: true });
  31. root = "";
  32. }
  33. });
  34. it("allows missing API keys for localhost OpenAI-compatible endpoints", async () => {
  35. root = await mkdtemp(join(tmpdir(), "inkos-config-loader-local-"));
  36. for (const key of ENV_KEYS) {
  37. previousEnv.set(key, process.env[key]);
  38. process.env[key] = "";
  39. }
  40. await writeFile(join(root, "inkos.json"), JSON.stringify({
  41. name: "local-project",
  42. version: "0.1.0",
  43. llm: {
  44. provider: "openai",
  45. baseUrl: "http://127.0.0.1:11434/v1",
  46. model: "gpt-oss:20b",
  47. },
  48. }, null, 2), "utf-8");
  49. await writeFile(join(root, ".env"), "", "utf-8");
  50. const config = await loadProjectConfig(root);
  51. expect(config.llm.baseUrl).toBe("http://127.0.0.1:11434/v1");
  52. expect(config.llm.model).toBe("gpt-oss:20b");
  53. expect(config.llm.apiKey).toBe("");
  54. });
  55. it("still requires API keys for remote hosted endpoints", async () => {
  56. root = await mkdtemp(join(tmpdir(), "inkos-config-loader-remote-"));
  57. for (const key of ENV_KEYS) {
  58. previousEnv.set(key, process.env[key]);
  59. process.env[key] = "";
  60. }
  61. await writeFile(join(root, "inkos.json"), JSON.stringify({
  62. name: "remote-project",
  63. version: "0.1.0",
  64. llm: {
  65. provider: "openai",
  66. baseUrl: "https://api.openai.com/v1",
  67. model: "gpt-5.4",
  68. },
  69. }, null, 2), "utf-8");
  70. await writeFile(join(root, ".env"), "", "utf-8");
  71. await expect(loadProjectConfig(root)).rejects.toThrow(/INKOS_LLM_API_KEY not set/i);
  72. });
  73. it("loads service-based config using defaultModel and project secrets", async () => {
  74. root = await mkdtemp(join(tmpdir(), "inkos-config-loader-services-"));
  75. for (const key of ENV_KEYS) {
  76. previousEnv.set(key, process.env[key]);
  77. process.env[key] = "";
  78. }
  79. await writeFile(join(root, "inkos.json"), JSON.stringify({
  80. name: "service-project",
  81. version: "0.1.0",
  82. language: "zh",
  83. llm: {
  84. services: [
  85. { service: "moonshot", temperature: 1, maxTokens: 4096 },
  86. ],
  87. defaultModel: "kimi-k2.5",
  88. },
  89. notify: [],
  90. }, null, 2), "utf-8");
  91. await mkdir(join(root, ".inkos"), { recursive: true });
  92. await writeFile(
  93. join(root, ".inkos", "secrets.json"),
  94. JSON.stringify({ services: { moonshot: { apiKey: "sk-moon" } } }, null, 2),
  95. "utf-8",
  96. );
  97. const config = await loadProjectConfig(root, { consumer: "studio" });
  98. expect(config.llm.service).toBe("moonshot");
  99. expect(config.llm.provider).toBe("openai");
  100. expect(config.llm.baseUrl).toBe("https://api.moonshot.cn/v1");
  101. expect(config.llm.model).toBe("kimi-k2.5");
  102. expect(config.llm.apiKey).toBe("sk-moon");
  103. expect(config.llm.temperature).toBe(1);
  104. });
  105. it("derives provider/baseUrl from the MiniMax preset single source of truth", async () => {
  106. root = await mkdtemp(join(tmpdir(), "inkos-config-loader-minimax-"));
  107. for (const key of ENV_KEYS) {
  108. previousEnv.set(key, process.env[key]);
  109. process.env[key] = "";
  110. }
  111. await writeFile(join(root, "inkos.json"), JSON.stringify({
  112. name: "minimax-project",
  113. version: "0.1.0",
  114. language: "zh",
  115. llm: {
  116. services: [
  117. { service: "minimax", temperature: 0.9, maxTokens: 4096 },
  118. ],
  119. defaultModel: "MiniMax-M2.7",
  120. },
  121. notify: [],
  122. }, null, 2), "utf-8");
  123. await mkdir(join(root, ".inkos"), { recursive: true });
  124. await writeFile(
  125. join(root, ".inkos", "secrets.json"),
  126. JSON.stringify({ services: { minimax: { apiKey: "sk-minimax" } } }, null, 2),
  127. "utf-8",
  128. );
  129. const config = await loadProjectConfig(root);
  130. expect(config.llm.service).toBe("minimax");
  131. expect(config.llm.provider).toBe("anthropic");
  132. expect(config.llm.baseUrl).toBe("https://api.minimaxi.com/anthropic");
  133. expect(config.llm.model).toBe("MiniMax-M2.7");
  134. expect(config.llm.apiKey).toBe("sk-minimax");
  135. });
  136. it("loads custom service config using custom secret key and entry baseUrl", async () => {
  137. root = await mkdtemp(join(tmpdir(), "inkos-config-loader-custom-"));
  138. for (const key of ENV_KEYS) {
  139. previousEnv.set(key, process.env[key]);
  140. process.env[key] = "";
  141. }
  142. await writeFile(join(root, "inkos.json"), JSON.stringify({
  143. name: "custom-project",
  144. version: "0.1.0",
  145. language: "zh",
  146. llm: {
  147. services: [
  148. { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
  149. ],
  150. defaultModel: "corp-chat",
  151. },
  152. notify: [],
  153. }, null, 2), "utf-8");
  154. await mkdir(join(root, ".inkos"), { recursive: true });
  155. await writeFile(
  156. join(root, ".inkos", "secrets.json"),
  157. JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }, null, 2),
  158. "utf-8",
  159. );
  160. const config = await loadProjectConfig(root);
  161. expect(config.llm.service).toBe("custom");
  162. expect(config.llm.provider).toBe("custom");
  163. expect(config.llm.baseUrl).toBe("https://llm.internal.corp/v1");
  164. expect(config.llm.model).toBe("corp-chat");
  165. expect(config.llm.apiKey).toBe("sk-corp");
  166. expect(config.llm.temperature).toBe(0.9);
  167. expect(config.llm.apiFormat).toBe("responses");
  168. expect(config.llm.stream).toBe(false);
  169. });
  170. it("keeps Studio config active when llm.configSource is studio", async () => {
  171. root = await mkdtemp(join(tmpdir(), "inkos-config-loader-studio-source-"));
  172. for (const key of ENV_KEYS) {
  173. previousEnv.set(key, process.env[key]);
  174. process.env[key] = "";
  175. }
  176. await writeFile(join(root, "inkos.json"), JSON.stringify({
  177. name: "studio-source-project",
  178. version: "0.1.0",
  179. language: "zh",
  180. llm: {
  181. configSource: "studio",
  182. services: [
  183. { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9 },
  184. ],
  185. defaultModel: "corp-chat",
  186. },
  187. notify: [],
  188. }, null, 2), "utf-8");
  189. await writeFile(join(root, ".env"), [
  190. "INKOS_LLM_PROVIDER=openai",
  191. "INKOS_LLM_BASE_URL=https://api-vip.codex-for.me/v1",
  192. "INKOS_LLM_MODEL=gpt-5.4",
  193. "INKOS_LLM_API_KEY=sk-env",
  194. ].join("\n"), "utf-8");
  195. await mkdir(join(root, ".inkos"), { recursive: true });
  196. await writeFile(
  197. join(root, ".inkos", "secrets.json"),
  198. JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }, null, 2),
  199. "utf-8",
  200. );
  201. const config = await loadProjectConfig(root);
  202. expect(config.llm.configSource).toBe("studio");
  203. expect(config.llm.provider).toBe("custom");
  204. expect(config.llm.baseUrl).toBe("https://llm.internal.corp/v1");
  205. expect(config.llm.model).toBe("corp-chat");
  206. expect(config.llm.apiKey).toBe("sk-corp");
  207. });
  208. it("does not mix stale top-level env-era model/baseUrl with selected Studio service", async () => {
  209. root = await mkdtemp(join(tmpdir(), "inkos-config-loader-studio-stale-top-level-"));
  210. for (const key of ENV_KEYS) {
  211. previousEnv.set(key, process.env[key]);
  212. process.env[key] = "";
  213. }
  214. await writeFile(join(root, "inkos.json"), JSON.stringify({
  215. name: "studio-stale-project",
  216. version: "0.1.0",
  217. language: "zh",
  218. llm: {
  219. configSource: "studio",
  220. service: "google",
  221. model: "kimi-k2.5",
  222. baseUrl: "https://api.moonshot.cn/v1",
  223. apiKey: "sk-moon-inline",
  224. services: [
  225. { service: "google", temperature: 0.7, apiFormat: "chat", stream: true },
  226. { service: "moonshot", temperature: 1, apiFormat: "chat", stream: true },
  227. ],
  228. defaultModel: "gemini-2.5-flash",
  229. },
  230. notify: [],
  231. }, null, 2), "utf-8");
  232. await writeFile(join(root, ".env"), [
  233. "INKOS_LLM_PROVIDER=custom",
  234. "INKOS_LLM_BASE_URL=https://api.moonshot.cn/v1",
  235. "INKOS_LLM_MODEL=kimi-k2.5",
  236. "INKOS_LLM_API_KEY=sk-env-moon",
  237. ].join("\n"), "utf-8");
  238. await mkdir(join(root, ".inkos"), { recursive: true });
  239. await writeFile(
  240. join(root, ".inkos", "secrets.json"),
  241. JSON.stringify({
  242. services: {
  243. google: { apiKey: "sk-google" },
  244. moonshot: { apiKey: "sk-moon" },
  245. },
  246. }, null, 2),
  247. "utf-8",
  248. );
  249. const config = await loadProjectConfig(root, { consumer: "studio", requireApiKey: false });
  250. expect(config.llm.configSource).toBe("studio");
  251. expect(config.llm.service).toBe("google");
  252. expect(config.llm.provider).toBe("openai");
  253. expect(config.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
  254. expect(config.llm.model).toBe("gemini-2.5-flash");
  255. expect(config.llm.apiKey).toBe("sk-google");
  256. });
  257. it("falls back to env when Studio config is still the empty bootstrap state", async () => {
  258. root = await mkdtemp(join(tmpdir(), "inkos-config-loader-studio-bootstrap-"));
  259. for (const key of ENV_KEYS) {
  260. previousEnv.set(key, process.env[key]);
  261. process.env[key] = "";
  262. }
  263. process.env.INKOS_LLM_PROVIDER = "openai";
  264. process.env.INKOS_LLM_BASE_URL = "https://api-vip.codex-for.me/v1";
  265. process.env.INKOS_LLM_MODEL = "gpt-5.4";
  266. process.env.INKOS_LLM_API_KEY = "sk-env";
  267. await writeFile(join(root, "inkos.json"), JSON.stringify({
  268. name: "studio-bootstrap-project",
  269. version: "0.1.0",
  270. language: "zh",
  271. llm: {
  272. provider: "openai",
  273. service: "custom",
  274. configSource: "studio",
  275. baseUrl: "",
  276. model: "",
  277. apiFormat: "chat",
  278. stream: true,
  279. },
  280. notify: [],
  281. }, null, 2), "utf-8");
  282. await writeFile(join(root, ".env"), "", "utf-8");
  283. const config = await loadProjectConfig(root);
  284. expect(config.llm.configSource).toBe("studio");
  285. expect(config.llm.provider).toBe("openai");
  286. expect(config.llm.baseUrl).toBe("https://api-vip.codex-for.me/v1");
  287. expect(config.llm.model).toBe("gpt-5.4");
  288. expect(config.llm.apiKey).toBe("sk-env");
  289. });
  290. });