From e978e242bcdde05f39b18504a786bdb695855378 Mon Sep 17 00:00:00 2001 From: siyoon Date: Sun, 26 Jul 2026 21:12:52 +0900 Subject: [PATCH 1/6] feat(friendli): fetch model list dynamically from /v1/models Convert Friendli from a static provider (4 hardcoded models) to a dynamic provider that fetches the live model list from the public https://api.friendli.ai/serverless/v1/models endpoint at runtime. - Add getFriendliModels() fetcher with zod schema validation - Wire friendli into modelCache, webviewMessageHandler, and dynamicProviders - FriendliHandler loads dynamic models in constructor, falls back to static friendliModels for cold-start and API lag - UI model picker uses routerModels.friendli instead of static list - Add fetcher spec (14 tests) and update Friendli.spec.tsx with ModelPicker mock --- .../__tests__/provider-identifiers.test.ts | 1 + packages/types/src/provider-settings.ts | 1 + packages/types/src/providers/friendli.ts | 8 +- .../fetchers/__tests__/friendli.spec.ts | 307 ++++++++++++++++++ src/api/providers/fetchers/friendli.ts | 247 ++++++++++++++ src/api/providers/fetchers/modelCache.ts | 8 + src/api/providers/friendli.ts | 64 +++- src/core/webview/webviewMessageHandler.ts | 2 + src/shared/api.ts | 1 + .../src/components/settings/ApiOptions.tsx | 4 + .../src/components/settings/constants.ts | 2 - .../settings/providers/Friendli.tsx | 37 ++- .../providers/__tests__/Friendli.spec.tsx | 4 + .../settings/utils/providerModelConfig.ts | 1 + .../hooks/__tests__/useSelectedModel.spec.ts | 1 + .../components/ui/hooks/useSelectedModel.ts | 10 +- .../src/utils/__tests__/validate.spec.ts | 1 + 17 files changed, 683 insertions(+), 16 deletions(-) create mode 100644 src/api/providers/fetchers/__tests__/friendli.spec.ts create mode 100644 src/api/providers/fetchers/friendli.ts 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 e17cd5ddbc..2721e0d35e 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -65,6 +65,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 b240e5ca34..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, @@ -64,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/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/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 6ef68864c1..b80408e438 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -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 }) @@ -268,6 +269,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { 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 a5507e355a..5967950c5a 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -1,7 +1,8 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { type FriendliModelId, friendliDefaultModelId, friendliModels } from "@roo-code/types" +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" @@ -11,6 +12,7 @@ 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" /** @@ -53,10 +55,24 @@ type FriendliChatCompletionNonStreamingParams = Omit< * 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. */ @@ -67,18 +83,54 @@ 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 id = - this.options.apiModelId && this.options.apiModelId in this.providerModels - ? (this.options.apiModelId as FriendliModelId) + 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 info = this.providerModels[id] const params = getModelParams({ format: "openai", modelId: id, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5a28ce12d0..29a648fad5 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1077,6 +1077,7 @@ export const webviewMessageHandler = async ( "opencode-go": {}, kenari: {}, "kimi-code": {}, + friendli: {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1111,6 +1112,7 @@ export const webviewMessageHandler = async ( }, }, { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, + { key: "friendli", options: { provider: "friendli" } }, { key: "zoo-gateway", options: { 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 5fca23ba8e..b333227c85 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1040,6 +1040,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 ec513ce885..d663e3592c 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -363,9 +363,13 @@ function getSelectedModel({ return { id, info } } case providerIdentifiers.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 providerIdentifiers.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 6ce9bf5245..cf2cd5c282 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: {}, } From 2c488d8cf85229a56ee3b3f9a74f0f1a65b9668c Mon Sep 17 00:00:00 2001 From: siyoon Date: Wed, 5 Aug 2026 15:19:34 +0900 Subject: [PATCH 2/6] fix(friendli): apply CodeRabbit review feedback - fetcher: return empty list when safeParse fails instead of consuming unvalidated response data - fetcher: add 10s timeout to /models axios request - fetcher: preserve API-provided reasoning effort values verbatim, dropping only "default" and unknown values like "ultracode" instead of merging hardcoded extra efforts - handler: track dynamicModelsLoaded and preserve a dynamic-only requestedId during the initial load window so the first request after construction doesn't silently fall back to the default model - shared/api.ts: use object type instead of eslint-disable suppression - Friendli.tsx: fall back to static friendliModels when routerModels is unavailable so the picker always has selectable models --- .../fetchers/__tests__/friendli.spec.ts | 11 +++-- src/api/providers/fetchers/friendli.ts | 48 ++++++++----------- src/api/providers/friendli.ts | 24 ++++++++-- src/shared/api.ts | 2 +- .../settings/providers/Friendli.tsx | 3 +- 5 files changed, 48 insertions(+), 40 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/friendli.spec.ts b/src/api/providers/fetchers/__tests__/friendli.spec.ts index 954d4c4f41..a07db5374e 100644 --- a/src/api/providers/fetchers/__tests__/friendli.spec.ts +++ b/src/api/providers/fetchers/__tests__/friendli.spec.ts @@ -84,7 +84,9 @@ describe("Friendli Fetchers", () => { const models = await getFriendliModels() - expect(mockedAxios.get).toHaveBeenCalledWith("https://api.friendli.ai/serverless/v1/models") + expect(mockedAxios.get).toHaveBeenCalledWith("https://api.friendli.ai/serverless/v1/models", { + timeout: 10_000, + }) // Two chat models, embedding model filtered out expect(Object.keys(models)).toHaveLength(2) expect(models["zai-org/GLM-5.2"]).toBeDefined() @@ -234,10 +236,9 @@ describe("Friendli Fetchers", () => { const result = parseFriendliModel({ id: "test/model", model }) - expect(result.supportsReasoningEffort).toEqual( - expect.arrayContaining(["low", "medium", "high", "minimal", "xhigh", "max"]), - ) - // "default" should be filtered out + // Only API-provided known values are preserved; "default" and unknown + // values (e.g. "ultracode") are dropped. + expect(result.supportsReasoningEffort).toEqual(["low", "medium", "high"]) expect(result.supportsReasoningEffort).not.toContain("default") expect(result.reasoningEffort).toBe("high") expect(result.supportsMaxTokens).toBe(true) diff --git a/src/api/providers/fetchers/friendli.ts b/src/api/providers/fetchers/friendli.ts index d5dc45c50d..5038f4af35 100644 --- a/src/api/providers/fetchers/friendli.ts +++ b/src/api/providers/fetchers/friendli.ts @@ -88,18 +88,11 @@ export const friendliModelsResponseSchema = z.object({ 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. + * Reasoning effort levels Zoo Code knows how to send. The Friendli API may + * return additional values (e.g. "default", "ultracode"); those are dropped. */ -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] +const KNOWN_REASONING_EFFORTS = ["disable", "none", "minimal", "low", "medium", "high", "xhigh", "max"] as const +type KnownReasoningEffort = (typeof KNOWN_REASONING_EFFORTS)[number] function buildSupportsReasoningEffort( reasoning: boolean | undefined, @@ -112,24 +105,21 @@ function buildSupportsReasoningEffort( 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[] = [] + // Controllable reasoning model with a discrete effort enum. Preserve + // the API-provided values that Zoo Code knows how to send, de-duplicated + // and in API order. Drop "default" (a placeholder meaning "use the model + // default" that the Friendli API rejects as a real effort value) and any + // values not in KNOWN_REASONING_EFFORTS (e.g. "ultracode"). + const seen = new Set() + const filtered: KnownReasoningEffort[] = [] 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) + if (v === "default" || seen.has(v)) continue + seen.add(v) + if ((KNOWN_REASONING_EFFORTS as readonly string[]).includes(v)) { + filtered.push(v as KnownReasoningEffort) + } } - // 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), - ) + return filtered } // Reasoning-capable model without a discrete effort enum — the handler can @@ -155,9 +145,9 @@ export async function getFriendliModels(_options?: ApiHandlerOptions): Promise(`${baseURL}/models`) + const response = await axios.get(`${baseURL}/models`, { timeout: 10_000 }) const result = friendliModelsResponseSchema.safeParse(response.data) - const data = result.success ? result.data.data : (response.data?.data ?? []) + const data = result.success ? result.data.data : [] if (!result.success) { console.error(`Friendli models response is invalid ${JSON.stringify(result.error.format())}`) diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index 5967950c5a..b854008a31 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -73,6 +73,16 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider { this.dynamicModels = models + this.dynamicModelsLoaded = true }) .catch((error) => { + this.dynamicModelsLoaded = true console.error("[FriendliHandler] Failed to load dynamic models:", error) }) } @@ -121,11 +133,15 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider // Build the dynamic options union from the map, intersected with CommonFetchParams diff --git a/webview-ui/src/components/settings/providers/Friendli.tsx b/webview-ui/src/components/settings/providers/Friendli.tsx index 398fccb46b..75df6749d7 100644 --- a/webview-ui/src/components/settings/providers/Friendli.tsx +++ b/webview-ui/src/components/settings/providers/Friendli.tsx @@ -6,6 +6,7 @@ import { type OrganizationAllowList, type RouterModels, friendliDefaultModelId, + friendliModels, } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -72,7 +73,7 @@ export const Friendli = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} defaultModelId={friendliDefaultModelId} - models={routerModels?.["friendli"] ?? {}} + models={routerModels?.["friendli"] ?? friendliModels} modelIdKey="apiModelId" serviceName="Friendli" serviceUrl="https://friendli.ai" From 846f62ebb487e43852a635ac006ac652aa12d9fe Mon Sep 17 00:00:00 2001 From: siyoon Date: Wed, 5 Aug 2026 15:33:56 +0900 Subject: [PATCH 3/6] test: fix webviewMessageHandler spec for friendli in routerModels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add friendli to routerModels expectations and mock sequences — it was added to the handler's provider list but the existing tests weren't updated, causing the mock call order to shift and expectations to miss. --- src/core/webview/__tests__/webviewMessageHandler.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index a3b76aa8b2..d981d41d3f 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -504,6 +504,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "opencode-go": mockModels, kenari: mockModels, "kimi-code": {}, + friendli: mockModels, }, values: undefined, }) @@ -692,6 +693,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "opencode-go": mockModels, kenari: mockModels, "kimi-code": {}, + friendli: mockModels, }, values: undefined, }) @@ -713,6 +715,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { .mockRejectedValueOnce(new Error("Requesty API error")) // requesty .mockResolvedValueOnce(mockModels) // unbound .mockResolvedValueOnce(mockModels) // vercel-ai-gateway + .mockResolvedValueOnce(mockModels) // friendli .mockResolvedValueOnce(mockModels) // zoo-gateway .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm .mockResolvedValueOnce(mockModels) // opencode-go @@ -754,6 +757,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "opencode-go": mockModels, kenari: mockModels, "kimi-code": {}, + friendli: mockModels, }, values: undefined, }) @@ -766,6 +770,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { .mockRejectedValueOnce(new Error("Requesty API error")) // requesty .mockRejectedValueOnce(new Error("Unbound error")) // unbound .mockRejectedValueOnce(new Error("Vercel AI Gateway error")) // vercel-ai-gateway + .mockRejectedValueOnce(new Error("Friendli error")) // friendli .mockRejectedValueOnce(new Error("Zoo Gateway error")) // zoo-gateway .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm From 8d745e0cbb70cfbe6235733b12f481f30fd435c2 Mon Sep 17 00:00:00 2001 From: siyoon Date: Wed, 5 Aug 2026 15:42:38 +0900 Subject: [PATCH 4/6] test: fix ClineProvider spec for friendli in routerModels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same issue as webviewMessageHandler spec — routerModels expectations and mock sequences needed friendli added to match the handler's provider list. --- src/core/webview/__tests__/ClineProvider.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 00f848bec4..90ba72af1b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3219,12 +3219,13 @@ describe("ClineProvider - Router Models", () => { "opencode-go": mockModels, kenari: mockModels, "kimi-code": {}, + friendli: mockModels, }, values: undefined, }) }) - test("handles requestRouterModels with individual provider failures", async () => { + it("handles requestRouterModels with individual provider failures", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] @@ -3248,6 +3249,7 @@ describe("ClineProvider - Router Models", () => { .mockRejectedValueOnce(new Error("Requesty API error")) // requesty fail .mockResolvedValueOnce(mockModels) // unbound success .mockResolvedValueOnce(mockModels) // vercel-ai-gateway success + .mockResolvedValueOnce(mockModels) // friendli success .mockResolvedValueOnce(mockModels) // zoo-gateway success .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail .mockResolvedValueOnce(mockModels) // opencode-go (public endpoint) @@ -3273,6 +3275,7 @@ describe("ClineProvider - Router Models", () => { "opencode-go": mockModels, kenari: mockModels, "kimi-code": {}, + friendli: mockModels, }, values: undefined, }) @@ -3373,6 +3376,7 @@ describe("ClineProvider - Router Models", () => { "opencode-go": mockModels, kenari: mockModels, "kimi-code": {}, + friendli: mockModels, }, values: undefined, }) From 5efa983947160047f0051abd6fb16f33f148d528 Mon Sep 17 00:00:00 2001 From: siyoon Date: Wed, 5 Aug 2026 16:12:38 +0900 Subject: [PATCH 5/6] test: add coverage for reasoning effort filtering and dynamic model loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetcher: test ultracode/unknown effort value dropping + de-duplication - handler: mock getModels, test dynamicModelsLoaded branches — pending load preserves dynamic-only id, completed load falls back to default, dynamic info used when available, rejection sets loaded flag --- src/api/providers/__tests__/friendli.spec.ts | 104 +++++++++++++++++- .../fetchers/__tests__/friendli.spec.ts | 28 +++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 7c31c754e7..436792758b 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -11,7 +11,10 @@ import { FriendliHandler } from "../friendli" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" // Create mock functions -const mockCreate = vi.fn() +const { mockCreate, mockGetModels } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockGetModels: vi.fn(), +})) // Mock OpenAI module vi.mock("openai", () => ({ @@ -26,11 +29,18 @@ vi.mock("openai", () => ({ }), })) +// Mock modelCache so we can control dynamic model loading +vi.mock("../fetchers/modelCache", () => ({ + getModels: mockGetModels, +})) + describe("FriendliHandler", () => { let handler: FriendliHandler beforeEach(() => { vi.clearAllMocks() + // By default, dynamic model fetch resolves to empty (static models win) + mockGetModels.mockResolvedValue({}) // Set up default mock implementation mockCreate.mockImplementation(async () => asyncStreamFrom([ @@ -540,3 +550,95 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { expect(callArgs.include_reasoning).toBe(true) }) }) + +describe("FriendliHandler — dynamic model loading", () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockImplementation(async () => asyncStreamFrom([])) + }) + + it("preserves a dynamic-only model id during the initial load window", () => { + // mockGetModels never resolves — simulates an in-flight fetch + mockGetModels.mockReturnValue(new Promise(() => {})) + + const handler = new FriendliHandler({ + apiModelId: "friendli-only/future-model", + friendliApiKey: "test-key", + }) + + // "friendli-only/future-model" is not in static friendliModels, but + // because dynamicModelsLoaded is still false the handler keeps the + // requested id and falls back to the default model's metadata. + const model = handler.getModel() + expect(model.id).toBe("friendli-only/future-model") + expect(model.info).toEqual(friendliModels[friendliDefaultModelId]) + }) + + it("falls back to default model after load completes and id is not in dynamic set", async () => { + // Dynamic fetch resolves to empty — no models + mockGetModels.mockResolvedValue({}) + + const handler = new FriendliHandler({ + apiModelId: "friendli-only/future-model", + friendliApiKey: "test-key", + }) + + // Wait for the dynamic fetch to settle + await vi.waitFor(() => { + expect((handler as unknown as Record)["dynamicModelsLoaded"]).toBe(true) + }) + + // After load, the dynamic-only id is not found — falls back to default + const model = handler.getModel() + expect(model.id).toBe(friendliDefaultModelId) + }) + + it("uses dynamic model info when available", async () => { + const dynamicModel = { + "friendli-only/future-model": { + maxTokens: 8192, + contextWindow: 100000, + supportsImages: false, + supportsPromptCache: false, + description: "A dynamic-only model", + }, + } + mockGetModels.mockResolvedValue(dynamicModel) + + const handler = new FriendliHandler({ + apiModelId: "friendli-only/future-model", + friendliApiKey: "test-key", + }) + + await vi.waitFor(() => { + expect((handler as unknown as Record)["dynamicModelsLoaded"]).toBe(true) + }) + + const model = handler.getModel() + expect(model.id).toBe("friendli-only/future-model") + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 8192, + contextWindow: 100000, + description: "A dynamic-only model", + }), + ) + }) + + it("sets dynamicModelsLoaded even when getModels rejects", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockGetModels.mockRejectedValue(new Error("Network error")) + + const handler = new FriendliHandler({ + friendliApiKey: "test-key", + }) + + await vi.waitFor(() => { + expect((handler as unknown as Record)["dynamicModelsLoaded"]).toBe(true) + }) + + // Falls back to default model + expect(handler.getModel().id).toBe(friendliDefaultModelId) + consoleErrorSpy.mockRestore() + }) +}) diff --git a/src/api/providers/fetchers/__tests__/friendli.spec.ts b/src/api/providers/fetchers/__tests__/friendli.spec.ts index a07db5374e..608f097682 100644 --- a/src/api/providers/fetchers/__tests__/friendli.spec.ts +++ b/src/api/providers/fetchers/__tests__/friendli.spec.ts @@ -257,6 +257,34 @@ describe("Friendli Fetchers", () => { expect(result.supportsMaxTokens).toBeUndefined() }) + it("drops unknown reasoning effort values like ultracode and de-duplicates", () => { + const model: FriendliModel = { + ...baseModel, + reasoning: true, + reasoning_options: [ + { type: "effort", values: ["low", "ultracode", "low", "high", "ultracode", "max", "default"] }, + ], + } + + const result = parseFriendliModel({ id: "test/model", model }) + + // "ultracode" is not a known effort — dropped; "default" dropped; + // duplicates removed; known values preserved in API order. + expect(result.supportsReasoningEffort).toEqual(["low", "high", "max"]) + }) + + it("returns empty array when effort values are all unknown or default", () => { + const model: FriendliModel = { + ...baseModel, + reasoning: true, + reasoning_options: [{ type: "effort", values: ["ultracode", "default"] }], + } + + const result = parseFriendliModel({ id: "test/model", model }) + + expect(result.supportsReasoningEffort).toEqual([]) + }) + it("omits supportsReasoningEffort for non-reasoning models", () => { const model: FriendliModel = { ...baseModel, From 067ea7dfef40b2f05d6fd1010d335dd8d7c3b06c Mon Sep 17 00:00:00 2001 From: siyoon Date: Wed, 5 Aug 2026 18:05:49 +0900 Subject: [PATCH 6/6] fix(friendli): use supportsReasoningBinary for models without effort enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Friendli API returns reasoning: true for all models, but only GLM-5.2 has a discrete effort enum (["high","max"]). Other models (DeepSeek-V3.2, MiniMax-M2.5, GLM-5.1, gemma, K-EXAONE) only support on/off thinking toggle via chat_template_kwargs.enable_thinking. Previously these models got supportsReasoningEffort: true (boolean), which made the UI show a full effort dropdown (low/medium/high/...) even though the API ignores reasoning_effort for them. Now they get supportsReasoningBinary: true, which shows a simple on/off checkbox. Also fixes max tokens: all Friendli reasoning models with max_completion_tokens now get supportsMaxTokens: true (the fetcher already did this, but the static fallback also needs it — it already has it, so dynamic + static are now consistent). Handler updated to send enable_thinking + parse_reasoning for binary reasoning models when reasoning is enabled, and nothing when disabled. --- packages/types/src/providers/friendli.ts | 8 ++++-- src/api/providers/__tests__/friendli.spec.ts | 28 ++++++------------- .../fetchers/__tests__/friendli.spec.ts | 8 ++++-- src/api/providers/fetchers/friendli.ts | 21 +++++++++----- src/api/providers/friendli.ts | 25 +++++++++++++---- 5 files changed, 54 insertions(+), 36 deletions(-) diff --git a/packages/types/src/providers/friendli.ts b/packages/types/src/providers/friendli.ts index 53e728caf0..761ce63dac 100644 --- a/packages/types/src/providers/friendli.ts +++ b/packages/types/src/providers/friendli.ts @@ -24,7 +24,7 @@ export const friendliModels: Record = { outputPrice: 4.4, cacheWritesPrice: 0, cacheReadsPrice: 0.26, - supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "max"], + supportsReasoningEffort: ["high", "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.", @@ -39,7 +39,7 @@ export const friendliModels: Record = { outputPrice: 4.4, cacheWritesPrice: 0, cacheReadsPrice: 0.26, - supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "max"], + supportsReasoningEffort: ["high", "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.", @@ -49,6 +49,8 @@ export const friendliModels: Record = { contextWindow: 163_840, supportsImages: false, supportsPromptCache: true, + supportsMaxTokens: true, + supportsReasoningBinary: true, inputPrice: 0.5, outputPrice: 1.5, cacheWritesPrice: 0, @@ -61,6 +63,8 @@ export const friendliModels: Record = { contextWindow: 204_800, supportsImages: false, supportsPromptCache: true, + supportsMaxTokens: true, + supportsReasoningBinary: true, inputPrice: 0.3, outputPrice: 1.2, cacheWritesPrice: 0, diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 436792758b..6a2cbaed3f 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -125,7 +125,6 @@ describe("FriendliHandler", () => { modelId: "zai-org/GLM-5.1" as const, contextWindow: 200_000, maxTokens: 131_072, - supportsMaxTokens: true, inputPrice: 1.4, outputPrice: 4.4, cacheWritesPrice: 0, @@ -135,7 +134,6 @@ describe("FriendliHandler", () => { modelId: "deepseek-ai/DeepSeek-V3.2" as const, contextWindow: 163_840, maxTokens: 16384, - supportsMaxTokens: undefined, inputPrice: 0.5, outputPrice: 1.5, cacheWritesPrice: 0, @@ -145,7 +143,6 @@ describe("FriendliHandler", () => { modelId: "MiniMaxAI/MiniMax-M2.5" as const, contextWindow: 204_800, maxTokens: 4096, - supportsMaxTokens: undefined, inputPrice: 0.3, outputPrice: 1.2, cacheWritesPrice: 0, @@ -153,21 +150,12 @@ describe("FriendliHandler", () => { }, ])( "should expose newly added model $modelId", - ({ - modelId, - contextWindow, - maxTokens, - supportsMaxTokens, - inputPrice, - outputPrice, - cacheWritesPrice, - cacheReadsPrice, - }) => { + ({ modelId, contextWindow, maxTokens, inputPrice, outputPrice, cacheWritesPrice, cacheReadsPrice }) => { expect(friendliModels[modelId]).toBeDefined() const info = friendliModels[modelId] as import("@roo-code/types").ModelInfo expect(info.maxTokens).toBe(maxTokens) expect(info.contextWindow).toBe(contextWindow) - expect(info.supportsMaxTokens).toBe(supportsMaxTokens) + expect(info.supportsMaxTokens).toBe(true) expect(info.inputPrice).toBe(inputPrice) expect(info.outputPrice).toBe(outputPrice) expect(info.cacheWritesPrice).toBe(cacheWritesPrice) @@ -479,7 +467,7 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { expect(callArgs.include_reasoning).toBe(true) }) - it("should not include any reasoning params for non-reasoning DeepSeek-V3.2", async () => { + it("should send enable_thinking + parse_reasoning (no reasoning_effort) for binary reasoning DeepSeek-V3.2", async () => { const handler = new FriendliHandler({ apiModelId: "deepseek-ai/DeepSeek-V3.2", friendliApiKey: "test-key", @@ -492,9 +480,11 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { await handler.createMessage("system", []).next() const callArgs = mockCreate.mock.calls[0][0] as Record + // Binary reasoning model: no reasoning_effort, but enable_thinking + parse_reasoning expect(callArgs.reasoning_effort).toBeUndefined() - expect(callArgs.chat_template_kwargs).toBeUndefined() - expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: true }) + expect(callArgs.parse_reasoning).toBe(true) + expect(callArgs.include_reasoning).toBe(true) }) it("should handle delta.reasoning_content from parse_reasoning=true stream", async () => { @@ -534,7 +524,7 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { apiModelId: "zai-org/GLM-5.2", friendliApiKey: "test-key", enableReasoningEffort: true, - reasoningEffort: "medium", + reasoningEffort: "high", }) mockCreate.mockResolvedValueOnce({ @@ -544,7 +534,7 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { await handler.completePrompt("test") const callArgs = mockCreate.mock.calls[0][0] as Record - expect(callArgs.reasoning_effort).toBe("medium") + 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) diff --git a/src/api/providers/fetchers/__tests__/friendli.spec.ts b/src/api/providers/fetchers/__tests__/friendli.spec.ts index 608f097682..1ef35fceb2 100644 --- a/src/api/providers/fetchers/__tests__/friendli.spec.ts +++ b/src/api/providers/fetchers/__tests__/friendli.spec.ts @@ -244,7 +244,7 @@ describe("Friendli Fetchers", () => { expect(result.supportsMaxTokens).toBe(true) }) - it("sets supportsReasoningEffort to true for reasoning models without effort options", () => { + it("sets supportsReasoningBinary for reasoning models without effort options", () => { const model: FriendliModel = { ...baseModel, reasoning: true, @@ -252,9 +252,11 @@ describe("Friendli Fetchers", () => { const result = parseFriendliModel({ id: "test/model", model }) - expect(result.supportsReasoningEffort).toBe(true) + expect(result.supportsReasoningBinary).toBe(true) + expect(result.supportsReasoningEffort).toBeUndefined() expect(result.reasoningEffort).toBeUndefined() - expect(result.supportsMaxTokens).toBeUndefined() + // supportsMaxTokens is set for all reasoning models with max_completion_tokens + expect(result.supportsMaxTokens).toBe(true) }) it("drops unknown reasoning effort values like ultracode and de-duplicates", () => { diff --git a/src/api/providers/fetchers/friendli.ts b/src/api/providers/fetchers/friendli.ts index 5038f4af35..b5afbb7a6e 100644 --- a/src/api/providers/fetchers/friendli.ts +++ b/src/api/providers/fetchers/friendli.ts @@ -214,18 +214,25 @@ export const parseFriendliModel = ({ id, model }: { id: string; model: FriendliM 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. + // Controllable reasoning model with discrete effort enum — expose + // the effort dropdown and default to "high". + modelInfo.supportsReasoningEffort = reasoningEffort modelInfo.reasoningEffort = "high" + } else { + // Reasoning-capable model without a discrete effort enum. Friendli + // only supports toggling thinking on/off via chat_template_kwargs for + // these models, so expose a binary toggle instead of an effort + // dropdown that would let the user pick values the API ignores. + modelInfo.supportsReasoningBinary = true } } - // 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)) { + // Friendli's chat models honour a configurable max-output slider + // (supportsMaxTokens). All Friendli models accept the max_tokens param, + // so surface the slider for every reasoning-capable model, not just + // controllable-reasoning ones with discrete effort enums. + if (model.reasoning && model.max_completion_tokens) { modelInfo.supportsMaxTokens = true } diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index b854008a31..e96712bf10 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -162,23 +162,38 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider { const { info: modelInfo, reasoningEffort } = this.getModel() const extra: Partial = {} const isControllableReasoning = Array.isArray(modelInfo.supportsReasoningEffort) + const isBinaryReasoning = !!modelInfo.supportsReasoningBinary const useReasoningEffort = modelInfo.supportsReasoningEffort ? shouldUseReasoningEffort({ model: modelInfo, settings: this.options }) : false + // Binary reasoning toggle (no effort enum). These models accept + // enable_thinking + parse_reasoning but not reasoning_effort. + if (isBinaryReasoning && !isControllableReasoning) { + if (this.options.enableReasoningEffort === false) { + return extra // reasoning disabled — send nothing + } + extra.parse_reasoning = true + extra.include_reasoning = true + extra.chat_template_kwargs = { enable_thinking: true } + return extra + } + // 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). @@ -187,12 +202,12 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider