feat(auth): add Ollama auth choice handler
Add support for Ollama as an auth provider with: - Auto-discovery of local Ollama instance at localhost:11434 - Custom endpoint URL support with HTTP/HTTPS validation - Model discovery via /api/tags endpoint - Graceful handling of unreachable/malformed responses - Support for existing OLLAMA_API_KEY environment variable
This commit is contained in:
parent
6859e1e6a6
commit
a4779a3560
185
src/commands/auth-choice.apply.ollama.ts
Normal file
185
src/commands/auth-choice.apply.ollama.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { formatApiKeyPreview } from "./auth-choice.api-key.js";
|
||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
OLLAMA_DEFAULT_MODEL_REF,
|
||||
setOllamaApiKey,
|
||||
} from "./onboard-auth.js";
|
||||
|
||||
const OLLAMA_PLACEHOLDER_KEY = "ollama";
|
||||
const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
|
||||
const OLLAMA_TIMEOUT_MS = 8000;
|
||||
|
||||
function isValidHttpUrl(value: string | undefined): string | undefined {
|
||||
if (!value?.trim()) return "URL is required";
|
||||
try {
|
||||
const { protocol } = new URL(value);
|
||||
return protocol === "http:" || protocol === "https:"
|
||||
? undefined
|
||||
: "Only HTTP and HTTPS URLs are supported";
|
||||
} catch {
|
||||
return "Invalid URL format";
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOllamaReachable(
|
||||
baseUrl: string,
|
||||
): Promise<{ reachable: boolean; models: string[] }> {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/tags`, {
|
||||
signal: AbortSignal.timeout(OLLAMA_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { reachable: false, models: [] };
|
||||
|
||||
const data: { models?: unknown } = await res.json();
|
||||
if (!Array.isArray(data.models)) return { reachable: true, models: [] };
|
||||
|
||||
const models = data.models
|
||||
.map((m: { name?: string }) => m?.name)
|
||||
.filter((name): name is string => typeof name === "string");
|
||||
return { reachable: true, models };
|
||||
} catch {
|
||||
return { reachable: false, models: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAuthChoiceOllama(
|
||||
params: ApplyAuthChoiceParams,
|
||||
): Promise<ApplyAuthChoiceResult | null> {
|
||||
if (params.authChoice !== "ollama") return null;
|
||||
|
||||
const { prompter, agentDir, setDefaultModel, agentId } = params;
|
||||
|
||||
// Prompt for custom endpoint
|
||||
const useCustomEndpoint = await prompter.confirm({
|
||||
message: `Use a custom Ollama endpoint? (default: ${OLLAMA_DEFAULT_BASE_URL})`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
let baseUrl = OLLAMA_DEFAULT_BASE_URL;
|
||||
if (useCustomEndpoint) {
|
||||
const input = await prompter.text({
|
||||
message: "Enter Ollama endpoint URL",
|
||||
initialValue: OLLAMA_DEFAULT_BASE_URL,
|
||||
validate: isValidHttpUrl,
|
||||
});
|
||||
baseUrl = typeof input === "string" && input.trim() ? input.trim() : OLLAMA_DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
const { reachable, models } = await checkOllamaReachable(baseUrl);
|
||||
|
||||
if (!reachable) {
|
||||
await prompter.note(
|
||||
`Ollama is not reachable at ${baseUrl}
|
||||
|
||||
Please ensure Ollama is running:
|
||||
1. Install Ollama: https://ollama.ai
|
||||
2. Start Ollama: ollama serve
|
||||
3. Pull a model: ollama pull llama3.3
|
||||
|
||||
Then re-run the onboarding wizard.`,
|
||||
"Ollama not detected",
|
||||
);
|
||||
return { config: params.config };
|
||||
}
|
||||
|
||||
// Show available models
|
||||
if (models.length > 0) {
|
||||
const truncate = (s: string) => (s.length > 40 ? `${s.slice(0, 37)}...` : s);
|
||||
const preview = models.slice(0, 5).map(truncate).join(", ");
|
||||
await prompter.note(
|
||||
`Found ${models.length} model(s): ${preview}${models.length > 5 ? ", ..." : ""}`,
|
||||
"Ollama",
|
||||
);
|
||||
} else {
|
||||
await prompter.note("No models found. Pull a model first:\n ollama pull llama3.3", "Ollama");
|
||||
}
|
||||
|
||||
// Check for existing OLLAMA_API_KEY
|
||||
let apiKey = OLLAMA_PLACEHOLDER_KEY;
|
||||
const envKey = resolveEnvApiKey("ollama");
|
||||
if (envKey) {
|
||||
const useExisting = await prompter.confirm({
|
||||
message: `Use existing OLLAMA_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) apiKey = envKey.apiKey;
|
||||
}
|
||||
|
||||
await setOllamaApiKey(apiKey, agentDir);
|
||||
|
||||
let config = applyAuthProfileConfig(params.config, {
|
||||
profileId: "ollama:default",
|
||||
provider: "ollama",
|
||||
mode: "api_key",
|
||||
});
|
||||
|
||||
const defaultModel = models.length > 0 ? `ollama/${models[0]}` : OLLAMA_DEFAULT_MODEL_REF;
|
||||
|
||||
const applied = await applyDefaultModelChoice({
|
||||
config,
|
||||
setDefaultModel,
|
||||
defaultModel,
|
||||
applyDefaultConfig: (cfg) => applyOllamaConfig(cfg, defaultModel, baseUrl),
|
||||
applyProviderConfig: (cfg) => applyOllamaProviderConfig(cfg, baseUrl),
|
||||
noteDefault: defaultModel,
|
||||
noteAgentModel: async (model) => {
|
||||
if (agentId) {
|
||||
await prompter.note(
|
||||
`Default model set to ${model} for agent "${agentId}".`,
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
},
|
||||
prompter,
|
||||
});
|
||||
|
||||
return { config: applied.config, agentModelOverride: applied.agentModelOverride };
|
||||
}
|
||||
|
||||
function applyOllamaProviderConfig(cfg: ClawdbotConfig, baseUrl: string): ClawdbotConfig {
|
||||
if (baseUrl === OLLAMA_DEFAULT_BASE_URL) {
|
||||
return {
|
||||
...cfg,
|
||||
models: { mode: cfg.models?.mode ?? "merge", providers: cfg.models?.providers },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers: {
|
||||
...cfg.models?.providers,
|
||||
ollama: {
|
||||
baseUrl: `${baseUrl}/v1`,
|
||||
api: "openai-completions" as const,
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyOllamaConfig(cfg: ClawdbotConfig, modelRef: string, baseUrl: string): ClawdbotConfig {
|
||||
const next = applyOllamaProviderConfig(cfg, baseUrl);
|
||||
const existing = next.agents?.defaults?.model;
|
||||
const fallbacks =
|
||||
existing && typeof existing === "object" && "fallbacks" in existing
|
||||
? (existing as { fallbacks?: string[] }).fallbacks
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: { ...(fallbacks && { fallbacks }), primary: modelRef },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -164,3 +164,17 @@ export async function setOpencodeZenApiKey(key: string, agentDir?: string) {
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export const OLLAMA_DEFAULT_MODEL_REF = "ollama/llama3.3";
|
||||
|
||||
export async function setOllamaApiKey(key: string, agentDir?: string) {
|
||||
upsertAuthProfile({
|
||||
profileId: "ollama:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "ollama",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
@ -33,12 +33,14 @@ export {
|
||||
applyOpencodeZenProviderConfig,
|
||||
} from "./onboard-auth.config-opencode.js";
|
||||
export {
|
||||
OLLAMA_DEFAULT_MODEL_REF,
|
||||
OPENROUTER_DEFAULT_MODEL_REF,
|
||||
setAnthropicApiKey,
|
||||
setGeminiApiKey,
|
||||
setKimiCodeApiKey,
|
||||
setMinimaxApiKey,
|
||||
setMoonshotApiKey,
|
||||
setOllamaApiKey,
|
||||
setOpencodeZenApiKey,
|
||||
setOpenrouterApiKey,
|
||||
setSyntheticApiKey,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user