import { afterEach, describe, expect, it, vi } from "vitest"; import * as serverUtils from "@paperclipai/adapter-utils/server-utils"; import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable, listOpenCodeModels, requireOpenCodeModelId, resetOpenCodeModelsCacheForTests, } from "./models.js"; describe("openCode models", () => { afterEach(() => { delete process.env.PAPERCLIP_OPENCODE_COMMAND; delete process.env.OPENCODE_ALLOW_ALL_MODELS; resetOpenCodeModelsCacheForTests(); vi.restoreAllMocks(); vi.useRealTimers(); }); it("returns an empty list when discovery command is unavailable", async () => { process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; await expect(listOpenCodeModels()).resolves.toEqual([]); }); it("rejects when model is missing", async () => { await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "" }), ).rejects.toThrow("OpenCode requires `adapterConfig.model`"); }); it("accepts a provider/model id without running discovery", () => { expect(requireOpenCodeModelId("openai/gpt-5.2-codex")).toBe( "openai/gpt-5.2-codex", ); }); it("rejects malformed provider/model ids before discovery", () => { expect(() => requireOpenCodeModelId("gpt-5.2-codex")).toThrow( "OpenCode requires `adapterConfig.model`", ); expect(() => requireOpenCodeModelId("openai/")).toThrow( "OpenCode requires `adapterConfig.model`", ); }); it("proceeds with the configured model when discovery cannot run (probe is best-effort, never fatal)", async () => { process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "openai/gpt-5", }), ).resolves.toEqual([{ id: "openai/gpt-5", label: "openai/gpt-5" }]); }); it("skips the availability check when OPENCODE_ALLOW_ALL_MODELS is set in the run env", async () => { process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "anthropic/tensorix/deepseek/deepseek-chat-v3.1", env: { OPENCODE_ALLOW_ALL_MODELS: "true" }, }), ).resolves.toEqual([ { id: "anthropic/tensorix/deepseek/deepseek-chat-v3.1", label: "anthropic/tensorix/deepseek/deepseek-chat-v3.1", }, ]); }); it("honours OPENCODE_ALLOW_ALL_MODELS from the process env", async () => { process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; process.env.OPENCODE_ALLOW_ALL_MODELS = "1"; await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "anthropic/gateway/some-model", }), ).resolves.toEqual([ { id: "anthropic/gateway/some-model", label: "anthropic/gateway/some-model", }, ]); }); it("still enforces provider/model format when OPENCODE_ALLOW_ALL_MODELS is set", async () => { await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "not-a-valid-id", env: { OPENCODE_ALLOW_ALL_MODELS: "true" }, }), ).rejects.toThrow("OpenCode requires `adapterConfig.model`"); }); it("retries a transient `opencode models` failure with backoff before succeeding", async () => { vi.useFakeTimers(); const spy = vi .spyOn(serverUtils, "runChildProcess") .mockResolvedValueOnce({ exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "queued behind another opencode run", pid: 1, startedAt: new Date().toISOString(), }) .mockResolvedValueOnce({ exitCode: null, signal: null, timedOut: true, stdout: "", stderr: "", pid: 1, startedAt: new Date().toISOString(), }) .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "ollama/qwen2.5-coder:7b\n", stderr: "", pid: 1, startedAt: new Date().toISOString(), }); const promise = discoverOpenCodeModels(); await vi.runAllTimersAsync(); await expect(promise).resolves.toEqual([ { id: "ollama/qwen2.5-coder:7b", label: "ollama/qwen2.5-coder:7b" }, ]); expect(spy).toHaveBeenCalledTimes(3); }); it("refreshes a stale non-empty catalog before rejecting the configured model", async () => { const spy = vi .spyOn(serverUtils, "runChildProcess") .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "openrouter/example/stale-model\n", stderr: "", pid: 1, startedAt: new Date().toISOString(), }) .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "Models cache refreshed\n", stderr: "", pid: 1, startedAt: new Date().toISOString(), }) .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "openrouter/example/current-model\nopenrouter/deepseek/deepseek-v4-flash-0731\n", stderr: "", pid: 1, startedAt: new Date().toISOString(), }); await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "openrouter/deepseek/deepseek-v4-flash-0731", }), ).resolves.toContainEqual({ id: "openrouter/deepseek/deepseek-v4-flash-0731", label: "openrouter/deepseek/deepseek-v4-flash-0731", }); expect(spy).toHaveBeenCalledTimes(3); expect(spy.mock.calls[0]?.[2]).toEqual(["models"]); expect(spy.mock.calls[1]?.[2]).toEqual(["models", "--refresh"]); expect(spy.mock.calls[2]?.[2]).toEqual(["models"]); }); it("still rejects when a refreshed non-empty catalog omits the configured model", async () => { const spy = vi .spyOn(serverUtils, "runChildProcess") .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "openrouter/example/stale-model\n", stderr: "", pid: 1, startedAt: new Date().toISOString(), }) .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "Models cache refreshed\n", stderr: "", pid: 1, startedAt: new Date().toISOString(), }) .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "openrouter/example/current-model\n", stderr: "", pid: 1, startedAt: new Date().toISOString(), }); await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "openrouter/deepseek/deepseek-v4-flash-0731", }), ).rejects.toThrow( "Configured OpenCode model is unavailable: openrouter/deepseek/deepseek-v4-flash-0731", ); expect(spy).toHaveBeenCalledTimes(3); expect(spy.mock.calls[1]?.[2]).toEqual(["models", "--refresh"]); expect(spy.mock.calls[2]?.[2]).toEqual(["models"]); }); it("still rejects from the original catalog when post-refresh enumeration returns no models", async () => { const spy = vi .spyOn(serverUtils, "runChildProcess") .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "openrouter/example/stale-model\n", stderr: "", pid: 1, startedAt: new Date().toISOString(), }) .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "Models cache refreshed\n", stderr: "", pid: 1, startedAt: new Date().toISOString(), }) .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: 1, startedAt: new Date().toISOString(), }); await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "openrouter/deepseek/deepseek-v4-flash-0731", }), ).rejects.toThrow("Available models: openrouter/example/stale-model"); expect(spy).toHaveBeenCalledTimes(3); expect(spy.mock.calls[1]?.[2]).toEqual(["models", "--refresh"]); expect(spy.mock.calls[2]?.[2]).toEqual(["models"]); }); it("still rejects from the original catalog when refresh fails", async () => { const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); const spy = vi .spyOn(serverUtils, "runChildProcess") .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "openrouter/example/stale-model\n", stderr: "", pid: 1, startedAt: new Date().toISOString(), }) .mockRejectedValueOnce(new Error("refresh unavailable")); await expect( ensureOpenCodeModelConfiguredAndAvailable({ model: "openrouter/deepseek/deepseek-v4-flash-0731", }), ).rejects.toThrow("Available models: openrouter/example/stale-model"); expect(spy).toHaveBeenCalledTimes(2); expect(spy.mock.calls[1]?.[2]).toEqual(["models", "--refresh"]); expect(warning).toHaveBeenCalledWith( expect.stringContaining( 'refresh failed for "openrouter/deepseek/deepseek-v4-flash-0731"', ), ); }); it("surfaces the last error once retries are exhausted", async () => { vi.useFakeTimers(); const spy = vi.spyOn(serverUtils, "runChildProcess").mockResolvedValue({ exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "queued behind another opencode run", pid: 1, startedAt: new Date().toISOString(), }); const promise = discoverOpenCodeModels(); const assertion = expect(promise).rejects.toThrow( "`opencode models` failed: queued behind another opencode run", ); await vi.runAllTimersAsync(); await assertion; expect(spy).toHaveBeenCalledTimes(3); }); });