chore(llms): add real-api test for all supported models
This commit is contained in:
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* Live compatibility test — hits real provider APIs.
|
||||||
|
*
|
||||||
|
* Purpose: verify request formatting and `modelPatch` (see `utils.ts`) work
|
||||||
|
* for every model below, across the providers that serve them. Not a
|
||||||
|
* correctness/quality eval — the tool call is trivial and forced via
|
||||||
|
* `toolChoiceName`, so a failure here means the request/response shape is
|
||||||
|
* wrong for that model, not that the model is "dumb".
|
||||||
|
*
|
||||||
|
* Tests `OpenAIClient` directly (not the `LLM` retry wrapper), so a failure
|
||||||
|
* always reflects the very first request/response — no retry can mask it.
|
||||||
|
*
|
||||||
|
* Each provider's tests skip (not fail) when its `TESTING_*_KEY` env var is
|
||||||
|
* absent, so this stays CI-safe. To actually run these, put keys in the
|
||||||
|
* repo-root `.env`:
|
||||||
|
*
|
||||||
|
* TESTING_OPENROUTER_KEY=...
|
||||||
|
* TESTING_DEEPSEEK_KEY=...
|
||||||
|
* TESTING_ALIYUN_KEY=...
|
||||||
|
*/
|
||||||
|
import { config as dotenvConfig } from 'dotenv'
|
||||||
|
import { dirname, resolve } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import * as z from 'zod/v4'
|
||||||
|
|
||||||
|
import { OpenAIClient } from './OpenAIClient'
|
||||||
|
import { parseLLMConfig } from './index'
|
||||||
|
import type { Message, Tool } from './types'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
dotenvConfig({ path: resolve(__dirname, '../../../.env'), quiet: true })
|
||||||
|
|
||||||
|
const TEST_TIMEOUT = 30_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors `packages/website/src/pages/docs/features/models/page.tsx`.
|
||||||
|
* This package cannot depend on the website, so the list is duplicated here.
|
||||||
|
* Keep both lists in sync manually when models are added or renamed.
|
||||||
|
*/
|
||||||
|
const MODEL_GROUPS: Record<string, string[]> = {
|
||||||
|
Qwen: [
|
||||||
|
'qwen3.7-max',
|
||||||
|
'qwen3.7-plus',
|
||||||
|
'qwen3.6-max',
|
||||||
|
'qwen3.6-plus',
|
||||||
|
'qwen3.6-flash',
|
||||||
|
'qwen3.5-plus',
|
||||||
|
'qwen3.5-flash',
|
||||||
|
'qwen3-max',
|
||||||
|
],
|
||||||
|
OpenAI: [
|
||||||
|
'gpt-5.5',
|
||||||
|
'gpt-5.4',
|
||||||
|
'gpt-5.4-mini',
|
||||||
|
'gpt-5.4-nano',
|
||||||
|
'gpt-5.2',
|
||||||
|
'gpt-5.1',
|
||||||
|
'gpt-5',
|
||||||
|
'gpt-5-mini',
|
||||||
|
'gpt-4.1',
|
||||||
|
'gpt-4.1-mini',
|
||||||
|
],
|
||||||
|
DeepSeek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-3.2'],
|
||||||
|
Google: [
|
||||||
|
'gemini-3.5-flash',
|
||||||
|
'gemini-3.1-pro',
|
||||||
|
'gemini-3.1-flash-lite',
|
||||||
|
'gemini-2.5-pro',
|
||||||
|
'gemini-2.5-flash',
|
||||||
|
],
|
||||||
|
Anthropic: [
|
||||||
|
'claude-sonnet-5',
|
||||||
|
'claude-opus-4-8',
|
||||||
|
'claude-opus-4-7',
|
||||||
|
'claude-opus-4-6',
|
||||||
|
'claude-opus-4-5',
|
||||||
|
'claude-sonnet-4-5',
|
||||||
|
'claude-haiku-4-5',
|
||||||
|
],
|
||||||
|
MiniMax: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.5'],
|
||||||
|
xAI: ['grok-4.3', 'grok-build-0.1'],
|
||||||
|
MoonshotAI: ['kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5'],
|
||||||
|
'Z.AI': ['glm-5.2', 'glm-5.1', 'glm-5', 'glm-4.7'],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenRouter lists every model as `<vendor-slug>/<model-id>`, lowercase.
|
||||||
|
* See the commented-out entries in the repo-root `.env` for real examples,
|
||||||
|
* e.g. `x-ai/grok-4.1-fast`, `qwen/qwen3-coder-next`, `deepseek/deepseek-v3.2-exp`.
|
||||||
|
*/
|
||||||
|
const OPENROUTER_VENDOR_SLUG: Record<string, string> = {
|
||||||
|
Qwen: 'qwen',
|
||||||
|
OpenAI: 'openai',
|
||||||
|
DeepSeek: 'deepseek',
|
||||||
|
Google: 'google',
|
||||||
|
Anthropic: 'anthropic',
|
||||||
|
MiniMax: 'minimax',
|
||||||
|
xAI: 'x-ai',
|
||||||
|
MoonshotAI: 'moonshotai',
|
||||||
|
'Z.AI': 'z-ai',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides for models whose OpenRouter id doesn't match the
|
||||||
|
* `<vendor-slug>/<lowercased-name>` heuristic — dated snapshots, "-preview"
|
||||||
|
* suffixes, "v"-prefixed versions, or dots instead of hyphens in the
|
||||||
|
* version number. Verified against `GET https://openrouter.ai/api/v1/models`
|
||||||
|
* on 2026-07-03; re-check when models are added to `MODEL_GROUPS`.
|
||||||
|
*/
|
||||||
|
const OPENROUTER_ID_OVERRIDES: Record<string, string> = {
|
||||||
|
'qwen3.6-max': 'qwen/qwen3.6-max-preview',
|
||||||
|
'qwen3.5-plus': 'qwen/qwen3.5-plus-20260420',
|
||||||
|
'qwen3.5-flash': 'qwen/qwen3.5-flash-02-23',
|
||||||
|
'deepseek-3.2': 'deepseek/deepseek-v3.2',
|
||||||
|
'gemini-3.1-pro': 'google/gemini-3.1-pro-preview',
|
||||||
|
'claude-opus-4-8': 'anthropic/claude-opus-4.8',
|
||||||
|
'claude-opus-4-7': 'anthropic/claude-opus-4.7',
|
||||||
|
'claude-opus-4-6': 'anthropic/claude-opus-4.6',
|
||||||
|
'claude-opus-4-5': 'anthropic/claude-opus-4.5',
|
||||||
|
'claude-sonnet-4-5': 'anthropic/claude-sonnet-4.5',
|
||||||
|
'claude-haiku-4-5': 'anthropic/claude-haiku-4.5',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a model's OpenRouter id from its brand (a `MODEL_GROUPS` key) and
|
||||||
|
* native model name.
|
||||||
|
*/
|
||||||
|
function toOpenRouterModelId(brand: string, model: string): string {
|
||||||
|
if (model in OPENROUTER_ID_OVERRIDES) return OPENROUTER_ID_OVERRIDES[model]
|
||||||
|
const slug = OPENROUTER_VENDOR_SLUG[brand]
|
||||||
|
if (!slug) throw new Error(`No OpenRouter vendor slug mapped for brand "${brand}"`)
|
||||||
|
return `${slug}/${model.toLowerCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDERS = {
|
||||||
|
openrouter: {
|
||||||
|
baseURL: 'https://openrouter.ai/api/v1',
|
||||||
|
apiKey: process.env.TESTING_OPENROUTER_KEY,
|
||||||
|
},
|
||||||
|
aliyun: {
|
||||||
|
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||||
|
apiKey: process.env.TESTING_ALIYUN_KEY,
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
baseURL: 'https://api.deepseek.com',
|
||||||
|
apiKey: process.env.TESTING_DEEPSEEK_KEY,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const ECHO_TOOL: Tool<{ message: string }, string> = {
|
||||||
|
description: 'Echo back the given message in uppercase.',
|
||||||
|
inputSchema: z.object({ message: z.string() }),
|
||||||
|
execute: async ({ message }) => message.toUpperCase(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMPT: Message[] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'Call the "echo" tool with message set to "ping". You must call the tool.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function expectEchoToolCall(baseURL: string, apiKey: string, model: string) {
|
||||||
|
const client = new OpenAIClient(parseLLMConfig({ baseURL, apiKey, model }))
|
||||||
|
const result = await client.invoke(PROMPT, { echo: ECHO_TOOL }, new AbortController().signal, {
|
||||||
|
toolChoiceName: 'echo',
|
||||||
|
})
|
||||||
|
expect(result.toolResult).toBe('PING')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.concurrent('OpenRouter — all listed models', () => {
|
||||||
|
const { baseURL, apiKey } = PROVIDERS.openrouter
|
||||||
|
|
||||||
|
for (const [brand, models] of Object.entries(MODEL_GROUPS)) {
|
||||||
|
for (const model of models) {
|
||||||
|
const id = toOpenRouterModelId(brand, model)
|
||||||
|
it.skipIf(!apiKey)(
|
||||||
|
id,
|
||||||
|
async () => {
|
||||||
|
await expectEchoToolCall(baseURL, apiKey!, id)
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Aliyun native ids that don't match the display name in MODEL_GROUPS.
|
||||||
|
const ALIYUN_ID_OVERRIDES: Record<string, string> = {
|
||||||
|
'qwen3.6-max': 'qwen3.6-max-preview',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.concurrent('Aliyun DashScope — Qwen native', () => {
|
||||||
|
const { baseURL, apiKey } = PROVIDERS.aliyun
|
||||||
|
|
||||||
|
for (const model of MODEL_GROUPS.Qwen) {
|
||||||
|
const id = ALIYUN_ID_OVERRIDES[model] ?? model
|
||||||
|
it.skipIf(!apiKey)(
|
||||||
|
id,
|
||||||
|
async () => {
|
||||||
|
await expectEchoToolCall(baseURL, apiKey!, id)
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.concurrent('DeepSeek — native', () => {
|
||||||
|
const { baseURL, apiKey } = PROVIDERS.deepseek
|
||||||
|
// deepseek-3.2 isn't served on DeepSeek's own API (only via OpenRouter
|
||||||
|
// resellers) — its official API only accepts deepseek-v4-pro/-flash.
|
||||||
|
const nativeModels = MODEL_GROUPS.DeepSeek.filter((model) => model !== 'deepseek-3.2')
|
||||||
|
|
||||||
|
for (const model of nativeModels) {
|
||||||
|
it.skipIf(!apiKey)(
|
||||||
|
model,
|
||||||
|
async () => {
|
||||||
|
await expectEchoToolCall(baseURL, apiKey!, model)
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user