diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 3898c65bcc..a0625e429c 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -56,6 +56,7 @@ export const dynamicProviders = [ providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.kimiCode, + providerIdentifiers.friendli, ] as const export type DynamicProvider = (typeof dynamicProviders)[number] diff --git a/packages/types/src/providers/friendli.ts b/packages/types/src/providers/friendli.ts index 71591d8f40..53e728caf0 100644 --- a/packages/types/src/providers/friendli.ts +++ b/packages/types/src/providers/friendli.ts @@ -8,8 +8,12 @@ export type FriendliModelId = export const friendliDefaultModelId: FriendliModelId = "zai-org/GLM-5.2" +// Static fallback for the Friendli provider. Used as a fallback when dynamic +// models cannot be fetched (cold start, network errors, API lag), in tests, +// and in the webview's MODELS_BY_PROVIDER fallback. The provider itself fetches +// the live list from https://api.friendli.ai/serverless/v1/models at runtime. // Pricing sourced from https://friendli.ai/api/public/model-apis (per 1M tokens). -export const friendliModels = { +export const friendliModels: Record = { "zai-org/GLM-5.2": { maxTokens: 131_072, contextWindow: 1_000_000, @@ -20,6 +24,8 @@ export const friendliModels = { outputPrice: 4.4, cacheWritesPrice: 0, cacheReadsPrice: 0.26, + supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffort: "high", description: "GLM-5.2 is Zhipu's flagship model with a 1M context window and 128k max output, served via Friendli Model APIs. It delivers top-tier long-context reasoning, coding, and agentic performance for extended engineering sessions.", }, @@ -33,6 +39,8 @@ export const friendliModels = { outputPrice: 4.4, cacheWritesPrice: 0, cacheReadsPrice: 0.26, + supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffort: "high", description: "GLM-5.1 is Zhipu's most capable model with a 200k context window and 128k max output, served via Friendli Model APIs. It delivers top-tier reasoning, coding, and agentic performance.", }, @@ -60,4 +68,4 @@ export const friendliModels = { description: "MiniMax M2.5 is a high-performance language model with a 204.8K context window, optimized for long-context understanding and generation tasks, served via Friendli Model APIs.", }, -} as const satisfies Record +} diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 41892d3bc6..c8fd81ad19 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -142,7 +142,16 @@ describe("FriendliHandler", () => { }, ])( "should expose newly added model $modelId", - ({ modelId, contextWindow, maxTokens, supportsMaxTokens, inputPrice, outputPrice, cacheWritesPrice, cacheReadsPrice }) => { + ({ + modelId, + contextWindow, + maxTokens, + supportsMaxTokens, + inputPrice, + outputPrice, + cacheWritesPrice, + cacheReadsPrice, + }) => { expect(friendliModels[modelId]).toBeDefined() const info = friendliModels[modelId] as import("@roo-code/types").ModelInfo expect(info.maxTokens).toBe(maxTokens) @@ -394,3 +403,217 @@ describe("Friendli model max output tokens (clamping behavior)", () => { expect(result).toBe(80_000) }) }) + +describe("FriendliHandler — Friendli-specific reasoning params", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should include reasoning_effort, chat_template_kwargs, parse_reasoning for GLM-5.2 with reasoning enabled", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "zai-org/GLM-5.2", + reasoning_effort: "high", + chat_template_kwargs: { enable_thinking: true }, + parse_reasoning: true, + include_reasoning: true, + }), + undefined, + ) + }) + + it("should send enable_thinking: false when enableReasoningEffort is false on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: false, + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.include_reasoning).toBeUndefined() + }) + + it("should send enable_thinking: false when reasoningEffort is none on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: true, + reasoningEffort: "none", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.include_reasoning).toBeUndefined() + }) + + it("should send enable_thinking: false when reasoningEffort is disable on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: true, + reasoningEffort: "disable", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.include_reasoning).toBeUndefined() + }) + + it("should use model default reasoningEffort when no explicit settings are provided", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + // No enableReasoningEffort or reasoningEffort — model default "high" kicks in + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBe("high") + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: true }) + expect(callArgs.parse_reasoning).toBe(true) + expect(callArgs.include_reasoning).toBe(true) + }) + + it("should not include any reasoning params for non-reasoning DeepSeek-V3.2", async () => { + const handler = new FriendliHandler({ + apiModelId: "deepseek-ai/DeepSeek-V3.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toBeUndefined() + expect(callArgs.parse_reasoning).toBeUndefined() + }) + + it("should handle delta.reasoning_content from parse_reasoning=true stream", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { reasoning_content: "Let me think..." } }], + usage: null, + } + yield { + choices: [{ delta: { content: "The answer is 42" } }], + usage: null, + } + yield { + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const stream = handler.createMessage("system", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual({ type: "reasoning", text: "Let me think..." }) + expect(chunks).toContainEqual({ type: "text", text: "The answer is 42" }) + }) + + it("completePrompt should include reasoning params when enabled", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "medium", + }) + + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "test result" } }], + }) + + await handler.completePrompt("test") + + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning_effort).toBe("medium") + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: true }) + expect(callArgs.parse_reasoning).toBe(true) + expect(callArgs.include_reasoning).toBe(true) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/friendli.spec.ts b/src/api/providers/fetchers/__tests__/friendli.spec.ts new file mode 100644 index 0000000000..1abd3cc4aa --- /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" + +vitest.mock("axios") +const mockedAxios = axios as jest.Mocked + +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", + } as unknown as FriendliModel, + } + + 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..ed43a0cb1c --- /dev/null +++ b/src/api/providers/fetchers/friendli.ts @@ -0,0 +1,242 @@ +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 + +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 + } + + // 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, + contextWindow: model.context_length, + supportsImages, + supportsPromptCache, + inputPrice: parseApiPrice(inputPriceStr), + outputPrice: parseApiPrice(outputPriceStr), + cacheWritesPrice, + cacheReadsPrice, + description: model.description && model.description.trim() !== "" ? model.description : undefined, + } + + if (model.deprecation_date) { + modelInfo.deprecated = true + } + + const reasoningEffort = buildSupportsReasoningEffort(model.reasoning, model.reasoning_options) + if (reasoningEffort !== undefined) { + modelInfo.supportsReasoningEffort = reasoningEffort + if (Array.isArray(reasoningEffort)) { + // Default the selected effort to "high" for controllable reasoning + // models, matching the static `friendliModels` entries for GLM-5.x. + modelInfo.reasoningEffort = "high" + } + } + + // Friendli's reasoning models honour a configurable max-output slider + // (supportsMaxTokens). The static fallback marks GLM-5.x with this; mirror + // it for dynamic controllable-reasoning models so the UI shows the slider. + if (Array.isArray(reasoningEffort)) { + modelInfo.supportsMaxTokens = true + } + + // We intentionally do not map tool_call / structured_output capability flags + // into ModelInfo — the OpenAI-compatible base class already sends tools for + // all models and the Friendli backend ignores the fields it doesn't support. + + return modelInfo +} diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index f56ddba34f..2f7c4d916d 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 }) @@ -234,6 +235,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { const publicProviders: Array<{ provider: RouterName; options: GetModelsOptions }> = [ { provider: "openrouter", options: { provider: "openrouter" } }, { provider: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, + { provider: "friendli", options: { provider: "friendli" } }, ] // Refresh each provider in background (fire and forget) diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index f9aa4fa20c..0854e85def 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -1,14 +1,78 @@ -import { type FriendliModelId, friendliDefaultModelId, friendliModels } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { type FriendliModelId, friendliDefaultModelId, friendliModels, type ModelInfo } from "@roo-code/types" +import type { ModelRecord } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { shouldUseReasoningEffort, getModelMaxOutputTokens } from "../../shared/api" + +import { convertToOpenAiMessages } from "../transform/openai-format" +import { getModelParams } from "../transform/model-params" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { handleOpenAIError } from "./utils/error-handler" +import { getModels } from "./fetchers/modelCache" +import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" + +/** + * Friendli extends the OpenAI Chat Completions API with these non-standard fields: + * - reasoning_effort: enum (minimal, low, medium, high, xhigh, max) — reasoning depth + * - chat_template_kwargs: { enable_thinking: boolean } — toggles thinking for controllable models + * - parse_reasoning / include_reasoning: when true, Friendli streams reasoning via + * delta.reasoning_content (which extractReasoningFromDelta already handles) + * - reasoning_budget: integer token budget (not currently surfaced in settings UI) + * + * The reasoning fields are shared across streaming and non-streaming requests; the base + * `ChatCompletionCreateParams` (non-streaming) variant is used for `completePrompt` while the + * `ChatCompletionCreateParamsStreaming` variant is used for `createStream`. + */ +type FriendliReasoningParams = { + chat_template_kwargs?: { enable_thinking: boolean } + parse_reasoning?: boolean + include_reasoning?: boolean + // Friendli's reasoning_effort supports a broader enum than OpenAI's type allows + reasoning_effort?: + | OpenAI.Chat.Completions.ChatCompletionCreateParams["reasoning_effort"] + | "minimal" + | "xhigh" + | "max" +} + +type FriendliChatCompletionParams = Omit< + OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + "reasoning_effort" +> & + FriendliReasoningParams + +type FriendliChatCompletionNonStreamingParams = Omit< + OpenAI.Chat.Completions.ChatCompletionCreateParams, + "reasoning_effort" +> & + FriendliReasoningParams /** * Handler for the Friendli Model APIs (OpenAI-compatible). * Routes chat completions to `https://api.friendli.ai/serverless/v1`. + * + * Model list is dynamic: on construction the handler kicks off a fire-and-forget + * fetch of the live model list from `https://api.friendli.ai/serverless/v1/models` + * (public, no auth) via the shared `getModels` cache. `getModel()` falls back to + * the static `friendliModels` map when dynamic models haven't loaded yet or when + * the requested model id isn't present in the dynamic set (e.g. the API lags + * behind a newly released model). This mirrors the OpenRouterHandler pattern. + * + * Overrides `createStream` and `completePrompt` to inject Friendli-specific + * reasoning parameters that the base class doesn't know about. */ export class FriendliHandler extends BaseOpenAiCompatibleProvider { + /** + * Dynamically fetched model list (populated asynchronously after construction). + * Empty until the background load completes; `getModel()` falls back to the + * static `providerModels` (`friendliModels`) in that window. + */ + private dynamicModels: ModelRecord = {} + /** * @param options Provider settings; `friendliApiKey` is required. */ @@ -19,8 +83,182 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider, defaultTemperature: 0.6, }) + + // Load dynamic models asynchronously to populate the cache before + // getModel() is called. Fire-and-forget; errors are logged by the + // cache layer and we gracefully fall back to static models. + getModels({ provider: "friendli" }) + .then((models) => { + this.dynamicModels = models + }) + .catch((error) => { + console.error("[FriendliHandler] Failed to load dynamic models:", error) + }) + } + + override getModel() { + const requestedId = this.options.apiModelId + + // Prefer dynamic info when available; fall back to static `providerModels` + // (the hardcoded `friendliModels` passed to super) for cold-start, network + // failure, or models not yet in the dynamic list. + const dynamicInfo = requestedId ? this.dynamicModels[requestedId] : undefined + const staticId = + requestedId && requestedId in this.providerModels + ? (requestedId as FriendliModelId) + : this.defaultProviderModelId + const staticInfo = this.providerModels[staticId] + + // Determine which id/info pair to use. + let id: string + let info: ModelInfo + if (dynamicInfo) { + id = requestedId! + info = dynamicInfo + } else if (requestedId && requestedId in this.providerModels) { + id = requestedId + 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 + info = this.dynamicModels[requestedId] + } else { + id = staticId + info = staticInfo + } + + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0.6, + }) + + return { id, info, ...params } + } + + /** + * Build Friendli-specific reasoning params to merge into the OpenAI request. + * + * Rules: + * - Controllable reasoning models (GLM-5.x): always send `chat_template_kwargs`. + * User enabled → { enable_thinking: true } + reasoning_effort + parse_reasoning. + * User disabled (none/disable) → { enable_thinking: false } to prevent the + * model's Jinja template from defaulting thinking ON. + * - Non-reasoning models (DeepSeek-V3.2, MiniMax-M2.5): no extra params + * (reasoning_effort silently ignored). + */ + private buildFriendliReasoningParams(): Partial { + const { info: modelInfo, reasoningEffort } = this.getModel() + const extra: Partial = {} + + const isControllableReasoning = Array.isArray(modelInfo.supportsReasoningEffort) + + const useReasoningEffort = modelInfo.supportsReasoningEffort + ? shouldUseReasoningEffort({ model: modelInfo, settings: this.options }) + : false + + // User disabled reasoning on a controllable model — explicitly turn thinking off. + // The model's Jinja chat template defaults enable_thinking to true, so omitting + // the param would leave reasoning active (burning tokens against user intent). + if (isControllableReasoning && !useReasoningEffort) { + extra.chat_template_kwargs = { enable_thinking: false } + return extra + } + + // Non-reasoning model — nothing to send + if (!useReasoningEffort) { + return extra + } + + // Reasoning is enabled + extra.parse_reasoning = true + extra.include_reasoning = true + extra.chat_template_kwargs = { enable_thinking: true } + + if (reasoningEffort) { + extra.reasoning_effort = reasoningEffort as FriendliReasoningParams["reasoning_effort"] + } + + return extra + } + + /** + * Override createStream to inject Friendli-specific reasoning params. + * The base class createMessage() calls createStream and handles all stream + * processing (TagMatcher, extractReasoningFromDelta, tool calls, usage). + */ + protected override createStream( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + requestOptions?: OpenAI.RequestOptions, + ) { + const friendliExtra = this.buildFriendliReasoningParams() + + const { id: model, info } = this.getModel() + + // Centralized cap: clamp to 20% of the context window + const max_tokens = + getModelMaxOutputTokens({ + modelId: model, + model: info, + settings: this.options, + format: "openai", + }) ?? undefined + + const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature + + const params: FriendliChatCompletionParams = { + model, + max_tokens, + temperature, + messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + stream: true, + stream_options: { include_usage: true }, + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + parallel_tool_calls: metadata?.parallelToolCalls ?? true, + ...friendliExtra, + } + + try { + return this.client.chat.completions.create( + params as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + requestOptions, + ) + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } + } + + override async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + const { id: modelId } = this.getModel() + const friendliExtra = this.buildFriendliReasoningParams() + + const params: FriendliChatCompletionNonStreamingParams = { + model: modelId, + messages: [{ role: "user", content: prompt }], + ...friendliExtra, + } + + try { + const requestOptions: OpenAI.RequestOptions | undefined = + options && (options.abortSignal !== undefined || options.timeoutMs !== undefined) + ? { signal: options.abortSignal, timeout: options.timeoutMs } + : undefined + const response = (await this.client.chat.completions.create( + params as OpenAI.Chat.Completions.ChatCompletionCreateParams, + requestOptions, + )) as OpenAI.Chat.Completions.ChatCompletion + return response.choices?.[0]?.message.content || "" + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a72c4955a3..e610387349 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1054,6 +1054,7 @@ export const webviewMessageHandler = async ( "opencode-go": {}, kenari: {}, "kimi-code": {}, + friendli: {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1088,6 +1089,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", ] /**