From fe9230e8a6fdd57a96319f671b2131c3c171807c Mon Sep 17 00:00:00 2001 From: siyoon Date: Sun, 26 Jul 2026 21:12:52 +0900 Subject: [PATCH 1/8] 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 + .../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 + 16 files changed, 683 insertions(+), 14 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 870ce77d78..9e572a4c95 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -107,6 +107,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 99b75de2e4..0f092eb598 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 0cc61052db..08159c1727 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -699,6 +699,10 @@ const ApiOptions = ({ )} diff --git a/webview-ui/src/components/settings/providers/Friendli.tsx b/webview-ui/src/components/settings/providers/Friendli.tsx index 66f4c554ef..398fccb46b 100644 --- a/webview-ui/src/components/settings/providers/Friendli.tsx +++ b/webview-ui/src/components/settings/providers/Friendli.tsx @@ -1,23 +1,42 @@ import { useCallback } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import type { ProviderSettings } from "@roo-code/types" +import { + type ProviderSettings, + type OrganizationAllowList, + type RouterModels, + friendliDefaultModelId, +} from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" import { inputEventTransform } from "../transforms" +import { ModelPicker } from "../ModelPicker" type FriendliProps = { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => 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 eccbf7ba1d..1a21f397ad 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -219,6 +219,7 @@ export const PROVIDERS_WITH_CUSTOM_MODEL_UI: ProviderName[] = [ providerIdentifiers.lmstudio, providerIdentifiers.vscodeLm, providerIdentifiers.moonshot, // Moonshot has custom ModelPicker inside Moonshot.tsx + providerIdentifiers.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 2571614085..c4200b19a6 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1241,6 +1241,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 6ed20ef47d..12cac94e04 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -383,9 +383,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 68597c2a5f9d4512cf8161f30bb3609c28c4bafa Mon Sep 17 00:00:00 2001 From: siyoon Date: Wed, 5 Aug 2026 15:19:34 +0900 Subject: [PATCH 2/8] 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 9ba6aa92cc680a0de910a57c4a073aa17b59435f Mon Sep 17 00:00:00 2001 From: siyoon Date: Wed, 5 Aug 2026 15:33:56 +0900 Subject: [PATCH 3/8] 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 b0fb94ef3c23f16d7d8e11b78ca5b56b6ffb0aa0 Mon Sep 17 00:00:00 2001 From: siyoon Date: Wed, 5 Aug 2026 15:42:38 +0900 Subject: [PATCH 4/8] 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 bf920bd460e11f04a812cfd599a0a3eb385165ad Mon Sep 17 00:00:00 2001 From: siyoon Date: Wed, 5 Aug 2026 16:12:38 +0900 Subject: [PATCH 5/8] 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 | 109 +++++++++++++++++- .../fetchers/__tests__/friendli.spec.ts | 28 +++++ 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 0e6b21c5e5..436792758b 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -9,10 +9,12 @@ import { buildApiHandler } from "../../index" import { getModelMaxOutputTokens } from "../../../shared/api" import { FriendliHandler } from "../friendli" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" -import { clearAllMocks } from "../../../test-utils/reset" // Create mock functions -const mockCreate = vi.fn() +const { mockCreate, mockGetModels } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockGetModels: vi.fn(), +})) // Mock OpenAI module vi.mock("openai", () => ({ @@ -27,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(() => { - clearAllMocks() + vi.clearAllMocks() + // By default, dynamic model fetch resolves to empty (static models win) + mockGetModels.mockResolvedValue({}) // Set up default mock implementation mockCreate.mockImplementation(async () => asyncStreamFrom([ @@ -369,7 +378,7 @@ describe("Friendli model max output tokens (clamping behavior)", () => { describe("FriendliHandler — Friendli-specific reasoning params", () => { beforeEach(() => { - clearAllMocks() + vi.clearAllMocks() }) it("should include reasoning_effort, chat_template_kwargs, parse_reasoning for GLM-5.2 with reasoning enabled", async () => { @@ -541,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 b54c4a73489f4839818597e75b915cded33c7ba5 Mon Sep 17 00:00:00 2001 From: siyoon Date: Wed, 5 Aug 2026 18:05:49 +0900 Subject: [PATCH 6/8] 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 Date: Tue, 11 Aug 2026 12:14:45 +0900 Subject: [PATCH 7/8] feat(friendli): reduce static seed to GLM-5.2, follow live /v1/models The /v1/models endpoint has been updated. Static seed now only contains GLM-5.2 (default model) with corrected maxTokens (1_048_576) and context window (1_048_576) matching the live API. All other models (GLM-5.1, DeepSeek-V3.2, MiniMax-M2.5) are removed from the static seed and are served exclusively from the dynamic /v1/models fetcher at runtime. - packages/types: FriendliModelId narrowed to "zai-org/GLM-5.2"; friendliModels reduced to single entry with live API values - src/api/providers/friendli.ts: updated comment about binary reasoning models to reference the live /v1/models list - Tests updated: removed static model assertions for GLM-5.1/DeepSeek-V3.2/ MiniMax-M2.5; DeepSeek-V3.2 binary reasoning test now provides a dynamic model mock; useSelectedModel test uses routerModels for dynamic models; clamp tests updated for new GLM-5.2 maxTokens --- packages/types/src/providers/friendli.ts | 59 +--------- src/api/providers/__tests__/friendli.spec.ts | 111 +++++------------- src/api/providers/friendli.ts | 9 +- .../hooks/__tests__/useSelectedModel.spec.ts | 27 ++++- 4 files changed, 65 insertions(+), 141 deletions(-) diff --git a/packages/types/src/providers/friendli.ts b/packages/types/src/providers/friendli.ts index 761ce63dac..f4246ae34d 100644 --- a/packages/types/src/providers/friendli.ts +++ b/packages/types/src/providers/friendli.ts @@ -1,10 +1,6 @@ import type { ModelInfo } from "../model.js" -export type FriendliModelId = - | "zai-org/GLM-5.2" - | "zai-org/GLM-5.1" - | "deepseek-ai/DeepSeek-V3.2" - | "MiniMaxAI/MiniMax-M2.5" +export type FriendliModelId = "zai-org/GLM-5.2" export const friendliDefaultModelId: FriendliModelId = "zai-org/GLM-5.2" @@ -12,11 +8,12 @@ export const friendliDefaultModelId: FriendliModelId = "zai-org/GLM-5.2" // 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). +// Only the default model is seeded statically — all other models come from the +// live /v1/models response. Pricing sourced from the live API response. export const friendliModels: Record = { "zai-org/GLM-5.2": { - maxTokens: 131_072, - contextWindow: 1_000_000, + maxTokens: 1_048_576, + contextWindow: 1_048_576, supportsImages: false, supportsPromptCache: true, supportsMaxTokens: true, @@ -26,50 +23,6 @@ export const friendliModels: Record = { cacheReadsPrice: 0.26, 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.", - }, - "zai-org/GLM-5.1": { - maxTokens: 131_072, - contextWindow: 200_000, - supportsImages: false, - supportsPromptCache: true, - supportsMaxTokens: true, - inputPrice: 1.4, - outputPrice: 4.4, - cacheWritesPrice: 0, - cacheReadsPrice: 0.26, - 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.", - }, - "deepseek-ai/DeepSeek-V3.2": { - maxTokens: 16384, - contextWindow: 163_840, - supportsImages: false, - supportsPromptCache: true, - supportsMaxTokens: true, - supportsReasoningBinary: true, - inputPrice: 0.5, - outputPrice: 1.5, - cacheWritesPrice: 0, - cacheReadsPrice: 0.25, - description: - "DeepSeek V3.2 is the latest iteration of the V3 model family with enhanced reasoning capabilities, improved code generation, and better instruction following, served via Friendli Model APIs.", - }, - "MiniMaxAI/MiniMax-M2.5": { - maxTokens: 4096, - contextWindow: 204_800, - supportsImages: false, - supportsPromptCache: true, - supportsMaxTokens: true, - supportsReasoningBinary: true, - inputPrice: 0.3, - outputPrice: 1.2, - cacheWritesPrice: 0, - cacheReadsPrice: 0.06, - 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.", + description: "Open flagship GLM for long-horizon coding agents and million-token context work", }, } diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 6a2cbaed3f..25895b0039 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -101,14 +101,14 @@ describe("FriendliHandler", () => { it("should return GLM-5.2 model with correct configuration", () => { const handlerWithModel = new FriendliHandler({ apiModelId: "zai-org/GLM-5.2", - friendliApiKey: "test-...ey", + friendliApiKey: "test-friendli-api-key", }) const model = handlerWithModel.getModel() expect(model.id).toBe("zai-org/GLM-5.2") expect(model.info).toEqual( expect.objectContaining({ - maxTokens: 131_072, - contextWindow: 1_000_000, + maxTokens: 1_048_576, + contextWindow: 1_048_576, supportsImages: false, supportsPromptCache: true, supportsMaxTokens: true, @@ -120,56 +120,6 @@ describe("FriendliHandler", () => { ) }) - it.each([ - { - modelId: "zai-org/GLM-5.1" as const, - contextWindow: 200_000, - maxTokens: 131_072, - inputPrice: 1.4, - outputPrice: 4.4, - cacheWritesPrice: 0, - cacheReadsPrice: 0.26, - }, - { - modelId: "deepseek-ai/DeepSeek-V3.2" as const, - contextWindow: 163_840, - maxTokens: 16384, - inputPrice: 0.5, - outputPrice: 1.5, - cacheWritesPrice: 0, - cacheReadsPrice: 0.25, - }, - { - modelId: "MiniMaxAI/MiniMax-M2.5" as const, - contextWindow: 204_800, - maxTokens: 4096, - inputPrice: 0.3, - outputPrice: 1.2, - cacheWritesPrice: 0, - cacheReadsPrice: 0.06, - }, - ])( - "should expose newly added model $modelId", - ({ 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(true) - expect(info.inputPrice).toBe(inputPrice) - expect(info.outputPrice).toBe(outputPrice) - expect(info.cacheWritesPrice).toBe(cacheWritesPrice) - expect(info.cacheReadsPrice).toBe(cacheReadsPrice) - expect(info.description).toBeTruthy() - - const handlerWithModel = new FriendliHandler({ - apiModelId: modelId, - friendliApiKey: "test-friendli-api-key", - }) - expect(handlerWithModel.getModel().id).toBe(modelId) - }, - ) - it("completePrompt method should return text from Friendli API", async () => { const expectedResponse = "This is a test response from Friendli" mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) @@ -225,10 +175,11 @@ describe("FriendliHandler", () => { const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) await messageGenerator.next() + // GLM-5.2 maxTokens (1_048_576) is clamped to 20% of context window (209_716) expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ model: modelId, - max_tokens: modelInfo.maxTokens, + max_tokens: 209_716, temperature: 0.6, messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), stream: true, @@ -327,7 +278,7 @@ describe("buildApiHandler friendli wiring", () => { }) describe("Friendli model max output tokens (clamping behavior)", () => { - it("GLM-5.2: maxTokens (131072) is under 20% of 1M context window — clamp is no-op", () => { + it("GLM-5.2: maxTokens (1048576) exceeds 20% of 1M context window — clamp binds to 209716", () => { const model = friendliModels["zai-org/GLM-5.2"] const result = getModelMaxOutputTokens({ modelId: "zai-org/GLM-5.2", @@ -335,32 +286,8 @@ describe("Friendli model max output tokens (clamping behavior)", () => { settings: { apiProvider: "friendli" }, format: "openai", }) - // 1_000_000 * 0.2 = 200_000 > 131_072 → no clamping - expect(result).toBe(131_072) - }) - - it("GLM-5.1: maxTokens (131072) exceeds 20% of 200k context window — clamp binds to 40000", () => { - const model = friendliModels["zai-org/GLM-5.1"] - const result = getModelMaxOutputTokens({ - modelId: "zai-org/GLM-5.1", - model, - settings: { apiProvider: "friendli" }, - format: "openai", - }) - // 200_000 * 0.2 = 40_000 < 131_072 → clamped to 40_000 - expect(result).toBe(40_000) - }) - - it("GLM-5.1 with user modelMaxTokens override: honors override capped at model maxTokens", () => { - const model = friendliModels["zai-org/GLM-5.1"] - const result = getModelMaxOutputTokens({ - modelId: "zai-org/GLM-5.1", - model, - settings: { apiProvider: "friendli", modelMaxTokens: 80_000 }, - format: "openai", - }) - // supportsMaxTokens=true, user set 80k, model ceiling 131072 → min(80000, 131072) = 80000 - expect(result).toBe(80_000) + // 1_048_576 * 0.2 = 209_715.2 → ceil = 209_716 < 1_048_576 → clamped + expect(result).toBe(209_716) }) }) @@ -468,13 +395,33 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { }) it("should send enable_thinking + parse_reasoning (no reasoning_effort) for binary reasoning DeepSeek-V3.2", async () => { + mockGetModels.mockResolvedValue({ + "deepseek-ai/DeepSeek-V3.2": { + maxTokens: 163840, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + supportsReasoningBinary: true, + inputPrice: 0.5, + outputPrice: 1.5, + cacheReadsPrice: 0.25, + description: "DeepSeek V3.2", + }, + }) const handler = new FriendliHandler({ apiModelId: "deepseek-ai/DeepSeek-V3.2", - friendliApiKey: "test-key", + friendliApiKey: "test-friendli-api-key", enableReasoningEffort: true, reasoningEffort: "high", }) + // Wait for dynamic model to load so getModel() uses the DeepSeek binary + // reasoning model instead of falling back to the GLM-5.2 static default + await vi.waitFor(() => { + expect((handler as unknown as Record)["dynamicModelsLoaded"]).toBe(true) + }) + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) await handler.createMessage("system", []).next() diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index e96712bf10..7e1c24d780 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -166,10 +166,11 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider { const { info: modelInfo, reasoningEffort } = this.getModel() 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 c4200b19a6..7befcf1bc2 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1267,7 +1267,30 @@ describe("useSelectedModel", () => { expect(result.current.info).toEqual(friendliModels[friendliDefaultModelId]) }) - it("should use custom model ID and info when model exists in friendliModels", () => { + it("should use custom model ID and info when model exists in routerModels", () => { + const dynamicModelInfo: ModelInfo = { + maxTokens: 202752, + contextWindow: 202752, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + inputPrice: 1.4, + outputPrice: 4.4, + cacheWritesPrice: 0, + cacheReadsPrice: 0.26, + description: "Dynamic GLM-5.1 model from /v1/models", + } + mockUseRouterModels.mockReturnValue({ + data: { + openrouter: {}, + requesty: {}, + litellm: {}, + friendli: { "zai-org/GLM-5.1": dynamicModelInfo }, + }, + isLoading: false, + isError: false, + } as any) + const apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.friendli, apiModelId: "zai-org/GLM-5.1", @@ -1278,7 +1301,7 @@ describe("useSelectedModel", () => { expect(result.current.provider).toBe(providerIdentifiers.friendli) expect(result.current.id).toBe("zai-org/GLM-5.1") - expect(result.current.info).toEqual(friendliModels["zai-org/GLM-5.1"]) + expect(result.current.info).toEqual(dynamicModelInfo) }) }) From 16a42706a743d5737a64d2aab0009bc12996016a Mon Sep 17 00:00:00 2001 From: siyoon Date: Tue, 11 Aug 2026 12:50:17 +0900 Subject: [PATCH 8/8] fix(friendli): address CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 actionable comments + 1 nitpick resolved: 1. (Major) fetchers/friendli.ts: empty effort array now falls back to binary reasoning instead of returning [] that defaults to unsupported reasoning_effort="high" 2. (Minor) fetchers/friendli.ts: supportsMaxTokens no longer requires model.reasoning — all chat models with max_completion_tokens get the max-token slider 3. (Major) friendli.ts: dynamic-loading fallback now uses openAiModelInfoSaneDefaults instead of GLM-5.2-specific metadata, preventing wrong reasoning params and max_tokens during cold start; createStream and completePrompt call getModel() once and pass the result to buildFriendliReasoningParams 4. (Major) Friendli.tsx: empty {} routerModels.friendli now falls back to static friendliModels (checks Object.keys length, not truthiness) 5. (Minor) Friendli.spec.tsx: ModelPicker mock now captures props and asserts bindings (defaultModelId, models, errorMessage) 6. (Minor) useSelectedModel.spec.ts: replaced `as any` with typed `as unknown as ReturnType` cast 7. (Major) useSelectedModel.ts: Friendli branch preserves saved model selection during dynamic catalog loading instead of substituting the default ID 8. (Nitpick) friendli.spec.ts: removed all dynamicModelsLoaded double assertions — tests now wait for observable getModel() outcomes --- src/api/providers/__tests__/friendli.spec.ts | 47 ++++++------ .../fetchers/__tests__/friendli.spec.ts | 34 ++++++++- src/api/providers/fetchers/friendli.ts | 19 +++-- src/api/providers/friendli.ts | 36 ++++++--- .../settings/providers/Friendli.tsx | 6 +- .../providers/__tests__/Friendli.spec.tsx | 42 ++++++++++- .../hooks/__tests__/useSelectedModel.spec.ts | 74 ++++++++++++++++++- .../components/ui/hooks/useSelectedModel.ts | 19 ++++- 8 files changed, 225 insertions(+), 52 deletions(-) diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 25895b0039..0f7b693304 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -416,10 +416,10 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { reasoningEffort: "high", }) - // Wait for dynamic model to load so getModel() uses the DeepSeek binary - // reasoning model instead of falling back to the GLM-5.2 static default + // Wait for the dynamic model to load so getModel() returns the DeepSeek + // binary reasoning model instead of falling back to the static default. await vi.waitFor(() => { - expect((handler as unknown as Record)["dynamicModelsLoaded"]).toBe(true) + expect(handler.getModel().id).toBe("deepseek-ai/DeepSeek-V3.2") }) mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) @@ -505,10 +505,11 @@ describe("FriendliHandler — dynamic model loading", () => { // "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. + // requested id and uses sane defaults (no model-specific metadata) + // until the dynamic list arrives. const model = handler.getModel() expect(model.id).toBe("friendli-only/future-model") - expect(model.info).toEqual(friendliModels[friendliDefaultModelId]) + expect(model.info).toEqual(expect.objectContaining({ supportsImages: true, supportsPromptCache: false })) }) it("falls back to default model after load completes and id is not in dynamic set", async () => { @@ -520,14 +521,11 @@ describe("FriendliHandler — dynamic model loading", () => { friendliApiKey: "test-key", }) - // Wait for the dynamic fetch to settle + // After load, the dynamic-only id is not found -- falls back to default. + // Wait for the observable getModel() result to reflect the fallback. await vi.waitFor(() => { - expect((handler as unknown as Record)["dynamicModelsLoaded"]).toBe(true) + expect(handler.getModel().id).toBe(friendliDefaultModelId) }) - - // 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 () => { @@ -547,19 +545,18 @@ describe("FriendliHandler — dynamic model loading", () => { friendliApiKey: "test-key", }) + // Wait for the dynamic model to appear in getModel() results. 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", + }), + ) }) - - 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 () => { @@ -570,12 +567,10 @@ describe("FriendliHandler — dynamic model loading", () => { friendliApiKey: "test-key", }) + // After rejection, getModel() falls back to the default model. await vi.waitFor(() => { - expect((handler as unknown as Record)["dynamicModelsLoaded"]).toBe(true) + expect(handler.getModel().id).toBe(friendliDefaultModelId) }) - - // 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 1ef35fceb2..594d02d88f 100644 --- a/src/api/providers/fetchers/__tests__/friendli.spec.ts +++ b/src/api/providers/fetchers/__tests__/friendli.spec.ts @@ -275,7 +275,7 @@ describe("Friendli Fetchers", () => { expect(result.supportsReasoningEffort).toEqual(["low", "high", "max"]) }) - it("returns empty array when effort values are all unknown or default", () => { + it("returns binary reasoning when all effort values are unknown or default", () => { const model: FriendliModel = { ...baseModel, reasoning: true, @@ -284,7 +284,37 @@ describe("Friendli Fetchers", () => { const result = parseFriendliModel({ id: "test/model", model }) - expect(result.supportsReasoningEffort).toEqual([]) + // All effort values filtered out -- instead of an empty array that would + // default to "high" (unsupported by the API), fall back to binary reasoning. + expect(result.supportsReasoningBinary).toBe(true) + expect(result.supportsReasoningEffort).toBeUndefined() + expect(result.reasoningEffort).toBeUndefined() + }) + + it("returns undefined for non-reasoning model with all effort values filtered", () => { + const model: FriendliModel = { + ...baseModel, + reasoning: false, + reasoning_options: [{ type: "effort", values: ["ultracode", "default"] }], + } + + const result = parseFriendliModel({ id: "test/model", model }) + + expect(result.supportsReasoningBinary).toBeUndefined() + expect(result.supportsReasoningEffort).toBeUndefined() + expect(result.reasoningEffort).toBeUndefined() + }) + + it("sets supportsMaxTokens for non-reasoning models with max_completion_tokens", () => { + const model: FriendliModel = { + ...baseModel, + reasoning: false, + max_completion_tokens: 8192, + } + + const result = parseFriendliModel({ id: "test/model", model }) + + expect(result.supportsMaxTokens).toBe(true) }) it("omits supportsReasoningEffort for non-reasoning models", () => { diff --git a/src/api/providers/fetchers/friendli.ts b/src/api/providers/fetchers/friendli.ts index b5afbb7a6e..9072bf793d 100644 --- a/src/api/providers/fetchers/friendli.ts +++ b/src/api/providers/fetchers/friendli.ts @@ -119,7 +119,16 @@ function buildSupportsReasoningEffort( filtered.push(v as KnownReasoningEffort) } } - return filtered + + // If the API provided effort values but none survived filtering (all were + // "default" or unknown), fall back to binary reasoning instead of an + // empty array — an empty effort list would later default to "high", + // which the API does not accept for this model. + if (filtered.length > 0) { + return filtered + } + // All values filtered out — treat as binary reasoning. + return reasoning ? true : undefined } // Reasoning-capable model without a discrete effort enum — the handler can @@ -229,10 +238,10 @@ export const parseFriendliModel = ({ id, model }: { id: string; model: FriendliM } // 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) { + // (supportsMaxTokens). All Friendli chat models accept the max_tokens param, + // so surface the slider for any model that has a max_completion_tokens value, + // not just reasoning-capable ones. + if (model.max_completion_tokens) { modelInfo.supportsMaxTokens = true } diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index 7e1c24d780..0f5b275201 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -1,7 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { type FriendliModelId, friendliDefaultModelId, friendliModels, type ModelInfo } from "@roo-code/types" +import { + type FriendliModelId, + friendliDefaultModelId, + friendliModels, + type ModelInfo, + openAiModelInfoSaneDefaults, +} from "@roo-code/types" import type { ModelRecord } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" @@ -137,11 +143,12 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider { - const { info: modelInfo, reasoningEffort } = this.getModel() + private buildFriendliReasoningParams(model: { + info: ModelInfo + reasoningEffort?: string + }): Partial { + const { info: modelInfo, reasoningEffort } = model const extra: Partial = {} const isControllableReasoning = Array.isArray(modelInfo.supportsReasoningEffort) @@ -231,9 +241,12 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider { - const { id: modelId } = this.getModel() - const friendliExtra = this.buildFriendliReasoningParams() + const model = this.getModel() + const { id: modelId } = model + const friendliExtra = this.buildFriendliReasoningParams(model) const params: FriendliChatCompletionNonStreamingParams = { model: modelId, diff --git a/webview-ui/src/components/settings/providers/Friendli.tsx b/webview-ui/src/components/settings/providers/Friendli.tsx index 75df6749d7..93d4bf8690 100644 --- a/webview-ui/src/components/settings/providers/Friendli.tsx +++ b/webview-ui/src/components/settings/providers/Friendli.tsx @@ -73,7 +73,11 @@ export const Friendli = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} defaultModelId={friendliDefaultModelId} - models={routerModels?.["friendli"] ?? friendliModels} + models={ + routerModels?.["friendli"] && Object.keys(routerModels["friendli"]).length > 0 + ? routerModels["friendli"] + : friendliModels + } modelIdKey="apiModelId" serviceName="Friendli" serviceUrl="https://friendli.ai" 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 90f3349f51..995fd6b25d 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Friendli.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Friendli.spec.tsx @@ -1,6 +1,6 @@ import React from "react" import { render, screen, fireEvent } from "@/utils/test-utils" -import type { ProviderSettings } from "@roo-code/types" +import { type ProviderSettings, friendliModels } from "@roo-code/types" import { Friendli } from "../Friendli" @@ -27,8 +27,24 @@ vi.mock("@src/components/common/VSCodeButtonLink", () => ({ ), })) +type ModelPickerMockProps = { + defaultModelId: string + models: Record + modelIdKey: string + serviceName: string + serviceUrl: string + errorMessage?: string + apiConfiguration: unknown + setApiConfigurationField: (field: string, value: unknown) => void +} + +let mockModelPickerProps: ModelPickerMockProps = {} as ModelPickerMockProps + vi.mock("../../ModelPicker", () => ({ - ModelPicker: () =>
, + ModelPicker: (props: ModelPickerMockProps) => { + mockModelPickerProps = props + return
+ }, })) describe("Friendli provider settings", () => { @@ -64,4 +80,26 @@ describe("Friendli provider settings", () => { fireEvent.input(screen.getByTestId("friendli-api-key-input"), { target: { value: "new-key" } }) expect(mockSetApiConfigurationField).toHaveBeenCalledWith("friendliApiKey", "new-key") }) + + it("passes correct props to ModelPicker (default model, static fallback, error message)", () => { + const mockSetApiConfigurationField = vi.fn() + const apiConfig = { apiProvider: "friendli" } as ProviderSettings + render( + , + ) + expect(screen.getByTestId("friendli-model-picker-mock")).toBeInTheDocument() + expect(mockModelPickerProps).toMatchObject({ + defaultModelId: "zai-org/GLM-5.2", + modelIdKey: "apiModelId", + serviceName: "Friendli", + serviceUrl: "https://friendli.ai", + errorMessage: "test error", + }) + // When routerModels is not provided, ModelPicker should receive the static fallback + expect(mockModelPickerProps.models).toEqual(friendliModels) + }) }) 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 7befcf1bc2..2542d80dcc 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1245,7 +1245,7 @@ describe("useSelectedModel", () => { }, isLoading: false, isError: false, - } as any) + } as unknown as ReturnType) mockUseOpenRouterModelProviders.mockReturnValue({ data: {}, @@ -1289,7 +1289,7 @@ describe("useSelectedModel", () => { }, isLoading: false, isError: false, - } as any) + } as unknown as ReturnType) const apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.friendli, @@ -1303,6 +1303,76 @@ describe("useSelectedModel", () => { expect(result.current.id).toBe("zai-org/GLM-5.1") expect(result.current.info).toEqual(dynamicModelInfo) }) + it("preserves saved static model when router data is empty", () => { + mockUseRouterModels.mockReturnValue({ + data: { + openrouter: {}, + requesty: {}, + litellm: {}, + friendli: {}, + }, + isLoading: false, + isError: false, + } as unknown as ReturnType) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.friendli, + apiModelId: "zai-org/GLM-5.2", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe("zai-org/GLM-5.2") + expect(result.current.info).toEqual(friendliModels["zai-org/GLM-5.2"]) + }) + + it("falls back to default model when router is empty and no saved model", () => { + mockUseRouterModels.mockReturnValue({ + data: { + openrouter: {}, + requesty: {}, + litellm: {}, + friendli: {}, + }, + isLoading: false, + isError: false, + } as unknown as ReturnType) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.friendli, + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe(friendliDefaultModelId) + expect(result.current.info).toEqual(friendliModels[friendliDefaultModelId]) + }) + + it("falls back to default when saved model is not in static seed", () => { + mockUseRouterModels.mockReturnValue({ + data: { + openrouter: {}, + requesty: {}, + litellm: {}, + friendli: {}, + }, + isLoading: false, + isError: false, + } as unknown as ReturnType) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.friendli, + apiModelId: "nonexistent/model", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe(friendliDefaultModelId) + expect(result.current.info).toEqual(friendliModels[friendliDefaultModelId]) + }) }) describe("Kimi Code provider", () => { diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 12cac94e04..e9e760037f 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -383,9 +383,22 @@ function getSelectedModel({ return { id, info } } case providerIdentifiers.friendli: { - const availableModels = routerModels.friendli - ? { ...friendliModels, ...routerModels.friendli } - : friendliModels + // When the Friendli router data is absent or empty (loading or fetch + // failed), fall back to the static seed. Preserve the saved model + // selection if it exists in the static seed rather than substituting + // the default, so the user's selection survives the loading window. + const hasFriendliRouter = routerModels.friendli && Object.keys(routerModels.friendli).length > 0 + + if (!hasFriendliRouter) { + const savedId = apiConfiguration.apiModelId + const staticInfo = savedId ? friendliModels[savedId as keyof typeof friendliModels] : undefined + if (savedId && staticInfo) { + return { id: savedId, info: staticInfo } + } + return { id: defaultModelId, info: friendliModels[defaultModelId as keyof typeof friendliModels] } + } + + const availableModels = { ...friendliModels, ...routerModels.friendli! } const id = getValidatedModelId(apiConfiguration.apiModelId, availableModels, defaultModelId) const routerInfo = routerModels.friendli?.[id] const staticInfo = friendliModels[id as keyof typeof friendliModels]