770 lines
25 KiB
TypeScript
770 lines
25 KiB
TypeScript
/**
|
|
* Phase 0: AI SDK Integration Validation
|
|
*
|
|
* Validates that AI SDK works in our Node.js/Electron-compatible runtime:
|
|
* - Provider imports and instantiation work
|
|
* - createProviderRegistry resolves providerId:modelId correctly
|
|
* - customProvider with fallback routes gateway models to correct SDK type
|
|
* - baseURL override produces correct request URLs
|
|
* - generateText and streamText produce correct shaped request bodies
|
|
* - No static model-to-provider maps needed — provider prefix is the routing
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import {
|
|
generateText,
|
|
streamText,
|
|
tool,
|
|
createProviderRegistry,
|
|
customProvider,
|
|
} from 'ai';
|
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
import { createOpenAI } from '@ai-sdk/openai';
|
|
import { createMistral } from '@ai-sdk/mistral';
|
|
import { z } from 'zod';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers: capture what the SDK would send without hitting a real API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a mock fetch that captures the request details and returns a
|
|
* provider-appropriate response so the SDK doesn't throw.
|
|
*/
|
|
function createCapturingFetch(providerType: 'anthropic' | 'openai') {
|
|
const captured: { url: string; body: any; headers: any }[] = [];
|
|
|
|
const mockFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === 'string' ? input : input.toString();
|
|
const body = init?.body ? JSON.parse(init.body as string) : undefined;
|
|
const headers = init?.headers;
|
|
captured.push({ url, body, headers });
|
|
|
|
// Return a minimal valid response for the provider type
|
|
if (providerType === 'anthropic') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'msg_test',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Hello from mock' }],
|
|
model: body?.model ?? 'claude-sonnet-4-5',
|
|
stop_reason: 'end_turn',
|
|
usage: { input_tokens: 10, output_tokens: 5 },
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
// OpenAI / Mistral (OpenAI-compatible)
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-test',
|
|
object: 'chat.completion',
|
|
model: body?.model ?? 'gpt-4.1',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: 'assistant', content: 'Hello from mock' },
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
return { mockFetch, captured };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Provider imports and instantiation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AI SDK Phase 0 — Provider Imports', () => {
|
|
it('creates Anthropic provider with baseURL override', () => {
|
|
const provider = createAnthropic({
|
|
baseURL: 'https://opencode.ai/zen/v1',
|
|
apiKey: 'test-key',
|
|
});
|
|
expect(provider).toBeDefined();
|
|
expect(typeof provider).toBe('function');
|
|
});
|
|
|
|
it('creates OpenAI provider with baseURL override', () => {
|
|
const provider = createOpenAI({
|
|
baseURL: 'https://opencode.ai/zen/v1',
|
|
apiKey: 'test-key',
|
|
});
|
|
expect(provider).toBeDefined();
|
|
expect(typeof provider).toBe('function');
|
|
});
|
|
|
|
it('creates Mistral provider with defaults', () => {
|
|
const provider = createMistral({
|
|
apiKey: 'test-key',
|
|
});
|
|
expect(provider).toBeDefined();
|
|
expect(typeof provider).toBe('function');
|
|
});
|
|
|
|
it('creates a LanguageModel from Anthropic provider', () => {
|
|
const provider = createAnthropic({ apiKey: 'test-key' });
|
|
const model = provider('claude-sonnet-4-5');
|
|
expect(model).toBeDefined();
|
|
expect(model.modelId).toBe('claude-sonnet-4-5');
|
|
expect(model.provider).toContain('anthropic');
|
|
});
|
|
|
|
it('creates a LanguageModel from OpenAI provider', () => {
|
|
const provider = createOpenAI({ apiKey: 'test-key' });
|
|
const model = provider('gpt-4.1');
|
|
expect(model).toBeDefined();
|
|
expect(model.modelId).toBe('gpt-4.1');
|
|
expect(model.provider).toContain('openai');
|
|
});
|
|
|
|
it('creates a LanguageModel from Mistral provider', () => {
|
|
const provider = createMistral({ apiKey: 'test-key' });
|
|
const model = provider('mistral-large-latest');
|
|
expect(model).toBeDefined();
|
|
expect(model.modelId).toBe('mistral-large-latest');
|
|
expect(model.provider).toContain('mistral');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Provider Registry — providerId:modelId routing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AI SDK Phase 0 — Provider Registry', () => {
|
|
it('resolves models via providerId:modelId prefix', () => {
|
|
const registry = createProviderRegistry({
|
|
anthropic: createAnthropic({ apiKey: 'k1' }),
|
|
openai: createOpenAI({ apiKey: 'k2' }),
|
|
mistral: createMistral({ apiKey: 'k3' }),
|
|
});
|
|
|
|
const claude = registry.languageModel('anthropic:claude-sonnet-4-5');
|
|
expect(claude.modelId).toBe('claude-sonnet-4-5');
|
|
expect(claude.provider).toContain('anthropic');
|
|
|
|
const gpt = registry.languageModel('openai:gpt-4.1');
|
|
expect(gpt.modelId).toBe('gpt-4.1');
|
|
expect(gpt.provider).toContain('openai');
|
|
|
|
const mistral = registry.languageModel('mistral:mistral-large-latest');
|
|
expect(mistral.modelId).toBe('mistral-large-latest');
|
|
expect(mistral.provider).toContain('mistral');
|
|
});
|
|
|
|
it('throws on unknown provider prefix', () => {
|
|
const registry = createProviderRegistry({
|
|
anthropic: createAnthropic({ apiKey: 'k1' }),
|
|
});
|
|
|
|
expect(() => registry.languageModel('unknown:model-id')).toThrow();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. Gateway pattern — OpenCode Zen as custom provider
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AI SDK Phase 0 — Gateway Custom Provider', () => {
|
|
const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
|
|
|
/**
|
|
* Creates an OpenCode gateway provider that routes models to the correct
|
|
* SDK type based on a minimal rule:
|
|
* - claude* → Anthropic Messages API
|
|
* - everything else → OpenAI Chat Completions API
|
|
*
|
|
* No static model list needed. The rule is: "Anthropic models use the
|
|
* Anthropic API, all others use OpenAI-compatible." This matches
|
|
* reality — only Anthropic has a separate Messages API.
|
|
*
|
|
* IMPORTANT: OpenAI SDK v6 defaults to the Responses API (/responses).
|
|
* OpenCode Zen (and most third-party gateways) only support Chat
|
|
* Completions (/chat/completions). Use provider.chat(modelId) to get
|
|
* a Chat Completions model instead of the default Responses model.
|
|
*/
|
|
function createOpenCodeGateway(apiKey: string, fetchImpl?: typeof fetch) {
|
|
const anthropicProvider = createAnthropic({
|
|
baseURL: ZEN_BASE_URL,
|
|
apiKey,
|
|
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
});
|
|
const openaiProvider = createOpenAI({
|
|
baseURL: ZEN_BASE_URL,
|
|
apiKey,
|
|
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
});
|
|
|
|
return customProvider({
|
|
// No explicit model list — use fallbackProvider with routing logic
|
|
languageModels: {},
|
|
fallbackProvider: {
|
|
languageModel: (modelId: string) => {
|
|
// Minimal routing: only Claude uses Anthropic Messages API
|
|
if (modelId.startsWith('claude')) {
|
|
return anthropicProvider(modelId);
|
|
}
|
|
// Use .chat() for Chat Completions — Zen doesn't support Responses API
|
|
return openaiProvider.chat(modelId);
|
|
},
|
|
embeddingModel: () => {
|
|
throw new Error('Embeddings not supported via OpenCode gateway');
|
|
},
|
|
imageModel: () => {
|
|
throw new Error('Image models not supported via OpenCode gateway');
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
it('routes claude models to Anthropic provider', () => {
|
|
const gateway = createOpenCodeGateway('test-key');
|
|
const model = gateway.languageModel('claude-sonnet-4-5');
|
|
expect(model.provider).toContain('anthropic');
|
|
expect(model.modelId).toBe('claude-sonnet-4-5');
|
|
});
|
|
|
|
it('routes gpt models to OpenAI provider', () => {
|
|
const gateway = createOpenCodeGateway('test-key');
|
|
const model = gateway.languageModel('gpt-4.1');
|
|
expect(model.provider).toContain('openai');
|
|
expect(model.modelId).toBe('gpt-4.1');
|
|
});
|
|
|
|
it('routes gemini models to OpenAI provider (OpenAI-compatible via Zen)', () => {
|
|
const gateway = createOpenCodeGateway('test-key');
|
|
const model = gateway.languageModel('gemini-2.5-pro');
|
|
expect(model.provider).toContain('openai');
|
|
expect(model.modelId).toBe('gemini-2.5-pro');
|
|
});
|
|
|
|
it('routes o3/o4 models to OpenAI provider', () => {
|
|
const gateway = createOpenCodeGateway('test-key');
|
|
expect(gateway.languageModel('o3-mini').provider).toContain('openai');
|
|
expect(gateway.languageModel('o4-mini').provider).toContain('openai');
|
|
});
|
|
|
|
it('integrates with provider registry', () => {
|
|
const registry = createProviderRegistry({
|
|
opencode: createOpenCodeGateway('oc-key'),
|
|
mistral: createMistral({ apiKey: 'mi-key' }),
|
|
});
|
|
|
|
// Claude through OpenCode gateway
|
|
const claude = registry.languageModel('opencode:claude-sonnet-4-5');
|
|
expect(claude.provider).toContain('anthropic');
|
|
|
|
// GPT through OpenCode gateway
|
|
const gpt = registry.languageModel('opencode:gpt-4.1');
|
|
expect(gpt.provider).toContain('openai');
|
|
|
|
// Mistral direct
|
|
const mistral = registry.languageModel('mistral:mistral-large-latest');
|
|
expect(mistral.provider).toContain('mistral');
|
|
});
|
|
|
|
it('supports adding direct providers alongside gateway', () => {
|
|
const registry = createProviderRegistry({
|
|
opencode: createOpenCodeGateway('oc-key'),
|
|
anthropic: createAnthropic({ apiKey: 'direct-anthropic-key' }),
|
|
openai: createOpenAI({ apiKey: 'direct-openai-key' }),
|
|
mistral: createMistral({ apiKey: 'mi-key' }),
|
|
});
|
|
|
|
// Same model, different providers (gateway vs direct)
|
|
const viaGateway = registry.languageModel('opencode:claude-sonnet-4-5');
|
|
const viaDirect = registry.languageModel('anthropic:claude-sonnet-4-5');
|
|
|
|
// Both are Anthropic SDK models but with different baseURLs
|
|
expect(viaGateway.provider).toContain('anthropic');
|
|
expect(viaDirect.provider).toContain('anthropic');
|
|
expect(viaGateway.modelId).toBe('claude-sonnet-4-5');
|
|
expect(viaDirect.modelId).toBe('claude-sonnet-4-5');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. BaseURL request path verification — Zen gateway compatibility
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AI SDK Phase 0 — Zen BaseURL Request Paths', () => {
|
|
const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
|
|
|
it('Anthropic provider appends /messages to baseURL', async () => {
|
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
|
|
|
const provider = createAnthropic({
|
|
baseURL: ZEN_BASE_URL,
|
|
apiKey: 'test-key',
|
|
fetch: mockFetch,
|
|
});
|
|
|
|
await generateText({
|
|
model: provider('claude-sonnet-4-5'),
|
|
prompt: 'Hello',
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(captured.length).toBeGreaterThan(0);
|
|
expect(captured[0].url).toBe('https://opencode.ai/zen/v1/messages');
|
|
});
|
|
|
|
it('OpenAI provider.chat() appends /chat/completions to baseURL', async () => {
|
|
const { mockFetch, captured } = createCapturingFetch('openai');
|
|
|
|
const provider = createOpenAI({
|
|
baseURL: ZEN_BASE_URL,
|
|
apiKey: 'test-key',
|
|
fetch: mockFetch,
|
|
});
|
|
|
|
// Use .chat() — the default provider() uses Responses API (/responses)
|
|
// which Zen and most third-party gateways don't support
|
|
await generateText({
|
|
model: provider.chat('gpt-4.1'),
|
|
prompt: 'Hello',
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(captured.length).toBeGreaterThan(0);
|
|
expect(captured[0].url).toBe('https://opencode.ai/zen/v1/chat/completions');
|
|
});
|
|
|
|
it('Anthropic provider sends x-api-key header', async () => {
|
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
|
|
|
const provider = createAnthropic({
|
|
baseURL: ZEN_BASE_URL,
|
|
apiKey: 'my-zen-key',
|
|
fetch: mockFetch,
|
|
});
|
|
|
|
await generateText({
|
|
model: provider('claude-sonnet-4-5'),
|
|
prompt: 'Hello',
|
|
maxRetries: 0,
|
|
});
|
|
|
|
const headers = captured[0].headers;
|
|
// Headers could be a Headers object or plain object
|
|
const apiKey =
|
|
headers instanceof Headers
|
|
? headers.get('x-api-key')
|
|
: headers?.['x-api-key'];
|
|
expect(apiKey).toBe('my-zen-key');
|
|
});
|
|
|
|
it('OpenAI provider sends Authorization Bearer header', async () => {
|
|
const { mockFetch, captured } = createCapturingFetch('openai');
|
|
|
|
const provider = createOpenAI({
|
|
baseURL: ZEN_BASE_URL,
|
|
apiKey: 'my-zen-key',
|
|
fetch: mockFetch,
|
|
});
|
|
|
|
await generateText({
|
|
model: provider.chat('gpt-4.1'),
|
|
prompt: 'Hello',
|
|
maxRetries: 0,
|
|
});
|
|
|
|
const headers = captured[0].headers;
|
|
const auth =
|
|
headers instanceof Headers
|
|
? headers.get('authorization')
|
|
: headers?.['Authorization'] ?? headers?.['authorization'];
|
|
expect(auth).toContain('Bearer my-zen-key');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. Tool definitions work with AI SDK tool() + Zod
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AI SDK Phase 0 — Tool Definitions', () => {
|
|
it('defines a tool with Zod schema and execute function', () => {
|
|
const checkTerm = tool({
|
|
description: 'Check whether a term exists as a category, tag, or both',
|
|
inputSchema: z.object({
|
|
term: z.string().describe('The term to look up'),
|
|
}),
|
|
execute: async ({ term }) => {
|
|
return { term, found: true, type: 'category' };
|
|
},
|
|
});
|
|
|
|
expect(checkTerm).toBeDefined();
|
|
expect(checkTerm.description).toBe(
|
|
'Check whether a term exists as a category, tag, or both',
|
|
);
|
|
});
|
|
|
|
it('defines a tool with toModelOutput for multimodal results', () => {
|
|
const viewImage = tool({
|
|
description: 'View an image',
|
|
inputSchema: z.object({
|
|
media_id: z.number(),
|
|
size: z.enum(['small', 'medium', 'large']).default('medium'),
|
|
}),
|
|
execute: async ({ media_id }) => {
|
|
return {
|
|
base64: 'iVBORw0KGgo...',
|
|
mediaType: 'image/png' as const,
|
|
caption: `Image #${media_id}`,
|
|
};
|
|
},
|
|
toModelOutput: ({ output }) => ({
|
|
type: 'content' as const,
|
|
value: [
|
|
{
|
|
type: 'image' as const,
|
|
data: output.base64,
|
|
mediaType: output.mediaType,
|
|
},
|
|
{ type: 'text' as const, text: output.caption },
|
|
],
|
|
}),
|
|
});
|
|
|
|
expect(viewImage).toBeDefined();
|
|
});
|
|
|
|
it('sends tools in generateText request body', async () => {
|
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
|
|
|
const provider = createAnthropic({
|
|
apiKey: 'test-key',
|
|
fetch: mockFetch,
|
|
});
|
|
|
|
await generateText({
|
|
model: provider('claude-sonnet-4-5'),
|
|
prompt: 'Check the term "javascript"',
|
|
tools: {
|
|
check_term: tool({
|
|
description: 'Check a term',
|
|
inputSchema: z.object({ term: z.string() }),
|
|
execute: async ({ term }) => ({ found: true, term }),
|
|
}),
|
|
},
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(captured[0].body.tools).toBeDefined();
|
|
expect(captured[0].body.tools.length).toBeGreaterThan(0);
|
|
expect(captured[0].body.tools[0].name).toBe('check_term');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. Anthropic-specific providerOptions (cache control, etc.)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AI SDK Phase 0 — Anthropic Provider Options', () => {
|
|
it('sends cache control on system message', async () => {
|
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
|
|
|
const provider = createAnthropic({
|
|
apiKey: 'test-key',
|
|
fetch: mockFetch,
|
|
});
|
|
|
|
await generateText({
|
|
model: provider('claude-sonnet-4-5'),
|
|
messages: [
|
|
{
|
|
role: 'system',
|
|
content: 'You are a blog editor.',
|
|
providerOptions: {
|
|
anthropic: { cacheControl: { type: 'ephemeral' } },
|
|
},
|
|
},
|
|
{ role: 'user', content: 'Hello' },
|
|
],
|
|
maxRetries: 0,
|
|
});
|
|
|
|
// Anthropic SDK sends system as top-level field or within messages
|
|
// The key assertion is that the request succeeds with providerOptions
|
|
expect(captured.length).toBeGreaterThan(0);
|
|
const body = captured[0].body;
|
|
// System with cache_control should appear in the request
|
|
expect(body.system).toBeDefined();
|
|
const systemBlock = Array.isArray(body.system) ? body.system : [body.system];
|
|
const hasCacheControl = systemBlock.some(
|
|
(s: any) => s.cache_control?.type === 'ephemeral',
|
|
);
|
|
expect(hasCacheControl).toBe(true);
|
|
});
|
|
|
|
it('sends cache control on tools', async () => {
|
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
|
|
|
const provider = createAnthropic({
|
|
apiKey: 'test-key',
|
|
fetch: mockFetch,
|
|
});
|
|
|
|
await generateText({
|
|
model: provider('claude-sonnet-4-5'),
|
|
prompt: 'Hello',
|
|
tools: {
|
|
check_term: tool({
|
|
description: 'Check a term',
|
|
inputSchema: z.object({ term: z.string() }),
|
|
execute: async ({ term }) => ({ found: true, term }),
|
|
providerOptions: {
|
|
anthropic: { cacheControl: { type: 'ephemeral' } },
|
|
},
|
|
}),
|
|
},
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(captured[0].body.tools).toBeDefined();
|
|
const toolDef = captured[0].body.tools[0];
|
|
expect(toolDef.cache_control?.type).toBe('ephemeral');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 7. generateText produces correct model + usage
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AI SDK Phase 0 — generateText Response', () => {
|
|
it('returns text and usage from Anthropic provider', async () => {
|
|
const { mockFetch } = createCapturingFetch('anthropic');
|
|
|
|
const provider = createAnthropic({
|
|
apiKey: 'test-key',
|
|
fetch: mockFetch,
|
|
});
|
|
|
|
const result = await generateText({
|
|
model: provider('claude-sonnet-4-5'),
|
|
prompt: 'Hello',
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(result.text).toBe('Hello from mock');
|
|
expect(result.usage.inputTokens).toBe(10);
|
|
expect(result.usage.outputTokens).toBe(5);
|
|
});
|
|
|
|
it('returns text and usage from OpenAI provider (chat mode)', async () => {
|
|
const { mockFetch } = createCapturingFetch('openai');
|
|
|
|
const provider = createOpenAI({
|
|
apiKey: 'test-key',
|
|
fetch: mockFetch,
|
|
});
|
|
|
|
// Use .chat() for Chat Completions format
|
|
const result = await generateText({
|
|
model: provider.chat('gpt-4.1'),
|
|
prompt: 'Hello',
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(result.text).toBe('Hello from mock');
|
|
expect(result.usage.inputTokens).toBe(10);
|
|
expect(result.usage.outputTokens).toBe(5);
|
|
});
|
|
|
|
it('returns text via provider registry resolution', async () => {
|
|
const { mockFetch } = createCapturingFetch('anthropic');
|
|
|
|
const registry = createProviderRegistry({
|
|
anthropic: createAnthropic({
|
|
apiKey: 'test-key',
|
|
fetch: mockFetch,
|
|
}),
|
|
});
|
|
|
|
const result = await generateText({
|
|
model: registry.languageModel('anthropic:claude-sonnet-4-5'),
|
|
prompt: 'Hello',
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(result.text).toBe('Hello from mock');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 8. Gateway end-to-end: registry → gateway → correct provider → correct URL
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AI SDK Phase 0 — Gateway End-to-End', () => {
|
|
const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
|
|
|
function createOpenCodeGateway(apiKey: string, fetchImpl: typeof fetch) {
|
|
const anthropicProvider = createAnthropic({
|
|
baseURL: ZEN_BASE_URL,
|
|
apiKey,
|
|
fetch: fetchImpl,
|
|
});
|
|
const openaiProvider = createOpenAI({
|
|
baseURL: ZEN_BASE_URL,
|
|
apiKey,
|
|
fetch: fetchImpl,
|
|
});
|
|
|
|
return customProvider({
|
|
languageModels: {},
|
|
fallbackProvider: {
|
|
languageModel: (modelId: string) => {
|
|
if (modelId.startsWith('claude')) {
|
|
return anthropicProvider(modelId);
|
|
}
|
|
// Must use .chat() for Chat Completions API (Zen gateway compatible)
|
|
return openaiProvider.chat(modelId);
|
|
},
|
|
embeddingModel: () => {
|
|
throw new Error('Not supported');
|
|
},
|
|
imageModel: () => {
|
|
throw new Error('Not supported');
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
it('Claude via gateway hits Zen Anthropic endpoint', async () => {
|
|
const { mockFetch, captured } = createCapturingFetch('anthropic');
|
|
|
|
const registry = createProviderRegistry({
|
|
opencode: createOpenCodeGateway('oc-key', mockFetch),
|
|
});
|
|
|
|
await generateText({
|
|
model: registry.languageModel('opencode:claude-sonnet-4-5'),
|
|
prompt: 'Hello',
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(captured[0].url).toBe('https://opencode.ai/zen/v1/messages');
|
|
expect(captured[0].body.model).toBe('claude-sonnet-4-5');
|
|
});
|
|
|
|
it('GPT via gateway hits Zen OpenAI endpoint', async () => {
|
|
const { mockFetch, captured } = createCapturingFetch('openai');
|
|
|
|
const registry = createProviderRegistry({
|
|
opencode: createOpenCodeGateway('oc-key', mockFetch),
|
|
});
|
|
|
|
await generateText({
|
|
model: registry.languageModel('opencode:gpt-4.1'),
|
|
prompt: 'Hello',
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(captured[0].url).toBe(
|
|
'https://opencode.ai/zen/v1/chat/completions',
|
|
);
|
|
expect(captured[0].body.model).toBe('gpt-4.1');
|
|
});
|
|
|
|
it('Gemini via gateway hits Zen OpenAI endpoint', async () => {
|
|
const { mockFetch, captured } = createCapturingFetch('openai');
|
|
|
|
const registry = createProviderRegistry({
|
|
opencode: createOpenCodeGateway('oc-key', mockFetch),
|
|
});
|
|
|
|
await generateText({
|
|
model: registry.languageModel('opencode:gemini-2.5-pro'),
|
|
prompt: 'Hello',
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(captured[0].url).toBe(
|
|
'https://opencode.ai/zen/v1/chat/completions',
|
|
);
|
|
expect(captured[0].body.model).toBe('gemini-2.5-pro');
|
|
});
|
|
|
|
it('Mistral via direct provider (not gateway)', async () => {
|
|
const { mockFetch: anthropicFetch } = createCapturingFetch('anthropic');
|
|
const { mockFetch: mistralFetch, captured: mistralCaptured } =
|
|
createCapturingFetch('openai'); // Mistral uses OpenAI-compat format
|
|
|
|
const registry = createProviderRegistry({
|
|
opencode: createOpenCodeGateway('oc-key', anthropicFetch),
|
|
mistral: createMistral({
|
|
apiKey: 'mi-key',
|
|
fetch: mistralFetch,
|
|
}),
|
|
});
|
|
|
|
await generateText({
|
|
model: registry.languageModel('mistral:mistral-large-latest'),
|
|
prompt: 'Hello',
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(mistralCaptured[0].url).toBe(
|
|
'https://api.mistral.ai/v1/chat/completions',
|
|
);
|
|
expect(mistralCaptured[0].body.model).toBe('mistral-large-latest');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 9. No static model map: the routing rule
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AI SDK Phase 0 — Model Routing Without Static Maps', () => {
|
|
it('the only routing rule is: claude* → Anthropic API, else → OpenAI API', () => {
|
|
// This test documents the design decision. There is no model registry or
|
|
// regex map. The gateway provider uses a single if/else to pick the SDK.
|
|
// This is correct because only Anthropic has a separate Messages API;
|
|
// every other model family (OpenAI, Google, Cohere, etc.) uses
|
|
// OpenAI-compatible Chat Completions endpoints.
|
|
|
|
const anthropicModels = [
|
|
'claude-sonnet-4-5',
|
|
'claude-opus-4-1',
|
|
'claude-haiku-4-5',
|
|
'claude-3-7-sonnet-20250219',
|
|
];
|
|
|
|
const openaiCompatModels = [
|
|
'gpt-4.1',
|
|
'gpt-4o',
|
|
'o3-mini',
|
|
'o4-mini',
|
|
'gemini-2.5-pro',
|
|
'gemini-2.5-flash',
|
|
'command-r-plus',
|
|
'some-future-model-xyz',
|
|
];
|
|
|
|
function routeModel(modelId: string): 'anthropic' | 'openai-compat' {
|
|
return modelId.startsWith('claude') ? 'anthropic' : 'openai-compat';
|
|
}
|
|
|
|
for (const m of anthropicModels) {
|
|
expect(routeModel(m)).toBe('anthropic');
|
|
}
|
|
for (const m of openaiCompatModels) {
|
|
expect(routeModel(m)).toBe('openai-compat');
|
|
}
|
|
});
|
|
});
|