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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user