fix(AI): stream thinking from /v1 reasoning field + abort on client disconnect (#1078)

Two defects made chat hang forever with thinking-capable models on the OpenAI-compat
(/v1) path, which NOMAD uses for both local and remote Ollama:

1. Field mismatch. chatStream()/chat() read `delta.thinking` / `message.thinking`,
   but Ollama's /v1 endpoint emits thinking tokens as `reasoning`. All thinking output
   was silently dropped, so the SSE stream was nothing but empty content+thinking chunks
   and never reached done. Now read `thinking ?? reasoning` in both paths (the inline
   <think>-tag parser for other backends is unchanged).

2. No abort on client disconnect. When the user gave up and closed the chat, the
   upstream generation kept decoding server-side. With Ollama's default
   OLLAMA_NUM_PARALLEL=1 that abandoned request occupied the only slot, so every later
   chat/RAG request queued behind it and the whole assistant appeared dead. The
   controller now wires an AbortController to the response 'close' event and threads the
   signal into the OpenAI SDK request, so a disconnect aborts the upstream generation.

Verified on NOMAD2 (qwen3:0.6b, which reports the `thinking` capability and emits
`reasoning` on /v1): before, the stream was endless empty chunks; after, thinking streams
visibly and reaches done. On disconnect, Ollama's decode counter freezes and the server
logs `cancel task` / `slot release`, freeing the slot for the next request.

Note: thinking is still force-on for capable models here; a user-facing per-model
thinking toggle (default off) is a planned follow-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
chriscrosstalk 2026-07-18 12:33:06 -05:00 committed by GitHub
parent 39507d60a1
commit 166247c284
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 36 additions and 10 deletions

View File

@ -164,14 +164,32 @@ export default class OllamaController {
if (reqData.stream) {
logger.debug(`[OllamaController] Initiating streaming response for model: "${reqData.model}" with think: ${think}`)
// Headers already flushed above
const stream = await this.ollamaService.chatStream({ ...ollamaRequest, think, numCtx })
// Headers already flushed above.
// Abort the upstream generation if the client disconnects — otherwise an abandoned
// request keeps decoding server-side and, with Ollama's default OLLAMA_NUM_PARALLEL=1,
// blocks every later chat/RAG request until the model is manually stopped (#1065).
const abortController = new AbortController()
response.response.on('close', () => abortController.abort())
const stream = await this.ollamaService.chatStream({
...ollamaRequest,
think,
numCtx,
signal: abortController.signal,
})
let fullContent = ''
for await (const chunk of stream) {
if (chunk.message?.content) {
fullContent += chunk.message.content
try {
for await (const chunk of stream) {
if (chunk.message?.content) {
fullContent += chunk.message.content
}
response.response.write(`data: ${JSON.stringify(chunk)}\n\n`)
}
response.response.write(`data: ${JSON.stringify(chunk)}\n\n`)
} catch (err) {
if (abortController.signal.aborted) {
logger.debug('[OllamaController] Client disconnected; aborted upstream Ollama generation')
return
}
throw err
}
response.response.end()

View File

@ -45,6 +45,9 @@ type ChatInput = {
think?: boolean | 'medium'
stream?: boolean
numCtx?: number
// Aborts the upstream request when the client disconnects, so an abandoned generation
// doesn't keep decoding server-side and block Ollama's single parallel slot (#1065).
signal?: AbortSignal
}
@inject()
@ -333,13 +336,15 @@ export class OllamaService {
params.num_ctx = chatRequest.numCtx
}
const response = await this.openai.chat.completions.create(params)
const response = await this.openai.chat.completions.create(params, { signal: chatRequest.signal })
const choice = response.choices[0]
return {
message: {
content: choice.message.content ?? '',
thinking: (choice.message as any).thinking ?? undefined,
// Ollama's OpenAI-compat endpoint (/v1) emits thinking as `reasoning`; its native
// shape uses `thinking`. Read both so thinking is never silently dropped (#1065).
thinking: (choice.message as any).thinking ?? (choice.message as any).reasoning ?? undefined,
},
done: true,
model: response.model,
@ -364,7 +369,9 @@ export class OllamaService {
params.num_ctx = chatRequest.numCtx
}
const stream = (await this.openai.chat.completions.create(params)) as unknown as Stream<ChatCompletionChunk>
const stream = (await this.openai.chat.completions.create(params, {
signal: chatRequest.signal,
})) as unknown as Stream<ChatCompletionChunk>
// Returns how many trailing chars of `text` could be the start of `tag`
function partialTagSuffix(tag: string, text: string): number {
@ -383,7 +390,8 @@ export class OllamaService {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
const nativeThinking: string = (delta as any)?.thinking ?? ''
// /v1 emits thinking as `reasoning`; native Ollama uses `thinking`. Read both (#1065).
const nativeThinking: string = (delta as any)?.thinking ?? (delta as any)?.reasoning ?? ''
const rawContent: string = delta?.content ?? ''
// Parse <think> tags out of the content stream