Skip to content
Merged
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
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions webview-ui/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ export default [
"no-case-declarations": "off",
},
},
{
files: ["src/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "axios",
message:
"axios was removed from the webview bundle in favour of global fetch. Use fetch() for network calls. The dependency is no longer declared in webview-ui/package.json, so an import here only resolves through the hoisted store and would fail at build time.",
},
],
},
],
},
},
{
files: ["src/__mocks__/**/*.js"],
rules: {
Expand All @@ -63,6 +80,11 @@ export default [
"error",
{
paths: [
{
name: "axios",
message:
"axios was removed from the webview bundle in favour of global fetch. Use fetch() for network calls. The dependency is no longer declared in webview-ui/package.json, so an import here only resolves through the hoisted store and would fail at build time.",
},
{
name: "@tanstack/react-query",
importNames: ["QueryClientProvider"],
Expand Down
1 change: 0 additions & 1 deletion webview-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
"@vscode/codicons": "^0.0.45",
"@vscode/webview-ui-toolkit": "^1.4.0",
"ansi-to-html": "^0.7.2",
"axios": "^1.18.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import React from "react"

import { stubTransport } from "@src/utils/test-utils"

import { useOpenRouterKeyInfo } from "../useOpenRouterKeyInfo"

const KEY_INFO = {
label: "sk-or-v1-...abc",
usage: 12.5,
is_free_tier: false,
is_provisioning_key: false,
rate_limit: { requests: 60, interval: "1m" },
limit: 100,
}

const wrapper = ({ children }: React.PropsWithChildren) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
{children}
</QueryClientProvider>
)

describe("useOpenRouterKeyInfo", () => {
afterEach(() => {
vi.restoreAllMocks()
})

it("returns undefined and issues no request when apiKey is missing", async () => {
const { calls } = stubTransport(() => ({ status: 200, body: { data: KEY_INFO } }))
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})

const { result } = renderHook(() => useOpenRouterKeyInfo(undefined), { wrapper })

await waitFor(() => expect(result.current.fetchStatus).toBe("idle"))
expect(result.current.data).toBeUndefined()
expect(calls).toHaveLength(0)
expect(consoleError).not.toHaveBeenCalled()
})

it("requests the default key endpoint with a bearer token and returns the parsed key info", async () => {
const { calls } = stubTransport(() => ({ status: 200, body: { data: KEY_INFO } }))

const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test"), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(KEY_INFO)
expect(calls).toEqual([
{
url: "https://openrouter.ai/api/v1/key",
method: "GET",
headers: { authorization: "Bearer sk-or-v1-test" },
},
])
})

it("uses a caller-supplied baseUrl verbatim", async () => {
const { calls } = stubTransport(() => ({ status: 200, body: { data: KEY_INFO } }))

const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test", "https://proxy.example.com/api/v1"), {
wrapper,
})

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(calls[0].url).toBe("https://proxy.example.com/api/v1/key")
})

it("returns null and logs a validation failure when the payload does not match the schema", async () => {
stubTransport(() => ({ status: 200, body: { data: { label: "only-label" } } }))
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})

const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test"), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toBeNull()
expect(consoleError).toHaveBeenCalledWith(
"OpenRouter API key info validation failed:",
expect.objectContaining({
issues: expect.arrayContaining([expect.objectContaining({ path: ["data", "usage"] })]),
}),
)
})

it("returns null and logs a fetch failure when the response is not 2xx", async () => {
stubTransport(() => ({ status: 401, body: { error: { message: "No auth credentials found" } } }))
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})

const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test"), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toBeNull()
expect(consoleError).toHaveBeenCalledWith(
"Error fetching OpenRouter key info:",
expect.objectContaining({ message: "Request failed with status code 401" }),
)
})

it("returns null and logs a fetch failure when the request rejects", async () => {
const failure = new Error("network down")
stubTransport(() => {
throw failure
})
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})

const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test"), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toBeNull()
expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter key info:", failure)
})

it("returns null and logs a fetch failure when a 2xx body is not valid JSON", async () => {
const parseFailure = new SyntaxError("Unexpected token '<', \"<html>\" is not valid JSON")
stubTransport(() => ({ status: 200, body: undefined, jsonRejects: parseFailure }))
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})

const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test"), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toBeNull()
expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter key info:", parseFailure)
})
})
Original file line number Diff line number Diff line change
@@ -1,14 +1,39 @@
import React from "react"
import axios from "axios"
import { QueryClientProvider } from "@tanstack/react-query"
import { renderHook, waitFor } from "@testing-library/react"
import { z } from "zod"
import { QueryClientProvider } from "@tanstack/react-query"
import React from "react"

import { createTestQueryClient } from "@src/utils/test-utils"
import { createTestQueryClient, stubTransport } from "@src/utils/test-utils"

import { useOpenRouterModelProviders } from "../useOpenRouterModelProviders"

