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
+88
View File
@@ -4024,6 +4024,94 @@ describe('SettingsPanel runtime files', () => {
)
})
it('edits and saves bounded custom model request headers and body', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
const headers = await screen.findByLabelText('自定义请求头')
const body = screen.getByLabelText('自定义请求体')
expect(headers).toHaveValue('{}')
expect(body).toHaveValue('{}')
expect(headers).toHaveAttribute('maxlength')
expect(body).toHaveAttribute('maxlength')
expect(
screen.getByText(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
).toBeInTheDocument()
fireEvent.change(headers, {
target: {
value: JSON.stringify({ 'x-tenant-id': 'tenant-a' })
}
})
fireEvent.change(body, {
target: {
value: JSON.stringify({
temperature: 0.2,
metadata: { source: 'goodbuddy' }
})
}
})
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: [
expect.objectContaining({
id: modelProfileId,
requestHeaders: {
'x-tenant-id': 'tenant-a'
},
requestBody: {
temperature: 0.2,
metadata: { source: 'goodbuddy' }
}
})
]
})
)
)
})
it('keeps invalid custom request JSON in the editor and blocks saving', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
const headers = await screen.findByLabelText('自定义请求头')
fireEvent.change(headers, {
target: { value: '{"Authorization":"replacement"}' }
})
expect(headers).toHaveAttribute('aria-invalid', 'true')
expect(
screen.getByText(/不能包含认证或传输保留字段/)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
expect(
await screen.findByText(/自定义请求头无效/)
).toBeInTheDocument()
expect(updateRuntime).not.toHaveBeenCalled()
expect(headers).toHaveValue('{"Authorization":"replacement"}')
})
it('keeps saved Runtime sources valid when defaulting a new text profile', async () => {
render(
<SettingsPanel
+162 -18
View File
@@ -39,7 +39,10 @@ import {
defaultModelProfileId as builtInDefaultModelProfileId,
defaultRuntimeSettings,
isAgentRuntimeModelProtocol,
isDeepSeekHarnessModelProfile
isDeepSeekHarnessModelProfile,
MODEL_REQUEST_CUSTOMIZATION_LIMITS,
modelRequestBodySchema,
modelRequestHeadersSchema
} from '../../shared/contracts'
import { RolePromptSettingsSection } from './RolePromptSettingsSection'
import { CapabilitiesAndToolsSettingsSection } from './CapabilitiesAndToolsSettingsSection'
@@ -85,8 +88,13 @@ type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
type AgentRuntimeType =
| RuntimeConfigActionInput['runtime']
| 'deepseek-harness'
type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
type ModelProfileDraft = Omit<
RuntimeSettings['modelProfiles'][number],
'requestHeaders' | 'requestBody'
> & {
supportsImageInput: boolean
requestHeadersText: string
requestBodyText: string
apiKey: string
clearApiKey: boolean
}
@@ -264,15 +272,55 @@ function settingsErrorMessage(reason: unknown, fallback: string): string {
function toModelProfileDrafts(
settings: RuntimeSettings
): ModelProfileDraft[] {
return settings.modelProfiles.map((profile) => ({
...profile,
supportsImageInput: profile.supportsImageInput ?? false,
maximumOutputTokens:
profile.maximumOutputTokens ??
defaultAnthropicMaximumOutputTokens,
apiKey: '',
clearApiKey: false
}))
return settings.modelProfiles.map(
({ requestHeaders, requestBody, ...profile }) => ({
...profile,
supportsImageInput: profile.supportsImageInput ?? false,
maximumOutputTokens:
profile.maximumOutputTokens ??
defaultAnthropicMaximumOutputTokens,
requestHeadersText: JSON.stringify(
requestHeaders ?? {},
null,
2
),
requestBodyText: JSON.stringify(requestBody ?? {}, null, 2),
apiKey: '',
clearApiKey: false
})
)
}
const maximumRequestHeadersDraftCharacters =
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaders *
(MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaderNameLength +
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaderValueLength *
2 +
16) +
2
const maximumRequestBodyDraftCharacters =
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumBodyBytes * 4
function parseRequestHeadersDraft(value: string) {
if (value.length > maximumRequestHeadersDraftCharacters) {
return { success: false } as const
}
try {
return modelRequestHeadersSchema.safeParse(JSON.parse(value))
} catch {
return { success: false } as const
}
}
function parseRequestBodyDraft(value: string) {
if (value.length > maximumRequestBodyDraftCharacters) {
return { success: false } as const
}
try {
return modelRequestBodySchema.safeParse(JSON.parse(value))
} catch {
return { success: false } as const
}
}
function configuredRuntimeSettings(
@@ -1336,6 +1384,26 @@ export function SettingsPanel({
const environmentManaged =
profile.credentialSource === 'environment' &&
configured !== undefined
const requestHeaders = parseRequestHeadersDraft(
profile.requestHeadersText
)
if (!requestHeaders.success) {
throw new Error(
t('errors.invalidModelRequestHeaders', {
name: modelProfileDisplayName(profile)
})
)
}
const requestBody = parseRequestBodyDraft(
profile.requestBodyText
)
if (!requestBody.success) {
throw new Error(
t('errors.invalidModelRequestBody', {
name: modelProfileDisplayName(profile)
})
)
}
return {
id: profile.id,
name: profile.name,
@@ -1351,6 +1419,8 @@ export function SettingsPanel({
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
requestHeaders: requestHeaders.data,
requestBody: requestBody.data,
apiKey: profile.clearApiKey
? ({ action: 'clear' } as const)
: profile.authentication === 'api-key' &&
@@ -1738,6 +1808,8 @@ export function SettingsPanel({
maximumOutputTokens: defaultAnthropicMaximumOutputTokens,
imageGenerationQuality:
defaultRuntimeSettings.imageGenerationQuality,
requestHeadersText: '{}',
requestBodyText: '{}',
apiKeyConfigured: false,
credentialSource: 'none',
apiKey: '',
@@ -2894,11 +2966,20 @@ export function SettingsPanel({
))}
</div>
</aside>
{selectedModelProfile && (() => {
const profile = selectedModelProfile
const environmentManaged =
profile.credentialSource === 'environment'
return (
{selectedModelProfile &&
(() => {
const profile = selectedModelProfile
const environmentManaged =
profile.credentialSource === 'environment'
const requestHeadersValid =
parseRequestHeadersDraft(
profile.requestHeadersText
).success
const requestBodyValid =
parseRequestBodyDraft(
profile.requestBodyText
).success
return (
<div
aria-labelledby={`model-connection-${profile.id}`}
className="model-connection-detail"
@@ -3271,6 +3352,69 @@ export function SettingsPanel({
</small>
</label>
)}
<label className="field">
<span>{t('model.profile.requestHeaders')}</span>
<textarea
aria-describedby={`model-request-headers-help-${profile.id}`}
aria-label={t('model.profile.requestHeaders')}
aria-invalid={
requestHeadersValid ? undefined : true
}
className="model-request-json-input"
maxLength={maximumRequestHeadersDraftCharacters}
onChange={(event) =>
updateModelProfile(profile.id, {
requestHeadersText: event.target.value
})
}
rows={6}
spellCheck={false}
value={profile.requestHeadersText}
/>
<small
id={`model-request-headers-help-${profile.id}`}
>
{t('model.profile.requestHeadersDescription')}
</small>
{!requestHeadersValid && (
<small className="field-error" role="alert">
{t('model.profile.requestHeadersInvalid')}
</small>
)}
</label>
<label className="field">
<span>{t('model.profile.requestBody')}</span>
<textarea
aria-describedby={`model-request-body-help-${profile.id}`}
aria-label={t('model.profile.requestBody')}
aria-invalid={
requestBodyValid ? undefined : true
}
className="model-request-json-input"
maxLength={maximumRequestBodyDraftCharacters}
onChange={(event) =>
updateModelProfile(profile.id, {
requestBodyText: event.target.value
})
}
rows={6}
spellCheck={false}
value={profile.requestBodyText}
/>
<small
id={`model-request-body-help-${profile.id}`}
>
{t('model.profile.requestBodyDescription')}
</small>
{!requestBodyValid && (
<small className="field-error" role="alert">
{t('model.profile.requestBodyInvalid')}
</small>
)}
</label>
<p className="settings-warning">
{t('model.profile.requestCustomizationWarning')}
</p>
<small className="model-connection-detail__compatibility">
{t('model.profile.compatibilitySummary', {
directCapability:
@@ -3293,8 +3437,8 @@ export function SettingsPanel({
})}
</small>
</div>
)
})()}
)
})()}
</div>
{settings && !settings.secureStorageAvailable && (
<p className="settings-warning">
@@ -131,6 +131,10 @@ export const settings = {
detectRuntimes: 'Could not detect Agent Runtimes',
readEmbeddingStatus: 'Could not load embedding model status',
requireModelConnection: 'Configure at least one model connection',
invalidModelRequestHeaders:
'Custom request headers are invalid for “{{name}}”',
invalidModelRequestBody:
'The custom request body is invalid for “{{name}}”',
refreshEmbeddingAfterSave:
'Settings were saved, but the vector model status could not be refreshed',
speechModelsUnavailable:
@@ -735,6 +739,18 @@ export const settings = {
},
imageQualityDescription:
'Used only for OpenAI-compatible image generation requests.',
requestHeaders: 'Custom request headers',
requestHeadersDescription:
'Enter a JSON object whose values are strings. Direct requests, Continue, OpenCode, and DeepSeek Harness use these headers where natively supported. Authentication, protocol, and Runtime headers take precedence.',
requestHeadersInvalid:
'Enter a valid JSON object with string values and no reserved authentication or transport headers.',
requestBody: 'Custom request body',
requestBodyDescription:
'Enter a JSON object for a top-level shallow merge. Direct requests and Continue support it, and the model gateway supports managed remote OpenCode. Local OpenCode and DeepSeek Harness ignore it. Runtime model, message, tool, and streaming fields take precedence.',
requestBodyInvalid:
'Enter a valid JSON object without reserved model, message, tool, streaming, or transport fields.',
requestCustomizationWarning:
'Do not enter API keys or other secrets here. These values are saved as ordinary connection settings; use the API Key field above for credentials.',
compatibilitySummary:
'Direct model: {{directCapability}} · Continue: {{continueCompatibility}} · OpenCode: {{openCodeCompatibility}} · DeepSeek Harness: {{deepseekHarnessCompatibility}}',
textChat: 'Text chat',
@@ -117,6 +117,10 @@ export const settings = {
detectRuntimes: 'Runtime 自动检测失败',
readEmbeddingStatus: '读取向量模型状态失败',
requireModelConnection: '请至少配置一个模型连接',
invalidModelRequestHeaders:
'模型连接“{{name}}”的自定义请求头无效',
invalidModelRequestBody:
'模型连接“{{name}}”的自定义请求体无效',
refreshEmbeddingAfterSave: '设置已保存,但刷新向量模型状态失败',
speechModelsUnavailable: '当前版本未提供语音模型服务',
saveSettings: '保存设置失败',
@@ -679,6 +683,18 @@ export const settings = {
high: '高'
},
imageQualityDescription: '仅用于 OpenAI 兼容图像生成请求。',
requestHeaders: '自定义请求头',
requestHeadersDescription:
'填写 JSON 对象,值必须是字符串。直连、Continue、OpenCode 和 DeepSeek Harness 会在原生支持范围内使用;认证、协议和 Runtime 生成的请求头优先。',
requestHeadersInvalid:
'请输入有效的 JSON 对象;值必须是字符串,且不能包含认证或传输保留字段。',
requestBody: '自定义请求体',
requestBodyDescription:
'填写 JSON 对象,与请求体做顶层浅合并。直连和 Continue 支持;受管远程 OpenCode 由模型网关支持。本地 OpenCode 与 DeepSeek Harness 不使用此项。模型、消息、工具和流式等 Runtime 字段优先。',
requestBodyInvalid:
'请输入有效的 JSON 对象;不能包含模型、消息、工具、流式或其他保留字段。',
requestCustomizationWarning:
'请勿在此处填写 API Key 或其他密钥。这些值作为普通连接设置保存;凭据请使用上方 API Key 字段。',
compatibilitySummary:
'直连模型:{{directCapability}} · Continue:{{continueCompatibility}} · OpenCode:{{openCodeCompatibility}} · DeepSeek Harness:{{deepseekHarnessCompatibility}}',
textChat: '文本对话',
+7
View File
@@ -9238,6 +9238,13 @@ details.settings-section > :not(summary) + :not(summary) {
line-height: 1.5;
}
.field textarea.model-request-json-input {
min-height: 120px;
font-family: var(--font-family-mono);
line-height: 1.5;
tab-size: 2;
}
.role-prompt-detail__prompt {
min-height: 240px;
font-family: inherit;