This commit is contained in:
nb213 2026-09-25 21:55:23 +03:00 • committed by GitHub
commit 91a35fc5f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 172 additions and 31 deletions

View file

@ -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=<key>
gitnexus wiki --provider atlascloud
# Force full regeneration
gitnexus wiki --force

View file

@ -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 <model> # Wiki with custom LLM model (default: minimax/minimax-m2.5)
ATLASCLOUD_API_KEY=<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

View file

@ -345,7 +345,7 @@ export const en = {
'Delete the index left in <repo>/.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',

View file

@ -314,7 +314,7 @@ export const zhCN = {
'help.option.clean.localIndex': '删除此检出迁入共享存储后遗留在 <repo>/.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',

View file

@ -391,7 +391,7 @@ program
.option('-f, --force', 'Force full regeneration even if up to date')
.option(
'--provider <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 <model>', 'LLM model or deployment name (default: MiniMax-M3)')
.option(

View file

@ -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 <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): `);

View file

@ -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<LLMConfig>): 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<LLMConfig>): 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,

View file

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

View file

@ -168,6 +168,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

@ -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<string, unknown> | 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<typeof import('../../src/core/wiki/llm-client.js')>();
@ -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();