diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 4e12741..231f639 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -53,6 +53,7 @@ "Ollama", "onwarn", "opensource", + "openrouter", "pageagent", "pageagentcore", "pageagentext", diff --git a/eslint.config.js b/eslint.config.js index 10658c3..42fc5da 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -53,6 +53,7 @@ export default defineConfig([ '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off', '@typescript-eslint/no-unnecessary-type-parameters': 'off', '@typescript-eslint/require-await': 'off', + '@typescript-eslint/no-deprecated': 'off', '@eslint-react/dom-no-missing-button-type': 'off', '@eslint-react/no-nested-component-definitions': 'off', '@eslint-react/no-array-index-key': 'off', diff --git a/packages/llms/src/OpenAIClient.ts b/packages/llms/src/OpenAIClient.ts index c3b45f9..ba97466 100644 --- a/packages/llms/src/OpenAIClient.ts +++ b/packages/llms/src/OpenAIClient.ts @@ -4,17 +4,24 @@ import * as z from 'zod/v4' import { InvokeError, InvokeErrorTypes } from './errors' -import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types' +import type { + InvokeOptions, + InvokeResult, + LLMClient, + Message, + ResolvedLLMConfig, + Tool, +} from './types' import { modelPatch, zodToOpenAITool } from './utils' /** * Client for OpenAI compatible APIs */ export class OpenAIClient implements LLMClient { - config: Required + config: ResolvedLLMConfig private fetch: typeof globalThis.fetch - constructor(config: Required) { + constructor(config: ResolvedLLMConfig) { this.config = config this.fetch = config.customFetch } @@ -39,14 +46,18 @@ export class OpenAIClient implements LLMClient { const requestBody: Record = { model: this.config.model, - temperature: this.config.temperature, messages, tools: openaiTools, parallel_tool_calls: false, tool_choice: toolChoice, } + // Only sent if the caller explicitly set it. Most new models throw if this is set. + if (this.config.temperature !== undefined) { + requestBody.temperature = this.config.temperature + } + + modelPatch(requestBody, this.config.baseURL) - modelPatch(requestBody) let transformedBody: Record | undefined try { transformedBody = this.config.transformRequestBody(requestBody) diff --git a/packages/llms/src/constants.ts b/packages/llms/src/constants.ts deleted file mode 100644 index 26b9629..0000000 --- a/packages/llms/src/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Internal constants -export const LLM_MAX_RETRIES = 2 -export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery diff --git a/packages/llms/src/index.ts b/packages/llms/src/index.ts index be5e7e7..5c2e0ec 100644 --- a/packages/llms/src/index.ts +++ b/packages/llms/src/index.ts @@ -1,34 +1,23 @@ import { OpenAIClient } from './OpenAIClient' -import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants' import { InvokeError, InvokeErrorTypes } from './errors' -import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types' +import type { + InvokeOptions, + InvokeResult, + LLMClient, + LLMConfig, + Message, + ResolvedLLMConfig, + Tool, +} from './types' export { InvokeError, InvokeErrorTypes } export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } -export function parseLLMConfig(config: LLMConfig): Required { - // Runtime validation as defensive programming (types already guarantee these) - if (!config.baseURL || !config.model) { - throw new Error( - '[PageAgent] LLM configuration required. Please provide: baseURL, model. ' + - 'See: https://alibaba.github.io/page-agent/docs/features/models' - ) - } - - return { - baseURL: config.baseURL, - model: config.model, - apiKey: config.apiKey || '', - temperature: config.temperature ?? DEFAULT_TEMPERATURE, - maxRetries: config.maxRetries ?? LLM_MAX_RETRIES, - transformRequestBody: config.transformRequestBody ?? ((requestBody) => requestBody), - disableNamedToolChoice: config.disableNamedToolChoice ?? false, - customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound - } -} - +/** + * LLM module + */ export class LLM extends EventTarget { - config: Required + config: ResolvedLLMConfig client: LLMClient constructor(config: LLMConfig) { @@ -90,3 +79,31 @@ async function withRetry( } } } + +export function parseLLMConfig(config: LLMConfig): ResolvedLLMConfig { + // Runtime validation as defensive programming (types already guarantee these) + if (!config.baseURL || !config.model) { + throw new Error( + '[PageAgent] LLM configuration required. Please provide: baseURL, model. ' + + 'See: https://alibaba.github.io/page-agent/docs/features/models' + ) + } + + if (config.temperature !== undefined) { + console.warn( + '[PageAgent] LLMConfig.temperature is deprecated and will be removed in a future version. ' + + 'Use transformRequestBody to set it only for models you have verified accept it.' + ) + } + + return { + baseURL: config.baseURL, + model: config.model, + apiKey: config.apiKey || '', + temperature: config.temperature, + maxRetries: config.maxRetries ?? 2, + transformRequestBody: config.transformRequestBody ?? ((requestBody) => requestBody), + disableNamedToolChoice: config.disableNamedToolChoice ?? false, + customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound + } +} diff --git a/packages/llms/src/live-models.test.ts b/packages/llms/src/live-models.test.ts new file mode 100644 index 0000000..39216fb --- /dev/null +++ b/packages/llms/src/live-models.test.ts @@ -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 = { + 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 `/`, 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 = { + 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 + * `/` 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 = { + '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 = { + '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 + ) + } +}) diff --git a/packages/llms/src/types.ts b/packages/llms/src/types.ts index f548da8..340496f 100644 --- a/packages/llms/src/types.ts +++ b/packages/llms/src/types.ts @@ -92,7 +92,12 @@ export interface LLMConfig { model: string apiKey?: string + /** + * @deprecated No longer a standard parameter; many models reject it outright. + * Use `transformRequestBody` to set it only for models you've verified. + */ temperature?: number + maxRetries?: number /** @@ -118,3 +123,5 @@ export interface LLMConfig { */ customFetch?: typeof globalThis.fetch } + +export type ResolvedLLMConfig = Required> & { temperature?: number } diff --git a/packages/llms/src/utils.test.ts b/packages/llms/src/utils.test.ts index 986f426..7e02697 100644 --- a/packages/llms/src/utils.test.ts +++ b/packages/llms/src/utils.test.ts @@ -1,162 +1,15 @@ import { describe, expect, it } from 'vitest' -import { modelPatch } from './utils' +import { modelPatch, normalizeModelName } from './utils' -/** - * Baseline request body used as starting point for each provider test. - * Mirrors what OpenAIClient builds before calling modelPatch. - */ -function baseBody(model: string) { - return { - model, - temperature: 0.7, - messages: [], - tools: [], - parallel_tool_calls: false, - tool_choice: 'required' as unknown, - } -} - -describe('modelPatch', () => { - it('returns body unchanged when model is missing', () => { - const body = { temperature: 0.7 } - expect(modelPatch(body)).toBe(body) - expect(body).toEqual({ temperature: 0.7 }) - }) - - it('qwen: bumps temperature and disables thinking', () => { - const body = baseBody('qwen-max') - modelPatch(body) - expect(body.temperature).toBe(1.0) - expect(body).toMatchObject({ enable_thinking: false }) - }) - - it('qwen: keeps higher caller-provided temperature', () => { - const body = baseBody('qwen-max') - body.temperature = 1.5 - modelPatch(body) - expect(body.temperature).toBe(1.5) - }) - - it('claude: disables thinking and converts tool_choice "required" -> { type: "any" }', () => { - const body = baseBody('claude-3-5-sonnet') - modelPatch(body) - expect(body).toMatchObject({ - thinking: { type: 'disabled' }, - tool_choice: { type: 'any' }, - }) - }) - - it('claude: converts named tool_choice to { type: "tool", name }', () => { - const body = baseBody('claude-3-5-sonnet') - body.tool_choice = { type: 'function', function: { name: 'doStuff' } } - modelPatch(body) - expect(body.tool_choice).toEqual({ type: 'tool', name: 'doStuff' }) - }) - - it('claude-opus-4-7: drops temperature', () => { - const body = baseBody('claude-opus-4-7') - modelPatch(body) - expect(body).not.toHaveProperty('temperature') - }) - - it('claude-opus-47 (alt id form): drops temperature', () => { - // Provider sometimes ships ids with the dot stripped; modelPatch normalizes. - const body = baseBody('claude-opus-47-20251029') - modelPatch(body) - expect(body).not.toHaveProperty('temperature') - }) - - it('claude-opus-4-8: drops temperature', () => { - const body = baseBody('claude-opus-4-8') - modelPatch(body) - expect(body).not.toHaveProperty('temperature') - }) - - it('claude-opus-48 (alt id form): drops temperature', () => { - const body = baseBody('claude-opus-48-20251210') - modelPatch(body) - expect(body).not.toHaveProperty('temperature') - }) - - it('grok: removes tool_choice and disables reasoning/thinking', () => { - const body = baseBody('grok-4') - modelPatch(body) - expect(body).not.toHaveProperty('tool_choice') - expect(body).toMatchObject({ - thinking: { type: 'disabled', effort: 'minimal' }, - reasoning: { enabled: false, effort: 'low' }, - }) - }) - - it('gpt-5: sets verbosity=low and reasoning_effort=low', () => { - const body = baseBody('gpt-5') - modelPatch(body) - expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'low' }) - }) - - it('gpt-5-mini: low effort, temperature=1', () => { - const body = baseBody('gpt-5-mini') - modelPatch(body) - expect(body).toMatchObject({ - verbosity: 'low', - reasoning_effort: 'low', - temperature: 1, - }) - }) - - it('gpt-5.1 (gpt-51): disables reasoning', () => { - const body = baseBody('gpt-5.1') - modelPatch(body) - expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'none' }) - }) - - it('gpt-5.4 (gpt-54): drops reasoning_effort', () => { - const body = baseBody('gpt-5.4') - modelPatch(body) - expect(body).toMatchObject({ verbosity: 'low' }) - expect(body).not.toHaveProperty('reasoning_effort') - }) - - it('gpt-5.5 (gpt-55): drops reasoning_effort and temperature', () => { - const body = baseBody('gpt-5.5') - modelPatch(body) - expect(body).toMatchObject({ verbosity: 'low' }) - expect(body).not.toHaveProperty('reasoning_effort') - expect(body).not.toHaveProperty('temperature') - }) - - it('gemini: sets reasoning_effort=minimal', () => { - const body = baseBody('gemini-2.5-pro') - modelPatch(body) - expect(body).toMatchObject({ reasoning_effort: 'minimal' }) - }) - - it('deepseek: removes tool_choice', () => { - const body = baseBody('deepseek-chat') - modelPatch(body) - expect(body).not.toHaveProperty('tool_choice') - }) - - it('minimax: clamps temperature into (0, 1] and removes parallel_tool_calls', () => { - const body = baseBody('minimax-m2') - body.temperature = 0 - modelPatch(body) - expect(body.temperature).toBeGreaterThan(0) - expect(body.temperature).toBeLessThanOrEqual(1) - expect(body).not.toHaveProperty('parallel_tool_calls') - }) - - it('minimax: caps temperature at 1', () => { - const body = baseBody('minimax-m2') - body.temperature = 2 - modelPatch(body) - expect(body.temperature).toBe(1) - }) - - it('normalizes provider-prefixed model id (openai/gpt-5)', () => { - const body = baseBody('openai/gpt-5') - modelPatch(body) - expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'low' }) +describe('normalizeModelName', () => { + it.each([ + ['gpt-5.2', 'gpt-52'], + ['gpt_5_2', 'gpt52'], + ['GPT-52-2026-01-01', 'gpt-52-2026-01-01'], + ['openai/gpt-5.2-chat', 'gpt-52-chat'], + ['claude_sonnet4_5', 'claudesonnet45'], + ])('%s -> %s', (input, expected) => { + expect(normalizeModelName(input)).toBe(expected) }) }) diff --git a/packages/llms/src/utils.ts b/packages/llms/src/utils.ts index 87dd80d..0d35376 100644 --- a/packages/llms/src/utils.ts +++ b/packages/llms/src/utils.ts @@ -24,106 +24,152 @@ export function zodToOpenAITool(name: string, tool: Tool) { } /** - * Patch model specific parameters - * @note in-place modification + * Patch model specific parameters. Only patches known models. + * + * @purpose + * - Reconcile the differences in the parameter schema each model accepts. + * - Disable thinking/reasoning, or lower it to the minimum where a full disable is impossible. + * - Minimize returned tokens. + * - Raise temperature for known smaller models to improve auto-recovery odds. + * @note Honor temperature if explicitly set by the user + * + * @todo Need vendor-specific patches. + * Local and 3rd-party hosted models may have different schema. */ -export function modelPatch(body: Record) { +export function modelPatch(body: Record, baseURL?: string) { const model: string = body.model || '' if (!model) return body + const provider = getProvider(baseURL) + const modelName = normalizeModelName(model) if (modelName.startsWith('qwen')) { - debug('Applying Qwen patch: use higher temperature for auto fixing') - body.temperature = Math.max(body.temperature || 0, 1.0) + debug('Patch Qwen: disable thinking') body.enable_thinking = false + if (body.temperature === undefined && !/max|plus/.test(modelName)) { + debug('Patch Qwen: raise temperature to 1.0') + body.temperature = 1.0 + } } - if (modelName.startsWith('claude')) { - debug('Applying Claude patch: disable thinking') + if (modelName.startsWith('deepseek')) { + debug('Patch DeepSeek: disable thinking, remove tool_choice') body.thinking = { type: 'disabled' } - - // Convert tool_choice to Claude format - if (body.tool_choice === 'required') { - // 'required' -> { type: 'any' } (must call some tool) - debug('Applying Claude patch: convert tool_choice "required" to { type: "any" }') - body.tool_choice = { type: 'any' } - } else if (body.tool_choice?.function?.name) { - // { type: 'function', function: { name: '...' } } -> { type: 'tool', name: '...' } - debug('Applying Claude patch: convert tool_choice format') - body.tool_choice = { type: 'tool', name: body.tool_choice.function.name } - } - - // TODO: Claude naming pattern has changed - // needs proper handling - if ( - modelName.startsWith('claude-opus-4-7') || - modelName.startsWith('claude-opus-47') || - modelName.startsWith('claude-opus-4-8') || - modelName.startsWith('claude-opus-48') - ) { - debug('Applying Claude-4.7/4.8 patch: remove temperature') - delete body.temperature - } - } - - if (modelName.startsWith('grok')) { - debug('Applying Grok patch: removing tool_choice') delete body.tool_choice - debug('Applying Grok patch: disable reasoning and thinking') - body.thinking = { type: 'disabled', effort: 'minimal' } - body.reasoning = { enabled: false, effort: 'low' } } if (modelName.startsWith('gpt')) { - debug('Applying GPT patch: set verbosity to low') - body.verbosity = 'low' + if (modelName.startsWith('gpt-5')) { + body.verbosity = 'low' + + // gpt-5 gpt-5-mini gpt-5-nano only supports "minimal"; + // 5.1+ supports "none". + body.reasoning_effort = /^gpt-5(-|$)/.test(modelName) ? 'minimal' : 'none' + debug(`Patch GPT-5: verbosity=low, reasoning_effort=${body.reasoning_effort}`) + } - // *-chat-latest models don't support reasoning_effort — skip patches that set it if (modelName.includes('chat-latest')) { debug('Omitting reasoning_effort and temperature for chat-latest') delete body.reasoning_effort delete body.temperature - } else if (modelName.startsWith('gpt-52')) { - debug('Applying GPT-52 patch: disable reasoning') - body.reasoning_effort = 'none' - } else if (modelName.startsWith('gpt-51')) { - debug('Applying GPT-51 patch: disable reasoning') - body.reasoning_effort = 'none' - } else if (modelName.startsWith('gpt-54')) { - debug('Applying GPT-5.4 patch: remove reasoning_effort') - delete body.reasoning_effort - } else if (modelName.startsWith('gpt-55')) { - debug('Applying GPT-5.4 patch: remove reasoning_effort and temperature') - delete body.reasoning_effort - delete body.temperature - } else if (modelName.startsWith('gpt-5-mini')) { - debug('Applying GPT-5-mini patch: set reasoning effort to low, temperature to 1') - body.reasoning_effort = 'low' - body.temperature = 1 - } else if (modelName.startsWith('gpt-5')) { - debug('Applying GPT-5 patch: set reasoning effort to low') + } + } + + if (modelName.startsWith('claude')) { + if (/opus|sonnet|haiku/.test(modelName)) { + debug('Patch Claude: disable thinking') + body.thinking = { type: 'disabled' } + + if (provider !== 'openrouter') { + // Convert tool_choice to Claude format + if (body.tool_choice === 'required') { + // 'required' -> { type: 'any' } (must call some tool) + debug('Applying Claude patch: convert tool_choice "required" to { type: "any" }') + body.tool_choice = { type: 'any' } + } else if (body.tool_choice?.function?.name) { + // { type: 'function', function: { name: '...' } } -> { type: 'tool', name: '...' } + debug('Applying Claude patch: convert tool_choice format') + body.tool_choice = { type: 'tool', name: body.tool_choice.function.name } + } + } + } else { + debug('Patch Claude: reasoning_effort=low') body.reasoning_effort = 'low' + + // Fable and mythos can not disable adaptive thinking. + // Claude does not support tool_choice with extended thinking. + // These 2 concepts are blurred. Basically no tool_choice with thinking. + delete body.tool_choice } } if (modelName.startsWith('gemini')) { - debug('Applying Gemini patch: set reasoning effort to minimal') - body.reasoning_effort = 'minimal' + debug('Patch Gemini: reasoning_effort=low') + body.reasoning_effort = 'low' + if (/^gemini-25(?!.*pro)/.test(modelName)) { + debug('Patch Gemini 2.5 non-Pro: reasoning_effort=none') + body.reasoning_effort = 'none' + } else if ( + modelName.startsWith('gemini-35-flash') || + modelName.startsWith('gemini-31-flash-lite') || + modelName.startsWith('gemini-3-flash') + ) { + debug('Patch Gemini 3.x Flash/Lite: reasoning_effort=minimal') + body.reasoning_effort = 'minimal' + } } - if (modelName.startsWith('deepseek')) { - debug('Applying DeepSeek patch: remove tool_choice') - delete body.tool_choice + if (modelName.startsWith('glm')) { + debug('Patch GLM: disable thinking') + body.thinking = { type: 'disabled' } + } + + if (modelName.startsWith('grok')) { + if (/^grok-4-?3/.test(modelName)) { + debug('Patch Grok 4.3: reasoning_effort=none') + body.reasoning_effort = 'none' + } else if (modelName.startsWith('grok-3-mini') || modelName.startsWith('grok-code-fast')) { + debug('Patch Grok mini/code: reasoning_effort=low') + body.reasoning_effort = 'low' + } + } + + if (modelName.startsWith('kimi')) { + if (!modelName.includes('code')) { + // kimi-k2.7-code cannot disable thinking + debug('Patch Kimi: disable thinking') + body.thinking = { type: 'disabled' } + } } if (modelName.startsWith('minimax')) { - debug('Applying MiniMax patch: clamp temperature to (0, 1]') - // MiniMax API rejects temperature = 0; clamp to a small positive value - body.temperature = Math.max(body.temperature || 0, 0.01) - if (body.temperature > 1) body.temperature = 1 - // MiniMax does not support parallel_tool_calls + debug('Patch MiniMax: remove parallel_tool_calls') delete body.parallel_tool_calls + + if (modelName.includes('m3')) { + // Only M3 can disable thinking + debug('Patch MiniMax: disable thinking') + body.thinking = { type: 'disabled' } + } + } + + // provider patches + + if (provider === 'openrouter') { + // openrouter use reasoning object instead of reasoning_effort + + const reasoningEffort = body.reasoning_effort + const reasoningDisabled = + body.thinking?.type === 'disabled' || + body.enable_thinking === false || + reasoningEffort === 'none' + + if (reasoningDisabled) { + body.reasoning = { enabled: false } + } else if (reasoningEffort) { + body.reasoning = { enabled: true, effort: reasoningEffort } + } } return body @@ -144,7 +190,7 @@ export function modelPatch(body: Record) { * They should be treated as the same model. * Normalize them to `gpt-52` */ -function normalizeModelName(modelName: string): string { +export function normalizeModelName(modelName: string): string { let normalizedName = modelName.toLowerCase() // remove prefix before '/' @@ -160,3 +206,15 @@ function normalizeModelName(modelName: string): string { return normalizedName } + +export function getProvider(baseURL?: string): 'openrouter' | undefined { + if (!baseURL) return undefined + try { + const url = new URL(baseURL) + const hostname = url.hostname + if (hostname === 'openrouter.ai') return 'openrouter' + return undefined + } catch (e) { + return undefined + } +} diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index 5b76f72..e90326e 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -208,9 +208,7 @@ export async function inputTextElement(element: HTMLElement, text: string) { selection?.removeAllRanges() selection?.addRange(range) - // eslint-disable-next-line @typescript-eslint/no-deprecated doc.execCommand('delete', false) - // eslint-disable-next-line @typescript-eslint/no-deprecated doc.execCommand('insertText', false, text) } diff --git a/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx b/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx index 40f5cf1..d5166f3 100644 --- a/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx +++ b/packages/website/src/pages/docs/advanced/page-agent-core/page.tsx @@ -143,13 +143,6 @@ const result = await agent.execute('Fill in the form with test data')`} required: false, description: 'LLM AK', }, - { - name: 'temperature', - type: 'number', - description: isZh - ? '模型温度参数,控制输出随机性' - : 'Model temperature, controls output randomness', - }, { name: 'maxRetries', type: 'number', diff --git a/packages/website/src/pages/docs/features/models/page.tsx b/packages/website/src/pages/docs/features/models/page.tsx index c330817..afaf4cb 100644 --- a/packages/website/src/pages/docs/features/models/page.tsx +++ b/packages/website/src/pages/docs/features/models/page.tsx @@ -6,9 +6,9 @@ import { Heading } from '@/components/Heading' import { useLanguage } from '@/i18n/context' const BASELINE = new Set([ - 'gpt-5.1', 'gpt-5.4-mini', - 'claude-haiku-4.5', + 'gpt-5.4-nano', + 'claude-haiku-4-5', 'gemini-3.5-flash', 'deepseek-v4-flash', 'qwen3.5-plus', @@ -25,9 +25,8 @@ const MODEL_GROUPS: Record = { 'qwen3.6-flash', 'qwen3.5-plus', 'qwen3.5-flash', - 'qwen3-coder-next', - 'qwen-3-max', - 'qwen-3-plus', + 'qwen3-max', + // 'qwen3-coder-next', // low success rate ], OpenAI: [ 'gpt-5.5', @@ -44,24 +43,29 @@ const MODEL_GROUPS: Record = { 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-3-pro', - 'gemini-3-flash', - 'gemini-2.5', + 'gemini-2.5-pro', + 'gemini-2.5-flash', ], Anthropic: [ - 'claude-opus-4.8', - 'claude-opus-4.7', - 'claude-opus-4.6', - 'claude-opus-4.5', - 'claude-sonnet-4.5', - 'claude-haiku-4.5', - 'claude-sonnet-3.5', + 'claude-sonnet-5', + 'claude-fable-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-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'], - xAI: ['grok-4.1-fast', 'grok-4', 'grok-code-fast'], - MoonshotAI: ['kimi-k2.5'], - 'Z.AI': ['glm-5', 'glm-4.7'], + MiniMax: [ + // 'MiniMax-M3', low success rate + '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'], } const ModelBadge = ({ model, baseline }: { model: string; baseline?: boolean }) => ( @@ -91,7 +95,7 @@ export default function Models() {
- {isZh ? '已测试模型' : 'Tested Models'} + {isZh ? '模型列表' : 'Model List'}
@@ -124,8 +128,8 @@ export default function Models() {
  • {isZh - ? 'ToolCall 能力较弱的模型可能返回错误的格式,常见错误能够自动恢复,建议设置较高的 temperature' - : 'Models with weaker ToolCall capabilities may return incorrect formats. Common errors usually auto-recover. Higher temperature recommended'} + ? 'ToolCall 能力较弱的模型可能返回错误的格式,常见错误能够自动恢复' + : 'Models with weaker ToolCall capabilities may return incorrect formats. Common errors usually auto-recover'}
  • {isZh @@ -281,7 +285,7 @@ LLM_MODEL_NAME="qwen3.5-plus"`} code={`const pageAgent = new PageAgent({ baseURL: 'https://your-claude-proxy.example/v1', apiKey: 'your-api-key', - model: 'claude-sonnet-4.5', + model: 'claude-sonnet-5', transformRequestBody: (requestBody) => ({ ...requestBody, cache_control: { type: 'ephemeral' },