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:
Abhigyan Patwari 2026-03-29 00:23:41 +05:30 • committed by GitHub
parent 2ca7df642b
commit 1b1c90eaab
6 changed files with 602 additions and 57 deletions

View file

@ -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",

View file

@ -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)')

View file

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

View file

@ -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');
}

View file

@ -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;
}
/**

View 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');
});
});