mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-28 06:22:22 +00:00
feat(wiki): Azure OpenAI support for wiki command (#562)
* feat(wiki): extend LLMConfig/CLIConfig with Azure and reasoning model fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wiki): restore cursor model resolution, fix LLMProvider type, clean up regex Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(wiki): detect content_filter finish_reason in SSE stream and throw clear error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wiki): skip delta accumulation after content_filter, use provider-neutral error message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat(wiki): add --api-version and --reasoning-model CLI flags for Azure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix(wiki): use execFileSync for EDITOR to prevent shell injection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2ca7df642b
commit
1b1c90eaab
6 changed files with 602 additions and 57 deletions
17
gitnexus/package-lock.json
generated
17
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <provider>', 'LLM provider: openai or cursor (default: openai)')
|
||||
.option('--model <model>', 'LLM model name (default depends on provider)')
|
||||
.option('--base-url <url>', 'LLM API base URL (for openai provider)')
|
||||
.option('--api-key <key>', 'LLM API key (saved to ~/.gitnexus/config.json)')
|
||||
.option('--model <model>', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)')
|
||||
.option(
|
||||
'--base-url <url>',
|
||||
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',
|
||||
)
|
||||
.option('--api-key <key>', 'LLM API key or Azure api-key (saved to ~/.gitnexus/config.json)')
|
||||
.option(
|
||||
'--api-version <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 <n>', '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)')
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {};
|
||||
const updates: Partial<typeof existing> = {};
|
||||
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<typeof saveCLIConfig>[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`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<LLMConfig>): 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<LLMConfig>): 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<LLMConfig>): 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<string, unknown> = {
|
||||
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<string, string> = 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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
332
gitnexus/test/unit/wiki-llm-client.test.ts
Normal file
332
gitnexus/test/unit/wiki-llm-client.test.ts
Normal file
|
|
@ -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<string, string> },
|
||||
];
|
||||
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<string, string> },
|
||||
];
|
||||
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<string, string> },
|
||||
];
|
||||
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<string, string> },
|
||||
];
|
||||
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<string, string> },
|
||||
];
|
||||
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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue