Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions src/api/providers/fetchers/__tests__/deepseek.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,35 @@ describe("getDeepSeekModels", () => {

await expect(getDeepSeekModels("http://127.0.0.1:43123/v1", "mock-key")).rejects.toThrow("HTTP 404: Not Found")
})

it("passes the caller's abort signal to the request", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(JSON.stringify({ data: [] }), { status: 200 }))
const controller = new AbortController()

await getDeepSeekModels(undefined, "test-key", { signal: controller.signal })

expect(fetchSpy).toHaveBeenCalledWith(
"https://api.deepseek.com/models",
expect.objectContaining({ signal: controller.signal }),
)
})

it("rejects instead of serving the static fallback when the signal is aborted during an error response", async () => {
process.env.E2E_MOCK_MODEL_LIST_FALLBACK = "true"
const controller = new AbortController()
controller.abort()

vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response('{"error":{"message":"Not found","type":"not_found"}}', {
status: 404,
statusText: "Not Found",
}),
)

await expect(
getDeepSeekModels("http://127.0.0.1:43123/v1", "mock-key", { signal: controller.signal }),
).rejects.toMatchObject({ name: "AbortError" })
})
})
31 changes: 29 additions & 2 deletions src/api/providers/fetchers/__tests__/kenari.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe("Kenari Fetchers", () => {

expect(mockedAxios.get).toHaveBeenCalledWith("https://kenari.id/v1/models", {
headers: { Authorization: "Bearer test-key" },
timeout: 10_000,
signal: undefined,
})

expect(Object.keys(models).sort()).toEqual(["claude-sonnet-5", "glm-5-2"])
Expand Down Expand Up @@ -100,6 +100,33 @@ describe("Kenari Fetchers", () => {

warnSpy.mockRestore()
})

it("forwards the caller's abort signal to the request", async () => {
mockedAxios.get.mockResolvedValue({ data: { data: [] } })
const controller = new AbortController()

await getKenariModels("test-key", { signal: controller.signal })

expect(mockedAxios.get).toHaveBeenCalledWith("https://kenari.id/v1/models", {
headers: { Authorization: "Bearer test-key" },
signal: controller.signal,
})
})

it("rejects with an AbortError when the signal aborts the pending request", async () => {
const controller = new AbortController()
mockedAxios.get.mockImplementation((_url: string, config?: { signal?: AbortSignal }) => {
// Mirror the HTTP client: a pending request rejects when its signal fires.
return new Promise<never>((_resolve, reject) => {
config?.signal?.addEventListener?.("abort", () => reject(new Error("canceled")), { once: true })
})
})

const fetchPromise = getKenariModels("k", { signal: controller.signal })
controller.abort()

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

describe("parseKenariModel", () => {
Expand All @@ -110,7 +137,7 @@ describe("Kenari Fetchers", () => {

expect(mockedAxios.get).toHaveBeenCalledWith("https://kenari.id/v1/models", {
headers: undefined,
timeout: 10_000,
signal: undefined,
})
})

Expand Down
59 changes: 43 additions & 16 deletions src/api/providers/fetchers/__tests__/litellm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ describe("getLiteLLMModels", () => {
"Content-Type": "application/json",
...DEFAULT_HEADERS,
},
timeout: 5000,
})
})

Expand All @@ -56,7 +55,6 @@ describe("getLiteLLMModels", () => {
"Content-Type": "application/json",
...DEFAULT_HEADERS,
},
timeout: 5000,
})
})

Expand All @@ -77,7 +75,6 @@ describe("getLiteLLMModels", () => {
"Content-Type": "application/json",
...DEFAULT_HEADERS,
},
timeout: 5000,
})
})

Expand All @@ -98,7 +95,6 @@ describe("getLiteLLMModels", () => {
"Content-Type": "application/json",
...DEFAULT_HEADERS,
},
timeout: 5000,
})
})

Expand All @@ -119,7 +115,6 @@ describe("getLiteLLMModels", () => {
"Content-Type": "application/json",
...DEFAULT_HEADERS,
},
timeout: 5000,
})
})

Expand All @@ -140,7 +135,6 @@ describe("getLiteLLMModels", () => {
"Content-Type": "application/json",
...DEFAULT_HEADERS,
},
timeout: 5000,
})
})

Expand All @@ -161,7 +155,6 @@ describe("getLiteLLMModels", () => {
"Content-Type": "application/json",
...DEFAULT_HEADERS,
},
timeout: 5000,
})
})

Expand Down Expand Up @@ -213,7 +206,6 @@ describe("getLiteLLMModels", () => {
"Content-Type": "application/json",
...DEFAULT_HEADERS,
},
timeout: 5000,
})

expect(result).toEqual({
Expand Down Expand Up @@ -334,7 +326,6 @@ describe("getLiteLLMModels", () => {
"Content-Type": "application/json",
...DEFAULT_HEADERS,
},
timeout: 5000,
})
})

Expand Down Expand Up @@ -456,18 +447,54 @@ describe("getLiteLLMModels", () => {
)
})

it("handles timeout parameter correctly", async () => {
it("forwards the caller signal and sends no per-call timeout", async () => {
const mockResponse = { data: { data: [] } }
mockedAxios.get.mockResolvedValue(mockResponse)
const controller = new AbortController()

await getLiteLLMModels("test-api-key", "http://localhost:4000", { signal: controller.signal })

// Exact-shape assertion: an added per-call option (e.g. a reintroduced
// timeout) would fail this match, keeping the request bound single-sourced.
expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/v1/model/info", {
headers: {
Authorization: "Bearer test-api-key",
"Content-Type": "application/json",
...DEFAULT_HEADERS,
},
signal: controller.signal,
})
})

