Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d298d4a
feat(api): abort signal support for requesty (completePrompt + shared…
easonliang28 Sep 5, 2026
ebd5404
test(api): strengthen requesty abort assertions and share settle guar…
easonliang28 Sep 5, 2026
f649508
feat(api): abort signal support for requesty (createMessage + kill te…
easonliang28 Sep 5, 2026
3162144
test(api): assert requesty createMessage listener removal identity
easonliang28 Sep 5, 2026
64ccc46
refactor(api): type requesty createMessage usage and strengthen liste…
easonliang28 Sep 5, 2026
7c8e729
test(api): assert requesty per-request signal identity and failure-pa…
easonliang28 Sep 5, 2026
f8c87ed
Merge branch 'main' into feat/abort-r1-requesty-createMessage
easonLiangWorldedtech Sep 5, 2026
48b232a
Merge branch main into feat/abort-r1-requesty-createMessage
easonliang28 Sep 6, 2026
23f386e
fix(api): thread cancellation signal and bounded timeout into Request…
easonliang28 Sep 7, 2026
7017290
chore: re-trigger CodeRabbit review
easonliang28 Sep 11, 2026
d70c2e7
fix(api): stop Requesty stream yields after a mid-chunk abort
easonliang28 Sep 11, 2026
28ded4a
fix(api): isolate Requesty model lookup from per-request abort signals
easonliang28 Sep 11, 2026
8961128
Merge remote-tracking branch 'upstream/main' into feat/abort-r1-reque…
easonliang28 Sep 11, 2026
96faf0c
Merge upstream/main into feat/abort-r1-requesty-createMessage
easonliang28 Sep 27, 2026
c6b5845
fix: enforce wall-clock deadline on Requesty model discovery via merg…
easonliang28 Sep 27, 2026
c0b23f7
Merge branch 'main' into feat/abort-r1-requesty-createMessage
easonLiangWorldedtech Sep 28, 2026
33be545
Merge remote-tracking branch 'upstream/main' into feat/abort-r1-reque…
Oct 2, 2026
ddedd78
Merge commit 'refs/shapes/upstream-main' into feat/abort-r1-requesty-…
Oct 2, 2026
6d5a4bd
Merge upstream main (3859e5dd8) into feat/abort-r1-requesty-createMes…
Oct 4, 2026
558cf38
Re-trigger review at the current head
Oct 4, 2026
5263b2a
Merge branch 'main' into feat/abort-r1-requesty-createMessage
easonLiangWorldedtech Oct 5, 2026
127a6c5
Merge upstream main (285f6687a) into feat/abort-r1-requesty-createMes…
easonliang28 Oct 13, 2026
145da11
Address review: assert the merged signal's runtime type
easonliang28 Oct 13, 2026
fe5f99b
Merge upstream/main (58341d882) into feat/abort-r1-requesty-createMes…
easonliang28 Oct 14, 2026
b0d338e
chore(api): remove Stryker suppression comments from touched files
easonliang28 Oct 14, 2026
27cdc39
fix(api): reject non-finite and cap overflowing mergeAbortSignalAndTi…
easonliang28 Oct 14, 2026
27c6468
fix(api): round fractional timeouts before AbortSignal.timeout
easonliang28 Oct 14, 2026
7445a89
test(api): assert the rounded delay reaching AbortSignal.timeout
easonliang28 Oct 14, 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
1,080 changes: 1,055 additions & 25 deletions src/api/providers/__tests__/requesty.spec.ts

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions src/api/providers/fetchers/__tests__/modelCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,29 @@ describe("getModels with new GetModelsOptions", () => {
expect(result).toEqual(mockModels)
})

it("forwards the caller's cancellation signal to Requesty model discovery", async () => {
setupCancellationMocks()
// Keep the fetch pending so the caller's abort lands mid-flight: under the
// single-flight contract the caller's signal detaches this waiter (and rejects
// it with the canonical AbortError) instead of being handed to the fetcher.
const fetchModels = vi.fn().mockImplementation(() => new Promise<ModelRecord>(() => {}))
const handler = makeHandler(REQUESTY_SCOPE, fetchModels)

const controller = new AbortController()
const promise = getModels(
{
provider: providerIdentifiers.requesty,
apiKey: DUMMY_REQUESTY_KEY,
signal: controller.signal,
},
handler,
)

controller.abort()

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

it("dispatches credentialed fetchers through canonical provider identifiers", async () => {
const mockModels = {
"requesty/canonical-model": {
Expand Down
63 changes: 61 additions & 2 deletions src/api/providers/fetchers/__tests__/requesty.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,16 +166,75 @@ describe("getRequestyModels", () => {
expect(sonnet.supportsTemperature).toBeUndefined()
})

it("passes the caller's abort signal to the catalog request", async () => {
it("threads a wall-clock merged signal and the bounded timeout into the models request", async () => {
const controller = new AbortController()
mockAxiosGet.mockResolvedValueOnce({ data: { data: [] } })

await getRequestyModels(undefined, undefined, { signal: controller.signal })

// The shared axios mock accumulates calls across this file's tests, so assert on
// the call this test just made (the last one) rather than a global call count.
const calls = mockAxiosGet.mock.calls
const config = calls[calls.length - 1]?.[1]
expect(config?.signal).toBeInstanceOf(AbortSignal)
// The request signal is the caller's signal merged with the wall-clock
// deadline, not the raw caller signal: axios's timeout option resets when
// headers arrive, so only the merged signal enforces the 10_000 ms limit.
expect(config?.signal).not.toBe(controller.signal)
expect(config?.timeout).toBe(10_000)

// The caller's abort still propagates through the merged signal.
controller.abort()
expect(config?.signal?.aborted).toBe(true)
})

it("applies the bounded timeout as a standalone timeout signal when no signal is provided", async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { data: [] } })

await getRequestyModels()

const calls = mockAxiosGet.mock.calls
const config = calls[calls.length - 1]?.[1]
// Even without a caller signal the request carries the wall-clock deadline
// as its own timeout signal.
expect(config?.signal).toBeInstanceOf(AbortSignal)
expect(config?.signal?.aborted).toBe(false)
expect(config?.timeout).toBe(10_000)
})

it("passes a merged request signal and the bounded timeout to the catalog request", async () => {
const controller = new AbortController()
mockAxiosGet.mockResolvedValueOnce({ data: { data: [] } })

await getRequestyModels(undefined, undefined, { signal: controller.signal })

const calls = mockAxiosGet.mock.calls
const config = calls[calls.length - 1]?.[1]
expect(mockAxiosGet).toHaveBeenCalledWith("https://router.requesty.ai/v1/models", {
headers: {},
signal: controller.signal,
signal: config?.signal,
timeout: 10_000,
})
expect(config?.signal).toBeInstanceOf(AbortSignal)
})

it("aborts the catalog request when the caller signal aborts after the request signal was merged", async () => {
const controller = new AbortController()
let requestSignal: AbortSignal | undefined
mockAxiosGet.mockImplementation(((_url: string, config?: { signal?: AbortSignal }) => {
requestSignal = config?.signal
// Mirror the HTTP client: reject when the request signal fires.
return new Promise<never>((_resolve, reject) => {
requestSignal?.addEventListener("abort", () => reject(new Error("canceled")), { once: true })
})
}) as typeof axios.get)

const fetchPromise = getRequestyModels(undefined, undefined, { signal: controller.signal })
expect(requestSignal).toBeInstanceOf(AbortSignal)
expect(requestSignal).not.toBe(controller.signal)
controller.abort()

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

it("rejects with an AbortError when the signal aborts the pending request", async () => {
Expand Down
18 changes: 16 additions & 2 deletions src/api/providers/fetchers/requesty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import type { ModelInfo } from "@roo-code/types"
import { parseApiPrice } from "../../../shared/cost"
import { toRequestyServiceUrl } from "../../../shared/utils/requesty"

import { throwIfAborted } from "../utils/abort-signal"
import { mergeAbortSignalAndTimeout, throwIfAborted } from "../utils/abort-signal"

// Bounded wall-clock limit for the models discovery request. Without it a hung connection
// would keep the request alive indefinitely; 10_000 ms matches the other in-tree axios
// fetchers (kenari, opencode-go, nanogpt).
const REQUESTY_MODELS_TIMEOUT_MS = 10_000

export async function getRequestyModels(
baseUrl?: string,
Expand All @@ -24,7 +29,16 @@ export async function getRequestyModels(
const resolvedBaseUrl = toRequestyServiceUrl(baseUrl)
const modelsUrl = new URL("v1/models", resolvedBaseUrl)

const response = await axios.get(modelsUrl.toString(), { headers, signal: opts?.signal })
// Axios's own timeout resets once response headers arrive (follow-redirects),
// so a slow body could outlive REQUESTY_MODELS_TIMEOUT_MS. Merge the wall-clock
// deadline into the request signal so the fetch is bounded end to end.
const requestSignal = mergeAbortSignalAndTimeout(opts?.signal, REQUESTY_MODELS_TIMEOUT_MS)

const response = await axios.get(modelsUrl.toString(), {
headers,
signal: requestSignal,
timeout: REQUESTY_MODELS_TIMEOUT_MS,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
const rawModels = response.data.data

for (const rawModel of rawModels) {
Expand Down
Loading
Loading