config-loader.test.ts 8.8 KB

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