feat: customize model provider requests

Model connections can now save bounded JSON request headers and top-level body fields. Direct requests and Continue support both; local OpenCode and DeepSeek Harness use only native header support, while the managed remote OpenCode gateway applies both.

Runtime, protocol, and authentication fields keep precedence. Custom values are ordinary connection settings, not credential storage. This migrates Runtime settings from v20 to v21 and updates the DeepSeek control and managed Runtime ACP capabilities.

Release note: 模型连接详情新增自定义请求头和请求体;直连与 Continue 支持两者,本地 OpenCode 和 DeepSeek Harness 仅应用原生支持的请求头,受管远程 OpenCode 支持两者,界面会明确提示生效范围。
This commit is contained in:
mesalogo
2026-09-03 22:34:01 +08:00
parent 3765a937bd
commit 4e60a4ca61
53 changed files with 2009 additions and 365 deletions
@@ -122,6 +122,52 @@ describe('AgentModelGateway', () => {
ledger.close()
})
it('merges trusted profile request customization without overriding Runtime fields', async () => {
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
const headers = new Headers(init?.headers)
expect(headers.get('x-tenant-id')).toBe('agent-tenant')
expect(headers.get('authorization')).toBe(`Bearer ${secret}`)
expect(JSON.parse(String(init?.body))).toEqual({
max_output_tokens: 4_096,
metadata: { source: 'goodbuddy' },
model: 'model-1',
temperature: 0.8
})
return new Response('{}')
})
const { gateway, ledger } = setup(fetcher)
const customizedProfile: AgentPromptModelProfile = {
...profile,
requestHeaders: {
'x-tenant-id': 'agent-tenant'
},
requestBody: {
temperature: 0.2,
metadata: { source: 'goodbuddy' }
}
}
const result = await gateway.dispatch(
{
...context,
profile: customizedProfile
},
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'model-1',
temperature: 0.8
})
).toString('base64')
},
new AbortController().signal
)
await result.acknowledgeDelivery()
expect(fetcher).toHaveBeenCalledOnce()
ledger.close()
})
it('does not impose prompt-wide model-call or output-token quotas', async () => {
const fetcher = vi.fn<typeof fetch>(async () =>
new Response(
+44 -30
View File
@@ -1,6 +1,9 @@
import { createHash } from 'node:crypto'
import type { DatabaseSync } from 'node:sqlite'
import type { AgentPromptModelProfile } from '../shared/model-bridge-contracts'
import {
modelBridgePathMatchesProtocol,
type AgentPromptModelProfile
} from '../shared/model-bridge-contracts'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
remoteModelGatewayRequestSchema,
@@ -12,6 +15,12 @@ import {
assertTextModelRequestPolicy,
ModelRequestPolicyError
} from '../shared/model-request-policy'
import { canonicalJson } from '../shared/agent-protocol/canonical'
import {
canonicalModelRequestHeaders,
mergeModelRequestBody,
mergeModelRequestHeaders
} from '../shared/model-request-customization'
import {
BoundedResponseTruncatedError,
BoundedResponseTooLargeError,
@@ -480,7 +489,12 @@ function prepareProviderRequest(
body: Uint8Array
canonicalRequest: string
} {
if (!pathMatches(profile.protocol, request.path)) {
if (
!modelBridgePathMatchesProtocol(
profile.protocol,
request.path
)
) {
throw new AgentModelGatewayError(
'policy',
'Provider path does not match the prompt profile'
@@ -498,6 +512,10 @@ function prepareProviderRequest(
'Provider request body is not UTF-8 JSON'
)
}
const normalized = mergeModelRequestBody(
profile.requestBody ?? {},
json as Record<string, unknown>
)
try {
assertTextModelRequestPolicy(
{
@@ -505,7 +523,7 @@ function prepareProviderRequest(
model: profile.model,
supportsImageInput: profile.capabilities.imageInput
},
json
normalized
)
} catch (error) {
if (!(error instanceof ModelRequestPolicyError)) {
@@ -516,7 +534,6 @@ function prepareProviderRequest(
'Provider request does not match the prompt profile'
)
}
const normalized = { ...(json as Record<string, unknown>) }
const outputField =
profile.protocol === 'openai-responses'
? 'max_output_tokens'
@@ -538,22 +555,37 @@ function prepareProviderRequest(
requestedOutput === undefined
? maximumOutputTokens
: Math.min(requestedOutput as number, maximumOutputTokens)
const body = Buffer.from(JSON.stringify(normalized), 'utf8')
const body = Buffer.from(canonicalJson(normalized), 'utf8')
if (
body.byteLength >
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
) {
throw new AgentModelGatewayError(
'policy',
'Customized provider request exceeds the configured byte limit'
)
}
const url = providerUrl(profile)
const headers = { ...request.headers }
const headers = mergeModelRequestHeaders(
profile.requestHeaders ?? {},
request.headers
)
if (profile.protocol === 'anthropic-messages') {
headers.set('anthropic-version', '2023-06-01')
}
const canonicalHeaders = canonicalModelRequestHeaders(headers)
if (profile.protocol === 'anthropic-messages') {
headers['anthropic-version'] = '2023-06-01'
if (profile.authentication === 'api-key') {
headers['x-api-key'] = profile.apiKey!
headers.set('x-api-key', profile.apiKey!)
}
} else if (profile.authentication === 'api-key') {
headers.authorization = `Bearer ${profile.apiKey!}`
headers.set('authorization', `Bearer ${profile.apiKey!}`)
}
return {
url,
headers,
headers: Object.fromEntries(headers.entries()),
body,
canonicalRequest: JSON.stringify({
canonicalRequest: canonicalJson({
profileId: profile.profileId,
provider: profile.provider,
protocol: profile.protocol,
@@ -561,11 +593,7 @@ function prepareProviderRequest(
url: url.toString(),
request: {
path: request.path,
headers: Object.fromEntries(
Object.entries(request.headers).sort(([left], [right]) =>
left.localeCompare(right)
)
),
headers: canonicalHeaders,
bodyBase64: body.toString('base64')
}
})
@@ -586,20 +614,6 @@ function providerUrl(profile: AgentPromptModelProfile): URL {
return url
}
function pathMatches(
protocol: AgentPromptModelProfile['protocol'],
path: string
): boolean {
if (protocol === 'anthropic-messages') {
return path === '/v1/messages'
}
if (protocol === 'openai-chat-completions') {
return path === '/chat/completions' || path === '/v1/chat/completions'
}
return path === '/responses' || path === '/v1/responses'
}
function sha256(value: string | Uint8Array): string {
return `sha256:${createHash('sha256').update(value).digest('hex')}`
}
+6 -2
View File
@@ -497,7 +497,11 @@ describe('AgentProtocolServer connection bounds', () => {
'runtime/completePrompt',
'runtime/getAcpCursors',
'runtime/escalateCancellation',
'runtime/reconcilePrompt'
'runtime/reconcilePrompt',
'runtime/startPrompt',
'runtime/attachPrompt',
'runtime/pagePromptTranscript',
'runtime/ackPromptTranscript'
].map((method) => [method, () => null])
)
const harness = createHarness({
@@ -525,7 +529,7 @@ describe('AgentProtocolServer connection bounds', () => {
expect(daemonCapabilitiesSchema.parse(result)).toMatchObject({
capabilities: [
{ name: 'agent/control', version: 1, critical: true },
{ name: 'runtime/acp', version: 3, critical: true },
{ name: 'runtime/acp', version: 5, critical: true },
{
name: 'runtime/model-bridge',
version: 1,
+11 -4
View File
@@ -14,6 +14,10 @@ import {
jsonRpcNotificationSchema,
jsonRpcRequestSchema,
positiveAgentSequenceSchema,
RUNTIME_ACP_CAPABILITY_NAME,
RUNTIME_ACP_CAPABILITY_VERSION,
RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME,
RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION,
type DaemonCapabilities,
type AgentProtocolFailureCategory,
type FrameAcknowledgment,
@@ -239,15 +243,18 @@ export class AgentProtocolServer {
...(runtimes.length > 0
? [
{
name: 'runtime/acp',
version: autonomousPromptsImplemented ? 4 : 3,
name: RUNTIME_ACP_CAPABILITY_NAME,
version: autonomousPromptsImplemented
? RUNTIME_ACP_CAPABILITY_VERSION
: 3,
critical: true
},
...(modelBridgeImplemented
? [
{
name: 'runtime/model-bridge',
version: 1,
name: RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME,
version:
RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION,
critical: true
}
]