diff --git a/src/api/providers/__tests__/poe.spec.ts b/src/api/providers/__tests__/poe.spec.ts index 627d203994..00712924f5 100644 --- a/src/api/providers/__tests__/poe.spec.ts +++ b/src/api/providers/__tests__/poe.spec.ts @@ -1,6 +1,55 @@ -const mockStreamText = vitest.fn() -const mockGenerateText = vitest.fn() -const mockCreatePoe = vitest.fn() +import { poeDefaultModelId, providerIdentifiers } from "@roo-code/types" + +import { PoeHandler } from "../poe" +import { getModelsFromCache } from "../fetchers/modelCache" + +import { clearAllMocks } from "../../../test-utils/reset" + +const { mockStreamText, mockGenerateText, mockCreatePoe, mockGetModelsFromCache, mockCaptureException } = + vitest.hoisted(() => ({ + mockStreamText: vitest.fn(), + mockGenerateText: vitest.fn(), + mockCreatePoe: vitest.fn(), + mockCaptureException: vitest.fn(), + mockGetModelsFromCache: vitest.fn(), + })) + +const cachedModels = { + "anthropic/claude-sonnet-4": { + maxTokens: 10_000, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningBudget: true, + inputPrice: 3, + outputPrice: 15, + }, + "openai/gpt-4o": { + maxTokens: 16_384, + contextWindow: 128_000, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 2.5, + outputPrice: 10, + }, + "openai/o3": { + maxTokens: 100_000, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "medium", "high"], + inputPrice: 10, + outputPrice: 40, + }, +} + +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: (...args: unknown[]) => mockCaptureException(...args), + }, + }, +})) vitest.mock("ai-sdk-provider-poe", () => ({ createPoe: (...args: unknown[]) => mockCreatePoe(...args), @@ -41,41 +90,9 @@ vitest.mock("ai", async (importOriginal) => { }) vitest.mock("../fetchers/modelCache", () => ({ - getModelsFromCache: vitest.fn().mockReturnValue({ - "anthropic/claude-sonnet-4": { - maxTokens: 10_000, - contextWindow: 200_000, - supportsImages: true, - supportsPromptCache: true, - supportsReasoningBudget: true, - inputPrice: 3, - outputPrice: 15, - }, - "openai/gpt-4o": { - maxTokens: 16_384, - contextWindow: 128_000, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 2.5, - outputPrice: 10, - }, - "openai/o3": { - maxTokens: 100_000, - contextWindow: 200_000, - supportsImages: true, - supportsPromptCache: false, - supportsReasoningEffort: ["low", "medium", "high"], - inputPrice: 10, - outputPrice: 40, - }, - }), + getModelsFromCache: mockGetModelsFromCache, })) -import { poeDefaultModelId } from "@roo-code/types" -import { PoeHandler } from "../poe" - -import { clearAllMocks } from "../../../test-utils/reset" - describe("PoeHandler", () => { const mockLanguageModel = { modelId: "test-model" } const mockPoeProvider = vitest.fn().mockReturnValue(mockLanguageModel) @@ -83,6 +100,7 @@ describe("PoeHandler", () => { beforeEach(() => { clearAllMocks() mockCreatePoe.mockReturnValue(mockPoeProvider) + mockGetModelsFromCache.mockReturnValue(cachedModels) }) describe("constructor", () => { @@ -116,9 +134,19 @@ describe("PoeHandler", () => { describe("getModel", () => { it("returns model info from cache", () => { - const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "anthropic/claude-sonnet-4" }) + const options = { + poeApiKey: "key", + poeBaseUrl: "https://custom.poe.com/v1", + apiModelId: "anthropic/claude-sonnet-4", + } + const handler = new PoeHandler(options) const result = handler.getModel() + expect(getModelsFromCache).toHaveBeenCalledWith({ + provider: providerIdentifiers.poe, + apiKey: options.poeApiKey, + baseUrl: options.poeBaseUrl, + }) expect(result.id).toBe("anthropic/claude-sonnet-4") expect(result.info.contextWindow).toBe(200_000) expect(result.info.maxTokens).toBe(10_000) @@ -166,6 +194,49 @@ describe("PoeHandler", () => { expect(chunks).toContainEqual({ type: "text", text: "world!" }) expect(chunks).toContainEqual(expect.objectContaining({ type: "usage", inputTokens: 10, outputTokens: 5 })) }) + + it("reports synchronous completion failures with the canonical provider identifier", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + mockStreamText.mockImplementationOnce(() => { + throw new Error("request failed") + }) + + await expect( + handler.createMessage("system", [{ role: "user" as const, content: "hello" }]).next(), + ).rejects.toThrow("Poe completion error: request failed") + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + provider: providerIdentifiers.poe, + modelId: "openai/gpt-4o", + operation: "createMessage", + }), + ) + }) + + it("reports asynchronous stream failures with the canonical provider identifier", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + const failedStream = { + [Symbol.asyncIterator]() { + return this + }, + next: vitest.fn().mockRejectedValueOnce(new Error("stream failed")), + } + mockStreamText.mockReturnValueOnce({ + fullStream: failedStream, + usage: Promise.resolve(undefined), + }) + + await expect( + handler.createMessage("system", [{ role: "user" as const, content: "hello" }]).next(), + ).rejects.toThrow("Poe streaming error: stream failed") + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + provider: providerIdentifiers.poe, + modelId: "openai/gpt-4o", + operation: "createMessage", + }), + ) + }) }) describe("reasoning", () => { @@ -311,5 +382,21 @@ describe("PoeHandler", () => { }), ) }) + + it("reports failures with the canonical provider identifier", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + mockGenerateText.mockRejectedValueOnce(new Error("generation failed")) + + await expect(handler.completePrompt("complete this")).rejects.toThrow( + "Poe completion error: generation failed", + ) + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + provider: providerIdentifiers.poe, + modelId: "openai/gpt-4o", + operation: "completePrompt", + }), + ) + }) }) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 9d91b25fe5..7b72b1100b 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -24,6 +24,7 @@ import { } from "../../core/prompts/tools/native-tools/converters" import { BaseProvider } from "./base-provider" +import { NOT_PROVIDED } from "./constants" import { parseVertexJsonCredentials } from "./utils/vertex-credentials" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" @@ -38,7 +39,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple this.options = options // https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions - const projectId = this.options.vertexProjectId ?? "not-provided" + const projectId = this.options.vertexProjectId ?? NOT_PROVIDED const region = this.options.vertexRegion ?? "us-east5" const parsedVertexCredentials = parseVertexJsonCredentials(this.options.vertexJsonCredentials) diff --git a/src/api/providers/constants.ts b/src/api/providers/constants.ts index 2269ce9b6c..e3491321e9 100644 --- a/src/api/providers/constants.ts +++ b/src/api/providers/constants.ts @@ -5,3 +5,5 @@ export const DEFAULT_HEADERS = { "X-Title": "Zoo Code", "User-Agent": `ZooCode/${Package.version}`, } + +export const NOT_PROVIDED = "not-provided" diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 12d4afaae2..64782c9fb4 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -17,6 +17,7 @@ import { getModelParams } from "../transform/model-params" import { convertToR1Format } from "../transform/r1-format" import { OpenAiHandler } from "./openai" +import { NOT_PROVIDED } from "./constants" import { extractReasoningFromDelta } from "./utils/extract-reasoning" import type { ApiHandlerCreateMessageMetadata } from "../index" import { handleOpenAIError } from "./utils/error-handler" @@ -84,7 +85,7 @@ export class DeepSeekHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { super({ ...options, - openAiApiKey: options.deepSeekApiKey ?? "not-provided", + openAiApiKey: options.deepSeekApiKey ?? NOT_PROVIDED, openAiModelId: options.apiModelId ?? deepSeekDefaultModelId, openAiBaseUrl: options.deepSeekBaseUrl || "https://api.deepseek.com", openAiStreamingEnabled: true, diff --git a/src/api/providers/fetchers/__tests__/lmstudio.test.ts b/src/api/providers/fetchers/__tests__/lmstudio.test.ts index bf7b57cdbd..789b57096f 100644 --- a/src/api/providers/fetchers/__tests__/lmstudio.test.ts +++ b/src/api/providers/fetchers/__tests__/lmstudio.test.ts @@ -1,9 +1,16 @@ import axios from "axios" import { LMStudioClient, LLMInstanceInfo, LLMInfo } from "@lmstudio/sdk" -import { ModelInfo, lMStudioDefaultModelInfo } from "@roo-code/types" +import { ModelInfo, lMStudioDefaultModelInfo, providerIdentifiers } from "@roo-code/types" -import { getLMStudioModels, parseLMStudioModel } from "../lmstudio" +import { forceFullModelDetailsLoad, getLMStudioModels, hasLoadedFullDetails, parseLMStudioModel } from "../lmstudio" + +const mockFlushModels = vi.hoisted(() => vi.fn()) + +vi.mock("../modelCache", () => ({ + flushModels: mockFlushModels, + getModels: vi.fn(), +})) // Mock axios vi.mock("axios") @@ -13,12 +20,14 @@ const mockedAxios = axios as any const mockGetModelInfo = vi.fn() const mockListLoaded = vi.fn() const mockListDownloadedModels = vi.fn() +const mockLoadModel = vi.fn() vi.mock("@lmstudio/sdk", () => { return { LMStudioClient: vi.fn().mockImplementation(function () { return { llm: { listLoaded: mockListLoaded, + model: mockLoadModel, }, system: { listDownloadedModels: mockListDownloadedModels, @@ -36,6 +45,30 @@ describe("LMStudio Fetcher", () => { mockListLoaded.mockClear() mockGetModelInfo.mockClear() mockListDownloadedModels.mockClear() + mockLoadModel.mockClear() + mockFlushModels.mockClear() + }) + + describe("forceFullModelDetailsLoad", () => { + it("loads the selected model before refreshing its server-scoped cache and recording full details", async () => { + const baseUrl = "https://securehost:4321" + const modelId = "mistralai/devstral-small-2505" + await getLMStudioModels("not a valid URL") + vi.clearAllMocks() + mockedAxios.get.mockResolvedValueOnce({ data: { status: "ok" } }) + mockLoadModel.mockResolvedValueOnce({}) + mockFlushModels.mockResolvedValueOnce(undefined) + + expect(hasLoadedFullDetails(modelId)).toBe(false) + + await forceFullModelDetailsLoad(baseUrl, modelId) + + expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`) + expect(MockedLMStudioClientConstructor).toHaveBeenCalledWith({ baseUrl: "wss://securehost:4321" }) + expect(mockLoadModel).toHaveBeenCalledWith(modelId) + expect(mockFlushModels).toHaveBeenCalledWith({ provider: providerIdentifiers.lmstudio, baseUrl }, true) + expect(hasLoadedFullDetails(modelId)).toBe(true) + }) }) describe("parseLMStudioModel", () => { diff --git a/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts b/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts index b5ff897ec4..300772617b 100644 --- a/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts @@ -1,6 +1,9 @@ // npx vitest run api/providers/fetchers/__tests__/modelEndpointCache.spec.ts import { vi, describe, it, expect, beforeEach } from "vitest" + +import { providerIdentifiers } from "@roo-code/types" + import { getModelEndpoints } from "../modelEndpointCache" import * as modelCache from "../modelCache" import * as openrouter from "../openrouter" @@ -54,7 +57,7 @@ describe("modelEndpointCache", () => { vi.spyOn(openrouter, "getOpenRouterModelEndpoints").mockResolvedValue(mockEndpoints as any) const result = await getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: "anthropic/claude-sonnet-4", endpoint: "anthropic", }) @@ -94,7 +97,7 @@ describe("modelEndpointCache", () => { vi.spyOn(openrouter, "getOpenRouterModelEndpoints").mockResolvedValue(mockEndpoints as any) const result = await getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: "test/model", endpoint: "endpoint-1", }) @@ -122,7 +125,7 @@ describe("modelEndpointCache", () => { vi.spyOn(openrouter, "getOpenRouterModelEndpoints").mockResolvedValue(mockEndpoints as any) const result = await getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: "missing/model", endpoint: "anthropic", }) @@ -134,7 +137,7 @@ describe("modelEndpointCache", () => { it("should return empty object for non-openrouter providers", async () => { const result = await getModelEndpoints({ - router: "vercel-ai-gateway", + router: providerIdentifiers.vercelAiGateway, modelId: "claude-sonnet-4", endpoint: "default", }) @@ -144,13 +147,13 @@ describe("modelEndpointCache", () => { it("should return empty object when modelId or endpoint is missing", async () => { const result1 = await getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: undefined, endpoint: "anthropic", }) const result2 = await getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: "anthropic/claude-sonnet-4", endpoint: undefined, }) diff --git a/src/api/providers/fetchers/lmstudio.ts b/src/api/providers/fetchers/lmstudio.ts index 73cb60e88e..842fe9d08d 100644 --- a/src/api/providers/fetchers/lmstudio.ts +++ b/src/api/providers/fetchers/lmstudio.ts @@ -1,7 +1,7 @@ import axios from "axios" import { LLM, LLMInfo, LLMInstanceInfo, LMStudioClient } from "@lmstudio/sdk" -import { type ModelInfo, lMStudioDefaultModelInfo } from "@roo-code/types" +import { type ModelInfo, lMStudioDefaultModelInfo, providerIdentifiers } from "@roo-code/types" import { flushModels, getModels } from "./modelCache" @@ -19,7 +19,7 @@ export const forceFullModelDetailsLoad = async (baseUrl: string, modelId: string const client = new LMStudioClient({ baseUrl: lmsUrl }) await client.llm.model(modelId) // Flush and refresh cache to get updated model details - await flushModels({ provider: "lmstudio", baseUrl }, true) + await flushModels({ provider: providerIdentifiers.lmstudio, baseUrl }, true) // Mark this model as having full details loaded. modelsWithLoadedDetails.add(modelId) diff --git a/src/api/providers/fetchers/modelEndpointCache.ts b/src/api/providers/fetchers/modelEndpointCache.ts index 06d6234f91..4e85213a6c 100644 --- a/src/api/providers/fetchers/modelEndpointCache.ts +++ b/src/api/providers/fetchers/modelEndpointCache.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import NodeCache from "node-cache" import sanitize from "sanitize-filename" -import type { ModelRecord } from "@roo-code/types" +import { providerIdentifiers, type ModelRecord } from "@roo-code/types" import { ContextProxy } from "../../../core/config/ContextProxy" import { RouterName } from "../../../shared/api" @@ -44,7 +44,7 @@ export const getModelEndpoints = async ({ }): Promise => { // OpenRouter is the only provider that supports model endpoints, but you // can see how we'd extend this to other providers in the future. - if (router !== "openrouter" || !modelId || !endpoint) { + if (router !== providerIdentifiers.openrouter || !modelId || !endpoint) { return {} } @@ -61,7 +61,7 @@ export const getModelEndpoints = async ({ // Copy model-level capabilities from the parent model to each endpoint // These are capabilities that don't vary by provider (tools, reasoning, etc.) if (Object.keys(modelProviders).length > 0) { - const parentModels = await getModels({ provider: "openrouter" }) + const parentModels = await getModels({ provider: providerIdentifiers.openrouter }) const parentModel = parentModels[modelId] if (parentModel) { diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 44065d8801..ec0d14e4c9 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -25,6 +25,7 @@ import { getModelParams } from "../transform/model-params" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { BaseProvider } from "./base-provider" +import { NOT_PROVIDED } from "./constants" import { parseVertexJsonCredentials } from "./utils/vertex-credentials" type GeminiHandlerOptions = ApiHandlerOptions & { @@ -184,9 +185,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl this.options = options - const project = this.options.vertexProjectId ?? "not-provided" - const location = this.options.vertexRegion ?? "not-provided" - const apiKey = this.options.geminiApiKey ?? "not-provided" + const project = this.options.vertexProjectId ?? NOT_PROVIDED + const location = this.options.vertexRegion ?? NOT_PROVIDED + const apiKey = this.options.geminiApiKey ?? NOT_PROVIDED const parsedVertexCredentials = parseVertexJsonCredentials(this.options.vertexJsonCredentials) diff --git a/src/api/providers/kenari.ts b/src/api/providers/kenari.ts index 7895a9452c..a6ad643ec3 100644 --- a/src/api/providers/kenari.ts +++ b/src/api/providers/kenari.ts @@ -6,6 +6,7 @@ import { kenariDefaultModelInfo, KENARI_DEFAULT_TEMPERATURE, KENARI_BASE_URL, + providerIdentifiers, } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" @@ -37,7 +38,7 @@ export class KenariHandler extends RouterProvider implements SingleCompletionHan constructor(options: ApiHandlerOptions) { super({ options, - name: "kenari", + name: providerIdentifiers.kenari, baseURL: KENARI_BASE_URL, apiKey: options.kenariApiKey, modelId: options.kenariModelId, diff --git a/src/api/providers/kimi-code.ts b/src/api/providers/kimi-code.ts index 0a50ce6ec3..3f7136806b 100644 --- a/src/api/providers/kimi-code.ts +++ b/src/api/providers/kimi-code.ts @@ -4,6 +4,8 @@ import { KIMI_CODE_BASE_URL, kimiCodeDefaultModelId, kimiCodeDefaultModelInfo, + providerIdentifiers, + type KimiCodeAuthMethod, type ModelInfo, type ModelRecord, } from "@roo-code/types" @@ -16,8 +18,12 @@ import type { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { OpenAiHandler } from "./openai" +import { NOT_PROVIDED } from "./constants" import { getModels } from "./fetchers/modelCache" +const OAUTH_AUTH_METHOD: KimiCodeAuthMethod = "oauth" +const API_KEY_AUTH_METHOD: KimiCodeAuthMethod = "api-key" + function getHttpStatus(error: unknown): number | undefined { if (!error || typeof error !== "object") return undefined const candidate = error as { status?: unknown; cause?: { status?: unknown } } @@ -37,7 +43,7 @@ export class KimiCodeHandler extends OpenAiHandler { super({ ...options, openAiBaseUrl: KIMI_CODE_BASE_URL, - openAiApiKey: options.kimiCodeApiKey ?? "not-provided", + openAiApiKey: options.kimiCodeApiKey ?? NOT_PROVIDED, openAiModelId: options.apiModelId ?? kimiCodeDefaultModelId, openAiStreamingEnabled: true, }) @@ -45,7 +51,7 @@ export class KimiCodeHandler extends OpenAiHandler { } private async resolveAccessToken(forceRefresh = false): Promise { - if ((this.kimiOptions.kimiCodeAuthMethod ?? "oauth") === "api-key") { + if ((this.kimiOptions.kimiCodeAuthMethod ?? OAUTH_AUTH_METHOD) === API_KEY_AUTH_METHOD) { if (!this.kimiOptions.kimiCodeApiKey) throw new Error("Kimi Code API key is required") return this.kimiOptions.kimiCodeApiKey } @@ -63,7 +69,7 @@ export class KimiCodeHandler extends OpenAiHandler { if (!this.modelDiscoveryAttempted) { this.modelDiscoveryAttempted = true try { - this.models = await getModels({ provider: "kimi-code", apiKey: accessToken }) + this.models = await getModels({ provider: providerIdentifiers.kimiCode, apiKey: accessToken }) } catch (error) { // Model discovery is best-effort; preserve the configured ID and fallback metadata. console.debug("[KimiCode] Model discovery failed; using fallback model metadata", { @@ -74,7 +80,7 @@ export class KimiCodeHandler extends OpenAiHandler { } private canRefreshOAuth(): boolean { - return (this.kimiOptions.kimiCodeAuthMethod ?? "oauth") === "oauth" + return (this.kimiOptions.kimiCodeAuthMethod ?? OAUTH_AUTH_METHOD) === OAUTH_AUTH_METHOD } override async *createMessage( diff --git a/src/api/providers/lite-llm.ts b/src/api/providers/lite-llm.ts index 74a610b073..8cfe2d0a19 100644 --- a/src/api/providers/lite-llm.ts +++ b/src/api/providers/lite-llm.ts @@ -1,7 +1,7 @@ import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" // Keep for type usage only -import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types" +import { litellmDefaultModelId, litellmDefaultModelInfo, providerIdentifiers } from "@roo-code/types" import { calculateApiCostOpenAI } from "../../shared/cost" @@ -27,7 +27,7 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa constructor(options: ApiHandlerOptions) { super({ options, - name: "litellm", + name: providerIdentifiers.litellm, baseURL: `${options.litellmBaseUrl || "http://localhost:4000"}`, apiKey: options.litellmApiKey || "dummy-key", modelId: options.litellmModelId, diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 79f5355ef8..be752ac887 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -2,7 +2,12 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import axios from "axios" -import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATURE } from "@roo-code/types" +import { + type ModelInfo, + openAiModelInfoSaneDefaults, + LMSTUDIO_DEFAULT_TEMPERATURE, + providerIdentifiers, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" @@ -171,7 +176,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan override getModel(): { id: string; info: ModelInfo } { const models = getModelsFromCache({ - provider: "lmstudio", + provider: providerIdentifiers.lmstudio, baseUrl: this.options.lmStudioBaseUrl, }) if (models && this.options.lmStudioModelId && models[this.options.lmStudioModelId]) { diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index 2901c2e926..e3a794afae 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -12,6 +12,7 @@ import { handleProviderError } from "./utils/error-handler" import { extractReasoningFromDelta } from "./utils/extract-reasoning" import { OpenAiHandler } from "./openai" +import { NOT_PROVIDED } from "./constants" import type { ApiHandlerCreateMessageMetadata } from "../index" import { sanitizeOpenAiCallId } from "../../utils/tool-id" @@ -27,7 +28,7 @@ export class MimoHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { super({ ...options, - openAiApiKey: options.mimoApiKey ?? "not-provided", + openAiApiKey: options.mimoApiKey ?? NOT_PROVIDED, openAiModelId: options.apiModelId ?? mimoDefaultModelId, openAiBaseUrl: options.mimoBaseUrl || "https://token-plan-sgp.xiaomimimo.com/v1", openAiStreamingEnabled: true, diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts index 42bd2bfaf7..6fd012ef7f 100644 --- a/src/api/providers/moonshot.ts +++ b/src/api/providers/moonshot.ts @@ -8,6 +8,7 @@ import type { ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { OpenAiHandler } from "./openai" +import { NOT_PROVIDED } from "./constants" export class MoonshotHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { @@ -16,7 +17,7 @@ export class MoonshotHandler extends OpenAiHandler { // OpenAI Node SDK path as the generic "OpenAI Compatible" provider. super({ ...options, - openAiApiKey: options.moonshotApiKey ?? "not-provided", + openAiApiKey: options.moonshotApiKey ?? NOT_PROVIDED, openAiModelId: options.apiModelId ?? moonshotDefaultModelId, openAiBaseUrl: options.moonshotBaseUrl || "https://api.moonshot.ai/v1", }) diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts index 73ce199d23..38fb54c384 100644 --- a/src/api/providers/nanogpt.ts +++ b/src/api/providers/nanogpt.ts @@ -6,6 +6,7 @@ import { NANOGPT_BASE_URL, nanoGptDefaultModelId, nanoGptDefaultModelInfo, + providerIdentifiers, type NanoGptRoutingPreference, } from "@roo-code/types" @@ -58,7 +59,7 @@ export class NanoGptHandler extends RouterProvider implements SingleCompletionHa constructor(options: ApiHandlerOptions) { super({ options, - name: "nanogpt", + name: providerIdentifiers.nanogpt, baseURL: NANOGPT_BASE_URL, apiKey: options.nanoGptApiKey, modelId: options.nanoGptModelId, diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 8dffe03dcc..a919e2c82b 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -28,6 +28,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" +import { NOT_PROVIDED } from "./constants" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { isMcpTool } from "../../utils/mcp-name" import { sanitizeOpenAiCallId } from "../../utils/tool-id" @@ -95,7 +96,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio if (this.options.enableResponsesReasoningSummary === undefined) { this.options.enableResponsesReasoningSummary = true } - const apiKey = this.options.openAiNativeApiKey ?? "not-provided" + const apiKey = this.options.openAiNativeApiKey ?? NOT_PROVIDED // Include originator, session_id, and User-Agent headers for API tracking and debugging const userAgent = `zoo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` this.client = new OpenAI({ @@ -555,7 +556,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio systemPrompt?: string, messages?: Anthropic.Messages.MessageParam[], ): ApiStream { - const apiKey = this.options.openAiNativeApiKey ?? "not-provided" + const apiKey = this.options.openAiNativeApiKey ?? NOT_PROVIDED const baseUrl = this.options.openAiNativeBaseUrl || "https://api.openai.com" const url = `${baseUrl}/v1/responses` diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 5b4476fdef..5588dd37d6 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -21,7 +21,7 @@ import { convertToR1Format } from "../transform/r1-format" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { handleOpenAIError } from "./utils/error-handler" @@ -40,7 +40,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl this.options = options const baseURL = this.options.openAiBaseUrl || "https://api.openai.com/v1" - const apiKey = this.options.openAiApiKey ?? "not-provided" + const apiKey = this.options.openAiApiKey ?? NOT_PROVIDED const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) const isAzureOpenAi = isAzureOpenAiBaseUrl(this.options.openAiBaseUrl, options.openAiUseAzure) diff --git a/src/api/providers/opencode-go.ts b/src/api/providers/opencode-go.ts index 6b3f633829..9456ac8fdb 100644 --- a/src/api/providers/opencode-go.ts +++ b/src/api/providers/opencode-go.ts @@ -8,6 +8,7 @@ import { opencodeGoDefaultModelInfo, OPENCODE_GO_DEFAULT_TEMPERATURE, isOpencodeGoAnthropicFormatModel, + providerIdentifiers, } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" @@ -81,7 +82,7 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio constructor(options: ApiHandlerOptions) { super({ options, - name: "opencode-go", + name: providerIdentifiers.opencodeGo, baseURL: "https://opencode.ai/zen/go/v1", apiKey: options.opencodeGoApiKey, modelId: options.opencodeGoModelId, diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 3e59b4360b..be042562b0 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -10,6 +10,7 @@ import { OPENROUTER_DEFAULT_PROVIDER_NAME, OPEN_ROUTER_PROMPT_CACHING_MODELS, DEEP_SEEK_DEFAULT_TEMPERATURE, + providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -33,7 +34,7 @@ import { getModelParams } from "../transform/model-params" import { getModels } from "./fetchers/modelCache" import { getModelEndpoints } from "./fetchers/modelEndpointCache" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { BaseProvider } from "./base-provider" import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions, SingleCompletionHandler } from "../index" import { handleOpenAIError } from "./utils/error-handler" @@ -151,7 +152,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH this.options = options const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1" - const apiKey = this.options.openRouterApiKey ?? "not-provided" + const apiKey = this.options.openRouterApiKey ?? NOT_PROVIDED this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS, timeout: this.timeoutMs }) @@ -164,9 +165,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH private async loadDynamicModels(): Promise { try { const [models, endpoints] = await Promise.all([ - getModels({ provider: "openrouter" }), + getModels({ provider: providerIdentifiers.openrouter }), getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: this.options.openRouterModelId, endpoint: this.options.openRouterSpecificProvider, }), @@ -535,9 +536,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH public async fetchModel() { const [models, endpoints] = await Promise.all([ - getModels({ provider: "openrouter" }), + getModels({ provider: providerIdentifiers.openrouter }), getModelEndpoints({ - router: "openrouter", + router: providerIdentifiers.openrouter, modelId: this.options.openRouterModelId, endpoint: this.options.openRouterSpecificProvider, }), diff --git a/src/api/providers/poe.ts b/src/api/providers/poe.ts index 1e5315b1ba..fb3255c572 100644 --- a/src/api/providers/poe.ts +++ b/src/api/providers/poe.ts @@ -9,6 +9,7 @@ import { type ModelInfo, type ReasoningEffortExtended, ApiProviderError, + providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -18,6 +19,7 @@ import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" +import { NOT_PROVIDED } from "./constants" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { getModelsFromCache } from "./fetchers/modelCache" @@ -31,7 +33,7 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler super() this.options = options this.poe = createPoe({ - apiKey: options.poeApiKey ?? "not-provided", + apiKey: options.poeApiKey ?? NOT_PROVIDED, baseURL: options.poeBaseUrl || undefined, }) } @@ -39,7 +41,7 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler override getModel() { const id = this.options.apiModelId ?? poeDefaultModelId const cached = getModelsFromCache({ - provider: "poe", + provider: providerIdentifiers.poe, apiKey: this.options.poeApiKey, baseUrl: this.options.poeBaseUrl, }) @@ -108,7 +110,9 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler }) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - TelemetryService.instance.captureException(new ApiProviderError(errorMessage, "poe", id, "createMessage")) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, providerIdentifiers.poe, id, "createMessage"), + ) throw new Error(`Poe completion error: ${errorMessage}`) } @@ -133,7 +137,9 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - TelemetryService.instance.captureException(new ApiProviderError(errorMessage, "poe", id, "createMessage")) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, providerIdentifiers.poe, id, "createMessage"), + ) throw new Error(`Poe streaming error: ${errorMessage}`) } } @@ -148,7 +154,9 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler return text } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - TelemetryService.instance.captureException(new ApiProviderError(errorMessage, "poe", id, "completePrompt")) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, providerIdentifiers.poe, id, "completePrompt"), + ) throw new Error(`Poe completion error: ${errorMessage}`) } } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 5753660de5..1ba0771ce2 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -1,7 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { type ModelInfo, type ModelRecord, requestyDefaultModelId, requestyDefaultModelInfo } from "@roo-code/types" +import { + type ModelInfo, + type ModelRecord, + providerIdentifiers, + requestyDefaultModelId, + requestyDefaultModelInfo, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" @@ -11,7 +17,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { AnthropicProviderReasoningParams, getAnthropicProviderReasoning } from "../transform/reasoning" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { getModels } from "./fetchers/modelCache" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" @@ -63,7 +69,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan this.options = options this.baseURL = toRequestyServiceUrl(options.requestyBaseUrl) - const apiKey = this.options.requestyApiKey ?? "not-provided" + const apiKey = this.options.requestyApiKey ?? NOT_PROVIDED this.client = new OpenAI({ baseURL: this.baseURL, @@ -74,7 +80,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan } public async fetchModel() { - this.models = await getModels({ provider: "requesty", baseUrl: this.baseURL }) + this.models = await getModels({ provider: providerIdentifiers.requesty, baseUrl: this.baseURL }) return this.getModel() } diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index 9e29be7bda..65ac904b87 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -7,7 +7,7 @@ import { ApiHandlerOptions, RouterName } from "../../shared/api" import { BaseProvider } from "./base-provider" import { getModels, getModelsFromCache } from "./fetchers/modelCache" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" type RouterProviderOptions = { name: RouterName @@ -41,7 +41,7 @@ export abstract class RouterProvider extends BaseProvider { this.client = new OpenAI({ baseURL, - apiKey: apiKey ?? "not-provided", + apiKey: apiKey ?? NOT_PROVIDED, defaultHeaders: { ...DEFAULT_HEADERS, ...(options.openAiHeaders || {}), diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index c3ec9c44fc..0848e0804b 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -1,7 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { type ModelInfo, type ModelRecord, unboundDefaultModelId, unboundDefaultModelInfo } from "@roo-code/types" +import { + type ModelInfo, + type ModelRecord, + providerIdentifiers, + unboundDefaultModelId, + unboundDefaultModelInfo, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" @@ -11,7 +17,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { OpenAiReasoningParams } from "../transform/reasoning" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { getModels } from "./fetchers/modelCache" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" @@ -54,7 +60,7 @@ export class UnboundHandler extends BaseProvider implements SingleCompletionHand this.options = options - const apiKey = this.options.unboundApiKey ?? "not-provided" + const apiKey = this.options.unboundApiKey ?? NOT_PROVIDED this.client = new OpenAI({ baseURL: "https://api.getunbound.ai/v1", @@ -68,7 +74,10 @@ export class UnboundHandler extends BaseProvider implements SingleCompletionHand } public async fetchModel() { - this.models = await getModels({ provider: "unbound", apiKey: this.options.unboundApiKey }) + this.models = await getModels({ + provider: providerIdentifiers.unbound, + apiKey: this.options.unboundApiKey, + }) return this.getModel() } diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index 0820e1b2d8..bf434e5a00 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -6,6 +6,7 @@ import { vercelAiGatewayDefaultModelInfo, VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE, VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS, + providerIdentifiers, } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" @@ -27,7 +28,7 @@ export class VercelAiGatewayHandler extends RouterProvider implements SingleComp constructor(options: ApiHandlerOptions) { super({ options, - name: "vercel-ai-gateway", + name: providerIdentifiers.vercelAiGateway, baseURL: "https://ai-gateway.vercel.sh/v1", apiKey: options.vercelAiGatewayApiKey, modelId: options.vercelAiGatewayModelId, diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index c47e9cefd1..189ec4e9ed 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -11,7 +11,7 @@ import { convertToResponsesApiInput } from "../transform/responses-api-input" import { processResponsesApiStream, createUsageNormalizer } from "../transform/responses-api-stream" import { getModelParams } from "../transform/model-params" -import { DEFAULT_HEADERS } from "./constants" +import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { handleOpenAIError } from "./utils/error-handler" @@ -28,7 +28,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler super() this.options = options - const apiKey = this.options.xaiApiKey ?? "not-provided" + const apiKey = this.options.xaiApiKey ?? NOT_PROVIDED this.client = new OpenAI({ baseURL: "https://api.x.ai/v1", diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index 65a6bf458c..d394663e45 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -15,6 +15,7 @@ import { convertToZAiFormat } from "../transform/zai-format" import type { ApiHandlerCreateMessageMetadata } from "../index" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { NOT_PROVIDED } from "./constants" import { handleOpenAIError } from "./utils/error-handler" // Custom interface for Z.ai params to support thinking mode and reasoning effort tiers. @@ -37,7 +38,7 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { ...options, providerName: "Z.ai", baseURL: zaiApiLineConfigs[apiLine].baseUrl, - apiKey: options.zaiApiKey ?? "not-provided", + apiKey: options.zaiApiKey ?? NOT_PROVIDED, defaultProviderModelId: defaultModelId, providerModels: models, defaultTemperature: ZAI_DEFAULT_TEMPERATURE, diff --git a/src/api/providers/zoo-gateway.ts b/src/api/providers/zoo-gateway.ts index cea410bb2e..4ff059df61 100644 --- a/src/api/providers/zoo-gateway.ts +++ b/src/api/providers/zoo-gateway.ts @@ -7,6 +7,7 @@ import { zooGatewayDefaultModelInfo, ZOO_GATEWAY_DEFAULT_TEMPERATURE, VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS, + providerIdentifiers, } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" @@ -19,6 +20,7 @@ import { convertToOpenAiMessages } from "../transform/openai-format" import { addCacheBreakpoints } from "../transform/caching/vercel-ai-gateway" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" +import { NOT_PROVIDED } from "./constants" import { RouterProvider } from "./router-provider" function getApiErrorStatus(error: unknown): number | undefined { @@ -159,9 +161,9 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio ...(options.openAiHeaders || {}), }, }, - name: "zoo-gateway", + name: providerIdentifiers.zooGateway, baseURL, - apiKey: sessionToken || "not-provided", + apiKey: sessionToken || NOT_PROVIDED, modelId: options.zooGatewayModelId, defaultModelId: zooGatewayDefaultModelId, defaultModelInfo: zooGatewayDefaultModelInfo, diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 3ceeb2f895..c4d00c4ef5 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" + +import { providerIdentifiers } from "@roo-code/types" + import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" @@ -68,13 +71,13 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { // Default mock: return distinct model maps per provider so we can verify keys getModelsMock.mockImplementation(async (options: any) => { switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -108,14 +111,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { const routerModels = call[0].routerModels as Record> // Aggregate handler initializes many known routers - ensure a few expected keys exist - expect(routerModels).toHaveProperty("openrouter") - expect(routerModels).toHaveProperty("requesty") - expect(routerModels).toHaveProperty("deepseek") - expect(routerModels).toHaveProperty("moonshot") + expect(routerModels).toHaveProperty(providerIdentifiers.openrouter) + expect(routerModels).toHaveProperty(providerIdentifiers.requesty) + expect(routerModels).toHaveProperty(providerIdentifiers.deepseek) + expect(routerModels).toHaveProperty(providerIdentifiers.moonshot) expect(routerModels.deepseek).toEqual({}) expect(routerModels.moonshot).toEqual({}) - expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: "deepseek" })) - expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: "moonshot" })) + expect(getModelsMock).not.toHaveBeenCalledWith( + expect.objectContaining({ provider: providerIdentifiers.deepseek }), + ) + expect(getModelsMock).not.toHaveBeenCalledWith( + expect.objectContaining({ provider: providerIdentifiers.moonshot }), + ) }) it("fetches DeepSeek models when stored DeepSeek credentials exist", async () => { @@ -127,18 +134,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) getModelsMock.mockImplementation(async (options: any) => { - if (options?.provider === "deepseek") { + if (options?.provider === providerIdentifiers.deepseek) { return { "deepseek-v4-flash": { contextWindow: 1_000_000, supportsPromptCache: true } } } switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -153,7 +160,7 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { ) expect(getModelsMock).toHaveBeenCalledWith({ - provider: "deepseek", + provider: providerIdentifiers.deepseek, apiKey: "stored-deepseek-key", baseUrl: "https://deepseek.example.com", }) @@ -175,18 +182,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) getModelsMock.mockImplementation(async (options: any) => { - if (options?.provider === "deepseek") { + if (options?.provider === providerIdentifiers.deepseek) { throw new Error("DeepSeek API error") } switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -204,7 +211,7 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { type: "singleRouterModelFetchResponse", success: false, error: "DeepSeek API error", - values: { provider: "deepseek" }, + values: { provider: providerIdentifiers.deepseek }, }) const call = (mockProvider.postMessageToWebview as any).mock.calls.find( @@ -219,7 +226,7 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { mockProvider as any, { type: "requestRouterModels", - values: { provider: "openrouter" }, + values: { provider: providerIdentifiers.openrouter }, } as any, ) @@ -230,11 +237,11 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { const routerModels = call[0].routerModels as Record> const keys = Object.keys(routerModels) - expect(keys).toEqual(["openrouter"]) + expect(keys).toEqual([providerIdentifiers.openrouter]) expect(Object.keys(routerModels.openrouter || {})).toContain("openrouter/qwen2.5") const providersCalled = getModelsMock.mock.calls.map((c: any[]) => c[0]?.provider) - expect(providersCalled).toEqual(["openrouter"]) + expect(providersCalled).toEqual([providerIdentifiers.openrouter]) }) it("flushes cache when LiteLLM credentials are provided in message values", async () => { @@ -252,15 +259,17 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { // flushModels should have been called for litellm with refresh=true and credentials expect(flushModelsMock).toHaveBeenCalledWith( - { provider: "litellm", apiKey: "test-api-key", baseUrl: "http://localhost:4000" }, + { provider: providerIdentifiers.litellm, apiKey: "test-api-key", baseUrl: "http://localhost:4000" }, true, ) // getModels should have been called with the provided credentials - const litellmCalls = getModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "litellm") + const litellmCalls = getModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.litellm, + ) expect(litellmCalls.length).toBe(1) expect(litellmCalls[0][0]).toEqual({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "test-api-key", baseUrl: "http://localhost:4000", }) @@ -283,19 +292,71 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { ) // flushModels should NOT have been called for litellm - const litellmFlushCalls = flushModelsMock.mock.calls.filter((c: any[]) => c[0] === "litellm") + const litellmFlushCalls = flushModelsMock.mock.calls.filter((c: any[]) => c[0] === providerIdentifiers.litellm) expect(litellmFlushCalls.length).toBe(0) // getModels should still have been called with stored credentials - const litellmCalls = getModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "litellm") + const litellmCalls = getModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.litellm, + ) expect(litellmCalls.length).toBe(1) expect(litellmCalls[0][0]).toEqual({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "stored-api-key", baseUrl: "http://stored:4000", }) }) + it("flushes and fetches Poe models with explicit unsaved credentials", async () => { + const poeModels = { "claude-sonnet": { contextWindow: 200_000, supportsPromptCache: false } } + getModelsMock.mockImplementation(async (options: { provider?: string }) => + options.provider === providerIdentifiers.poe ? poeModels : {}, + ) + + await webviewMessageHandler(mockProvider, { + type: "requestRouterModels", + values: { + poeApiKey: "new-poe-key", + poeBaseUrl: "https://poe.example.com/v1", + }, + }) + + const poeOptions = { + provider: providerIdentifiers.poe, + apiKey: "new-poe-key", + baseUrl: "https://poe.example.com/v1", + } + expect(flushModelsMock).toHaveBeenCalledWith(poeOptions, true) + expect(getModelsMock).toHaveBeenCalledWith(poeOptions) + + const response = mockProvider.postMessageToWebview.mock.calls.find((call) => call[0]?.type === "routerModels") + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.poe).toEqual(poeModels) + }) + + it("flushes DeepSeek models when an unsaved base URL is paired with the stored API key", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + deepSeekApiKey: "stored-deepseek-key", + deepSeekBaseUrl: "https://stored.deepseek.example.com", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: "requestRouterModels", + values: { deepSeekBaseUrl: "https://preview.deepseek.example.com" }, + }) + + const deepSeekOptions = { + provider: providerIdentifiers.deepseek, + apiKey: "stored-deepseek-key", + baseUrl: "https://preview.deepseek.example.com", + } + expect(flushModelsMock).toHaveBeenCalledWith(deepSeekOptions, true) + expect(getModelsMock).toHaveBeenCalledWith(deepSeekOptions) + }) + it("fetches Moonshot models when stored Moonshot credentials exist", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { @@ -305,18 +366,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) getModelsMock.mockImplementation(async (options: any) => { - if (options?.provider === "moonshot") { + if (options?.provider === providerIdentifiers.moonshot) { return { "kimi-k2-0905-preview": { contextWindow: 262144, supportsPromptCache: true } } } switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -331,7 +392,7 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { ) expect(getModelsMock).toHaveBeenCalledWith({ - provider: "moonshot", + provider: providerIdentifiers.moonshot, apiKey: "stored-moonshot-key", baseUrl: "https://api.moonshot.ai/v1", }) @@ -362,19 +423,23 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { ) // flushModels should have been called for moonshot - const moonshotFlushCalls = flushModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + const moonshotFlushCalls = flushModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.moonshot, + ) expect(moonshotFlushCalls.length).toBe(1) expect(moonshotFlushCalls[0][0]).toEqual({ - provider: "moonshot", + provider: providerIdentifiers.moonshot, apiKey: "new-moonshot-key", baseUrl: "https://api.moonshot.cn/v1", }) // getModels should use the provided credentials - const moonshotCalls = getModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + const moonshotCalls = getModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.moonshot, + ) expect(moonshotCalls.length).toBe(1) expect(moonshotCalls[0][0]).toEqual({ - provider: "moonshot", + provider: providerIdentifiers.moonshot, apiKey: "new-moonshot-key", baseUrl: "https://api.moonshot.cn/v1", }) @@ -388,18 +453,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) getModelsMock.mockImplementation(async (options: any) => { - if (options?.provider === "moonshot") { + if (options?.provider === providerIdentifiers.moonshot) { return { "kimi-k2-0905-preview": { contextWindow: 262144, supportsPromptCache: true } } } switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -414,14 +479,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { ) // flushModels should NOT have been called for moonshot - const moonshotFlushCalls = flushModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + const moonshotFlushCalls = flushModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.moonshot, + ) expect(moonshotFlushCalls.length).toBe(0) // getModels should still have been called with stored credentials - const moonshotCalls = getModelsMock.mock.calls.filter((c: any[]) => c[0]?.provider === "moonshot") + const moonshotCalls = getModelsMock.mock.calls.filter( + (c: any[]) => c[0]?.provider === providerIdentifiers.moonshot, + ) expect(moonshotCalls.length).toBe(1) expect(moonshotCalls[0][0]).toEqual({ - provider: "moonshot", + provider: providerIdentifiers.moonshot, apiKey: "stored-moonshot-key", baseUrl: undefined, }) @@ -435,18 +504,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) getModelsMock.mockImplementation(async (options: any) => { - if (options?.provider === "moonshot") { + if (options?.provider === providerIdentifiers.moonshot) { throw new Error("Moonshot API error") } switch (options?.provider) { - case "openrouter": + case providerIdentifiers.openrouter: return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } - case "requesty": + case providerIdentifiers.requesty: return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "litellm": + case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: return {} @@ -462,7 +531,9 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { // Should have posted an error for moonshot const errorCall = (mockProvider.postMessageToWebview as any).mock.calls.find( - (c: any[]) => c[0]?.type === "singleRouterModelFetchResponse" && c[0]?.values?.provider === "moonshot", + (c: any[]) => + c[0]?.type === "singleRouterModelFetchResponse" && + c[0]?.values?.provider === providerIdentifiers.moonshot, ) expect(errorCall).toBeTruthy() expect(errorCall[0].success).toBe(false) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f0fc33501f..00bb716017 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1063,21 +1063,21 @@ export const webviewMessageHandler = async ( const routerModels: Record = providerFilter ? ({} as Record) : { - openrouter: {}, - "vercel-ai-gateway": {}, - "zoo-gateway": {}, - litellm: {}, - requesty: {}, - unbound: {}, - ollama: {}, - lmstudio: {}, - poe: {}, - deepseek: {}, - moonshot: {}, - "opencode-go": {}, - kenari: {}, - nanogpt: {}, - "kimi-code": {}, + [providerIdentifiers.openrouter]: {}, + [providerIdentifiers.vercelAiGateway]: {}, + [providerIdentifiers.zooGateway]: {}, + [providerIdentifiers.litellm]: {}, + [providerIdentifiers.requesty]: {}, + [providerIdentifiers.unbound]: {}, + [providerIdentifiers.ollama]: {}, + [providerIdentifiers.lmstudio]: {}, + [providerIdentifiers.poe]: {}, + [providerIdentifiers.deepseek]: {}, + [providerIdentifiers.moonshot]: {}, + [providerIdentifiers.opencodeGo]: {}, + [providerIdentifiers.kenari]: {}, + [providerIdentifiers.nanogpt]: {}, + [providerIdentifiers.kimiCode]: {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1095,27 +1095,33 @@ export const webviewMessageHandler = async ( // Base candidates (only those handled by this aggregate fetcher) const candidates: { key: RouterName; options: GetModelsOptions }[] = [ - { key: "openrouter", options: { provider: "openrouter" } }, { - key: "requesty", + key: providerIdentifiers.openrouter, + options: { provider: providerIdentifiers.openrouter }, + }, + { + key: providerIdentifiers.requesty, options: { - provider: "requesty", + provider: providerIdentifiers.requesty, apiKey: apiConfiguration.requestyApiKey, baseUrl: apiConfiguration.requestyBaseUrl, }, }, { - key: "unbound", + key: providerIdentifiers.unbound, options: { - provider: "unbound", + provider: providerIdentifiers.unbound, apiKey: apiConfiguration.unboundApiKey, }, }, - { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, { - key: "zoo-gateway", + key: providerIdentifiers.vercelAiGateway, + options: { provider: providerIdentifiers.vercelAiGateway }, + }, + { + key: providerIdentifiers.zooGateway, options: { - provider: "zoo-gateway", + provider: providerIdentifiers.zooGateway, apiKey: apiConfiguration.zooSessionToken, baseUrl: apiConfiguration.zooGatewayBaseUrl, }, @@ -1132,12 +1138,15 @@ export const webviewMessageHandler = async ( // If explicit credentials are provided in message.values (from Refresh Models button), // flush the cache first to ensure we fetch fresh data with the new credentials if (message?.values?.litellmApiKey || message?.values?.litellmBaseUrl) { - await flushModels({ provider: "litellm", apiKey: litellmApiKey, baseUrl: litellmBaseUrl }, true) + await flushModels( + { provider: providerIdentifiers.litellm, apiKey: litellmApiKey, baseUrl: litellmBaseUrl }, + true, + ) } candidates.push({ - key: "litellm", - options: { provider: "litellm", apiKey: litellmApiKey, baseUrl: litellmBaseUrl }, + key: providerIdentifiers.litellm, + options: { provider: providerIdentifiers.litellm, apiKey: litellmApiKey, baseUrl: litellmBaseUrl }, }) } @@ -1147,12 +1156,15 @@ export const webviewMessageHandler = async ( if (poeApiKey) { if (message?.values?.poeApiKey || message?.values?.poeBaseUrl) { - await flushModels({ provider: "poe", apiKey: poeApiKey, baseUrl: poeBaseUrl }, true) + await flushModels( + { provider: providerIdentifiers.poe, apiKey: poeApiKey, baseUrl: poeBaseUrl }, + true, + ) } candidates.push({ - key: "poe", - options: { provider: "poe", apiKey: poeApiKey, baseUrl: poeBaseUrl }, + key: providerIdentifiers.poe, + options: { provider: providerIdentifiers.poe, apiKey: poeApiKey, baseUrl: poeBaseUrl }, }) } @@ -1162,12 +1174,19 @@ export const webviewMessageHandler = async ( if (deepSeekApiKey) { if (message?.values?.deepSeekApiKey || message?.values?.deepSeekBaseUrl) { - await flushModels({ provider: "deepseek", apiKey: deepSeekApiKey, baseUrl: deepSeekBaseUrl }, true) + await flushModels( + { provider: providerIdentifiers.deepseek, apiKey: deepSeekApiKey, baseUrl: deepSeekBaseUrl }, + true, + ) } candidates.push({ - key: "deepseek", - options: { provider: "deepseek", apiKey: deepSeekApiKey, baseUrl: deepSeekBaseUrl }, + key: providerIdentifiers.deepseek, + options: { + provider: providerIdentifiers.deepseek, + apiKey: deepSeekApiKey, + baseUrl: deepSeekBaseUrl, + }, }) } @@ -1177,12 +1196,19 @@ export const webviewMessageHandler = async ( if (moonshotApiKey) { if (message?.values?.moonshotApiKey || message?.values?.moonshotBaseUrl) { - await flushModels({ provider: "moonshot", apiKey: moonshotApiKey, baseUrl: moonshotBaseUrl }, true) + await flushModels( + { provider: providerIdentifiers.moonshot, apiKey: moonshotApiKey, baseUrl: moonshotBaseUrl }, + true, + ) } candidates.push({ - key: "moonshot", - options: { provider: "moonshot", apiKey: moonshotApiKey, baseUrl: moonshotBaseUrl }, + key: providerIdentifiers.moonshot, + options: { + provider: providerIdentifiers.moonshot, + apiKey: moonshotApiKey, + baseUrl: moonshotBaseUrl, + }, }) } @@ -1195,12 +1221,12 @@ export const webviewMessageHandler = async ( // Refresh the cache when a new key is explicitly provided (e.g. the Refresh Models button). if (message?.values?.opencodeGoApiKey) { - await flushModels({ provider: "opencode-go", apiKey: opencodeGoApiKey }, true) + await flushModels({ provider: providerIdentifiers.opencodeGo, apiKey: opencodeGoApiKey }, true) } candidates.push({ - key: "opencode-go", - options: { provider: "opencode-go", apiKey: opencodeGoApiKey }, + key: providerIdentifiers.opencodeGo, + options: { provider: providerIdentifiers.opencodeGo, apiKey: opencodeGoApiKey }, }) // Kenari's /models endpoint is public — it returns the full model list with no @@ -1212,12 +1238,12 @@ export const webviewMessageHandler = async ( // Refresh the cache when a new key is explicitly provided (e.g. the Refresh Models button). if (message?.values?.kenariApiKey) { - await flushModels({ provider: "kenari", apiKey: kenariApiKey }, true) + await flushModels({ provider: providerIdentifiers.kenari, apiKey: kenariApiKey }, true) } candidates.push({ - key: "kenari", - options: { provider: "kenari", apiKey: kenariApiKey }, + key: providerIdentifiers.kenari, + options: { provider: providerIdentifiers.kenari, apiKey: kenariApiKey }, }) // NanoGPT's detailed catalog is public, while an optional key can expose a @@ -1225,15 +1251,15 @@ export const webviewMessageHandler = async ( // same key-scoped options for refresh and retrieval. const nanoGptApiKey = message?.values?.nanoGptApiKey ?? apiConfiguration.nanoGptApiKey if (message?.values?.nanoGptApiKey !== undefined) { - await flushModels({ provider: "nanogpt", apiKey: nanoGptApiKey }, true) + await flushModels({ provider: providerIdentifiers.nanogpt, apiKey: nanoGptApiKey }, true) } candidates.push({ - key: "nanogpt", - options: { provider: "nanogpt", apiKey: nanoGptApiKey }, + key: providerIdentifiers.nanogpt, + options: { provider: providerIdentifiers.nanogpt, apiKey: nanoGptApiKey }, }) - if (!providerFilter || providerFilter === "kimi-code") { + if (!providerFilter || providerFilter === providerIdentifiers.kimiCode) { const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") const kimiCodeAuthMethod = message?.values?.kimiCodeAuthMethod ?? apiConfiguration.kimiCodeAuthMethod ?? "oauth" @@ -1243,8 +1269,8 @@ export const webviewMessageHandler = async ( : await kimiCodeOAuthManager.getAccessToken() if (kimiCodeApiKey) { candidates.push({ - key: "kimi-code", - options: { provider: "kimi-code", apiKey: kimiCodeApiKey }, + key: providerIdentifiers.kimiCode, + options: { provider: providerIdentifiers.kimiCode, apiKey: kimiCodeApiKey }, }) } } @@ -1308,7 +1334,7 @@ export const webviewMessageHandler = async ( const apiKey = message.values?.apiKey ?? ollamaApiConfig.ollamaApiKey const logBaseUrl = baseUrl || "http://localhost:11434" const ollamaOptions = { - provider: "ollama" as const, + provider: providerIdentifiers.ollama, baseUrl, apiKey, } @@ -1356,7 +1382,7 @@ export const webviewMessageHandler = async ( lmStudioModels = await getLMStudioModels(requestedBaseUrl) } else { const lmStudioOptions = { - provider: "lmstudio" as const, + provider: providerIdentifiers.lmstudio, baseUrl: lmStudioApiConfig.lmStudioBaseUrl, } // Flush cache and refresh to ensure fresh models.