diff --git a/admin/app/controllers/ollama_controller.ts b/admin/app/controllers/ollama_controller.ts index 9174616..cd22326 100644 --- a/admin/app/controllers/ollama_controller.ts +++ b/admin/app/controllers/ollama_controller.ts @@ -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() diff --git a/admin/app/services/ollama_service.ts b/admin/app/services/ollama_service.ts index be319e9..b0ca3b0 100644 --- a/admin/app/services/ollama_service.ts +++ b/admin/app/services/ollama_service.ts @@ -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 + const stream = (await this.openai.chat.completions.create(params, { + signal: chatRequest.signal, + })) as unknown as Stream // 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 tags out of the content stream