diff --git a/src/agents/models-config.providers.ollama.test.ts b/src/agents/models-config.providers.ollama.test.ts index 0dab1b22c..0a2346958 100644 --- a/src/agents/models-config.providers.ollama.test.ts +++ b/src/agents/models-config.providers.ollama.test.ts @@ -1,10 +1,26 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resolveImplicitProviders } from "./models-config.providers.js"; import { mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; describe("Ollama provider", () => { + const previousOllamaKey = process.env.OLLAMA_API_KEY; + + beforeEach(() => { + // Clear OLLAMA_API_KEY to test the "no key" scenario + delete process.env.OLLAMA_API_KEY; + }); + + afterEach(() => { + // Restore original env var + if (previousOllamaKey === undefined) { + delete process.env.OLLAMA_API_KEY; + } else { + process.env.OLLAMA_API_KEY = previousOllamaKey; + } + }); + it("should not include ollama when no API key is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-")); const providers = await resolveImplicitProviders({ agentDir }); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index fbc434aa3..62a812ad3 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -33,6 +33,7 @@ describe("applyAuthChoice", () => { const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; const previousOpenrouterKey = process.env.OPENROUTER_API_KEY; const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY; + const previousOllamaKey = process.env.OLLAMA_API_KEY; const previousSshTty = process.env.SSH_TTY; const previousChutesClientId = process.env.CHUTES_CLIENT_ID; let tempStateDir: string | null = null; @@ -69,6 +70,11 @@ describe("applyAuthChoice", () => { } else { process.env.AI_GATEWAY_API_KEY = previousAiGatewayKey; } + if (previousOllamaKey === undefined) { + delete process.env.OLLAMA_API_KEY; + } else { + process.env.OLLAMA_API_KEY = previousOllamaKey; + } if (previousSshTty === undefined) { delete process.env.SSH_TTY; } else { @@ -492,6 +498,549 @@ describe("applyAuthChoice", () => { }); }); + it("configures Ollama when selecting ollama and Ollama is reachable", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + const fetchSpy = vi.fn(async (input: string | URL | Request) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "http://127.0.0.1:11434/api/tags") { + return new Response( + JSON.stringify({ + models: [ + { name: "llama3.3:latest", modified_at: "2024-01-01", size: 1000, digest: "abc" }, + { name: "qwen2.5:latest", modified_at: "2024-01-01", size: 2000, digest: "def" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchSpy); + + const confirm = vi.fn(async () => false); // Don't use custom endpoint + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm, + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "ollama", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("custom Ollama endpoint"), + }), + ); + expect(result.config.auth?.profiles?.["ollama:default"]).toMatchObject({ + provider: "ollama", + mode: "api_key", + }); + // Should use first discovered model as default + expect(result.config.agents?.defaults?.model?.primary).toBe("ollama/llama3.3:latest"); + + const authProfilePath = authProfilePathFor(requireAgentDir()); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["ollama:default"]?.key).toBe("ollama"); + }); + + it("uses existing OLLAMA_API_KEY when selecting ollama", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + process.env.OLLAMA_API_KEY = "my-ollama-key"; + + const fetchSpy = vi.fn(async (input: string | URL | Request) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "http://127.0.0.1:11434/api/tags") { + return new Response( + JSON.stringify({ + models: [{ name: "llama3.3", modified_at: "2024-01-01", size: 1000, digest: "abc" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchSpy); + + const confirm = vi + .fn() + .mockResolvedValueOnce(false) // Don't use custom endpoint + .mockResolvedValueOnce(true); // Use existing OLLAMA_API_KEY + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm, + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "ollama", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("OLLAMA_API_KEY"), + }), + ); + expect(result.config.auth?.profiles?.["ollama:default"]).toMatchObject({ + provider: "ollama", + mode: "api_key", + }); + + const authProfilePath = authProfilePathFor(requireAgentDir()); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["ollama:default"]?.key).toBe("my-ollama-key"); + + delete process.env.OLLAMA_API_KEY; + }); + + it("shows error when Ollama is not reachable", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + const fetchSpy = vi.fn(async () => { + throw new Error("Connection refused"); + }); + vi.stubGlobal("fetch", fetchSpy); + + const note = vi.fn(noopAsync); + const confirm = vi.fn(async () => false); // Don't use custom endpoint + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note, + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm, + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "ollama", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Ollama is not reachable"), + "Ollama not detected", + ); + // Should not set auth profile when Ollama is not reachable + expect(result.config.auth?.profiles?.["ollama:default"]).toBeUndefined(); + }); + + it("uses custom Ollama endpoint when specified", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + const customEndpoint = "http://192.168.1.100:11434"; + const fetchSpy = vi.fn(async (input: string | URL | Request) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === `${customEndpoint}/api/tags`) { + return new Response( + JSON.stringify({ + models: [ + { name: "mistral:latest", modified_at: "2024-01-01", size: 1000, digest: "abc" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchSpy); + + const text = vi.fn(async () => customEndpoint); + const confirm = vi.fn(async () => true); // Use custom endpoint + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text, + confirm, + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "ollama", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ message: "Enter Ollama endpoint URL" }), + ); + expect(fetchSpy).toHaveBeenCalledWith(`${customEndpoint}/api/tags`, expect.anything()); + expect(result.config.agents?.defaults?.model?.primary).toBe("ollama/mistral:latest"); + // Custom endpoint should be in provider config + expect(result.config.models?.providers?.ollama).toMatchObject({ + baseUrl: `${customEndpoint}/v1`, + api: "openai-completions", + }); + }); + + it("uses fallback model when Ollama returns empty models list", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + const fetchSpy = vi.fn(async (input: string | URL | Request) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "http://127.0.0.1:11434/api/tags") { + return new Response(JSON.stringify({ models: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchSpy); + + const note = vi.fn(noopAsync); + const confirm = vi.fn(async () => false); // Don't use custom endpoint + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note, + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm, + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "ollama", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(note).toHaveBeenCalledWith(expect.stringContaining("No models found"), "Ollama"); + // Should use the fallback model + expect(result.config.agents?.defaults?.model?.primary).toBe("ollama/llama3.3"); + }); + + it("uses placeholder when user declines existing OLLAMA_API_KEY", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + process.env.OLLAMA_API_KEY = "my-secret-ollama-key"; + + const fetchSpy = vi.fn(async (input: string | URL | Request) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "http://127.0.0.1:11434/api/tags") { + return new Response( + JSON.stringify({ + models: [{ name: "llama3.3", modified_at: "2024-01-01", size: 1000, digest: "abc" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchSpy); + + const confirm = vi + .fn() + .mockResolvedValueOnce(false) // Don't use custom endpoint + .mockResolvedValueOnce(false); // Decline existing OLLAMA_API_KEY + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm, + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + await applyAuthChoice({ + authChoice: "ollama", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + // Should have asked about existing key + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("OLLAMA_API_KEY"), + }), + ); + + const authProfilePath = authProfilePathFor(requireAgentDir()); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + // Should use placeholder "ollama" instead of the env key + expect(parsed.profiles?.["ollama:default"]?.key).toBe("ollama"); + + delete process.env.OLLAMA_API_KEY; + }); + + it("treats non-200 Ollama response as unreachable", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + const fetchSpy = vi.fn(async () => { + return new Response("Internal Server Error", { status: 500 }); + }); + vi.stubGlobal("fetch", fetchSpy); + + const note = vi.fn(noopAsync); + const confirm = vi.fn(async () => false); // Don't use custom endpoint + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note, + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm, + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "ollama", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Ollama is not reachable"), + "Ollama not detected", + ); + expect(result.config.auth?.profiles?.["ollama:default"]).toBeUndefined(); + }); + + it("returns agentModelOverride when setDefaultModel is false", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + const fetchSpy = vi.fn(async (input: string | URL | Request) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "http://127.0.0.1:11434/api/tags") { + return new Response( + JSON.stringify({ + models: [ + { name: "codellama:latest", modified_at: "2024-01-01", size: 1000, digest: "abc" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchSpy); + + const confirm = vi.fn(async () => false); // Don't use custom endpoint + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm, + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "ollama", + config: { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + }, + }, + }, + prompter, + runtime, + setDefaultModel: false, + }); + + // Should not change the existing default model + expect(result.config.agents?.defaults?.model?.primary).toBe("anthropic/claude-opus-4-5"); + // Should return the override for the agent + expect(result.agentModelOverride).toBe("ollama/codellama:latest"); + }); + + it("handles malformed Ollama response gracefully", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + const fetchSpy = vi.fn(async (input: string | URL | Request) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "http://127.0.0.1:11434/api/tags") { + // Return unexpected structure - models is not an array + return new Response(JSON.stringify({ unexpected: "format", models: "not-an-array" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchSpy); + + const note = vi.fn(noopAsync); + const confirm = vi.fn(async () => false); // Don't use custom endpoint + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note, + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm, + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "ollama", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + // Should treat malformed response as reachable but with no models + expect(note).toHaveBeenCalledWith(expect.stringContaining("No models found"), "Ollama"); + // Should still configure auth profile + expect(result.config.auth?.profiles?.["ollama:default"]).toMatchObject({ + provider: "ollama", + mode: "api_key", + }); + // Should use fallback model + expect(result.config.agents?.defaults?.model?.primary).toBe("ollama/llama3.3"); + }); + it("writes Qwen credentials when selecting qwen-portal", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); process.env.CLAWDBOT_STATE_DIR = tempStateDir; @@ -597,6 +1146,10 @@ describe("resolvePreferredProviderForAuthChoice", () => { expect(resolvePreferredProviderForAuthChoice("qwen-portal")).toBe("qwen-portal"); }); + it("maps ollama to the provider", () => { + expect(resolvePreferredProviderForAuthChoice("ollama")).toBe("ollama"); + }); + it("returns undefined for unknown choices", () => { expect(resolvePreferredProviderForAuthChoice("unknown" as AuthChoice)).toBeUndefined(); });