@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
259 lines (258 loc) • 10 kB
JavaScript
/**
* Azure OpenAI Provider for AI Changelog Generator
*
* Uses the OpenAI SDK against Azure's `/openai/v1/` endpoint.
* Supported authentication methods:
* - API key via `OPENAI_API_KEY` or `AZURE_OPENAI_KEY`
* - OAuth token via `AZURE_OAUTH_TOKEN`
* - Microsoft Entra ID via `AZURE_USE_AD_AUTH=true`
*
* Required configuration:
* - `OPENAI_BASE_URL`, or
* - `AZURE_OPENAI_ENDPOINT` (normalized to `/openai/v1/`)
*
* The `model` parameter must always be the Azure deployment name.
*/
import { DefaultAzureCredential, getBearerTokenProvider } from '@azure/identity';
import { OpenAI } from 'openai';
import { BaseProvider } from '../core/base-provider.js';
import { applyMixins, ProviderResponseHandler } from '../utils/base-provider-helpers.js';
import { getProviderModelConfig } from '../utils/model-config.js';
const AzureAuthType = {
API_KEY: 'api_key',
OAUTH_TOKEN: 'oauth_token',
AZURE_AD: 'azure_ad',
};
function normalizeAzureOpenAIBaseUrl(rawValue) {
if (typeof rawValue !== 'string') {
return null;
}
const trimmed = rawValue.trim().replace(/\/+$/, '');
if (!trimmed) {
return null;
}
if (trimmed.includes('/openai/v1')) {
return trimmed.endsWith('/openai/v1') ? `${trimmed}/` : trimmed;
}
return `${trimmed}/openai/v1/`;
}
class AzureOpenAIProvider extends BaseProvider {
constructor(config) {
super(config);
this.azureClient = null;
this._cachedDeployments = null;
this._deploymentsCacheTime = 0;
this._authType = null;
this._baseUrl = this.resolveBaseUrl();
if (this.isAvailable()) {
try {
this.initializeClient();
}
catch (error) {
console.warn(`Azure provider initialization warning: ${error.message}`);
}
}
}
resolveBaseUrl() {
return normalizeAzureOpenAIBaseUrl(this.config.OPENAI_BASE_URL || this.config.AZURE_OPENAI_ENDPOINT);
}
getAuthType() {
return this._authType;
}
getAuthInfo() {
return {
type: this._authType,
endpoint: this._baseUrl,
valid: this.isAvailable(),
};
}
setOAuthToken(token) {
this.config.AZURE_OAUTH_TOKEN = token;
this._cachedDeployments = null;
this.initializeClient();
}
initializeClient() {
this._baseUrl = this.resolveBaseUrl();
if (!this._baseUrl) {
throw new Error('Azure OpenAI is not configured. Set OPENAI_BASE_URL or AZURE_OPENAI_ENDPOINT.');
}
if (this.config.AZURE_USE_AD_AUTH === 'true') {
const azureADTokenProvider = getBearerTokenProvider(new DefaultAzureCredential(), 'https://ai.azure.com/.default');
this.azureClient = new OpenAI({
baseURL: this._baseUrl,
apiKey: azureADTokenProvider,
timeout: 30000,
maxRetries: 2,
});
this._authType = AzureAuthType.AZURE_AD;
return;
}
if (this.config.AZURE_OAUTH_TOKEN) {
this.azureClient = new OpenAI({
baseURL: this._baseUrl,
apiKey: this.config.AZURE_OAUTH_TOKEN,
timeout: 30000,
maxRetries: 2,
});
this._authType = AzureAuthType.OAUTH_TOKEN;
return;
}
const apiKey = this.config.OPENAI_API_KEY || this.config.AZURE_OPENAI_KEY;
if (!apiKey) {
throw new Error('Azure OpenAI is not configured. Set AZURE_OPENAI_KEY or OPENAI_API_KEY.');
}
this.azureClient = new OpenAI({
baseURL: this._baseUrl,
apiKey,
timeout: 30000,
maxRetries: 2,
});
this._authType = AzureAuthType.API_KEY;
}
getName() {
return 'azure';
}
isAvailable() {
const baseUrl = this.resolveBaseUrl();
if (!baseUrl) {
return false;
}
return !!(this.config.OPENAI_API_KEY ||
this.config.AZURE_OPENAI_KEY ||
this.config.AZURE_OAUTH_TOKEN ||
this.config.AZURE_USE_AD_AUTH === 'true');
}
async generateCompletion(messages, options = {}) {
return ProviderResponseHandler.executeWithErrorHandling(this, 'generate_completion', async () => {
if (!this.azureClient) {
this.initializeClient();
}
const modelConfig = this.getProviderModelConfig();
const deploymentName = options.model || this.config.AZURE_OPENAI_DEPLOYMENT_NAME || modelConfig.standardModel;
if (!deploymentName) {
throw new Error('Azure deployment name is not configured. Set AZURE_OPENAI_DEPLOYMENT_NAME or pass a deployment name in options.model.');
}
const params = {
model: deploymentName,
messages,
max_tokens: options.max_tokens || 2000,
temperature: options.temperature || 0.3,
user: options.user || this.config.AZURE_USER_ID,
};
if (options.tools) {
params.tools = options.tools;
params.tool_choice = options.tool_choice || 'auto';
}
if (options.dataSources) {
params.data_sources = options.dataSources;
}
if (options.stream) {
params.stream = true;
const stream = await this.azureClient.chat.completions.create(params);
return { stream, model: deploymentName };
}
const completion = await Promise.race([
this.azureClient.chat.completions.create(params),
new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout after 25 seconds')), 25000)),
]);
if (!(completion.choices?.length > 0 && completion.choices[0]?.message?.content)) {
const finishReason = completion.choices?.[0]?.finish_reason;
const errorMessage = finishReason === 'length'
? `Response truncated due to token limit (max_tokens: ${params.max_tokens}). Consider increasing max_tokens or reducing prompt size.`
: 'Empty response from Azure API';
throw new Error(errorMessage);
}
const content = completion.choices[0].message.content;
const finishReason = completion.choices[0].finish_reason;
if (finishReason === 'length') {
console.warn(`Azure response truncated due to token limit (${params.max_tokens}). Response may be incomplete.`);
}
return {
content,
model: completion.model,
tokens: completion.usage?.total_tokens,
usage: completion.usage,
finish_reason: finishReason,
tool_calls: completion.choices[0].message.tool_calls,
content_filters: completion.choices[0].content_filter_results || null,
};
}, { model: options.model });
}
getDeploymentName() {
const modelConfig = this.getProviderModelConfig();
return this.config.AZURE_OPENAI_DEPLOYMENT_NAME || modelConfig.standardModel;
}
async testDeployment(deploymentName) {
try {
if (!this.azureClient) {
this.initializeClient();
}
const response = await this.azureClient.chat.completions.create({
model: deploymentName,
messages: [{ role: 'user', content: 'Test' }],
max_tokens: 1,
});
return {
success: true,
deployment: deploymentName,
model: response.model,
};
}
catch (error) {
return {
success: false,
error: error.message,
deployment: deploymentName,
};
}
}
async getAvailableModels() {
if (!this.isAvailable()) {
return [];
}
if (this._cachedDeployments && Date.now() - this._deploymentsCacheTime < 300000) {
return this._cachedDeployments;
}
const modelConfig = getProviderModelConfig('azure', this.config);
const potentialDeployments = [
this.config.AZURE_OPENAI_DEPLOYMENT_NAME,
modelConfig.complexModel,
modelConfig.standardModel,
modelConfig.mediumModel,
modelConfig.smallModel,
...modelConfig.fallbacks,
]
.filter(Boolean)
.filter((value, index, values) => values.indexOf(value) === index);
const availableDeployments = [];
const testPromises = potentialDeployments.slice(0, 8).map(async (deployment) => {
const result = await this.testDeployment(deployment);
if (result.success) {
availableDeployments.push(deployment);
}
return result;
});
try {
await Promise.allSettled(testPromises);
this._cachedDeployments = availableDeployments;
this._deploymentsCacheTime = Date.now();
if (availableDeployments.length === 0) {
const fallback = this.config.AZURE_OPENAI_DEPLOYMENT_NAME || modelConfig.standardModel;
if (fallback) {
return [fallback];
}
}
return availableDeployments;
}
catch (error) {
console.warn('Failed to detect Azure deployments:', error.message);
return modelConfig.fallbacks;
}
}
async refreshAvailableModels() {
this._cachedDeployments = null;
this._deploymentsCacheTime = 0;
return await this.getAvailableModels();
}
}
export default applyMixins(AzureOpenAIProvider, 'azure');