test(auth): add comprehensive tests for Ollama provider

Test coverage for:
- Ollama reachable with models discovered
- Custom endpoint configuration
- Empty models list (fallback model)
- Existing OLLAMA_API_KEY usage and decline
- Non-200 response handling
- Malformed response handling
- setDefaultModel=false (agent model override)
- Provider mapping verification
This commit is contained in:
ParthSareen 2026-01-26 17:18:05 -08:00
parent 48b5d4c857
commit 9660ba5786
2 changed files with 570 additions and 1 deletions

View File

@ -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 { resolveImplicitProviders } from "./models-config.providers.js";
import { mkdtempSync } from "node:fs"; import { mkdtempSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
describe("Ollama provider", () => { 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 () => { it("should not include ollama when no API key is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-")); const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-"));
const providers = await resolveImplicitProviders({ agentDir }); const providers = await resolveImplicitProviders({ agentDir });

View File

@ -33,6 +33,7 @@ describe("applyAuthChoice", () => {
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
const previousOpenrouterKey = process.env.OPENROUTER_API_KEY; const previousOpenrouterKey = process.env.OPENROUTER_API_KEY;
const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY; const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY;
const previousOllamaKey = process.env.OLLAMA_API_KEY;
const previousSshTty = process.env.SSH_TTY; const previousSshTty = process.env.SSH_TTY;
const previousChutesClientId = process.env.CHUTES_CLIENT_ID; const previousChutesClientId = process.env.CHUTES_CLIENT_ID;
let tempStateDir: string | null = null; let tempStateDir: string | null = null;
@ -69,6 +70,11 @@ describe("applyAuthChoice", () => {
} else { } else {
process.env.AI_GATEWAY_API_KEY = previousAiGatewayKey; 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) { if (previousSshTty === undefined) {
delete process.env.SSH_TTY; delete process.env.SSH_TTY;
} else { } 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<string, { key?: string }>;
};
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<string, { key?: string }>;
};
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<string, { key?: string }>;
};
// 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 () => { it("writes Qwen credentials when selecting qwen-portal", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir; process.env.CLAWDBOT_STATE_DIR = tempStateDir;
@ -597,6 +1146,10 @@ describe("resolvePreferredProviderForAuthChoice", () => {
expect(resolvePreferredProviderForAuthChoice("qwen-portal")).toBe("qwen-portal"); expect(resolvePreferredProviderForAuthChoice("qwen-portal")).toBe("qwen-portal");
}); });
it("maps ollama to the provider", () => {
expect(resolvePreferredProviderForAuthChoice("ollama")).toBe("ollama");
});
it("returns undefined for unknown choices", () => { it("returns undefined for unknown choices", () => {
expect(resolvePreferredProviderForAuthChoice("unknown" as AuthChoice)).toBeUndefined(); expect(resolvePreferredProviderForAuthChoice("unknown" as AuthChoice)).toBeUndefined();
}); });