feat(wiki): add Atlas Cloud provider preset

Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com>
This commit is contained in:
binyangzhu000-sudo 2026-08-07 12:00:52 +08:00
parent 021ac30376
commit 5b00b332f6
10 changed files with 128 additions and 17 deletions

View file

@ -768,6 +768,10 @@ gitnexus wiki
gitnexus wiki --model gpt-4o
gitnexus wiki --base-url https://api.anthropic.com/v1
# Use the Atlas Cloud preset
export ATLASCLOUD_API_KEY=<key>
gitnexus wiki --provider atlascloud
# Force full regeneration
gitnexus wiki --force

View file

@ -256,6 +256,7 @@ gitnexus clean # Delete index for current repo
gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: minimax/minimax-m2.5)
ATLASCLOUD_API_KEY=<key> gitnexus wiki --provider atlascloud # Atlas Cloud preset
gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local
# Allow an exact LAN/self-hosted HTTP LLM host; env: GITNEXUS_ALLOW_INSECURE_CONNECTION
gitnexus doctor # Show runtime platform capabilities and embedding configuration

View file

@ -226,7 +226,7 @@ export const en = {
'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',
'help.option.wiki.force': 'Force full regeneration even if up to date',
'help.option.wiki.provider':
'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)',
'LLM provider: openai, openrouter, atlascloud, azure, custom, cursor, claude, codex, or opencode (default: openai)',
'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)',
'help.option.wiki.baseUrl':
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',

View file

@ -214,7 +214,7 @@ export const zhCN = {
'清理已暂存的 LadybugDB 恢复 sidecar(missing-shadow WAL 隔离文件与 dirty-recovery 暂存文件)',
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
'help.option.wiki.provider':
'LLM 提供商:openai、openrouter、azure、custom、cursor、claude、codex 或 opencode(默认:openai)',
'LLM 提供商:openai、openrouter、atlascloud、azure、custom、cursor、claude、codex 或 opencode(默认:openai)',
'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称(默认:minimax/minimax-m2.5)',
'help.option.wiki.baseUrl':
'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1',

View file

@ -303,7 +303,7 @@ program
.option('-f, --force', 'Force full regeneration even if up to date')
.option(
'--provider <provider>',
'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)',
'LLM provider: openai, openrouter, atlascloud, azure, custom, cursor, claude, codex, or opencode (default: openai)',
)
.option('--model <model>', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)')
.option(

View file

@ -18,6 +18,9 @@ import {
} from '../storage/repo-manager.js';
import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js';
import {
ATLAS_CLOUD_BASE_URL,
ATLAS_CLOUD_DEFAULT_MODEL,
getProviderEnvApiKey,
parseLLMAllowedInsecureHttpHosts,
resolveLLMConfig,
type LLMProvider,
@ -206,6 +209,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
// ── Resolve LLM config (with interactive fallback) ─────────────────
// Save any CLI overrides immediately
let switchedToAtlasCloud = false;
if (
options?.apiKey ||
options?.model ||
@ -219,6 +223,11 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
if (options.apiKey) updates.apiKey = options.apiKey;
if (options.baseUrl) updates.baseUrl = options.baseUrl;
if (options.provider) updates.provider = options.provider;
if (options.provider === 'atlascloud' && existing.provider !== 'atlascloud') {
switchedToAtlasCloud = true;
if (!options.baseUrl) updates.baseUrl = ATLAS_CLOUD_BASE_URL;
if (!options.model) updates.model = ATLAS_CLOUD_DEFAULT_MODEL;
}
if (options.apiVersion) updates.apiVersion = options.apiVersion;
if (options.reasoningModel !== undefined) updates.isReasoningModel = options.reasoningModel;
// Save model to appropriate field based on provider.
@ -257,6 +266,10 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
isReasoningModel: options?.reasoningModel,
allowedInsecureHttpHosts,
});
if (switchedToAtlasCloud && !options?.apiKey && !getProviderEnvApiKey('atlascloud')) {
// Do not send a key saved for a different provider to Atlas Cloud.
llmConfig.apiKey = '';
}
// Run interactive setup if no saved config and no CLI flags provided
// (even if env vars exist — let user explicitly choose their provider)
@ -265,7 +278,9 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
// Non-interactive mode — need either API key or Cursor CLI
if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) {
console.log(' Error: No LLM API key found.');
console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,');
console.log(
' Set ATLASCLOUD_API_KEY, OPENAI_API_KEY, or GITNEXUS_API_KEY environment variable,',
);
console.log(' or pass --api-key <key>, or use --provider cursor|claude|codex|opencode.\n');
process.exitCode = 1;
return;
@ -274,7 +289,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
} else {
console.log(" No LLM configured. Let's set it up.\n");
console.log(
' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, Codex CLI, or OpenCode CLI.\n',
' Supports OpenAI, OpenRouter, Atlas Cloud, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, Codex CLI, or OpenCode CLI.\n',
);
// Check if local agent CLIs are available.
@ -291,8 +306,9 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
console.log(' [1] OpenAI (api.openai.com)');
console.log(' [2] OpenRouter (openrouter.ai)');
console.log(' [3] Azure OpenAI');
console.log(' [4] Custom endpoint');
let nextChoice = 5;
console.log(' [4] Atlas Cloud (api.atlascloud.ai)');
console.log(' [5] Custom endpoint');
let nextChoice = 6;
if (hasCursor) {
const choice = String(nextChoice++);
localChoices.push({
@ -413,12 +429,16 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
provider: 'azure',
};
} else {
// OpenAI-compatible provider (OpenAI, OpenRouter, Custom)
// OpenAI-compatible provider (OpenAI, OpenRouter, Atlas Cloud, Custom)
if (choice === '2') {
baseUrl = 'https://openrouter.ai/api/v1';
defaultModel = 'minimax/minimax-m2.5';
provider = 'openrouter';
} else if (choice === '4') {
baseUrl = ATLAS_CLOUD_BASE_URL;
defaultModel = ATLAS_CLOUD_DEFAULT_MODEL;
provider = 'atlascloud';
} else if (choice === '5') {
baseUrl = await prompt(' Base URL (e.g. http://localhost:11434/v1): ');
if (!baseUrl) {
console.log('\n No URL provided. Aborting.\n');
@ -438,7 +458,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
const model = modelInput || defaultModel;
// API key — pre-fill hint if env var exists
const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || '';
const envKey = getProviderEnvApiKey(provider);
if (envKey) {
const masked = envKey.slice(0, 6) + '...' + envKey.slice(-4);
const useEnv = await prompt(` Use existing env key (${masked})? (Y/n): `);

View file

@ -4,7 +4,7 @@ import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from '
* LLM Client for Wiki Generation
*
* OpenAI-compatible API client using native fetch.
* Supports OpenAI, Azure, LiteLLM, Ollama, and any OpenAI-compatible endpoint.
* Supports OpenAI, Atlas Cloud, Azure, LiteLLM, Ollama, and any OpenAI-compatible endpoint.
*
* Config priority: CLI flags > env vars > defaults
*/
@ -12,6 +12,7 @@ import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from '
export type LLMProvider =
| 'openai'
| 'openrouter'
| 'atlascloud'
| 'azure'
| 'custom'
| 'cursor'
@ -19,6 +20,9 @@ export type LLMProvider =
| 'codex'
| 'opencode';
export const ATLAS_CLOUD_BASE_URL = 'https://api.atlascloud.ai/v1';
export const ATLAS_CLOUD_DEFAULT_MODEL = 'deepseek-ai/deepseek-v4-pro';
export interface LLMConfig {
apiKey: string;
baseUrl: string;
@ -45,6 +49,13 @@ export interface LLMResponse {
completionTokens?: number;
}
export function getProviderEnvApiKey(provider: LLMProvider | undefined): string {
if (provider === 'atlascloud') {
return process.env.ATLASCLOUD_API_KEY || process.env.GITNEXUS_API_KEY || '';
}
return process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || '';
}
/**
* Resolve LLM configuration from env vars, saved config, and optional overrides.
* Priority: overrides (CLI flags) > env vars > ~/.gitnexus/config.json > error
@ -55,6 +66,8 @@ export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<
const { loadCLIConfig } = await import('../../storage/repo-manager.js');
const savedConfig = await loadCLIConfig();
const savedProvider = overrides?.provider ?? savedConfig.provider;
const providerChanged =
overrides?.provider !== undefined && overrides.provider !== savedConfig.provider;
const savedLocalModel =
savedProvider === 'cursor'
? savedConfig.cursorModel
@ -73,23 +86,26 @@ export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<
const apiKey =
overrides?.apiKey ||
process.env.GITNEXUS_API_KEY ||
process.env.OPENAI_API_KEY ||
savedConfig.apiKey ||
getProviderEnvApiKey(savedProvider) ||
(providerChanged ? undefined : savedConfig.apiKey) ||
'';
const defaultBaseUrl =
savedProvider === 'atlascloud' ? ATLAS_CLOUD_BASE_URL : 'https://openrouter.ai/api/v1';
const defaultModel =
savedProvider === 'atlascloud' ? ATLAS_CLOUD_DEFAULT_MODEL : 'minimax/minimax-m2.5';
return {
apiKey,
baseUrl:
overrides?.baseUrl ||
process.env.GITNEXUS_LLM_BASE_URL ||
savedConfig.baseUrl ||
'https://openrouter.ai/api/v1',
(providerChanged ? undefined : savedConfig.baseUrl) ||
defaultBaseUrl,
model:
overrides?.model ||
(localProvider ? undefined : process.env.GITNEXUS_MODEL) ||
savedLocalModel ||
(localProvider ? '' : savedConfig.model || 'minimax/minimax-m2.5'),
(localProvider ? '' : (providerChanged ? undefined : savedConfig.model) || defaultModel),
maxTokens: overrides?.maxTokens ?? 16_384,
temperature: overrides?.temperature ?? 0,
provider: savedProvider ?? 'openai',

View file

@ -1877,6 +1877,7 @@ export interface CLIConfig {
provider?:
| 'openai'
| 'openrouter'
| 'atlascloud'
| 'azure'
| 'custom'
| 'cursor'

View file

@ -163,6 +163,13 @@ describe('CLI help surface', () => {
expect(result.stdout).toContain('-c, --coding-agent <agents>');
});
it('wiki help lists the Atlas Cloud provider preset', () => {
const result = runHelp('wiki');
expect(result.status).toBe(0);
expect(result.stdout).toContain('atlascloud');
});
it('localizes every registered CLI command and option description in zh-CN help', () => {
const zhHelpOutput = allHelpCommands
.map((args) => {

View file

@ -131,9 +131,22 @@ describe('detectLocalCLI', () => {
describe('resolveLLMConfig', () => {
let tmpDir: string;
const envKeys = [
'ATLASCLOUD_API_KEY',
'GITNEXUS_API_KEY',
'OPENAI_API_KEY',
'GITNEXUS_LLM_BASE_URL',
'GITNEXUS_MODEL',
] as const;
let originalEnv: Record<(typeof envKeys)[number], string | undefined>;
beforeEach(async () => {
vi.resetModules();
originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record<
(typeof envKeys)[number],
string | undefined
>;
for (const key of envKeys) delete process.env[key];
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-test-config-'));
// Create empty config so loadCLIConfig returns {}
const configDir = path.join(tmpDir, '.gitnexus');
@ -143,6 +156,11 @@ describe('resolveLLMConfig', () => {
afterEach(async () => {
vi.restoreAllMocks();
for (const key of envKeys) {
const value = originalEnv[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
await fs.rm(tmpDir, { recursive: true, force: true });
});
@ -250,6 +268,27 @@ describe('resolveLLMConfig', () => {
expect(config.baseUrl).toBe('https://openrouter.ai/api/v1');
});
it('uses Atlas Cloud defaults and its provider-specific API key', async () => {
process.env.ATLASCLOUD_API_KEY = 'atlas-key';
process.env.OPENAI_API_KEY = 'openai-key';
vi.doMock('../../src/storage/repo-manager.js', () => ({
loadCLIConfig: vi.fn().mockResolvedValue({
provider: 'openai',
apiKey: 'saved-openai-key',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
}),
}));
const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js');
const config = await resolveLLMConfig({ provider: 'atlascloud' });
expect(config.provider).toBe('atlascloud');
expect(config.apiKey).toBe('atlas-key');
expect(config.baseUrl).toBe('https://api.atlascloud.ai/v1');
expect(config.model).toBe('deepseek-ai/deepseek-v4-pro');
});
it('CLI overrides take priority over saved config', async () => {
vi.doMock('../../src/storage/repo-manager.js', () => ({
loadCLIConfig: vi.fn().mockResolvedValue({
@ -579,6 +618,7 @@ describe('wikiCommand --timeout mapping', () => {
async function loadWikiCommandHarness() {
let capturedConfig: Record<string, unknown> | undefined;
const saveCLIConfig = vi.fn();
const resolveLLMConfig = vi.fn().mockImplementation((overrides = {}) =>
Promise.resolve({
apiKey: 'sk-test',
@ -614,7 +654,7 @@ describe('wikiCommand --timeout mapping', () => {
model: 'gpt-4o',
provider: 'openai',
}),
saveCLIConfig: vi.fn(),
saveCLIConfig,
}));
vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/wiki/llm-client.js')>();
@ -647,9 +687,31 @@ describe('wikiCommand --timeout mapping', () => {
consoleSpy,
getCapturedConfig: () => capturedConfig,
resolveLLMConfig,
saveCLIConfig,
};
}
it('switches to Atlas Cloud without overwriting a saved key from another provider', async () => {
const originalAtlasKey = process.env.ATLASCLOUD_API_KEY;
delete process.env.ATLASCLOUD_API_KEY;
const harness = await loadWikiCommandHarness();
try {
await harness.wikiCommand('/tmp/repo', { provider: 'atlascloud' });
expect(harness.saveCLIConfig).toHaveBeenCalledWith({
apiKey: 'sk-test',
baseUrl: 'https://api.atlascloud.ai/v1',
model: 'deepseek-ai/deepseek-v4-pro',
provider: 'atlascloud',
});
expect(harness.getCapturedConfig()?.apiKey).toBe('');
} finally {
if (originalAtlasKey === undefined) delete process.env.ATLASCLOUD_API_KEY;
else process.env.ATLASCLOUD_API_KEY = originalAtlasKey;
}
});
it('maps --timeout seconds to requestTimeoutMs before constructing WikiGenerator', async () => {
const harness = await loadWikiCommandHarness();