chore: prepare GoodBuddy 0.8.5
This commit is contained in:
@@ -19,4 +19,14 @@ describe('Anthropic endpoint normalization', () => {
|
||||
createAnthropicMessagesUrl('https://model.example/v1').toString()
|
||||
).toBe('https://model.example/v1/messages')
|
||||
})
|
||||
|
||||
it('keeps a gateway query and intranet path prefix on the request URL', () => {
|
||||
expect(
|
||||
createAnthropicMessagesUrl(
|
||||
'http://10.0.0.5:8000/gateway?api-version=2024-02-01'
|
||||
).toString()
|
||||
).toBe(
|
||||
'http://10.0.0.5:8000/gateway/v1/messages?api-version=2024-02-01'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,11 +2,14 @@ export function createAnthropicApiBaseUrl(baseUrl: string): string {
|
||||
const url = new URL(baseUrl)
|
||||
const path = url.pathname.replace(/\/+$/, '')
|
||||
url.pathname = path.endsWith('/v1') ? path : `${path}/v1`
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString().replace(/\/$/, '')
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function createAnthropicMessagesUrl(baseUrl: string): URL {
|
||||
return new URL(`${createAnthropicApiBaseUrl(baseUrl)}/messages`)
|
||||
const url = new URL(baseUrl)
|
||||
const path = url.pathname.replace(/\/+$/u, '')
|
||||
url.pathname = `${path.endsWith('/v1') ? path : `${path}/v1`}/messages`
|
||||
url.hash = ''
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ function settings(
|
||||
continueMode: 'chat',
|
||||
runtimeSandboxMode: 'off',
|
||||
subagentSmartRoutingEnabled: false,
|
||||
intranetCompatibilityEnabled: true,
|
||||
knowledgeEmbeddingEnabled: false,
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
'http://127.0.0.1:11434/v1/embeddings',
|
||||
@@ -178,7 +177,19 @@ describe('createAgentRuntime model compatibility', () => {
|
||||
modelProtocol: 'openai-images-generations',
|
||||
modelAuthentication: 'api-key',
|
||||
imageGenerationQuality: 'high',
|
||||
apiKey: 'secret'
|
||||
apiKey: 'secret',
|
||||
modelProfiles: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
name: '默认图像模型',
|
||||
baseUrl: 'https://bigtoken.ai/v1',
|
||||
modelName: 'gpt-image-2',
|
||||
protocol: 'openai-images-generations',
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'high',
|
||||
apiKey: 'secret'
|
||||
}
|
||||
]
|
||||
})
|
||||
const runtime = createAgentRuntime(process.cwd(), imageSettings)
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
|
||||
@@ -161,11 +161,17 @@ export function createAgentRuntime(
|
||||
})
|
||||
}
|
||||
|
||||
const defaultModelProfile =
|
||||
settings?.modelProfiles.find(
|
||||
(profile) => profile.id === settings.defaultModelProfileId
|
||||
) ?? settings?.modelProfiles[0]
|
||||
const modelApiKey =
|
||||
defaultModelProfile?.apiKey ||
|
||||
settings?.apiKey ||
|
||||
process.env.GOODBUDDY_MODEL_API_KEY?.trim() ||
|
||||
process.env.GOODBUDDY_BIGTOKEN_API_KEY?.trim()
|
||||
const modelAuthentication =
|
||||
defaultModelProfile?.authentication ??
|
||||
settings?.modelAuthentication ??
|
||||
defaultRuntimeSettings.modelAuthentication
|
||||
if (
|
||||
@@ -176,20 +182,24 @@ export function createAgentRuntime(
|
||||
return new ModelAgentRuntime({
|
||||
apiKey: modelApiKey ?? '',
|
||||
baseUrl:
|
||||
defaultModelProfile?.baseUrl ||
|
||||
settings?.modelBaseUrl ||
|
||||
process.env.GOODBUDDY_MODEL_BASE_URL?.trim() ||
|
||||
process.env.GOODBUDDY_BIGTOKEN_BASE_URL?.trim() ||
|
||||
defaultRuntimeSettings.modelBaseUrl,
|
||||
model:
|
||||
defaultModelProfile?.modelName ||
|
||||
settings?.modelName ||
|
||||
process.env.GOODBUDDY_MODEL_NAME?.trim() ||
|
||||
process.env.GOODBUDDY_BIGTOKEN_MODEL?.trim() ||
|
||||
defaultRuntimeSettings.modelName,
|
||||
protocol:
|
||||
defaultModelProfile?.protocol ??
|
||||
settings?.modelProtocol ??
|
||||
defaultRuntimeSettings.modelProtocol,
|
||||
authentication: modelAuthentication,
|
||||
imageGenerationQuality:
|
||||
defaultModelProfile?.imageGenerationQuality ??
|
||||
settings?.imageGenerationQuality ??
|
||||
defaultRuntimeSettings.imageGenerationQuality,
|
||||
skillInstructions: capabilities.skillInstructions,
|
||||
|
||||
@@ -34,7 +34,7 @@ function createMultimodalToolResult(): ModelToolResult {
|
||||
}
|
||||
}
|
||||
|
||||
function createEventStream(text: string): string {
|
||||
function createEventStream(text: string, thinking?: string): string {
|
||||
return [
|
||||
'event: message_start',
|
||||
`data: ${JSON.stringify({
|
||||
@@ -50,6 +50,16 @@ function createEventStream(text: string): string {
|
||||
}
|
||||
})}`,
|
||||
'',
|
||||
...(thinking
|
||||
? [
|
||||
'event: content_block_delta',
|
||||
`data: ${JSON.stringify({
|
||||
type: 'content_block_delta',
|
||||
delta: { type: 'thinking_delta', thinking }
|
||||
})}`,
|
||||
''
|
||||
]
|
||||
: []),
|
||||
'event: content_block_delta',
|
||||
`data: ${JSON.stringify({
|
||||
type: 'content_block_delta',
|
||||
@@ -69,8 +79,21 @@ function createEventStream(text: string): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function createResponsesEventStream(text: string): string {
|
||||
function createResponsesEventStream(
|
||||
text: string,
|
||||
reasoning?: string
|
||||
): string {
|
||||
return [
|
||||
...(reasoning
|
||||
? [
|
||||
'event: response.reasoning_summary_text.delta',
|
||||
`data: ${JSON.stringify({
|
||||
type: 'response.reasoning_summary_text.delta',
|
||||
delta: reasoning
|
||||
})}`,
|
||||
''
|
||||
]
|
||||
: []),
|
||||
'event: response.output_text.delta',
|
||||
`data: ${JSON.stringify({
|
||||
type: 'response.output_text.delta',
|
||||
@@ -154,7 +177,7 @@ describe('ModelAgentRuntime', () => {
|
||||
|
||||
it('uses the Anthropic messages endpoint and streams text deltas', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||||
return new Response(createEventStream('真实模型回答'), {
|
||||
return new Response(createEventStream('真实模型回答', '先分析问题'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' }
|
||||
})
|
||||
@@ -198,6 +221,12 @@ describe('ModelAgentRuntime', () => {
|
||||
})
|
||||
expect(body.system).toContain('# 文档写作')
|
||||
expect(body.system).toContain('Trusted specialist system instruction.')
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'reasoning',
|
||||
delta: '先分析问题'
|
||||
})
|
||||
)
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
@@ -427,10 +456,13 @@ describe('ModelAgentRuntime', () => {
|
||||
|
||||
it('uses the OpenAI Responses endpoint and streams output text', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
new Response(createResponsesEventStream('Responses 回答'), {
|
||||
new Response(
|
||||
createResponsesEventStream('Responses 回答', 'Responses 推理'),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' }
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: 'test-key',
|
||||
@@ -468,6 +500,12 @@ describe('ModelAgentRuntime', () => {
|
||||
expect.objectContaining({ role: 'user', content: '你好' })
|
||||
]
|
||||
})
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'reasoning',
|
||||
delta: 'Responses 推理'
|
||||
})
|
||||
)
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
|
||||
@@ -82,6 +82,7 @@ type ModelToolCall = {
|
||||
|
||||
type ModelToolResponse = {
|
||||
text: string
|
||||
reasoning: string
|
||||
toolCalls: ModelToolCall[]
|
||||
assistantMessage?: Record<string, unknown>
|
||||
responsesOutput?: Array<Record<string, unknown>>
|
||||
@@ -162,6 +163,25 @@ function getAnthropicTextDelta(value: unknown): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getAnthropicReasoningDelta(value: unknown): string | undefined {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!('type' in value) ||
|
||||
value.type !== 'content_block_delta' ||
|
||||
!('delta' in value) ||
|
||||
!value.delta ||
|
||||
typeof value.delta !== 'object' ||
|
||||
!('type' in value.delta) ||
|
||||
value.delta.type !== 'thinking_delta' ||
|
||||
!('thinking' in value.delta) ||
|
||||
typeof value.delta.thinking !== 'string'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return value.delta.thinking
|
||||
}
|
||||
|
||||
function getOpenAITextDelta(value: unknown): string | undefined {
|
||||
if (
|
||||
!value ||
|
||||
@@ -186,6 +206,22 @@ function getOpenAITextDelta(value: unknown): string | undefined {
|
||||
return first.delta.content
|
||||
}
|
||||
|
||||
function getOpenAIReasoningDelta(value: unknown): string | undefined {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!('choices' in value) ||
|
||||
!Array.isArray(value.choices)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const first = getRecord(value.choices[0])
|
||||
const delta = getRecord(first?.delta)
|
||||
const reasoning =
|
||||
delta?.reasoning_content ?? delta?.reasoning ?? delta?.thinking
|
||||
return typeof reasoning === 'string' ? reasoning : undefined
|
||||
}
|
||||
|
||||
function getOpenAIResponsesTextDelta(
|
||||
value: unknown
|
||||
): string | undefined {
|
||||
@@ -202,6 +238,19 @@ function getOpenAIResponsesTextDelta(
|
||||
return value.delta
|
||||
}
|
||||
|
||||
function getOpenAIResponsesReasoningDelta(
|
||||
value: unknown
|
||||
): string | undefined {
|
||||
const event = getRecord(value)
|
||||
if (
|
||||
event?.type !== 'response.reasoning_summary_text.delta' &&
|
||||
event?.type !== 'response.reasoning_text.delta'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return typeof event.delta === 'string' ? event.delta : undefined
|
||||
}
|
||||
|
||||
function getRecord(
|
||||
value: unknown
|
||||
): Record<string, unknown> | undefined {
|
||||
@@ -647,6 +696,7 @@ function parseModelToolResponse(
|
||||
throw new Error('Anthropic 模型接口未返回 content')
|
||||
}
|
||||
const text: string[] = []
|
||||
const reasoning: string[] = []
|
||||
const toolCalls: ModelToolCall[] = []
|
||||
for (const block of payload.content) {
|
||||
const record = getRecord(block)
|
||||
@@ -655,6 +705,11 @@ function parseModelToolResponse(
|
||||
}
|
||||
if (record.type === 'text' && typeof record.text === 'string') {
|
||||
text.push(record.text)
|
||||
} else if (
|
||||
record.type === 'thinking' &&
|
||||
typeof record.thinking === 'string'
|
||||
) {
|
||||
reasoning.push(record.thinking)
|
||||
} else if (record.type === 'tool_use') {
|
||||
const identity = parseToolCallIdentity(record.id, record.name)
|
||||
toolCalls.push({
|
||||
@@ -665,6 +720,7 @@ function parseModelToolResponse(
|
||||
}
|
||||
return {
|
||||
text: text.join(''),
|
||||
reasoning: reasoning.join(''),
|
||||
toolCalls,
|
||||
assistantMessage: {
|
||||
role: 'assistant',
|
||||
@@ -696,6 +752,7 @@ function parseModelToolResponse(
|
||||
throw new Error('OpenAI Responses 接口返回格式无效')
|
||||
}
|
||||
const text: string[] = []
|
||||
const reasoning: string[] = []
|
||||
const toolCalls: ModelToolCall[] = []
|
||||
for (const item of payload.output) {
|
||||
const output = getRecord(item)
|
||||
@@ -712,6 +769,20 @@ function parseModelToolResponse(
|
||||
text.push(content.text)
|
||||
}
|
||||
}
|
||||
} else if (output.type === 'reasoning') {
|
||||
for (const part of [
|
||||
...(Array.isArray(output.summary) ? output.summary : []),
|
||||
...(Array.isArray(output.content) ? output.content : [])
|
||||
]) {
|
||||
const content = getRecord(part)
|
||||
if (
|
||||
(content?.type === 'summary_text' ||
|
||||
content?.type === 'reasoning_text') &&
|
||||
typeof content.text === 'string'
|
||||
) {
|
||||
reasoning.push(content.text)
|
||||
}
|
||||
}
|
||||
} else if (output.type === 'function_call') {
|
||||
const identity = parseToolCallIdentity(
|
||||
output.call_id,
|
||||
@@ -725,6 +796,7 @@ function parseModelToolResponse(
|
||||
}
|
||||
return {
|
||||
text: text.join(''),
|
||||
reasoning: reasoning.join(''),
|
||||
toolCalls,
|
||||
responsesOutput: payload.output.flatMap((item) => {
|
||||
const output = getRecord(item)
|
||||
@@ -743,6 +815,10 @@ function parseModelToolResponse(
|
||||
throw new Error('OpenAI 模型接口未返回 assistant message')
|
||||
}
|
||||
const text = typeof message.content === 'string' ? message.content : ''
|
||||
const reasoningValue =
|
||||
message.reasoning_content ?? message.reasoning ?? message.thinking
|
||||
const reasoning =
|
||||
typeof reasoningValue === 'string' ? reasoningValue : ''
|
||||
const toolCalls: ModelToolCall[] = []
|
||||
if (message.tool_calls !== undefined) {
|
||||
if (!Array.isArray(message.tool_calls)) {
|
||||
@@ -766,6 +842,7 @@ function parseModelToolResponse(
|
||||
}
|
||||
return {
|
||||
text,
|
||||
reasoning,
|
||||
toolCalls,
|
||||
assistantMessage: {
|
||||
role: 'assistant',
|
||||
@@ -783,6 +860,7 @@ function parseStreamBlock(
|
||||
protocol: ModelProtocol
|
||||
): {
|
||||
delta?: string
|
||||
reasoningDelta?: string
|
||||
stopped: boolean
|
||||
usage?: ModelUsageUpdate
|
||||
} {
|
||||
@@ -840,6 +918,12 @@ function parseStreamBlock(
|
||||
: protocol === 'openai-responses'
|
||||
? getOpenAIResponsesTextDelta(event)
|
||||
: getOpenAITextDelta(event),
|
||||
reasoningDelta:
|
||||
protocol === 'anthropic-messages'
|
||||
? getAnthropicReasoningDelta(event)
|
||||
: protocol === 'openai-responses'
|
||||
? getOpenAIResponsesReasoningDelta(event)
|
||||
: getOpenAIReasoningDelta(event),
|
||||
usage: getUsageUpdate(
|
||||
event,
|
||||
protocol === 'anthropic-messages' ? 'anthropic' : 'openai'
|
||||
@@ -1380,6 +1464,13 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
if (usageEvent) {
|
||||
yield usageEvent
|
||||
}
|
||||
if (response.reasoning) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'reasoning',
|
||||
delta: response.reasoning
|
||||
}
|
||||
}
|
||||
if (response.text) {
|
||||
answer += response.text
|
||||
if (Buffer.byteLength(answer) > 1024 * 1024) {
|
||||
@@ -1748,6 +1839,13 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
if (parsed.usage) {
|
||||
applyUsageUpdate(usage, parsed.usage)
|
||||
}
|
||||
if (parsed.reasoningDelta) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'reasoning',
|
||||
delta: parsed.reasoningDelta
|
||||
}
|
||||
}
|
||||
const { delta } = parsed
|
||||
if (delta) {
|
||||
answer += delta
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createOpenAIApiBaseUrl,
|
||||
createOpenAIChatCompletionsUrl,
|
||||
createOpenAIImagesGenerationsUrl,
|
||||
createOpenAIResponsesUrl
|
||||
} from './openai-endpoint'
|
||||
|
||||
describe('OpenAI endpoint normalization', () => {
|
||||
it.each([
|
||||
['https://model.example/v1', 'https://model.example/v1'],
|
||||
['https://model.example/v1/', 'https://model.example/v1'],
|
||||
['http://10.0.0.5:8000/proxy/v1', 'http://10.0.0.5:8000/proxy/v1']
|
||||
])('normalizes %s to an API root', (input, expected) => {
|
||||
expect(createOpenAIApiBaseUrl(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it('appends API paths onto an intranet path prefix', () => {
|
||||
const baseUrl = 'http://192.168.1.50:8000/openai/v1'
|
||||
expect(createOpenAIChatCompletionsUrl(baseUrl).toString()).toBe(
|
||||
'http://192.168.1.50:8000/openai/v1/chat/completions'
|
||||
)
|
||||
expect(createOpenAIResponsesUrl(baseUrl).toString()).toBe(
|
||||
'http://192.168.1.50:8000/openai/v1/responses'
|
||||
)
|
||||
expect(createOpenAIImagesGenerationsUrl(baseUrl).toString()).toBe(
|
||||
'http://192.168.1.50:8000/openai/v1/images/generations'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a gateway query on base and request URLs', () => {
|
||||
const baseUrl = 'https://gateway.example/v1?api-version=2024-02-01'
|
||||
expect(createOpenAIApiBaseUrl(baseUrl)).toBe(
|
||||
'https://gateway.example/v1?api-version=2024-02-01'
|
||||
)
|
||||
expect(createOpenAIChatCompletionsUrl(baseUrl).toString()).toBe(
|
||||
'https://gateway.example/v1/chat/completions?api-version=2024-02-01'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,33 @@
|
||||
export function createOpenAIApiBaseUrl(baseUrl: string): string {
|
||||
const url = new URL(baseUrl)
|
||||
url.pathname = url.pathname.replace(/\/+$/u, '')
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString().replace(/\/$/u, '')
|
||||
const normalized = url.toString()
|
||||
return url.pathname === '/'
|
||||
? normalized.replace(/\/(?=[?#]|$)/u, '')
|
||||
: normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an API path while preserving any query the base URL carries, which
|
||||
* gateways such as Azure OpenAI require. Child runtimes cannot forward a query
|
||||
* through their own base URL, so they keep using createOpenAIApiBaseUrl.
|
||||
*/
|
||||
function createOpenAIRequestUrl(baseUrl: string, path: string): URL {
|
||||
const url = new URL(baseUrl)
|
||||
url.pathname = `${url.pathname.replace(/\/+$/u, '')}${path}`
|
||||
url.hash = ''
|
||||
return url
|
||||
}
|
||||
|
||||
export function createOpenAIChatCompletionsUrl(baseUrl: string): URL {
|
||||
return new URL(`${createOpenAIApiBaseUrl(baseUrl)}/chat/completions`)
|
||||
return createOpenAIRequestUrl(baseUrl, '/chat/completions')
|
||||
}
|
||||
|
||||
export function createOpenAIResponsesUrl(baseUrl: string): URL {
|
||||
return new URL(`${createOpenAIApiBaseUrl(baseUrl)}/responses`)
|
||||
return createOpenAIRequestUrl(baseUrl, '/responses')
|
||||
}
|
||||
|
||||
export function createOpenAIImagesGenerationsUrl(baseUrl: string): URL {
|
||||
return new URL(`${createOpenAIApiBaseUrl(baseUrl)}/images/generations`)
|
||||
return createOpenAIRequestUrl(baseUrl, '/images/generations')
|
||||
}
|
||||
|
||||
@@ -1333,6 +1333,32 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
permissionEvent(),
|
||||
permissionEvent(),
|
||||
completedToolEvent(),
|
||||
{
|
||||
id: 'event-reasoning-part',
|
||||
type: 'message.part.updated',
|
||||
properties: {
|
||||
sessionID: 'session-1',
|
||||
part: {
|
||||
id: 'part-reasoning',
|
||||
sessionID: 'session-1',
|
||||
messageID: 'message-1',
|
||||
type: 'reasoning',
|
||||
text: '',
|
||||
time: { start: 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'event-reasoning',
|
||||
type: 'message.part.delta',
|
||||
properties: {
|
||||
sessionID: 'session-1',
|
||||
messageID: 'message-1',
|
||||
partID: 'part-reasoning',
|
||||
field: 'text',
|
||||
delta: 'reasoning output'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'event-text',
|
||||
type: 'message.part.delta',
|
||||
@@ -1368,6 +1394,12 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
directory: process.cwd(),
|
||||
reply: 'once'
|
||||
})
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'reasoning',
|
||||
delta: 'reasoning output'
|
||||
})
|
||||
)
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
|
||||
@@ -903,6 +903,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
error?: string
|
||||
}
|
||||
>()
|
||||
const reasoningPartIds = new Set<string>()
|
||||
try {
|
||||
const promptText =
|
||||
session.created && request.history?.length
|
||||
@@ -953,13 +954,22 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (
|
||||
event.type === 'message.part.delta' &&
|
||||
event.properties.sessionID === sessionId &&
|
||||
event.properties.field === 'text' &&
|
||||
event.properties.delta
|
||||
) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: event.properties.delta
|
||||
const reasoning =
|
||||
reasoningPartIds.has(event.properties.partID) ||
|
||||
[
|
||||
'reasoning',
|
||||
'reasoning_content',
|
||||
'reasoning_details',
|
||||
'thinking'
|
||||
].includes(event.properties.field)
|
||||
if (reasoning || event.properties.field === 'text') {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: reasoning ? 'reasoning' : 'text',
|
||||
delta: event.properties.delta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -968,7 +978,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
event.properties.sessionID === sessionId
|
||||
) {
|
||||
const { part } = event.properties
|
||||
if (part.type === 'tool') {
|
||||
if (part.type === 'reasoning') {
|
||||
reasoningPartIds.add(part.id)
|
||||
} else if (part.type === 'tool') {
|
||||
const callId = part.callID || part.id
|
||||
if (!callId || callId.length > 256) {
|
||||
throw new Error('OpenCode 工具调用 ID 格式无效')
|
||||
@@ -1003,6 +1015,18 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'session.next.reasoning.delta' &&
|
||||
event.properties.sessionID === sessionId &&
|
||||
event.properties.delta
|
||||
) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'reasoning',
|
||||
delta: event.properties.delta
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.usesEmbeddedPermissionMediation() &&
|
||||
event.type === 'permission.asked'
|
||||
|
||||
@@ -24,28 +24,25 @@ describe('buildRuntimeEnvironment', () => {
|
||||
PATH: 'C:\\Tools',
|
||||
TEMP: 'C:\\Temp',
|
||||
ANTHROPIC_API_KEY: 'provider-key',
|
||||
GOODBUDDY_RUNTIME_TOKEN: 'scoped-token'
|
||||
GOODBUDDY_RUNTIME_TOKEN: 'scoped-token',
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0'
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates insecure TLS only when compatibility mode is enabled', () => {
|
||||
it('always propagates intranet TLS compatibility to child runtimes', () => {
|
||||
const source = {
|
||||
PATH: '/tools',
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '1'
|
||||
}
|
||||
|
||||
expect(buildRuntimeEnvironment({}, source, true)).toEqual({
|
||||
expect(buildRuntimeEnvironment({}, source)).toEqual({
|
||||
PATH: '/tools',
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0'
|
||||
})
|
||||
expect(buildRuntimeEnvironment({}, source, false)).toEqual({
|
||||
PATH: '/tools'
|
||||
})
|
||||
expect(
|
||||
buildRuntimeEnvironment(
|
||||
{ NODE_TLS_REJECT_UNAUTHORIZED: '1' },
|
||||
source,
|
||||
true
|
||||
source
|
||||
)
|
||||
).toEqual({
|
||||
PATH: '/tools',
|
||||
@@ -77,23 +74,19 @@ describe('buildRuntimeEnvironment', () => {
|
||||
buildExplicitProfileRuntimeEnvironment(
|
||||
{ GOODBUDDY_RUNTIME_TOKEN: 'scoped-token' },
|
||||
{ name: 'OPENAI_API_KEY', value: 'selected-key' },
|
||||
source,
|
||||
false
|
||||
source
|
||||
)
|
||||
).toEqual({
|
||||
PATH: '/tools',
|
||||
GOODBUDDY_RUNTIME_TOKEN: 'scoped-token',
|
||||
OPENAI_API_KEY: 'selected-key'
|
||||
OPENAI_API_KEY: 'selected-key',
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0'
|
||||
})
|
||||
expect(
|
||||
buildExplicitProfileRuntimeEnvironment(
|
||||
{},
|
||||
undefined,
|
||||
source,
|
||||
false
|
||||
)
|
||||
buildExplicitProfileRuntimeEnvironment({}, undefined, source)
|
||||
).toEqual({
|
||||
PATH: '/tools'
|
||||
PATH: '/tools',
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { isControlledChildTlsCompatibilityEnabled } from '../global-tls-policy'
|
||||
|
||||
const runtimeProviderEnvironmentNames = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
@@ -65,9 +63,7 @@ export const runtimePrivacyEnvironment: NodeJS.ProcessEnv = {
|
||||
|
||||
export function buildRuntimeEnvironment(
|
||||
overrides: NodeJS.ProcessEnv,
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
tlsCompatibilityEnabled =
|
||||
isControlledChildTlsCompatibilityEnabled()
|
||||
source: NodeJS.ProcessEnv = process.env
|
||||
): NodeJS.ProcessEnv {
|
||||
const environment: NodeJS.ProcessEnv = {}
|
||||
for (const name of runtimeEnvironmentAllowlist) {
|
||||
@@ -75,30 +71,19 @@ export function buildRuntimeEnvironment(
|
||||
environment[name] = source[name]
|
||||
}
|
||||
}
|
||||
const runtimeEnvironment = {
|
||||
return {
|
||||
...environment,
|
||||
...overrides
|
||||
...overrides,
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0'
|
||||
}
|
||||
if (tlsCompatibilityEnabled) {
|
||||
runtimeEnvironment.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
} else {
|
||||
delete runtimeEnvironment.NODE_TLS_REJECT_UNAUTHORIZED
|
||||
}
|
||||
return runtimeEnvironment
|
||||
}
|
||||
|
||||
export function buildExplicitProfileRuntimeEnvironment(
|
||||
overrides: NodeJS.ProcessEnv,
|
||||
credential?: RuntimeProfileCredential,
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
tlsCompatibilityEnabled =
|
||||
isControlledChildTlsCompatibilityEnabled()
|
||||
source: NodeJS.ProcessEnv = process.env
|
||||
): NodeJS.ProcessEnv {
|
||||
const environment = buildRuntimeEnvironment(
|
||||
overrides,
|
||||
source,
|
||||
tlsCompatibilityEnabled
|
||||
)
|
||||
const environment = buildRuntimeEnvironment(overrides, source)
|
||||
for (const name of runtimeProviderEnvironmentNames) {
|
||||
delete environment[name]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ReasoningTagStreamParser } from './reasoning-stream'
|
||||
|
||||
describe('ReasoningTagStreamParser', () => {
|
||||
it('separates think and thinking blocks from final text', () => {
|
||||
const parser = new ReasoningTagStreamParser()
|
||||
|
||||
expect(
|
||||
parser.push(
|
||||
'开头<think>分析一</think>中间<thinking>分析二</thinking>结尾'
|
||||
)
|
||||
).toEqual([
|
||||
{ type: 'text', delta: '开头' },
|
||||
{ type: 'reasoning', delta: '分析一' },
|
||||
{ type: 'text', delta: '中间' },
|
||||
{ type: 'reasoning', delta: '分析二' },
|
||||
{ type: 'text', delta: '结尾' }
|
||||
])
|
||||
expect(parser.finish()).toEqual([])
|
||||
})
|
||||
|
||||
it('handles tags split across streaming chunks', () => {
|
||||
const parser = new ReasoningTagStreamParser()
|
||||
|
||||
expect(parser.push('回答前<thi')).toEqual([
|
||||
{ type: 'text', delta: '回答前' }
|
||||
])
|
||||
expect(parser.push('nk>逐步分析</th')).toEqual([
|
||||
{ type: 'reasoning', delta: '逐步分析' }
|
||||
])
|
||||
expect(parser.push('ink>最终答案')).toEqual([
|
||||
{ type: 'text', delta: '最终答案' }
|
||||
])
|
||||
expect(parser.finish()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps an unclosed reasoning block as reasoning', () => {
|
||||
const parser = new ReasoningTagStreamParser()
|
||||
|
||||
expect(parser.push('<THINKING>仍在分析')).toEqual([
|
||||
{ type: 'reasoning', delta: '仍在分析' }
|
||||
])
|
||||
expect(parser.finish()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
export type ReasoningStreamSegment = {
|
||||
type: 'text' | 'reasoning'
|
||||
delta: string
|
||||
}
|
||||
|
||||
const openingTags = ['<think>', '<thinking>'] as const
|
||||
|
||||
function longestTagPrefixSuffix(
|
||||
value: string,
|
||||
tags: readonly string[]
|
||||
): number {
|
||||
const lowerValue = value.toLocaleLowerCase()
|
||||
let retained = 0
|
||||
for (const tag of tags) {
|
||||
const maximum = Math.min(value.length, tag.length - 1)
|
||||
for (let length = maximum; length > retained; length -= 1) {
|
||||
if (
|
||||
lowerValue.endsWith(tag.slice(0, length).toLocaleLowerCase())
|
||||
) {
|
||||
retained = length
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return retained
|
||||
}
|
||||
|
||||
function appendDelta(
|
||||
result: ReasoningStreamSegment[],
|
||||
type: ReasoningStreamSegment['type'],
|
||||
value: string
|
||||
): void {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
const previous = result.at(-1)
|
||||
if (previous?.type === type) {
|
||||
previous.delta += value
|
||||
} else {
|
||||
result.push({ type, delta: value })
|
||||
}
|
||||
}
|
||||
|
||||
export class ReasoningTagStreamParser {
|
||||
private buffer = ''
|
||||
private closingTag: '</think>' | '</thinking>' | undefined
|
||||
|
||||
push(delta: string): ReasoningStreamSegment[] {
|
||||
this.buffer += delta
|
||||
return this.drain(false)
|
||||
}
|
||||
|
||||
finish(): ReasoningStreamSegment[] {
|
||||
return this.drain(true)
|
||||
}
|
||||
|
||||
private drain(flush: boolean): ReasoningStreamSegment[] {
|
||||
const result: ReasoningStreamSegment[] = []
|
||||
while (this.buffer) {
|
||||
const tags = this.closingTag ? [this.closingTag] : openingTags
|
||||
const lowerBuffer = this.buffer.toLocaleLowerCase()
|
||||
let tagIndex = -1
|
||||
let matchedTag: string | undefined
|
||||
for (const tag of tags) {
|
||||
const candidateIndex = lowerBuffer.indexOf(
|
||||
tag.toLocaleLowerCase()
|
||||
)
|
||||
if (
|
||||
candidateIndex >= 0 &&
|
||||
(tagIndex < 0 || candidateIndex < tagIndex)
|
||||
) {
|
||||
tagIndex = candidateIndex
|
||||
matchedTag = tag
|
||||
}
|
||||
}
|
||||
|
||||
const target = this.closingTag ? 'reasoning' : 'text'
|
||||
if (matchedTag !== undefined) {
|
||||
appendDelta(result, target, this.buffer.slice(0, tagIndex))
|
||||
this.buffer = this.buffer.slice(tagIndex + matchedTag.length)
|
||||
if (this.closingTag) {
|
||||
this.closingTag = undefined
|
||||
} else {
|
||||
this.closingTag =
|
||||
matchedTag.toLocaleLowerCase() === '<thinking>'
|
||||
? '</thinking>'
|
||||
: '</think>'
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const retained = flush
|
||||
? 0
|
||||
: longestTagPrefixSuffix(this.buffer, tags)
|
||||
const boundary = this.buffer.length - retained
|
||||
appendDelta(result, target, this.buffer.slice(0, boundary))
|
||||
this.buffer = this.buffer.slice(boundary)
|
||||
break
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,6 @@ function settings(
|
||||
continueMode: 'chat',
|
||||
runtimeSandboxMode: 'auto',
|
||||
subagentSmartRoutingEnabled: false,
|
||||
intranetCompatibilityEnabled: true,
|
||||
knowledgeEmbeddingEnabled: false,
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
'http://127.0.0.1:11434/v1/embeddings',
|
||||
|
||||
Reference in New Issue
Block a user