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
55 changes: 49 additions & 6 deletions src/api/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,42 @@ vitest.mock("vscode", () => ({
},
}))

// Handler constructors can require credentials or initialize SDK clients. Replace them
// with inert classes so these tests exercise only the factory's routing behavior.
// Replace handlers with inert classes (no-op constructors) that keep the real prototype
// chain, so tests exercise routing and can still inspect methods such as fetchModels.
const { inertClass } = vitest.hoisted(() => ({
inertClass: (actual: unknown): unknown => {
if (typeof actual !== "function" || !actual.prototype) {
return actual
}
const Inert = class {}
Object.setPrototypeOf(Inert.prototype, actual.prototype)
return Inert
},
}))

vitest.mock("../providers", async () => {
const providers = await vitest.importActual<Record<string, unknown>>("../providers")

return Object.fromEntries(Object.keys(providers).map((name) => [name, class {}]))
return Object.fromEntries(Object.entries(providers).map(([name, actual]) => [name, inertClass(actual)]))
})

vitest.mock("../providers/native-ollama", () => ({
NativeOllamaHandler: class {},
}))
vitest.mock("../providers/native-ollama", async () => {
const actual = await vitest.importActual<typeof import("../providers/native-ollama")>("../providers/native-ollama")

return { NativeOllamaHandler: inertClass(actual.NativeOllamaHandler) }
})

import {
dynamicProviders,
localProviders,
providerIdentifiers,
retiredProviderIdentifiers,
type ProviderName,
type ProviderNameWithRetired,
} from "@roo-code/types"

import type { RouterName } from "../../shared/api"

import { buildApiHandler } from "../index"
import {
AnthropicHandler,
Expand Down Expand Up @@ -155,3 +172,29 @@ describe("buildApiHandler", () => {
expect(handler).toBeInstanceOf(AnthropicHandler)
})
})

// fetchModels is optional on ApiHandler, so this replaces the old fetcher switch's
// exhaustiveness check: every RouterName must build a handler that implements it.
describe("buildApiHandler model catalog coverage", () => {
const routerNames: RouterName[] = [...dynamicProviders, ...localProviders]

it("covers at least one router", () => {
expect(routerNames.length).toBeGreaterThan(0)
})

it.each(routerNames)("returns a handler implementing fetchModels for %s", (apiProvider) => {
const handler = buildApiHandler({ apiProvider })

expect(handler).not.toBeInstanceOf(AnthropicHandler)
expect(typeof handler.fetchModels).toBe("function")
expect(typeof handler.getModelCacheScope).toBe("function")
})

it("does not expose fetchModels on the default fallback handler", () => {
const handler = buildApiHandler({
apiProvider: "unsupported-provider" as ProviderNameWithRetired,
})

expect(handler.fetchModels).toBeUndefined()
})
})
26 changes: 26 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
retiredProviderIdentifiers,
type ProviderSettings,
type ModelInfo,
type ModelRecord,
} from "@roo-code/types"

import { getRouterRemovalMessage } from "../core/config/routerRemoval"
import type { GetModelsOptions } from "../shared/api"
import { ApiStream } from "./transform/stream"

import {
Expand Down Expand Up @@ -117,6 +119,18 @@ export interface ApiHandlerCreateMessageMetadata {
abortSignal?: AbortSignal
}

/**
* Describes how the model cache must key and persist a provider's catalog.
*/
export interface ModelCacheScope {
/** Cache key must include the normalized baseUrl. */
urlScoped: boolean
/** Cache key must include a short, irreversible API-key discriminator. */
keyScoped: boolean
/** Never persist to disk/memory; results are per signed-in user. */
authScoped: boolean
}

export interface ApiHandler {
createMessage(
systemPrompt: string,
Expand Down Expand Up @@ -156,6 +170,18 @@ export interface ApiHandler {
* @returns A promise resolving to the token count
*/
countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number>

/**
* Fetch this provider's live model catalog. Only dynamic-catalog providers
* implement it; others leave it undefined and the model cache rejects.
*/
fetchModels?(options: GetModelsOptions, signal?: AbortSignal): Promise<ModelRecord>

/**
* How the model cache must key and persist this provider's catalog.
* Defaults to all-false in BaseProvider.
*/
getModelCacheScope(): ModelCacheScope
}

export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
Expand Down
11 changes: 9 additions & 2 deletions src/api/providers/__tests__/kenari.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ vitest.mock("../fetchers/modelCache", () => ({
},
}),
),
refreshModels: vitest.fn(async (options) => {
refreshModels: vitest.fn(async (options, handler) => {
const { getModels } = await import("../fetchers/modelCache")
return getModels(options)
return getModels(options, handler)
}),
getModelsFromCache: vitest.fn().mockReturnValue(undefined),
}))
Expand Down Expand Up @@ -371,4 +371,11 @@ describe("KenariHandler", () => {
expect("temperature" in callArgs).toBe(false)
})
})

describe("model catalog", () => {
it("reports its cache scope", () => {
const handler = new KenariHandler(mockOptions)
expect(handler.getModelCacheScope()).toEqual({ urlScoped: false, keyScoped: false, authScoped: false })
})
})
})
11 changes: 9 additions & 2 deletions src/api/providers/__tests__/lite-llm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ vi.mock("../fetchers/modelCache", () => ({
"vertex_ai/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 },
})
}),
refreshModels: vi.fn(async (options) => {
refreshModels: vi.fn(async (options, handler) => {
const { getModels } = await import("../fetchers/modelCache")
return getModels(options)
return getModels(options, handler)
}),
getModelsFromCache: vi.fn().mockReturnValue(undefined),
}))
Expand Down Expand Up @@ -1380,4 +1380,11 @@ describe("LiteLLMHandler", () => {
expect(requestHeaders).not.toHaveProperty("X-Zoo-Session-ID")
})
})

describe("model catalog", () => {
it("reports its cache scope", () => {
const handler = new LiteLLMHandler(mockOptions)
expect(handler.getModelCacheScope()).toEqual({ urlScoped: true, keyScoped: true, authScoped: false })
})
})
})
Loading
Loading