From 5b00b332f630ca1915716f67de2bd0bea5a95cfb Mon Sep 17 00:00:00 2001 From: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:00:52 +0800 Subject: [PATCH] feat(wiki): add Atlas Cloud provider preset Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> --- README.md | 4 ++ gitnexus/README.md | 1 + gitnexus/src/cli/i18n/en.ts | 2 +- gitnexus/src/cli/i18n/zh-CN.ts | 2 +- gitnexus/src/cli/index.ts | 2 +- gitnexus/src/cli/wiki.ts | 32 +++++++++--- gitnexus/src/core/wiki/llm-client.ts | 30 ++++++++--- gitnexus/src/storage/repo-manager.ts | 1 + gitnexus/test/unit/cli-index-help.test.ts | 7 +++ gitnexus/test/unit/wiki-flags.test.ts | 64 ++++++++++++++++++++++- 10 files changed, 128 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d6534f658..32e2de11c 100644 --- a/README.md +++ b/README.md @@ -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= +gitnexus wiki --provider atlascloud + # Force full regeneration gitnexus wiki --force diff --git a/gitnexus/README.md b/gitnexus/README.md index e9b16ac73..1563c3054 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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 # Wiki with custom LLM model (default: minimax/minimax-m2.5) +ATLASCLOUD_API_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 diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 566dc6d87..ce34011ad 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -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', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 0c99d37d9..9a381fdb4 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -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', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 41aad4d6a..b59cb9f64 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -303,7 +303,7 @@ program .option('-f, --force', 'Force full regeneration even if up to date') .option( '--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 ', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)') .option( diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index ef6776fbd..3a653e5f6 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -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 , 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): `); diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 2fe42cdf0..aeefc0b22 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 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): 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): 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', diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index f122cddce..8c475a427 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -1877,6 +1877,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 b0c276bc2..493c97930 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -163,6 +163,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 43d2a070a..981cd8d97 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -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 | 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(); @@ -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();