diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index b3640a8f5d..bd39e44dcb 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -106,6 +106,7 @@ describe("provider identifiers", () => { providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.kimiCode, + providerIdentifiers.friendli, ]) expect(localProviders).toEqual([providerIdentifiers.ollama, providerIdentifiers.lmstudio]) expect(internalProviders).toEqual([providerIdentifiers.vscodeLm]) diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 3898c65bcc..a0625e429c 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -56,6 +56,7 @@ export const dynamicProviders = [ providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.kimiCode, + providerIdentifiers.friendli, ] as const export type DynamicProvider = (typeof dynamicProviders)[number] diff --git a/packages/types/src/providers/friendli.ts b/packages/types/src/providers/friendli.ts index 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 af956548ef..9bd4a94d6a 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 }) @@ -237,6 +238,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 3126d4ebf5..d87a1a1597 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1055,6 +1055,7 @@ export const webviewMessageHandler = async ( "opencode-go": {}, kenari: {}, "kimi-code": {}, + friendli: {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1089,6 +1090,7 @@ export const webviewMessageHandler = async ( }, }, { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, + { key: "friendli", options: { provider: "friendli" } }, { key: "zoo-gateway", options: { diff --git a/src/shared/api.ts b/src/shared/api.ts index 056612f9f9..6911100b34 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -190,6 +190,7 @@ const dynamicProviderExtras = { "opencode-go": {} as { apiKey?: string }, kenari: {} as { apiKey?: string }, "kimi-code": {} as { apiKey?: string }, + friendli: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type } as const satisfies Record // Build the dynamic options union from the map, intersected with CommonFetchParams diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index c5e69978ff..30ac11dbd2 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -698,6 +698,10 @@ const ApiOptions = ({ )} diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 15061e333d..904c86e1d8 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -15,7 +15,6 @@ import { sambaNovaModels, internationalZAiModels, fireworksModels, - friendliModels, minimaxModels, basetenModels, mimoModels, @@ -36,7 +35,6 @@ export const MODELS_BY_PROVIDER: Partial void + routerModels?: RouterModels + organizationAllowList?: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean } /** * Settings form for the Friendli provider. - * Renders an API-key input and a "Get Friendli API Key" link when the key is empty. + * Renders an API-key input, a "Get Friendli API Key" link when the key is + * empty, and a model picker driven by the dynamic `routerModels.friendli` list + * (falling back to an empty object until the live list has been fetched). */ -export const Friendli = ({ apiConfiguration, setApiConfigurationField }: FriendliProps) => { +export const Friendli = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, + simplifySettings, +}: FriendliProps) => { const { t } = useAppTranslation() const handleInputChange = useCallback( @@ -49,6 +68,18 @@ export const Friendli = ({ apiConfiguration, setApiConfigurationField }: Friendl {t("settings:providers.getFriendliApiKey")} )} + ) } diff --git a/webview-ui/src/components/settings/providers/__tests__/Friendli.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Friendli.spec.tsx index 20ad73075b..90f3349f51 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Friendli.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Friendli.spec.tsx @@ -27,6 +27,10 @@ vi.mock("@src/components/common/VSCodeButtonLink", () => ({ ), })) +vi.mock("../../ModelPicker", () => ({ + ModelPicker: () =>
, +})) + describe("Friendli provider settings", () => { it("renders the 'Get Friendli API Key' link when no key is set", () => { render( diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index da660976f4..78f5b04258 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -212,6 +212,7 @@ export const PROVIDERS_WITH_CUSTOM_MODEL_UI: ProviderName[] = [ "lmstudio", "vscode-lm", "moonshot", // Moonshot has custom ModelPicker inside Moonshot.tsx + "friendli", ] /** diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 9433bb8148..72d269bdcf 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1004,6 +1004,7 @@ describe("useSelectedModel", () => { openrouter: {}, requesty: {}, litellm: {}, + friendli: {}, }, isLoading: false, isError: false, diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 50886d4538..a65c2761bd 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -362,9 +362,13 @@ function getSelectedModel({ return { id, info } } case "friendli": { - const id = apiConfiguration.apiModelId ?? defaultModelId - const info = friendliModels[id as keyof typeof friendliModels] - return { id, info } + const availableModels = routerModels.friendli + ? { ...friendliModels, ...routerModels.friendli } + : friendliModels + const id = getValidatedModelId(apiConfiguration.apiModelId, availableModels, defaultModelId) + const routerInfo = routerModels.friendli?.[id] + const staticInfo = friendliModels[id as keyof typeof friendliModels] + return { id, info: routerInfo ?? staticInfo } } case "poe": { const id = apiConfiguration.apiModelId ?? defaultModelId diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 8aebe3f8e5..96deda0707 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -55,6 +55,7 @@ describe("Model Validation Functions", () => { kenari: {}, "zoo-gateway": {}, "kimi-code": {}, + friendli: {}, moonshot: {}, }