vi.mock("axios")
const endpointsPayload = (overrides: Record<string, unknown> = {}) => ({
data: {
id: "vendor/model",
name: "Vendor Model",
description: "A test model",
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
endpoints: [
{
name: "Provider A",
tag: "provider-a",
context_length: 128000,
max_completion_tokens: 4096,
pricing: {
prompt: "0.000003",
completion: "0.000015",
input_cache_read: "0.0000003",
input_cache_write: "0.0000036",
},
},
{
name: "Provider B",
context_length: 32000,
pricing: { prompt: "0.000001", completion: "0.000002" },
},
],
...overrides,
},
})

const wrapper = ({ children }: React.PropsWithChildren) => (
<QueryClientProvider client={createTestQueryClient()}>{children}</QueryClientProvider>
Expand All @@ -19,21 +44,129 @@ describe("useOpenRouterModelProviders", () => {
vi.restoreAllMocks()
})

it("logs the zod issues and returns no providers when validation throws", async () => {
const zodError = new z.ZodError([{ code: "custom", path: ["data"], message: "bad data" }])
vi.mocked(axios.get).mockRejectedValue(zodError)
it("returns an empty record without a request when modelId is missing", async () => {
const { calls } = stubTransport(() => ({ status: 200, body: endpointsPayload() }))

const { result } = renderHook(() => useOpenRouterModelProviders(undefined), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual({})
expect(calls).toHaveLength(0)
})

it("requests the endpoints URL and maps each endpoint to provider model info", async () => {
const { calls } = stubTransport(() => ({ status: 200, body: endpointsPayload() }))

const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))

expect(calls).toEqual([
{ url: "https://openrouter.ai/api/v1/models/vendor/model/endpoints", method: "GET", headers: {} },
])

expect(result.current.data).toEqual({
"provider-a": {
maxTokens: 4096,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: true,
cacheReadsPrice: 0.3,
cacheWritesPrice: expect.closeTo(3.6, 10),
inputPrice: 3,
outputPrice: 15,
description: "A test model",
label: "provider-a",
},
"Provider B": {
maxTokens: 32000,
contextWindow: 32000,
supportsImages: true,
supportsPromptCache: false,
cacheReadsPrice: undefined,
cacheWritesPrice: undefined,
inputPrice: 1,
outputPrice: 2,
description: "A test model",
label: "Provider B",
},
})
})

it.each([
{ name: "text-only architecture", architecture: { input_modalities: ["text"], output_modalities: ["text"] } },
{ name: "omitted architecture", architecture: undefined },
])("disables image support for $name", async ({ architecture }) => {
stubTransport(() => ({ status: 200, body: endpointsPayload({ architecture }) }))

const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toMatchObject({
"provider-a": { supportsImages: false },
"Provider B": { supportsImages: false },
})
})

it("uses a caller-supplied baseUrl", async () => {
const { calls } = stubTransport(() => ({ status: 200, body: endpointsPayload() }))

const { result } = renderHook(
() => useOpenRouterModelProviders("vendor/model", "https://proxy.example.com/api/v1"),
{ wrapper },
)

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(calls[0].url).toBe("https://proxy.example.com/api/v1/models/vendor/model/endpoints")
})

it("returns no providers for image-generation models", async () => {
stubTransport(() => ({
status: 200,
body: endpointsPayload({ architecture: { input_modalities: ["text"], output_modalities: ["image"] } }),
}))

const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual({})
})

it("logs a validation failure and returns no providers when the payload does not match the schema", async () => {
stubTransport(() => ({ status: 200, body: { data: { id: "vendor/model" } } }))
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})

const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual({})
expect(consoleError).toHaveBeenCalledWith(
"OpenRouter API response validation failed:",
expect.objectContaining({
issues: expect.arrayContaining([expect.objectContaining({ path: ["data", "endpoints"] })]),
}),
)
})

it("logs a fetch failure and returns no providers when the response is not 2xx", async () => {
stubTransport(() => ({ status: 500, body: { error: "boom" } }))
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})

const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual({})
expect(consoleError).toHaveBeenCalledWith("OpenRouter API response validation failed:", zodError.issues)
expect(consoleError).toHaveBeenCalledWith(
"Error fetching OpenRouter providers:",
expect.objectContaining({ message: "Request failed with status code 500" }),
)
})

it("logs other errors and returns no providers", async () => {
it("logs a fetch failure and returns no providers when the request rejects", async () => {
const failure = new Error("network down")
vi.mocked(axios.get).mockRejectedValue(failure)
stubTransport(() => {
throw failure
})
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})

const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper })
Expand All @@ -42,4 +175,16 @@ describe("useOpenRouterModelProviders", () => {
expect(result.current.data).toEqual({})
expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter providers:", failure)
})

it("logs a fetch failure and returns no providers when a 2xx body is not valid JSON", async () => {
const parseFailure = new SyntaxError("Unexpected token '<', \"<html>\" is not valid JSON")
stubTransport(() => ({ status: 200, body: undefined, jsonRejects: parseFailure }))
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})

const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual({})
expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter providers:", parseFailure)
})
})
Loading
Loading