Skip to content

fix(api): propagate AbortSignal to OpenAI-compatible providers for mid-stream cancellation#429

Open
proyectoauraorg wants to merge 2 commits into
Zoo-Code-Org:mainfrom
proyectoauraorg:fix/404-openai-compatible-abort-stream
Open

fix(api): propagate AbortSignal to OpenAI-compatible providers for mid-stream cancellation#429
proyectoauraorg wants to merge 2 commits into
Zoo-Code-Org:mainfrom
proyectoauraorg:fix/404-openai-compatible-abort-stream

Conversation

@proyectoauraorg
Copy link
Copy Markdown
Contributor

@proyectoauraorg proyectoauraorg commented Jun 1, 2026

Related GitHub Issue

Closes: #404

Description

Root cause: Both BaseOpenAiCompatibleProvider and OpenAICompatibleHandler did not create an AbortController or pass a signal to their underlying SDK calls. When a user hit Stop, the Task-level Promise.race would break out of the chunk consumption loop, but the HTTP request continued running server-side until it finished naturally — wasting compute on local providers like llama.cpp.

In contrast, OpenAINativeHandler and OpenAICodexHandler both correctly create an AbortController and pass signal: this.abortController.signal to their API calls.

Fix:

  1. BaseOpenAiCompatibleProvider (base-openai-compatible-provider.ts):

    • Added abortController field
    • createMessage(): creates AbortController, passes { signal } via requestOptions to client.chat.completions.create(), checks signal.aborted in the stream loop, cleans up in finally
    • completePrompt(): creates AbortController, passes { signal } to client.chat.completions.create(), cleans up in finally
  2. OpenAICompatibleHandler (openai-compatible.ts):

    • Added abortController field
    • createMessage(): creates AbortController, passes abortSignal to streamText(), checks signal.aborted in the fullStream loop, cleans up in finally
    • completePrompt(): creates AbortController, passes abortSignal to generateText(), cleans up in finally

Test Procedure

  • Unit tests: 5 new tests added + 1 existing test updated (all 18 pass):
    • Verifies signal is passed to chat.completions.create in createMessage
    • Verifies stream stops yielding when abort is signaled mid-stream
    • Verifies signal is passed to chat.completions.create in completePrompt
    • Verifies abortController is cleaned up after createMessage completes
    • Verifies abortController is cleaned up after completePrompt completes
  • Manual test: Configure OpenAI Compatible provider (e.g., local llama.cpp), start a long generation, hit Stop → underlying HTTP request should be immediately aborted (visible in provider logs)

Pre-Submission Checklist

  • Issue Linked: Closes [BUG] Stop does not work on OpenAI Compatible API Provider #404
  • Scope: Focused fix — only abort signal propagation to two provider classes
  • Self-Review: Thorough review performed
  • Testing: 5 new regression tests added, all 18 tests pass
  • Documentation Impact: No documentation changes needed
  • Contribution Guidelines: Read and agreed

Summary by CodeRabbit

  • Bug Fixes
    • Added request cancellation support for OpenAI-compatible providers so users can abort in-progress streaming and non-streaming completions; resources are cleaned up after cancellation.
  • Tests
    • Updated provider tests to validate cancellation behavior and ensure streaming and completion flows handle aborted requests correctly.

…d-stream cancellation

Both BaseOpenAiCompatibleProvider and OpenAICompatibleHandler did not create
an AbortController or pass a signal to their underlying SDK calls. When a user
hit Stop, the Task-level Promise.race would break out of the chunk loop, but
the HTTP request continued running server-side until it finished naturally.

This fix adds an AbortController to both providers:
- BaseOpenAiCompatibleProvider: signal passed via requestOptions to
  chat.completions.create() and checked in the stream iteration loop
- OpenAICompatibleHandler: abortSignal passed to streamText() and
  generateText() from the AI SDK
- Both providers clean up the controller in finally blocks
- completePrompt() also gets abort support in both providers

Includes 5 regression tests covering signal propagation, mid-stream abort,
and controller cleanup.

Closes: Zoo-Code-Org#404
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 1, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Adds per-request cancellation: handlers and the base provider create an AbortController for each request, pass its signal to streaming and non-streaming SDK calls, stop processing when aborted, and always clear the controller. Tests updated to assert signal propagation, mid-stream abort behavior, and controller cleanup.

Changes

Request Cancellation via AbortController

Layer / File(s) Summary
AbortController field definitions
src/api/providers/openai-compatible.ts, src/api/providers/base-openai-compatible-provider.ts
Optional abortController field added to OpenAICompatibleHandler and BaseOpenAiCompatibleProvider to manage per-request cancellation state.
BaseOpenAiCompatibleProvider cancellation support
src/api/providers/base-openai-compatible-provider.ts
createMessage and completePrompt now create an AbortController, pass its signal into createStream and chat.completions.create, stop streaming when aborted, and clear the controller in finally blocks.
OpenAICompatibleHandler cancellation support
src/api/providers/openai-compatible.ts
Handler createMessage and completePrompt instantiate AbortController, pass the signal as abortSignal to streamText and generateText, break processing on abort, and clear the controller in finally.
Abort/cancellation test coverage
src/api/providers/__tests__/base-openai-compatible-provider.spec.ts, src/api/providers/__tests__/fireworks.spec.ts, src/api/providers/__tests__/sambanova.spec.ts, src/api/providers/__tests__/zai.spec.ts
Existing stream test updated to expect an AbortSignal; new tests validate that abort signals are forwarded to the SDK, mid-stream abort stops chunk consumption, and the internal abort controller is cleaned up after streaming and non-streaming flows.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • taltas
  • navedmerchant
  • hannesrudolph
  • edelauna

Poem

