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
161 changes: 124 additions & 37 deletions src/api/providers/__tests__/poe.spec.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -41,48 +90,17 @@ 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)

beforeEach(() => {
clearAllMocks()
mockCreatePoe.mockReturnValue(mockPoeProvider)
mockGetModelsFromCache.mockReturnValue(cachedModels)
})

describe("constructor", () => {
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since both the production code and this assertion import providerIdentifiers.poe, a regression where production reverts to the literal "poe" goes undetected here. Would using the string literal "poe" in the expectation be more mutation-resistant? (The constant's string value is already pinned in provider-identifiers.test.ts.)

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)
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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",
}),
)
})
})
})
3 changes: 2 additions & 1 deletion src/api/providers/anthropic-vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/api/providers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export const DEFAULT_HEADERS = {
"X-Title": "Zoo Code",
"User-Agent": `ZooCode/${Package.version}`,
}

export const NOT_PROVIDED = "not-provided"
3 changes: 2 additions & 1 deletion src/api/providers/deepseek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 35 additions & 2 deletions src/api/providers/fetchers/__tests__/lmstudio.test.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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,
Expand All @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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",
})
Expand Down Expand Up @@ -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",
})
Expand Down Expand Up @@ -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",
})
Expand All @@ -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",
})
Expand All @@ -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,
})
Expand Down
4 changes: 2 additions & 2 deletions src/api/providers/fetchers/lmstudio.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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)
Expand Down
Loading
Loading