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..4e427f5 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,12 +46,15 @@ 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) let transformedBody: Record | undefined 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/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..dff9796 100644 --- a/packages/llms/src/utils.ts +++ b/packages/llms/src/utils.ts @@ -24,8 +24,17 @@ 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) { const model: string = body.model || '' @@ -34,13 +43,30 @@ export function modelPatch(body: Record) { 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' } + delete body.tool_choice + } + + if (modelName.startsWith('gpt-5')) { + // verbosity trims output tokens across the whole GPT-5 family. + body.verbosity = 'low' + // The GPT-5.0 generation 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}`) + } + + if (modelName.startsWith('claude') && /opus|sonnet|haiku/.test(modelName)) { + debug('Patch Claude: disable thinking') body.thinking = { type: 'disabled' } // Convert tool_choice to Claude format @@ -53,76 +79,50 @@ export function modelPatch(body: Record) { 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' - - // *-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') - body.reasoning_effort = 'low' - } } 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') && !modelName.includes('code')) { + // kimi-k2.7-code cannot disable thinking (errors), hence the code exclusion. + 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 + // Only M3 can disable thinking; M2.x accepts the field as a silent no-op. + // parallel_tool_calls is unsupported. + debug('Patch MiniMax: disable thinking, remove parallel_tool_calls') + body.thinking = { type: 'disabled' } delete body.parallel_tool_calls } @@ -144,7 +144,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 '/' 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..d03ce35 100644 --- a/packages/website/src/pages/docs/features/models/page.tsx +++ b/packages/website/src/pages/docs/features/models/page.tsx @@ -124,8 +124,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