Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
0e967d4
feat(api): abort signal and timeout support for anthropic, anthropic-…
easonliang28 Aug 19, 2026
dfbc1d8
ci: re-run e2e-mock on flaky restart-persistence (uses openrouter; th…
easonliang28 Aug 19, 2026
81a75d4
fix(api): address CodeRabbit review on anthropic family abort handling
easonliang28 Aug 19, 2026
af279dd
fix(api): type xAI streaming request body with the OpenAI SDK type
easonliang28 Aug 20, 2026
3aec038
Merge branch 'main' into feat/abort-r1-anthropic-family
easonLiangWorldedtech Aug 30, 2026
afed085
fix(api): detach external abort listeners and cover timeoutMs 0
easonliang28 Sep 2, 2026
e290664
Merge remote-tracking branch 'upstream/main' into feat/abort-r1-anthr…
easonliang28 Sep 2, 2026
8afead4
refactor(api): map xai tool_choice to Responses API shape instead of …
easonliang28 Sep 2, 2026
9b23d30
fix(api): flatten xai allowed_tools entries for the Responses API
easonliang28 Sep 2, 2026
8ead9bb
Merge branch 'main' into feat/abort-r1-anthropic-family
edelauna Sep 3, 2026
a741615
test(api): cover abort-bridge and request-body mutation gaps
easonliang28 Sep 5, 2026
7ee64dd
Merge remote-tracking branch 'upstream/main' into feat/abort-r1-anthr…
easonliang28 Sep 5, 2026
262ec50
fix(api): remove dead code flagged by the mutation gate in anthropic,…
easonliang28 Sep 5, 2026
3d92d06
fix(api): attach anthropic abort signal via spread to drop the surviv…
easonliang28 Sep 5, 2026
895660b
fix(api): detach external abort listeners when anthropic/minimax requ…
easonliang28 Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 232 additions & 12 deletions src/api/providers/__tests__/anthropic-vertex.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { VERTEX_1M_CONTEXT_MODEL_IDS } from "@roo-code/types"

import { AnthropicVertexHandler } from "../anthropic-vertex"
import { asyncStreamFrom, collectStream } from "../../../test-utils/stream"
import { makeCreateMessageMetadata } from "../../../test-utils/api"

vitest.mock("../utils/timeout-config", () => ({
getApiRequestTimeout: vitest.fn().mockReturnValue(300_000),
Expand Down Expand Up @@ -746,6 +747,129 @@ describe("VertexHandler", () => {
expect(calledMessages).toHaveLength(2) // Only the two user messages
expect(calledMessages.every((m: any) => m.role === "user")).toBe(true)
})

it("should reject with AbortError when createMessage is called with an already-aborted signal", async () => {
const abortedController = new AbortController()
abortedController.abort()

const mockCreate = vitest
.spyOn(handler["client"].messages, "create")
.mockImplementation((_params: unknown, options?: { signal?: AbortSignal | null }) => {
if (options?.signal?.aborted) {
const error = new Error("The operation was aborted")
error.name = "AbortError"
throw error
}
return asyncStreamFrom([]) as never
})

const stream = handler.createMessage(
systemPrompt,
[{ role: "user", content: "Hello" }],
makeCreateMessageMetadata({ abortSignal: abortedController.signal }),
)

await expect(stream.next()).rejects.toMatchObject({ name: "AbortError" })
})

it("should abort the request when the external signal aborts mid-flight", async () => {
const controller = new AbortController()

const mockCreate = vitest.spyOn(handler["client"].messages, "create").mockImplementation(
(_params: unknown, options?: { signal?: AbortSignal | null }) =>
new Promise<void>((_resolve, reject) => {
const signal = options?.signal
if (!signal) {
return
}
if (signal.aborted) {
const error = new Error("The operation was aborted")
error.name = "AbortError"
reject(error)
return
}
signal.addEventListener(
"abort",
() => {
const error = new Error("The operation was aborted")
error.name = "AbortError"
reject(error)
},
{ once: true },
)
}) as never,
)

const stream = handler.createMessage(
systemPrompt,
[{ role: "user", content: "Hello" }],
makeCreateMessageMetadata({ abortSignal: controller.signal }),
)

const promise = stream.next()
controller.abort()
await expect(promise).rejects.toMatchObject({ name: "AbortError" })
})

it("should remove the external abort listener when the stream completes", async () => {
const handlerWithSignal = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})

const controller = new AbortController()
const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener")
const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener")

const stream = handlerWithSignal.createMessage(
systemPrompt,
[{ role: "user", content: "Hello" }],
makeCreateMessageMetadata({ abortSignal: controller.signal }),
)

await collectStream(stream)

expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
const [event, listener] = addEventListenerSpy.mock.calls[0]
expect(event).toBe("abort")
// The same retained callback must be detached once the stream is done.
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1)
expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", listener)
})

it("should default message_start outputTokens to zero when output_tokens is omitted", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})