A rabbit taps the abort control,
Streams stop quick — no tokens roll,
Controllers hush the noisy stream,
Cleaned up tidy, neat and clean,
Hop, stop, and sprint—away I go! 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: propagating AbortSignal to OpenAI-compatible providers to enable mid-stream cancellation.
Description check ✅ Passed The description covers all required template sections: linked issue (#404), clear explanation of root cause and fix with implementation details, comprehensive test procedure, and completed pre-submission checklist.
Linked Issues check ✅ Passed The PR fully addresses issue #404 by implementing AbortController creation and signal propagation in both BaseOpenAiCompatibleProvider and OpenAICompatibleHandler, enabling immediate cancellation of underlying HTTP requests.
Out of Scope Changes check ✅ Passed All changes directly address the abort signal propagation objective: modifications to base-openai-compatible-provider.ts and openai-compatible.ts implementations, plus aligned test updates to verify signal passing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/api/providers/__tests__/fireworks.spec.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

src/api/providers/__tests__/sambanova.spec.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

src/api/providers/__tests__/zai.spec.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
src/api/providers/openai-compatible.ts (2)

215-234: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

completePrompt finally also needs abort().

Same fix as the streaming path: abort before clearing the controller.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/openai-compatible.ts` around lines 215 - 234, In
completePrompt, the finally block currently clears this.abortController but
doesn't call abort; update the finally to first call
this.abortController.abort() (guarded with a truthy check) to cancel any
in-flight generateText, then set this.abortController = undefined; reference the
completePrompt method and the abortController property to locate and modify the
finally block.

160-209: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Same cancellation defect as BaseOpenAiCompatibleProviderfinally clears but never aborts.

The finally (Line 207-209) nulls this.abortController without calling .abort(), so closing the generator early does not cancel the streamText request, and signal.aborted (Line 192) never becomes true in production. Add this.abortController?.abort() in finally (and consider the same local-controller approach to avoid the shared-field race).

Additionally note: once abort actually fires, await result.usage at Line 203 may reject for an aborted stream — guard that path or skip the usage read when aborted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/openai-compatible.ts` around lines 160 - 209, The finally
block currently only clears this.abortController without aborting the request;
update the generator in openai-compatible.ts to call
this.abortController?.abort() before setting it to undefined (or better: create
a local const controller = new AbortController(), use controller for
request/abort and assign to this.abortController = controller to avoid races),
ensure streamText is given controller.signal, and guard the usage read
(result.usage) so it is skipped or try/catch'd when the
controller.signal.aborted to avoid an exception after an abort; reference
symbols: this.abortController, streamText, result.usage, processAiSdkStreamPart,
and consider mirroring the local-controller approach used in
BaseOpenAiCompatibleProvider.
src/api/providers/base-openai-compatible-provider.ts (1)

243-274: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Same missing abort() in completePrompt's finally.

Same root cause as createMessage: the controller is cleared but never aborted, so the signal cannot cancel the request. Apply the matching fix here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/base-openai-compatible-provider.ts` around lines 243 - 274,
The finally block in completePrompt currently clears this.abortController
without aborting the in-flight request; mirror the fix from createMessage by
calling this.abortController?.abort() before setting this.abortController =
undefined so the AbortSignal actually cancels the request (apply to the
completePrompt method in class BaseOpenAICompatibleProvider and ensure the same
pattern matches createMessage).
🧹 Nitpick comments (1)
src/api/providers/__tests__/base-openai-compatible-provider.spec.ts (1)

578-628: ⚡ Quick win

Test exercises the loop break but not actual request cancellation — it can't catch the missing abort().

This test signals abort via the private field and asserts the loop stops yielding, which the current code already satisfies. It does not assert that the underlying SDK request is aborted when the generator is closed early (the real #404 scenario). Consider adding a test that closes the generator (iterator.return()) without manually aborting, then asserts the controller's signal became aborted — this would have surfaced the finally gap.

💚 Suggested coverage
+		it("should abort the underlying request when the generator is closed early", async () => {
+			mockCreate.mockImplementationOnce(() => ({
+				[Symbol.asyncIterator]: () => ({
+					next: vi.fn().mockResolvedValue({
+						done: false,
+						value: { choices: [{ delta: { content: "chunk" } }] },
+					}),
+				}),
+			}))
+
+			const stream = handler.createMessage("system prompt", [])
+			const iterator = stream[Symbol.asyncIterator]()
+			await iterator.next()
+			const controller = (handler as any).abortController as AbortController
+			const signal = controller.signal
+			await iterator.return?.()
+			expect(signal.aborted).toBe(true)
+		})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/__tests__/base-openai-compatible-provider.spec.ts` around
lines 578 - 628, Add a new test case (or modify the existing one) that verifies
the underlying request is actually aborted when the async generator is closed:
call handler.createMessage(...) to get the stream, obtain its async iterator,
then invoke iterator.return() (instead of calling controller.abort()), and
assert that the handler's private abortController.signal.aborted (or that
abortController was signaled) becomes true; this will exercise the
generator-close path and catch the missing cancellation in the finally/cleanup
logic around createMessage/abortController/iterator.return().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/api/providers/base-openai-compatible-provider.ts`:
- Around line 120-217: The finally block in the stream-handling code for
createStream never calls abort on the AbortController, so in-flight requests
won't be cancelled; update the finally in the method that creates the stream
(where this.abortController is set) to call this.abortController.abort() if
present before clearing it (keep the existing this.abortController = undefined),
ensuring you reference the same AbortController instance used when calling
createStream (symbols: this.abortController, createStream, and the surrounding
try/catch/finally block in base-openai-compatible-provider.ts).
- Around line 39-40: BaseOpenAiCompatibleProvider currently uses an instance
field abortController shared across requests; change createMessage and
completePrompt to create a local AbortController (const abortController = new
AbortController()) and use its signal for all abort checks and API calls within
that invocation instead of this.abortController, pass that local signal into any
fetch/SDK options, and remove any writes/clears to this.abortController so
finally blocks do not unset a controller used by another concurrent call; keep
the per-invocation signal local throughout the method and only check/clear that
local controller.

---

Duplicate comments:
In `@src/api/providers/base-openai-compatible-provider.ts`:
- Around line 243-274: The finally block in completePrompt currently clears
this.abortController without aborting the in-flight request; mirror the fix from
createMessage by calling this.abortController?.abort() before setting
this.abortController = undefined so the AbortSignal actually cancels the request
(apply to the completePrompt method in class BaseOpenAICompatibleProvider and
ensure the same pattern matches createMessage).

In `@src/api/providers/openai-compatible.ts`:
- Around line 215-234: In completePrompt, the finally block currently clears
this.abortController but doesn't call abort; update the finally to first call
this.abortController.abort() (guarded with a truthy check) to cancel any
in-flight generateText, then set this.abortController = undefined; reference the
completePrompt method and the abortController property to locate and modify the
finally block.
- Around line 160-209: The finally block currently only clears
this.abortController without aborting the request; update the generator in
openai-compatible.ts to call this.abortController?.abort() before setting it to
undefined (or better: create a local const controller = new AbortController(),
use controller for request/abort and assign to this.abortController = controller
to avoid races), ensure streamText is given controller.signal, and guard the
usage read (result.usage) so it is skipped or try/catch'd when the
controller.signal.aborted to avoid an exception after an abort; reference
symbols: this.abortController, streamText, result.usage, processAiSdkStreamPart,
and consider mirroring the local-controller approach used in
BaseOpenAiCompatibleProvider.

---

Nitpick comments:
In `@src/api/providers/__tests__/base-openai-compatible-provider.spec.ts`:
- Around line 578-628: Add a new test case (or modify the existing one) that
verifies the underlying request is actually aborted when the async generator is
closed: call handler.createMessage(...) to get the stream, obtain its async
iterator, then invoke iterator.return() (instead of calling controller.abort()),
and assert that the handler's private abortController.signal.aborted (or that
abortController was signaled) becomes true; this will exercise the
generator-close path and catch the missing cancellation in the finally/cleanup
logic around createMessage/abortController/iterator.return().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ea6e5497-adae-403f-9d0f-789f4c0b585e

📥 Commits

Reviewing files that changed from the base of the PR and between 3df406e and b7156e8.

📒 Files selected for processing (3)
  • src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
  • src/api/providers/base-openai-compatible-provider.ts
  • src/api/providers/openai-compatible.ts

Comment on lines +39 to +40
// Abort controller for cancelling ongoing requests (fixes #404)
private abortController?: AbortController
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP 'createMessage|completePrompt' --type=ts -C2 -g '!**/*.spec.ts' -g '!**/__tests__/**'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 33232


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/api/providers/base-openai-compatible-provider.ts"

echo "== abortController occurrences =="
rg -n "abortController" "$FILE" || true

echo
echo "== createMessage around abortController usage =="
rg -n "createMessage\\(" "$FILE" -n || true

echo
echo "== completePrompt around abortController usage =="
rg -n "completePrompt\\(" "$FILE" || true

echo
echo "== context: lines 1-120 =="
nl -ba "$FILE" | sed -n '1,160p'

echo
echo "== context: around createMessage & completePrompt =="
nl -ba "$FILE" | sed -n '100,320p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 757


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "== Providers factory / instantiation =="
rg -n "new (.*Provider)" src/api -S --type=ts || true
echo
echo "== Where api handler is created and passed to Task =="
rg -n "new .*Provider|getProvider|provider" src/core/task/Task.ts src/api/index.ts src/api -S --type=ts || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/api/providers/base-openai-compatible-provider.ts"

echo "== abortController occurrences =="
rg -n "abortController" "$FILE" || true

echo
echo "== context: lines 1-220 =="
nl -ba "$FILE" | sed -n '1,260p'
echo
echo "== context: lines 220-420 =="
nl -ba "$FILE" | sed -n '220,420p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 559


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/api/providers/base-openai-compatible-provider.ts"

echo "== abortController occurrences =="
rg -n "abortController" "$FILE" || true

echo
echo "== context: lines 1-260 =="
nl -ba "$FILE" | sed -n '1,260p'

echo
echo "== context: lines 260-460 =="
nl -ba "$FILE" | sed -n '260,460p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 559


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/api/providers/base-openai-compatible-provider.ts"

echo "== show code around abortController in base-openai-compatible-provider.ts =="
# print with line numbers using awk
awk 'NR>=1 && NR<=320 {printf "%4d:%s\n", NR, $0}' "$FILE" | sed -n '1,260p'
echo
echo "== specific ranges (100-190) =="
awk 'NR>=90 && NR<=200 {printf "%4d:%s\n", NR, $0}' "$FILE"
echo
echo "== specific ranges (210-310) =="
awk 'NR>=210 && NR<=310 {printf "%4d:%s\n", NR, $0}' "$FILE"

echo
echo "== search for api handler instance creation in src/api =="
rg -n "getProvider|createProvider|createHandler|new .*Provider\\(|BaseOpenAiCompatibleProvider" src/api --type=ts || true

echo
echo "== search Task.ts for createMessage/completePrompt call sites =="
rg -n "createMessage\\(|completePrompt\\(" src/core/task/Task.ts --type=ts || true

echo
echo "== show Task.ts around createMessage call (near reported line 4167) =="
awk 'NR>=4120 && NR<=4225 {printf "%5d:%s\n", NR, $0}' src/core/task/Task.ts || true

echo
echo "== search for Promise.all or other concurrency patterns around API calls =="
rg -n "Promise\\.all|allSettled|await .*\\[|concurrent|subtask|parallel" src/core/task/Task.ts --type=ts || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 29277


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/api/providers/base-openai-compatible-provider.ts"

echo "== show code around abortController in base-openai-compatible-provider.ts =="
awk 'NR>=1 && NR<=310 {printf "%4d:%s\n", NR, $0}' "$FILE" | sed -n '1,160p'

echo
echo "== specific ranges (100-190) =="
awk 'NR>=90 && NR<=200 {printf "%4d:%s\n", NR, $0}' "$FILE"

echo
echo "== specific ranges (210-310) =="
awk 'NR>=200 && NR<=310 {printf "%4d:%s\n", NR, $0}' "$FILE"

echo
echo "== search for api handler instance creation in src/api =="
rg -n "getProvider|createProvider|createHandler|new .*Provider\\(|BaseOpenAiCompatibleProvider" src/api --type=ts || true

echo
echo "== search Task.ts for createMessage/completePrompt call sites =="
rg -n "createMessage\\(|completePrompt\\(" src/core/task/Task.ts --type=ts || true

echo
echo "== show Task.ts around createMessage call (near reported line 4167) =="
awk 'NR>=4140 && NR<=4210 {printf "%5d:%s\n", NR, $0}' src/core/task/Task.ts

echo
echo "== search for concurrency patterns in nearby Task.ts region =="
awk 'NR>=4050 && NR<=4300 {print NR":"$0}' src/core/task/Task.ts | rg -n "Promise\\.all|allSettled|parallel|concurrent|subtask" || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 23269


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/api/providers/base-openai-compatible-provider.ts"

echo "== show code around abortController in base-openai-compatible-provider.ts =="
awk 'NR>=1 && NR<=310 {printf "%4d:%s\n", NR, $0}' "$FILE" | sed -n '1,160p'

echo
echo "== specific ranges (100-190) =="
awk 'NR>=90 && NR<=200 {printf "%4d:%s\n", NR, $0}' "$FILE"

echo
echo "== specific ranges (200-310) =="
awk 'NR>=200 && NR<=310 {printf "%4d:%s\n", NR, $0}' "$FILE"

echo
echo "== search for api handler instance creation in src/api =="
rg -n "getProvider|createProvider|createHandler|new .*Provider\\(|BaseOpenAiCompatibleProvider" src/api --type=ts || true

echo
echo "== search Task.ts for createMessage/completePrompt call sites =="
rg -n "createMessage\\(|completePrompt\\(" src/core/task/Task.ts --type=ts || true

echo
echo "== show Task.ts around createMessage call (near reported line 4167) =="
awk 'NR>=4140 && NR<=4210 {printf "%5d:%s\n", NR, $0}' src/core/task/Task.ts

echo
echo "== search for concurrency patterns in nearby Task.ts region =="
awk 'NR>=4050 && NR<=4300 {print NR":"$0}' src/core/task/Task.ts | rg -n "Promise\\.all|allSettled|parallel|concurrent|subtask" || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 23269


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/api/providers/base-openai-compatible-provider.ts"

echo "== specific ranges (90-200) =="
awk 'NR>=90 && NR<=200 {printf "%4d:%s\n", NR, $0}' "$FILE"

echo
echo "== specific ranges (200-310) =="
awk 'NR>=200 && NR<=310 {printf "%4d:%s\n", NR, $0}' "$FILE"

echo
echo "== search api handler instance creation in src/api =="
rg -n "getProvider|createProvider|createHandler|new .*Provider\\(|BaseOpenAiCompatibleProvider" src/api --type=ts || true

echo
echo "== search Task.ts for createMessage/completePrompt call sites =="
rg -n "createMessage\\(|completePrompt\\(" src/core/task/Task.ts --type=ts || true

echo
echo "== show Task.ts around createMessage call (near 4160-4210) =="
awk 'NR>=4140 && NR<=4210 {printf "%5d:%s\n", NR, $0}' src/core/task/Task.ts

echo
echo "== search for Promise.all / concurrent patterns in Task.ts =="
rg -n "Promise\\.all|allSettled|concurrent|parallel|subtask" src/core/task/Task.ts --type=ts || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 17909


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/api/providers/base-openai-compatible-provider.ts"

echo "== specific ranges (90-200) =="
awk 'NR>=90 && NR<=200 {printf "%4d:%s\n", NR, $0}' "$FILE"

echo
echo "== specific ranges (200-310) =="
awk 'NR>=200 && NR<=310 {printf "%4d:%s\n", NR, $0}' "$FILE"

echo
echo "== search api handler instance creation in src/api =="
rg -n "getProvider|createProvider|createHandler|new .*Provider\\(|BaseOpenAiCompatibleProvider" src/api --type=ts || true

echo
echo "== search Task.ts for createMessage/completePrompt call sites =="
rg -n "createMessage\\(|completePrompt\\(" src/core/task/Task.ts --type=ts || true

echo
echo "== show Task.ts around createMessage call (near 4140-4210) =="
awk 'NR>=4140 && NR<=4210 {printf "%5d:%s\n", NR, $0}' src/core/task/Task.ts

echo
echo "== search for Promise.all / concurrent patterns in Task.ts =="
rg -n "Promise\\.all|allSettled|concurrent|parallel|subtask" src/core/task/Task.ts --type=ts || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 17909


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Look for .abort usage on abortController or related cancellation in the provider
FILE="src/api/providers/base-openai-compatible-provider.ts"
echo "== abort() calls in base-openai-compatible-provider.ts =="
rg -n "abortController\\?\\.abort|abortController\\.abort|\\.abort\\(" "$FILE" || true

echo
echo "== abortController usage (broader) =="
rg -n "abortController" "$FILE"

echo
echo "== search for currentRequestAbortController usage in Task.ts =="
rg -n "currentRequestAbortController" src/core/task/Task.ts --type=ts || true

echo
echo "== search for anything that calls api/provider abort in Task.ts near abort handling =="
rg -n "abort\\(|cancel\\(|AbortSignal|Request cancelled" src/core/task/Task.ts --type=ts | head -n 80

echo
echo "== inspect Task.ts around where abort might be handled (recent lines) =="
awk 'NR>=1060 && NR<=1220 {printf "%5d:%s\n", NR, $0}' src/core/task/Task.ts | sed -n '1,200p'

echo
echo "== inspect Task.ts around abort logic near attemptApiRequest loop start =="
rg -n "abort" src/core/task/Task.ts --type=ts | head -n 50

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 12365


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Look for how cancellation is actually triggered (abort() calls) in the provider
FILE="src/api/providers/base-openai-compatible-provider.ts"
echo "== abort() calls in base-openai-compatible-provider.ts =="
rg -n "abortController\\?\\.abort|abortController\\.abort|\\.abort\\(" "$FILE" || true

echo
echo "== abortController usage (broader) =="
rg -n "abortController" "$FILE"

echo
echo "== search for currentRequestAbortController usage in Task.ts =="
rg -n "currentRequestAbortController" src/core/task/Task.ts --type=ts || true

echo
echo "== search for cancellation/error text in Task.ts =="
rg -n "Request cancelled by user|AbortSignal|AbortSignal|cancel|abort\\(" src/core/task/Task.ts --type=ts | head -n 80

echo
echo "== inspect Task.ts around createMessage abort handling =="
awk 'NR>=4120 && NR<=4245 {printf "%5d:%s\n", NR, $0}' src/core/task/Task.ts

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 10154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find how Task assigns/creates this.api (the handler/provider instance)
rg -n "this\\.api\\s*=" src/core/task/Task.ts --type=ts || true
echo
rg -n "api\\b.*new|create.*api|getApi|provider.*api" src/core/task/Task.ts --type=ts || true
echo
rg -n "class Task|constructor\\(" src/core/task/Task.ts --type=ts | head -n 50 || true
echo
# Find type of this.api property declaration
rg -n "api\\?:" src/core/task/Task.ts --type=ts || true
echo
# Search for assignments to this.api.current or similar
rg -n "this\\.api\\." src/core/task/Task.ts --type=ts | head -n 50 || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 1697


🏁 Script executed:

#!/bin/bash
set -euo pipefail

TASK="src/core/task/Task.ts"

echo "== show Task.ts around this.api assignment at ~440-520 =="
awk 'NR>=440 && NR<=520 {print NR":"$0}' "$TASK"

echo
echo "== show Task.ts around this.api assignment at ~1400-1460 =="
awk 'NR>=1380 && NR<=1460 {print NR":"$0}' "$TASK"

echo
echo "== find Task.ts call sites for completePrompt on this.api =="
rg -n "this\\.api\\.(completePrompt|createMessage)\\(" "$TASK" --type=ts || true

echo
echo "== find buildApiHandler definition =="
rg -n "function buildApiHandler|buildApiHandler\\(" src/api --type=ts || true

echo
echo "== inspect buildApiHandler implementation (if found) =="
# try to locate file containing buildApiHandler
FILE=$(rg -l "buildApiHandler\\(" src/api --type=ts | head -n 1 || true)
if [ -n "${FILE:-}" ]; then
  echo "Found: $FILE"
  awk 'NR>=1 && NR<=220 {print NR":"$0}' "$FILE" | sed -n '1,220p'
else
  echo "No buildApiHandler implementation found under src/api"
fi

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 13351


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for new Task() usage (subtasks likely)
rg -n "new Task\\(" src/core/task/Task.ts src/core/task -S --type=ts || true

# Search where request loop calls completePrompt (condense / summarization)
rg -n "\\.completePrompt\\(" src/core src/api -S --type=ts | head -n 100 || true

# Search for initiateTaskLoop / parallel subtask execution patterns
rg -n "initiateTaskLoop\\(|subtask|delegateParentAndOpenChild|childTask|currentTask|rootTaskId|parentTaskId" src/core/task/Task.ts --type=ts | head -n 120 || true

# Inspect relevant parts around initiateTaskLoop and any subtask branching
awk 'NR>=2200 && NR<=2400 {print NR":"$0}' src/core/task/Task.ts | sed -n '1,220p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 28161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== rg for completePrompt call sites =="
rg -n "completePrompt\\(" src/core src/api --type=ts -S --glob '!**/*.spec.ts' --glob '!**/__tests__/**' | head -n 200

echo
echo "== rg for this.api.completePrompt =="
rg -n "this\\.api\\.completePrompt\\(" src/core --type=ts -S || true

echo
echo "== rg for apiHandler.completePrompt =="
rg -n "apiHandler\\.?completePrompt\\(" src/core --type=ts -S || true

echo
echo "== Show condense/index.ts completePrompt call area =="
rg -n "completePrompt" src/core/condense/index.ts --type=ts || true
if [ -f src/core/condense/index.ts ]; then
  awk 'NR>=280 && NR<=380 {print NR":"$0}' src/core/condense/index.ts
fi

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 6788


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== rg for completePrompt call sites =="
rg -n "completePrompt\\(" src/core src/api --type=ts -S --glob '!**/*.spec.ts' --glob '!**/__tests__/**' | head -n 200

echo
echo "== rg for this.api.completePrompt =="
rg -n "this\\.api\\.completePrompt\\(" src/core --type=ts -S || true

echo
echo "== rg for apiHandler.completePrompt =="
rg -n "apiHandler\\.?completePrompt\\(" src/core --type=ts -S || true

echo
echo "== Show condense/index.ts completePrompt call area =="
rg -n "completePrompt" src/core/condense/index.ts --type=ts || true
if [ -f src/core/condense/index.ts ]; then
  awk 'NR>=280 && NR<=380 {print NR":"$0}' src/core/condense/index.ts
fi

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 6788


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== find usage of single-completion-handler.ts =="
rg -n "single-completion-handler|SingleCompletionHandler" src --type=ts -S --glob '!**/*.spec.ts' --glob '!**/__tests__/**' || true

echo
echo "== find completePrompt call sites in non-test code =="
rg -n "completePrompt\\(" src --type=ts -S --glob '!**/*.spec.ts' --glob '!**/__tests__/**' || true

echo
echo "== show src/utils/single-completion-handler.ts =="
sed -n '1,200p' src/utils/single-completion-handler.ts

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 10262


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "404|fixes `#404`|AbortSignal|cancelCurrentRequest|currentRequestAbortController|abortController\\." src --type=ts --glob '!**/*.spec.ts' --glob '!**/__tests__/**' || true
echo "--- tests ---"
rg -n "404|fixes `#404`|AbortSignal|currentRequestAbortController|cancelCurrentRequest|abortController\\." src --type=ts -S --glob '**/*.spec.ts' --glob '**/__tests__/**/*.ts' | head -n 120 || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 11745


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/api/providers/openai-compatible.ts"

