provider.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. import { 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. apiFormat: "chat",
  103. stream: true,
  104. _piModel: MOCK_PI_MODEL,
  105. _apiKey: "test-key",
  106. defaults: {
  107. temperature,
  108. maxTokens: 512,
  109. thinkingBudget: 0,
  110. maxTokensCap: null,
  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("检查提供方文档");
  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("uses native fetch transport for custom anthropic-compatible non-stream chat", async () => {
  244. const fetchMock = vi.fn().mockResolvedValue({
  245. ok: true,
  246. json: async () => ({
  247. content: [{ type: "text", text: "你好,Anthropic!" }],
  248. usage: { input_tokens: 5, output_tokens: 3 },
  249. }),
  250. });
  251. vi.stubGlobal("fetch", fetchMock);
  252. const client = makeClient(0.7, {
  253. provider: "anthropic",
  254. service: "custom",
  255. stream: false,
  256. _piModel: {
  257. ...MOCK_PI_MODEL,
  258. provider: "anthropic",
  259. api: "anthropic-messages" as Api,
  260. baseUrl: "https://gateway.example",
  261. },
  262. });
  263. const result = await chatCompletion(client, "claude-sonnet-4-6", [{ role: "user", content: "nihao" }]);
  264. expect(result.content).toBe("你好,Anthropic!");
  265. expect(result.usage.promptTokens).toBe(5);
  266. expect(result.usage.completionTokens).toBe(3);
  267. expect(fetchMock).toHaveBeenCalledOnce();
  268. expect(mockCompleteSimple).not.toHaveBeenCalled();
  269. expect(mockStreamSimple).not.toHaveBeenCalled();
  270. vi.unstubAllGlobals();
  271. });
  272. it("uses native fetch transport for custom anthropic-compatible stream chat", async () => {
  273. const encoder = new TextEncoder();
  274. const sse = [
  275. "event: message_start\n",
  276. "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":4}}}\n\n",
  277. "event: content_block_delta\n",
  278. "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你\"}}\n\n",
  279. "event: content_block_delta\n",
  280. "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"好\"}}\n\n",
  281. "event: message_delta\n",
  282. "data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":2}}\n\n",
  283. "event: message_stop\n",
  284. "data: {\"type\":\"message_stop\"}\n\n",
  285. ].join("");
  286. const fetchMock = vi.fn().mockResolvedValue({
  287. ok: true,
  288. body: new ReadableStream({
  289. start(controller) {
  290. controller.enqueue(encoder.encode(sse));
  291. controller.close();
  292. },
  293. }),
  294. });
  295. vi.stubGlobal("fetch", fetchMock);
  296. const client = makeClient(0.7, {
  297. provider: "anthropic",
  298. service: "custom",
  299. stream: true,
  300. _piModel: {
  301. ...MOCK_PI_MODEL,
  302. provider: "anthropic",
  303. api: "anthropic-messages" as Api,
  304. baseUrl: "https://gateway.example",
  305. },
  306. });
  307. const result = await chatCompletion(client, "claude-sonnet-4-6", [{ role: "user", content: "nihao" }]);
  308. expect(result.content).toBe("你好");
  309. expect(result.usage.promptTokens).toBe(4);
  310. expect(result.usage.completionTokens).toBe(2);
  311. expect(result.usage.totalTokens).toBe(6);
  312. expect(fetchMock).toHaveBeenCalledOnce();
  313. expect(mockCompleteSimple).not.toHaveBeenCalled();
  314. expect(mockStreamSimple).not.toHaveBeenCalled();
  315. vi.unstubAllGlobals();
  316. });
  317. });
  318. describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
  319. beforeEach(() => {
  320. __resetFixedTemperatureWarnings();
  321. mockStreamSimple.mockReset();
  322. mockStreamSimple.mockReturnValue(makeTextStream("ok"));
  323. });
  324. it("forces temperature=1 for kimi-k2.5 even when client default is 0.7", async () => {
  325. const client = makeClient(0.7);
  326. const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
  327. await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
  328. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  329. expect(opts.temperature).toBe(1);
  330. expect(warn).toHaveBeenCalledOnce();
  331. expect(warn.mock.calls[0]?.[0]).toContain("kimi-k2.5");
  332. warn.mockRestore();
  333. });
  334. it("clamps per-call temperature override (0.3) to 1 for kimi-k2.5", async () => {
  335. const client = makeClient(0.7);
  336. vi.spyOn(console, "warn").mockImplementation(() => {});
  337. await chatCompletion(
  338. client,
  339. "kimi-k2.5",
  340. [{ role: "user", content: "hi" }],
  341. { temperature: 0.3 },
  342. );
  343. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  344. expect(opts.temperature).toBe(1);
  345. });
  346. it("only warns once per model name across multiple calls", async () => {
  347. const client = makeClient(0.7);
  348. const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
  349. await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "a" }]);
  350. await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "b" }]);
  351. await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "c" }]);
  352. expect(warn).toHaveBeenCalledOnce();
  353. warn.mockRestore();
  354. });
  355. it("also clamps any model name containing 'thinking'", async () => {
  356. const client = makeClient(0.5);
  357. vi.spyOn(console, "warn").mockImplementation(() => {});
  358. await chatCompletion(client, "kimi-thinking-preview", [
  359. { role: "user", content: "hi" },
  360. ]);
  361. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  362. expect(opts.temperature).toBe(1);
  363. });
  364. it("leaves regular models untouched (no clamp, no warning)", async () => {
  365. const client = makeClient(0.7);
  366. const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
  367. await chatCompletion(
  368. client,
  369. "moonshot-v1-32k",
  370. [{ role: "user", content: "hi" }],
  371. { temperature: 0.3 },
  372. );
  373. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  374. expect(opts.temperature).toBe(0.3);
  375. expect(warn).not.toHaveBeenCalled();
  376. warn.mockRestore();
  377. });
  378. it("does not warn when requested temperature is already 1", async () => {
  379. const client = makeClient(1);
  380. const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
  381. await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
  382. const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
  383. expect(opts.temperature).toBe(1);
  384. expect(warn).not.toHaveBeenCalled();
  385. warn.mockRestore();
  386. });
  387. });