diff --git a/src/cli.ts b/src/cli.ts index 8b49386a..fe4d40fa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -345,7 +345,7 @@ program .option("--no-open", "Skip the viewer handoff after a successful compile") .option( "--provider ", - "Override LLMWIKI_PROVIDER for this run only (e.g. anthropic, openai, ollama)", + "Override LLMWIKI_PROVIDER for this run only (e.g. anthropic, openai, ollama, atlascloud)", ) .option( "--lang ", diff --git a/src/eval/citation-support.ts b/src/eval/citation-support.ts index e65ee129..d835681e 100644 --- a/src/eval/citation-support.ts +++ b/src/eval/citation-support.ts @@ -17,7 +17,12 @@ import path from "path"; import { collectAllPages } from "../linter/rules.js"; import { parseFrontmatter, extractClaimCitations, splitProseParagraphs } from "../utils/markdown.js"; import { callClaude } from "../utils/llm.js"; -import { SOURCES_DIR, DEFAULT_PROVIDER, PROVIDER_MODELS } from "../utils/constants.js"; +import { + SOURCES_DIR, + DEFAULT_PROVIDER, + PROVIDER_MODELS, + normalizeProviderName, +} from "../utils/constants.js"; import { resolveSourceFile } from "./source-path.js"; import type { LLMTool } from "../utils/provider.js"; import type { CitationJudgement, CitationSupportResult } from "./types.js"; @@ -212,7 +217,7 @@ async function appendCachedJudgement(root: string, judgement: CitationJudgement) /** Resolve the current model identifier for recording in judgements. */ function resolveModel(): string { - const provider = process.env.LLMWIKI_PROVIDER ?? DEFAULT_PROVIDER; + const provider = normalizeProviderName(process.env.LLMWIKI_PROVIDER ?? DEFAULT_PROVIDER); return process.env.LLMWIKI_MODEL ?? PROVIDER_MODELS[provider] ?? provider; } diff --git a/src/providers/atlascloud.ts b/src/providers/atlascloud.ts new file mode 100644 index 00000000..575b9f98 --- /dev/null +++ b/src/providers/atlascloud.ts @@ -0,0 +1,52 @@ +/** + * Atlas Cloud LLM provider implementation. + * + * Atlas Cloud exposes an OpenAI-compatible chat completions API. Embeddings are + * not wired until a compatible embedding model is verified for llmwiki. + */ + +import { OpenAIProvider } from "./openai.js"; +import { + ATLASCLOUD_API_KEY_ENV_VARS, + ATLASCLOUD_BASE_URL, + ATLASCLOUD_BASE_URL_ENV_VARS, +} from "../utils/constants.js"; + +function readFirstEnv(names: readonly string[]): string | undefined { + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) return value; + } + return undefined; +} + +/** Resolve Atlas Cloud API key from the supported env-var aliases. */ +export function resolveAtlasCloudApiKeyFromEnv(): string | undefined { + return readFirstEnv(ATLASCLOUD_API_KEY_ENV_VARS); +} + +/** Resolve Atlas Cloud OpenAI-compatible base URL from env, or use the default. */ +export function resolveAtlasCloudBaseURLFromEnv(): string { + return readFirstEnv(ATLASCLOUD_BASE_URL_ENV_VARS) ?? ATLASCLOUD_BASE_URL; +} + +/** Atlas Cloud-backed LLM provider using the OpenAI-compatible endpoint. */ +export class AtlasCloudProvider extends OpenAIProvider { + constructor(model: string, apiKey: string, baseURL = ATLASCLOUD_BASE_URL) { + super(model, { baseURL, apiKey }); + } + + /** Atlas Cloud embedding support is unverified; fail closed instead of inheriting OpenAI semantics. */ + override async embed(_text: string): Promise { + throw new Error( + "Atlas Cloud provider does not support embeddings in llmwiki yet.\n" + + " For semantic search, use LLMWIKI_PROVIDER=openai, anthropic, claude-agent, or ollama.", + ); + } + + /** Atlas Cloud batch embeddings are unsupported for the same reason as single embeddings. */ + override async embedBatch(_texts: string[]): Promise { + await this.embed(""); + return []; + } +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index eb651189..79185892 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -66,6 +66,27 @@ export const RETRY_MULTIPLIER = 4; /** Default provider when LLMWIKI_PROVIDER is not set. */ export const DEFAULT_PROVIDER = "anthropic"; +/** Provider names accepted by LLMWIKI_PROVIDER, including aliases. */ +export const SUPPORTED_PROVIDER_INPUTS = [ + "anthropic", + "claude-agent", + "openai", + "ollama", + "minimax", + "copilot", + "atlascloud", + "atlas-cloud", + "atlas", +] as const; + +/** Normalize accepted LLMWIKI_PROVIDER aliases to provider implementation names. */ +export function normalizeProviderName(providerName: string): string { + if (providerName === "atlas-cloud" || providerName === "atlas") { + return "atlascloud"; + } + return providerName; +} + /** Default model per provider. */ export const PROVIDER_MODELS: Record = { anthropic: "claude-sonnet-4-6", @@ -74,6 +95,7 @@ export const PROVIDER_MODELS: Record = { ollama: "llama3.1", minimax: "MiniMax-M2.7", copilot: "gpt-4o", + atlascloud: "qwen/qwen3.5-flash", }; /** Default Ollama API base URL. */ @@ -82,6 +104,15 @@ export const OLLAMA_DEFAULT_HOST = "http://localhost:11434/v1"; /** GitHub Copilot API base URL (OpenAI-compatible, requires OAuth token). */ export const COPILOT_BASE_URL = "https://api.githubcopilot.com"; +/** Atlas Cloud OpenAI-compatible API base URL. */ +export const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai/v1"; + +/** Atlas Cloud API key env vars, checked in order. */ +export const ATLASCLOUD_API_KEY_ENV_VARS = ["ATLASCLOUD_API_KEY", "ATLAS_CLOUD_API_KEY"] as const; + +/** Atlas Cloud base URL env vars, checked in order. */ +export const ATLASCLOUD_BASE_URL_ENV_VARS = ["ATLASCLOUD_BASE_URL", "ATLAS_CLOUD_BASE_URL"] as const; + /** * Default request timeout for cloud OpenAI-compatible providers (10 minutes). * Matches the OpenAI SDK's own default; called out here so it's explicit. diff --git a/src/utils/provider-guard.ts b/src/utils/provider-guard.ts index c8341268..aa19fda5 100644 --- a/src/utils/provider-guard.ts +++ b/src/utils/provider-guard.ts @@ -17,7 +17,12 @@ * surface fired the guard. */ -import { DEFAULT_PROVIDER } from "./constants.js"; +import { + ATLASCLOUD_API_KEY_ENV_VARS, + DEFAULT_PROVIDER, + SUPPORTED_PROVIDER_INPUTS, + normalizeProviderName, +} from "./constants.js"; import { resolveAnthropicAuthFromEnv } from "./claude-settings.js"; /** Thrown when the active provider has no usable credentials. */ @@ -38,23 +43,28 @@ export class UnknownProviderError extends Error { } } -/** Map of provider name to the env var that satisfies it. Null = no key needed. */ -const PROVIDER_KEY_VARS: Record = { +/** Map of provider name to env vars that satisfy it. Null = no key needed. */ +const PROVIDER_KEY_VARS: Record = { anthropic: "ANTHROPIC_API_KEY", "claude-agent": null, openai: "OPENAI_API_KEY", ollama: null, minimax: "MINIMAX_API_KEY", copilot: "GITHUB_TOKEN", + atlascloud: ATLASCLOUD_API_KEY_ENV_VARS, }; +function normalizeKeyVars(keyVars: string | readonly string[]): string[] { + return typeof keyVars === "string" ? [keyVars] : [...keyVars]; +} + /** * Throw if the active LLM provider is missing credentials. * Anthropic accepts either ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN * (resolved through the Claude Code settings fallback chain). */ export function ensureProviderAvailable(): void { - const provider = process.env.LLMWIKI_PROVIDER ?? DEFAULT_PROVIDER; + const provider = normalizeProviderName(process.env.LLMWIKI_PROVIDER ?? DEFAULT_PROVIDER); if (provider === "anthropic") { const auth = resolveAnthropicAuthFromEnv(); @@ -71,7 +81,7 @@ export function ensureProviderAvailable(): void { const keyVar = PROVIDER_KEY_VARS[provider]; if (keyVar === undefined) { - const supported = Object.keys(PROVIDER_KEY_VARS); + const supported = [...SUPPORTED_PROVIDER_INPUTS]; throw new UnknownProviderError( provider, supported, @@ -79,12 +89,16 @@ export function ensureProviderAvailable(): void { ); } - if (keyVar && !process.env[keyVar]) { + if (!keyVar) return; + + const keyVars = normalizeKeyVars(keyVar); + const hasCredential = keyVars.some((name) => Boolean(process.env[name]?.trim())); + if (!hasCredential) { throw new ProviderUnavailableError( provider, - [keyVar], - `${keyVar} environment variable is required for the "${provider}" provider.\n` + - ` Set it with: export ${keyVar}=`, + keyVars, + `${keyVars.join(" or ")} environment variable is required for the "${provider}" provider.\n` + + ` Set one with: export ${keyVars[0]}=`, ); } } diff --git a/src/utils/provider.ts b/src/utils/provider.ts index a4949593..785f7b36 100644 --- a/src/utils/provider.ts +++ b/src/utils/provider.ts @@ -3,16 +3,27 @@ * * Defines the LLMProvider interface and a factory function that reads * LLMWIKI_PROVIDER and LLMWIKI_MODEL env vars to instantiate the - * appropriate backend (Anthropic, OpenAI, Ollama, or MiniMax). + * appropriate backend (Anthropic, OpenAI, Ollama, MiniMax, or Atlas Cloud). */ -import { DEFAULT_PROVIDER, PROVIDER_MODELS, OLLAMA_DEFAULT_HOST } from "./constants.js"; +import { + DEFAULT_PROVIDER, + PROVIDER_MODELS, + OLLAMA_DEFAULT_HOST, + SUPPORTED_PROVIDER_INPUTS, + normalizeProviderName, +} from "./constants.js"; import { AnthropicProvider } from "../providers/anthropic.js"; import { OpenAIProvider } from "../providers/openai.js"; import { OllamaProvider } from "../providers/ollama.js"; import { MiniMaxProvider } from "../providers/minimax.js"; import { CopilotProvider } from "../providers/copilot.js"; import { ClaudeAgentProvider } from "../providers/claude-agent.js"; +import { + AtlasCloudProvider, + resolveAtlasCloudApiKeyFromEnv, + resolveAtlasCloudBaseURLFromEnv, +} from "../providers/atlascloud.js"; import { resolveAnthropicAuthFromEnv, resolveAnthropicBaseURLFromEnv, @@ -56,7 +67,15 @@ export interface LLMProvider { embedBatch?(texts: string[], inputType?: EmbeddingInputType): Promise; } -const SUPPORTED_PROVIDERS: ReadonlySet = new Set(["anthropic", "claude-agent", "openai", "ollama", "minimax", "copilot"]); +const SUPPORTED_PROVIDERS: ReadonlySet = new Set([ + "anthropic", + "claude-agent", + "openai", + "ollama", + "minimax", + "copilot", + "atlascloud", +]); /** * Factory that returns the appropriate LLMProvider based on env vars. @@ -89,6 +108,8 @@ export function getProvider(): LLMProvider { return getMiniMaxProvider(); case "copilot": return getCopilotProvider(); + case "atlascloud": + return getAtlasCloudProvider(); default: throw new Error(`Unhandled provider: ${providerName}`); } @@ -99,7 +120,7 @@ function readOptionalEnv(name: string): string | undefined { return value ? value : undefined; } -function getModelForProvider(providerName: "openai" | "ollama" | "minimax" | "copilot"): string { +function getModelForProvider(providerName: "openai" | "ollama" | "minimax" | "copilot" | "atlascloud"): string { return process.env.LLMWIKI_MODEL ?? PROVIDER_MODELS[providerName]; } @@ -127,6 +148,21 @@ function getCopilotProvider(): CopilotProvider { return new CopilotProvider(getModelForProvider("copilot"), apiKey); } +function getAtlasCloudProvider(): AtlasCloudProvider { + const apiKey = resolveAtlasCloudApiKeyFromEnv(); + if (!apiKey) { + throw new Error( + "Atlas Cloud provider requires ATLASCLOUD_API_KEY or ATLAS_CLOUD_API_KEY environment variable.\n" + + " Set one with: export ATLASCLOUD_API_KEY=your_key", + ); + } + return new AtlasCloudProvider( + getModelForProvider("atlascloud"), + apiKey, + resolveAtlasCloudBaseURLFromEnv(), + ); +} + function getAnthropicProvider(): AnthropicProvider { const model = resolveAnthropicModelFromEnv() ?? PROVIDER_MODELS.anthropic; const baseURL = resolveAnthropicBaseURLFromEnv(); @@ -149,10 +185,10 @@ function getClaudeAgentProvider(): ClaudeAgentProvider { } function getProviderName(): string { - const providerName = process.env.LLMWIKI_PROVIDER ?? DEFAULT_PROVIDER; + const providerName = normalizeProviderName(process.env.LLMWIKI_PROVIDER ?? DEFAULT_PROVIDER); if (!SUPPORTED_PROVIDERS.has(providerName)) { throw new Error( - `Unknown provider "${providerName}". Supported: ${[...SUPPORTED_PROVIDERS].join(", ")}`, + `Unknown provider "${providerName}". Supported: ${SUPPORTED_PROVIDER_INPUTS.join(", ")}`, ); } return providerName; @@ -177,5 +213,10 @@ export function resolveActiveModelId(): string { if (providerName === "anthropic") { return resolveAnthropicModelFromEnv() ?? PROVIDER_MODELS.anthropic; } - return getModelForProvider(providerName as "openai" | "ollama" | "minimax" | "copilot"); + if (providerName === "claude-agent") { + return resolveAnthropicModelFromEnv() ?? PROVIDER_MODELS["claude-agent"]; + } + return getModelForProvider( + providerName as "openai" | "ollama" | "minimax" | "copilot" | "atlascloud", + ); } diff --git a/test/provider-atlascloud.test.ts b/test/provider-atlascloud.test.ts new file mode 100644 index 00000000..58181865 --- /dev/null +++ b/test/provider-atlascloud.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for the Atlas Cloud LLM provider. + * Covers constructor behavior, env alias resolution, and the embed() stub. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { + AtlasCloudProvider, + resolveAtlasCloudApiKeyFromEnv, + resolveAtlasCloudBaseURLFromEnv, +} from "../src/providers/atlascloud.js"; +import { ATLASCLOUD_BASE_URL } from "../src/utils/constants.js"; + +describe("AtlasCloudProvider", () => { + afterEach(() => { + delete process.env.ATLASCLOUD_API_KEY; + delete process.env.ATLAS_CLOUD_API_KEY; + delete process.env.ATLASCLOUD_BASE_URL; + delete process.env.ATLAS_CLOUD_BASE_URL; + }); + + it("constructs without throwing when given a key and model", () => { + expect(() => new AtlasCloudProvider("qwen/qwen3.5-flash", "atlas-test-key")).not.toThrow(); + }); + + it("uses the Atlas Cloud OpenAI-compatible base URL by default", () => { + const provider = new AtlasCloudProvider("qwen/qwen3.5-flash", "atlas-test-key"); + const clientBaseURL = Reflect.get(Reflect.get(provider, "client"), "baseURL") as string; + expect(clientBaseURL).toBe(ATLASCLOUD_BASE_URL); + }); + + it("resolves ATLASCLOUD_API_KEY before the ATLAS_CLOUD_API_KEY alias", () => { + process.env.ATLASCLOUD_API_KEY = "primary-key"; + process.env.ATLAS_CLOUD_API_KEY = "alias-key"; + + expect(resolveAtlasCloudApiKeyFromEnv()).toBe("primary-key"); + }); + + it("falls back to ATLAS_CLOUD_API_KEY when the primary key is absent", () => { + process.env.ATLAS_CLOUD_API_KEY = "alias-key"; + + expect(resolveAtlasCloudApiKeyFromEnv()).toBe("alias-key"); + }); + + it("resolves the Atlas Cloud base URL alias", () => { + process.env.ATLAS_CLOUD_BASE_URL = "https://atlas-proxy.example/v1"; + + expect(resolveAtlasCloudBaseURLFromEnv()).toBe("https://atlas-proxy.example/v1"); + }); + + it("throws on embed() with a helpful message", async () => { + const provider = new AtlasCloudProvider("qwen/qwen3.5-flash", "atlas-test-key"); + + await expect(provider.embed("hello")).rejects.toThrow( + "Atlas Cloud provider does not support embeddings", + ); + }); +}); diff --git a/test/provider-factory.test.ts b/test/provider-factory.test.ts index e1a2b686..360a8ee6 100644 --- a/test/provider-factory.test.ts +++ b/test/provider-factory.test.ts @@ -7,11 +7,13 @@ import { describe, it, expect, afterEach } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { getProvider } from "../src/utils/provider.js"; +import { getProvider, resolveActiveModelId } from "../src/utils/provider.js"; import { AnthropicProvider } from "../src/providers/anthropic.js"; import { OpenAIProvider } from "../src/providers/openai.js"; import { OllamaProvider } from "../src/providers/ollama.js"; import { MiniMaxProvider } from "../src/providers/minimax.js"; +import { AtlasCloudProvider } from "../src/providers/atlascloud.js"; +import { ATLASCLOUD_BASE_URL, PROVIDER_MODELS } from "../src/utils/constants.js"; const TEST_SETTINGS_PATH_ENV = "LLMWIKI_CLAUDE_SETTINGS_PATH"; const tempDirs: string[] = []; @@ -63,6 +65,10 @@ describe("getProvider", () => { delete process.env.OLLAMA_EMBEDDINGS_HOST; delete process.env[TEST_SETTINGS_PATH_ENV]; delete process.env.MINIMAX_API_KEY; + delete process.env.ATLASCLOUD_API_KEY; + delete process.env.ATLAS_CLOUD_API_KEY; + delete process.env.ATLASCLOUD_BASE_URL; + delete process.env.ATLAS_CLOUD_BASE_URL; for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); @@ -134,6 +140,46 @@ describe("getProvider", () => { expect(() => getProvider()).toThrow("MINIMAX_API_KEY"); }); + it.each(["atlascloud", "atlas-cloud", "atlas"])( + "returns AtlasCloudProvider when LLMWIKI_PROVIDER=%s", + (providerName) => { + process.env.LLMWIKI_PROVIDER = providerName; + process.env.ATLASCLOUD_API_KEY = "atlas-test-key"; + + const provider = getProvider(); + + expect(provider).toBeInstanceOf(AtlasCloudProvider); + expect(Reflect.get(provider, "model")).toBe(PROVIDER_MODELS.atlascloud); + expectClientBaseURL(provider, "client", ATLASCLOUD_BASE_URL); + }, + ); + + it("uses ATLAS_CLOUD_API_KEY when ATLASCLOUD_API_KEY is absent", () => { + process.env.LLMWIKI_PROVIDER = "atlascloud"; + process.env.ATLAS_CLOUD_API_KEY = "atlas-alias-key"; + + const provider = getProvider(); + + expect(provider).toBeInstanceOf(AtlasCloudProvider); + }); + + it("passes configured Atlas Cloud base URL alias", () => { + process.env.LLMWIKI_PROVIDER = "atlascloud"; + process.env.ATLASCLOUD_API_KEY = "atlas-test-key"; + process.env.ATLAS_CLOUD_BASE_URL = "https://atlas-proxy.example/v1"; + + const provider = getProvider(); + + expect(provider).toBeInstanceOf(AtlasCloudProvider); + expectClientBaseURL(provider, "client", "https://atlas-proxy.example/v1"); + }); + + it("resolves the Atlas Cloud default model for provider aliases", () => { + process.env.LLMWIKI_PROVIDER = "atlas"; + + expect(resolveActiveModelId()).toBe(PROVIDER_MODELS.atlascloud); + }); + it("respects LLMWIKI_MODEL override", () => { process.env.LLMWIKI_PROVIDER = "openai"; process.env.LLMWIKI_MODEL = "gpt-4-turbo"; diff --git a/test/utils/provider-guard.test.ts b/test/utils/provider-guard.test.ts index 5ed23557..0dc350f0 100644 --- a/test/utils/provider-guard.test.ts +++ b/test/utils/provider-guard.test.ts @@ -39,6 +39,24 @@ describe("ensureProviderAvailable error taxonomy", () => { expect(err.missing).toContain("OPENAI_API_KEY"); }); + it("throws ProviderUnavailableError with both Atlas Cloud key aliases when missing", () => { + process.env.LLMWIKI_PROVIDER = "atlas-cloud"; + delete process.env.ATLASCLOUD_API_KEY; + delete process.env.ATLAS_CLOUD_API_KEY; + + const err = assertUnavailableError(catchGuardError(), "atlascloud"); + + expect(err.missing).toContain("ATLASCLOUD_API_KEY"); + expect(err.missing).toContain("ATLAS_CLOUD_API_KEY"); + }); + + it("accepts ATLAS_CLOUD_API_KEY for Atlas Cloud provider aliases", () => { + process.env.LLMWIKI_PROVIDER = "atlas"; + process.env.ATLAS_CLOUD_API_KEY = "atlas-test-key"; + + expect(() => ensureProviderAvailable()).not.toThrow(); + }); + it("throws UnknownProviderError for an unsupported provider", () => { process.env.LLMWIKI_PROVIDER = "bogus"; const e = catchGuardError();