config-loader.test.ts 8.9 KB

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