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:
@@ -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(
|
||||
|
||||
@@ -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')}`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -965,6 +965,13 @@ describe('ContinueHostAdapter', () => {
|
||||
protocol,
|
||||
authentication,
|
||||
supportsImageInput: true,
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'continue-tenant'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.25,
|
||||
metadata: { source: 'goodbuddy' }
|
||||
},
|
||||
...(authentication === 'api-key'
|
||||
? { apiKey: 'private-key' }
|
||||
: {})
|
||||
@@ -1009,7 +1016,16 @@ describe('ContinueHostAdapter', () => {
|
||||
apiBase: 'http://127.0.0.1:11434/v1',
|
||||
model: 'qwen3',
|
||||
useResponsesApi,
|
||||
capabilities: ['image_input']
|
||||
capabilities: ['image_input'],
|
||||
requestOptions: {
|
||||
headers: {
|
||||
'x-tenant-id': 'continue-tenant'
|
||||
},
|
||||
extraBodyProperties: {
|
||||
temperature: 0.25,
|
||||
metadata: { source: 'goodbuddy' }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
mcpServers: [
|
||||
@@ -2035,8 +2051,10 @@ describe('ContinueHostAdapter', () => {
|
||||
temporaryDirectories.push(root)
|
||||
const requestPaths: string[] = []
|
||||
const requestBodies: unknown[] = []
|
||||
const tenantHeaders: Array<string | string[] | undefined> = []
|
||||
const server = createServer(async (request, response) => {
|
||||
requestPaths.push(request.url ?? '')
|
||||
tenantHeaders.push(request.headers['x-tenant-id'])
|
||||
let body = ''
|
||||
for await (const chunk of request) {
|
||||
body += chunk
|
||||
@@ -2081,7 +2099,14 @@ describe('ContinueHostAdapter', () => {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
modelName: 'probe-model',
|
||||
protocol,
|
||||
authentication: 'none'
|
||||
authentication: 'none',
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'continue-native'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.25,
|
||||
metadata: { source: 'goodbuddy' }
|
||||
}
|
||||
}
|
||||
})
|
||||
const controller = new AbortController()
|
||||
@@ -2095,6 +2120,13 @@ describe('ContinueHostAdapter', () => {
|
||||
.catch(() => undefined)
|
||||
expect(requestPaths).toContain(expectedPath)
|
||||
expect(requestPaths).not.toContain(unexpectedPath)
|
||||
expect(tenantHeaders).toContain('continue-native')
|
||||
expect(requestBodies).toContainEqual(
|
||||
expect.objectContaining({
|
||||
temperature: 0.25,
|
||||
metadata: { source: 'goodbuddy' }
|
||||
})
|
||||
)
|
||||
if (protocol === 'openai-chat-completions') {
|
||||
const chatRequest = requestBodies.find(
|
||||
(body): body is { messages: Array<{ role?: unknown }> } =>
|
||||
|
||||
@@ -1375,6 +1375,22 @@ export class ContinueHostAdapter {
|
||||
? '${{ secrets.ANTHROPIC_API_KEY }}'
|
||||
: '${{ secrets.OPENAI_API_KEY }}'
|
||||
}
|
||||
const requestHeaders =
|
||||
this.options.modelProfile.requestHeaders ?? {}
|
||||
const requestBody = this.options.modelProfile.requestBody ?? {}
|
||||
if (
|
||||
Object.keys(requestHeaders).length > 0 ||
|
||||
Object.keys(requestBody).length > 0
|
||||
) {
|
||||
modelConfig.requestOptions = {
|
||||
...(Object.keys(requestHeaders).length > 0
|
||||
? { headers: requestHeaders }
|
||||
: {}),
|
||||
...(Object.keys(requestBody).length > 0
|
||||
? { extraBodyProperties: requestBody }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
const configured = this.options.configPath.trim()
|
||||
? await loadContinueConfig(this.options.configPath.trim())
|
||||
: {}
|
||||
|
||||
@@ -114,6 +114,8 @@ describe('createAgentRuntime model compatibility', () => {
|
||||
authentication: 'api-key' as const,
|
||||
supportsImageInput: true,
|
||||
imageGenerationQuality: 'auto' as const,
|
||||
requestHeaders: { 'x-tenant-id': 'harness-tenant' },
|
||||
requestBody: { temperature: 0.2 },
|
||||
apiKey: 'gateway-key'
|
||||
}
|
||||
const deepseekHarnessLauncher = vi
|
||||
@@ -135,8 +137,14 @@ describe('createAgentRuntime model compatibility', () => {
|
||||
available: false
|
||||
})
|
||||
expect(deepseekHarnessLauncher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ supportsImageInput: true })
|
||||
expect.objectContaining({
|
||||
supportsImageInput: true,
|
||||
requestHeaders: { 'x-tenant-id': 'harness-tenant' }
|
||||
})
|
||||
)
|
||||
expect(
|
||||
deepseekHarnessLauncher.mock.calls[0]?.[0]
|
||||
).not.toHaveProperty('requestBody')
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -104,7 +104,9 @@ function resolveContextCompression(
|
||||
>,
|
||||
authentication: summaryProfile.authentication,
|
||||
contextWindowTokens: summaryProfile.contextWindowTokens,
|
||||
maximumOutputTokens: summaryProfile.maximumOutputTokens
|
||||
maximumOutputTokens: summaryProfile.maximumOutputTokens,
|
||||
requestHeaders: summaryProfile.requestHeaders,
|
||||
requestBody: summaryProfile.requestBody
|
||||
}
|
||||
}
|
||||
: {})
|
||||
@@ -127,6 +129,8 @@ export function createDefaultModelRuntime(
|
||||
model: settings.modelName,
|
||||
protocol: settings.modelProtocol,
|
||||
authentication: settings.modelAuthentication,
|
||||
requestHeaders: currentProfile?.requestHeaders,
|
||||
requestBody: currentProfile?.requestBody,
|
||||
supportsImageInput: settings.supportsImageInput,
|
||||
maximumOutputTokens: currentProfile?.maximumOutputTokens,
|
||||
defaultWorkspace: settings.workspacePath || defaultWorkspace,
|
||||
@@ -149,6 +153,8 @@ export function createModelProfileRuntime(
|
||||
model: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
requestHeaders: profile.requestHeaders,
|
||||
requestBody: profile.requestBody,
|
||||
supportsImageInput: profile.supportsImageInput,
|
||||
maximumOutputTokens: profile.maximumOutputTokens,
|
||||
imageGenerationQuality:
|
||||
@@ -211,6 +217,7 @@ export function createAgentRuntime(
|
||||
baseUrl: profile.baseUrl,
|
||||
model: profile.modelName,
|
||||
supportsImageInput: profile.supportsImageInput === true,
|
||||
requestHeaders: profile.requestHeaders,
|
||||
launch: capabilities.deepseekHarnessLauncher,
|
||||
credentialRefs: {
|
||||
GOODBUDDY_HARNESS_MODEL_API_KEY: profile.apiKey
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './goodbuddy-harness-control-plane'
|
||||
import { DshNpmExtensionInstaller } from './dsh-extension-marketplace'
|
||||
import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol'
|
||||
import { createOpenAIChatCompletionsUrl } from './openai-endpoint'
|
||||
import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
|
||||
|
||||
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
||||
const SKILL_CALL_ID = 'e2e-skill-call'
|
||||
@@ -329,6 +331,7 @@ function createInProcessLaunch(
|
||||
provider: 'goodbuddy',
|
||||
model: options.model,
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
requestHeaders: options.requestHeaders,
|
||||
harnessVersion: '0.1.0-rc.8',
|
||||
credentialRefs: options.credentialRefs,
|
||||
skillPackages: options.skillPackages,
|
||||
@@ -891,6 +894,79 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
60_000
|
||||
)
|
||||
|
||||
it.runIf(liveModelEnabled)(
|
||||
'forwards a custom model header through the real DeepSeek Harness provider',
|
||||
async () => {
|
||||
if (!liveApiKey) {
|
||||
throw new Error(
|
||||
'GOODBUDDY_DSH_API_KEY is required for live DSH model E2E'
|
||||
)
|
||||
}
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-header-model-'))
|
||||
)
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'dsh-home')
|
||||
await Promise.all([mkdir(workspace), mkdir(dshHome)])
|
||||
const probe = await createModelRequestProbe({
|
||||
upstreamUrl: createOpenAIChatCompletionsUrl(liveBaseUrl),
|
||||
headerName: 'x-goodbuddy-e2e'
|
||||
})
|
||||
const inProcess = createInProcessLaunch(dshHome)
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
baseUrl: probe.baseUrl,
|
||||
model: liveModel,
|
||||
requestHeaders: {
|
||||
'x-goodbuddy-e2e': 'harness-header-present'
|
||||
},
|
||||
launch: (options) => inProcess.launch(options),
|
||||
credentialRefs: {
|
||||
[CREDENTIAL_REF]: liveApiKey
|
||||
},
|
||||
initializationTimeoutMs: 20_000,
|
||||
promptTimeoutMs: 120_000,
|
||||
shutdownTimeoutMs: 5_000
|
||||
})
|
||||
|
||||
try {
|
||||
const events = await collect(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: 'request-live-custom-header',
|
||||
conversationId: 'live-custom-header',
|
||||
prompt:
|
||||
'Reply with exactly DSH_CUSTOM_HEADER_E2E_OK and nothing else.',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
expect(
|
||||
events
|
||||
.flatMap((event) =>
|
||||
event.type === 'text' ? [event.delta] : []
|
||||
)
|
||||
.join('')
|
||||
).toContain('DSH_CUSTOM_HEADER_E2E_OK')
|
||||
expect(probe.observations).toEqual([
|
||||
{
|
||||
headerValue: 'harness-header-present',
|
||||
bodyFields: {}
|
||||
}
|
||||
])
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await Promise.allSettled(
|
||||
inProcess.hosts.map((host) => host.dispose())
|
||||
)
|
||||
await probe.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
180_000
|
||||
)
|
||||
|
||||
it.runIf(liveModelEnabled)(
|
||||
'lets a real model use Main-brokered Web Search and Fetch in Ask',
|
||||
async () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { isDeepSeekHarnessCompatibleBaseUrl } from '../../shared/deepseek-harness-compatibility'
|
||||
import {
|
||||
modelRequestHeadersSchema
|
||||
} from '../../shared/model-request-customization'
|
||||
import {
|
||||
runtimeExtensionConfigurationSchema,
|
||||
runtimeExtensionIdSchema
|
||||
@@ -9,7 +12,7 @@ import { bundledDeepSeekHarnessVersion } from './bundled-runtimes'
|
||||
|
||||
export const DEEPSEEK_HARNESS_CONTROL_PROTOCOL =
|
||||
'goodbuddy.deepseek-harness.control'
|
||||
export const DEEPSEEK_HARNESS_CONTROL_VERSION = 2
|
||||
export const DEEPSEEK_HARNESS_CONTROL_VERSION = 3
|
||||
export const DEEPSEEK_HARNESS_HOST_VERSION =
|
||||
bundledDeepSeekHarnessVersion
|
||||
export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
|
||||
@@ -84,6 +87,7 @@ export const controlledHarnessHostConfigSchema = z
|
||||
provider: z.literal('goodbuddy'),
|
||||
model: z.string().min(1).max(128),
|
||||
supportsImageInput: z.boolean(),
|
||||
requestHeaders: modelRequestHeadersSchema,
|
||||
harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION),
|
||||
credentialRefs: z
|
||||
.tuple([z.literal(DEEPSEEK_HARNESS_CREDENTIAL_REF)])
|
||||
|
||||
@@ -48,6 +48,7 @@ function setup(
|
||||
maxEventCharacters?: number
|
||||
maxRequestOutputCharacters?: number
|
||||
supportsImageInput?: boolean
|
||||
requestHeaders?: Record<string, string>
|
||||
advertisedImageInput?: boolean
|
||||
initializationTimeoutMs?: number
|
||||
useDefaultInitializationTimeout?: boolean
|
||||
@@ -247,6 +248,7 @@ function setup(
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
requestHeaders: options.requestHeaders,
|
||||
launch,
|
||||
loadAcpSdk: async () => sdk,
|
||||
...(options.useDefaultInitializationTimeout
|
||||
@@ -639,7 +641,9 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
})
|
||||
|
||||
it('uses ACP stdio, maps conversations to sessions, and streams text', async () => {
|
||||
const harness = setup()
|
||||
const harness = setup({
|
||||
requestHeaders: { 'x-tenant-id': 'harness-tenant' }
|
||||
})
|
||||
const first = collect(
|
||||
harness.runtime.run(
|
||||
request('one'),
|
||||
@@ -678,6 +682,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
supportsImageInput: false,
|
||||
requestHeaders: { 'x-tenant-id': 'harness-tenant' },
|
||||
credentialRefs: [],
|
||||
skillPackages: [],
|
||||
extensionPackages: []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AgentRuntimeStatus,
|
||||
ModelRequestHeaders,
|
||||
RuntimeNativeSnapshot,
|
||||
RuntimeNativeTool
|
||||
} from '../../shared/contracts'
|
||||
@@ -173,6 +174,7 @@ export type DeepSeekHarnessLaunchOptions = {
|
||||
baseUrl: string
|
||||
model: string
|
||||
supportsImageInput: boolean
|
||||
requestHeaders?: ModelRequestHeaders
|
||||
credentialRefs: readonly string[]
|
||||
skillPackages: readonly RuntimeSkillPackage[]
|
||||
extensionPackages: readonly ControlledHarnessExtensionPackage[]
|
||||
@@ -183,6 +185,7 @@ export type DeepSeekHarnessRuntimeOptions = {
|
||||
baseUrl: string
|
||||
model: string
|
||||
supportsImageInput?: boolean
|
||||
requestHeaders?: ModelRequestHeaders
|
||||
launch: (
|
||||
options: DeepSeekHarnessLaunchOptions
|
||||
) => Promise<DeepSeekHarnessChild>
|
||||
@@ -821,6 +824,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
model: this.options.model,
|
||||
supportsImageInput:
|
||||
this.options.supportsImageInput === true,
|
||||
requestHeaders: this.options.requestHeaders ?? {},
|
||||
credentialRefs: Object.keys(
|
||||
this.options.credentialRefs ?? {}
|
||||
),
|
||||
|
||||
@@ -54,6 +54,7 @@ async function fixture() {
|
||||
baseUrl: 'http://gateway.example/openai/v1?api-version=1',
|
||||
model: 'qwen-plus',
|
||||
supportsImageInput: false,
|
||||
requestHeaders: { 'x-tenant-id': 'harness-tenant' },
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||
skillPackages: [],
|
||||
extensionPackages: []
|
||||
@@ -63,6 +64,7 @@ async function fixture() {
|
||||
|
||||
describe('DeepSeek Harness utility launcher', () => {
|
||||
it('accepts only strict control messages and secret-free config', () => {
|
||||
expect(DEEPSEEK_HARNESS_CONTROL_VERSION).toBe(3)
|
||||
expect(
|
||||
parseHarnessControlMessage({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
@@ -106,6 +108,9 @@ describe('DeepSeek Harness utility launcher', () => {
|
||||
baseUrl: 'http://gateway.example/openai/v1?api-version=1',
|
||||
model: 'qwen-plus',
|
||||
supportsImageInput: false,
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'harness-tenant'
|
||||
},
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -288,6 +288,7 @@ export function createDeepSeekHarnessUtilityLauncher(
|
||||
provider: 'goodbuddy',
|
||||
model: options.model,
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
requestHeaders: options.requestHeaders ?? {},
|
||||
harnessVersion: DEEPSEEK_HARNESS_HOST_VERSION,
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||
skillPackages: canonicalSkillPackages,
|
||||
|
||||
@@ -464,6 +464,59 @@ describe('ModelAgentRuntime', () => {
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('merges custom headers and body fields into a direct model request without overriding runtime fields', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async (_input, init) => {
|
||||
const body = JSON.parse(init?.body as string) as {
|
||||
messages: Array<{ content: string }>
|
||||
}
|
||||
const marker =
|
||||
/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec(
|
||||
body.messages[0]?.content ?? ''
|
||||
)?.[0]
|
||||
return Response.json({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: marker
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: 'runtime-key',
|
||||
baseUrl: 'https://model.example/v1',
|
||||
model: 'runtime-model',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'tenant-a',
|
||||
authorization: 'Bearer custom-key'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.2,
|
||||
model: 'custom-model',
|
||||
stream: true
|
||||
},
|
||||
fetcher
|
||||
})
|
||||
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
const init = fetcher.mock.calls[0]?.[1]
|
||||
const headers = new Headers(init?.headers)
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
expect(headers.get('x-tenant-id')).toBe('tenant-a')
|
||||
expect(headers.get('authorization')).toBe('Bearer runtime-key')
|
||||
expect(body).toMatchObject({
|
||||
model: 'runtime-model',
|
||||
stream: false,
|
||||
temperature: 0.2
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the Anthropic messages endpoint and streams text deltas', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||||
return new Response(createEventStream('真实模型回答', '先分析问题'), {
|
||||
@@ -569,6 +622,8 @@ describe('ModelAgentRuntime', () => {
|
||||
model: 'sonnet-5',
|
||||
protocol: 'anthropic-messages',
|
||||
authentication: 'api-key',
|
||||
requestHeaders: { 'x-model-route': 'summary-test' },
|
||||
requestBody: { temperature: 0.1 },
|
||||
fetcher,
|
||||
contextCompression: {
|
||||
settings: {
|
||||
@@ -622,6 +677,14 @@ describe('ModelAgentRuntime', () => {
|
||||
}
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
for (const [, init] of fetcher.mock.calls) {
|
||||
expect(new Headers(init?.headers).get('x-model-route')).toBe(
|
||||
'summary-test'
|
||||
)
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
temperature: 0.1
|
||||
})
|
||||
}
|
||||
const summaryBody = JSON.parse(
|
||||
fetcher.mock.calls[0]![1]!.body as string
|
||||
) as { max_tokens: number; system: string; messages: unknown[] }
|
||||
@@ -5168,6 +5231,11 @@ describe('ModelAgentRuntime', () => {
|
||||
protocol: 'openai-images-generations',
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'medium',
|
||||
requestHeaders: { 'x-image-route': 'internal' },
|
||||
requestBody: {
|
||||
background: 'transparent',
|
||||
quality: 'high'
|
||||
},
|
||||
fetcher
|
||||
})
|
||||
|
||||
@@ -5185,8 +5253,14 @@ describe('ModelAgentRuntime', () => {
|
||||
model: 'gpt-image-2',
|
||||
n: 1,
|
||||
quality: 'medium',
|
||||
background: 'transparent',
|
||||
response_format: 'b64_json'
|
||||
})
|
||||
expect(
|
||||
new Headers(fetcher.mock.calls[0]?.[1]?.headers).get(
|
||||
'x-image-route'
|
||||
)
|
||||
).toBe('internal')
|
||||
})
|
||||
|
||||
it('generates a bounded image through the BigToken-compatible endpoint', async () => {
|
||||
|
||||
@@ -6,9 +6,15 @@ import type {
|
||||
ImageGenerationQuality,
|
||||
ModelAuthentication,
|
||||
ModelProtocol,
|
||||
ModelRequestBody,
|
||||
ModelRequestHeaders,
|
||||
RuntimeConversationCompactInput
|
||||
} from '../../shared/contracts'
|
||||
import { defaultAnthropicMaximumOutputTokens } from '../../shared/contracts'
|
||||
import {
|
||||
defaultAnthropicMaximumOutputTokens,
|
||||
mergeModelRequestBody,
|
||||
mergeModelRequestHeaders
|
||||
} from '../../shared/contracts'
|
||||
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
||||
import type { BrowserToolService } from '../browser/browser-model-tools'
|
||||
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
|
||||
@@ -242,6 +248,8 @@ export type ModelRuntimeOptions = {
|
||||
model: string
|
||||
protocol: ModelProtocol
|
||||
authentication: ModelAuthentication
|
||||
requestHeaders?: ModelRequestHeaders
|
||||
requestBody?: ModelRequestBody
|
||||
supportsImageInput?: boolean
|
||||
imageGenerationQuality?: ImageGenerationQuality
|
||||
skillInstructions?: string
|
||||
@@ -265,6 +273,8 @@ export type ModelRuntimeOptions = {
|
||||
authentication: ModelAuthentication
|
||||
contextWindowTokens?: number
|
||||
maximumOutputTokens?: number
|
||||
requestHeaders?: ModelRequestHeaders
|
||||
requestBody?: ModelRequestBody
|
||||
}
|
||||
}
|
||||
maximumOutputTokens?: number
|
||||
@@ -1762,35 +1772,47 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
const headers = mergeModelRequestHeaders(
|
||||
this.options.requestHeaders ?? {},
|
||||
{
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
)
|
||||
if (
|
||||
this.options.authentication === 'api-key' &&
|
||||
this.options.apiKey
|
||||
) {
|
||||
if (this.options.protocol === 'anthropic-messages') {
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
headers['x-api-key'] = this.options.apiKey
|
||||
headers.set('anthropic-version', '2023-06-01')
|
||||
headers.set('x-api-key', this.options.apiKey)
|
||||
} else {
|
||||
headers.authorization = `Bearer ${this.options.apiKey}`
|
||||
headers.set('authorization', `Bearer ${this.options.apiKey}`)
|
||||
}
|
||||
} else if (this.options.protocol === 'anthropic-messages') {
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
headers.set('anthropic-version', '2023-06-01')
|
||||
}
|
||||
return headers
|
||||
return Object.fromEntries(headers.entries())
|
||||
}
|
||||
|
||||
private createRequestBody(
|
||||
runtimeBody: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return mergeModelRequestBody(
|
||||
this.options.requestBody ?? {},
|
||||
runtimeBody
|
||||
)
|
||||
}
|
||||
|
||||
private createImageGenerationRequest(
|
||||
prompt: string
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
return this.createRequestBody({
|
||||
model: this.options.model,
|
||||
prompt: prompt.slice(0, 100_000),
|
||||
n: 1,
|
||||
quality: this.options.imageGenerationQuality ?? 'auto',
|
||||
response_format: 'b64_json'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(
|
||||
@@ -1921,19 +1943,21 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
body: JSON.stringify(
|
||||
imageGeneration
|
||||
? this.createImageGenerationRequest(prompt)
|
||||
: this.options.protocol === 'openai-responses'
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_output_tokens: 64,
|
||||
stream: false,
|
||||
input: prompt
|
||||
}
|
||||
: {
|
||||
model: this.options.model,
|
||||
max_tokens: 64,
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: prompt }]
|
||||
}
|
||||
: this.createRequestBody(
|
||||
this.options.protocol === 'openai-responses'
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_output_tokens: 64,
|
||||
stream: false,
|
||||
input: prompt
|
||||
}
|
||||
: {
|
||||
model: this.options.model,
|
||||
max_tokens: 64,
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: prompt }]
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
const responseText = await readBoundedResponseText(response, {
|
||||
@@ -2121,7 +2145,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
'openai-images-generations'
|
||||
>,
|
||||
authentication: this.options.authentication,
|
||||
maximumOutputTokens: this.options.maximumOutputTokens
|
||||
maximumOutputTokens: this.options.maximumOutputTokens,
|
||||
requestHeaders: this.options.requestHeaders,
|
||||
requestBody: this.options.requestBody
|
||||
}
|
||||
const summaryRuntime = new ModelAgentRuntime({
|
||||
...summaryModel,
|
||||
@@ -2775,38 +2801,40 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
}))
|
||||
const body = JSON.stringify(
|
||||
responses
|
||||
? {
|
||||
model: this.options.model,
|
||||
stream: true,
|
||||
instructions: system,
|
||||
input: messages,
|
||||
...(providerTools.length > 0
|
||||
? { tools: providerTools }
|
||||
: {})
|
||||
}
|
||||
: anthropic
|
||||
this.createRequestBody(
|
||||
responses
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_tokens: this.anthropicProtocolMaximumTokens,
|
||||
stream: true,
|
||||
system,
|
||||
messages,
|
||||
...(providerTools.length > 0
|
||||
? { tools: providerTools }
|
||||
: {})
|
||||
}
|
||||
: {
|
||||
model: this.options.model,
|
||||
stream: true,
|
||||
stream_options: {
|
||||
include_usage: true
|
||||
},
|
||||
messages,
|
||||
instructions: system,
|
||||
input: messages,
|
||||
...(providerTools.length > 0
|
||||
? { tools: providerTools }
|
||||
: {})
|
||||
}
|
||||
: anthropic
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_tokens: this.anthropicProtocolMaximumTokens,
|
||||
stream: true,
|
||||
system,
|
||||
messages,
|
||||
...(providerTools.length > 0
|
||||
? { tools: providerTools }
|
||||
: {})
|
||||
}
|
||||
: {
|
||||
model: this.options.model,
|
||||
stream: true,
|
||||
stream_options: {
|
||||
include_usage: true
|
||||
},
|
||||
messages,
|
||||
...(providerTools.length > 0
|
||||
? { tools: providerTools }
|
||||
: {})
|
||||
}
|
||||
)
|
||||
)
|
||||
if (Buffer.byteLength(body) > 2 * 1024 * 1024) {
|
||||
throw new Error('模型工具请求上下文超过 2MB 安全限制')
|
||||
@@ -3059,29 +3087,31 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(
|
||||
responses
|
||||
? {
|
||||
model: this.options.model,
|
||||
stream: true,
|
||||
instructions: system,
|
||||
input: messages
|
||||
}
|
||||
: anthropic
|
||||
this.createRequestBody(
|
||||
responses
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_tokens: this.anthropicProtocolMaximumTokens,
|
||||
stream: true,
|
||||
system,
|
||||
messages
|
||||
}
|
||||
: {
|
||||
model: this.options.model,
|
||||
stream: true,
|
||||
stream_options: {
|
||||
include_usage: true
|
||||
},
|
||||
messages
|
||||
instructions: system,
|
||||
input: messages
|
||||
}
|
||||
: anthropic
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_tokens: this.anthropicProtocolMaximumTokens,
|
||||
stream: true,
|
||||
system,
|
||||
messages
|
||||
}
|
||||
: {
|
||||
model: this.options.model,
|
||||
stream: true,
|
||||
stream_options: {
|
||||
include_usage: true
|
||||
},
|
||||
messages
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
signal
|
||||
|
||||
@@ -678,9 +678,20 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
join(tmpdir(), 'goodbuddy-opencode-routing-')
|
||||
)
|
||||
const requestPaths: string[] = []
|
||||
const server = createServer((request, response) => {
|
||||
const tenantHeaders: Array<string | string[] | undefined> = []
|
||||
const requestBodies: Array<Record<string, unknown>> = []
|
||||
const server = createServer(async (request, response) => {
|
||||
requestPaths.push(request.url ?? '')
|
||||
request.resume()
|
||||
tenantHeaders.push(request.headers['x-tenant-id'])
|
||||
let body = ''
|
||||
for await (const chunk of request) {
|
||||
body += String(chunk)
|
||||
}
|
||||
if (body) {
|
||||
requestBodies.push(
|
||||
JSON.parse(body) as Record<string, unknown>
|
||||
)
|
||||
}
|
||||
response.writeHead(400, {
|
||||
'content-type': 'application/json'
|
||||
})
|
||||
@@ -730,7 +741,13 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
modelName: 'probe-model',
|
||||
protocol,
|
||||
authentication: 'api-key',
|
||||
apiKey: 'local-probe-key'
|
||||
apiKey: 'local-probe-key',
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'opencode-native'
|
||||
},
|
||||
requestBody: {
|
||||
goodbuddy_unsupported_probe: 'must-not-send'
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ startupTimeoutMs: 20_000 }
|
||||
@@ -765,6 +782,12 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
}
|
||||
expect(requestPaths).toContain(expectedPath)
|
||||
expect(requestPaths).not.toContain(unexpectedPath)
|
||||
expect(tenantHeaders).toContain('opencode-native')
|
||||
expect(requestBodies).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
goodbuddy_unsupported_probe: 'must-not-send'
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
await runtime.dispose()
|
||||
@@ -820,6 +843,12 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
modelName: 'custom-model',
|
||||
protocol,
|
||||
authentication,
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'opencode-tenant'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.25
|
||||
},
|
||||
...(authentication === 'api-key'
|
||||
? { apiKey: 'private-key' }
|
||||
: {})
|
||||
@@ -854,7 +883,10 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
expect(config.provider?.[providerId]).toMatchObject({
|
||||
npm: providerPackage,
|
||||
options: {
|
||||
baseURL: 'https://model.example/v1'
|
||||
baseURL: 'https://model.example/v1',
|
||||
headers: {
|
||||
'x-tenant-id': 'opencode-tenant'
|
||||
}
|
||||
},
|
||||
models: {
|
||||
'custom-model': {
|
||||
@@ -873,6 +905,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
config.provider?.[providerId]?.options
|
||||
).not.toHaveProperty('apiKey')
|
||||
}
|
||||
expect(JSON.stringify(config)).not.toContain('temperature')
|
||||
await runtime.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -135,6 +135,7 @@ type OpenCodeProviderConfig = {
|
||||
options: {
|
||||
apiKey?: string
|
||||
baseURL: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
models: Record<
|
||||
string,
|
||||
@@ -214,8 +215,13 @@ function createOpenCodeProviderConfig(
|
||||
const options: {
|
||||
apiKey?: string
|
||||
baseURL: string
|
||||
headers?: Record<string, string>
|
||||
} = {
|
||||
baseURL: provider.baseURL
|
||||
baseURL: provider.baseURL,
|
||||
...(profile.requestHeaders &&
|
||||
Object.keys(profile.requestHeaders).length > 0
|
||||
? { headers: profile.requestHeaders }
|
||||
: {})
|
||||
}
|
||||
if (profile.authentication === 'api-key' && profile.apiKey) {
|
||||
options.apiKey = profile.apiKey
|
||||
|
||||
@@ -154,6 +154,59 @@ describe('RemoteModelGateway', () => {
|
||||
expect(JSON.stringify(completed)).not.toContain('"messages"')
|
||||
})
|
||||
|
||||
it('applies prompt-scoped custom headers and body fields before dispatch', async () => {
|
||||
const store = await createStore()
|
||||
const customizedProfile: ResolvedModelProfile = {
|
||||
...anthropicProfile,
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'remote-tenant'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.2,
|
||||
metadata: { source: 'goodbuddy' }
|
||||
}
|
||||
}
|
||||
const customizedRequest = {
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [],
|
||||
temperature: 0.8
|
||||
})
|
||||
).toString('base64')
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get('x-tenant-id')).toBe(
|
||||
'remote-tenant'
|
||||
)
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
messages: [],
|
||||
metadata: { source: 'goodbuddy' },
|
||||
model: 'claude-test',
|
||||
temperature: 0.8
|
||||
})
|
||||
return new Response('{}')
|
||||
})
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await gateway.dispatch(
|
||||
{
|
||||
...context,
|
||||
modelProfile: customizedProfile,
|
||||
modelProfileDigest:
|
||||
createResolvedModelProfileDigest(customizedProfile)
|
||||
},
|
||||
customizedRequest,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
createResolvedModelProfileDigest(customizedProfile)
|
||||
).not.toBe(createResolvedModelProfileDigest(anthropicProfile))
|
||||
})
|
||||
|
||||
it('does not clamp Runtime-selected provider output parameters', async () => {
|
||||
const store = await createStore()
|
||||
let sentBody: Record<string, unknown> | undefined
|
||||
|
||||
@@ -28,6 +28,13 @@ import {
|
||||
assertTextModelRequestPolicy,
|
||||
ModelRequestPolicyError
|
||||
} from '../../shared/model-request-policy'
|
||||
import { modelBridgePathMatchesProtocol } from '../../shared/model-bridge-contracts'
|
||||
import { canonicalJson } from '../../shared/agent-protocol/canonical'
|
||||
import {
|
||||
canonicalModelRequestHeaders,
|
||||
mergeModelRequestBody,
|
||||
mergeModelRequestHeaders
|
||||
} from '../../shared/model-request-customization'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
const RESPONSE_HEADER_NAMES = [
|
||||
@@ -302,6 +309,24 @@ function prepareDispatch(
|
||||
profile,
|
||||
Buffer.from(parsedRequest.bodyBase64, 'base64')
|
||||
)
|
||||
if (
|
||||
body.byteLength >
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
|
||||
) {
|
||||
throw new RemoteModelGatewayError(
|
||||
'request-too-large',
|
||||
'Customized remote model request is too large'
|
||||
)
|
||||
}
|
||||
const providerHeaders = mergeModelRequestHeaders(
|
||||
profile.requestHeaders ?? {},
|
||||
parsedRequest.headers
|
||||
)
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
providerHeaders.set('anthropic-version', '2023-06-01')
|
||||
}
|
||||
const canonicalHeaders =
|
||||
canonicalModelRequestHeaders(providerHeaders)
|
||||
const normalizedBodyBase64 = body.toString('base64')
|
||||
const canonicalRequest = JSON.stringify({
|
||||
profileId: profile.id,
|
||||
@@ -312,11 +337,7 @@ function prepareDispatch(
|
||||
request: {
|
||||
method: parsedRequest.method,
|
||||
path: parsedRequest.path,
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(parsedRequest.headers).sort(([left], [right]) =>
|
||||
left.localeCompare(right)
|
||||
)
|
||||
),
|
||||
headers: canonicalHeaders,
|
||||
bodyBase64: normalizedBodyBase64
|
||||
}
|
||||
})
|
||||
@@ -338,16 +359,12 @@ function prepareDispatch(
|
||||
protocol: profile.protocol
|
||||
})
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...parsedRequest.headers
|
||||
}
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
if (profile.authentication === 'api-key') {
|
||||
headers['x-api-key'] = profile.apiKey!
|
||||
providerHeaders.set('x-api-key', profile.apiKey!)
|
||||
}
|
||||
} else if (profile.authentication === 'api-key') {
|
||||
headers.authorization = `Bearer ${profile.apiKey!}`
|
||||
providerHeaders.set('authorization', `Bearer ${profile.apiKey!}`)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -355,7 +372,7 @@ function prepareDispatch(
|
||||
requestDigest,
|
||||
modelProfileDigest,
|
||||
url,
|
||||
headers,
|
||||
headers: Object.fromEntries(providerHeaders.entries()),
|
||||
body
|
||||
}
|
||||
}
|
||||
@@ -381,7 +398,7 @@ export function createResolvedModelProfileDigest(
|
||||
profile: ResolvedModelProfile
|
||||
): string {
|
||||
return sha256(
|
||||
JSON.stringify({
|
||||
canonicalJson({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
@@ -391,7 +408,13 @@ export function createResolvedModelProfileDigest(
|
||||
supportsImageInput: profile.supportsImageInput === true,
|
||||
contextWindowTokens: profile.contextWindowTokens ?? null,
|
||||
maximumOutputTokens: profile.maximumOutputTokens ?? null,
|
||||
imageGenerationQuality: profile.imageGenerationQuality ?? null
|
||||
imageGenerationQuality: profile.imageGenerationQuality ?? null,
|
||||
requestHeaders: Object.fromEntries(
|
||||
Object.entries(profile.requestHeaders ?? {}).map(
|
||||
([name, value]) => [name.toLowerCase(), value]
|
||||
)
|
||||
),
|
||||
requestBody: profile.requestBody ?? {}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -415,6 +438,10 @@ function normalizeProviderRequestBody(
|
||||
) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
const request = mergeModelRequestBody(
|
||||
profile.requestBody ?? {},
|
||||
value as Record<string, unknown>
|
||||
)
|
||||
try {
|
||||
assertTextModelRequestPolicy(
|
||||
{
|
||||
@@ -422,7 +449,7 @@ function normalizeProviderRequestBody(
|
||||
model: profile.modelName,
|
||||
supportsImageInput: profile.supportsImageInput === true
|
||||
},
|
||||
value
|
||||
request
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ModelRequestPolicyError)) {
|
||||
@@ -430,8 +457,7 @@ function normalizeProviderRequestBody(
|
||||
}
|
||||
throw requestPolicyError()
|
||||
}
|
||||
const request = { ...value }
|
||||
return Buffer.from(JSON.stringify(request), 'utf8')
|
||||
return Buffer.from(canonicalJson(request), 'utf8')
|
||||
}
|
||||
|
||||
function requestPolicyError(): RemoteModelGatewayError {
|
||||
@@ -445,25 +471,19 @@ function providerUrl(
|
||||
profile: ResolvedModelProfile,
|
||||
path: string
|
||||
): URL {
|
||||
if (
|
||||
profile.protocol === 'openai-images-generations' ||
|
||||
!modelBridgePathMatchesProtocol(profile.protocol, path)
|
||||
) {
|
||||
throw protocolPathError()
|
||||
}
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
if (path !== '/v1/messages') {
|
||||
throw protocolPathError()
|
||||
}
|
||||
return createAnthropicMessagesUrl(profile.baseUrl)
|
||||
}
|
||||
if (profile.protocol === 'openai-chat-completions') {
|
||||
if (
|
||||
path !== '/chat/completions' &&
|
||||
path !== '/v1/chat/completions'
|
||||
) {
|
||||
throw protocolPathError()
|
||||
}
|
||||
return createOpenAIChatCompletionsUrl(profile.baseUrl)
|
||||
}
|
||||
if (profile.protocol === 'openai-responses') {
|
||||
if (path !== '/responses' && path !== '/v1/responses') {
|
||||
throw protocolPathError()
|
||||
}
|
||||
return createOpenAIResponsesUrl(profile.baseUrl)
|
||||
}
|
||||
throw protocolPathError()
|
||||
|
||||
@@ -36,6 +36,12 @@ import {
|
||||
} from '../../shared/goodbuddy-config-tools'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { SubagentScheduler } from '../assistant/subagent-scheduler'
|
||||
import { createAnthropicMessagesUrl } from './anthropic-endpoint'
|
||||
import {
|
||||
createOpenAIChatCompletionsUrl,
|
||||
createOpenAIResponsesUrl
|
||||
} from './openai-endpoint'
|
||||
import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
|
||||
|
||||
const enabled = process.env.GOODBUDDY_RUN_RUNTIME_E2E === '1'
|
||||
const apiKey =
|
||||
@@ -374,6 +380,65 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
120_000
|
||||
)
|
||||
|
||||
it(
|
||||
'adds configured headers and body fields to one real direct model request',
|
||||
async () => {
|
||||
const upstreamUrl =
|
||||
protocol === 'anthropic-messages'
|
||||
? createAnthropicMessagesUrl(baseUrl)
|
||||
: protocol === 'openai-responses'
|
||||
? createOpenAIResponsesUrl(baseUrl)
|
||||
: createOpenAIChatCompletionsUrl(baseUrl)
|
||||
const probe = await createModelRequestProbe({
|
||||
upstreamUrl,
|
||||
headerName: 'x-goodbuddy-e2e',
|
||||
bodyFieldNames: ['goodbuddy_e2e_probe'],
|
||||
removeBodyFieldNames: ['goodbuddy_e2e_probe']
|
||||
})
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey,
|
||||
baseUrl: probe.baseUrl,
|
||||
model: modelName,
|
||||
protocol,
|
||||
authentication: 'api-key',
|
||||
requestHeaders: {
|
||||
'x-goodbuddy-e2e': 'header-present'
|
||||
},
|
||||
requestBody: {
|
||||
goodbuddy_e2e_probe: 'body-present'
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const output = await collectText(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
workMode: 'ask',
|
||||
prompt:
|
||||
'Return exactly this text and nothing else: CUSTOM_REQUEST_E2E_OK'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
expect(output).toContain('CUSTOM_REQUEST_E2E_OK')
|
||||
expect(probe.observations).toEqual([
|
||||
{
|
||||
headerValue: 'header-present',
|
||||
bodyFields: {
|
||||
goodbuddy_e2e_probe: 'body-present'
|
||||
}
|
||||
}
|
||||
])
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await probe.close()
|
||||
}
|
||||
},
|
||||
180_000
|
||||
)
|
||||
|
||||
it(
|
||||
'uses real direct-model process execution and a programming Subagent to repair and verify code',
|
||||
async () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from './agent/deepseek-harness-extension-loader'
|
||||
import type { Stream } from '@agentclientprotocol/sdk'
|
||||
import { isDeepSeekHarnessCompatibleBaseUrl } from '../shared/deepseek-harness-compatibility'
|
||||
import type { ModelRequestHeaders } from '../shared/model-request-customization'
|
||||
import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './agent/deepseek-harness-control-protocol'
|
||||
|
||||
const MAX_DIAGNOSTIC_BYTES = 64 * 1024
|
||||
@@ -45,6 +46,7 @@ export type ControlledHarnessHostConfig = Omit<
|
||||
workspace: string
|
||||
baseUrl: string
|
||||
api: 'openai-completions'
|
||||
requestHeaders?: ModelRequestHeaders
|
||||
maxFrameBytes?: number
|
||||
stream?: Stream
|
||||
dshHome: string
|
||||
@@ -249,6 +251,7 @@ export async function startControlledDeepSeekHarnessHost(
|
||||
apiKeyEnv: config.credentialRefs[0],
|
||||
api: config.api,
|
||||
baseURL: config.baseUrl,
|
||||
headers: config.requestHeaders ?? {},
|
||||
models: [
|
||||
{
|
||||
id: config.model,
|
||||
|
||||
@@ -11,6 +11,12 @@ const profile: ResolvedModelProfile = {
|
||||
authentication: 'api-key',
|
||||
supportsImageInput: true,
|
||||
maximumOutputTokens: 8_192,
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'remote-tenant'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.2
|
||||
},
|
||||
apiKey: 'prompt-scoped-secret'
|
||||
}
|
||||
|
||||
@@ -29,6 +35,12 @@ describe('managed Agent prompt model setup', () => {
|
||||
protocol: 'openai-responses',
|
||||
authentication: 'api-key',
|
||||
apiKey: 'prompt-scoped-secret',
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'remote-tenant'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.2
|
||||
},
|
||||
capabilities: { imageInput: true },
|
||||
limits: {
|
||||
maximumOutputTokens: 8_192,
|
||||
|
||||
@@ -55,6 +55,14 @@ export function createManagedModelBridge(options: {
|
||||
...(selected.apiKey === undefined
|
||||
? {}
|
||||
: { apiKey: selected.apiKey }),
|
||||
...(selected.requestHeaders &&
|
||||
Object.keys(selected.requestHeaders).length > 0
|
||||
? { requestHeaders: selected.requestHeaders }
|
||||
: {}),
|
||||
...(selected.requestBody &&
|
||||
Object.keys(selected.requestBody).length > 0
|
||||
? { requestBody: selected.requestBody }
|
||||
: {}),
|
||||
capabilities: {
|
||||
imageInput: selected.supportsImageInput === true
|
||||
},
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
fixture.options.agentServices.connectionManager.acquire
|
||||
).toHaveBeenCalledWith('host-1', expect.objectContaining({
|
||||
requiredCapabilities: [
|
||||
{ name: 'runtime/acp', exactVersion: 4, critical: true }
|
||||
{ name: 'runtime/acp', exactVersion: 5, critical: true }
|
||||
]
|
||||
}))
|
||||
|
||||
@@ -514,7 +514,7 @@ function capabilities() {
|
||||
return {
|
||||
generation: 7,
|
||||
capabilities: [
|
||||
{ name: 'runtime/acp', version: 4, critical: true }
|
||||
{ name: 'runtime/acp', version: 5, critical: true }
|
||||
],
|
||||
runtimes: [
|
||||
{
|
||||
|
||||
@@ -26,10 +26,14 @@ import type {
|
||||
RemoteRuntimeInstallationIdentity,
|
||||
RemoteRuntimeInstallationManager
|
||||
} from './remote-runtime-installation-manager'
|
||||
import {
|
||||
RUNTIME_ACP_CAPABILITY_NAME,
|
||||
RUNTIME_ACP_CAPABILITY_VERSION
|
||||
} from '../../shared/agent-protocol'
|
||||
|
||||
const RUNTIME_ACP_CAPABILITY = {
|
||||
name: 'runtime/acp',
|
||||
exactVersion: 4,
|
||||
name: RUNTIME_ACP_CAPABILITY_NAME,
|
||||
exactVersion: RUNTIME_ACP_CAPABILITY_VERSION,
|
||||
critical: true
|
||||
} as const
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ function advertisedCapabilities(): DaemonCapabilities {
|
||||
generation: 2,
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true },
|
||||
{ name: 'runtime/acp', version: 4, critical: true },
|
||||
{ name: 'runtime/acp', version: 5, critical: true },
|
||||
{
|
||||
name: 'runtime/model-bridge',
|
||||
version: 1,
|
||||
@@ -250,7 +250,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
|
||||
...advertisedCapabilities(),
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true },
|
||||
{ name: 'runtime/acp', version: 4, critical: true }
|
||||
{ name: 'runtime/acp', version: 5, critical: true }
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -260,7 +260,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
|
||||
...advertisedCapabilities(),
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true },
|
||||
{ name: 'runtime/acp', version: 4, critical: true },
|
||||
{ name: 'runtime/acp', version: 5, critical: true },
|
||||
{
|
||||
name: 'runtime/model-bridge',
|
||||
version: 3,
|
||||
@@ -276,7 +276,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
|
||||
capabilities: [
|
||||
{
|
||||
name: 'runtime/acp',
|
||||
version: 4,
|
||||
version: 5,
|
||||
critical: false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -10,11 +10,12 @@ import type {
|
||||
RemoteRuntimeInstallationIdentity,
|
||||
RemoteRuntimeInstallationManager
|
||||
} from './remote-runtime-installation-manager'
|
||||
|
||||
const RUNTIME_ACP_CAPABILITY = 'runtime/acp'
|
||||
const RUNTIME_ACP_CAPABILITY_VERSION = 4
|
||||
const RUNTIME_MODEL_BRIDGE_CAPABILITY = 'runtime/model-bridge'
|
||||
const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1
|
||||
import {
|
||||
RUNTIME_ACP_CAPABILITY_NAME,
|
||||
RUNTIME_ACP_CAPABILITY_VERSION,
|
||||
RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME,
|
||||
RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION
|
||||
} from '../../shared/agent-protocol'
|
||||
|
||||
export class ManagedRemoteProjectRuntimeValidator
|
||||
implements RemoteProjectRuntimeValidator
|
||||
@@ -178,11 +179,11 @@ function assertRuntimeCapability(
|
||||
installation: RemoteRuntimeInstallationIdentity
|
||||
): void {
|
||||
const acp = capabilities.capabilities.find(
|
||||
(capability) => capability.name === RUNTIME_ACP_CAPABILITY
|
||||
(capability) => capability.name === RUNTIME_ACP_CAPABILITY_NAME
|
||||
)
|
||||
const modelBridge = capabilities.capabilities.find(
|
||||
(capability) =>
|
||||
capability.name === RUNTIME_MODEL_BRIDGE_CAPABILITY
|
||||
capability.name === RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME
|
||||
)
|
||||
const runtimes = capabilities.runtimes.filter(
|
||||
(runtime) => runtime.runtimeId === 'opencode'
|
||||
|
||||
@@ -320,7 +320,7 @@ function validCapabilities(): DaemonCapabilities {
|
||||
return {
|
||||
generation: 11,
|
||||
capabilities: [
|
||||
{ name: 'runtime/acp', version: 4, critical: true }
|
||||
{ name: 'runtime/acp', version: 5, critical: true }
|
||||
],
|
||||
runtimes: [
|
||||
{
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
acpReplayChannelResultSchema,
|
||||
acpReconcilePromptRequestSchema as promptReconciliationRequestSchema,
|
||||
acpReconcilePromptResultSchema as promptReconciliationResultSchema,
|
||||
acpResumeChannelResultSchema
|
||||
acpResumeChannelResultSchema,
|
||||
RUNTIME_ACP_CAPABILITY_NAME,
|
||||
RUNTIME_ACP_CAPABILITY_VERSION,
|
||||
RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME,
|
||||
RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION
|
||||
} from '../../shared/agent-protocol'
|
||||
import { AgentFrameError } from '../../shared/agent-protocol/frame'
|
||||
import {
|
||||
@@ -75,10 +79,6 @@ import type {
|
||||
MainModelBridgePoison
|
||||
} from './main-model-bridge-dispatcher'
|
||||
|
||||
const RUNTIME_ACP_CAPABILITY = 'runtime/acp'
|
||||
const RUNTIME_ACP_CAPABILITY_VERSION = 4
|
||||
const RUNTIME_MODEL_BRIDGE_CAPABILITY = 'runtime/model-bridge'
|
||||
const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1
|
||||
const DEFAULT_CONTROL_TIMEOUT_MS = 15_000
|
||||
const DEFAULT_MAXIMUM_PENDING_CONTROL_REQUESTS = 32
|
||||
const UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS = Date.parse(
|
||||
@@ -1817,13 +1817,14 @@ function assertRuntimeCapability(
|
||||
requireModelBridge: boolean
|
||||
): RuntimeProtocolConnection['capabilities']['runtimes'][number] {
|
||||
const capability = capabilities.capabilities.find(
|
||||
(entry) => entry.name === RUNTIME_ACP_CAPABILITY
|
||||
(entry) => entry.name === RUNTIME_ACP_CAPABILITY_NAME
|
||||
)
|
||||
const runtimes = capabilities.runtimes.filter(
|
||||
(entry) => entry.runtimeId === runtimeId
|
||||
)
|
||||
const modelBridgeCapability = capabilities.capabilities.find(
|
||||
(entry) => entry.name === RUNTIME_MODEL_BRIDGE_CAPABILITY
|
||||
(entry) =>
|
||||
entry.name === RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME
|
||||
)
|
||||
if (
|
||||
!Number.isSafeInteger(capabilities.generation) ||
|
||||
|
||||
@@ -99,6 +99,108 @@ describe('RuntimeSettingsStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates version 20 model profiles with empty request customization', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(settings())
|
||||
const version20 = JSON.parse(
|
||||
await readFile(filePath, 'utf8')
|
||||
) as {
|
||||
version: number
|
||||
modelProfiles: Array<Record<string, unknown>>
|
||||
}
|
||||
version20.version = 20
|
||||
for (const profile of version20.modelProfiles) {
|
||||
delete profile.requestHeaders
|
||||
delete profile.requestBody
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify(version20), 'utf8')
|
||||
|
||||
const migrated = new RuntimeSettingsStore(filePath, cipher, {})
|
||||
await expect(migrated.getPublicSettings()).resolves.toMatchObject({
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
requestHeaders: {},
|
||||
requestBody: {}
|
||||
})
|
||||
]
|
||||
})
|
||||
await expect(migrated.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
requestHeaders: {},
|
||||
requestBody: {}
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips model request headers and body without treating them as credentials', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const profileId = '00000000-0000-4000-8000-000000000010'
|
||||
const updated = await store.update(
|
||||
settings({
|
||||
modelProfiles: [
|
||||
{
|
||||
id: profileId,
|
||||
name: 'Customized model',
|
||||
baseUrl: 'https://provider.example/v1',
|
||||
modelName: 'model-1',
|
||||
protocol: 'openai-responses',
|
||||
authentication: 'none',
|
||||
supportsImageInput: false,
|
||||
imageGenerationQuality: 'auto',
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'tenant-a'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.2,
|
||||
metadata: { source: 'goodbuddy' }
|
||||
},
|
||||
apiKey: { action: 'keep' }
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: profileId,
|
||||
opencodeModelSource: { kind: 'profile', profileId },
|
||||
continueModelSource: { kind: 'profile', profileId }
|
||||
})
|
||||
)
|
||||
|
||||
expect(updated.modelProfiles[0]).toMatchObject({
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'tenant-a'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.2,
|
||||
metadata: { source: 'goodbuddy' }
|
||||
},
|
||||
apiKeyConfigured: false
|
||||
})
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'tenant-a'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.2,
|
||||
metadata: { source: 'goodbuddy' }
|
||||
},
|
||||
apiKey: undefined
|
||||
})
|
||||
]
|
||||
})
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
|
||||
version: number
|
||||
modelProfiles: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(persisted.version).toBe(21)
|
||||
expect(persisted.modelProfiles[0]).toMatchObject({
|
||||
requestHeaders: { 'x-tenant-id': 'tenant-a' },
|
||||
requestBody: { temperature: 0.2 }
|
||||
})
|
||||
expect(persisted.modelProfiles[0]).not.toHaveProperty('apiKey')
|
||||
})
|
||||
|
||||
it('replaces only the untouched legacy BigToken seed', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(settings())
|
||||
@@ -619,7 +721,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
const persisted = JSON.parse(
|
||||
await readFile(filePath, 'utf8')
|
||||
) as Record<string, unknown>
|
||||
expect(persisted.version).toBe(20)
|
||||
expect(persisted.version).toBe(21)
|
||||
expect(persisted).not.toHaveProperty(
|
||||
'deepseekHarnessBinaryPath'
|
||||
)
|
||||
@@ -898,7 +1000,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
|
||||
version: number
|
||||
}
|
||||
expect(persisted.version).toBe(20)
|
||||
expect(persisted.version).toBe(21)
|
||||
})
|
||||
|
||||
it('migrates version 11 and removes the obsolete intranet toggle', async () => {
|
||||
@@ -918,7 +1020,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
intranetCompatibilityEnabled?: boolean
|
||||
}
|
||||
expect(persisted.version).toBe(20)
|
||||
expect(persisted.version).toBe(21)
|
||||
expect(persisted).not.toHaveProperty('intranetCompatibilityEnabled')
|
||||
})
|
||||
|
||||
@@ -1196,7 +1298,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
(JSON.parse(await readFile(filePath, 'utf8')) as {
|
||||
version: number
|
||||
}).version
|
||||
).toBe(20)
|
||||
).toBe(21)
|
||||
})
|
||||
|
||||
it('migrates version 13 with reranking disabled by default', async () => {
|
||||
@@ -1703,7 +1805,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
modelProfiles: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(persisted.version).toBe(20)
|
||||
expect(persisted.version).toBe(21)
|
||||
expect(persisted.modelProfiles).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: imageId,
|
||||
@@ -2005,7 +2107,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
unknown
|
||||
>
|
||||
expect(saved).toMatchObject({
|
||||
version: 20,
|
||||
version: 21,
|
||||
provider: 'model',
|
||||
continueBinaryPath: '',
|
||||
continueMode: 'chat',
|
||||
@@ -2284,7 +2386,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
modelProfiles: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(persisted.version).toBe(20)
|
||||
expect(persisted.version).toBe(21)
|
||||
expect(persisted.modelProfiles[0]).not.toHaveProperty('credential')
|
||||
})
|
||||
|
||||
|
||||
+179
-163
@@ -20,6 +20,8 @@ import {
|
||||
minimumModelContextWindowTokens,
|
||||
modelAuthenticationSchema,
|
||||
modelProtocolSchema,
|
||||
modelRequestBodySchema,
|
||||
modelRequestHeadersSchema,
|
||||
runtimeModelSourceSchema,
|
||||
runtimeCustomizationSettingsSchema,
|
||||
runtimePathSchema,
|
||||
@@ -48,6 +50,7 @@ import {
|
||||
} from './settings-credential-cipher'
|
||||
|
||||
const credentialSchema = encryptedSettingsCredentialSchema.optional()
|
||||
const CURRENT_SETTINGS_VERSION = 21
|
||||
const legacyRuntimeSandboxModeSchema = z.enum([
|
||||
'off',
|
||||
'auto',
|
||||
@@ -276,12 +279,27 @@ const version19StoredSettingsSchema = version18StoredSettingsSchema
|
||||
activeEmbeddingConnectionId: z.string().uuid()
|
||||
})
|
||||
|
||||
const storedSettingsSchema = version19StoredSettingsSchema
|
||||
const version20StoredSettingsSchema = version19StoredSettingsSchema
|
||||
.omit({ version: true })
|
||||
.extend({
|
||||
version: z.literal(20)
|
||||
})
|
||||
|
||||
const storedSettingsSchema = version20StoredSettingsSchema
|
||||
.omit({ version: true, modelProfiles: true })
|
||||
.extend({
|
||||
version: z.literal(CURRENT_SETTINGS_VERSION),
|
||||
modelProfiles: z
|
||||
.array(
|
||||
currentStoredModelProfileSchema.extend({
|
||||
requestHeaders: modelRequestHeadersSchema,
|
||||
requestBody: modelRequestBodySchema
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(20)
|
||||
})
|
||||
|
||||
type StoredSettings = z.infer<typeof storedSettingsSchema>
|
||||
export type RuntimeSettingsRollback = {
|
||||
publicSettings: RuntimeSettings
|
||||
@@ -296,6 +314,9 @@ type Version18StoredSettings = z.infer<
|
||||
type Version19StoredSettings = z.infer<
|
||||
typeof version19StoredSettingsSchema
|
||||
>
|
||||
type Version20StoredSettings = z.infer<
|
||||
typeof version20StoredSettingsSchema
|
||||
>
|
||||
type Version16StoredSettings = z.infer<
|
||||
typeof version16StoredSettingsSchema
|
||||
>
|
||||
@@ -442,6 +463,8 @@ export type ResolvedModelProfile = {
|
||||
contextWindowTokens?: number
|
||||
maximumOutputTokens?: number
|
||||
imageGenerationQuality?: RuntimeSettings['imageGenerationQuality']
|
||||
requestHeaders?: RuntimeSettings['modelProfiles'][number]['requestHeaders']
|
||||
requestBody?: RuntimeSettings['modelProfiles'][number]['requestBody']
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
@@ -468,7 +491,7 @@ type ResolvedModelCredential = {
|
||||
}
|
||||
|
||||
const defaultSettings: StoredSettings = {
|
||||
version: 20,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
provider: defaultRuntimeSettings.provider,
|
||||
modelProfiles: [
|
||||
{
|
||||
@@ -480,7 +503,9 @@ const defaultSettings: StoredSettings = {
|
||||
authentication: defaultRuntimeSettings.modelAuthentication,
|
||||
supportsImageInput: defaultRuntimeSettings.supportsImageInput,
|
||||
imageGenerationQuality:
|
||||
defaultRuntimeSettings.imageGenerationQuality
|
||||
defaultRuntimeSettings.imageGenerationQuality,
|
||||
requestHeaders: {},
|
||||
requestBody: {}
|
||||
}
|
||||
],
|
||||
defaultModelProfileId,
|
||||
@@ -698,7 +723,7 @@ function migrateVersion18(
|
||||
function migrateVersion19(
|
||||
settings: Version19StoredSettings
|
||||
): StoredSettings {
|
||||
return {
|
||||
return migrateVersion20({
|
||||
...settings,
|
||||
version: 20,
|
||||
modelProfiles: settings.modelProfiles.map((profile) =>
|
||||
@@ -722,6 +747,20 @@ function migrateVersion19(
|
||||
}
|
||||
: profile
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function migrateVersion20(
|
||||
settings: Version20StoredSettings
|
||||
): StoredSettings {
|
||||
return {
|
||||
...settings,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
modelProfiles: settings.modelProfiles.map((profile) => ({
|
||||
...profile,
|
||||
requestHeaders: {},
|
||||
requestBody: {}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,6 +1053,119 @@ function migrateVersion9(
|
||||
})
|
||||
}
|
||||
|
||||
function migrateVersion3(
|
||||
settings: z.infer<typeof version3StoredSettingsSchema>
|
||||
): StoredSettings {
|
||||
return migrateVersion4({
|
||||
...settings,
|
||||
version: 4,
|
||||
continueMode: 'chat'
|
||||
})
|
||||
}
|
||||
|
||||
function migrateVersion2(
|
||||
settings: z.infer<typeof version2StoredSettingsSchema>
|
||||
): StoredSettings {
|
||||
return migrateVersion4({
|
||||
version: 4,
|
||||
provider: settings.provider,
|
||||
modelBaseUrl: settings.modelBaseUrl,
|
||||
modelName: settings.modelName,
|
||||
opencodeBaseUrl: settings.opencodeBaseUrl,
|
||||
opencodeEmbedded: settings.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
settings.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: settings.workspacePath,
|
||||
credential: settings.credential,
|
||||
toolApproval: settings.toolApproval
|
||||
})
|
||||
}
|
||||
|
||||
function migrateLegacySettings(
|
||||
settings: z.infer<typeof legacyStoredSettingsSchema>
|
||||
): StoredSettings {
|
||||
return migrateVersion4({
|
||||
version: 4,
|
||||
provider:
|
||||
settings.provider === 'bigtoken' ? 'model' : settings.provider,
|
||||
modelBaseUrl: settings.bigtokenBaseUrl,
|
||||
modelName: settings.bigtokenModel,
|
||||
opencodeBaseUrl: settings.opencodeBaseUrl,
|
||||
opencodeEmbedded: settings.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
settings.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: settings.workspacePath,
|
||||
credential: settings.credential,
|
||||
toolApproval: settings.toolApproval
|
||||
})
|
||||
}
|
||||
|
||||
function parseStoredSettings(value: unknown): StoredSettings {
|
||||
const version =
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'version' in value &&
|
||||
typeof value.version === 'number'
|
||||
? value.version
|
||||
: undefined
|
||||
switch (version) {
|
||||
case CURRENT_SETTINGS_VERSION:
|
||||
return storedSettingsSchema.parse(value)
|
||||
case 20:
|
||||
return migrateVersion20(version20StoredSettingsSchema.parse(value))
|
||||
case 19:
|
||||
return migrateVersion19(version19StoredSettingsSchema.parse(value))
|
||||
case 18:
|
||||
return migrateVersion18(version18StoredSettingsSchema.parse(value))
|
||||
case 17:
|
||||
return migrateVersion17(version17StoredSettingsSchema.parse(value))
|
||||
case 16:
|
||||
return migrateVersion16(version16StoredSettingsSchema.parse(value))
|
||||
case 15:
|
||||
return migrateVersion15(version15StoredSettingsSchema.parse(value))
|
||||
case 14:
|
||||
return migrateVersion14(version14StoredSettingsSchema.parse(value))
|
||||
case 13:
|
||||
return migrateVersion13(version13StoredSettingsSchema.parse(value))
|
||||
case 12:
|
||||
return migrateVersion12(version12StoredSettingsSchema.parse(value))
|
||||
case 11:
|
||||
return migrateVersion11(version11StoredSettingsSchema.parse(value))
|
||||
case 10:
|
||||
return migrateVersion10(version10StoredSettingsSchema.parse(value))
|
||||
case 9:
|
||||
return migrateVersion9(version9StoredSettingsSchema.parse(value))
|
||||
case 8:
|
||||
return migrateVersion8(version8StoredSettingsSchema.parse(value))
|
||||
case 7:
|
||||
return migrateVersion7(version7StoredSettingsSchema.parse(value))
|
||||
case 6:
|
||||
return migrateVersion6(version6StoredSettingsSchema.parse(value))
|
||||
case 5:
|
||||
return migrateVersion5(version5StoredSettingsSchema.parse(value))
|
||||
case 4:
|
||||
return migrateVersion4(version4StoredSettingsSchema.parse(value))
|
||||
case 3:
|
||||
return migrateVersion3(version3StoredSettingsSchema.parse(value))
|
||||
case 2:
|
||||
return migrateVersion2(version2StoredSettingsSchema.parse(value))
|
||||
default:
|
||||
return migrateLegacySettings(
|
||||
legacyStoredSettingsSchema.parse(value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class RuntimeSettingsStore {
|
||||
private settings?: StoredSettings
|
||||
private settingsLoad?: Promise<StoredSettings>
|
||||
@@ -1044,165 +1196,11 @@ export class RuntimeSettingsStore {
|
||||
const parsed: unknown = JSON.parse(contents)
|
||||
assertSupportedSettingsVersion(
|
||||
parsed,
|
||||
20,
|
||||
CURRENT_SETTINGS_VERSION,
|
||||
(version) =>
|
||||
`当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
|
||||
)
|
||||
const current = storedSettingsSchema.safeParse(parsed)
|
||||
const version19 =
|
||||
version19StoredSettingsSchema.safeParse(parsed)
|
||||
if (current.success) {
|
||||
this.settings = current.data
|
||||
} else if (version19.success) {
|
||||
this.settings = migrateVersion19(version19.data)
|
||||
} else {
|
||||
const version18 =
|
||||
version18StoredSettingsSchema.safeParse(parsed)
|
||||
if (version18.success) {
|
||||
this.settings = migrateVersion18(version18.data)
|
||||
} else {
|
||||
const version17 =
|
||||
version17StoredSettingsSchema.safeParse(parsed)
|
||||
if (version17.success) {
|
||||
this.settings = migrateVersion17(version17.data)
|
||||
} else {
|
||||
const version16 =
|
||||
version16StoredSettingsSchema.safeParse(parsed)
|
||||
if (version16.success) {
|
||||
this.settings = migrateVersion16(version16.data)
|
||||
} else {
|
||||
const version15 =
|
||||
version15StoredSettingsSchema.safeParse(parsed)
|
||||
if (version15.success) {
|
||||
this.settings = migrateVersion15(version15.data)
|
||||
} else {
|
||||
const version14 =
|
||||
version14StoredSettingsSchema.safeParse(parsed)
|
||||
if (version14.success) {
|
||||
this.settings = migrateVersion14(version14.data)
|
||||
} else {
|
||||
const version13 =
|
||||
version13StoredSettingsSchema.safeParse(parsed)
|
||||
if (version13.success) {
|
||||
this.settings = migrateVersion13(version13.data)
|
||||
} else {
|
||||
const version12 =
|
||||
version12StoredSettingsSchema.safeParse(parsed)
|
||||
if (version12.success) {
|
||||
this.settings = migrateVersion12(version12.data)
|
||||
} else {
|
||||
const version11 =
|
||||
version11StoredSettingsSchema.safeParse(parsed)
|
||||
if (version11.success) {
|
||||
this.settings = migrateVersion11(version11.data)
|
||||
} else {
|
||||
const version10 =
|
||||
version10StoredSettingsSchema.safeParse(parsed)
|
||||
if (version10.success) {
|
||||
this.settings = migrateVersion10(version10.data)
|
||||
} else {
|
||||
const version9 =
|
||||
version9StoredSettingsSchema.safeParse(parsed)
|
||||
if (version9.success) {
|
||||
this.settings = migrateVersion9(version9.data)
|
||||
} else {
|
||||
const version8 =
|
||||
version8StoredSettingsSchema.safeParse(parsed)
|
||||
if (version8.success) {
|
||||
this.settings = migrateVersion8(version8.data)
|
||||
} else {
|
||||
const version7 =
|
||||
version7StoredSettingsSchema.safeParse(parsed)
|
||||
if (version7.success) {
|
||||
this.settings = migrateVersion7(version7.data)
|
||||
} else {
|
||||
const version6 =
|
||||
version6StoredSettingsSchema.safeParse(parsed)
|
||||
if (version6.success) {
|
||||
this.settings = migrateVersion6(version6.data)
|
||||
} else {
|
||||
const version5 =
|
||||
version5StoredSettingsSchema.safeParse(parsed)
|
||||
if (version5.success) {
|
||||
this.settings = migrateVersion5(version5.data)
|
||||
} else {
|
||||
const version4 =
|
||||
version4StoredSettingsSchema.safeParse(parsed)
|
||||
if (version4.success) {
|
||||
this.settings = migrateVersion4(version4.data)
|
||||
} else {
|
||||
const version3 =
|
||||
version3StoredSettingsSchema.safeParse(parsed)
|
||||
if (version3.success) {
|
||||
this.settings = migrateVersion4({
|
||||
...version3.data,
|
||||
version: 4,
|
||||
continueMode: 'chat'
|
||||
})
|
||||
} else {
|
||||
const version2 =
|
||||
version2StoredSettingsSchema.safeParse(parsed)
|
||||
if (version2.success) {
|
||||
this.settings = migrateVersion4({
|
||||
version: 4,
|
||||
provider: version2.data.provider,
|
||||
modelBaseUrl: version2.data.modelBaseUrl,
|
||||
modelName: version2.data.modelName,
|
||||
opencodeBaseUrl: version2.data.opencodeBaseUrl,
|
||||
opencodeEmbedded: version2.data.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
version2.data.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: version2.data.workspacePath,
|
||||
credential: version2.data.credential,
|
||||
toolApproval: version2.data.toolApproval
|
||||
})
|
||||
} else {
|
||||
const legacy =
|
||||
legacyStoredSettingsSchema.parse(parsed)
|
||||
this.settings = migrateVersion4({
|
||||
version: 4,
|
||||
provider:
|
||||
legacy.provider === 'bigtoken'
|
||||
? 'model'
|
||||
: legacy.provider,
|
||||
modelBaseUrl: legacy.bigtokenBaseUrl,
|
||||
modelName: legacy.bigtokenModel,
|
||||
opencodeBaseUrl: legacy.opencodeBaseUrl,
|
||||
opencodeEmbedded: legacy.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
legacy.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: legacy.workspacePath,
|
||||
credential: legacy.credential,
|
||||
toolApproval: legacy.toolApproval
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.settings = parseStoredSettings(parsed)
|
||||
this.settings = normalizeStoredSettings(this.settings)
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedSettingsVersionError) {
|
||||
@@ -1501,6 +1499,8 @@ export class RuntimeSettingsStore {
|
||||
contextWindowTokens?: number
|
||||
maximumOutputTokens?: number
|
||||
imageGenerationQuality: RuntimeSettings['imageGenerationQuality']
|
||||
requestHeaders: RuntimeSettings['modelProfiles'][number]['requestHeaders']
|
||||
requestBody: RuntimeSettings['modelProfiles'][number]['requestBody']
|
||||
credentialSource: RuntimeSettings['credentialSource']
|
||||
} {
|
||||
const profile =
|
||||
@@ -1563,6 +1563,8 @@ export class RuntimeSettingsStore {
|
||||
contextWindowTokens: profile.contextWindowTokens,
|
||||
maximumOutputTokens: profile.maximumOutputTokens,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
requestHeaders: profile.requestHeaders,
|
||||
requestBody: profile.requestBody,
|
||||
credentialSource: credential.source
|
||||
}
|
||||
}
|
||||
@@ -1587,6 +1589,8 @@ export class RuntimeSettingsStore {
|
||||
contextWindowTokens: effective.contextWindowTokens,
|
||||
maximumOutputTokens: effective.maximumOutputTokens,
|
||||
imageGenerationQuality: effective.imageGenerationQuality,
|
||||
requestHeaders: effective.requestHeaders,
|
||||
requestBody: effective.requestBody,
|
||||
apiKey: effective.apiKey
|
||||
}
|
||||
}
|
||||
@@ -1605,6 +1609,8 @@ export class RuntimeSettingsStore {
|
||||
contextWindowTokens: profile.contextWindowTokens,
|
||||
maximumOutputTokens: profile.maximumOutputTokens,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
requestHeaders: profile.requestHeaders,
|
||||
requestBody: profile.requestBody,
|
||||
apiKey: credential.activeApiKey
|
||||
}
|
||||
})
|
||||
@@ -1690,6 +1696,8 @@ export class RuntimeSettingsStore {
|
||||
imageGenerationQuality:
|
||||
resolved.imageGenerationQuality ??
|
||||
defaultRuntimeSettings.imageGenerationQuality,
|
||||
requestHeaders: resolved.requestHeaders ?? {},
|
||||
requestBody: resolved.requestBody ?? {},
|
||||
apiKeyConfigured: credential.configured,
|
||||
credentialSource: credential.source
|
||||
}
|
||||
@@ -1712,6 +1720,8 @@ export class RuntimeSettingsStore {
|
||||
imageGenerationQuality:
|
||||
profile.imageGenerationQuality ??
|
||||
defaultRuntimeSettings.imageGenerationQuality,
|
||||
requestHeaders: profile.requestHeaders,
|
||||
requestBody: profile.requestBody,
|
||||
apiKeyConfigured: credential.configured,
|
||||
credentialSource: credential.source
|
||||
}
|
||||
@@ -2014,6 +2024,8 @@ export class RuntimeSettingsStore {
|
||||
contextWindowTokens: profile.contextWindowTokens,
|
||||
maximumOutputTokens: profile.maximumOutputTokens,
|
||||
imageGenerationQuality: input.imageGenerationQuality,
|
||||
requestHeaders: profile.requestHeaders,
|
||||
requestBody: profile.requestBody,
|
||||
apiKey: input.apiKey
|
||||
}
|
||||
: {
|
||||
@@ -2027,6 +2039,8 @@ export class RuntimeSettingsStore {
|
||||
contextWindowTokens: profile.contextWindowTokens,
|
||||
maximumOutputTokens: profile.maximumOutputTokens,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
requestHeaders: profile.requestHeaders,
|
||||
requestBody: profile.requestBody,
|
||||
apiKey: { action: 'keep' as const }
|
||||
}
|
||||
)
|
||||
@@ -2078,7 +2092,9 @@ export class RuntimeSettingsStore {
|
||||
supportsImageInput: profile.supportsImageInput ?? false,
|
||||
contextWindowTokens: profile.contextWindowTokens,
|
||||
maximumOutputTokens: profile.maximumOutputTokens,
|
||||
imageGenerationQuality: profile.imageGenerationQuality
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
requestHeaders: profile.requestHeaders ?? {},
|
||||
requestBody: profile.requestBody ?? {}
|
||||
}
|
||||
if (
|
||||
profile.apiKey.action === 'keep' &&
|
||||
@@ -2346,7 +2362,7 @@ export class RuntimeSettingsStore {
|
||||
|
||||
const next: StoredSettings = {
|
||||
...current,
|
||||
version: 20,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
provider: input.provider,
|
||||
modelProfiles,
|
||||
defaultModelProfileId,
|
||||
@@ -2407,7 +2423,7 @@ export class RuntimeSettingsStore {
|
||||
const current = await this.load()
|
||||
const next: StoredSettings = {
|
||||
...current,
|
||||
version: 20,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
runtimeCustomization: parsed
|
||||
}
|
||||
await writeJsonFileAtomically(this.filePath, next)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '文本对话',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user