echo "== abortController occurrences in openai-compatible.ts =="
rg -n "abortController" "$FILE" --type=ts || true

echo
echo "== show code around createMessage/completePrompt abort usage =="
# show around each line with abortController
rg -n "abortController" "$FILE" --type=ts | cut -d: -f1 | while read -r ln; do
  start=$((ln-15)); end=$((ln+20));
  echo "--- around $ln ($start-$end) ---"
  awk -v s=$start -v e=$end 'NR>=s && NR<=e {print NR":"$0}' "$FILE"
  echo
done

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 8912


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for condense entrypoints and how they’re triggered from Task
rg -n "condense" src/core/task/Task.ts --type=ts -S || true
rg -n "condense.*(Task|Context)|condenseTask|condense.*context|SUMMARY_PROMPT" src/core --type=ts -S || true

# Find where condense/index.ts is imported/called
rg -n "from \"\\.\\./condense|from \"\\.\\./\\.\\./condense|condense/index|core/condense" src/core --type=ts -S || true

# Specifically search for uses of condense that might run without awaiting (e.g., void/Promise)
rg -n "void .*condense|Promise\\..*condense|condense\\(.*\\)\\s*;" src/core --type=ts -S || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 13097


🏁 Script executed:

#!/bin/bash
set -euo pipefail

