UNPKG

@entro314labs/ai-changelog-generator

Version:

AI-powered changelog generator with MCP server support - works with most providers, online and local models

276 lines (275 loc) 12.5 kB
/** * Vercel AI Gateway Provider * * Routes completions through the Vercel AI Gateway using the Vercel AI SDK * (`ai` + `@ai-sdk/gateway`) rather than a vendor SDK. One credential reaches * every model the gateway fronts, addressed as `<provider>/<model>`. * * Authentication resolves in the SDK's own order: * 1. `AI_GATEWAY_API_KEY` * 2. Vercel OIDC (`VERCEL_OIDC_TOKEN`, or the per-request OIDC header when * running on Vercel) * * BYOK and local providers (OpenAI, Anthropic, Google, Bedrock, Ollama, * LM Studio, ...) remain available and unchanged; this is an additional route, * not a replacement for them. */ import process from 'node:process'; import { createGateway } from '@ai-sdk/gateway'; import { generateText, wrapLanguageModel } from 'ai'; import { ProviderError } from '../../../shared/utils/error-classes.js'; import { BaseProvider } from '../core/base-provider.js'; import { applyMixins } from '../utils/base-provider-helpers.js'; import { classifyAiError } from '../utils/error-classification.js'; import { createModelCacheMiddleware, isModelCacheEnabled } from '../utils/model-cache-middleware.js'; /** * Pinned snapshot rather than a floating alias: a changelog is a reproducible * artifact, and an upstream snapshot rotation would otherwise silently change * generated output with no code change to point at. */ export const DEFAULT_GATEWAY_MODEL = 'deepseek/deepseek-v4-flash-0731'; /** * Resilient fallback catalogue for when live gateway discovery is unavailable * (offline, auth not yet configured, gateway degraded). Discovery is still * preferred — this only keeps model pickers and `--list-models` useful instead * of collapsing to an empty list. */ /** * Output budget for gateway calls. * * The default flash snapshot spends a chunk of its budget before emitting * visible text, so a tight cap returns an EMPTY string with finishReason * `length` rather than a short answer. Measured against the live gateway: a * one-line changelog summary finished cleanly at a 400-token cap using 173 * tokens, and returned nothing at a 60-token cap. 4000 leaves ample headroom for * the multi-entry prompts this package actually sends. * * Note: explicitly sending `providerOptions.deepseek.thinking = { type: * 'disabled' }` was measured to make this WORSE through the gateway — it * consumed the entire budget and returned empty text where the default finished * cleanly — so thinking is deliberately left at the provider default here. */ const DEFAULT_MAX_OUTPUT_TOKENS = 4000; const FALLBACK_GATEWAY_MODELS = [ { id: DEFAULT_GATEWAY_MODEL, name: 'DeepSeek V4 Flash (0731)', description: 'Default low-cost, long-context workhorse for changelog generation', }, { id: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro', description: 'Stronger DeepSeek tier for harder analyses', }, { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', description: 'Balanced Claude tier', }, { id: 'openai/gpt-5.6-luna', name: 'GPT-5.6 Luna', description: 'High-volume, low-cost OpenAI tier', }, ]; class VercelGatewayProvider extends BaseProvider { constructor(config) { super(config); this.client = null; if (this.isAvailable()) { this.initializeClient(); } } initializeClient() { const baseURL = this.config.AI_GATEWAY_BASE_URL?.trim(); const apiKey = this.config.AI_GATEWAY_API_KEY?.trim(); this.client = createGateway({ // Leave both undefined unless actually configured, so the SDK's own // defaults apply. A present-but-empty env var would otherwise pin the // base URL to '' and break every call, because the SDK falls back with `??`. ...(apiKey ? { apiKey } : {}), ...(baseURL ? { baseURL } : {}), }); } getName() { return 'vercel-gateway'; } /** * An API key or an OIDC token is enough. On a Vercel runtime the OIDC token * usually arrives as a request header and never appears in the environment, * so treat "running on Vercel" as credentials-may-exist: a false negative * here disables the provider entirely, which is the worse failure. */ isAvailable() { return Boolean(this.config.AI_GATEWAY_API_KEY?.trim() || this.config.VERCEL_OIDC_TOKEN?.trim() || process.env.VERCEL?.trim()); } // The credential is AI_GATEWAY_API_KEY, not the VERCEL-GATEWAY_API_KEY the // generic mixin would derive from the provider name. getRequiredEnvVars() { return ['AI_GATEWAY_API_KEY']; } // Model tiers are registered in model-config.ts under 'vercel-gateway', so the // shared getDefaultModel resolution (AI_MODEL / <PROVIDER>_MODEL / standardModel) // applies unchanged. getCapabilities(_modelName) { return { streaming: true, tool_use: true, json_mode: true, vision: false, reasoning: true, temperature_control: true, }; } /** * The gateway's catalogue is remote and changes independently of this package, * so it is queried rather than hard-coded. Returns [] when unreachable: an * empty catalogue degrades model pickers, a throw would break provider health. */ async getAvailableModels() { if (!this.isAvailable()) { return FALLBACK_GATEWAY_MODELS; } try { const { models } = await this.client.getAvailableModels(); if (!Array.isArray(models) || models.length === 0) { return FALLBACK_GATEWAY_MODELS; } return models.map((model) => ({ id: model.id, name: model.name ?? model.id, description: model.description ?? '', })); } catch { // Discovery is best-effort: a degraded gateway must not empty the catalogue. return FALLBACK_GATEWAY_MODELS; } } /** * Resolve a gateway model, wrapped in the response cache unless caching is * disabled. Wrapping at the model boundary means every call path — commit * summaries, working-directory analysis, commit messages — shares one cache * without any of them knowing about it. */ resolveModel(modelId) { const model = this.client(modelId); if (!isModelCacheEnabled()) { return model; } this._cacheMiddleware ??= createModelCacheMiddleware({ onHit: () => { this._cacheHits = (this._cacheHits || 0) + 1; }, onMiss: () => { this._cacheMisses = (this._cacheMisses || 0) + 1; }, }); return wrapLanguageModel({ model, middleware: this._cacheMiddleware }); } resolveTemperature(options, modelConfig) { if (options.temperature !== undefined) { return options.temperature; } if (typeof modelConfig?.temperature === 'number') { return modelConfig.temperature; } const configured = Number.parseFloat(this.config?.AI_TEMPERATURE); return Number.isFinite(configured) ? configured : 0.3; } /** Cache counters for this process, surfaced in provider info/metrics. */ getCacheStats() { return { hits: this._cacheHits || 0, misses: this._cacheMisses || 0 }; } /** * Translate the internal chat-message contract into an AI SDK call. Any * leading system messages become `system`, matching how generateText separates * the system prompt from the conversation. */ async generateCompletion(messages, options = {}) { if (!this.isAvailable()) { return this.handleProviderError(new Error('Vercel AI Gateway is not configured. Set AI_GATEWAY_API_KEY, or run on Vercel with OIDC enabled.'), 'generate_completion'); } const modelId = options.model || this.getDefaultModel(); try { const systemPrompt = messages .filter((message) => message.role === 'system') .map((message) => message.content) .join('\n\n'); const conversation = messages .filter((message) => message.role !== 'system') .map((message) => ({ role: message.role, content: message.content })); const modelConfig = this.getProviderModelConfig(); const result = await generateText({ model: this.resolveModel(modelId), ...(systemPrompt ? { system: systemPrompt } : {}), messages: conversation, // The shared model registry carries no temperature for this provider, so // resolve it explicitly: per-call wins, then the configured // AI_TEMPERATURE, then a low default suited to changelog prose. temperature: this.resolveTemperature(options, modelConfig), maxOutputTokens: options.max_tokens || modelConfig.maxTokens || DEFAULT_MAX_OUTPUT_TOKENS, ...(options.tools ? { tools: options.tools } : {}), // Propagate cancellation so a timed-out or aborted caller actually stops // the upstream request instead of leaving it in flight. ...(options.signal ? { abortSignal: options.signal } : {}), }); // A model can finish "successfully" with no usable text — most often when // the output budget was consumed before any visible answer was produced. // Returning '' here would write an empty changelog entry and look like a // success, so it is surfaced as a failure and the caller falls back to // rule-based analysis. if (!result.text || result.text.trim().length === 0) { const truncated = result.finishReason === 'length'; return { success: false, error: truncated ? 'The model reached its output limit before producing any text.' : 'The model returned no usable output.', errorCode: truncated ? 'output_truncated' : 'no_output', retryable: truncated, shouldFallback: true, model: modelId, finish_reason: result.finishReason, suggestions: truncated ? [ 'Increase the output budget for this model', 'Narrow the commit range so the prompt is smaller', ] : ['Retry, or use --no-ai for rule-based generation'], }; } return { content: result.text, model: modelId, tokens: result.usage?.totalTokens ?? 0, finish_reason: result.finishReason, tool_calls: result.toolCalls?.length ? result.toolCalls : undefined, }; } catch (error) { // Classify before delegating: the shared handler matches on message text, // which cannot distinguish "retry will work" (rate limit, 5xx, network) // from "retry is pointless" (bad credential, missing model, quota). const classified = classifyAiError(error); return { ...this.handleProviderError(error, 'generate_completion', { model: modelId }), errorCode: classified.code, retryable: classified.retryable, shouldFallback: classified.shouldFallback, message: classified.message, suggestions: classified.suggestions, }; } } async generateEmbedding() { // Embeddings go through `embed`/`embedMany`, a different SDK surface with a // different model catalogue. Nothing in this package requests embeddings // from the gateway yet, so fail loudly rather than return a fake vector. throw new ProviderError('Embeddings are not implemented for the Vercel AI Gateway provider', 'vercel-gateway', 'generateEmbedding'); } } export default applyMixins(VercelGatewayProvider, 'vercel-gateway'); export { VercelGatewayProvider };