Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/services/service-ai/src/__tests__/ai-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
16 changes: 13 additions & 3 deletions packages/services/service-ai/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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';
Expand All @@ -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
);
}
Expand Down
Loading