Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ program
.option("--no-open", "Skip the viewer handoff after a successful compile")
.option(
"--provider <name>",
"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 <code>",
Expand Down
9 changes: 7 additions & 2 deletions src/eval/citation-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

Expand Down
52 changes: 52 additions & 0 deletions src/providers/atlascloud.ts
Original file line number Diff line number Diff line change
@@ -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<number[]> {
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<number[][]> {
await this.embed("");
return [];
}
}
31 changes: 31 additions & 0 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
anthropic: "claude-sonnet-4-6",
Expand All @@ -74,6 +95,7 @@ export const PROVIDER_MODELS: Record<string, string> = {
ollama: "llama3.1",
minimax: "MiniMax-M2.7",
copilot: "gpt-4o",
atlascloud: "qwen/qwen3.5-flash",
};

/** Default Ollama API base URL. */
Expand All @@ -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.
Expand Down
32 changes: 23 additions & 9 deletions src/utils/provider-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<string, string | null> = {
/** Map of provider name to env vars that satisfy it. Null = no key needed. */
const PROVIDER_KEY_VARS: Record<string, string | readonly string[] | null> = {
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();
Expand All @@ -71,20 +81,24 @@ 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,
`Unknown provider "${provider}".\n` + ` Supported: ${supported.join(", ")}`,
);
}

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}=<your-key>`,
keyVars,
`${keyVars.join(" or ")} environment variable is required for the "${provider}" provider.\n` +
` Set one with: export ${keyVars[0]}=<your-key>`,
);
}
}
55 changes: 48 additions & 7 deletions src/utils/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -56,7 +67,15 @@ export interface LLMProvider {
embedBatch?(texts: string[], inputType?: EmbeddingInputType): Promise<number[][]>;
}

const SUPPORTED_PROVIDERS: ReadonlySet<string> = new Set(["anthropic", "claude-agent", "openai", "ollama", "minimax", "copilot"]);
const SUPPORTED_PROVIDERS: ReadonlySet<string> = new Set([
"anthropic",
"claude-agent",
"openai",
"ollama",
"minimax",
"copilot",
"atlascloud",
]);

/**
* Factory that returns the appropriate LLMProvider based on env vars.
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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];
}

Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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",
);
}
58 changes: 58 additions & 0 deletions test/provider-atlascloud.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
Loading
Loading