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
@@ -145,6 +145,12 @@ export const attachWelcomeSchema = z
export type AttachPreface = z.infer<typeof attachPrefaceSchema>
export type AttachWelcome = z.infer<typeof attachWelcomeSchema>
export const RUNTIME_ACP_CAPABILITY_NAME = 'runtime/acp'
export const RUNTIME_ACP_CAPABILITY_VERSION = 5
export const RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME =
'runtime/model-bridge'
export const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1
export const agentCapabilitySchema = z
.object({
name: z
+21
View File
@@ -1,4 +1,10 @@
import { z } from 'zod'
import {
modelRequestBodySchema,
modelRequestHeadersSchema,
type ModelRequestBody,
type ModelRequestHeaders
} from './model-request-customization'
import {
maximumModelContextWindowTokens,
minimumModelContextWindowTokens
@@ -597,6 +603,17 @@ export {
maximumModelContextWindowTokens,
minimumModelContextWindowTokens
} from './context-window'
export {
MODEL_REQUEST_CUSTOMIZATION_LIMITS,
modelRequestBodySchema,
modelRequestHeadersSchema,
canonicalModelRequestHeaders,
mergeModelRequestBody,
mergeModelRequestHeaders,
type ModelRequestBody,
type ModelRequestHeaders,
type ModelRequestJsonValue
} from './model-request-customization'
export const defaultAnthropicMaximumOutputTokens = 32_000
export const maximumModelOutputTokens = 10_000_000
@@ -628,6 +645,8 @@ const modelProfileInputSchema = z
.max(maximumModelOutputTokens)
.optional(),
imageGenerationQuality: imageGenerationQualitySchema,
requestHeaders: modelRequestHeadersSchema.optional(),
requestBody: modelRequestBodySchema.optional(),
apiKey: modelApiKeyUpdateSchema
})
.strict()
@@ -1009,6 +1028,8 @@ export type ModelConnectionSettings = {
contextWindowTokens?: number
maximumOutputTokens?: number
imageGenerationQuality: ImageGenerationQuality
requestHeaders?: ModelRequestHeaders
requestBody?: ModelRequestBody
apiKeyConfigured: boolean
credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
}
+13 -2
View File
@@ -10,6 +10,10 @@ import {
remoteModelGatewayRequestSchema,
remoteModelGatewayResponseSchema
} from './remote-model-gateway-contracts'
import {
modelRequestBodySchema,
modelRequestHeadersSchema
} from './model-request-customization'
const textEncoder = new TextEncoder()
const fatalTextDecoder = new TextDecoder('utf-8', { fatal: true })
@@ -118,6 +122,8 @@ export const agentPromptModelProfileSchema = z
minimumBytes: 1,
label: 'Model provider API key'
}).optional(),
requestHeaders: modelRequestHeadersSchema.optional(),
requestBody: modelRequestBodySchema.optional(),
capabilities: z
.object({
imageInput: z.boolean()
@@ -214,7 +220,12 @@ export const modelBridgeRequestMessageSchema =
message: 'Policy model profile digest does not match the message identity'
})
}
if (!pathMatchesProtocol(message.policy.protocol, message.request.path)) {
if (
!modelBridgePathMatchesProtocol(
message.policy.protocol,
message.request.path
)
) {
context.addIssue({
code: 'custom',
path: ['request', 'path'],
@@ -472,7 +483,7 @@ function sameIdentity(
)
}
function pathMatchesProtocol(
export function modelBridgePathMatchesProtocol(
protocol: ModelBridgeModelProtocol,
path: string
): boolean {
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
canonicalModelRequestHeaders,
mergeModelRequestBody,
mergeModelRequestHeaders,
modelRequestBodySchema,
modelRequestHeadersSchema
} from './model-request-customization'
describe('model request customization', () => {
it('accepts bounded custom headers and rejects reserved or ambiguous names', () => {
expect(
modelRequestHeadersSchema.parse({
'x-tenant-id': 'tenant-a',
'x-routing-mode': ''
})
).toEqual({
'x-tenant-id': 'tenant-a',
'x-routing-mode': ''
})
expect(() =>
modelRequestHeadersSchema.parse({
Authorization: 'Bearer replacement'
})
).toThrow()
expect(() =>
modelRequestHeadersSchema.parse({
'X-Tenant': 'one',
'x-tenant': 'two'
})
).toThrow()
})
it('accepts bounded JSON bodies and rejects reserved top-level fields', () => {
expect(
modelRequestBodySchema.parse({
temperature: 0.2,
metadata: {
trace: true,
tags: ['internal', null]
}
})
).toMatchObject({
temperature: 0.2,
metadata: { trace: true }
})
expect(() =>
modelRequestBodySchema.parse({ model: 'replacement' })
).toThrow()
expect(() =>
modelRequestBodySchema.parse({ tools: [] })
).toThrow()
expect(() =>
modelRequestBodySchema.parse({
oversized: 'x'.repeat(32_769)
})
).toThrow()
expect(() =>
modelRequestHeadersSchema.parse(
Object.fromEntries(
Array.from({ length: 65 }, (_, index) => [
`x-header-${index}`,
'value'
])
)
)
).toThrow()
})
it('lets runtime headers and top-level body fields take precedence', () => {
const headers = mergeModelRequestHeaders(
{
'x-tenant': 'custom',
accept: 'text/plain'
},
{
Accept: 'application/json',
'content-type': 'application/json'
}
)
expect(Object.fromEntries(headers.entries())).toEqual({
accept: 'application/json',
'content-type': 'application/json',
'x-tenant': 'custom'
})
expect(canonicalModelRequestHeaders(headers)).toEqual({
accept: 'application/json',
'content-type': 'application/json',
'x-tenant': 'custom'
})
expect(
mergeModelRequestBody(
{
temperature: 0.2,
metadata: { source: 'custom' }
},
{
temperature: 0.8,
model: 'runtime-model'
}
)
).toEqual({
temperature: 0.8,
metadata: { source: 'custom' },
model: 'runtime-model'
})
})
})
+248
View File
@@ -0,0 +1,248 @@
import { z } from 'zod'
import type { CanonicalJsonValue } from './agent-protocol/canonical'
export const MODEL_REQUEST_CUSTOMIZATION_LIMITS = {
maximumHeaders: 64,
maximumHeaderNameLength: 128,
maximumHeaderValueLength: 4_096,
maximumBodyBytes: 64 * 1024,
maximumBodyDepth: 16,
maximumBodyNodes: 4_096,
maximumBodyEntries: 256,
maximumBodyKeyLength: 256,
maximumBodyStringLength: 32_768
} as const
const reservedHeaderNames = new Set([
'anthropic-version',
'api-key',
'authorization',
'connection',
'content-length',
'content-type',
'host',
'transfer-encoding',
'user-agent',
'x-api-key'
])
const reservedBodyKeys = new Set([
'input',
'instructions',
'max_output_tokens',
'max_tokens',
'messages',
'model',
'n',
'parallel_tool_calls',
'prompt',
'quality',
'response_format',
'stream',
'stream_options',
'system',
'tool_choice',
'tools'
])
export type ModelRequestHeaders = Record<string, string>
export type ModelRequestJsonValue = CanonicalJsonValue
export type ModelRequestBody = Record<string, ModelRequestJsonValue>
function hasControlCharacter(value: string): boolean {
return [...value].some((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127
})
}
function isHeaderName(value: string): boolean {
return (
value.length > 0 &&
value.length <=
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaderNameLength &&
/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(value)
)
}
function isModelRequestHeaders(value: unknown): value is ModelRequestHeaders {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.getPrototypeOf(value) !== Object.prototype
) {
return false
}
const entries = Object.entries(value)
if (
entries.length >
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaders
) {
return false
}
const normalizedNames = new Set<string>()
for (const [name, headerValue] of entries) {
const normalizedName = name.toLowerCase()
if (
!isHeaderName(name) ||
normalizedNames.has(normalizedName) ||
reservedHeaderNames.has(normalizedName) ||
typeof headerValue !== 'string' ||
headerValue.length >
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaderValueLength ||
hasControlCharacter(headerValue)
) {
return false
}
normalizedNames.add(normalizedName)
}
return true
}
function isModelRequestBody(value: unknown): value is ModelRequestBody {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.getPrototypeOf(value) !== Object.prototype
) {
return false
}
if (
Object.keys(value).some((key) =>
reservedBodyKeys.has(key.toLowerCase())
)
) {
return false
}
const pending: Array<{ value: unknown; depth: number }> = [
{ value, depth: 0 }
]
const seen = new Set<object>()
let nodes = 0
while (pending.length > 0) {
const current = pending.pop()!
nodes += 1
if (
nodes > MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumBodyNodes ||
current.depth >
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumBodyDepth
) {
return false
}
if (
current.value === null ||
typeof current.value === 'boolean'
) {
continue
}
if (typeof current.value === 'number') {
if (!Number.isFinite(current.value)) {
return false
}
continue
}
if (typeof current.value === 'string') {
if (
current.value.length >
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumBodyStringLength
) {
return false
}
continue
}
if (
!current.value ||
typeof current.value !== 'object' ||
seen.has(current.value)
) {
return false
}
seen.add(current.value)
if (Array.isArray(current.value)) {
if (
current.value.length >
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumBodyEntries
) {
return false
}
for (const item of current.value) {
pending.push({ value: item, depth: current.depth + 1 })
}
continue
}
const prototype = Object.getPrototypeOf(current.value)
if (prototype !== Object.prototype && prototype !== null) {
return false
}
const entries = Object.entries(current.value)
if (
entries.length >
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumBodyEntries
) {
return false
}
for (const [key, item] of entries) {
if (
key.length === 0 ||
key.length >
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumBodyKeyLength
) {
return false
}
pending.push({ value: item, depth: current.depth + 1 })
}
}
try {
return (
new TextEncoder().encode(JSON.stringify(value)).byteLength <=
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumBodyBytes
)
} catch {
return false
}
}
export const modelRequestHeadersSchema =
z.custom<ModelRequestHeaders>(
isModelRequestHeaders,
'自定义请求头必须是有界的字符串对象,且不能覆盖认证或传输头'
)
export const modelRequestBodySchema = z.custom<ModelRequestBody>(
isModelRequestBody,
'自定义请求体必须是有界的 JSON 对象,且不能覆盖模型、消息、工具或流式字段'
)
export function mergeModelRequestHeaders(
customHeaders: ModelRequestHeaders,
runtimeHeaders: ConstructorParameters<typeof Headers>[0]
): Headers {
const headers = new Headers(customHeaders)
const runtime = new Headers(runtimeHeaders)
runtime.forEach((value, name) => headers.set(name, value))
return headers
}
export function canonicalModelRequestHeaders(
headers: Headers
): Record<string, string> {
return Object.fromEntries(
[...headers.entries()].sort(([left], [right]) =>
left.localeCompare(right)
)
)
}
export function mergeModelRequestBody(
customBody: ModelRequestBody,
runtimeBody: Record<string, unknown>
): Record<string, unknown> {
return {
...customBody,
...runtimeBody
}
}