const mockCreate = vitest.fn().mockImplementation(async () =>
asyncStreamFrom([
{
type: "message_start",
message: {
usage: {
input_tokens: 10,
},
},
},
]),
)
handler["client"].messages.create = mockCreate

const stream = handler.createMessage(systemPrompt, [{ role: "user", content: "Hello" }])
const chunks = await collectStream(stream)

expect(chunks[0]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 0,
cacheWriteTokens: undefined,
cacheReadTokens: undefined,
})
})
})

describe("completePrompt", () => {
Expand All @@ -758,18 +882,22 @@ describe("VertexHandler", () => {

const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(handler["client"].messages.create).toHaveBeenCalledWith({
model: "claude-3-5-sonnet-v2@20241022",
max_tokens: 8192,
temperature: 0,
messages: [
{
role: "user",
content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }],
},
],
stream: false,
})
expect(handler["client"].messages.create).toHaveBeenCalledWith(
{
model: "claude-3-5-sonnet-v2@20241022",
max_tokens: 8192,
temperature: 0,
messages: [
{
role: "user",
content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }],
},
],
stream: false,
thinking: undefined,
},
undefined,
)
})

it("should handle API errors for Claude", async () => {
Expand Down Expand Up @@ -820,6 +948,98 @@ describe("VertexHandler", () => {
expect(result).toBe("")
})

it("should pass abort signal through to client", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})

const controller = new AbortController()
const mockCreate = vitest
.spyOn(handler["client"].messages, "create")
.mockResolvedValue({ content: [{ type: "text", text: "response" }] } as never)

await handler.completePrompt("test prompt", { abortSignal: controller.signal })

const [, requestOptions] = mockCreate.mock.calls[0]
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: expect.any(String) }),
expect.any(Object),
)
expect(requestOptions?.signal).toBe(controller.signal)
})

it("should work without options (backward compatible)", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})

const mockCreate = vitest
.spyOn(handler["client"].messages, "create")
.mockResolvedValue({ content: [{ type: "text", text: "response" }] } as never)

const result = await handler.completePrompt("test prompt")
expect(result).toBe("response")
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), undefined)
})

it("completePrompt should pass signal through to client", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})

const controller = new AbortController()
const mockCreate = vitest
.spyOn(handler["client"].messages, "create")
.mockResolvedValue({ content: [{ type: "text", text: "response" }] } as never)

await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })

const [, requestOptions] = mockCreate.mock.calls[0]
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: expect.any(String) }),
expect.objectContaining({ timeout: 5000 }),
)
expect(requestOptions?.signal).toBe(controller.signal)
})

it("completePrompt should pass timeoutMs when provided", async () => {
const mockCreate = vitest
.spyOn(handler["client"].messages, "create")
.mockResolvedValue({ content: [{ type: "text", text: "response" }] } as never)

await handler.completePrompt("test prompt", { timeoutMs: 3000 })
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: expect.any(String) }),
expect.objectContaining({ timeout: 3000 }),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

it("completePrompt should pass timeout when timeoutMs=0 (defined check)", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})

const mockCreate = vitest
.spyOn(handler["client"].messages, "create")
.mockResolvedValue({ content: [{ type: "text", text: "response" }] } as never)

await handler.completePrompt("test prompt", { timeoutMs: 0 })
// 0 is a defined value: it must reach the client as `timeout: 0`,
// not be dropped by a truthiness check.
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: expect.any(String) }),
expect.objectContaining({ timeout: 0 }),
)
})

it("should handle empty content array for Claude", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
Expand Down
Loading
Loading