chore: prepare GoodBuddy 0.8.5

This commit is contained in:
lofyer
2026-08-07 10:45:01 +08:00
parent e20cb447af
commit 17e66a3369
54 changed files with 1306 additions and 1640 deletions
+10
View File
@@ -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'
)
})
})
+6 -3
View File
@@ -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
}
+13 -2
View File
@@ -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({
+10
View File
@@ -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,
+43 -5
View File
@@ -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',
+98
View File
@@ -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
+40
View File
@@ -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'
)
})
})
+19 -5
View File
@@ -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')
}
+32
View File
@@ -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',
+30 -6
View File
@@ -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'
+11 -18
View File
@@ -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'
})
})
})
+6 -21
View File
@@ -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]
}
+45
View File
@@ -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([])
})
})
+102
View File
@@ -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
}
}
-1
View File
@@ -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',