feat: rewrite llm config and model patch

This commit is contained in:
Simon
2026-07-02 21:31:44 +08:00
parent 83fee37fb4
commit 8ad719744a
10 changed files with 144 additions and 268 deletions
+1
View File
@@ -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',
+14 -4
View File
@@ -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<LLMConfig>
config: ResolvedLLMConfig
private fetch: typeof globalThis.fetch
constructor(config: Required<LLMConfig>) {
constructor(config: ResolvedLLMConfig) {
this.config = config
this.fetch = config.customFetch
}
@@ -39,12 +46,15 @@ export class OpenAIClient implements LLMClient {
const requestBody: Record<string, unknown> = {
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<string, unknown> | undefined
-3
View File
@@ -1,3 +0,0 @@
// Internal constants
export const LLM_MAX_RETRIES = 2
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
+41 -24
View File
@@ -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<LLMConfig> {
// 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<LLMConfig>
config: ResolvedLLMConfig
client: LLMClient
constructor(config: LLMConfig) {
@@ -90,3 +79,31 @@ async function withRetry<T>(
}
}
}
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
}
}
+7
View File
@@ -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<Omit<LLMConfig, 'temperature'>> & { temperature?: number }
+10 -157
View File
@@ -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)
})
})
+69 -69
View File
@@ -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<string, any>) {
const model: string = body.model || ''
@@ -34,13 +43,30 @@ export function modelPatch(body: Record<string, any>) {
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<string, any>) {
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<string, any>) {
* 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 '/'
-2
View File
@@ -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)
}
@@ -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',
@@ -124,8 +124,8 @@ export default function Models() {
</li>
<li>
{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'}
</li>
<li>
{isZh