diff --git a/README.md b/README.md index 2afc65d49..d25b26805 100644 --- a/README.md +++ b/README.md @@ -890,6 +890,10 @@ gitnexus wiki --model gpt-4o gitnexus wiki --base-url https://api.anthropic.com/v1 gitnexus wiki --provider grok # local Grok Build CLI (uses `grok login`, no API key) +# Use the Atlas Cloud preset +export ATLASCLOUD_API_KEY= +gitnexus wiki --provider atlascloud + # Force full regeneration gitnexus wiki --force diff --git a/gitnexus/README.md b/gitnexus/README.md index 1d5012a6f..e11bba0de 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -290,6 +290,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 # Wiki with custom LLM model (default: minimax/minimax-m2.5) +ATLASCLOUD_API_KEY= gitnexus wiki --provider atlascloud # Atlas Cloud preset gitnexus wiki --provider grok # Local Grok Build CLI (uses `grok login`, no API key) 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 diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 0a88908f6..9ab63d04b 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -345,7 +345,7 @@ export const en = { 'Delete the index left in /.gitnexus after this checkout moved into a shared store', 'help.option.wiki.force': 'Force full regeneration even if up to date', 'help.option.wiki.provider': - 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)', + 'LLM provider: minimax, openai, openrouter, atlascloud, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)', 'help.option.wiki.model': 'LLM model or deployment name (default: MiniMax-M3)', 'help.option.wiki.baseUrl': 'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 766e2f261..de215534f 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -314,7 +314,7 @@ export const zhCN = { 'help.option.clean.localIndex': '删除此检出迁入共享存储后遗留在 /.gitnexus 中的索引', 'help.option.wiki.force': '即使已是最新也强制完整重新生成', 'help.option.wiki.provider': - 'LLM 提供商:minimax、openai、openrouter、azure、custom、cursor、claude、codex、opencode 或 grok(默认:minimax)', + 'LLM 提供商:minimax、openai、openrouter、atlascloud、azure、custom、cursor、claude、codex、opencode 或 grok(默认:minimax)', 'help.option.wiki.model': 'LLM 模型或 deployment 名称(默认:MiniMax-M3)', 'help.option.wiki.baseUrl': 'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 0563f0c2f..4641848ad 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -391,7 +391,7 @@ program .option('-f, --force', 'Force full regeneration even if up to date') .option( '--provider ', - 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)', + 'LLM provider: minimax, openai, openrouter, atlascloud, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)', ) .option('--model ', 'LLM model or deployment name (default: MiniMax-M3)') .option( diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index e9218c7e5..212f60b16 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -19,6 +19,9 @@ import { } from '../storage/storage-resolver.js'; import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js'; import { + ATLAS_CLOUD_BASE_URL, + ATLAS_CLOUD_DEFAULT_MODEL, + getProviderEnvApiKey, MINIMAX_MODEL_IDS, MINIMAX_OPENAI_BASE_URLS, parseLLMAllowedInsecureHttpHosts, @@ -220,6 +223,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 || @@ -241,6 +245,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; if (options.provider === 'minimax') { @@ -290,6 +299,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) @@ -298,7 +311,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 MINIMAX_API_KEY, GITNEXUS_API_KEY, or OPENAI_API_KEY,'); + console.log( + ' Set MINIMAX_API_KEY, ATLASCLOUD_API_KEY, GITNEXUS_API_KEY, or OPENAI_API_KEY,', + ); console.log( ' or pass --api-key , or use --provider cursor|claude|codex|opencode|grok.\n', ); @@ -308,7 +323,9 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) // Non-interactive with env var or cursor — just use it } else { console.log(" No LLM configured. Let's set it up.\n"); - console.log(' Supports MiniMax, OpenAI-compatible APIs, and local agent CLIs.\n'); + console.log( + ' Supports MiniMax, OpenAI, OpenRouter, Atlas Cloud, Azure, custom OpenAI-compatible APIs, and local agent CLIs.\n', + ); // Check if local agent CLIs are available. const hasCursor = detectCursorCLI(); @@ -325,10 +342,11 @@ 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'); - console.log(' [5] MiniMax Global (api.minimax.io)'); - console.log(' [6] MiniMax China (api.minimaxi.com)'); - let nextChoice = 7; + console.log(' [4] Atlas Cloud (api.atlascloud.ai)'); + console.log(' [5] Custom endpoint'); + console.log(' [6] MiniMax Global (api.minimax.io)'); + console.log(' [7] MiniMax China (api.minimaxi.com)'); + let nextChoice = 8; if (hasCursor) { const choice = String(nextChoice++); localChoices.push({ @@ -463,6 +481,10 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) defaultModel = ''; 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'); @@ -471,9 +493,9 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } defaultModel = 'gpt-4o-mini'; provider = 'custom'; - } else if (choice === '5' || choice === '6') { + } else if (choice === '6' || choice === '7') { baseUrl = - choice === '6' ? MINIMAX_OPENAI_BASE_URLS.cn_zh : MINIMAX_OPENAI_BASE_URLS.global_en; + choice === '7' ? MINIMAX_OPENAI_BASE_URLS.cn_zh : MINIMAX_OPENAI_BASE_URLS.global_en; defaultModel = MINIMAX_MODEL_IDS[0]; provider = 'minimax'; } else { @@ -494,11 +516,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } // API key — pre-fill hint if env var exists - const envKey = - (provider === 'minimax' ? process.env.MINIMAX_API_KEY : undefined) || - 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): `); diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index b8af62099..1d3c3d137 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -4,7 +4,7 @@ import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from ' * LLM Client for Wiki Generation * * OpenAI-compatible API client using native fetch. - * Supports MiniMax and other OpenAI-compatible endpoints. + * Supports MiniMax, Atlas Cloud, and other OpenAI-compatible endpoints. * * 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' @@ -53,6 +54,9 @@ export type LLMUserContent = } >; +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; @@ -79,6 +83,21 @@ 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 || ''; + } + if (provider === 'minimax') { + return ( + process.env.MINIMAX_API_KEY || + process.env.GITNEXUS_API_KEY || + process.env.OPENAI_API_KEY || + '' + ); + } + return process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || ''; +} + export function resolveMiniMaxThinkingMode( model: string, reasoningOverride?: boolean, @@ -123,13 +142,24 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< savedProvider === 'opencode' || savedProvider === 'grok'; + const envApiKey = + savedProvider === 'minimax' + ? process.env.MINIMAX_API_KEY || '' + : getProviderEnvApiKey(savedProvider); const apiKey = - overrides?.apiKey || - (savedProvider === 'minimax' ? process.env.MINIMAX_API_KEY : undefined) || - (savedProvider !== 'minimax' ? process.env.GITNEXUS_API_KEY : undefined) || - (savedProvider !== 'minimax' ? process.env.OPENAI_API_KEY : undefined) || - (reuseSavedHttpConfig ? savedConfig.apiKey : undefined) || - ''; + overrides?.apiKey || envApiKey || (reuseSavedHttpConfig ? savedConfig.apiKey : undefined) || ''; + const defaultBaseUrl = + savedProvider === 'atlascloud' + ? ATLAS_CLOUD_BASE_URL + : savedProvider === 'minimax' + ? MINIMAX_OPENAI_BASE_URLS.global_en + : 'https://openrouter.ai/api/v1'; + const defaultModel = + savedProvider === 'atlascloud' + ? ATLAS_CLOUD_DEFAULT_MODEL + : savedProvider === 'minimax' + ? MINIMAX_MODEL_IDS[0] + : ''; return { apiKey, @@ -137,17 +167,12 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< overrides?.baseUrl || process.env.GITNEXUS_LLM_BASE_URL || (reuseSavedHttpConfig ? savedConfig.baseUrl : undefined) || - (savedProvider === 'minimax' - ? MINIMAX_OPENAI_BASE_URLS.global_en - : 'https://openrouter.ai/api/v1'), + defaultBaseUrl, model: overrides?.model || (localProvider ? undefined : process.env.GITNEXUS_MODEL) || savedLocalModel || - (localProvider - ? '' - : (reuseSavedHttpConfig ? savedConfig.model : undefined) || - (savedProvider === 'minimax' ? MINIMAX_MODEL_IDS[0] : '')), + (localProvider ? '' : (reuseSavedHttpConfig ? savedConfig.model : undefined) || defaultModel), maxTokens: overrides?.maxTokens ?? 16_384, temperature: overrides?.temperature ?? 0, provider: savedProvider, diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index c6a362bff..df97db999 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -1822,6 +1822,7 @@ export interface CLIConfig { provider?: | 'openai' | 'openrouter' + | 'atlascloud' | 'azure' | 'custom' | 'cursor' diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index b9862b87f..d68363783 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -168,6 +168,13 @@ describe('CLI help surface', () => { expect(result.stdout).toContain('-c, --coding-agent '); }); + 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) => { diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 6ffd46d57..4212a9058 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -138,9 +138,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'); @@ -151,6 +164,11 @@ describe('resolveLLMConfig', () => { afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); + 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 }); }); @@ -343,6 +361,27 @@ describe('resolveLLMConfig', () => { expect(config.baseUrl).toBe('https://legacy.example/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({ @@ -842,6 +881,7 @@ describe('wikiCommand --timeout mapping', () => { async function loadWikiCommandHarness() { let capturedConfig: Record | undefined; + const saveCLIConfig = vi.fn(); const resolveLLMConfig = vi.fn().mockImplementation((overrides = {}) => Promise.resolve({ apiKey: 'sk-test', @@ -877,7 +917,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(); @@ -911,9 +951,54 @@ 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; + const originalGitNexusKey = process.env.GITNEXUS_API_KEY; + delete process.env.ATLASCLOUD_API_KEY; + delete process.env.GITNEXUS_API_KEY; + const harness = await loadWikiCommandHarness(); + + try { + await harness.wikiCommand('/tmp/repo', { provider: 'atlascloud' }); + + expect(harness.saveCLIConfig).toHaveBeenCalledWith({ + apiKey: undefined, + 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; + if (originalGitNexusKey === undefined) delete process.env.GITNEXUS_API_KEY; + else process.env.GITNEXUS_API_KEY = originalGitNexusKey; + } + }); + + it('preserves the Atlas Cloud fallback key when GITNEXUS_API_KEY is set', async () => { + const originalAtlasKey = process.env.ATLASCLOUD_API_KEY; + const originalGitNexusKey = process.env.GITNEXUS_API_KEY; + delete process.env.ATLASCLOUD_API_KEY; + process.env.GITNEXUS_API_KEY = 'gitnexus-test-key'; + const harness = await loadWikiCommandHarness(); + + try { + await harness.wikiCommand('/tmp/repo', { provider: 'atlascloud' }); + + expect(harness.getCapturedConfig()?.apiKey).toBeUndefined(); + } finally { + if (originalAtlasKey === undefined) delete process.env.ATLASCLOUD_API_KEY; + else process.env.ATLASCLOUD_API_KEY = originalAtlasKey; + if (originalGitNexusKey === undefined) delete process.env.GITNEXUS_API_KEY; + else process.env.GITNEXUS_API_KEY = originalGitNexusKey; + } + }); + it('maps --timeout seconds to requestTimeoutMs before constructing WikiGenerator', async () => { const harness = await loadWikiCommandHarness();