feat(llms): add transformRequestBody hook and improve prompt assembly (#480)

* feat(llms): add transformRequestBody hook and refine prompt handling

* docs(website): document transformRequestBody usage

* refactor(extension): keep function-valued config handling consistent in useAgent

* feat: simplify `transformRequestBody`

---------

Co-authored-by: Simon <10131203+gaomeng1900@users.noreply.github.com>
This commit is contained in:
zzy-life
2026-04-27 19:46:46 +08:00
committed by GitHub
parent 349609614b
commit a7cc935453
6 changed files with 69 additions and 2 deletions

View File

@@ -146,6 +146,7 @@ function CopyButton({ text, label }: { text: string; label: string }) {
function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null { function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null {
const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages
if (!messages) return null if (!messages) return null
if (!Array.isArray(messages)) return null
const msg = const msg =
role === 'system' role === 'system'
? messages.find((m) => m.role === role) ? messages.find((m) => m.role === role)

View File

@@ -45,6 +45,17 @@ export class OpenAIClient implements LLMClient {
} }
modelPatch(requestBody) modelPatch(requestBody)
let transformedBody: Record<string, unknown> | undefined
try {
transformedBody = this.config.transformRequestBody(requestBody)
} catch (error) {
throw new InvokeError(
InvokeErrorType.CONFIG_ERROR,
`transformRequestBody failed: ${(error as Error).message}`,
error
)
}
const finalRequestBody = transformedBody ?? requestBody
// 2. Call API // 2. Call API
let response: Response let response: Response
@@ -55,7 +66,7 @@ export class OpenAIClient implements LLMClient {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(this.config.apiKey && { Authorization: `Bearer ${this.config.apiKey}` }), ...(this.config.apiKey && { Authorization: `Bearer ${this.config.apiKey}` }),
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(finalRequestBody),
signal: abortSignal, signal: abortSignal,
}) })
} catch (error: unknown) { } catch (error: unknown) {
@@ -225,7 +236,7 @@ export class OpenAIClient implements LLMClient {
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens, reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
}, },
rawResponse: data, rawResponse: data,
rawRequest: requestBody, rawRequest: finalRequestBody,
} }
} }
} }

View File

@@ -14,6 +14,7 @@ export const InvokeErrorType = {
UNKNOWN: 'unknown', UNKNOWN: 'unknown',
// Non-retryable // Non-retryable
CONFIG_ERROR: 'config_error', // Invalid local configuration or hook
AUTH_ERROR: 'auth_error', // Authentication failed AUTH_ERROR: 'auth_error', // Authentication failed
CONTEXT_LENGTH: 'context_length', // Prompt too long CONTEXT_LENGTH: 'context_length', // Prompt too long
CONTENT_FILTER: 'content_filter', // Content filtered CONTENT_FILTER: 'content_filter', // Content filtered

View File

@@ -21,6 +21,7 @@ export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
apiKey: config.apiKey || '', apiKey: config.apiKey || '',
temperature: config.temperature ?? DEFAULT_TEMPERATURE, temperature: config.temperature ?? DEFAULT_TEMPERATURE,
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES, maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
transformRequestBody: config.transformRequestBody ?? ((requestBody) => requestBody),
disableNamedToolChoice: config.disableNamedToolChoice ?? false, disableNamedToolChoice: config.disableNamedToolChoice ?? false,
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
} }

View File

@@ -95,6 +95,16 @@ export interface LLMConfig {
temperature?: number temperature?: number
maxRetries?: number maxRetries?: number
/**
* Transform the final request body before sending it to the provider.
* Use this to implement provider-specific request tweaks such as caching hints or custom flags.
*
* Return a new object, or mutate the input object and return undefined.
*/
transformRequestBody?: (
requestBody: Record<string, unknown>
) => Record<string, unknown> | undefined
/** /**
* remove the tool_choice field from the request. * remove the tool_choice field from the request.
* @note fix "Invalid tool_choice type: 'object'" for some LLMs. * @note fix "Invalid tool_choice type: 'object'" for some LLMs.

View File

@@ -156,6 +156,13 @@ const result = await agent.execute('Fill in the form with test data')`}
defaultValue: '3', defaultValue: '3',
description: isZh ? 'API 调用失败时的最大重试次数' : 'Maximum retries on API failure', description: isZh ? 'API 调用失败时的最大重试次数' : 'Maximum retries on API failure',
}, },
{
name: 'transformRequestBody',
type: '(requestBody) => Record<string, unknown> | undefined',
description: isZh
? '在请求发送前转换最终 request body。可用于处理供应商特定的缓存提示或私有参数。'
: 'Transform the final request body before sending it. Useful for provider-specific cache hints or private request parameters.',
},
{ {
name: 'disableNamedToolChoice', name: 'disableNamedToolChoice',
type: 'boolean', type: 'boolean',
@@ -174,6 +181,42 @@ const result = await agent.execute('Fill in the form with test data')`}
]} ]}
/> />
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-800 dark:text-gray-200">
{isZh ? 'transformRequestBody 示例' : 'transformRequestBody Example'}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{isZh
? '如果某个供应商需要私有字段或缓存提示,可以通过 transformRequestBody 在发送前透传请求体,而不需要把供应商逻辑写进 PageAgent 内部。'
: 'If a provider needs private fields or cache hints, use transformRequestBody to tweak the request body before sending it instead of baking provider-specific logic into PageAgent.'}
</p>
<CodeEditor
language="typescript"
code={`const agent = new PageAgentCore({
pageController: new PageController({ enableMask: true }),
baseURL: 'https://your-provider.example/v1',
apiKey: 'your-api-key',
model: 'qwen3.5-plus',
transformRequestBody: (requestBody) => {
const messages = requestBody.messages
if (!Array.isArray(messages)) return
const systemMessage = messages.find(
(message) => message && typeof message === 'object' && message.role === 'system'
) as { role: string; content?: unknown } | undefined
if (!systemMessage || typeof systemMessage.content !== 'string') return
systemMessage.content = [
{
type: 'text',
text: systemMessage.content,
cache_control: { type: 'ephemeral' },
},
]
},
})`}
/>
{/* Agent Config */} {/* Agent Config */}
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-800 dark:text-gray-200"> <h3 className="text-lg font-semibold mt-6 mb-3 text-gray-800 dark:text-gray-200">
{isZh ? 'Agent 配置' : 'Agent Config'} {isZh ? 'Agent 配置' : 'Agent Config'}