feat: add support for reasoning fields (OpenRouter) (#6206)

* add support for reasoning fields (OpenRouter)

* reformat

* fix linter

* Update web-app/src/utils/reasoning.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Dinh Long Nguyen 2025-08-18 21:59:14 +07:00 committed by GitHub
parent 5ad3d282af
commit 2d486d7b3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 109 additions and 8 deletions

View File

@ -6,6 +6,7 @@ import { EngineManager } from './EngineManager'
export interface chatCompletionRequestMessage { export interface chatCompletionRequestMessage {
role: 'system' | 'user' | 'assistant' | 'tool' role: 'system' | 'user' | 'assistant' | 'tool'
content: string | null | Content[] // Content can be a string OR an array of content parts content: string | null | Content[] // Content can be a string OR an array of content parts
reasoning?: string | null // Some models return reasoning in completed responses
name?: string name?: string
tool_calls?: any[] // Simplified tool_call_id?: string tool_calls?: any[] // Simplified tool_call_id?: string
} }

View File

@ -29,6 +29,10 @@ import { updateSettings } from '@/services/providers'
import { useContextSizeApproval } from './useModelContextApproval' import { useContextSizeApproval } from './useModelContextApproval'
import { useModelLoad } from './useModelLoad' import { useModelLoad } from './useModelLoad'
import { useGeneralSetting } from './useGeneralSetting' import { useGeneralSetting } from './useGeneralSetting'
import {
ReasoningProcessor,
extractReasoningFromMessage,
} from '@/utils/reasoning'
export const useChat = () => { export const useChat = () => {
const { prompt, setPrompt } = usePrompt() const { prompt, setPrompt } = usePrompt()
@ -285,16 +289,25 @@ export const useChat = () => {
const toolCalls: ChatCompletionMessageToolCall[] = [] const toolCalls: ChatCompletionMessageToolCall[] = []
try { try {
if (isCompletionResponse(completion)) { if (isCompletionResponse(completion)) {
accumulatedText = const message = completion.choices[0]?.message
(completion.choices[0]?.message?.content as string) || '' accumulatedText = (message?.content as string) || ''
if (completion.choices[0]?.message?.tool_calls) {
toolCalls.push(...completion.choices[0].message.tool_calls) // Handle reasoning field if there is one
const reasoning = extractReasoningFromMessage(message)
if (reasoning) {
accumulatedText =
`<think>${reasoning}</think>` + accumulatedText
}
if (message?.tool_calls) {
toolCalls.push(...message.tool_calls)
} }
} else { } else {
// High-throughput scheduler: batch UI updates on rAF (requestAnimationFrame) // High-throughput scheduler: batch UI updates on rAF (requestAnimationFrame)
let rafScheduled = false let rafScheduled = false
let rafHandle: number | undefined let rafHandle: number | undefined
let pendingDeltaCount = 0 let pendingDeltaCount = 0
const reasoningProcessor = new ReasoningProcessor()
const scheduleFlush = () => { const scheduleFlush = () => {
if (rafScheduled) return if (rafScheduled) return
rafScheduled = true rafScheduled = true
@ -328,7 +341,10 @@ export const useChat = () => {
} }
const flushIfPending = () => { const flushIfPending = () => {
if (!rafScheduled) return if (!rafScheduled) return
if (typeof cancelAnimationFrame !== 'undefined' && rafHandle !== undefined) { if (
typeof cancelAnimationFrame !== 'undefined' &&
rafHandle !== undefined
) {
cancelAnimationFrame(rafHandle) cancelAnimationFrame(rafHandle)
} else if (rafHandle !== undefined) { } else if (rafHandle !== undefined) {
clearTimeout(rafHandle) clearTimeout(rafHandle)
@ -360,20 +376,30 @@ export const useChat = () => {
: (JSON.stringify(part) ?? '') : (JSON.stringify(part) ?? '')
) )
} }
const delta = part.choices[0]?.delta?.content || ''
if (part.choices[0]?.delta?.tool_calls) { if (part.choices[0]?.delta?.tool_calls) {
extractToolCall(part, currentCall, toolCalls) extractToolCall(part, currentCall, toolCalls)
// Schedule a flush to reflect tool update // Schedule a flush to reflect tool update
scheduleFlush() scheduleFlush()
} }
if (delta) { const deltaReasoning =
accumulatedText += delta reasoningProcessor.processReasoningChunk(part)
if (deltaReasoning) {
accumulatedText += deltaReasoning
pendingDeltaCount += 1
// Schedule flush for reasoning updates
scheduleFlush()
}
const deltaContent = part.choices[0]?.delta?.content || ''
if (deltaContent) {
accumulatedText += deltaContent
pendingDeltaCount += 1 pendingDeltaCount += 1
// Batch UI update on next animation frame // Batch UI update on next animation frame
scheduleFlush() scheduleFlush()
} }
} }
// Finalize reasoning (close any open think tags)
accumulatedText += reasoningProcessor.finalize()
// Ensure any pending buffered content is rendered at the end // Ensure any pending buffered content is rendered at the end
flushIfPending() flushIfPending()
} }

View File

@ -0,0 +1,74 @@
import { CompletionResponseChunk } from 'token.js'
import {
chatCompletionChunk,
ChatCompletionMessage,
chatCompletionRequestMessage,
} from '@janhq/core'
// Extract reasoning from a message (for completed responses)
export function extractReasoningFromMessage(
message: chatCompletionRequestMessage | ChatCompletionMessage
): string | null {
if (!message) return null
const extendedMessage = message as chatCompletionRequestMessage
return extendedMessage.reasoning || null
}
// Extract reasoning from a chunk (for streaming responses)
function extractReasoningFromChunk(
chunk: CompletionResponseChunk | chatCompletionChunk
): string | null {
if (!chunk.choices?.[0]?.delta) return null
const delta = chunk.choices[0].delta as chatCompletionRequestMessage
const reasoning = delta.reasoning
// Return null for falsy values, non-strings, or whitespace-only strings
if (!reasoning || typeof reasoning !== 'string' || !reasoning.trim())
return null
return reasoning
}
// Tracks reasoning state and appends reasoning tokens with proper think tags
export class ReasoningProcessor {
private isReasoningActive = false
processReasoningChunk(
chunk: CompletionResponseChunk | chatCompletionChunk
): string {
const reasoning = extractReasoningFromChunk(chunk)
const chunkContent = chunk.choices?.[0]?.delta?.content || ''
// Handle reasoning tokens
if (reasoning) {
if (!this.isReasoningActive) {
this.isReasoningActive = true
return '<think>' + reasoning
}
return reasoning
}
// Handle reasoning end when content starts
if (this.isReasoningActive && chunkContent) {
this.isReasoningActive = false
return '</think>'
}
// No reasoning to process
return ''
}
finalize(): string {
if (this.isReasoningActive) {
this.isReasoningActive = false
return '</think>'
}
return ''
}
isReasoningInProgress(): boolean {
return this.isReasoningActive
}
}