provider.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import type { AssistantMessage, Model, Api } from "@mariozechner/pi-ai";
  3. import {
  4. __resetFixedTemperatureWarnings,
  5. chatCompletion,
  6. type LLMClient,
  7. } from "../llm/provider.js";
  8. // ── Mock @mariozechner/pi-ai ──────────────────────────────────────────────────
  9. // We intercept streamSimple so tests don't hit the network.
  10. const mockStreamSimple = vi.fn();
  11. const mockCompleteSimple = vi.fn();
  12. const mockComplete = vi.fn();
  13. vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
  14. const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
  15. return {
  16. ...original,
  17. streamSimple: (...args: unknown[]) => mockStreamSimple(...args),
  18. completeSimple: (...args: unknown[]) => mockCompleteSimple(...args),
  19. complete: (...args: unknown[]) => mockComplete(...args),
  20. };
  21. });
  22. // ── Helpers ───────────────────────────────────────────────────────────────────
  23. const MOCK_USAGE = {
  24. input: 11,
  25. output: 7,
  26. cacheRead: 0,
  27. cacheWrite: 0,
  28. totalTokens: 18,
  29. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
  30. };
  31. function makeAssistantMessage(text: string): AssistantMessage {
  32. return {
  33. role: "assistant",
  34. content: [{ type: "text", text }],
  35. api: "openai-completions" as Api,
  36. provider: "openai",
  37. model: "test-model",
  38. usage: MOCK_USAGE,
  39. stopReason: "stop",
  40. timestamp: Date.now(),
  41. };
  42. }
  43. /** Builds an async iterable that emits the given events. */
  44. function makeEventStream(
  45. events: Array<Record<string, unknown>>,
  46. ): AsyncIterable<Record<string, unknown>> {
  47. return {
  48. [Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
  49. let i = 0;
  50. return {
  51. async next() {
  52. if (i < events.length) return { value: events[i++]!, done: false };
  53. return { value: undefined as unknown as Record<string, unknown>, done: true };
  54. },
  55. };
  56. },
  57. };
  58. }
  59. /** Stream that emits one text_delta and then done. */
  60. function makeTextStream(text: string): AsyncIterable<Record<string, unknown>> {
  61. const msg = makeAssistantMessage(text);
  62. return makeEventStream([
  63. { type: "text_delta", contentIndex: 0, delta: text, partial: msg },
  64. { type: "done", reason: "stop", message: msg },
  65. ]);
  66. }
  67. /** Stream that emits only done with empty content. */
  68. function makeEmptyStream(): AsyncIterable<Record<string, unknown>> {
  69. const msg = makeAssistantMessage("");
  70. return makeEventStream([
  71. { type: "done", reason: "stop", message: msg },
  72. ]);
  73. }
  74. /** Stream that throws immediately. */
  75. function makeErrorStream(message: string): AsyncIterable<Record<string, unknown>> {
  76. return {
  77. [Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
  78. return {
  79. async next() {
  80. throw new Error(message);
  81. },
  82. };
  83. },
  84. };
  85. }
  86. const MOCK_PI_MODEL: Model<Api> = {
  87. id: "test-model",
  88. name: "test-model",
  89. api: "openai-completions",
  90. provider: "openai",
  91. baseUrl: "https://api.openai.com/v1",
  92. reasoning: false,
  93. input: ["text"],
  94. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  95. contextWindow: 128000,
  96. maxTokens: 8192,
  97. };
  98. function makeClient(temperature = 0.7, extra: Partial<LLMClient> = {}): LLMClient {
  99. return {
  100. provider: "openai",
  101. service: "openai",
  102. configSource: "studio",
  103. apiFormat: "chat",
  104. stream: true,
  105. _piModel: MOCK_PI_MODEL,
  106. _apiKey: "test-key",
  107. defaults: {
  108. temperature,
  109. maxTokens: 512,
  110. thinkingBudget: 0,
  111. extra: {},
  112. },
  113. ...extra,
  114. };
  115. }
  116. async function captureError(task: Promise<unknown>): Promise<Error> {
  117. try {
  118. await task;
  119. } catch (error) {
  120. return error as Error;
  121. }
  122. throw new Error("Expected promise to reject");
  123. }
  124. // ── Tests ─────────────────────────────────────────────────────────────────────
  125. describe("chatCompletion via pi-ai", () => {
  126. beforeEach(() => {
  127. mockStreamSimple.mockReset();
  128. mockCompleteSimple.mockReset();
  129. mockComplete.mockReset();
  130. });
  131. it("returns text content from a successful stream", async () => {
  132. mockStreamSimple.mockReturnValue(makeTextStream("hello world"));
  133. const client = makeClient();
  134. const result = await chatCompletion(client, "test-model", [
  135. { role: "user", content: "ping" },
  136. ]);
  137. expect(result.content).toBe("hello world");
  138. expect(result.usage.promptTokens).toBe(11);
  139. expect(result.usage.completionTokens).toBe(7);
  140. expect(result.usage.totalTokens).toBe(18);
  141. expect(mockStreamSimple).toHaveBeenCalledOnce();
  142. });
  143. it("throws when stream produces no text content", async () => {
  144. mockStreamSimple.mockReturnValue(makeEmptyStream());
  145. const client = makeClient();
  146. const error = await captureError(
  147. chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
  148. );
  149. expect(error.message).toContain("empty response");
  150. });
  151. it("wraps 400 API errors with a user-friendly message", async () => {
  152. mockStreamSimple.mockReturnValue(makeErrorStream("400 Bad Request"));
  153. const client = makeClient();
  154. const error = await captureError(
  155. chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
  156. );
  157. expect(error.message).toContain("API 返回 400");
  158. expect(error.message).toContain("temperature");
  159. });
  160. it("wraps 401 errors with an unauthorized message", async () => {
  161. mockStreamSimple.mockReturnValue(makeErrorStream("401 Unauthorized"));
  162. const client = makeClient();
  163. const error = await captureError(
  164. chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
  165. );
  166. expect(error.message).toContain("API 返回 401");
  167. });
  168. it("wraps connection errors with a friendly message", async () => {
  169. mockStreamSimple.mockReturnValue(makeErrorStream("fetch failed: ECONNREFUSED"));
  170. const client = makeClient();
  171. const error = await captureError(
  172. chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
  173. );
  174. expect(error.message).toContain("无法连接到 API 服务");
  175. });
  176. it("passes temperature and maxTokens to streamSimple", async () => {
  177. mockStreamSimple.mockReturnValue(makeTextStream("ok"));
  178. const client = makeClient(0.5);
  179. await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }], {
  180. temperature: 0.3,
  181. maxTokens: 256,
  182. });
  183. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  184. expect(opts.temperature).toBe(0.3);
  185. expect(opts.maxTokens).toBe(256);
  186. });
  187. it("uses client defaults when no per-call overrides are provided", async () => {
  188. mockStreamSimple.mockReturnValue(makeTextStream("ok"));
  189. const client = makeClient(0.8);
  190. await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }]);
  191. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  192. expect(opts.temperature).toBe(0.8);
  193. expect(opts.maxTokens).toBe(512);
  194. });
  195. it("calls onTextDelta for each text chunk", async () => {
  196. const msg = makeAssistantMessage("abc");
  197. mockStreamSimple.mockReturnValue(makeEventStream([
  198. { type: "text_delta", contentIndex: 0, delta: "a", partial: msg },
  199. { type: "text_delta", contentIndex: 0, delta: "b", partial: msg },
  200. { type: "text_delta", contentIndex: 0, delta: "c", partial: msg },
  201. { type: "done", reason: "stop", message: msg },
  202. ]));
  203. const deltas: string[] = [];
  204. const client = makeClient();
  205. await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }], {
  206. onTextDelta: (d) => deltas.push(d),
  207. });
  208. expect(deltas).toEqual(["a", "b", "c"]);
  209. });
  210. it("uses completeSimple when client.stream is false", async () => {
  211. mockCompleteSimple.mockResolvedValue(makeAssistantMessage("offline hello"));
  212. const client = makeClient(0.7, { stream: false });
  213. const result = await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }]);
  214. expect(result.content).toBe("offline hello");
  215. expect(mockCompleteSimple).toHaveBeenCalledOnce();
  216. expect(mockStreamSimple).not.toHaveBeenCalled();
  217. });
  218. it("uses native fetch transport for custom openai-compatible chat", async () => {
  219. const fetchMock = vi.fn().mockResolvedValue({
  220. ok: true,
  221. json: async () => ({
  222. choices: [{ message: { content: "你好!" } }],
  223. usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
  224. }),
  225. });
  226. vi.stubGlobal("fetch", fetchMock);
  227. const client = makeClient(0.7, {
  228. service: "custom",
  229. stream: false,
  230. _piModel: {
  231. ...MOCK_PI_MODEL,
  232. provider: "openai",
  233. baseUrl: "https://gateway.example/v1",
  234. },
  235. });
  236. const result = await chatCompletion(client, "gpt-5.4", [{ role: "user", content: "nihao" }]);
  237. expect(result.content).toBe("你好!");
  238. expect(fetchMock).toHaveBeenCalledOnce();
  239. expect(mockCompleteSimple).not.toHaveBeenCalled();
  240. expect(mockStreamSimple).not.toHaveBeenCalled();
  241. vi.unstubAllGlobals();
  242. });
  243. it("keeps legacy env custom openai-compatible chat on pi-ai path", async () => {
  244. const fetchMock = vi.fn();
  245. vi.stubGlobal("fetch", fetchMock);
  246. mockCompleteSimple.mockResolvedValue(makeAssistantMessage("legacy ok"));
  247. const client = makeClient(0.7, {
  248. service: "custom",
  249. configSource: "env",
  250. stream: false,
  251. _piModel: {
  252. ...MOCK_PI_MODEL,
  253. provider: "openai",
  254. baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
  255. },
  256. });
  257. const result = await chatCompletion(client, "gemma-4", [{ role: "user", content: "ping" }]);
  258. expect(result.content).toBe("legacy ok");
  259. expect(mockCompleteSimple).toHaveBeenCalledOnce();
  260. expect(fetchMock).not.toHaveBeenCalled();
  261. vi.unstubAllGlobals();
  262. });
  263. it("uses native fetch transport for custom anthropic-compatible non-stream chat", async () => {
  264. const fetchMock = vi.fn().mockResolvedValue({
  265. ok: true,
  266. json: async () => ({
  267. content: [{ type: "text", text: "你好,Anthropic!" }],
  268. usage: { input_tokens: 5, output_tokens: 3 },
  269. }),
  270. });
  271. vi.stubGlobal("fetch", fetchMock);
  272. const client = makeClient(0.7, {
  273. provider: "anthropic",
  274. service: "custom",
  275. stream: false,
  276. _piModel: {
  277. ...MOCK_PI_MODEL,
  278. provider: "anthropic",
  279. api: "anthropic-messages" as Api,
  280. baseUrl: "https://gateway.example",
  281. },
  282. });
  283. const result = await chatCompletion(client, "claude-sonnet-4-6", [{ role: "user", content: "nihao" }]);
  284. expect(result.content).toBe("你好,Anthropic!");
  285. expect(result.usage.promptTokens).toBe(5);
  286. expect(result.usage.completionTokens).toBe(3);
  287. expect(fetchMock).toHaveBeenCalledOnce();
  288. expect(mockCompleteSimple).not.toHaveBeenCalled();
  289. expect(mockStreamSimple).not.toHaveBeenCalled();
  290. vi.unstubAllGlobals();
  291. });
  292. it("uses native fetch transport for custom anthropic-compatible stream chat", async () => {
  293. const encoder = new TextEncoder();
  294. const sse = [
  295. "event: message_start\n",
  296. "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":4}}}\n\n",
  297. "event: content_block_delta\n",
  298. "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你\"}}\n\n",
  299. "event: content_block_delta\n",
  300. "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"好\"}}\n\n",
  301. "event: message_delta\n",
  302. "data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":2}}\n\n",
  303. "event: message_stop\n",
  304. "data: {\"type\":\"message_stop\"}\n\n",
  305. ].join("");
  306. const fetchMock = vi.fn().mockResolvedValue({
  307. ok: true,
  308. body: new ReadableStream({
  309. start(controller) {
  310. controller.enqueue(encoder.encode(sse));
  311. controller.close();
  312. },
  313. }),
  314. });
  315. vi.stubGlobal("fetch", fetchMock);
  316. const client = makeClient(0.7, {
  317. provider: "anthropic",
  318. service: "custom",
  319. stream: true,
  320. _piModel: {
  321. ...MOCK_PI_MODEL,
  322. provider: "anthropic",
  323. api: "anthropic-messages" as Api,
  324. baseUrl: "https://gateway.example",
  325. },
  326. });
  327. const result = await chatCompletion(client, "claude-sonnet-4-6", [{ role: "user", content: "nihao" }]);
  328. expect(result.content).toBe("你好");
  329. expect(result.usage.promptTokens).toBe(4);
  330. expect(result.usage.completionTokens).toBe(2);
  331. expect(result.usage.totalTokens).toBe(6);
  332. expect(fetchMock).toHaveBeenCalledOnce();
  333. expect(mockCompleteSimple).not.toHaveBeenCalled();
  334. expect(mockStreamSimple).not.toHaveBeenCalled();
  335. vi.unstubAllGlobals();
  336. });
  337. });
  338. describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
  339. beforeEach(() => {
  340. __resetFixedTemperatureWarnings();
  341. mockStreamSimple.mockReset();
  342. mockStreamSimple.mockReturnValue(makeTextStream("ok"));
  343. });
  344. afterEach(() => {
  345. vi.restoreAllMocks();
  346. });
  347. it("forces temperature=1 for kimi-k2.5 even when client default is 0.7", async () => {
  348. const client = makeClient(0.7);
  349. const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
  350. await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
  351. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  352. expect(opts.temperature).toBe(1);
  353. expect(warn).toHaveBeenCalledOnce();
  354. expect(warn.mock.calls[0]?.[0]).toContain("kimi-k2.5");
  355. warn.mockRestore();
  356. });
  357. it("clamps per-call temperature override (0.3) to 1 for kimi-k2.5", async () => {
  358. const client = makeClient(0.7);
  359. vi.spyOn(console, "warn").mockImplementation(() => {});
  360. await chatCompletion(
  361. client,
  362. "kimi-k2.5",
  363. [{ role: "user", content: "hi" }],
  364. { temperature: 0.3 },
  365. );
  366. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  367. expect(opts.temperature).toBe(1);
  368. });
  369. it("only warns once per model name across multiple calls", async () => {
  370. const client = makeClient(0.7);
  371. const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
  372. await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "a" }]);
  373. await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "b" }]);
  374. await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "c" }]);
  375. expect(warn).toHaveBeenCalledOnce();
  376. warn.mockRestore();
  377. });
  378. it("clamps kimi-k2-thinking (bank-marked temperature:1)", async () => {
  379. const client = makeClient(0.5);
  380. vi.spyOn(console, "warn").mockImplementation(() => {});
  381. await chatCompletion(client, "kimi-k2-thinking", [
  382. { role: "user", content: "hi" },
  383. ]);
  384. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  385. expect(opts.temperature).toBe(1);
  386. });
  387. it("leaves regular models untouched (no clamp, no warning)", async () => {
  388. const client = makeClient(0.7);
  389. const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
  390. await chatCompletion(
  391. client,
  392. "moonshot-v1-32k",
  393. [{ role: "user", content: "hi" }],
  394. { temperature: 0.3 },
  395. );
  396. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  397. expect(opts.temperature).toBe(0.3);
  398. expect(warn).not.toHaveBeenCalled();
  399. warn.mockRestore();
  400. });
  401. it("does not warn when requested temperature is already 1", async () => {
  402. const client = makeClient(1);
  403. const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
  404. await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
  405. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  406. expect(opts.temperature).toBe(1);
  407. expect(warn).not.toHaveBeenCalled();
  408. warn.mockRestore();
  409. });
  410. });
  411. // ── 回归测试:per-call maxTokens 不被裁剪(v2.0.0 精简版)─────────────
  412. // 背景:v2.0.0 删除了 config.maxTokens / maxTokensCap 字段,provider 层不再做 cap,
  413. // agent per-call 传的 maxTokens 原样透传到下游。
  414. describe("createLLMClient per-call maxTokens not capped (v2.0.0)", () => {
  415. it("per-call maxTokens 16384 reaches the API as-is", async () => {
  416. const { createLLMClient } = await import("../llm/provider.js");
  417. const { LLMConfigSchema } = await import("../models/project.js");
  418. const client = createLLMClient(LLMConfigSchema.parse({
  419. provider: "openai",
  420. baseUrl: "http://localhost:0",
  421. model: "test-model",
  422. }));
  423. mockStreamSimple.mockReset();
  424. mockStreamSimple.mockReturnValue(makeTextStream("ok"));
  425. await chatCompletion(client, "test-model", [
  426. { role: "user", content: "architect" },
  427. ], { maxTokens: 16384 });
  428. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  429. expect(opts.maxTokens).toBe(16384);
  430. });
  431. });
  432. describe("createLLMClient with providers lookup", () => {
  433. it("anthropic + claude-sonnet-4-6 拿到 modelCard 的 maxOutput (64000),不是未知模型兜底", async () => {
  434. const { createLLMClient } = await import("../llm/provider.js");
  435. const { LLMConfigSchema } = await import("../models/project.js");
  436. const client = createLLMClient(LLMConfigSchema.parse({
  437. provider: "anthropic",
  438. service: "anthropic",
  439. model: "claude-sonnet-4-6",
  440. apiKey: "test",
  441. baseUrl: "https://api.anthropic.com",
  442. }));
  443. expect(client.defaults.maxTokens).toBe(64_000);
  444. expect(client._piModel?.maxTokens).toBe(64_000);
  445. expect(client._piModel?.contextWindow).toBe(1_000_000);
  446. });
  447. it("custom service + gpt-4o 靠 Layer 2 全局扫命中 openai provider", async () => {
  448. const { createLLMClient } = await import("../llm/provider.js");
  449. const { LLMConfigSchema } = await import("../models/project.js");
  450. const client = createLLMClient(LLMConfigSchema.parse({
  451. provider: "openai",
  452. service: "custom",
  453. model: "gpt-4o",
  454. apiKey: "test",
  455. baseUrl: "https://middleman.example/v1",
  456. }));
  457. // lobe 数据里 gpt-4o maxOutput=4096
  458. expect(client.defaults.maxTokens).toBe(4096);
  459. });
  460. it("未知 model 走 8192 * 3 的写作兜底预算", async () => {
  461. const { createLLMClient } = await import("../llm/provider.js");
  462. const { LLMConfigSchema } = await import("../models/project.js");
  463. const client = createLLMClient(LLMConfigSchema.parse({
  464. provider: "openai",
  465. service: "custom",
  466. model: "my-private-xyz-model-does-not-exist",
  467. apiKey: "test",
  468. baseUrl: "https://middleman.example/v1",
  469. }));
  470. expect(client.defaults.maxTokens).toBe(24_576);
  471. expect(client._piModel?.maxTokens).toBe(24_576);
  472. });
  473. it("config.maxTokens 命中 modelCard 后被覆盖(用户填 4000 还是用 modelCard 的 64000)", async () => {
  474. const { createLLMClient } = await import("../llm/provider.js");
  475. const { LLMConfigSchema } = await import("../models/project.js");
  476. const client = createLLMClient(LLMConfigSchema.parse({
  477. provider: "anthropic",
  478. service: "anthropic",
  479. model: "claude-sonnet-4-6",
  480. apiKey: "test",
  481. baseUrl: "https://api.anthropic.com",
  482. maxTokens: 4000,
  483. }));
  484. expect(client.defaults.maxTokens).toBe(64_000);
  485. });
  486. it("B7: kimiCodingPlan 的 kimi-k2.5 走 API 时 piModel.id 是 deploymentName (k2p5)", async () => {
  487. const { createLLMClient } = await import("../llm/provider.js");
  488. const { LLMConfigSchema } = await import("../models/project.js");
  489. const client = createLLMClient(LLMConfigSchema.parse({
  490. provider: "anthropic",
  491. service: "kimiCodingPlan",
  492. model: "kimi-k2.5",
  493. apiKey: "test",
  494. baseUrl: "https://api.moonshot.cn/anthropic",
  495. }));
  496. expect(client._piModel?.id).toBe("k2p5");
  497. });
  498. it("B7: 没有 deploymentName 的 model piModel.id 保持原 config.model", async () => {
  499. const { createLLMClient } = await import("../llm/provider.js");
  500. const { LLMConfigSchema } = await import("../models/project.js");
  501. const client = createLLMClient(LLMConfigSchema.parse({
  502. provider: "anthropic",
  503. service: "kimiCodingPlan",
  504. model: "kimi-k2-thinking",
  505. apiKey: "test",
  506. baseUrl: "https://api.moonshot.cn/anthropic",
  507. }));
  508. expect(client._piModel?.id).toBe("kimi-k2-thinking");
  509. });
  510. it("Google Gemini uses native google-generative-ai provider", async () => {
  511. const { createLLMClient } = await import("../llm/provider.js");
  512. const { LLMConfigSchema } = await import("../models/project.js");
  513. const client = createLLMClient(LLMConfigSchema.parse({
  514. provider: "openai",
  515. service: "google",
  516. model: "gemini-2.5-flash",
  517. apiKey: "test",
  518. baseUrl: "https://generativelanguage.googleapis.com/v1beta",
  519. }));
  520. expect(client._piModel?.api).toBe("google-generative-ai");
  521. expect(client._piModel?.provider).toBe("google");
  522. expect(client._piModel?.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
  523. expect(client._piModel?.compat).toBeUndefined();
  524. });
  525. });