settings-model-list.spec.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import { afterEach, describe, expect, it, vi } from "vitest"
  2. import type { LlmConfig } from "@/stores/wiki-store"
  3. import { invoke } from "@tauri-apps/api/core"
  4. const fetchMock = vi.fn()
  5. vi.mock("@/lib/tauri-fetch", () => ({
  6. getHttpFetch: async () => fetchMock,
  7. }))
  8. vi.mock("@tauri-apps/api/core", () => ({
  9. invoke: vi.fn(),
  10. }))
  11. vi.mock("@/lib/platform", () => ({
  12. isTauri: () => true,
  13. }))
  14. function customConfig(overrides: Partial<LlmConfig> = {}): LlmConfig {
  15. return {
  16. provider: "custom",
  17. apiKey: "sk-test",
  18. model: "gpt-4o",
  19. ollamaUrl: "http://localhost:11434",
  20. customEndpoint: "https://hub.linux.do/v1",
  21. maxContextSize: 128000,
  22. apiMode: "chat_completions",
  23. reasoning: { mode: "off" },
  24. ...overrides,
  25. }
  26. }
  27. afterEach(() => {
  28. fetchMock.mockReset()
  29. vi.mocked(invoke).mockReset()
  30. })
  31. describe("settings model list", () => {
  32. it("fetches custom OpenAI-compatible models from the normalized /models endpoint", async () => {
  33. fetchMock.mockResolvedValueOnce(
  34. new Response(JSON.stringify({ data: [{ id: "gpt-test" }] }), {
  35. status: 200,
  36. headers: { "Content-Type": "application/json" },
  37. }),
  38. )
  39. const { fetchLlmModelList } = await import("./settings-model-list")
  40. const result = await fetchLlmModelList(customConfig())
  41. expect(fetchMock).toHaveBeenCalledWith("https://hub.linux.do/v1/models", {
  42. method: "GET",
  43. headers: {
  44. Authorization: "Bearer sk-test",
  45. Origin: "",
  46. },
  47. })
  48. expect(result.models).toEqual(["gpt-test"])
  49. })
  50. it("retries model list 403 responses with browser-compatible OpenAI headers", async () => {
  51. fetchMock
  52. .mockResolvedValueOnce(new Response("forbidden", { status: 403 }))
  53. .mockResolvedValueOnce(
  54. new Response(JSON.stringify({ data: [{ id: "linux-do-model" }] }), {
  55. status: 200,
  56. headers: { "Content-Type": "application/json" },
  57. }),
  58. )
  59. const { fetchLlmModelList } = await import("./settings-model-list")
  60. const result = await fetchLlmModelList(customConfig())
  61. expect(fetchMock).toHaveBeenCalledTimes(2)
  62. expect(fetchMock.mock.calls[1]).toEqual([
  63. "https://hub.linux.do/v1/models",
  64. {
  65. method: "GET",
  66. headers: expect.objectContaining({
  67. Authorization: "Bearer sk-test",
  68. Accept: "application/json",
  69. "User-Agent": expect.stringContaining("Mozilla/5.0"),
  70. }),
  71. },
  72. ])
  73. expect(result.models).toEqual(["linux-do-model"])
  74. })
  75. it("keeps the original 403 diagnostic when the compatibility retry cannot be sent", async () => {
  76. fetchMock
  77. .mockResolvedValueOnce(new Response("forbidden", { status: 403 }))
  78. .mockRejectedValueOnce(new TypeError("Refused to set unsafe header"))
  79. const { fetchLlmModelList } = await import("./settings-model-list")
  80. await expect(fetchLlmModelList(customConfig())).rejects.toThrow(
  81. "模型列表拉取失败:HTTP 403 forbidden",
  82. )
  83. })
  84. it("reads the configured local Claude CLI model from Tauri detection", async () => {
  85. vi.mocked(invoke).mockResolvedValueOnce({
  86. installed: true,
  87. version: "2.1.169 (Claude Code)",
  88. path: "C:/Users/Administrator/AppData/Roaming/npm/claude.cmd",
  89. model: "haiku",
  90. error: null,
  91. })
  92. const { fetchLlmModelList } = await import("./settings-model-list")
  93. const result = await fetchLlmModelList(customConfig({
  94. provider: "claude-code",
  95. apiKey: "",
  96. model: "",
  97. }))
  98. expect(invoke).toHaveBeenCalledWith("claude_cli_detect")
  99. expect(result.models).toEqual(["haiku"])
  100. })
  101. it("reads the configured local Codex CLI model from Tauri detection", async () => {
  102. vi.mocked(invoke).mockResolvedValueOnce({
  103. installed: true,
  104. version: "codex-cli 0.146.1",
  105. path: "C:/Users/Administrator/AppData/Roaming/npm/codex.cmd",
  106. model: "gpt-5.6-terra",
  107. appServerReady: true,
  108. dynamicToolsReady: true,
  109. models: ["gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.6-luna"],
  110. error: null,
  111. })
  112. const { fetchLlmModelList } = await import("./settings-model-list")
  113. const result = await fetchLlmModelList(customConfig({
  114. provider: "codex-cli",
  115. apiKey: "",
  116. model: "",
  117. }))
  118. expect(invoke).toHaveBeenCalledWith("codex_cli_detect")
  119. expect(result.models).toEqual(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"])
  120. })
  121. it("rejects a Codex CLI without app-server dynamic tools", async () => {
  122. vi.mocked(invoke).mockResolvedValueOnce({
  123. installed: true,
  124. version: "codex-cli 0.120.0",
  125. path: "/usr/local/bin/codex",
  126. appServerReady: false,
  127. dynamicToolsReady: false,
  128. models: [],
  129. error: "当前 Codex CLI 不支持 QMAI 主 Agent,请升级 Codex CLI。",
  130. })
  131. const { fetchLlmModelList } = await import("./settings-model-list")
  132. await expect(fetchLlmModelList(customConfig({
  133. provider: "codex-cli",
  134. apiKey: "",
  135. model: "",
  136. }))).rejects.toThrow("请升级 Codex CLI")
  137. })
  138. })