diff --git a/packages/types/src/__tests__/provider-default-model.test.ts b/packages/types/src/__tests__/provider-default-model.test.ts new file mode 100644 index 0000000000..33ba019253 --- /dev/null +++ b/packages/types/src/__tests__/provider-default-model.test.ts @@ -0,0 +1,63 @@ +import type { ProviderName } from "../provider-settings.js" + +vi.mock("../provider-identifiers.js", async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + providerIdentifiers: { + ...actual.providerIdentifiers, + openrouter: "canonical-openrouter-test-value", + }, + } +}) + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + anthropicDefaultModelId, + getProviderDefaultModelId, + internationalZAiDefaultModelId, + kimiCodeDefaultModelId, + mainlandZAiDefaultModelId, + openRouterDefaultModelId, + vscodeLlmDefaultModelId, + zooGatewayDefaultModelId, +} from "../providers/index.js" + +describe("getProviderDefaultModelId", () => { + it("selects a static default through the canonical provider identifier", () => { + expect(getProviderDefaultModelId(providerIdentifiers.openrouter as ProviderName)).toBe(openRouterDefaultModelId) + }) + + it("triangulates static selection with another provider category", () => { + expect(getProviderDefaultModelId(providerIdentifiers.vscodeLm)).toBe(vscodeLlmDefaultModelId) + }) + + it.each([ + [providerIdentifiers.kimiCode, kimiCodeDefaultModelId], + [providerIdentifiers.zooGateway, zooGatewayDefaultModelId], + ])("preserves the %s default added on main", (provider, expectedModelId) => { + expect(getProviderDefaultModelId(provider)).toBe(expectedModelId) + }) + + it("preserves region-dependent defaults", () => { + // These defaults currently share the same model ID, so the assertions document + // both branches but cannot detect swapped ternary arms until the IDs diverge. + expect(getProviderDefaultModelId(providerIdentifiers.zai, { isChina: true })).toBe(mainlandZAiDefaultModelId) + expect(getProviderDefaultModelId(providerIdentifiers.zai)).toBe(internationalZAiDefaultModelId) + }) + + it.each([providerIdentifiers.openai, providerIdentifiers.ollama, providerIdentifiers.lmstudio])( + "returns an empty default for custom or locally selected models from %s", + (provider) => { + expect(getProviderDefaultModelId(provider)).toBe("") + }, + ) + + it.each([providerIdentifiers.anthropic, providerIdentifiers.geminiCli, providerIdentifiers.fakeAi])( + "preserves the Anthropic fallback for %s", + (provider) => { + expect(getProviderDefaultModelId(provider)).toBe(anthropicDefaultModelId) + }, + ) +}) diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index b3640a8f5d..bd39e44dcb 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -106,6 +106,7 @@ describe("provider identifiers", () => { providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.kimiCode, + providerIdentifiers.friendli, ]) expect(localProviders).toEqual([providerIdentifiers.ollama, providerIdentifiers.lmstudio]) expect(internalProviders).toEqual([providerIdentifiers.vscodeLm]) diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 3898c65bcc..a0625e429c 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -56,6 +56,7 @@ export const dynamicProviders = [ providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.kimiCode, + providerIdentifiers.friendli, ] as const export type DynamicProvider = (typeof dynamicProviders)[number] diff --git a/packages/types/src/providers/friendli.ts b/packages/types/src/providers/friendli.ts index 71591d8f40..53e728caf0 100644 --- a/packages/types/src/providers/friendli.ts +++ b/packages/types/src/providers/friendli.ts @@ -8,8 +8,12 @@ export type FriendliModelId = export const friendliDefaultModelId: FriendliModelId = "zai-org/GLM-5.2" +// Static fallback for the Friendli provider. Used as a fallback when dynamic +// models cannot be fetched (cold start, network errors, API lag), in tests, +// and in the webview's MODELS_BY_PROVIDER fallback. The provider itself fetches +// the live list from https://api.friendli.ai/serverless/v1/models at runtime. // Pricing sourced from https://friendli.ai/api/public/model-apis (per 1M tokens). -export const friendliModels = { +export const friendliModels: Record = { "zai-org/GLM-5.2": { maxTokens: 131_072, contextWindow: 1_000_000, @@ -20,6 +24,8 @@ export const friendliModels = { outputPrice: 4.4, cacheWritesPrice: 0, cacheReadsPrice: 0.26, + supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffort: "high", description: "GLM-5.2 is Zhipu's flagship model with a 1M context window and 128k max output, served via Friendli Model APIs. It delivers top-tier long-context reasoning, coding, and agentic performance for extended engineering sessions.", }, @@ -33,6 +39,8 @@ export const friendliModels = { outputPrice: 4.4, cacheWritesPrice: 0, cacheReadsPrice: 0.26, + supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffort: "high", description: "GLM-5.1 is Zhipu's most capable model with a 200k context window and 128k max output, served via Friendli Model APIs. It delivers top-tier reasoning, coding, and agentic performance.", }, @@ -60,4 +68,4 @@ export const friendliModels = { description: "MiniMax M2.5 is a high-performance language model with a 204.8K context window, optimized for long-context understanding and generation tasks, served via Friendli Model APIs.", }, -} as const satisfies Record +} diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 9a78de11ac..5f7d1afda4 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -62,6 +62,9 @@ import { zooGatewayDefaultModelId } from "./zoo-gateway.js" // Import the ProviderName type from provider-settings to avoid duplication import type { ProviderName } from "../provider-settings.js" +import { providerIdentifiers } from "../provider-identifiers.js" + +const NO_DEFAULT_MODEL_ID = "" /** * Get the default model ID for a given provider. @@ -73,71 +76,70 @@ export function getProviderDefaultModelId( options: { isChina?: boolean } = { isChina: false }, ): string { switch (provider) { - case "openrouter": + case providerIdentifiers.openrouter: return openRouterDefaultModelId - case "requesty": + case providerIdentifiers.requesty: return requestyDefaultModelId - case "litellm": + case providerIdentifiers.litellm: return litellmDefaultModelId - case "xai": + case providerIdentifiers.xai: return xaiDefaultModelId - case "baseten": + case providerIdentifiers.baseten: return basetenDefaultModelId - case "bedrock": + case providerIdentifiers.bedrock: return bedrockDefaultModelId - case "vertex": + case providerIdentifiers.vertex: return vertexDefaultModelId - case "gemini": + case providerIdentifiers.gemini: return geminiDefaultModelId - case "deepseek": + case providerIdentifiers.deepseek: return deepSeekDefaultModelId - case "moonshot": + case providerIdentifiers.moonshot: return moonshotDefaultModelId - case "minimax": + case providerIdentifiers.minimax: return minimaxDefaultModelId - case "mimo": + case providerIdentifiers.mimo: return mimoDefaultModelId - case "zai": + case providerIdentifiers.zai: return options?.isChina ? mainlandZAiDefaultModelId : internationalZAiDefaultModelId - case "openai-native": + case providerIdentifiers.openaiNative: + // TODO(#992): Replace this stale fallback with openAiNativeDefaultModelId. return "gpt-4o" // Based on openai-native patterns - case "openai-codex": + case providerIdentifiers.openaiCodex: return openAiCodexDefaultModelId - case "mistral": + case providerIdentifiers.mistral: return mistralDefaultModelId - case "openai": - return "" // OpenAI provider uses custom model configuration - case "ollama": - return "" // Ollama uses dynamic model selection - case "lmstudio": - return "" // LMStudio uses dynamic model selection - case "vscode-lm": + case providerIdentifiers.openai: + case providerIdentifiers.ollama: + case providerIdentifiers.lmstudio: + return NO_DEFAULT_MODEL_ID + case providerIdentifiers.vscodeLm: return vscodeLlmDefaultModelId - case "sambanova": + case providerIdentifiers.sambanova: return sambaNovaDefaultModelId - case "fireworks": + case providerIdentifiers.fireworks: return fireworksDefaultModelId - case "friendli": + case providerIdentifiers.friendli: return friendliDefaultModelId - case "qwen-code": + case providerIdentifiers.qwenCode: return qwenCodeDefaultModelId - case "poe": + case providerIdentifiers.poe: return poeDefaultModelId - case "unbound": + case providerIdentifiers.unbound: return unboundDefaultModelId - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return vercelAiGatewayDefaultModelId - case "opencode-go": + case providerIdentifiers.opencodeGo: return opencodeGoDefaultModelId - case "kenari": + case providerIdentifiers.kenari: return kenariDefaultModelId - case "kimi-code": + case providerIdentifiers.kimiCode: return kimiCodeDefaultModelId - case "zoo-gateway": + case providerIdentifiers.zooGateway: return zooGatewayDefaultModelId - case "anthropic": - case "gemini-cli": - case "fake-ai": + case providerIdentifiers.anthropic: + case providerIdentifiers.geminiCli: + case providerIdentifiers.fakeAi: default: return anthropicDefaultModelId } diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index b86e73dac6..3617c3cd6d 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -9,21 +9,147 @@ vitest.mock("vscode", () => ({ }, })) -import type { ProviderSettings } from "@roo-code/types" +// Handler constructors can require credentials or initialize SDK clients. Replace them +// with inert classes so these tests exercise only the factory's routing behavior. +vitest.mock("../providers", async () => { + const providers = await vitest.importActual>("../providers") + + return Object.fromEntries(Object.keys(providers).map((name) => [name, class {}])) +}) + +vitest.mock("../providers/native-ollama", () => ({ + NativeOllamaHandler: class {}, +})) + +import { + providerIdentifiers, + retiredProviderIdentifiers, + type ProviderName, + type ProviderNameWithRetired, +} from "@roo-code/types" import { buildApiHandler } from "../index" -import { KenariHandler } from "../providers/kenari" +import { + AnthropicHandler, + AnthropicVertexHandler, + AwsBedrockHandler, + BasetenHandler, + DeepSeekHandler, + FakeAIHandler, + FireworksHandler, + FriendliHandler, + GeminiHandler, + KenariHandler, + KimiCodeHandler, + LiteLLMHandler, + LmStudioHandler, + MiniMaxHandler, + MimoHandler, + MistralHandler, + MoonshotHandler, + OpenAiCodexHandler, + OpenAiHandler, + OpenAiNativeHandler, + OpencodeGoHandler, + OpenRouterHandler, + PoeHandler, + QwenCodeHandler, + RequestyHandler, + SambaNovaHandler, + UnboundHandler, + VercelAiGatewayHandler, + VertexHandler, + VsCodeLmHandler, + XAIHandler, + ZAiHandler, + ZooGatewayHandler, +} from "../providers" +import { NativeOllamaHandler } from "../providers/native-ollama" + +type HandlerConstructor = new (...args: never[]) => object + +const expectedHandlers = { + [providerIdentifiers.anthropic]: AnthropicHandler, + [providerIdentifiers.openrouter]: OpenRouterHandler, + [providerIdentifiers.bedrock]: AwsBedrockHandler, + [providerIdentifiers.openai]: OpenAiHandler, + [providerIdentifiers.ollama]: NativeOllamaHandler, + [providerIdentifiers.lmstudio]: LmStudioHandler, + [providerIdentifiers.gemini]: GeminiHandler, + // Gemini CLI currently relies on the factory's default Anthropic handler. + [providerIdentifiers.geminiCli]: AnthropicHandler, + [providerIdentifiers.openaiCodex]: OpenAiCodexHandler, + [providerIdentifiers.openaiNative]: OpenAiNativeHandler, + [providerIdentifiers.deepseek]: DeepSeekHandler, + [providerIdentifiers.qwenCode]: QwenCodeHandler, + [providerIdentifiers.moonshot]: MoonshotHandler, + [providerIdentifiers.kimiCode]: KimiCodeHandler, + [providerIdentifiers.vscodeLm]: VsCodeLmHandler, + [providerIdentifiers.mistral]: MistralHandler, + [providerIdentifiers.requesty]: RequestyHandler, + [providerIdentifiers.unbound]: UnboundHandler, + [providerIdentifiers.fakeAi]: FakeAIHandler, + [providerIdentifiers.xai]: XAIHandler, + [providerIdentifiers.litellm]: LiteLLMHandler, + [providerIdentifiers.sambanova]: SambaNovaHandler, + [providerIdentifiers.mimo]: MimoHandler, + [providerIdentifiers.zai]: ZAiHandler, + [providerIdentifiers.fireworks]: FireworksHandler, + [providerIdentifiers.friendli]: FriendliHandler, + [providerIdentifiers.vercelAiGateway]: VercelAiGatewayHandler, + [providerIdentifiers.opencodeGo]: OpencodeGoHandler, + [providerIdentifiers.kenari]: KenariHandler, + [providerIdentifiers.zooGateway]: ZooGatewayHandler, + [providerIdentifiers.minimax]: MiniMaxHandler, + [providerIdentifiers.baseten]: BasetenHandler, + [providerIdentifiers.poe]: PoeHandler, +} satisfies Record, HandlerConstructor> + +const expectedHandlerEntries = Object.entries(expectedHandlers) as Array< + [Exclude, HandlerConstructor] +> describe("buildApiHandler", () => { - it("returns a KenariHandler for the kenari provider", () => { - const configuration: ProviderSettings = { - apiProvider: "kenari", - kenariApiKey: "test-key", - kenariModelId: "glm-5-2", - } + it.each(expectedHandlerEntries)("returns the expected handler for %s", (apiProvider, Handler) => { + const handler = buildApiHandler({ apiProvider }) + + expect(handler).toBeInstanceOf(Handler) + }) + + it.each([ + ["an unspecified model", undefined, VertexHandler], + ["non-Claude models", "non-claude-test-model", VertexHandler], + ["Claude models", "claude-test-model", AnthropicVertexHandler], + ] as const)("returns the expected Vertex handler for %s", (_description, apiModelId, Handler) => { + const handler = buildApiHandler({ + apiProvider: providerIdentifiers.vertex, + apiModelId, + }) + + expect(handler).toBeInstanceOf(Handler) + }) + + it("preserves the dedicated removal error for the retired Roo provider", () => { + expect(() => + buildApiHandler({ + apiProvider: retiredProviderIdentifiers.roo, + }), + ).toThrow("Roo Code Router has been removed") + }) + + it("rejects other retired providers", () => { + expect(() => + buildApiHandler({ + apiProvider: retiredProviderIdentifiers.cerebras, + }), + ).toThrow("this provider is no longer supported") + }) - const handler = buildApiHandler(configuration) + it("falls back to Anthropic for an unsupported provider value", () => { + const handler = buildApiHandler({ + apiProvider: "unsupported-provider" as ProviderNameWithRetired, + }) - expect(handler).toBeInstanceOf(KenariHandler) + expect(handler).toBeInstanceOf(AnthropicHandler) }) }) diff --git a/src/api/index.ts b/src/api/index.ts index fccbf48867..c9992c9c39 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { isRetiredProvider, type ProviderSettings, type ModelInfo } from "@roo-code/types" +import { + isRetiredProvider, + providerIdentifiers, + retiredProviderIdentifiers, + type ProviderSettings, + type ModelInfo, +} from "@roo-code/types" import { getRouterRemovalMessage } from "../core/config/routerRemoval" import { ApiStream } from "./transform/stream" @@ -140,7 +146,7 @@ export interface ApiHandler { export function buildApiHandler(configuration: ProviderSettings): ApiHandler { const { apiProvider, ...options } = configuration - if (apiProvider === "roo") { + if (apiProvider === retiredProviderIdentifiers.roo) { throw new Error(getRouterRemovalMessage()) } @@ -151,73 +157,73 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { } switch (apiProvider) { - case "anthropic": + case providerIdentifiers.anthropic: return new AnthropicHandler(options) - case "openrouter": + case providerIdentifiers.openrouter: return new OpenRouterHandler(options) - case "bedrock": + case providerIdentifiers.bedrock: return new AwsBedrockHandler(options) - case "vertex": + case providerIdentifiers.vertex: return options.apiModelId?.startsWith("claude") ? new AnthropicVertexHandler(options) : new VertexHandler(options) - case "openai": + case providerIdentifiers.openai: return new OpenAiHandler(options) - case "ollama": + case providerIdentifiers.ollama: return new NativeOllamaHandler(options) - case "lmstudio": + case providerIdentifiers.lmstudio: return new LmStudioHandler(options) - case "gemini": + case providerIdentifiers.gemini: return new GeminiHandler(options) - case "openai-codex": + case providerIdentifiers.openaiCodex: return new OpenAiCodexHandler(options) - case "openai-native": + case providerIdentifiers.openaiNative: return new OpenAiNativeHandler(options) - case "deepseek": + case providerIdentifiers.deepseek: return new DeepSeekHandler(options) - case "qwen-code": + case providerIdentifiers.qwenCode: return new QwenCodeHandler(options) - case "moonshot": + case providerIdentifiers.moonshot: return new MoonshotHandler(options) - case "kimi-code": + case providerIdentifiers.kimiCode: return new KimiCodeHandler(options) - case "vscode-lm": + case providerIdentifiers.vscodeLm: return new VsCodeLmHandler(options) - case "mistral": + case providerIdentifiers.mistral: return new MistralHandler(options) - case "requesty": + case providerIdentifiers.requesty: return new RequestyHandler(options) - case "unbound": + case providerIdentifiers.unbound: return new UnboundHandler(options) - case "fake-ai": + case providerIdentifiers.fakeAi: return new FakeAIHandler(options) - case "xai": + case providerIdentifiers.xai: return new XAIHandler(options) - case "litellm": + case providerIdentifiers.litellm: return new LiteLLMHandler(options) - case "sambanova": + case providerIdentifiers.sambanova: return new SambaNovaHandler(options) - case "mimo": + case providerIdentifiers.mimo: return new MimoHandler(options) - case "zai": + case providerIdentifiers.zai: return new ZAiHandler(options) - case "fireworks": + case providerIdentifiers.fireworks: return new FireworksHandler(options) - case "friendli": + case providerIdentifiers.friendli: return new FriendliHandler(options) - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return new VercelAiGatewayHandler(options) - case "opencode-go": + case providerIdentifiers.opencodeGo: return new OpencodeGoHandler(options) - case "kenari": + case providerIdentifiers.kenari: return new KenariHandler(options) - case "zoo-gateway": + case providerIdentifiers.zooGateway: return new ZooGatewayHandler(options) - case "minimax": + case providerIdentifiers.minimax: return new MiniMaxHandler(options) - case "baseten": + case providerIdentifiers.baseten: return new BasetenHandler(options) - case "poe": + case providerIdentifiers.poe: return new PoeHandler(options) default: return new AnthropicHandler(options) diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 41892d3bc6..c8fd81ad19 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -142,7 +142,16 @@ describe("FriendliHandler", () => { }, ])( "should expose newly added model $modelId", - ({ modelId, contextWindow, maxTokens, supportsMaxTokens, inputPrice, outputPrice, cacheWritesPrice, cacheReadsPrice }) => { + ({ + modelId, + contextWindow, + maxTokens, + supportsMaxTokens, + inputPrice, + outputPrice, + cacheWritesPrice, + cacheReadsPrice, + }) => { expect(friendliModels[modelId]).toBeDefined() const info = friendliModels[modelId] as import("@roo-code/types").ModelInfo expect(info.maxTokens).toBe(maxTokens) @@ -394,3 +403,217 @@ describe("Friendli model max output tokens (clamping behavior)", () => { expect(result).toBe(80_000) }) }) + +describe("FriendliHandler — Friendli-specific reasoning params", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should include reasoning_effort, chat_template_kwargs, parse_reasoning for GLM-5.2 with reasoning enabled", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "zai-org/GLM-5.2", + reasoning_effort: "high", + chat_template_kwargs: { enable_thinking: true }, + parse_reasoning: true, + include_reasoning: true, + }), + undefined, + ) + }) + + it("should send enable_thinking: false when enableReasoningEffort is false on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: false, + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.include_reasoning).toBeUndefined() + }) + + it("should send enable_thinking: false when reasoningEffort is none on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: true, + reasoningEffort: "none", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.include_reasoning).toBeUndefined() + }) + + it("should send enable_thinking: false when reasoningEffort is disable on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: true, + reasoningEffort: "disable", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.include_reasoning).toBeUndefined() + }) + + it("should use model default reasoningEffort when no explicit settings are provided", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + // No enableReasoningEffort or reasoningEffort — model default "high" kicks in + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBe("high") + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: true }) + expect(callArgs.parse_reasoning).toBe(true) + expect(callArgs.include_reasoning).toBe(true) + }) + + it("should not include any reasoning params for non-reasoning DeepSeek-V3.2", async () => { + const handler = new FriendliHandler({ + apiModelId: "deepseek-ai/DeepSeek-V3.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toBeUndefined() + expect(callArgs.parse_reasoning).toBeUndefined() + }) + + it("should handle delta.reasoning_content from parse_reasoning=true stream", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { reasoning_content: "Let me think..." } }], + usage: null, + } + yield { + choices: [{ delta: { content: "The answer is 42" } }], + usage: null, + } + yield { + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const stream = handler.createMessage("system", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual({ type: "reasoning", text: "Let me think..." }) + expect(chunks).toContainEqual({ type: "text", text: "The answer is 42" }) + }) + + it("completePrompt should include reasoning params when enabled", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "medium", + }) + + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "test result" } }], + }) + + await handler.completePrompt("test") + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBe("medium") + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: true }) + expect(callArgs.parse_reasoning).toBe(true) + expect(callArgs.include_reasoning).toBe(true) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/friendli.spec.ts b/src/api/providers/fetchers/__tests__/friendli.spec.ts new file mode 100644 index 0000000000..954d4c4f41 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/friendli.spec.ts @@ -0,0 +1,307 @@ +// npx vitest run api/providers/fetchers/__tests__/friendli.spec.ts + +import axios from "axios" + +import { getFriendliModels, parseFriendliModel } from "../friendli" +import type { FriendliModel } from "../friendli" + +vi.mock("axios") +const mockedAxios = vi.mocked(axios, { partial: true }) + +describe("Friendli Fetchers", () => { + beforeEach(() => { + vitest.clearAllMocks() + }) + + describe("getFriendliModels", () => { + const mockResponse = { + data: { + data: [ + { + id: "zai-org/GLM-5.2", + name: "zai-org/GLM-5.2", + created: 1776162486, + context_length: 1048576, + max_completion_tokens: 131072, + pricing: { + input: "0.0000014", + output: "0.0000044", + input_cache_read: "0.00000026", + cache_write: "0.0000015", + }, + functionality: { + tool_call: true, + parallel_tool_call: true, + structured_output: true, + tool_choice: true, + system_messages: true, + }, + description: "GLM-5.2 flagship model", + reasoning: true, + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "high", "default"] }, + { type: "budget_tokens", min: -1, max: 202752 }, + ], + input_modalities: ["text"], + output_modalities: ["text"], + mode: "chat", + }, + { + id: "deepseek-ai/DeepSeek-V3.2", + name: "deepseek-ai/DeepSeek-V3.2", + context_length: 163840, + max_completion_tokens: 163840, + pricing: { + input: "0.0000005", + output: "0.0000015", + input_cache_read: "0.00000025", + }, + functionality: { + tool_call: true, + parallel_tool_call: true, + structured_output: true, + }, + description: "DeepSeek V3.2", + reasoning: false, + input_modalities: ["text"], + output_modalities: ["text"], + mode: "chat", + }, + { + id: "some/embedding-model", + context_length: 8192, + max_completion_tokens: 8192, + mode: "embedding", + pricing: { input: "0.0000001", output: "0" }, + }, + ], + }, + } + + it("fetches and parses models correctly", async () => { + mockedAxios.get.mockResolvedValueOnce(mockResponse) + + const models = await getFriendliModels() + + expect(mockedAxios.get).toHaveBeenCalledWith("https://api.friendli.ai/serverless/v1/models") + // Two chat models, embedding model filtered out + expect(Object.keys(models)).toHaveLength(2) + expect(models["zai-org/GLM-5.2"]).toBeDefined() + expect(models["deepseek-ai/DeepSeek-V3.2"]).toBeDefined() + }) + + it("handles API errors gracefully", async () => { + const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(function () {}) + mockedAxios.get.mockRejectedValueOnce(new Error("Network error")) + + const models = await getFriendliModels() + + expect(models).toEqual({}) + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Error fetching Friendli models")) + consoleErrorSpy.mockRestore() + }) + + it("handles invalid response schema gracefully", async () => { + const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(function () {}) + mockedAxios.get.mockResolvedValueOnce({ + data: { invalid: "response" }, + }) + + const models = await getFriendliModels() + + expect(models).toEqual({}) + expect(consoleErrorSpy).toHaveBeenCalled() + consoleErrorSpy.mockRestore() + }) + + it("filters out non-chat models", async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { + data: [ + { + id: "test/chat-model", + context_length: 4096, + max_completion_tokens: 2048, + mode: "chat", + pricing: { input: "0.0000001", output: "0.0000002" }, + }, + { + id: "test/embedding-model", + context_length: 4096, + max_completion_tokens: 2048, + mode: "embedding", + pricing: { input: "0.0000001", output: "0" }, + }, + ], + }, + }) + + const models = await getFriendliModels() + + expect(Object.keys(models)).toHaveLength(1) + expect(models["test/chat-model"]).toBeDefined() + expect(models["test/embedding-model"]).toBeUndefined() + }) + }) + + describe("parseFriendliModel", () => { + const baseModel: FriendliModel = { + id: "test/model", + name: "test/model", + context_length: 100000, + max_completion_tokens: 8000, + pricing: { + input: "0.0000025", + output: "0.00001", + }, + description: "A test model", + input_modalities: ["text"], + output_modalities: ["text"], + mode: "chat", + } + + it("parses basic model info correctly", () => { + const result = parseFriendliModel({ id: "test/model", model: baseModel }) + + expect(result.maxTokens).toBe(8000) + expect(result.contextWindow).toBe(100000) + expect(result.supportsImages).toBe(false) + expect(result.supportsPromptCache).toBe(false) + expect(result.inputPrice).toBe(2.5) // 0.0000025 * 1_000_000 = 2.5 + expect(result.outputPrice).toBe(10) // 0.00001 * 1_000_000 = 10 + expect(result.cacheWritesPrice).toBeUndefined() + expect(result.cacheReadsPrice).toBeUndefined() + expect(result.description).toBe("A test model") + }) + + it("parses cache pricing when available", () => { + const modelWithCache: FriendliModel = { + ...baseModel, + pricing: { + input: "0.0000030", + output: "0.0000150", + input_cache_read: "0.00000030", + cache_write: "0.00000375", + }, + } + + const result = parseFriendliModel({ id: "test/model", model: modelWithCache }) + + expect(result.supportsPromptCache).toBe(true) + expect(result.cacheWritesPrice).toBe(3.75) + expect(result.cacheReadsPrice).toBe(0.3) + }) + + it("handles partial cache pricing (only read)", () => { + const modelPartialCache: FriendliModel = { + ...baseModel, + pricing: { + input: "0.0000025", + output: "0.00001", + input_cache_read: "0.00000030", + }, + } + + const result = parseFriendliModel({ id: "test/model", model: modelPartialCache }) + + expect(result.supportsPromptCache).toBe(true) + expect(result.cacheWritesPrice).toBeUndefined() + expect(result.cacheReadsPrice).toBe(0.3) + }) + + it("detects image support from input_modalities", () => { + const visionModel: FriendliModel = { + ...baseModel, + input_modalities: ["text", "image"], + } + + const result = parseFriendliModel({ id: "test/model", model: visionModel }) + + expect(result.supportsImages).toBe(true) + }) + + it("sets supportsReasoningEffort as array for controllable reasoning models", () => { + const model: FriendliModel = { + ...baseModel, + reasoning: true, + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "high", "default"] }, + { type: "budget_tokens", min: -1, max: 8000 }, + ], + } + + const result = parseFriendliModel({ id: "test/model", model }) + + expect(result.supportsReasoningEffort).toEqual( + expect.arrayContaining(["low", "medium", "high", "minimal", "xhigh", "max"]), + ) + // "default" should be filtered out + expect(result.supportsReasoningEffort).not.toContain("default") + expect(result.reasoningEffort).toBe("high") + expect(result.supportsMaxTokens).toBe(true) + }) + + it("sets supportsReasoningEffort to true for reasoning models without effort options", () => { + const model: FriendliModel = { + ...baseModel, + reasoning: true, + } + + const result = parseFriendliModel({ id: "test/model", model }) + + expect(result.supportsReasoningEffort).toBe(true) + expect(result.reasoningEffort).toBeUndefined() + expect(result.supportsMaxTokens).toBeUndefined() + }) + + it("omits supportsReasoningEffort for non-reasoning models", () => { + const model: FriendliModel = { + ...baseModel, + reasoning: false, + } + + const result = parseFriendliModel({ id: "test/model", model }) + + expect(result.supportsReasoningEffort).toBeUndefined() + }) + + it("marks deprecated models", () => { + const model: FriendliModel = { + ...baseModel, + deprecation_date: "2026-08-05T00:00:00Z", + } + + const result = parseFriendliModel({ id: "test/model", model }) + + expect(result.deprecated).toBe(true) + }) + + it("handles empty description", () => { + const model: FriendliModel = { + ...baseModel, + description: " ", + } + + const result = parseFriendliModel({ id: "test/model", model }) + + expect(result.description).toBeUndefined() + }) + + it("falls back to prompt/completion pricing aliases", () => { + const model: FriendliModel = { + ...baseModel, + pricing: { + prompt: "0.0000025", + completion: "0.00001", + }, + } + + const result = parseFriendliModel({ id: "test/model", model }) + + expect(result.inputPrice).toBe(2.5) + expect(result.outputPrice).toBe(10) + }) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 23586b1364..169e4a0efa 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -58,7 +58,8 @@ vi.mock("../../../core/config/ContextProxy", () => ({ })) // Then imports -import type { Mock } from "vitest" +import type { Mock, Mocked } from "vitest" +import { providerIdentifiers } from "@roo-code/types" import * as fsSync from "fs" import NodeCache from "node-cache" import { getModels, getModelsFromCache } from "../modelCache" @@ -93,7 +94,7 @@ describe("getModels with new GetModelsOptions", () => { mockGetLiteLLMModels.mockResolvedValue(mockModels) const result = await getModels({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "test-api-key", baseUrl: "http://localhost:4000", }) @@ -113,7 +114,24 @@ describe("getModels with new GetModelsOptions", () => { } mockGetOpenRouterModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "openrouter" }) + const result = await getModels({ provider: providerIdentifiers.openrouter }) + + expect(mockGetOpenRouterModels).toHaveBeenCalled() + expect(result).toEqual(mockModels) + }) + + it("dispatches OpenRouter through its canonical provider identifier", async () => { + const mockModels = { + "openrouter/canonical-model": { + maxTokens: 8192, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + + mockGetOpenRouterModels.mockResolvedValue(mockModels) + + const result = await getModels({ provider: providerIdentifiers.openrouter }) expect(mockGetOpenRouterModels).toHaveBeenCalled() expect(result).toEqual(mockModels) @@ -130,12 +148,33 @@ describe("getModels with new GetModelsOptions", () => { } mockGetRequestyModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "requesty", apiKey: DUMMY_REQUESTY_KEY }) + const result = await getModels({ provider: providerIdentifiers.requesty, apiKey: DUMMY_REQUESTY_KEY }) expect(mockGetRequestyModels).toHaveBeenCalledWith(undefined, DUMMY_REQUESTY_KEY) expect(result).toEqual(mockModels) }) + it("dispatches credentialed fetchers through canonical provider identifiers", async () => { + const mockModels = { + "requesty/canonical-model": { + maxTokens: 4096, + contextWindow: 8192, + supportsPromptCache: false, + }, + } + + mockGetRequestyModels.mockResolvedValue(mockModels) + + const result = await getModels({ + provider: providerIdentifiers.requesty, + apiKey: DUMMY_REQUESTY_KEY, + baseUrl: "https://router.requesty.ai/v1", + }) + + expect(mockGetRequestyModels).toHaveBeenCalledWith("https://router.requesty.ai/v1", DUMMY_REQUESTY_KEY) + expect(result).toEqual(mockModels) + }) + it("calls getKenariModels with optional API key", async () => { const mockModels = { "glm-5-2": { @@ -147,7 +186,7 @@ describe("getModels with new GetModelsOptions", () => { } mockGetKenariModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "kenari", apiKey: "kenari-key-for-testing" }) + const result = await getModels({ provider: providerIdentifiers.kenari, apiKey: "kenari-key-for-testing" }) expect(mockGetKenariModels).toHaveBeenCalledWith("kenari-key-for-testing") expect(result).toEqual(mockModels) @@ -159,7 +198,7 @@ describe("getModels with new GetModelsOptions", () => { await expect( getModels({ - provider: "litellm", + provider: providerIdentifiers.litellm, apiKey: "test-api-key", baseUrl: "http://localhost:4000", }), @@ -178,7 +217,7 @@ describe("getModels with new GetModelsOptions", () => { mockGetMoonshotModels.mockResolvedValue(mockModels) const result = await getModels({ - provider: "moonshot", + provider: providerIdentifiers.moonshot, apiKey: "test-key", baseUrl: "https://api.moonshot.ai/v1", }) @@ -190,7 +229,7 @@ describe("getModels with new GetModelsOptions", () => { it("validates exhaustive provider checking with unknown provider", async () => { // This test ensures TypeScript catches unknown providers at compile time // In practice, the discriminated union should prevent this at compile time - const unknownProvider = "unknown" as any + const unknownProvider = "unknown" as typeof providerIdentifiers.openrouter await expect( getModels({ @@ -201,13 +240,13 @@ describe("getModels with new GetModelsOptions", () => { }) describe("getModelsFromCache disk fallback", () => { - let mockCache: any + let mockCache: Mocked beforeEach(() => { vi.clearAllMocks() // Get the mock cache instance const MockedNodeCache = vi.mocked(NodeCache) - mockCache = new MockedNodeCache() + mockCache = vi.mocked(new MockedNodeCache()) // Reset memory cache to always miss mockCache.get.mockReturnValue(undefined) // Reset fs mocks @@ -218,7 +257,7 @@ describe("getModelsFromCache disk fallback", () => { it("returns undefined when both memory and disk cache miss", () => { vi.mocked(fsSync.existsSync).mockReturnValue(false) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toBeUndefined() }) @@ -234,13 +273,30 @@ describe("getModelsFromCache disk fallback", () => { mockCache.get.mockReturnValue(memoryModels) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toEqual(memoryModels) // Disk should not be checked when memory cache hits expect(fsSync.existsSync).not.toHaveBeenCalled() }) + it("isolates authenticated users through the canonical Zoo Gateway identifier", () => { + const previousUserModels = { + "previous-user/model": { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + + mockCache.get.mockReturnValue(previousUserModels) + + const result = getModelsFromCache(providerIdentifiers.zooGateway) + + expect(result).toBeUndefined() + expect(mockCache.get).not.toHaveBeenCalled() + }) + it("returns disk cache data when memory cache misses and context is available", () => { // Note: This test validates the logic but the ContextProxy mock in test environment // returns undefined for getCacheDirectoryPathSync, which is expected behavior @@ -257,7 +313,7 @@ describe("getModelsFromCache disk fallback", () => { vi.mocked(fsSync.existsSync).mockReturnValue(true) vi.mocked(fsSync.readFileSync).mockReturnValue(JSON.stringify(diskModels)) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) // In the test environment, ContextProxy.instance may not be fully initialized, // so getCacheDirectoryPathSync returns undefined and disk cache is not attempted @@ -272,7 +328,7 @@ describe("getModelsFromCache disk fallback", () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(function () {}) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toBeUndefined() expect(consoleErrorSpy).toHaveBeenCalled() @@ -286,7 +342,7 @@ describe("getModelsFromCache disk fallback", () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(function () {}) - const result = getModelsFromCache("openrouter") + const result = getModelsFromCache(providerIdentifiers.openrouter) expect(result).toBeUndefined() expect(consoleErrorSpy).toHaveBeenCalled() @@ -296,15 +352,15 @@ describe("getModelsFromCache disk fallback", () => { }) describe("empty cache protection", () => { - let mockCache: any - let mockGet: Mock - let mockSet: Mock + let mockCache: Mocked + let mockGet: Mocked["get"] + let mockSet: Mocked["set"] beforeEach(() => { vi.clearAllMocks() // Get the mock cache instance const MockedNodeCache = vi.mocked(NodeCache) - mockCache = new MockedNodeCache() + mockCache = vi.mocked(new MockedNodeCache()) mockGet = mockCache.get mockSet = mockCache.set // Reset memory cache to always miss by default @@ -316,7 +372,7 @@ describe("empty cache protection", () => { // API returns empty object (simulating failure) mockGetOpenRouterModels.mockResolvedValue({}) - const result = await getModels({ provider: "openrouter" }) + const result = await getModels({ provider: providerIdentifiers.openrouter }) // Should return empty but NOT cache it expect(result).toEqual({}) @@ -334,7 +390,7 @@ describe("empty cache protection", () => { } mockGetOpenRouterModels.mockResolvedValue(mockModels) - const result = await getModels({ provider: "openrouter" }) + const result = await getModels({ provider: providerIdentifiers.openrouter }) expect(result).toEqual(mockModels) expect(mockSet).toHaveBeenCalledWith("openrouter", mockModels) @@ -358,7 +414,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockResolvedValue({}) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return existing cache, not empty expect(result).toEqual(existingModels) @@ -388,7 +444,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockResolvedValue(newModels) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return new models expect(result).toEqual(newModels) @@ -410,7 +466,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockRejectedValue(new Error("API error")) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return existing cache on error expect(result).toEqual(existingModels) @@ -421,7 +477,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockRejectedValue(new Error("API error")) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return empty when no cache and API fails expect(result).toEqual({}) @@ -434,7 +490,7 @@ describe("empty cache protection", () => { mockGetOpenRouterModels.mockResolvedValue({}) const { refreshModels } = await import("../modelCache") - const result = await refreshModels({ provider: "openrouter" }) + const result = await refreshModels({ provider: providerIdentifiers.openrouter }) // Should return empty but NOT cache it expect(result).toEqual({}) @@ -462,8 +518,8 @@ describe("empty cache protection", () => { const { refreshModels } = await import("../modelCache") // Start two concurrent refresh calls - const promise1 = refreshModels({ provider: "openrouter" }) - const promise2 = refreshModels({ provider: "openrouter" }) + const promise1 = refreshModels({ provider: providerIdentifiers.openrouter }) + const promise2 = refreshModels({ provider: providerIdentifiers.openrouter }) // API should only be called once (second call reuses in-flight request) expect(mockGetOpenRouterModels).toHaveBeenCalledTimes(1) @@ -495,8 +551,8 @@ describe("empty cache protection", () => { // Different keys -> separate compound keys -> two distinct fetches. const [a, b] = await Promise.all([ - refreshModels({ provider: "requesty", apiKey: "key-one" }), - refreshModels({ provider: "requesty", apiKey: "key-two" }), + refreshModels({ provider: providerIdentifiers.requesty, apiKey: "key-one" }), + refreshModels({ provider: providerIdentifiers.requesty, apiKey: "key-two" }), ]) expect(mockGetRequestyModels).toHaveBeenCalledTimes(2) expect(a).toEqual(mockModels) @@ -512,8 +568,8 @@ describe("empty cache protection", () => { }), ) - const shared1 = refreshModels({ provider: "requesty", apiKey: "same-key" }) - const shared2 = refreshModels({ provider: "requesty", apiKey: "same-key" }) + const shared1 = refreshModels({ provider: providerIdentifiers.requesty, apiKey: "same-key" }) + const shared2 = refreshModels({ provider: providerIdentifiers.requesty, apiKey: "same-key" }) expect(mockGetRequestyModels).toHaveBeenCalledTimes(1) @@ -529,10 +585,10 @@ describe("key-scoped cache key derivation", () => { // Exercises the per-API-key cache discriminator that all KEY_SCOPED_PROVIDERS share. // Requesty is used only because it is a key-scoped provider with a mocked fetcher; the // behavior under test is provider-agnostic. - const keyScopedProvider = "requesty" as const + const keyScopedProvider = providerIdentifiers.requesty - let mockCache: any - let mockSet: Mock + let mockCache: Mocked + let mockSet: Mocked["set"] const mockModels = { "key-scoped/model": { @@ -546,7 +602,7 @@ describe("key-scoped cache key derivation", () => { beforeEach(() => { vi.clearAllMocks() const MockedNodeCache = vi.mocked(NodeCache) - mockCache = new MockedNodeCache() + mockCache = vi.mocked(new MockedNodeCache()) mockCache.get.mockReturnValue(undefined) mockSet = mockCache.set mockGetRequestyModels.mockResolvedValue(mockModels) @@ -627,7 +683,11 @@ describe("compound cache key derivation across scoping dimensions", () => { } it("includes both the server URL and the key discriminator for url+key-scoped providers", async () => { - await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000" }) + await getModels({ + provider: providerIdentifiers.litellm, + apiKey: "compound-key", + baseUrl: "http://host:4000", + }) const cacheKey = writtenCacheKey() // Expected shape: provider:url:keyDiscriminator @@ -635,18 +695,26 @@ describe("compound cache key derivation across scoping dimensions", () => { }) it("normalizes trailing slashes in the server URL so equivalent URLs share a cache key", async () => { - await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000/" }) + await getModels({ + provider: providerIdentifiers.litellm, + apiKey: "compound-key", + baseUrl: "http://host:4000/", + }) const withSlash = writtenCacheKey() mockSet.mockClear() - await getModels({ provider: "litellm", apiKey: "compound-key", baseUrl: "http://host:4000" }) + await getModels({ + provider: providerIdentifiers.litellm, + apiKey: "compound-key", + baseUrl: "http://host:4000", + }) const withoutSlash = writtenCacheKey() expect(withSlash).toEqual(withoutSlash) }) it("includes only the server URL when a url-scoped provider has no API key", async () => { - await getModels({ provider: "litellm", baseUrl: "http://host:4000" }) + await getModels({ provider: providerIdentifiers.litellm, baseUrl: "http://host:4000" }) const cacheKey = writtenCacheKey() // No trailing key discriminator when apiKey is absent. @@ -654,7 +722,11 @@ describe("compound cache key derivation across scoping dimensions", () => { }) it("falls back to the bare provider name for providers that are neither url- nor key-scoped", async () => { - await getModels({ provider: "openrouter", apiKey: "ignored-key", baseUrl: "http://ignored:4000" }) + await getModels({ + provider: providerIdentifiers.openrouter, + apiKey: "ignored-key", + baseUrl: "http://ignored:4000", + }) const cacheKey = writtenCacheKey() expect(cacheKey).toBe("openrouter") diff --git a/src/api/providers/fetchers/friendli.ts b/src/api/providers/fetchers/friendli.ts new file mode 100644 index 0000000000..d5dc45c50d --- /dev/null +++ b/src/api/providers/fetchers/friendli.ts @@ -0,0 +1,247 @@ +import axios from "axios" +import { z } from "zod" + +import type { ModelInfo } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" +import { parseApiPrice } from "../../../shared/cost" + +/** + * FriendliPricing + * + * All prices are strings (USD per-token); `parseApiPrice` converts to per-1M-token numbers. + * Some fields may be absent on some models (e.g. input_cache_read, cache_write). + */ +const friendliPricingSchema = z.object({ + input: z.string().optional(), + output: z.string().optional(), + prompt: z.string().optional(), // alias for input + completion: z.string().optional(), // alias for output + input_cache_read: z.string().optional(), + cache_write: z.string().optional(), +}) + +/** + * FriendliFunctionality + * + * Capability flags returned per-model. Several fields may be absent. + */ +const friendliFunctionalitySchema = z.object({ + tool_call: z.boolean().optional(), + builtin_tool: z.boolean().optional(), + parallel_tool_call: z.boolean().optional(), + structured_output: z.boolean().optional(), + tool_choice: z.boolean().optional(), + system_messages: z.boolean().optional(), +}) + +/** + * FriendliReasoningOption + * + * Each entry in `reasoning_options` describes one axis of reasoning control: + * - "toggle": on/off via chat_template_kwargs.enable_thinking + * - "effort": discrete effort enum (low/medium/high/default/...) + * - "budget_tokens": integer token budget with min/max bounds + */ +const friendliReasoningOptionSchema = z + .object({ + type: z.string(), + values: z.array(z.string()).optional(), + min: z.number().optional(), + max: z.number().optional(), + }) + // Allow unknown option shapes the schema doesn't model yet so we don't + // drop models that add new reasoning control axes. + .passthrough() + +/** + * FriendliModel + */ +const friendliModelSchema = z + .object({ + id: z.string(), + name: z.string().optional(), + created: z.number().optional(), + context_length: z.number().optional(), + max_completion_tokens: z.number().optional(), + pricing: friendliPricingSchema.optional(), + functionality: friendliFunctionalitySchema.optional(), + description: z.string().optional(), + reasoning: z.boolean().optional(), + reasoning_options: z.array(friendliReasoningOptionSchema).optional(), + input_modalities: z.array(z.string()).optional(), + output_modalities: z.array(z.string()).optional(), + mode: z.string().optional(), + deprecation_date: z.string().nullable().optional(), + }) + .passthrough() + +export type FriendliModel = z.infer + +/** + * FriendliModelsResponse + */ +export const friendliModelsResponseSchema = z.object({ + data: z.array(friendliModelSchema), +}) + +type FriendliModelsResponse = z.infer + +/** + * Friendli reasoning effort values exposed by the Friendli handler. + * The Friendli API returns an "effort" option with a `values` array (e.g. + * ["low", "medium", "high", "default"]). The Roo Code reasoning controls and + * the FriendliHandler's reasoning param builder operate on the extended set + * ["minimal", "low", "medium", "high", "xhigh", "max"], so we extend the + * API-provided values with the extras the handler knows about. This mirrors + * what the static `friendliModels` entries declare for GLM-5.x. + */ +const FRIENDLI_EXTRA_EFFORTS = ["minimal", "xhigh", "max"] as const + +const REASONING_EFFORT_LEVELS = ["disable", "none", "minimal", "low", "medium", "high", "xhigh", "max"] as const +type ReasoningEffortLevel = (typeof REASONING_EFFORT_LEVELS)[number] + +function buildSupportsReasoningEffort( + reasoning: boolean | undefined, + reasoningOptions: FriendliModel["reasoning_options"], +): ModelInfo["supportsReasoningEffort"] { + if (!reasoning && reasoningOptions === undefined) { + // Non-reasoning model — omit the field. + return undefined + } + + const effortOption = reasoningOptions?.find((opt) => opt.type === "effort") + if (effortOption && Array.isArray(effortOption.values) && effortOption.values.length > 0) { + // Controllable reasoning model with a discrete effort enum. Extend the + // API-provided values with the extra efforts the FriendliHandler uses + // (minimal/xhigh/max), preserving API order and de-duplicating. + const merged: string[] = [] + for (const v of effortOption.values) { + if (!merged.includes(v)) merged.push(v) + } + for (const v of FRIENDLI_EXTRA_EFFORTS) { + if (!merged.includes(v)) merged.push(v) + } + // Drop "default" — it's not a real effort level the handler sends; it's + // a placeholder the API uses to mean "use the model default". Keeping + // it in the capability array would let shouldUseReasoningEffort match a + // settings value of "default" that the Friendli API rejects. + const filtered = merged.filter((v) => v !== "default") + return filtered.filter((v): v is ReasoningEffortLevel => + (REASONING_EFFORT_LEVELS as readonly string[]).includes(v), + ) + } + + // Reasoning-capable model without a discrete effort enum — the handler can + // still toggle thinking on/off, so expose a boolean capability. + if (reasoning) { + return true + } + + return undefined +} + +/** + * getFriendliModels + * + * Fetches the live model list from the public Friendli API + * (https://api.friendli.ai/serverless/v1/models — no auth required) and maps + * each entry to a `ModelInfo`. Resilient: uses zod `safeParse` on the response + * shape and logs (but does not throw on) per-model mapping errors, mirroring + * the Vercel AI Gateway fetcher. + */ +export async function getFriendliModels(_options?: ApiHandlerOptions): Promise> { + const models: Record = {} + const baseURL = "https://api.friendli.ai/serverless/v1" + + try { + const response = await axios.get(`${baseURL}/models`) + const result = friendliModelsResponseSchema.safeParse(response.data) + const data = result.success ? result.data.data : (response.data?.data ?? []) + + if (!result.success) { + console.error(`Friendli models response is invalid ${JSON.stringify(result.error.format())}`) + } + + for (const model of data) { + const { id } = model + + // Only include chat models. Embedding/vision-generation-only modes + // are not surfaced through this path. + if (model.mode && model.mode !== "chat") { + continue + } + + try { + models[id] = parseFriendliModel({ id, model }) + } catch (error) { + console.error(`[Friendli fetcher] Failed to parse model ${id}:`, error) + } + } + } catch (error) { + console.error(`Error fetching Friendli models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + } + + return models +} + +/** + * parseFriendliModel + * + * Pure transform from a Friendli API model entry to a `ModelInfo`. Factored out + * so tests can exercise it directly without going through axios. + */ +export const parseFriendliModel = ({ id, model }: { id: string; model: FriendliModel }): ModelInfo => { + // Friendli returns both `input`/`output` and legacy `prompt`/`completion` + // aliases. Prefer the canonical names and fall back to the aliases. + const inputPriceStr = model.pricing?.input ?? model.pricing?.prompt + const outputPriceStr = model.pricing?.output ?? model.pricing?.completion + + const cacheWritesPrice = model.pricing?.cache_write ? parseApiPrice(model.pricing.cache_write) : undefined + const cacheReadsPrice = model.pricing?.input_cache_read ? parseApiPrice(model.pricing.input_cache_read) : undefined + + // supportsPromptCache is true when the API exposes cache pricing at all — + // even a zero write price indicates the provider honors cached reads. + const supportsPromptCache = typeof cacheWritesPrice !== "undefined" || typeof cacheReadsPrice !== "undefined" + + const supportsImages = Array.isArray(model.input_modalities) ? model.input_modalities.includes("image") : false + + const modelInfo: ModelInfo = { + maxTokens: model.max_completion_tokens ?? 0, + contextWindow: model.context_length ?? 0, + supportsImages, + supportsPromptCache, + inputPrice: parseApiPrice(inputPriceStr), + outputPrice: parseApiPrice(outputPriceStr), + cacheWritesPrice, + cacheReadsPrice, + description: model.description && model.description.trim() !== "" ? model.description : undefined, + } + + if (model.deprecation_date) { + modelInfo.deprecated = true + } + + const reasoningEffort = buildSupportsReasoningEffort(model.reasoning, model.reasoning_options) + if (reasoningEffort !== undefined) { + modelInfo.supportsReasoningEffort = reasoningEffort + if (Array.isArray(reasoningEffort)) { + // Default the selected effort to "high" for controllable reasoning + // models, matching the static `friendliModels` entries for GLM-5.x. + modelInfo.reasoningEffort = "high" + } + } + + // Friendli's reasoning models honour a configurable max-output slider + // (supportsMaxTokens). The static fallback marks GLM-5.x with this; mirror + // it for dynamic controllable-reasoning models so the UI shows the slider. + if (Array.isArray(reasoningEffort)) { + modelInfo.supportsMaxTokens = true + } + + // We intentionally do not map tool_call / structured_output capability flags + // into ModelInfo — the OpenAI-compatible base class already sends tools for + // all models and the Friendli backend ignores the fields it doesn't support. + + return modelInfo +} diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index f56ddba34f..9bd4a94d6a 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -7,7 +7,7 @@ import NodeCache from "node-cache" import { z } from "zod" import type { ProviderName, ModelRecord } from "@roo-code/types" -import { modelInfoSchema, TelemetryEventName } from "@roo-code/types" +import { modelInfoSchema, providerIdentifiers, TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { safeWriteJson } from "../../../utils/safeWriteJson" @@ -32,6 +32,7 @@ import { getDeepSeekModels } from "./deepseek" import { getMoonshotModels } from "./moonshot" import { getZooGatewayModels } from "./zoo-gateway" import { getKimiCodeModels } from "./kimi-code" +import { getFriendliModels } from "./friendli" const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) @@ -43,33 +44,36 @@ const modelRecordSchema = z.record(z.string(), modelInfoSchema) // deduplicate each other's in-flight refreshes. const inFlightRefresh = new Map>() -// Providers whose model lists are scoped to the signed-in user (e.g. per-account -// allowlists or org policies). For these we MUST NOT cache results on disk or -// in memory: a sign-in/out cycle could otherwise serve a previous user's model -// list to the next user, and stale data could mask backend allowlist updates. -const AUTH_SCOPED_PROVIDERS: ReadonlySet = new Set(["zoo-gateway", "kimi-code"]) - // Providers whose model list is determined by the server URL, not just by the provider name. // Each unique baseUrl must be cached independently so that switching endpoints never serves // stale results from a previously-cached server. const URL_SCOPED_PROVIDERS: ReadonlySet = new Set([ - "litellm", - "poe", - "deepseek", - "moonshot", - "ollama", - "lmstudio", - "requesty", + providerIdentifiers.litellm, + providerIdentifiers.poe, + providerIdentifiers.deepseek, + providerIdentifiers.moonshot, + providerIdentifiers.ollama, + providerIdentifiers.lmstudio, + providerIdentifiers.requesty, ]) // Providers where the API key itself determines which models are visible (e.g. per-key // allowlists). For these the cache key also includes a short hash of // the API key so that two different keys on the same server never share a cache entry. const KEY_SCOPED_PROVIDERS: ReadonlySet = new Set([ - "litellm", // Per-key model allowlists are a first-class LiteLLM proxy feature - "poe", // Per-account model availability - "requesty", // Per-account custom model policies - "moonshot", // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn) + providerIdentifiers.litellm, // Per-key model allowlists are a first-class LiteLLM proxy feature + providerIdentifiers.poe, // Per-account model availability + providerIdentifiers.requesty, // Per-account custom model policies + providerIdentifiers.moonshot, // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn) +]) + +// Providers whose model lists are scoped to the signed-in user (e.g. per-account +// allowlists or org policies). For these we MUST NOT cache results on disk or +// in memory: a sign-in/out cycle could otherwise serve a previous user's model +// list to the next user, and stale data could mask backend allowlist updates. +const AUTH_SCOPED_PROVIDERS: ReadonlySet = new Set([ + providerIdentifiers.zooGateway, + providerIdentifiers.kimiCode, ]) function isAuthScopedProvider(provider: RouterName): boolean { @@ -191,49 +195,52 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { setTimeout(async () => { // Providers that work without API keys const publicProviders: Array<{ provider: RouterName; options: GetModelsOptions }> = [ - { provider: "openrouter", options: { provider: "openrouter" } }, - { provider: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, + { + provider: providerIdentifiers.openrouter, + options: { provider: providerIdentifiers.openrouter }, + }, + { + provider: providerIdentifiers.vercelAiGateway, + options: { provider: providerIdentifiers.vercelAiGateway }, + }, + { + provider: providerIdentifiers.friendli, + options: { provider: providerIdentifiers.friendli }, + }, ] // Refresh each provider in background (fire and forget) diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index f9aa4fa20c..5967950c5a 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -1,14 +1,78 @@ -import { type FriendliModelId, friendliDefaultModelId, friendliModels } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { type FriendliModelId, friendliDefaultModelId, friendliModels, type ModelInfo } from "@roo-code/types" +import type { ModelRecord } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { shouldUseReasoningEffort, getModelMaxOutputTokens } from "../../shared/api" + +import { convertToOpenAiMessages } from "../transform/openai-format" +import { getModelParams } from "../transform/model-params" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { handleOpenAIError } from "./utils/error-handler" +import { getModels } from "./fetchers/modelCache" +import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" + +/** + * Friendli extends the OpenAI Chat Completions API with these non-standard fields: + * - reasoning_effort: enum (minimal, low, medium, high, xhigh, max) — reasoning depth + * - chat_template_kwargs: { enable_thinking: boolean } — toggles thinking for controllable models + * - parse_reasoning / include_reasoning: when true, Friendli streams reasoning via + * delta.reasoning_content (which extractReasoningFromDelta already handles) + * - reasoning_budget: integer token budget (not currently surfaced in settings UI) + * + * The reasoning fields are shared across streaming and non-streaming requests; the base + * `ChatCompletionCreateParams` (non-streaming) variant is used for `completePrompt` while the + * `ChatCompletionCreateParamsStreaming` variant is used for `createStream`. + */ +type FriendliReasoningParams = { + chat_template_kwargs?: { enable_thinking: boolean } + parse_reasoning?: boolean + include_reasoning?: boolean + // Friendli's reasoning_effort supports a broader enum than OpenAI's type allows + reasoning_effort?: + | OpenAI.Chat.Completions.ChatCompletionCreateParams["reasoning_effort"] + | "minimal" + | "xhigh" + | "max" +} + +type FriendliChatCompletionParams = Omit< + OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + "reasoning_effort" +> & + FriendliReasoningParams + +type FriendliChatCompletionNonStreamingParams = Omit< + OpenAI.Chat.Completions.ChatCompletionCreateParams, + "reasoning_effort" +> & + FriendliReasoningParams /** * Handler for the Friendli Model APIs (OpenAI-compatible). * Routes chat completions to `https://api.friendli.ai/serverless/v1`. + * + * Model list is dynamic: on construction the handler kicks off a fire-and-forget + * fetch of the live model list from `https://api.friendli.ai/serverless/v1/models` + * (public, no auth) via the shared `getModels` cache. `getModel()` falls back to + * the static `friendliModels` map when dynamic models haven't loaded yet or when + * the requested model id isn't present in the dynamic set (e.g. the API lags + * behind a newly released model). This mirrors the OpenRouterHandler pattern. + * + * Overrides `createStream` and `completePrompt` to inject Friendli-specific + * reasoning parameters that the base class doesn't know about. */ export class FriendliHandler extends BaseOpenAiCompatibleProvider { + /** + * Dynamically fetched model list (populated asynchronously after construction). + * Empty until the background load completes; `getModel()` falls back to the + * static `providerModels` (`friendliModels`) in that window. + */ + private dynamicModels: ModelRecord = {} + /** * @param options Provider settings; `friendliApiKey` is required. */ @@ -19,8 +83,182 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider, defaultTemperature: 0.6, }) + + // Load dynamic models asynchronously to populate the cache before + // getModel() is called. Fire-and-forget; errors are logged by the + // cache layer and we gracefully fall back to static models. + getModels({ provider: "friendli" }) + .then((models) => { + this.dynamicModels = models + }) + .catch((error) => { + console.error("[FriendliHandler] Failed to load dynamic models:", error) + }) + } + + override getModel() { + const requestedId = this.options.apiModelId + + // Prefer dynamic info when available; fall back to static `providerModels` + // (the hardcoded `friendliModels` passed to super) for cold-start, network + // failure, or models not yet in the dynamic list. + const dynamicInfo = requestedId ? this.dynamicModels[requestedId] : undefined + const staticId = + requestedId && requestedId in this.providerModels + ? (requestedId as FriendliModelId) + : this.defaultProviderModelId + const staticInfo = this.providerModels[staticId] + + // Determine which id/info pair to use. + let id: FriendliModelId + let info: ModelInfo + if (dynamicInfo) { + id = requestedId as FriendliModelId + info = dynamicInfo + } else if (requestedId && requestedId in this.providerModels) { + id = requestedId as FriendliModelId + info = staticInfo + } else if (requestedId && this.dynamicModels[requestedId]) { + // Edge case: requestedId is dynamic but dynamicModels lookup above + // was undefined — shouldn't happen, but keep this branch for safety. + id = requestedId as FriendliModelId + info = this.dynamicModels[requestedId] + } else { + id = staticId + info = staticInfo + } + + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0.6, + }) + + return { id, info, ...params } + } + + /** + * Build Friendli-specific reasoning params to merge into the OpenAI request. + * + * Rules: + * - Controllable reasoning models (GLM-5.x): always send `chat_template_kwargs`. + * User enabled → { enable_thinking: true } + reasoning_effort + parse_reasoning. + * User disabled (none/disable) → { enable_thinking: false } to prevent the + * model's Jinja template from defaulting thinking ON. + * - Non-reasoning models (DeepSeek-V3.2, MiniMax-M2.5): no extra params + * (reasoning_effort silently ignored). + */ + private buildFriendliReasoningParams(): Partial { + const { info: modelInfo, reasoningEffort } = this.getModel() + const extra: Partial = {} + + const isControllableReasoning = Array.isArray(modelInfo.supportsReasoningEffort) + + const useReasoningEffort = modelInfo.supportsReasoningEffort + ? shouldUseReasoningEffort({ model: modelInfo, settings: this.options }) + : false + + // User disabled reasoning on a controllable model — explicitly turn thinking off. + // The model's Jinja chat template defaults enable_thinking to true, so omitting + // the param would leave reasoning active (burning tokens against user intent). + if (isControllableReasoning && !useReasoningEffort) { + extra.chat_template_kwargs = { enable_thinking: false } + return extra + } + + // Non-reasoning model — nothing to send + if (!useReasoningEffort) { + return extra + } + + // Reasoning is enabled + extra.parse_reasoning = true + extra.include_reasoning = true + extra.chat_template_kwargs = { enable_thinking: true } + + if (reasoningEffort) { + extra.reasoning_effort = reasoningEffort as FriendliReasoningParams["reasoning_effort"] + } + + return extra + } + + /** + * Override createStream to inject Friendli-specific reasoning params. + * The base class createMessage() calls createStream and handles all stream + * processing (TagMatcher, extractReasoningFromDelta, tool calls, usage). + */ + protected override createStream( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + requestOptions?: OpenAI.RequestOptions, + ) { + const friendliExtra = this.buildFriendliReasoningParams() + + const { id: model, info } = this.getModel() + + // Centralized cap: clamp to 20% of the context window + const max_tokens = + getModelMaxOutputTokens({ + modelId: model, + model: info, + settings: this.options, + format: "openai", + }) ?? undefined + + const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature + + const params: FriendliChatCompletionParams = { + model, + max_tokens, + temperature, + messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + stream: true, + stream_options: { include_usage: true }, + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + parallel_tool_calls: metadata?.parallelToolCalls ?? true, + ...friendliExtra, + } + + try { + return this.client.chat.completions.create( + params as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + requestOptions, + ) + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } + } + + override async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + const { id: modelId } = this.getModel() + const friendliExtra = this.buildFriendliReasoningParams() + + const params: FriendliChatCompletionNonStreamingParams = { + model: modelId, + messages: [{ role: "user", content: prompt }], + ...friendliExtra, + } + + try { + const requestOptions: OpenAI.RequestOptions | undefined = + options && (options.abortSignal !== undefined || options.timeoutMs !== undefined) + ? { signal: options.abortSignal, timeout: options.timeoutMs } + : undefined + const response = (await this.client.chat.completions.create( + params as OpenAI.Chat.Completions.ChatCompletionCreateParams, + requestOptions, + )) as OpenAI.Chat.Completions.ChatCompletion + return response.choices?.[0]?.message.content || "" + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } } } diff --git a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts index 1fcdac0ed7..df47e83c21 100644 --- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts +++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts @@ -446,4 +446,22 @@ describe("getEnvironmentDetails", () => { expect(getGitStatus).toHaveBeenCalledWith(mockCwd, 5) }) + + // Regression test for https://github.com/Zoo-Code-Org/Zoo-Code/issues/1024 + // When ripgrep cannot be found (e.g. @vscode/ripgrep >=1.18 platform layout on + // Windows), listFiles throws "Could not find ripgrep binary". getEnvironmentDetails + // must not propagate this — the task should proceed to the API call, not hang at 0%. + it("should degrade gracefully when listFiles rejects with an Error", async () => { + ;(listFiles as Mock).mockRejectedValue(new Error("Could not find ripgrep binary")) + + const result = await getEnvironmentDetails(mockCline as Task, true) + expect(result).toContain("File listing unavailable: Could not find ripgrep binary") + }) + + it("should degrade gracefully when listFiles rejects with a non-Error value", async () => { + ;(listFiles as Mock).mockRejectedValue("unexpected string rejection") + + const result = await getEnvironmentDetails(mockCline as Task, true) + expect(result).toContain("File listing unavailable: unexpected string rejection") + }) }) diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 141ffdf3e2..0e7d18a57a 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -241,18 +241,22 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo if (maxFiles === 0) { details += "(Workspace files context disabled. Use list_files to explore if needed.)" } else { - const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles) - const { showRooIgnoredFiles = false } = state ?? {} - - const result = formatResponse.formatFilesList( - cline.cwd, - files, - didHitLimit, - cline.rooIgnoreController, - showRooIgnoredFiles, - ) - - details += result + try { + const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles) + const { showRooIgnoredFiles = false } = state ?? {} + + const result = formatResponse.formatFilesList( + cline.cwd, + files, + didHitLimit, + cline.rooIgnoreController, + showRooIgnoredFiles, + ) + + details += result + } catch (error) { + details += `(File listing unavailable: ${error instanceof Error ? error.message : String(error)})` + } } } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7bee3c5e14..da593f0553 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -54,6 +54,7 @@ import { ConsecutiveMistakeError, MAX_MCP_TOOLS_THRESHOLD, countEnabledMcpTools, + providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService } from "@roo-code/cloud" @@ -4220,7 +4221,7 @@ export class Task extends EventEmitter implements TaskLike { // but uses allowedFunctionNames to restrict which tools can be called. // Other providers (Anthropic, OpenAI, etc.) don't support this feature yet, // so they continue to receive only the filtered tools for the current mode. - const supportsAllowedFunctionNames = apiConfiguration?.apiProvider === "gemini" + const supportsAllowedFunctionNames = apiConfiguration?.apiProvider === providerIdentifiers.gemini { const provider = this.providerRef.deref() diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 0c784821c1..0b4201d135 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6,7 +6,13 @@ import * as path from "path" import * as vscode from "vscode" import { Anthropic } from "@anthropic-ai/sdk" -import type { GlobalState, ProviderSettings, ModelInfo } from "@roo-code/types" +import { + providerIdentifiers, + type GlobalState, + type ProviderSettings, + type ModelInfo, + type TaskLike, +} from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../Task" @@ -17,6 +23,27 @@ import { ApiStreamChunk } from "../../../api/transform/stream" import { ContextProxy } from "../../config/ContextProxy" import { processUserContentMentions } from "../../mentions/processUserContentMentions" import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" +import type { ApiMessage } from "../../task-persistence" + +type TaskTestAccess = { + getSystemPrompt: () => Promise + startTask: (task?: string, images?: string[]) => Promise + resumeTaskFromHistory: () => Promise + presentAssistantMessageSafe: () => void + updateClineMessage: (message: import("@roo-code/types").ClineMessage) => Promise + saveClineMessages: () => Promise +} + +function getTaskTestAccess(task: Task): TaskTestAccess { + return task as unknown as TaskTestAccess +} + +function requireDefined(value: T | null | undefined): T { + if (value == null) { + throw new Error("Expected test value to be defined") + } + return value +} // Mock delay before any imports that might use it vi.mock("delay", () => ({ @@ -161,7 +188,7 @@ vi.mock("../../environment/getEnvironmentDetails", () => ({ vi.mock("../../ignore/RooIgnoreController") vi.mock("../../condense", async (importOriginal) => { - const actual = (await importOriginal()) as any + const actual = await importOriginal() return { ...actual, summarizeConversation: vi.fn().mockResolvedValue({ @@ -274,11 +301,11 @@ describe("Cline", () => { mockOutputChannel, "sidebar", new ContextProxy(mockExtensionContext), - ) as any + ) // Setup mock API configuration mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", // Add API key to mock config } @@ -409,7 +436,7 @@ describe("Cline", () => { task: "test task", startTask: false, }) - vi.spyOn(cline as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") const mockStream = { async *[Symbol.asyncIterator]() { @@ -437,7 +464,7 @@ describe("Cline", () => { ts: Date.now(), extraProp: "should be removed", }, - ] as any + ] as Array const iterator = cline.attemptApiRequest(0) await iterator.next() @@ -480,7 +507,7 @@ describe("Cline", () => { task: "test task", startTask: false, }) - vi.spyOn(withImages as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(withImages), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(withImages.api, "getModel").mockReturnValue({ id: "claude-3-sonnet", @@ -503,7 +530,7 @@ describe("Cline", () => { task: "test task", startTask: false, }) - vi.spyOn(withoutImages as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(withoutImages), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(withoutImages.api, "getModel").mockReturnValue({ id: "gpt-3.5-turbo", @@ -537,8 +564,8 @@ describe("Cline", () => { const withImagesSpy = vi.spyOn(withImages.api, "createMessage").mockReturnValue(mockStream) const withoutImagesSpy = vi.spyOn(withoutImages.api, "createMessage").mockReturnValue(mockStream) - withImages.apiConversationHistory = conversationHistory as any - withoutImages.apiConversationHistory = conversationHistory as any + withImages.apiConversationHistory = conversationHistory as ApiMessage[] + withoutImages.apiConversationHistory = conversationHistory as ApiMessage[] const withImagesIterator = withImages.attemptApiRequest(0) await withImagesIterator.next() @@ -582,7 +609,7 @@ describe("Cline", () => { task: "test task", startTask: false, }) - vi.spyOn(cline as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock delay to track countdown timing const mockDelay = vi.fn().mockResolvedValue(undefined) @@ -676,7 +703,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: clock, }) - vi.spyOn(cline as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") const mockDelay = vi.fn().mockResolvedValue(undefined) vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay) @@ -750,7 +777,7 @@ describe("Cline", () => { task: "test task", startTask: false, }) - vi.spyOn(cline as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock delay to track countdown timing const mockDelay = vi.fn().mockResolvedValue(undefined) @@ -919,7 +946,7 @@ describe("Cline", () => { beforeEach(() => { vi.clearAllMocks() mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", rateLimitSeconds: 5, } @@ -966,7 +993,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(parent as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { @@ -1004,7 +1031,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(child as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(child), "getSystemPrompt").mockResolvedValue("mock system prompt") // Spy on child.say to verify the emitted message type const saySpy = vi.spyOn(child, "say") @@ -1059,7 +1086,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(parent as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { @@ -1099,7 +1126,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(child as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(child), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream) @@ -1125,7 +1152,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(parent as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { @@ -1160,7 +1187,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(child1 as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(child1), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child1.api, "createMessage").mockReturnValue(mockStream) @@ -1185,7 +1212,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(child2 as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(child2), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child2.api, "createMessage").mockReturnValue(mockStream) @@ -1215,7 +1242,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(parent as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { @@ -1250,7 +1277,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: sharedClock, }) - vi.spyOn(child as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(child), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream) @@ -1273,7 +1300,7 @@ describe("Cline", () => { startTask: false, rateLimitClock: clock, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { @@ -1312,7 +1339,7 @@ describe("Cline", () => { vi.clearAllMocks() mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } @@ -1366,7 +1393,7 @@ describe("Cline", () => { // Test with Anthropic provider const anthropicConfig = { ...mockApiConfig, - apiProvider: "anthropic" as const, + apiProvider: providerIdentifiers.anthropic, apiModelId: "gpt-4", } const anthropicTask = new Task({ @@ -1380,7 +1407,7 @@ describe("Cline", () => { // Test with OpenRouter provider and Claude model const openrouterClaudeConfig = { - apiProvider: "openrouter" as const, + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "anthropic/claude-3-opus", } const openrouterClaudeTask = new Task({ @@ -1393,7 +1420,7 @@ describe("Cline", () => { // Test with OpenRouter provider and non-Claude model const openrouterGptConfig = { - apiProvider: "openrouter" as const, + apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", } const openrouterGptTask = new Task({ @@ -1415,7 +1442,7 @@ describe("Cline", () => { for (const modelId of claudeModelFormats) { const config = { - apiProvider: "openai" as const, + apiProvider: providerIdentifiers.openai, openAiModelId: modelId, } const task = new Task({ @@ -1444,7 +1471,7 @@ describe("Cline", () => { // Test with no model ID const noModelConfig = { - apiProvider: "openai" as const, + apiProvider: providerIdentifiers.openai, } const noModelTask = new Task({ provider: mockProvider, @@ -1629,7 +1656,7 @@ describe("Cline", () => { }) // Cast to TaskLike to ensure interface compliance - const taskLike = task as any // TaskLike interface from types package + const taskLike: TaskLike = task // Verify abortTask method exists and is callable expect(typeof taskLike.abortTask).toBe("function") @@ -1784,11 +1811,9 @@ describe("Cline", () => { const cancelSpy = vi.spyOn(task, "cancelCurrentRequest") // Mock other dispose operations - vi.spyOn(task.messageQueueService, "removeListener").mockImplementation( - () => task.messageQueueService as any, - ) + vi.spyOn(task.messageQueueService, "removeListener").mockImplementation(() => task.messageQueueService) vi.spyOn(task.messageQueueService, "dispose").mockImplementation(() => {}) - vi.spyOn(task, "removeAllListeners").mockImplementation(() => task as any) + vi.spyOn(task, "removeAllListeners").mockImplementation(() => task) // Call dispose task.dispose() @@ -1806,7 +1831,7 @@ describe("Cline", () => { }) task.currentRequestAbortController = new AbortController() - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") await task.condenseContext() @@ -1823,7 +1848,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") await task.condenseContext() @@ -1842,7 +1867,7 @@ describe("Cline", () => { }) // Mock required methods for attemptApiRequest to work without hanging - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, @@ -1889,7 +1914,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] const iterator = task.attemptApiRequest(0) await iterator.next() @@ -1902,6 +1927,55 @@ describe("Cline", () => { expect(metadata!.abortSignal).toBe(task.currentRequestAbortController!.signal) }) + it("configures tool restrictions for Gemini requests", async () => { + const apiConfiguration = { + ...mockApiConfig, + apiProvider: providerIdentifiers.gemini, + } as ProviderSettings + const task = new Task({ + provider: mockProvider, + apiConfiguration, + task: "test task", + startTask: false, + }) + + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: requireDefined(mockApiConfig.apiModelId), + info: { contextWindow: 200000, maxTokens: 4096 } as ModelInfo, + }) + const providerState = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue({ + ...providerState, + apiConfiguration, + autoApprovalEnabled: true, + requestDelaySeconds: 0, + }) + const mockStream = (async function* () { + yield { type: "text", text: "response" } as ApiStreamChunk + })() + const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + + await task.attemptApiRequest(0).next() + + const [, , metadata] = requireDefined(createMessageSpy.mock.calls[0]) + const tools = requireDefined(metadata?.tools) + const allowedFunctionNames = requireDefined(metadata?.allowedFunctionNames) + const toolNames = tools.map((tool) => { + if (tool.type !== "function") { + throw new Error(`Unexpected tool type: ${tool.type}`) + } + return tool.function.name + }) + + expect(tools.length).toBeGreaterThan(0) + expect(allowedFunctionNames.length).toBeGreaterThan(0) + expect(allowedFunctionNames.every((name) => toolNames.includes(name))).toBe(true) + }) + it("should invoke abort on currentRequestAbortController during first-chunk wait", async () => { const task = new Task({ provider: mockProvider, @@ -1930,7 +2004,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { @@ -1991,7 +2065,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] const streamIterator = task.attemptApiRequest(0) await expect(streamIterator.next()).resolves.toMatchObject({ @@ -2014,7 +2088,7 @@ describe("Cline", () => { }) // Mock required methods for attemptApiRequest to work without hanging - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, @@ -2061,7 +2135,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] const iterator = task.attemptApiRequest(0) await iterator.next() @@ -2082,7 +2156,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { @@ -2126,7 +2200,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] expect(task.currentRequestAbortController).toBeUndefined() @@ -2147,7 +2221,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { @@ -2191,7 +2265,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] const iterator = task.attemptApiRequest(0) await iterator.next() @@ -2210,7 +2284,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { @@ -2246,7 +2320,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] // First request const iterator1 = task.attemptApiRequest(0) @@ -2284,7 +2358,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, @@ -2325,7 +2399,7 @@ describe("Cline", () => { content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, - ] as any + ] let firstCall = true const retryStream = { @@ -2393,7 +2467,7 @@ describe("Cline", () => { }) // Manually trigger start - const startTaskSpy = vi.spyOn(task as any, "startTask").mockImplementation(async () => {}) + const startTaskSpy = vi.spyOn(getTaskTestAccess(task), "startTask").mockImplementation(async () => {}) task.start() expect(startTaskSpy).toHaveBeenCalledTimes(1) @@ -2406,7 +2480,9 @@ describe("Cline", () => { it("should not call startTask if already started via constructor", () => { // Create a task that starts immediately (startTask defaults to true) // but mock startTask to prevent actual execution - const startTaskSpy = vi.spyOn(Task.prototype as any, "startTask").mockImplementation(async () => {}) + const startTaskSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "startTask") + .mockImplementation(async () => {}) const task = new Task({ provider: mockProvider, @@ -2447,9 +2523,11 @@ describe("Cline", () => { it("logs (instead of crashing) when startTask rejects from the constructor", async () => { const boom = new Error("startTask boom") - const startTaskSpy = vi.spyOn(Task.prototype as any, "startTask").mockImplementation(async () => { - throw boom - }) + const startTaskSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "startTask") + .mockImplementation(async () => { + throw boom + }) new Task({ provider: mockProvider, @@ -2467,9 +2545,11 @@ describe("Cline", () => { it("logs (instead of crashing) when resumeTaskFromHistory rejects from the constructor", async () => { const boom = new Error("resume boom") - const resumeSpy = vi.spyOn(Task.prototype as any, "resumeTaskFromHistory").mockImplementation(async () => { - throw boom - }) + const resumeSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "resumeTaskFromHistory") + .mockImplementation(async () => { + throw boom + }) new Task({ provider: mockProvider, @@ -2526,7 +2606,7 @@ describe("Cline", () => { startTask: false, }) - vi.spyOn(task as any, "startTask").mockImplementation(async () => { + vi.spyOn(getTaskTestAccess(task), "startTask").mockImplementation(async () => { throw boom }) @@ -2556,7 +2636,7 @@ describe("Cline", () => { consoleErrorSpy.mockClear() task.abort = true - ;(task as any).presentAssistantMessageSafe() + getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) @@ -2579,7 +2659,7 @@ describe("Cline", () => { }) expect(task.abort).toBeFalsy() - ;(task as any).presentAssistantMessageSafe() + getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) @@ -2610,7 +2690,7 @@ describe("Cline", () => { // Simulate the TOCTOU race: abort flips between throw and catch. task.abort = true - ;(task as any).presentAssistantMessageSafe() + getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) @@ -2640,7 +2720,7 @@ describe("Cline", () => { consoleErrorSpy.mockClear() expect(task.abort).toBeFalsy() - ;(task as any).presentAssistantMessageSafe() + getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) @@ -2657,9 +2737,11 @@ describe("Cline", () => { // consumer-attached listener — that path must surface as a log, // not an unhandled rejection. const boom = new Error("updateClineMessage boom") - const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => { - throw boom - }) + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockImplementation(async () => { + throw boom + }) const task = new Task({ provider: mockProvider, @@ -2689,10 +2771,12 @@ describe("Cline", () => { // Pins the symmetric .catch arm on the fire-and-forget // updateClineMessage call in ask() when finalizing a partial. const boom = new Error("updateClineMessage boom") - const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => { - throw boom - }) - const saveSpy = vi.spyOn(Task.prototype as any, "saveClineMessages").mockResolvedValue(true) + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockImplementation(async () => { + throw boom + }) + const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) const task = new Task({ provider: mockProvider, @@ -2728,9 +2812,11 @@ describe("Cline", () => { // in ask() when a new partial ask arrives while the previous partial // is still pending (AskIgnoredError path). const boom = new Error("updateClineMessage boom") - const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => { - throw boom - }) + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockImplementation(async () => { + throw boom + }) const task = new Task({ provider: mockProvider, @@ -2761,10 +2847,12 @@ describe("Cline", () => { // Pins the .catch arm on the fire-and-forget updateClineMessage call // in handleWebviewAskResponse when marking a tool ask as answered. const boom = new Error("updateClineMessage boom") - const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => { - throw boom - }) - vi.spyOn(Task.prototype as any, "saveClineMessages").mockResolvedValue(undefined) + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockImplementation(async () => { + throw boom + }) + vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(false) const task = new Task({ provider: mockProvider, @@ -2827,7 +2915,12 @@ describe("Queued message processing after condense", () => { dispose: vi.fn(), } - const provider = new ClineProvider(ctx, output as any, "sidebar", new ContextProxy(ctx)) as any + const provider = new ClineProvider( + ctx, + output as unknown as vscode.OutputChannel, + "sidebar", + new ContextProxy(ctx), + ) provider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) provider.postStateToWebview = vi.fn().mockResolvedValue(undefined) provider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) @@ -2836,10 +2929,10 @@ describe("Queued message processing after condense", () => { } const apiConfig: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", - } as any + } it("processes queued message after condense completes", async () => { const provider = createProvider() @@ -2851,7 +2944,7 @@ describe("Queued message processing after condense", () => { }) // Make condense fast + deterministic - vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("system") + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("system") const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) // Queue a message during condensing @@ -2886,8 +2979,8 @@ describe("Queued message processing after condense", () => { startTask: false, }) - vi.spyOn(taskA as any, "getSystemPrompt").mockResolvedValue("system") - vi.spyOn(taskB as any, "getSystemPrompt").mockResolvedValue("system") + vi.spyOn(getTaskTestAccess(taskA), "getSystemPrompt").mockResolvedValue("system") + vi.spyOn(getTaskTestAccess(taskB), "getSystemPrompt").mockResolvedValue("system") const spyA = vi.spyOn(taskA, "submitUserMessage").mockResolvedValue(undefined) const spyB = vi.spyOn(taskB, "submitUserMessage").mockResolvedValue(undefined) @@ -2922,7 +3015,7 @@ describe("pushToolResultToUserContent", () => { beforeEach(() => { mockApiConfig = { - apiProvider: "anthropic", + apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } @@ -2965,7 +3058,7 @@ describe("pushToolResultToUserContent", () => { mockOutputChannel, "sidebar", new ContextProxy(mockExtensionContext), - ) as any + ) mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 643c4f5a22..458120d9a4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -50,6 +50,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, isRetiredProvider, + providerIdentifiers, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -479,7 +480,7 @@ export class ClineProvider async performPreparationTasks(cline: Task) { // LMStudio: We need to force model loading in order to read its context // size; we do it now since we're starting a task with that model selected. - if (cline.apiConfiguration && cline.apiConfiguration.apiProvider === "lmstudio") { + if (cline.apiConfiguration && cline.apiConfiguration.apiProvider === providerIdentifiers.lmstudio) { try { if (!hasLoadedFullDetails(cline.apiConfiguration.lmStudioModelId!)) { await forceFullModelDetailsLoad( @@ -1007,7 +1008,7 @@ export class ClineProvider // Using .find() would miss stale tokens in duplicate/renamed profiles since handleZooCodeCallback // uses .filter() and updates all of them — the early-return guard must match. const allProfiles = await this.providerSettingsManager.listConfig() - const zooGatewayProfiles = allProfiles.filter((p) => p.apiProvider === "zoo-gateway") + const zooGatewayProfiles = allProfiles.filter((p) => p.apiProvider === providerIdentifiers.zooGateway) if (zooGatewayProfiles.length === 0) { this.log("[ensureZooGatewayProfileSeeded] No zoo-gateway profile found, creating one") @@ -1868,14 +1869,14 @@ export class ClineProvider // Check if Zoo Gateway is the currently active profile by apiProvider identity, // not by profile name (profile names are user-renameable). - const isZooGatewayActive = currentSettings.apiProvider === "zoo-gateway" + const isZooGatewayActive = currentSettings.apiProvider === providerIdentifiers.zooGateway // Always scan ALL profiles and update every zoo-gateway profile with the new token. // This ensures renamed profiles, duplicate profiles, and inactive profiles all stay // in sync. The model lookup in requestRouterModels uses .find() which returns the // first zoo-gateway profile it finds — if that profile has a stale token, requests fail. const allProfiles = await this.providerSettingsManager.listConfig() - const zooProfiles = allProfiles.filter((p) => p.apiProvider === "zoo-gateway") + const zooProfiles = allProfiles.filter((p) => p.apiProvider === providerIdentifiers.zooGateway) if (zooProfiles.length === 0) { // No existing zoo-gateway profile — create the canonical default. diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 7e111442a9..93f4f2afaa 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -16,6 +16,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_WRITE_DELAY_MS, + providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -29,6 +30,7 @@ import { safeWriteJson } from "../../../utils/safeWriteJson" import { ClineProvider } from "../ClineProvider" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" +import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" // Mock setup must come before imports. vi.mock("../../prompts/sections/custom-instructions") @@ -258,6 +260,11 @@ vi.mock("../../../api/providers/fetchers/modelCache", () => ({ getModelsFromCache: vi.fn().mockReturnValue(undefined), })) +vi.mock("../../../api/providers/fetchers/lmstudio", () => ({ + hasLoadedFullDetails: vi.fn().mockReturnValue(false), + forceFullModelDetailsLoad: vi.fn().mockResolvedValue(undefined), +})) + vi.mock("../../../services/zoo-code-auth", () => ({ getZooCodeBaseUrl: vi.fn(() => "https://www.zoocode.dev"), getCachedZooCodeToken: vi.fn(), @@ -521,6 +528,32 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + test("loads full model details when preparing an LM Studio task", async () => { + await provider.performPreparationTasks({ + apiConfiguration: { + apiProvider: providerIdentifiers.lmstudio, + lmStudioBaseUrl: "http://localhost:1234", + lmStudioModelId: "test-model", + }, + } as Task) + + expect(forceFullModelDetailsLoad).toHaveBeenCalledWith("http://localhost:1234", "test-model") + }) + + test("does not reload full model details when the LM Studio model is already loaded", async () => { + vi.mocked(hasLoadedFullDetails).mockReturnValue(true) + + await provider.performPreparationTasks({ + apiConfiguration: { + apiProvider: providerIdentifiers.lmstudio, + lmStudioBaseUrl: "http://localhost:1234", + lmStudioModelId: "test-model", + }, + } as Task) + + expect(forceFullModelDetailsLoad).not.toHaveBeenCalled() + }) + test("resolveWebviewView hydrates the saved terminalProfile into the process-wide Terminal state", async () => { const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile").mockImplementation(() => {}) // Seed the persisted setting so the real getState() returns it during hydration. @@ -1458,7 +1491,7 @@ describe("ClineProvider", () => { test("handles case when no current task exists", async () => { // Clear the cline stack - ;(provider as any).taskRegistry = new TaskRegistry() + Object.assign(provider, { taskRegistry: new TaskRegistry() }) // Trigger message deletion const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index d843a3a6d9..64ad6804df 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1495,7 +1495,7 @@ describe("zooCodeSignOut", () => { vi.clearAllMocks() }) - it("disconnects Zoo Code and clears tokens from all zoo-gateway profiles", async () => { + it("disconnects Zoo Code and clears tokens from all Zoo Gateway profiles", async () => { const { disconnectZooCode } = await import("../../../services/zoo-code-auth") const upsertProviderProfile = vi.fn().mockResolvedValue(undefined) const saveConfig = vi.fn().mockResolvedValue(undefined) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a72c4955a3..d87a1a1597 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -22,6 +22,7 @@ import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, getCompletionCheckpoint, + providerIdentifiers, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -1054,6 +1055,7 @@ export const webviewMessageHandler = async ( "opencode-go": {}, kenari: {}, "kimi-code": {}, + friendli: {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1088,6 +1090,7 @@ export const webviewMessageHandler = async ( }, }, { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, + { key: "friendli", options: { provider: "friendli" } }, { key: "zoo-gateway", options: { @@ -2773,11 +2776,11 @@ export const webviewMessageHandler = async ( const allProfiles = await provider.providerSettingsManager.listConfig() // Check if Zoo Gateway is the currently active profile by apiProvider identity const currentSettings = provider.contextProxy.getProviderSettings() - const isZooGatewayActive = currentSettings.apiProvider === "zoo-gateway" + const isZooGatewayActive = currentSettings.apiProvider === providerIdentifiers.zooGateway const currentApiConfigName = provider.contextProxy.getValues().currentApiConfigName for (const entry of allProfiles) { - if (entry.apiProvider !== "zoo-gateway") { + if (entry.apiProvider !== providerIdentifiers.zooGateway) { continue } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index a0f00c04e3..4b0f48253f 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -349,11 +349,6 @@ "count": 3 } }, - "api/providers/fetchers/__tests__/modelCache.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, "api/providers/fetchers/__tests__/modelEndpointCache.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 9 @@ -881,7 +876,7 @@ }, "core/task/__tests__/Task.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 93 + "count": 31 } }, "core/task/__tests__/Task.sticky-profile-race.spec.ts": { @@ -1111,7 +1106,7 @@ }, "core/webview/__tests__/ClineProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 199 + "count": 198 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { @@ -1689,11 +1684,6 @@ "count": 2 } }, - "shared/__tests__/ProfileValidator.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "shared/__tests__/api.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 8 diff --git a/src/services/ripgrep/__tests__/index.spec.ts b/src/services/ripgrep/__tests__/index.spec.ts index e6a613ad67..defc6d23b2 100644 --- a/src/services/ripgrep/__tests__/index.spec.ts +++ b/src/services/ripgrep/__tests__/index.spec.ts @@ -95,4 +95,50 @@ describe("getBinPath", () => { expect(await getBinPath(appRoot)).toBeUndefined() }) + + // Regression test for https://github.com/Zoo-Code-Org/Zoo-Code/issues/1024 + // VS Code 1.130+ ships @vscode/ripgrep >=1.18, where the binary lives in a + // platform-specific optional package (e.g. @vscode/ripgrep-win32-x64). + // None of the previous candidate paths matched this layout. + it("resolves ripgrep from the @vscode/ripgrep >=1.18 platform-package layout", async () => { + const arch = process.env.npm_config_arch || process.arch + const rg = path.join(appRoot, `node_modules/@vscode/ripgrep-${process.platform}-${arch}/bin`, binName) + mockFileExists.mockImplementation(async (p: string) => p === rg) + + expect(await getBinPath(appRoot)).toBe(rg) + }) + + it("resolves ripgrep from the unpacked @vscode/ripgrep >=1.18 platform-package layout", async () => { + const arch = process.env.npm_config_arch || process.arch + const rg = path.join( + appRoot, + `node_modules.asar.unpacked/@vscode/ripgrep-${process.platform}-${arch}/bin`, + binName, + ) + mockFileExists.mockImplementation(async (p: string) => p === rg) + + expect(await getBinPath(appRoot)).toBe(rg) + }) + + it("respects npm_config_arch when selecting the platform package", async () => { + const overrideArch = "x64" + const original = process.env.npm_config_arch + process.env.npm_config_arch = overrideArch + try { + const rg = path.join( + appRoot, + `node_modules/@vscode/ripgrep-${process.platform}-${overrideArch}/bin`, + binName, + ) + mockFileExists.mockImplementation(async (p: string) => p === rg) + + expect(await getBinPath(appRoot)).toBe(rg) + } finally { + if (original === undefined) { + delete process.env.npm_config_arch + } else { + process.env.npm_config_arch = original + } + } + }) }) diff --git a/src/services/ripgrep/index.ts b/src/services/ripgrep/index.ts index 96f5c34e4b..0d4a25056b 100644 --- a/src/services/ripgrep/index.ts +++ b/src/services/ripgrep/index.ts @@ -90,6 +90,9 @@ export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): * resolution) and the diagnostic command (existence report for all paths). */ export function ripgrepCandidatePaths(vscodeAppRoot: string): readonly string[] { + // Read at call time so process.env.npm_config_arch overrides take effect, + // matching @vscode/ripgrep's own arch selection logic. + const platformPkg = `@vscode/ripgrep-${process.platform}-${process.env.npm_config_arch || process.arch}` return [ path.join(vscodeAppRoot, "node_modules/@vscode/ripgrep/bin/", binName), path.join(vscodeAppRoot, "node_modules/vscode-ripgrep/bin", binName), @@ -101,15 +104,18 @@ export function ripgrepCandidatePaths(vscodeAppRoot: string): readonly string[] `node_modules.asar.unpacked/@vscode/ripgrep-universal/${ripgrepUniversalBinDir}`, binName, ), + // @vscode/ripgrep >=1.18 (VS Code 1.130+): binary lives in a platform-specific optional package. + path.join(vscodeAppRoot, `node_modules/${platformPkg}/bin`, binName), + path.join(vscodeAppRoot, `node_modules.asar.unpacked/${platformPkg}/bin`, binName), ] } /** * Get the path to the ripgrep binary shipped inside the VS Code installation. * - * Both the long-standing `@vscode/ripgrep` layout and the newer - * `@vscode/ripgrep-universal` layout are checked — the latter is what VS Code - * Insiders' staged-install builds use (see microsoft/vscode#252063). + * Probes all known layouts: classic @vscode/ripgrep, @vscode/ripgrep-universal + * (VS Code Insiders staged-install), and the @vscode/ripgrep >=1.18 + * platform-package layout used by VS Code 1.130+. * * Returns `undefined` when ripgrep cannot be located. */ diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index bb56718a7d..923582bd06 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -1,4 +1,4 @@ -import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" +import { providerIdentifiers, type ProviderSettings, type OrganizationAllowList } from "@roo-code/types" export class ProfileValidator { public static isProfileAllowed(profile: ProviderSettings, allowList: OrganizationAllowList): boolean { @@ -51,36 +51,36 @@ export class ProfileValidator { private static getModelIdFromProfile(profile: ProviderSettings): string | undefined { switch (profile.apiProvider) { - case "openai": + case providerIdentifiers.openai: return profile.openAiModelId - case "anthropic": - case "openai-native": - case "bedrock": - case "vertex": - case "gemini": - case "mistral": - case "deepseek": - case "xai": - case "sambanova": - case "fireworks": - case "friendli": + case providerIdentifiers.anthropic: + case providerIdentifiers.openaiNative: + case providerIdentifiers.bedrock: + case providerIdentifiers.vertex: + case providerIdentifiers.gemini: + case providerIdentifiers.mistral: + case providerIdentifiers.deepseek: + case providerIdentifiers.xai: + case providerIdentifiers.sambanova: + case providerIdentifiers.fireworks: + case providerIdentifiers.friendli: return profile.apiModelId - case "litellm": + case providerIdentifiers.litellm: return profile.litellmModelId - case "lmstudio": + case providerIdentifiers.lmstudio: return profile.lmStudioModelId - case "vscode-lm": + case providerIdentifiers.vscodeLm: // We probably need something more flexible for this one, if we need to really support it here. return profile.vsCodeLmModelSelector?.id - case "openrouter": + case providerIdentifiers.openrouter: return profile.openRouterModelId - case "ollama": + case providerIdentifiers.ollama: return profile.ollamaModelId - case "requesty": + case providerIdentifiers.requesty: return profile.requestyModelId - case "unbound": + case providerIdentifiers.unbound: return profile.unboundModelId - case "fake-ai": + case providerIdentifiers.fakeAi: default: return undefined } diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index efeb7a4820..ace96c9fd0 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -1,11 +1,78 @@ // npx vitest run src/shared/__tests__/ProfileValidator.spec.ts -import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" +import { providerIdentifiers, type ProviderSettings, type OrganizationAllowList } from "@roo-code/types" import { ProfileValidator } from "../ProfileValidator" describe("ProfileValidator", () => { describe("isProfileAllowed", () => { + it.each([ + ["openai", { openAiModelId: "model" }], + ["anthropic", { apiModelId: "model" }], + ["openaiNative", { apiModelId: "model" }], + ["bedrock", { apiModelId: "model" }], + ["vertex", { apiModelId: "model" }], + ["gemini", { apiModelId: "model" }], + ["mistral", { apiModelId: "model" }], + ["deepseek", { apiModelId: "model" }], + ["xai", { apiModelId: "model" }], + ["sambanova", { apiModelId: "model" }], + ["fireworks", { apiModelId: "model" }], + ["friendli", { apiModelId: "model" }], + ["litellm", { litellmModelId: "model" }], + ["lmstudio", { lmStudioModelId: "model" }], + ["vscodeLm", { vsCodeLmModelSelector: { id: "model" } }], + ["openrouter", { openRouterModelId: "model" }], + ["ollama", { ollamaModelId: "model" }], + ["requesty", { requestyModelId: "model" }], + ["unbound", { unboundModelId: "model" }], + ])("resolves %s model fields through canonical identifiers", (identifierKey, profileSettings) => { + const canonicalIdentifier = providerIdentifiers[identifierKey as keyof typeof providerIdentifiers] + const modelId = "model" + + const profile = { + apiProvider: canonicalIdentifier, + ...profileSettings, + } as ProviderSettings + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { + [canonicalIdentifier]: { allowAll: false, models: [modelId] }, + }, + } + + expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) + + const negativeAllowList: OrganizationAllowList = { + allowAll: false, + providers: { + [canonicalIdentifier]: { allowAll: false, models: ["other-model"] }, + }, + } + + expect(ProfileValidator.isProfileAllowed(profile, negativeAllowList)).toBe(false) + }) + + it.each([ + { providerAllowAll: false, expected: false }, + { providerAllowAll: true, expected: true }, + ])( + "preserves missing-model fallback behavior when provider allowAll is $providerAllowAll", + ({ providerAllowAll, expected }) => { + const profile: ProviderSettings = { + apiProvider: providerIdentifiers.openai, + } + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { + [providerIdentifiers.openai]: { allowAll: providerAllowAll }, + }, + } + + expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(expected) + }, + ) + it("should allow any profile when allowAll is true", () => { const allowList: OrganizationAllowList = { allowAll: true, @@ -168,17 +235,17 @@ describe("ProfileValidator", () => { // Test specific providers that use apiModelId const apiModelProviders = [ - "anthropic", - "openai-native", - "bedrock", - "vertex", - "gemini", - "mistral", - "deepseek", - "xai", - "sambanova", - "fireworks", - "friendli", + providerIdentifiers.anthropic, + providerIdentifiers.openaiNative, + providerIdentifiers.bedrock, + providerIdentifiers.vertex, + providerIdentifiers.gemini, + providerIdentifiers.mistral, + providerIdentifiers.deepseek, + providerIdentifiers.xai, + providerIdentifiers.sambanova, + providerIdentifiers.fireworks, + providerIdentifiers.friendli, ] apiModelProviders.forEach((provider) => { @@ -190,7 +257,7 @@ describe("ProfileValidator", () => { }, } const profile: ProviderSettings = { - apiProvider: provider as any, // Type assertion needed here + apiProvider: provider, apiModelId: "test-model", } @@ -203,11 +270,11 @@ describe("ProfileValidator", () => { const allowList: OrganizationAllowList = { allowAll: false, providers: { - litellm: { allowAll: false, models: ["test-model"] }, + [providerIdentifiers.litellm]: { allowAll: false, models: ["test-model"] }, }, } const profile: ProviderSettings = { - apiProvider: "litellm" as any, + apiProvider: providerIdentifiers.litellm, litellmModelId: "test-model", } @@ -218,11 +285,11 @@ describe("ProfileValidator", () => { const allowList: OrganizationAllowList = { allowAll: false, providers: { - "vscode-lm": { allowAll: false, models: ["copilot-gpt-3.5"] }, + [providerIdentifiers.vscodeLm]: { allowAll: false, models: ["copilot-gpt-3.5"] }, }, } const profile: ProviderSettings = { - apiProvider: "vscode-lm", + apiProvider: providerIdentifiers.vscodeLm, vsCodeLmModelSelector: { id: "copilot-gpt-3.5" }, } @@ -278,11 +345,11 @@ describe("ProfileValidator", () => { const allowList: OrganizationAllowList = { allowAll: false, providers: { - "fake-ai": { allowAll: false }, + [providerIdentifiers.fakeAi]: { allowAll: false }, }, } const profile: ProviderSettings = { - apiProvider: "fake-ai", + apiProvider: providerIdentifiers.fakeAi, } expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(false) diff --git a/src/shared/api.ts b/src/shared/api.ts index 056612f9f9..6911100b34 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -190,6 +190,7 @@ const dynamicProviderExtras = { "opencode-go": {} as { apiKey?: string }, kenari: {} as { apiKey?: string }, "kimi-code": {} as { apiKey?: string }, + friendli: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type } as const satisfies Record // Build the dynamic options union from the map, intersected with CommonFetchParams diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index c5e69978ff..30ac11dbd2 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -698,6 +698,10 @@ const ApiOptions = ({ )} diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 15061e333d..904c86e1d8 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -15,7 +15,6 @@ import { sambaNovaModels, internationalZAiModels, fireworksModels, - friendliModels, minimaxModels, basetenModels, mimoModels, @@ -36,7 +35,6 @@ export const MODELS_BY_PROVIDER: Partial void + routerModels?: RouterModels + organizationAllowList?: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean } /** * Settings form for the Friendli provider. - * Renders an API-key input and a "Get Friendli API Key" link when the key is empty. + * Renders an API-key input, a "Get Friendli API Key" link when the key is + * empty, and a model picker driven by the dynamic `routerModels.friendli` list + * (falling back to an empty object until the live list has been fetched). */ -export const Friendli = ({ apiConfiguration, setApiConfigurationField }: FriendliProps) => { +export const Friendli = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, + simplifySettings, +}: FriendliProps) => { const { t } = useAppTranslation() const handleInputChange = useCallback( @@ -49,6 +68,18 @@ export const Friendli = ({ apiConfiguration, setApiConfigurationField }: Friendl {t("settings:providers.getFriendliApiKey")} )} + ) } diff --git a/webview-ui/src/components/settings/providers/__tests__/Friendli.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Friendli.spec.tsx index 20ad73075b..90f3349f51 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Friendli.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Friendli.spec.tsx @@ -27,6 +27,10 @@ vi.mock("@src/components/common/VSCodeButtonLink", () => ({ ), })) +vi.mock("../../ModelPicker", () => ({ + ModelPicker: () =>
, +})) + describe("Friendli provider settings", () => { it("renders the 'Get Friendli API Key' link when no key is set", () => { render( diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index da660976f4..78f5b04258 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -212,6 +212,7 @@ export const PROVIDERS_WITH_CUSTOM_MODEL_UI: ProviderName[] = [ "lmstudio", "vscode-lm", "moonshot", // Moonshot has custom ModelPicker inside Moonshot.tsx + "friendli", ] /** diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 9433bb8148..72d269bdcf 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1004,6 +1004,7 @@ describe("useSelectedModel", () => { openrouter: {}, requesty: {}, litellm: {}, + friendli: {}, }, isLoading: false, isError: false, diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 50886d4538..a65c2761bd 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -362,9 +362,13 @@ function getSelectedModel({ return { id, info } } case "friendli": { - const id = apiConfiguration.apiModelId ?? defaultModelId - const info = friendliModels[id as keyof typeof friendliModels] - return { id, info } + const availableModels = routerModels.friendli + ? { ...friendliModels, ...routerModels.friendli } + : friendliModels + const id = getValidatedModelId(apiConfiguration.apiModelId, availableModels, defaultModelId) + const routerInfo = routerModels.friendli?.[id] + const staticInfo = friendliModels[id as keyof typeof friendliModels] + return { id, info: routerInfo ?? staticInfo } } case "poe": { const id = apiConfiguration.apiModelId ?? defaultModelId diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 8aebe3f8e5..96deda0707 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -55,6 +55,7 @@ describe("Model Validation Functions", () => { kenari: {}, "zoo-gateway": {}, "kimi-code": {}, + friendli: {}, moonshot: {}, }