diff --git a/packages/services/service-ai/src/__tests__/ai-service.test.ts b/packages/services/service-ai/src/__tests__/ai-service.test.ts index ac387ac0b..84d77536e 100644 --- a/packages/services/service-ai/src/__tests__/ai-service.test.ts +++ b/packages/services/service-ai/src/__tests__/ai-service.test.ts @@ -1045,6 +1045,61 @@ describe('AIServicePlugin', () => { } }); + it('should prefer the gatewayModel option over the AI_GATEWAY_MODEL env var', async () => { + // Mock the gateway SDK to fail so detection falls through deterministically; + // the warn message echoes the CHOSEN model, letting us assert precedence. + vi.doMock('@ai-sdk/gateway', () => { throw new Error("Cannot find module '@ai-sdk/gateway'"); }); + const { AIServicePlugin: FreshPlugin } = await import('../plugin.js'); + const plugin = new FreshPlugin({ gatewayModel: 'anthropic/claude-haiku-4-5' }); + const ctx = createMockContext(); + + const oldEnv = { ...process.env }; + process.env.AI_GATEWAY_MODEL = 'openai/gpt-5.5'; + delete process.env.OPENAI_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.GOOGLE_GENERATIVE_AI_API_KEY; + + try { + await plugin.init(ctx); + // The option's model must be the one attempted, not the env var's. + expect(silentLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('anthropic/claude-haiku-4-5'), + expect.anything(), + ); + expect(silentLogger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('openai/gpt-5.5'), + expect.anything(), + ); + } finally { + process.env = oldEnv; + vi.doUnmock('@ai-sdk/gateway'); + } + }); + + it('should fall back to AI_GATEWAY_MODEL env when no gatewayModel option is set', async () => { + vi.doMock('@ai-sdk/gateway', () => { throw new Error("Cannot find module '@ai-sdk/gateway'"); }); + const { AIServicePlugin: FreshPlugin } = await import('../plugin.js'); + const plugin = new FreshPlugin(); + const ctx = createMockContext(); + + const oldEnv = { ...process.env }; + process.env.AI_GATEWAY_MODEL = 'anthropic/claude-sonnet-4-5'; + delete process.env.OPENAI_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.GOOGLE_GENERATIVE_AI_API_KEY; + + try { + await plugin.init(ctx); + expect(silentLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('anthropic/claude-sonnet-4-5'), + expect.anything(), + ); + } finally { + process.env = oldEnv; + vi.doUnmock('@ai-sdk/gateway'); + } + }); + it('should prefer explicit adapter over auto-detection', async () => { const customAdapter: LLMAdapter = { name: 'custom-explicit', diff --git a/packages/services/service-ai/src/plugin.ts b/packages/services/service-ai/src/plugin.ts index d78f6bcce..92bceb321 100644 --- a/packages/services/service-ai/src/plugin.ts +++ b/packages/services/service-ai/src/plugin.ts @@ -48,6 +48,14 @@ export interface AIServicePluginOptions { models?: AI.ModelConfig[]; /** Default model id (must appear in `models`). */ defaultModelId?: string; + /** + * Vercel AI Gateway model id (e.g. `anthropic/claude-haiku-4-5`) for this + * plugin instance. Takes precedence over the `AI_GATEWAY_MODEL` env var so a + * host can select the model per kernel — e.g. a multi-tenant runtime routing + * by plan. When omitted, falls back to `AI_GATEWAY_MODEL` (unchanged + * behavior). Pairs with the gateway adapter only; ignored by other providers. + */ + gatewayModel?: string; /** * Explicit trace recorder override. When set, auto-detection * of {@link ObjectQLTraceRecorder} is skipped. @@ -388,8 +396,10 @@ export class AIServicePlugin implements Plugin { * Returns the adapter and a description for logging. */ private async detectAdapter(ctx: PluginContext): Promise<{ adapter: LLMAdapter; description: string }> { - // 1. Vercel AI Gateway — works with any provider via gateway('provider/model') - const gatewayModel = process.env.AI_GATEWAY_MODEL; + // 1. Vercel AI Gateway — works with any provider via gateway('provider/model'). + // A per-instance `gatewayModel` option wins over the process-wide env var + // so a multi-tenant host can route the model per kernel (e.g. by plan). + const gatewayModel = this.options.gatewayModel ?? process.env.AI_GATEWAY_MODEL; if (gatewayModel) { try { const gatewayPkg = '@ai-sdk/gateway'; @@ -398,7 +408,7 @@ export class AIServicePlugin implements Plugin { return { adapter, description: `Vercel AI Gateway (model: ${gatewayModel})` }; } catch (err) { ctx.logger.warn( - `[AI] Failed to load @ai-sdk/gateway for AI_GATEWAY_MODEL=${gatewayModel}, trying next provider`, + `[AI] Failed to load @ai-sdk/gateway for model=${gatewayModel}, trying next provider`, err instanceof Error ? { error: err.message } : undefined ); }