TASK="src/core/task/Task.ts"
ClineProvider="src/core/webview/ClineProvider.ts"
webviewMsgHandler="src/core/webview/webviewMessageHandler.ts"
condenseIdx="src/core/condense/index.ts"
contextMgmt="src/core/context-management/index.ts"

echo "== Task.ts: condenseContext() implementation (~1480-1705) =="
awk 'NR>=1460 && NR<=1710 {print NR":"$0}' "$TASK" | sed -n '1,260p'

echo
echo "== Task.ts: condenseContext invocation / context management (~3680-4100) =="
awk 'NR>=3680 && NR<=4105 {print NR":"$0}' "$TASK" | sed -n '1,340p'

echo
echo "== ClineProvider.ts: condenseTaskContext() (~1770-1835) =="
awk 'NR>=1760 && NR<=1845 {print NR":"$0}' "$ClineProvider"

echo
echo "== webviewMessageHandler.ts: condenseTaskContextRequest handling (~780-840) =="
awk 'NR>=760 && NR<=840 {print NR":"$0}' "$webviewMsgHandler"

echo
echo "== condense/index.ts: summarizeConversation signature & how it calls apiHandler.createMessage (~90-390) =="
awk 'NR>=1 && NR<=390 {print NR":"$0}' "$condenseIdx" | sed -n '1,220p'

