From 1b1c90eaabc3ebe71c2f35f0855f3ca4639152c9 Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:23:41 +0530 Subject: [PATCH] feat(wiki): Azure OpenAI support for wiki command (#562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(wiki): extend LLMConfig/CLIConfig with Azure and reasoning model fields Co-Authored-By: Claude Sonnet 4.6 * fix(wiki): restore cursor model resolution, fix LLMProvider type, clean up regex Co-Authored-By: Claude Sonnet 4.6 * chore(wiki): remove stale LLMProvider type alias from repo-manager * fix(wiki): fix Azure auth header, api-version param, reasoning model params, content_filter error Co-Authored-By: Claude Sonnet 4.6 * fix(wiki): tighten Azure detection, reasoning model regex, content_filter gating - isReasoningModel: new regex matches only o1/o3 bare + any oN-mini/oN-preview; bare o4/o5/etc now return false - isAzureProvider: use URL hostname matching to block spoofed subdomain URLs - callLLM: warn on Azure legacy /deployments/ URL without api-version - callLLM: gate content_filter error to azure===true; also catch ResponsibleAIPolicyViolation - tests: add afterEach stub cleanup, spoofed-URL, bare-o4, non-Azure content_filter, and URL-only Azure auto-detect tests Co-Authored-By: Claude Sonnet 4.6 * fix(wiki): detect content_filter finish_reason in SSE stream and throw clear error Co-Authored-By: Claude Sonnet 4.6 * fix(wiki): skip delta accumulation after content_filter, use provider-neutral error message Co-Authored-By: Claude Sonnet 4.6 * feat(wiki): add Azure OpenAI option to interactive setup wizard Inserts Azure as option [3] in the provider menu (shifting Custom to [4] and Cursor to [5]), adds guided Azure setup flow with resource/deployment prompts, v1/legacy URL format selection, reasoning-model flag, and content_filter error handling in the catch block. Co-Authored-By: Claude Sonnet 4.6 * fix(wiki): store explicit false for non-reasoning Azure deployments, trim resource name inputs - isReasoningModelDeployment now stores false (not undefined) when user says no - Always include isReasoningModel in saved azureConfig (no conditional guard needed) - Trim resourceName and deploymentName prompt inputs to avoid whitespace issues - Improve reasoning model note to mention Azure requirement Co-Authored-By: Claude Sonnet 4.6 * feat(wiki): add --api-version and --reasoning-model CLI flags for Azure Co-Authored-By: Claude Sonnet 4.6 * fix(wiki): include apiVersion and reasoningModel in hasCLIOverrides guard * fix(wiki): default provider to 'openai' in resolveLLMConfig when not configured * style: apply prettier formatting * fix(wiki): address PR review — remove unrelated files, harden inputs - Remove evidence/, fix-adapter.js, and planning doc accidentally included - URL-encode apiVersion in buildRequestUrl to prevent query string injection - Add --no-reasoning-model flag to allow CLI override of saved config - Simplify verbose ternary in Azure wizard prompt Co-Authored-By: Claude Opus 4.6 (1M context) * fix(wiki): use execFileSync for EDITOR to prevent shell injection Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- gitnexus/package-lock.json | 17 -- gitnexus/src/cli/index.ts | 18 +- gitnexus/src/cli/wiki.ts | 154 +++++++++- gitnexus/src/core/wiki/llm-client.ts | 130 ++++++-- gitnexus/src/storage/repo-manager.ts | 8 +- gitnexus/test/unit/wiki-llm-client.test.ts | 332 +++++++++++++++++++++ 6 files changed, 602 insertions(+), 57 deletions(-) create mode 100644 gitnexus/test/unit/wiki-llm-client.test.ts diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index bc15636ef3..9994f13c15 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -50,7 +50,6 @@ "@types/node": "^20.0.0", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.0.18", - "husky": "^9.1.7", "tsx": "^4.0.0", "typescript": "^5.4.5", "vitest": "^4.0.18" @@ -3422,22 +3421,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, - "license": "MIT", - "bin": { - "husky": "bin.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 7ccdf16de1..1575863477 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -75,9 +75,21 @@ program .description('Generate repository wiki from knowledge graph') .option('-f, --force', 'Force full regeneration even if up to date') .option('--provider ', 'LLM provider: openai or cursor (default: openai)') - .option('--model ', 'LLM model name (default depends on provider)') - .option('--base-url ', 'LLM API base URL (for openai provider)') - .option('--api-key ', 'LLM API key (saved to ~/.gitnexus/config.json)') + .option('--model ', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)') + .option( + '--base-url ', + 'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1', + ) + .option('--api-key ', 'LLM API key or Azure api-key (saved to ~/.gitnexus/config.json)') + .option( + '--api-version ', + 'Azure api-version query param, e.g. 2024-10-21 (legacy Azure API only)', + ) + .option( + '--reasoning-model', + 'Mark deployment as reasoning model (o1/o3/o4-mini) — strips temperature, uses max_completion_tokens', + ) + .option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)') .option('--concurrency ', 'Parallel LLM calls (default: 3)', '3') .option('--gist', 'Publish wiki as a public GitHub Gist after generation') .option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)') diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index af451beac4..8d44468181 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -25,6 +25,8 @@ export interface WikiCommandOptions { model?: string; baseUrl?: string; apiKey?: string; + apiVersion?: string; + reasoningModel?: boolean; concurrency?: string; gist?: boolean; provider?: LLMProvider; @@ -124,12 +126,21 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio // ── Resolve LLM config (with interactive fallback) ───────────────── // Save any CLI overrides immediately - if (options?.apiKey || options?.model || options?.baseUrl || options?.provider) { + if ( + options?.apiKey || + options?.model || + options?.baseUrl || + options?.provider || + options?.apiVersion || + options?.reasoningModel !== undefined + ) { const existing = await loadCLIConfig(); - const updates: Record = {}; + const updates: Partial = {}; if (options.apiKey) updates.apiKey = options.apiKey; if (options.baseUrl) updates.baseUrl = options.baseUrl; if (options.provider) updates.provider = options.provider; + if (options.apiVersion) updates.apiVersion = options.apiVersion; + if (options.reasoningModel !== undefined) updates.isReasoningModel = options.reasoningModel; // Save model to appropriate field based on provider if (options.model) { if (options.provider === 'cursor') { @@ -151,7 +162,9 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio options?.apiKey || options?.model || options?.baseUrl || - options?.provider + options?.provider || + options?.apiVersion || + options?.reasoningModel !== undefined ); let llmConfig = await resolveLLMConfig({ @@ -159,6 +172,8 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio baseUrl: options?.baseUrl, apiKey: options?.apiKey, provider: options?.provider, + apiVersion: options?.apiVersion, + isReasoningModel: options?.reasoningModel, }); // Run interactive setup if no saved config and no CLI flags provided @@ -176,7 +191,9 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio // 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 OpenAI, OpenRouter, any OpenAI-compatible API, or Cursor CLI.\n'); + console.log( + ' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, or Cursor CLI.\n', + ); // Check if Cursor CLI is available const hasCursor = detectCursorCLI(); @@ -184,13 +201,14 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio // Provider selection console.log(' [1] OpenAI (api.openai.com)'); console.log(' [2] OpenRouter (openrouter.ai)'); - console.log(' [3] Custom endpoint'); + console.log(' [3] Azure OpenAI'); + console.log(' [4] Custom endpoint'); if (hasCursor) { - console.log(' [4] Cursor CLI (local, uses your Cursor subscription)'); + console.log(' [5] Cursor CLI (local, uses your Cursor subscription)'); } console.log(''); - const maxChoice = hasCursor ? '4' : '3'; + const maxChoice = hasCursor ? '5' : '4'; const choice = await prompt(` Select provider (1/${maxChoice}): `); let baseUrl: string; @@ -198,7 +216,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio let provider: LLMProvider = 'openai'; let key = ''; - if (choice === '4' && hasCursor) { + if (choice === '5' && hasCursor) { // Cursor CLI selected - model defaults to 'auto' (Cursor's default) provider = 'cursor'; baseUrl = ''; @@ -213,12 +231,114 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio console.log(' Config saved to ~/.gitnexus/config.json\n'); llmConfig = { ...llmConfig, provider: 'cursor', model, apiKey: '', baseUrl: '' }; + } else if (choice === '3') { + // Azure OpenAI guided setup + console.log('\n Azure OpenAI setup.'); + console.log( + ' You need: your resource name, deployment name, and API key from the Azure portal.\n', + ); + + const resourceName = ( + await prompt(' Azure resource name (e.g. my-openai-resource): ') + ).trim(); + if (!resourceName) { + console.log('\n No resource name provided. Aborting.\n'); + process.exitCode = 1; + return; + } + + const deploymentName = ( + await prompt(' Deployment name (the name you gave your model deployment): ') + ).trim(); + if (!deploymentName) { + console.log('\n No deployment name provided. Aborting.\n'); + process.exitCode = 1; + return; + } + + // Offer v1 or legacy URL + console.log('\n API format:'); + console.log(' [1] v1 API — recommended (no api-version needed)'); + console.log(' [2] Legacy — uses api-version query param\n'); + const apiFormat = await prompt(' Select format (1/2, default: 1): '); + + let azureApiVersion: string | undefined; + let azureBaseUrl: string; + if (apiFormat === '2') { + const versionInput = await prompt(' api-version (default: 2024-10-21): '); + azureApiVersion = versionInput || '2024-10-21'; + azureBaseUrl = `https://${resourceName}.openai.azure.com/openai/deployments/${deploymentName}`; + } else { + azureBaseUrl = `https://${resourceName}.openai.azure.com/openai/v1`; + azureApiVersion = undefined; + } + + defaultModel = deploymentName; + + // Ask if this is a reasoning model deployment + const reasoningAnswer = await prompt( + ' Is this a reasoning model (o1, o3, o4-mini)? (y/N): ', + ); + const isReasoningModelDeployment = ['y', 'yes'].includes(reasoningAnswer.toLowerCase()); + + if (isReasoningModelDeployment) { + console.log( + ' Note: temperature and max_tokens will be omitted for this deployment (Azure reasoning model requirement).\n', + ); + } + + const modelInput = await prompt(` Model / deployment name (default: ${defaultModel}): `); + const model = modelInput || defaultModel; + + // API key + const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || ''; + let azureKey: string; + if (envKey) { + const masked = envKey.slice(0, 6) + '...' + envKey.slice(-4); + const useEnv = await prompt(` Use existing env key (${masked})? (Y/n): `); + if (!useEnv || useEnv.toLowerCase() === 'y' || useEnv.toLowerCase() === 'yes') { + azureKey = envKey; + } else { + azureKey = await prompt(' API key: ', true); + } + } else { + azureKey = await prompt(' API key: ', true); + } + + if (!azureKey) { + console.log('\n No key provided. Aborting.\n'); + process.exitCode = 1; + return; + } + + // Save Azure config including optional apiVersion and isReasoningModel + const azureConfig: Parameters[0] = { + apiKey: azureKey, + baseUrl: azureBaseUrl, + model, + provider: 'azure', + isReasoningModel: isReasoningModelDeployment, + }; + if (azureApiVersion) azureConfig.apiVersion = azureApiVersion; + await saveCLIConfig(azureConfig); + console.log(' Config saved to ~/.gitnexus/config.json\n'); + + llmConfig = { + ...llmConfig, + apiKey: azureKey, + baseUrl: azureBaseUrl, + model, + provider: 'azure', + apiVersion: azureApiVersion, + isReasoningModel: isReasoningModelDeployment, + }; } else { - // OpenAI-compatible provider + // OpenAI-compatible provider (OpenAI, OpenRouter, Custom) if (choice === '2') { baseUrl = 'https://openrouter.ai/api/v1'; defaultModel = 'minimax/minimax-m2.5'; - } else if (choice === '3') { + provider = 'openrouter'; + } else if (choice === '4') { baseUrl = await prompt(' Base URL (e.g. http://localhost:11434/v1): '); if (!baseUrl) { console.log('\n No URL provided. Aborting.\n'); @@ -226,9 +346,11 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio return; } defaultModel = 'gpt-4o-mini'; + provider = 'custom'; } else { baseUrl = 'https://api.openai.com/v1'; defaultModel = 'gpt-4o-mini'; + provider = 'openai'; } // Model @@ -256,10 +378,10 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio } // Save - await saveCLIConfig({ apiKey: key, baseUrl, model, provider: 'openai' }); + await saveCLIConfig({ apiKey: key, baseUrl, model, provider }); console.log(' Config saved to ~/.gitnexus/config.json\n'); - llmConfig = { ...llmConfig, apiKey: key, baseUrl, model, provider: 'openai' }; + llmConfig = { ...llmConfig, apiKey: key, baseUrl, model, provider }; } } } @@ -378,7 +500,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio console.log(' Save and close the editor when done.\n'); try { - execSync(`${editor} "${treeFile}"`, { stdio: 'inherit' }); + execFileSync(editor, [treeFile], { stdio: 'inherit' }); } catch { console.log(` Could not open editor. Please edit manually:\n ${treeFile}\n`); console.log(' Then run `gitnexus wiki` to continue.\n'); @@ -468,6 +590,12 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio if (err.message?.includes('No source files')) { console.log(`\n ${err.message}\n`); + } else if (err.message?.includes('content filter')) { + // Content filter block — actionable message + console.log(`\n Content Filter: ${err.message}\n`); + console.log( + ' To resolve: rephrase your prompt or adjust the content filter policy for your deployment.\n', + ); } else if (err.message?.includes('API key') || err.message?.includes('API error')) { console.log(`\n LLM Error: ${err.message}\n`); diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 466f380fc0..98e8456966 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -7,7 +7,7 @@ * Config priority: CLI flags > env vars > defaults */ -export type LLMProvider = 'openai' | 'cursor'; +export type LLMProvider = 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor'; export interface LLMConfig { apiKey: string; @@ -15,7 +15,12 @@ export interface LLMConfig { model: string; maxTokens: number; temperature: number; - provider?: LLMProvider; + /** Provider type — controls auth header behaviour */ + provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor'; + /** Azure api-version query param (e.g. '2024-10-21'). Appended to URL when set. */ + apiVersion?: string; + /** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */ + isReasoningModel?: boolean; } export interface LLMResponse { @@ -34,8 +39,6 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< const { loadCLIConfig } = await import('../../storage/repo-manager.js'); const savedConfig = await loadCLIConfig(); - const provider = overrides?.provider || savedConfig.provider || 'openai'; - const apiKey = overrides?.apiKey || process.env.GITNEXUS_API_KEY || @@ -43,16 +46,6 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< savedConfig.apiKey || ''; - // For cursor provider, only use model if explicitly provided (default is 'auto' handled by CLI) - // For openai provider, use model with fallback to default - const model = - provider === 'cursor' - ? overrides?.model || savedConfig.cursorModel || '' - : overrides?.model || - process.env.GITNEXUS_MODEL || - savedConfig.model || - 'minimax/minimax-m2.5'; - return { apiKey, baseUrl: @@ -60,10 +53,18 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< process.env.GITNEXUS_LLM_BASE_URL || savedConfig.baseUrl || 'https://openrouter.ai/api/v1', - model, + model: + overrides?.model || + process.env.GITNEXUS_MODEL || + (savedConfig.provider === 'cursor' ? savedConfig.cursorModel : undefined) || + savedConfig.model || + 'minimax/minimax-m2.5', maxTokens: overrides?.maxTokens ?? 16_384, temperature: overrides?.temperature ?? 0, - provider, + provider: overrides?.provider ?? savedConfig.provider ?? 'openai', + apiVersion: + overrides?.apiVersion || process.env.GITNEXUS_AZURE_API_VERSION || savedConfig.apiVersion, + isReasoningModel: overrides?.isReasoningModel ?? savedConfig.isReasoningModel, }; } @@ -74,6 +75,40 @@ export function estimateTokens(text: string): number { return Math.ceil(text.length / 4); } +/** + * Returns true if the given base URL is an Azure OpenAI endpoint. + * Uses proper hostname matching to avoid spoofed URLs like + * "https://myresource.openai.azure.com.evil.com/v1". + */ +export function isAzureProvider(baseUrl: string): boolean { + try { + const { hostname } = new URL(baseUrl); + return hostname.endsWith('.openai.azure.com') || hostname.endsWith('.services.ai.azure.com'); + } catch { + // If URL is malformed, fall back to substring check + return baseUrl.includes('.openai.azure.com') || baseUrl.includes('.services.ai.azure.com'); + } +} + +/** + * Returns true if the model name matches a known reasoning model pattern, + * or if the explicit override is true. + * Pass override=false to force non-reasoning even for o-series names. + */ +export function isReasoningModel(model: string, override?: boolean): boolean { + if (override !== undefined) return override; + // Match known bare reasoning models (o1, o3) and any o-series with -mini/-preview suffix + return /^o[1-9]\d*(-mini|-preview)$|^o1$|^o3$/i.test(model); +} + +/** + * Build the full chat completions URL, appending ?api-version when provided. + */ +export function buildRequestUrl(baseUrl: string, apiVersion: string | undefined): string { + const base = `${baseUrl.replace(/\/+$/, '')}/chat/completions`; + return apiVersion ? `${base}?api-version=${encodeURIComponent(apiVersion)}` : base; +} + export interface CallLLMOptions { onChunk?: (charsReceived: number) => void; } @@ -95,17 +130,43 @@ export async function callLLM( } messages.push({ role: 'user', content: prompt }); - const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`; + // Detect Azure endpoint (by provider field or URL pattern) + const azure = config.provider === 'azure' || isAzureProvider(config.baseUrl); + + // Warn when using Azure legacy deployment URL without api-version + if (azure && !config.apiVersion && config.baseUrl.includes('/deployments/')) { + console.warn( + '[gitnexus] Warning: Azure legacy deployment URL detected but no api-version set. Add --api-version 2024-10-21 or use the v1 API format.', + ); + } + + // Detect reasoning model (o1, o3, o4-mini etc.) or explicit override + const reasoning = isReasoningModel(config.model, config.isReasoningModel); + + const url = buildRequestUrl(config.baseUrl, azure ? config.apiVersion : undefined); const useStream = !!options?.onChunk; + // Build request body — reasoning models reject temperature and use max_completion_tokens const body: Record = { model: config.model, messages, - max_tokens: config.maxTokens, - temperature: config.temperature, }; + + if (reasoning) { + body.max_completion_tokens = config.maxTokens; + // Do NOT include temperature, top_p, presence_penalty, frequency_penalty + } else { + body.max_tokens = config.maxTokens; + body.temperature = config.temperature; + } + if (useStream) body.stream = true; + // Build auth headers — Azure uses api-key header, everyone else uses Authorization: Bearer + const authHeaders: Record = azure + ? { 'api-key': config.apiKey } + : { Authorization: `Bearer ${config.apiKey}` }; + const MAX_RETRIES = 3; let lastError: Error | null = null; @@ -115,7 +176,7 @@ export async function callLLM( method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${config.apiKey}`, + ...authHeaders, }, body: JSON.stringify(body), }); @@ -123,6 +184,18 @@ export async function callLLM( if (!response.ok) { const errorText = await response.text().catch(() => 'unknown error'); + // Azure content filter — surface a clear message instead of a generic API error + if ( + azure && + response.status === 400 && + (errorText.includes('content_filter') || + errorText.includes('ResponsibleAIPolicyViolation')) + ) { + throw new Error( + `Azure content filter blocked this request. The prompt triggered content policy. Details: ${errorText.slice(0, 300)}`, + ); + } + // Rate limit — wait with exponential backoff and retry if (response.status === 429 && attempt < MAX_RETRIES - 1) { const retryAfter = parseInt(response.headers.get('retry-after') || '0', 10); @@ -187,6 +260,7 @@ async function readSSEStream( const reader = body.getReader(); let content = ''; let buffer = ''; + let contentFilterTriggered = false; while (true) { const { done, value } = await reader.read(); @@ -204,7 +278,15 @@ async function readSSEStream( try { const parsed = JSON.parse(data); - const delta = parsed.choices?.[0]?.delta?.content; + const choice = parsed.choices?.[0]; + + // Detect content filter finish reason — skip delta from this chunk + if (choice?.finish_reason === 'content_filter') { + contentFilterTriggered = true; + continue; + } + + const delta = choice?.delta?.content; if (delta) { content += delta; onChunk(content.length); @@ -215,6 +297,12 @@ async function readSSEStream( } } + if (contentFilterTriggered) { + throw new Error( + 'content filter triggered mid-stream. The generated content was blocked by content policy. Adjust your prompt and retry.', + ); + } + if (!content) { throw new Error('LLM returned empty streaming response'); } diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 116a920d17..cc447aa4c4 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -320,14 +320,16 @@ export const listRegisteredRepos = async (opts?: { // ─── Global CLI Config (~/.gitnexus/config.json) ───────────────────────── -export type LLMProvider = 'openai' | 'cursor'; - export interface CLIConfig { apiKey?: string; model?: string; baseUrl?: string; - provider?: LLMProvider; + provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor'; cursorModel?: string; + /** Azure api-version query param (e.g. '2024-10-21'). Only used when provider is 'azure'. */ + apiVersion?: string; + /** Set true when the deployment is a reasoning model (o1, o3, o4-mini). Auto-detected for OpenAI; must be set for Azure deployments. */ + isReasoningModel?: boolean; } /** diff --git a/gitnexus/test/unit/wiki-llm-client.test.ts b/gitnexus/test/unit/wiki-llm-client.test.ts new file mode 100644 index 0000000000..f17d0c20bc --- /dev/null +++ b/gitnexus/test/unit/wiki-llm-client.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +// Import the function we'll add in the next step +import { + isAzureProvider, + isReasoningModel, + buildRequestUrl, +} from '../../src/core/wiki/llm-client.js'; + +describe('isAzureProvider', () => { + it('returns true for .openai.azure.com URLs', () => { + expect(isAzureProvider('https://myresource.openai.azure.com/openai/v1')).toBe(true); + }); + + it('returns true for .services.ai.azure.com URLs', () => { + expect(isAzureProvider('https://myresource.services.ai.azure.com/openai/v1')).toBe(true); + }); + + it('returns false for openai.com', () => { + expect(isAzureProvider('https://api.openai.com/v1')).toBe(false); + }); + + it('returns false for openrouter', () => { + expect(isAzureProvider('https://openrouter.ai/api/v1')).toBe(false); + }); + + it('returns false for spoofed URLs containing azure hostname as subdomain', () => { + expect(isAzureProvider('https://myresource.openai.azure.com.evil.com/v1')).toBe(false); + }); +}); + +describe('isReasoningModel', () => { + it('detects o1 model', () => { + expect(isReasoningModel('o1')).toBe(true); + expect(isReasoningModel('o1-mini')).toBe(true); + }); + + it('detects o3 model', () => { + expect(isReasoningModel('o3')).toBe(true); + expect(isReasoningModel('o3-mini')).toBe(true); + }); + + it('detects o4-mini', () => { + expect(isReasoningModel('o4-mini')).toBe(true); + }); + + it('returns false for bare o4 (not a known reasoning model)', () => { + expect(isReasoningModel('o4')).toBe(false); + }); + + it('returns false for gpt-4o', () => { + expect(isReasoningModel('gpt-4o')).toBe(false); + }); + + it('returns false for minimax', () => { + expect(isReasoningModel('minimax/minimax-m2.5')).toBe(false); + }); + + it('respects explicit override', () => { + expect(isReasoningModel('my-azure-deployment', true)).toBe(true); + expect(isReasoningModel('o1', false)).toBe(false); + }); +}); + +describe('buildRequestUrl', () => { + it('appends /chat/completions to plain base URL', () => { + expect(buildRequestUrl('https://api.openai.com/v1', undefined)).toBe( + 'https://api.openai.com/v1/chat/completions', + ); + }); + + it('strips trailing slash before appending', () => { + expect(buildRequestUrl('https://api.openai.com/v1/', undefined)).toBe( + 'https://api.openai.com/v1/chat/completions', + ); + }); + + it('appends api-version query param when provided', () => { + expect( + buildRequestUrl('https://myres.openai.azure.com/openai/deployments/dep1', '2024-10-21'), + ).toBe( + 'https://myres.openai.azure.com/openai/deployments/dep1/chat/completions?api-version=2024-10-21', + ); + }); + + it('does not append api-version when undefined', () => { + expect(buildRequestUrl('https://myres.openai.azure.com/openai/v1', undefined)).toBe( + 'https://myres.openai.azure.com/openai/v1/chat/completions', + ); + }); +}); + +describe('callLLM — auth header', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('uses Authorization: Bearer for non-Azure endpoints', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'hello' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 100, + temperature: 0, + }); + + const [, init] = fetchSpy.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + expect(init.headers['Authorization']).toBe('Bearer sk-test'); + expect((init.headers as any)['api-key']).toBeUndefined(); + }); + + it('uses api-key header for Azure endpoints', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'hello' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'azure-key-123', + baseUrl: 'https://myres.openai.azure.com/openai/deployments/my-dep', + model: 'my-dep', + maxTokens: 100, + temperature: 0, + provider: 'azure', + apiVersion: '2024-10-21', + }); + + const [url, init] = fetchSpy.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + expect(url).toContain('?api-version=2024-10-21'); + expect((init.headers as any)['api-key']).toBe('azure-key-123'); + expect(init.headers['Authorization']).toBeUndefined(); + }); + + it('auto-detects Azure from URL when no provider field set', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'hello' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'azure-key-auto', + baseUrl: 'https://myres.openai.azure.com/openai/v1', + model: 'my-deployment', + maxTokens: 100, + temperature: 0, + // no provider field — should auto-detect from URL + }); + + const [, init] = fetchSpy.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + expect((init.headers as any)['api-key']).toBe('azure-key-auto'); + expect(init.headers['Authorization']).toBeUndefined(); + }); +}); + +describe('callLLM — reasoning model params', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('uses max_completion_tokens and strips temperature for reasoning models', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'o3-mini', + maxTokens: 500, + temperature: 0, + }); + + const [, init] = fetchSpy.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + const body = JSON.parse(init.body as string); + expect(body.max_completion_tokens).toBe(500); + expect(body.max_tokens).toBeUndefined(); + expect(body.temperature).toBeUndefined(); + }); + + it('uses max_tokens and temperature for non-reasoning models', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0.5, + }); + + const [, init] = fetchSpy.mock.calls[0] as [ + string, + RequestInit & { headers: Record }, + ]; + const body = JSON.parse(init.body as string); + expect(body.max_tokens).toBe(500); + expect(body.max_completion_tokens).toBeUndefined(); + expect(body.temperature).toBe(0.5); + }); +}); + +describe('callLLM — Azure content_filter error', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('throws a clear error when Azure returns content_filter 400', async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ error: { code: 'content_filter', message: 'Prompt triggered policy' } }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await expect( + callLLM('test', { + apiKey: 'azure-key', + baseUrl: 'https://myres.openai.azure.com/openai/v1', + model: 'gpt-4o', + maxTokens: 100, + temperature: 0, + provider: 'azure', + }), + ).rejects.toThrow('content filter'); + }); + + it('does not throw Azure content filter error for non-Azure providers', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response('{"error": {"code": "content_filter", "message": "Filtered"}}', { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await expect( + callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 100, + temperature: 0, + }), + ).rejects.toThrow('LLM API error (400)'); + }); +}); + +describe('readSSEStream — content_filter handling', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('throws a clear error when finish_reason is content_filter', async () => { + const streamContent = [ + 'data: {"choices":[{"delta":{"content":"partial "},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"content_filter"}]}\n\n', + 'data: [DONE]\n\n', + ].join(''); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(streamContent)); + controller.close(); + }, + }); + + const fetchSpy = vi.fn().mockResolvedValue( + new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + + await expect( + callLLM( + 'test', + { + apiKey: 'azure-key', + baseUrl: 'https://myres.openai.azure.com/openai/v1', + model: 'gpt-4o', + maxTokens: 100, + temperature: 0, + provider: 'azure', + }, + undefined, + { onChunk: () => {} }, + ), + ).rejects.toThrow('content filter'); + }); +});