diff --git a/CHANGELOG.md b/CHANGELOG.md index 47942320..42e03273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Atlas Cloud provider** — `LLMWIKI_PROVIDER=atlascloud` (aliases `atlas-cloud`, `atlas`) routes chat and tool calls through the [Atlas Cloud](https://www.atlascloud.ai) gateway, which exposes an OpenAI-compatible API across models from several publishers. Authenticate with `ATLASCLOUD_API_KEY` or `ATLAS_CLOUD_API_KEY`; `ATLASCLOUD_BASE_URL` overrides the endpoint. + + Model ids are namespaced by publisher, and `compile` extracts concepts through a tool call, so the default is a model Atlas Cloud lists as supporting tools. Embeddings are not wired up: the provider fails closed rather than inheriting OpenAI's semantics, so route them elsewhere with `LLMWIKI_EMBEDDING_PROVIDER` for semantic search. + + Contributed by **@binyangzhu000-sudo** (#167). + - **Separate embedding provider** — `LLMWIKI_EMBEDDING_PROVIDER` selects the backend that serves embeddings, independently of `LLMWIKI_PROVIDER`. This makes split setups possible, such as Claude Agent SDK for generation with a local vLLM instance serving embeddings over its OpenAI-compatible endpoint. Valid values are `anthropic`, `claude-agent`, `openai`, and `ollama`. `minimax` and `copilot` expose no embeddings API, and naming one now fails with a clear error listing the valid values instead of an opaque failure from the provider's `embed()`. When the variable is set, the provider's own credential is required — `VOYAGE_API_KEY` for `anthropic` and `claude-agent`, `OPENAI_API_KEY` for `openai` — unless `OPENAI_EMBEDDINGS_BASE_URL` points at a self-hosted endpoint, which needs no key. Behaviour is unchanged when the variable is unset. Thanks to **@knew-inventai** for the request (#154). diff --git a/README.md b/README.md index bc0545dd..0a29d50b 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Do not use llmwiki as a general static-site generator, a heavy ontology database - **SDK.** `createWiki({ root })` drives ingest, compile, query, context, status, export, eval, and OKF import/export from TypeScript without shelling out. - **Open Knowledge Format exchange.** Export and import OKF bundles for portable, markdown-native knowledge exchange. External OKF imports are staged through the review queue by default; trusted bundles can be written live explicitly. - **Other portable exports.** Export JSON, JSON-LD, GraphML, Marp slides, and `llms.txt` for downstream systems. -- **Provider portable.** Anthropic, Claude Agent SDK local login, OpenAI-compatible servers, Ollama, GitHub Copilot, and local OpenAI-compatible runtimes. +- **Provider portable.** Anthropic, Claude Agent SDK local login, OpenAI-compatible servers, Ollama, GitHub Copilot, Atlas Cloud, and local OpenAI-compatible runtimes. ## Configurable Lifecycle Profiles (CLP) @@ -276,6 +276,7 @@ Provider selection is environment-driven: | OpenAI-compatible | `LLMWIKI_PROVIDER=openai`, `OPENAI_API_KEY`, optional `OPENAI_BASE_URL` | | Ollama | `LLMWIKI_PROVIDER=ollama`, `OLLAMA_HOST` | | GitHub Copilot | `LLMWIKI_PROVIDER=copilot`, `GITHUB_TOKEN=$(gh auth token)` | +| Atlas Cloud | `LLMWIKI_PROVIDER=atlascloud`, `ATLASCLOUD_API_KEY` | See [`docs/configuration/providers.mdx`](docs/configuration/providers.mdx) and [`docs/configuration/environment-variables.mdx`](docs/configuration/environment-variables.mdx). diff --git a/docs/configuration/environment-variables.mdx b/docs/configuration/environment-variables.mdx index e5520e7d..e21b35ba 100644 --- a/docs/configuration/environment-variables.mdx +++ b/docs/configuration/environment-variables.mdx @@ -14,7 +14,7 @@ You don't need to set everything - only the variables relevant to your chosen pr | Variable | Default | Description | |---|---|---| -| `LLMWIKI_PROVIDER` | `anthropic` | Provider to use. One of: `anthropic`, `openai`, `ollama`, `minimax`, `copilot`, `claude-agent` | +| `LLMWIKI_PROVIDER` | `anthropic` | Provider to use. One of: `anthropic`, `openai`, `ollama`, `minimax`, `copilot`, `claude-agent`, `atlascloud` (aliases: `atlas-cloud`, `atlas`) | | `LLMWIKI_MODEL` | Provider default | Model name to use. Overrides the provider's built-in default model | | `LLMWIKI_EMBEDDING_PROVIDER` | Active chat provider | Backend serving embeddings, independent of `LLMWIKI_PROVIDER`. One of `anthropic`, `claude-agent`, `openai`, `ollama` | | `LLMWIKI_EMBEDDING_MODEL` | Provider default | Embedding model to use. Applies only when the effective embedding provider is `openai` or `ollama` - ignored for `anthropic` and `claude-agent`, even when `LLMWIKI_EMBEDDING_PROVIDER` names one of them explicitly | @@ -79,6 +79,17 @@ Either `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` satisfies authentication - --- +## Atlas Cloud + +| Variable | Required | Description | +|---|---|---| +| `ATLASCLOUD_API_KEY` | Yes (for `atlascloud` provider) | API key for the [Atlas Cloud](https://www.atlascloud.ai) gateway. `ATLAS_CLOUD_API_KEY` is accepted as an alternative; whichever is set first in that order wins | +| `ATLASCLOUD_BASE_URL` | No | Override the API base URL. `ATLAS_CLOUD_BASE_URL` is accepted as an alternative | + +Model names are namespaced by publisher (e.g. `qwen/qwen3.5-35b-a3b`, the default). An override set through `LLMWIKI_MODEL` must be a model Atlas Cloud lists as supporting tools, because `compile` extracts concepts through a tool call. + +--- + ## Voyage (embeddings) | Variable | Required | Description | diff --git a/docs/configuration/providers.mdx b/docs/configuration/providers.mdx index 527edd31..3baf43fe 100644 --- a/docs/configuration/providers.mdx +++ b/docs/configuration/providers.mdx @@ -1,7 +1,7 @@ --- title: "LLM Provider Setup and Configuration Guide for llmwiki" sidebarTitle: "Providers" -description: "Configure llmwiki to use Anthropic, OpenAI-compatible endpoints, Ollama, GitHub Copilot, or the Claude Agent SDK provider for local login." +description: "Configure llmwiki to use Anthropic, OpenAI-compatible endpoints, Ollama, GitHub Copilot, Atlas Cloud, or the Claude Agent SDK provider for local login." --- llmwiki is provider-portable. Whether you have an Anthropic API key, a GitHub Copilot subscription, a locally-running Ollama server, or just a Claude Code login, you can point llmwiki at the right backend with a handful of environment variables - no config files required for most setups. Choose the provider that matches your existing credentials and infrastructure. @@ -220,6 +220,44 @@ export OPENAI_EMBEDDINGS_API_KEY= `GITHUB_TOKEN` must be a GitHub OAuth token obtained via `gh auth token` after running `gh auth refresh --scopes copilot`. Classic personal access tokens (PATs) are rejected by the Copilot API and will not work. + + + +The `atlascloud` provider routes calls through [Atlas Cloud](https://www.atlascloud.ai), a hosted gateway exposing an OpenAI-compatible API across models from several vendors. Model names are namespaced by publisher (for example `qwen/qwen3.5-35b-a3b`). + +`LLMWIKI_PROVIDER` also accepts `atlas-cloud` and `atlas` as aliases. + +**Variables** + +| Variable | Required | Description | +|---|---|---| +| `LLMWIKI_PROVIDER` | Yes | Set to `atlascloud` (or `atlas-cloud`, `atlas`) | +| `ATLASCLOUD_API_KEY` | Yes | Your Atlas Cloud API key. `ATLAS_CLOUD_API_KEY` is accepted as an alternative | +| `LLMWIKI_MODEL` | No | Model override. Must be a namespaced id such as `deepseek-ai/deepseek-v3.2` | +| `ATLASCLOUD_BASE_URL` | No | Override the API base URL. `ATLAS_CLOUD_BASE_URL` is accepted as an alternative | + +**Setup** + +```bash +export LLMWIKI_PROVIDER=atlascloud +export ATLASCLOUD_API_KEY= +llmwiki compile +``` + +**Choosing a model** + +`llmwiki compile` extracts concepts through a tool call and requires the model to return tool arguments, so an override must be a model Atlas Cloud lists as supporting tools. The default, `qwen/qwen3.5-35b-a3b`, does. A model without tool support fails on the first extraction request rather than degrading. + +**Embeddings** + +Atlas Cloud embeddings are not wired up in llmwiki, so the provider fails closed rather than inheriting OpenAI's embedding semantics. To keep Atlas Cloud for chat while using embeddings, route them to another backend with `LLMWIKI_EMBEDDING_PROVIDER` - see [Environment Variables](/configuration/environment-variables): + +```bash +export LLMWIKI_PROVIDER=atlascloud +export LLMWIKI_EMBEDDING_PROVIDER=openai +export OPENAI_EMBEDDINGS_API_KEY= +``` + diff --git a/src/cli.ts b/src/cli.ts index 60163c5b..e72afa21 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -360,7 +360,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..fcf10732 --- /dev/null +++ b/src/providers/atlascloud.ts @@ -0,0 +1,54 @@ +/** + * 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, route embeddings to another backend and keep this\n" + + " provider for chat: export LLMWIKI_EMBEDDING_PROVIDER=openai (or anthropic,\n" + + " claude-agent, 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/providers/copilot.ts b/src/providers/copilot.ts index 29f08958..002b7d88 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -27,9 +27,10 @@ export class CopilotProvider extends OpenAIProvider { override async embed(_text: string): Promise { throw new Error( "GitHub Copilot does not support embeddings.\n" + - " For semantic search (llmwiki query), switch to the OpenAI provider:\n" + - " export LLMWIKI_PROVIDER=openai\n" + - " export OPENAI_API_KEY=sk-...", + " For semantic search (llmwiki query), route embeddings to another backend\n" + + " and keep Copilot for chat:\n" + + " export LLMWIKI_EMBEDDING_PROVIDER=openai\n" + + " export OPENAI_EMBEDDINGS_API_KEY=sk-...", ); } diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts index e6c9eefb..57975542 100644 --- a/src/providers/minimax.ts +++ b/src/providers/minimax.ts @@ -20,7 +20,9 @@ export class MiniMaxProvider extends OpenAIProvider { override async embed(_text: string): Promise { throw new Error( "MiniMax provider does not support embeddings in llmwiki yet.\n" + - " For semantic search, use LLMWIKI_PROVIDER=openai, anthropic, claude-agent, or ollama.", + " For semantic search, route embeddings to another backend and keep this\n" + + " provider for chat: export LLMWIKI_EMBEDDING_PROVIDER=openai (or anthropic,\n" + + " claude-agent, ollama).", ); } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index eb651189..610fef56 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,15 @@ export const PROVIDER_MODELS: Record = { ollama: "llama3.1", minimax: "MiniMax-M2.7", copilot: "gpt-4o", + // Atlas Cloud's catalogue marks tool support per model in `supported_features`, + // and `qwen/qwen3.5-flash` carries no such entry while 35 of its 136 models + // advertise `["json_mode","structured_outputs","tools"]`. Compile's extraction + // pass goes through `toolCall` with `tool_choice: "required"`, so a default + // that cannot return tool calls fails on the first request rather than + // degrading — the provider would look installed and not work. This is the + // smallest catalogue model that does advertise tools, keeping the cheap-and-fast + // intent of the original default. + atlascloud: "qwen/qwen3.5-35b-a3b", }; /** Default Ollama API base URL. */ @@ -82,6 +112,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 fb8e1eda..7ee4a8ef 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"; import { findEmbeddingProviderProblem } from "./embedding-provider.js"; @@ -39,16 +44,27 @@ 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 the env var(s) that satisfy it. Null = no key needed. + * + * A LIST means any one of them satisfies the check, in the order a message + * should name them. + */ +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, }; +/** One-or-many credential names as a list, so the check has a single shape. */ +function normalizeKeyVars(keyVars: string | readonly string[]): string[] { + return typeof keyVars === "string" ? [keyVars] : [...keyVars]; +} + /** * Throw if LLMWIKI_EMBEDDING_PROVIDER names a backend that cannot serve * embeddings, or one whose credential is missing (issue #154). @@ -80,7 +96,7 @@ function ensureEmbeddingProviderAvailable(): void { */ export function ensureProviderAvailable(): void { ensureEmbeddingProviderAvailable(); - const provider = process.env.LLMWIKI_PROVIDER ?? DEFAULT_PROVIDER; + const provider = normalizeProviderName(process.env.LLMWIKI_PROVIDER ?? DEFAULT_PROVIDER); if (provider === "anthropic") { const auth = resolveAnthropicAuthFromEnv(); @@ -97,7 +113,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, @@ -105,12 +121,14 @@ export function ensureProviderAvailable(): void { ); } - if (keyVar && !process.env[keyVar]) { - throw new ProviderUnavailableError( - provider, - [keyVar], - `${keyVar} environment variable is required for the "${provider}" provider.\n` + - ` Set it with: export ${keyVar}=`, - ); - } + if (!keyVar) return; + + const keyVars = normalizeKeyVars(keyVar); + if (keyVars.some((name) => Boolean(process.env[name]?.trim()))) return; + throw new ProviderUnavailableError( + provider, + 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 0582da20..a8d5857c 100644 --- a/src/utils/provider.ts +++ b/src/utils/provider.ts @@ -3,16 +3,28 @@ * * 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, Claude Agent SDK, OpenAI, Ollama, MiniMax, + * GitHub Copilot, 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 +68,17 @@ export interface LLMProvider { embedBatch?(texts: string[], inputType?: EmbeddingInputType): Promise; } -const SUPPORTED_PROVIDERS: ReadonlySet = new Set(["anthropic", "claude-agent", "openai", "ollama", "minimax", "copilot"]); +/** + * Implementation names `buildProvider` can construct, DERIVED from the accepted + * inputs rather than written out again. + * + * The two used to be separate hand-maintained lists, which is a guard that + * rejects what the factory can build as soon as someone updates one and not the + * other. Deriving means a new provider is added in exactly one place. + */ +const SUPPORTED_PROVIDERS: ReadonlySet = new Set( + SUPPORTED_PROVIDER_INPUTS.map(normalizeProviderName), +); /** * Construct the provider named `providerName`, independent of which provider is @@ -88,6 +110,8 @@ export function buildProvider(providerName: string): LLMProvider { return getMiniMaxProvider(); case "copilot": return getCopilotProvider(); + case "atlascloud": + return getAtlasCloudProvider(); default: throw new Error(`Unhandled provider: ${providerName}`); } @@ -107,7 +131,9 @@ 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]; } @@ -135,6 +161,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(); @@ -157,10 +198,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; @@ -185,5 +226,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-copilot.test.ts b/test/provider-copilot.test.ts index db611617..7a51e5c8 100644 --- a/test/provider-copilot.test.ts +++ b/test/provider-copilot.test.ts @@ -26,9 +26,18 @@ describe("CopilotProvider", () => { ); }); - it("embed() error message mentions switching to the openai provider", async () => { + // The remedy is LLMWIKI_EMBEDDING_PROVIDER, not LLMWIKI_PROVIDER: #154 exists + // so a project can keep Copilot for chat and route only embeddings elsewhere. + // Telling the user to change LLMWIKI_PROVIDER costs them the provider they + // chose, and contradicts what the docs prescribe. + it("embed() points at the embedding-provider override, not at switching provider", async () => { const provider = new CopilotProvider("gpt-4o", "ghp_test"); - await expect(provider.embed("hello")).rejects.toThrow("LLMWIKI_PROVIDER=openai"); + await expect(provider.embed("hello")).rejects.toThrow("LLMWIKI_EMBEDDING_PROVIDER=openai"); + }); + + it("embed() does not tell the user to abandon Copilot for chat", async () => { + const provider = new CopilotProvider("gpt-4o", "ghp_test"); + await expect(provider.embed("hello")).rejects.not.toThrow("LLMWIKI_PROVIDER=openai"); }); }); 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();