echo
echo "== context-management/index.ts: where condensing runs (~250-380 and surrounding) =="
rg -n "condenseContext|summarizeConversation|truncate|autoCondenseContext" "$contextMgmt" --type=ts -S
awk 'NR>=250 && NR<=430 {print NR":"$0}' "$contextMgmt" | sed -n '1,240p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 44821


Avoid shared abortController across overlapping createMessage/completePrompt calls
BaseOpenAiCompatibleProvider stores a single abortController on the handler instance, but both createMessage and completePrompt overwrite/read/clear it (this.abortController = new AbortController(), this.abortController?.signal.aborted, and finally { this.abortController = undefined }). If the same handler instance can serve overlapping requests (e.g., Task.condenseContext()/context management calls this.api.createMessage() while the main request is still streaming, and can be triggered via condenseTaskContextRequest), calls can consult/clear the wrong controller. Use a per-invocation local AbortController/signal and keep abort checks tied to that local controller (don’t rely on a shared instance field).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/base-openai-compatible-provider.ts` around lines 39 - 40,
BaseOpenAiCompatibleProvider currently uses an instance field abortController
shared across requests; change createMessage and completePrompt to create a
local AbortController (const abortController = new AbortController()) and use
its signal for all abort checks and API calls within that invocation instead of
this.abortController, pass that local signal into any fetch/SDK options, and
remove any writes/clears to this.abortController so finally blocks do not unset
a controller used by another concurrent call; keep the per-invocation signal
local throughout the method and only check/clear that local controller.

Comment on lines +120 to 217
// Create AbortController for cancellation (fixes #404)
this.abortController = new AbortController()

const matcher = new TagMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)

let lastUsage: OpenAI.CompletionUsage | undefined
const activeToolCallIds = new Set<string>()
try {
const stream = await this.createStream(systemPrompt, messages, metadata, {
signal: this.abortController.signal,
})

const matcher = new TagMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)

let lastUsage: OpenAI.CompletionUsage | undefined
const activeToolCallIds = new Set<string>()

for await (const chunk of stream) {
// Check if request was aborted (fixes #404)
if (this.abortController?.signal.aborted) {
break
}

for await (const chunk of stream) {
// Check for provider-specific error responses (e.g., MiniMax base_resp)
const chunkAny = chunk as any
if (chunkAny.base_resp?.status_code && chunkAny.base_resp.status_code !== 0) {
throw new Error(
`${this.providerName} API Error (${chunkAny.base_resp.status_code}): ${chunkAny.base_resp.status_msg || "Unknown error"}`,
)
}
// Check for provider-specific error responses (e.g., MiniMax base_resp)
const chunkAny = chunk as any
if (chunkAny.base_resp?.status_code && chunkAny.base_resp.status_code !== 0) {
throw new Error(
`${this.providerName} API Error (${chunkAny.base_resp.status_code}): ${chunkAny.base_resp.status_msg || "Unknown error"}`,
)
}

const delta = chunk.choices?.[0]?.delta
const finishReason = chunk.choices?.[0]?.finish_reason
const delta = chunk.choices?.[0]?.delta
const finishReason = chunk.choices?.[0]?.finish_reason

if (delta?.content) {
for (const processedChunk of matcher.update(delta.content)) {
yield processedChunk
if (delta?.content) {
for (const processedChunk of matcher.update(delta.content)) {
yield processedChunk
}
}
}

if (delta) {
for (const key of ["reasoning_content", "reasoning"] as const) {
if (key in delta) {
const reasoning_content = ((delta as any)[key] as string | undefined) || ""
if (reasoning_content?.trim()) {
yield { type: "reasoning", text: reasoning_content }
if (delta) {
for (const key of ["reasoning_content", "reasoning"] as const) {
if (key in delta) {
const reasoning_content = ((delta as any)[key] as string | undefined) || ""
if (reasoning_content?.trim()) {
yield { type: "reasoning", text: reasoning_content }
}
break
}
break
}
}
}

// Emit raw tool call chunks - NativeToolCallParser handles state management
if (delta?.tool_calls) {
for (const toolCall of delta.tool_calls) {
if (toolCall.id) {
activeToolCallIds.add(toolCall.id)
// Emit raw tool call chunks - NativeToolCallParser handles state management
if (delta?.tool_calls) {
for (const toolCall of delta.tool_calls) {
if (toolCall.id) {
activeToolCallIds.add(toolCall.id)
}
yield {
type: "tool_call_partial",
index: toolCall.index,
id: toolCall.id,
name: toolCall.function?.name,
arguments: toolCall.function?.arguments,
}
}
yield {
type: "tool_call_partial",
index: toolCall.index,
id: toolCall.id,
name: toolCall.function?.name,
arguments: toolCall.function?.arguments,
}

// Emit tool_call_end events when finish_reason is "tool_calls"
// This ensures tool calls are finalized even if the stream doesn't properly close
if (finishReason === "tool_calls" && activeToolCallIds.size > 0) {
for (const id of activeToolCallIds) {
yield { type: "tool_call_end", id }
}
activeToolCallIds.clear()
}
}

// Emit tool_call_end events when finish_reason is "tool_calls"
// This ensures tool calls are finalized even if the stream doesn't properly close
if (finishReason === "tool_calls" && activeToolCallIds.size > 0) {
for (const id of activeToolCallIds) {
yield { type: "tool_call_end", id }
if (chunk.usage) {
lastUsage = chunk.usage
}
activeToolCallIds.clear()
}

if (chunk.usage) {
lastUsage = chunk.usage
if (lastUsage) {
yield this.processUsageMetrics(lastUsage, this.getModel().info)
}
}

if (lastUsage) {
yield this.processUsageMetrics(lastUsage, this.getModel().info)
}

// Process any remaining content
for (const processedChunk of matcher.final()) {
yield processedChunk
// Process any remaining content
for (const processedChunk of matcher.final()) {
yield processedChunk
}
} catch (error) {
throw handleOpenAIError(error, this.providerName)
} finally {
this.abortController = undefined
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for any abort() invocations or public abort surface tied to these providers
rg -nP '\.abort\s*\(' --type=ts -g '!**/__tests__/**' -g '!**/*.spec.ts'
echo '--- abortController references ---'
rg -nP 'abortController' --type=ts -C2 -g '!**/*.spec.ts'
echo '--- how Task/stop interacts with providers (createMessage consumers) ---'
rg -nP '\.return\(|abort|cancelStream|stopStream' --type=ts -C2 -g '*[Tt]ask*'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 48095


Call abort() in finally to cancel in-flight streaming on Stop

In src/api/providers/base-openai-compatible-provider.ts, the stream generator passes signal: this.abortController.signal to createStream, but its finally block only clears the reference (this.abortController = undefined) and never calls this.abortController.abort(). So when the consumer stops pulling/returns the generator, cancellation won’t occur via this provider’s AbortController, and the underlying request may keep running.

🐛 Proposed fix
 		} catch (error) {
 			throw handleOpenAIError(error, this.providerName)
 		} finally {
+			// Abort the underlying request if the generator is closed early (e.g. Stop)
+			this.abortController?.abort()
 			this.abortController = undefined
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Create AbortController for cancellation (fixes #404)
this.abortController = new AbortController()
const matcher = new TagMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)
let lastUsage: OpenAI.CompletionUsage | undefined
const activeToolCallIds = new Set<string>()
try {
const stream = await this.createStream(systemPrompt, messages, metadata, {
signal: this.abortController.signal,
})
const matcher = new TagMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)
let lastUsage: OpenAI.CompletionUsage | undefined
const activeToolCallIds = new Set<string>()
for await (const chunk of stream) {
// Check if request was aborted (fixes #404)
if (this.abortController?.signal.aborted) {
break
}
for await (const chunk of stream) {
// Check for provider-specific error responses (e.g., MiniMax base_resp)
const chunkAny = chunk as any
if (chunkAny.base_resp?.status_code && chunkAny.base_resp.status_code !== 0) {
throw new Error(
`${this.providerName} API Error (${chunkAny.base_resp.status_code}): ${chunkAny.base_resp.status_msg || "Unknown error"}`,
)
}
// Check for provider-specific error responses (e.g., MiniMax base_resp)
const chunkAny = chunk as any
if (chunkAny.base_resp?.status_code && chunkAny.base_resp.status_code !== 0) {
throw new Error(
`${this.providerName} API Error (${chunkAny.base_resp.status_code}): ${chunkAny.base_resp.status_msg || "Unknown error"}`,
)
}
const delta = chunk.choices?.[0]?.delta
const finishReason = chunk.choices?.[0]?.finish_reason
const delta = chunk.choices?.[0]?.delta
const finishReason = chunk.choices?.[0]?.finish_reason
if (delta?.content) {
for (const processedChunk of matcher.update(delta.content)) {
yield processedChunk
if (delta?.content) {
for (const processedChunk of matcher.update(delta.content)) {
yield processedChunk
}
}
}
if (delta) {
for (const key of ["reasoning_content", "reasoning"] as const) {
if (key in delta) {
const reasoning_content = ((delta as any)[key] as string | undefined) || ""
if (reasoning_content?.trim()) {
yield { type: "reasoning", text: reasoning_content }
if (delta) {
for (const key of ["reasoning_content", "reasoning"] as const) {
if (key in delta) {
const reasoning_content = ((delta as any)[key] as string | undefined) || ""
if (reasoning_content?.trim()) {
yield { type: "reasoning", text: reasoning_content }
}
break
}
break
}
}
}
// Emit raw tool call chunks - NativeToolCallParser handles state management
if (delta?.tool_calls) {
for (const toolCall of delta.tool_calls) {
if (toolCall.id) {
activeToolCallIds.add(toolCall.id)
// Emit raw tool call chunks - NativeToolCallParser handles state management
if (delta?.tool_calls) {
for (const toolCall of delta.tool_calls) {
if (toolCall.id) {
activeToolCallIds.add(toolCall.id)
}
yield {
type: "tool_call_partial",
index: toolCall.index,
id: toolCall.id,
name: toolCall.function?.name,
arguments: toolCall.function?.arguments,
}
}
yield {
type: "tool_call_partial",
index: toolCall.index,
id: toolCall.id,
name: toolCall.function?.name,
arguments: toolCall.function?.arguments,
}
// Emit tool_call_end events when finish_reason is "tool_calls"
// This ensures tool calls are finalized even if the stream doesn't properly close
if (finishReason === "tool_calls" && activeToolCallIds.size > 0) {
for (const id of activeToolCallIds) {
yield { type: "tool_call_end", id }
}
activeToolCallIds.clear()
}
}
// Emit tool_call_end events when finish_reason is "tool_calls"
// This ensures tool calls are finalized even if the stream doesn't properly close
if (finishReason === "tool_calls" && activeToolCallIds.size > 0) {
for (const id of activeToolCallIds) {
yield { type: "tool_call_end", id }
if (chunk.usage) {
lastUsage = chunk.usage
}
activeToolCallIds.clear()
}
if (chunk.usage) {
lastUsage = chunk.usage
if (lastUsage) {
yield this.processUsageMetrics(lastUsage, this.getModel().info)
}
}
if (lastUsage) {
yield this.processUsageMetrics(lastUsage, this.getModel().info)
}
// Process any remaining content
for (const processedChunk of matcher.final()) {
yield processedChunk
// Process any remaining content
for (const processedChunk of matcher.final()) {
yield processedChunk
}
} catch (error) {
throw handleOpenAIError(error, this.providerName)
} finally {
this.abortController = undefined
}
// Create AbortController for cancellation (fixes `#404`)
this.abortController = new AbortController()
try {
const stream = await this.createStream(systemPrompt, messages, metadata, {
signal: this.abortController.signal,
})
const matcher = new TagMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)
let lastUsage: OpenAI.CompletionUsage | undefined
const activeToolCallIds = new Set<string>()
for await (const chunk of stream) {
// Check if request was aborted (fixes `#404`)
if (this.abortController?.signal.aborted) {
break
}
// Check for provider-specific error responses (e.g., MiniMax base_resp)
const chunkAny = chunk as any
if (chunkAny.base_resp?.status_code && chunkAny.base_resp.status_code !== 0) {
throw new Error(
`${this.providerName} API Error (${chunkAny.base_resp.status_code}): ${chunkAny.base_resp.status_msg || "Unknown error"}`,
)
}
const delta = chunk.choices?.[0]?.delta
const finishReason = chunk.choices?.[0]?.finish_reason
if (delta?.content) {
for (const processedChunk of matcher.update(delta.content)) {
yield processedChunk
}
}
if (delta) {
for (const key of ["reasoning_content", "reasoning"] as const) {
if (key in delta) {
const reasoning_content = ((delta as any)[key] as string | undefined) || ""
if (reasoning_content?.trim()) {
yield { type: "reasoning", text: reasoning_content }
}
break
}
}
}
// Emit raw tool call chunks - NativeToolCallParser handles state management
if (delta?.tool_calls) {
for (const toolCall of delta.tool_calls) {
if (toolCall.id) {
activeToolCallIds.add(toolCall.id)
}
yield {
type: "tool_call_partial",
index: toolCall.index,
id: toolCall.id,
name: toolCall.function?.name,
arguments: toolCall.function?.arguments,
}
}
}
// Emit tool_call_end events when finish_reason is "tool_calls"
// This ensures tool calls are finalized even if the stream doesn't properly close
if (finishReason === "tool_calls" && activeToolCallIds.size > 0) {
for (const id of activeToolCallIds) {
yield { type: "tool_call_end", id }
}
activeToolCallIds.clear()
}
if (chunk.usage) {
lastUsage = chunk.usage
}
}
if (lastUsage) {
yield this.processUsageMetrics(lastUsage, this.getModel().info)
}
// Process any remaining content
for (const processedChunk of matcher.final()) {
yield processedChunk
}
} catch (error) {
throw handleOpenAIError(error, this.providerName)
} finally {
// Abort the underlying request if the generator is closed early (e.g. Stop)
this.abortController?.abort()
this.abortController = undefined
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/base-openai-compatible-provider.ts` around lines 120 - 217,
The finally block in the stream-handling code for createStream never calls abort
on the AbortController, so in-flight requests won't be cancelled; update the
finally in the method that creates the stream (where this.abortController is
set) to call this.abortController.abort() if present before clearing it (keep
the existing this.abortController = undefined), ensuring you reference the same
AbortController instance used when calling createStream (symbols:
this.abortController, createStream, and the surrounding try/catch/finally block
in base-openai-compatible-provider.ts).

…rovider specs

The AbortSignal propagation change passes a second argument
`{ signal }` to `chat.completions.create`. Fireworks, SambaNova and
Z AI specs still asserted the second arg as `undefined`, causing 6
failures in platform-unit-test. Update them to match the base provider
spec pattern: `expect.objectContaining({ signal: expect.any(AbortSignal) })`.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/api/providers/__tests__/zai.spec.ts (1)

498-504: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Update ZAI provider tests to include the { signal } argument in toHaveBeenCalledWith assertions

createMessage calls this.client.chat.completions.create(params, { signal }), but several mockCreate.toHaveBeenCalledWith assertions in src/api/providers/__tests__/zai.spec.ts only check the first (params) argument—omitting the second options argument—so they should fail (missing expect.objectContaining({ signal: expect.any(AbortSignal) }) at lines 498-504, 528-534, 559-565, 590-596, 643-649, 673-679).

🐛 Proposed fix (apply the same pattern to each affected assertion)
 			expect(mockCreate).toHaveBeenCalledWith(
 				expect.objectContaining({
 					model: "glm-5.1",
 					max_tokens: 40_000,
 				}),
+				expect.objectContaining({ signal: expect.any(AbortSignal) }),
 			)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/__tests__/zai.spec.ts` around lines 498 - 504, The failing
tests omit the second options argument with the AbortSignal when asserting calls
to the ZAI client: update each mockCreate.toHaveBeenCalledWith(...) in the
zai.spec.ts tests to include the second arg by asserting { signal:
expect.any(AbortSignal) } (e.g. use
toHaveBeenCalledWith(expect.objectContaining({...params...}),
expect.objectContaining({ signal: expect.any(AbortSignal) })); this matches how
createMessage calls this.client.chat.completions.create(params, { signal }) and
should be applied to the mockCreate assertions mentioned (those referencing
createMessage / this.client.chat.completions.create / mockCreate).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/api/providers/__tests__/zai.spec.ts`:
- Around line 498-504: The failing tests omit the second options argument with
the AbortSignal when asserting calls to the ZAI client: update each
mockCreate.toHaveBeenCalledWith(...) in the zai.spec.ts tests to include the
second arg by asserting { signal: expect.any(AbortSignal) } (e.g. use
toHaveBeenCalledWith(expect.objectContaining({...params...}),
expect.objectContaining({ signal: expect.any(AbortSignal) })); this matches how
createMessage calls this.client.chat.completions.create(params, { signal }) and
should be applied to the mockCreate assertions mentioned (those referencing
createMessage / this.client.chat.completions.create / mockCreate).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3e741f9d-aa12-4c6b-b8cf-b5eefd492dcb

📥 Commits

Reviewing files that changed from the base of the PR and between b7156e8 and 6c051fd.

📒 Files selected for processing (3)
  • src/api/providers/__tests__/fireworks.spec.ts
  • src/api/providers/__tests__/sambanova.spec.ts
  • src/api/providers/__tests__/zai.spec.ts

@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 1, 2026

Codecov Report

❌ Patch coverage is 88.33333% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...c/api/providers/base-openai-compatible-provider.ts 86.48% 10 Missing ⚠️
src/api/providers/openai-compatible.ts 91.30% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Stop does not work on OpenAI Compatible API Provider

1 participant