it("passes an undefined signal through when no options are provided", async () => {
const mockResponse = { data: { data: [] } }
mockedAxios.get.mockResolvedValue(mockResponse)

await getLiteLLMModels("test-api-key", "http://localhost:4000")

expect(mockedAxios.get).toHaveBeenCalledWith(
"http://localhost:4000/v1/model/info",
expect.objectContaining({
timeout: 5000,
}),
)
expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/v1/model/info", {
headers: {
Authorization: "Bearer test-api-key",
"Content-Type": "application/json",
...DEFAULT_HEADERS,
},
signal: undefined,
})
})

it("rejects with an AbortError when the signal aborts the pending request", async () => {
const controller = new AbortController()
mockedAxios.get.mockImplementation((_url: string, config?: { signal?: AbortSignal }) => {
// Mirror the HTTP client: a pending request rejects when its signal fires.
return new Promise<never>((_resolve, reject) => {
config?.signal?.addEventListener?.("abort", () => reject(new Error("canceled")), { once: true })
})
})

const fetchPromise = getLiteLLMModels("test-api-key", "http://localhost:4000", { signal: controller.signal })
controller.abort()

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

it("returns empty object when data array is empty", async () => {
Expand Down
46 changes: 40 additions & 6 deletions src/api/providers/fetchers/__tests__/lmstudio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ describe("LMStudio Fetcher", () => {
const result = await getLMStudioModels(baseUrl)

expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`, { signal: undefined })
expect(MockedLMStudioClientConstructor).toHaveBeenCalledTimes(1)
expect(MockedLMStudioClientConstructor).toHaveBeenCalledWith({ baseUrl: lmsUrl })
expect(mockListDownloadedModels).toHaveBeenCalledTimes(1)
Expand All @@ -168,7 +168,7 @@ describe("LMStudio Fetcher", () => {
const result = await getLMStudioModels(baseUrl)

expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`, { signal: undefined })
expect(MockedLMStudioClientConstructor).toHaveBeenCalledTimes(1)
expect(MockedLMStudioClientConstructor).toHaveBeenCalledWith({ baseUrl: lmsUrl })
expect(mockListDownloadedModels).toHaveBeenCalledTimes(1)
Expand Down Expand Up @@ -408,7 +408,7 @@ describe("LMStudio Fetcher", () => {

await getLMStudioModels("")

expect(mockedAxios.get).toHaveBeenCalledWith(`${defaultBaseUrl}/v1/models`)
expect(mockedAxios.get).toHaveBeenCalledWith(`${defaultBaseUrl}/v1/models`, { signal: undefined })
expect(MockedLMStudioClientConstructor).toHaveBeenCalledWith({ baseUrl: defaultLmsUrl })
})

Expand All @@ -420,7 +420,7 @@ describe("LMStudio Fetcher", () => {

await getLMStudioModels(httpsBaseUrl)

expect(mockedAxios.get).toHaveBeenCalledWith(`${httpsBaseUrl}/v1/models`)
expect(mockedAxios.get).toHaveBeenCalledWith(`${httpsBaseUrl}/v1/models`, { signal: undefined })
expect(MockedLMStudioClientConstructor).toHaveBeenCalledWith({ baseUrl: wssLmsUrl })
})

Expand All @@ -442,7 +442,7 @@ describe("LMStudio Fetcher", () => {
const result = await getLMStudioModels(baseUrl)

expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`, { signal: undefined })
expect(MockedLMStudioClientConstructor).not.toHaveBeenCalled()
expect(mockListLoaded).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
Expand All @@ -461,7 +461,7 @@ describe("LMStudio Fetcher", () => {
const result = await getLMStudioModels(baseUrl)

expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`, { signal: undefined })
expect(MockedLMStudioClientConstructor).not.toHaveBeenCalled()
expect(mockListLoaded).not.toHaveBeenCalled()
expect(consoleInfoSpy).toHaveBeenCalledWith(`Error connecting to LMStudio at ${baseUrl}`)
Expand All @@ -488,5 +488,39 @@ describe("LMStudio Fetcher", () => {
expect(result).toEqual({})
consoleErrorSpy.mockRestore()
})

it("should pass the caller's abort signal to the connection probe", async () => {
const controller = new AbortController()
mockedAxios.get.mockResolvedValueOnce({ data: { status: "ok" } })
mockListDownloadedModels.mockResolvedValueOnce([])
mockListLoaded.mockResolvedValueOnce([])

await getLMStudioModels(baseUrl, { signal: controller.signal })

expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`, { signal: controller.signal })
})

it("should reject with an AbortError when the signal aborts the probe, without calling the SDK", async () => {
const controller = new AbortController()
mockedAxios.get.mockImplementation((_url: string, config?: { signal?: AbortSignal }) => {
// Mirror the HTTP client: a request rejects when its signal fires,
// including when the signal was already aborted when the request started.
return new Promise<never>((_resolve, reject) => {
if (config?.signal?.aborted) {
reject(new Error("canceled"))
return
}
config?.signal?.addEventListener?.("abort", () => reject(new Error("canceled")), { once: true })
})
})

const fetchPromise = getLMStudioModels(baseUrl, { signal: controller.signal })
controller.abort()

await expect(fetchPromise).rejects.toMatchObject({ name: "AbortError" })
expect(MockedLMStudioClientConstructor).not.toHaveBeenCalled()
expect(mockListDownloadedModels).not.toHaveBeenCalled()
expect(mockListLoaded).not.toHaveBeenCalled()
})
})
})
Loading
Loading