From bcaac8378a6fcfcc95f5b2e032c952e2ec851386 Mon Sep 17 00:00:00 2001 From: siyoon Date: Fri, 26 Jun 2026 01:12:23 +0900 Subject: [PATCH 1/3] feat(friendli): add provider + 4 models Friendli OpenAI-compatible provider. Models: GLM-5.2, GLM-5.1, DeepSeek-V3.2, MiniMax-M2.5. Pricing from friendli.ai pricing API. Mirror fireworks provider pattern (commit 4a9222b): - types: model info, schema, secret key, provider name - api: BaseOpenAiCompatibleProvider handler at api.friendli.ai/serverless/v1 - webview: provider UI, model picker, validation, i18n (18 locales) - tests: handler spec, ProfileValidator entry Reasoning streams back via base handler's reasoning_content extraction. Friendli defaults parse_reasoning=true for GLM reasoning models. --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + packages/types/src/global-settings.ts | 1 + packages/types/src/provider-settings.ts | 14 + packages/types/src/providers/friendli.ts | 63 ++++ packages/types/src/providers/index.ts | 4 + src/api/index.ts | 3 + src/api/providers/__tests__/friendli.spec.ts | 339 ++++++++++++++++++ src/api/providers/friendli.ts | 19 + src/api/providers/index.ts | 1 + src/shared/ProfileValidator.ts | 1 + src/shared/__tests__/ProfileValidator.spec.ts | 1 + .../src/components/settings/ApiOptions.tsx | 8 + .../src/components/settings/constants.ts | 3 + .../settings/providers/Friendli.tsx | 50 +++ .../components/settings/providers/index.ts | 1 + .../settings/utils/providerModelConfig.ts | 4 + .../components/ui/hooks/useSelectedModel.ts | 6 + webview-ui/src/i18n/locales/ca/settings.json | 2 + webview-ui/src/i18n/locales/de/settings.json | 2 + webview-ui/src/i18n/locales/en/settings.json | 2 + webview-ui/src/i18n/locales/es/settings.json | 2 + webview-ui/src/i18n/locales/fr/settings.json | 2 + webview-ui/src/i18n/locales/hi/settings.json | 2 + webview-ui/src/i18n/locales/id/settings.json | 2 + webview-ui/src/i18n/locales/it/settings.json | 2 + webview-ui/src/i18n/locales/ja/settings.json | 2 + webview-ui/src/i18n/locales/ko/settings.json | 2 + webview-ui/src/i18n/locales/nl/settings.json | 2 + webview-ui/src/i18n/locales/pl/settings.json | 2 + .../src/i18n/locales/pt-BR/settings.json | 2 + webview-ui/src/i18n/locales/ru/settings.json | 2 + webview-ui/src/i18n/locales/tr/settings.json | 2 + webview-ui/src/i18n/locales/vi/settings.json | 2 + .../src/i18n/locales/zh-CN/settings.json | 2 + .../src/i18n/locales/zh-TW/settings.json | 2 + webview-ui/src/utils/validate.ts | 5 + 36 files changed, 560 insertions(+) create mode 100644 packages/types/src/providers/friendli.ts create mode 100644 src/api/providers/__tests__/friendli.spec.ts create mode 100644 src/api/providers/friendli.ts create mode 100644 webview-ui/src/components/settings/providers/Friendli.tsx diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 87594fb85..63ac8a97d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -81,6 +81,7 @@ body: - DeepSeek - Featherless AI - Fireworks AI + - Friendli - Google Gemini - Google Vertex AI - Groq diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 1f43d3093..e71e988ec 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -302,6 +302,7 @@ export const SECRET_STATE_KEYS = [ "sambaNovaApiKey", "zaiApiKey", "fireworksApiKey", + "friendliApiKey", "vercelAiGatewayApiKey", "opencodeGoApiKey", "basetenApiKey", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 26c4dee7e..d91d042a8 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -8,6 +8,7 @@ import { bedrockModels, deepSeekModels, fireworksModels, + friendliModels, geminiModels, mistralModels, moonshotModels, @@ -119,6 +120,7 @@ export const providerNames = [ "baseten", "deepseek", "fireworks", + "friendli", "gemini", "gemini-cli", "mistral", @@ -393,6 +395,10 @@ const fireworksSchema = apiModelIdProviderModelSchema.extend({ fireworksApiKey: z.string().optional(), }) +const friendliSchema = apiModelIdProviderModelSchema.extend({ + friendliApiKey: z.string().optional(), +}) + const qwenCodeSchema = apiModelIdProviderModelSchema.extend({ qwenCodeOauthPath: z.string().optional(), }) @@ -449,6 +455,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv sambaNovaSchema.merge(z.object({ apiProvider: z.literal("sambanova") })), zaiSchema.merge(z.object({ apiProvider: z.literal("zai") })), fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })), + friendliSchema.merge(z.object({ apiProvider: z.literal("friendli") })), qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })), vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })), opencodeGoSchema.merge(z.object({ apiProvider: z.literal("opencode-go") })), @@ -485,6 +492,7 @@ export const providerSettingsSchema = z.object({ ...sambaNovaSchema.shape, ...zaiSchema.shape, ...fireworksSchema.shape, + ...friendliSchema.shape, ...qwenCodeSchema.shape, ...vercelAiGatewaySchema.shape, ...opencodeGoSchema.shape, @@ -565,6 +573,7 @@ export const modelIdKeysByProvider: Record = { sambanova: "apiModelId", zai: "apiModelId", fireworks: "apiModelId", + friendli: "apiModelId", "vercel-ai-gateway": "vercelAiGatewayModelId", "opencode-go": "opencodeGoModelId", "zoo-gateway": "zooGatewayModelId", @@ -638,6 +647,11 @@ export const MODELS_BY_PROVIDER: Record< label: "Fireworks", models: Object.keys(fireworksModels), }, + friendli: { + id: "friendli", + label: "Friendli", + models: Object.keys(friendliModels), + }, gemini: { id: "gemini", label: "Google Gemini", diff --git a/packages/types/src/providers/friendli.ts b/packages/types/src/providers/friendli.ts new file mode 100644 index 000000000..71591d8f4 --- /dev/null +++ b/packages/types/src/providers/friendli.ts @@ -0,0 +1,63 @@ +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 const friendliDefaultModelId: FriendliModelId = "zai-org/GLM-5.2" + +// Pricing sourced from https://friendli.ai/api/public/model-apis (per 1M tokens). +export const friendliModels = { + "zai-org/GLM-5.2": { + maxTokens: 131_072, + contextWindow: 1_000_000, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + inputPrice: 1.4, + outputPrice: 4.4, + cacheWritesPrice: 0, + cacheReadsPrice: 0.26, + 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, + 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, + 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, + 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.", + }, +} as const satisfies Record diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index f283cb474..bcb4a76d5 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -3,6 +3,7 @@ export * from "./baseten.js" export * from "./bedrock.js" export * from "./deepseek.js" export * from "./fireworks.js" +export * from "./friendli.js" export * from "./gemini.js" export * from "./lite-llm.js" export * from "./lm-studio.js" @@ -33,6 +34,7 @@ import { basetenDefaultModelId } from "./baseten.js" import { bedrockDefaultModelId } from "./bedrock.js" import { deepSeekDefaultModelId } from "./deepseek.js" import { fireworksDefaultModelId } from "./fireworks.js" +import { friendliDefaultModelId } from "./friendli.js" import { geminiDefaultModelId } from "./gemini.js" import { litellmDefaultModelId } from "./lite-llm.js" import { mistralDefaultModelId } from "./mistral.js" @@ -111,6 +113,8 @@ export function getProviderDefaultModelId( return sambaNovaDefaultModelId case "fireworks": return fireworksDefaultModelId + case "friendli": + return friendliDefaultModelId case "qwen-code": return qwenCodeDefaultModelId case "poe": diff --git a/src/api/index.ts b/src/api/index.ts index 0c901f8e2..a1f7180ab 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -31,6 +31,7 @@ import { SambaNovaHandler, ZAiHandler, FireworksHandler, + FriendliHandler, VercelAiGatewayHandler, OpencodeGoHandler, ZooGatewayHandler, @@ -182,6 +183,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new ZAiHandler(options) case "fireworks": return new FireworksHandler(options) + case "friendli": + return new FriendliHandler(options) case "vercel-ai-gateway": return new VercelAiGatewayHandler(options) case "opencode-go": diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts new file mode 100644 index 000000000..59670f81a --- /dev/null +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -0,0 +1,339 @@ +// npx vitest run api/providers/__tests__/friendli.spec.ts + +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { friendliDefaultModelId, friendliModels } from "@roo-code/types" + +import { FriendliHandler } from "../friendli" + +// Create mock functions +const mockCreate = vi.fn() + +// Mock OpenAI module +vi.mock("openai", () => ({ + default: vi.fn(function () { + return { + chat: { + completions: { + create: mockCreate, + }, + }, + } + }), +})) + +describe("FriendliHandler", () => { + let handler: FriendliHandler + + beforeEach(() => { + vi.clearAllMocks() + // Set up default mock implementation + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { content: "Test response" }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: {}, + index: 0, + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }, + } + }, + })) + handler = new FriendliHandler({ friendliApiKey: "test-key" }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should use the correct Friendli base URL", () => { + new FriendliHandler({ friendliApiKey: "test-friendli-api-key" }) + expect(OpenAI).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: "https://api.friendli.ai/serverless/v1" }), + ) + }) + + it("should use the provided API key", () => { + const friendliApiKey = "test-friendli-api-key" + new FriendliHandler({ friendliApiKey }) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: friendliApiKey })) + }) + + it("should throw error when API key is not provided", () => { + expect(() => new FriendliHandler({})).toThrow("API key is required") + }) + + it("should return default model when no model is specified", () => { + const model = handler.getModel() + expect(model.id).toBe(friendliDefaultModelId) + expect(model.info).toEqual(expect.objectContaining(friendliModels[friendliDefaultModelId])) + }) + + it("should return GLM-5.2 model with correct configuration", () => { + const handlerWithModel = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + 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, + supportsImages: false, + supportsPromptCache: true, + inputPrice: 1.4, + outputPrice: 4.4, + cacheReadsPrice: 0.26, + }), + ) + }) + + it.each([ + { + modelId: "zai-org/GLM-5.1" as const, + contextWindow: 200_000, + maxTokens: 131_072, + inputPrice: 1.4, + outputPrice: 4.4, + cacheReadsPrice: 0.26, + }, + { + modelId: "deepseek-ai/DeepSeek-V3.2" as const, + contextWindow: 163_840, + maxTokens: 16384, + inputPrice: 0.5, + outputPrice: 1.5, + cacheReadsPrice: 0.25, + }, + { + modelId: "MiniMaxAI/MiniMax-M2.5" as const, + contextWindow: 204_800, + maxTokens: 4096, + inputPrice: 0.3, + outputPrice: 1.2, + cacheReadsPrice: 0.06, + }, + ])( + "should expose newly added model $modelId", + ({ modelId, contextWindow, maxTokens, inputPrice, outputPrice, cacheReadsPrice }) => { + expect(friendliModels[modelId]).toBeDefined() + const info = friendliModels[modelId] + expect(info.maxTokens).toBe(maxTokens) + expect(info.contextWindow).toBe(contextWindow) + expect(info.inputPrice).toBe(inputPrice) + expect(info.outputPrice).toBe(outputPrice) + 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 } }] }) + const result = await handler.completePrompt("test prompt") + expect(result).toBe(expectedResponse) + }) + + it("should handle errors in completePrompt", async () => { + const errorMessage = "Friendli API error" + mockCreate.mockRejectedValueOnce(new Error(errorMessage)) + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + `Friendli completion error: ${errorMessage}`, + ) + }) + + it("createMessage should yield text content from stream", async () => { + const testContent = "This is test content from Friendli stream" + + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { choices: [{ delta: { content: testContent } }] }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const firstChunk = await stream.next() + + expect(firstChunk.done).toBe(false) + expect(firstChunk.value).toEqual({ type: "text", text: testContent }) + }) + + it("createMessage should yield usage data from stream", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const firstChunk = await stream.next() + + expect(firstChunk.done).toBe(false) + expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) + }) + + it("createMessage should pass correct parameters to Friendli client", async () => { + const modelId = "zai-org/GLM-5.2" + const modelInfo = friendliModels[modelId] + const handlerWithModel = new FriendliHandler({ + apiModelId: modelId, + friendliApiKey: "test-friendli-api-key", + }) + + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + } + }) + + const systemPrompt = "Test system prompt for Friendli" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Friendli" }] + + const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) + await messageGenerator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: modelId, + max_tokens: modelInfo.maxTokens, + temperature: 0.6, + messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), + stream: true, + stream_options: { include_usage: true }, + }), + undefined, + ) + }) + + it("should use user-specified temperature over provider default", async () => { + const handlerWithModel = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-friendli-api-key", + modelTemperature: 0.3, + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + const messageGenerator = handlerWithModel.createMessage("system", []) + await messageGenerator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.3, + }), + undefined, + ) + }) + + it("should handle empty response in completePrompt", async () => { + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: null } }] }) + const result = await handler.completePrompt("test prompt") + expect(result).toBe("") + }) + + it("should handle missing choices in completePrompt", async () => { + mockCreate.mockResolvedValueOnce({ choices: [] }) + const result = await handler.completePrompt("test prompt") + expect(result).toBe("") + }) + + it("createMessage should handle stream with multiple chunks", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { content: "Hello" }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { content: " world" }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: {}, + index: 0, + }, + ], + usage: { + prompt_tokens: 5, + completion_tokens: 10, + total_tokens: 15, + }, + } + }, + })) + + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + const stream = handler.createMessage(systemPrompt, messages) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks[0]).toEqual({ type: "text", text: "Hello" }) + expect(chunks[1]).toEqual({ type: "text", text: " world" }) + expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 }) + }) +}) diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts new file mode 100644 index 000000000..5ad7f5997 --- /dev/null +++ b/src/api/providers/friendli.ts @@ -0,0 +1,19 @@ +import { type FriendliModelId, friendliDefaultModelId, friendliModels } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" + +import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" + +export class FriendliHandler extends BaseOpenAiCompatibleProvider { + constructor(options: ApiHandlerOptions) { + super({ + ...options, + providerName: "Friendli", + baseURL: "https://api.friendli.ai/serverless/v1", + apiKey: options.friendliApiKey, + defaultProviderModelId: friendliDefaultModelId, + providerModels: friendliModels, + defaultTemperature: 0.6, + }) + } +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 3c0d1e03e..6b7a74f54 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -24,6 +24,7 @@ export { VsCodeLmHandler } from "./vscode-lm" export { XAIHandler } from "./xai" export { ZAiHandler } from "./zai" export { FireworksHandler } from "./fireworks" +export { FriendliHandler } from "./friendli" export { VercelAiGatewayHandler } from "./vercel-ai-gateway" export { OpencodeGoHandler } from "./opencode-go" export { ZooGatewayHandler } from "./zoo-gateway" diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index 7246a9017..bb56718a7 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -63,6 +63,7 @@ export class ProfileValidator { case "xai": case "sambanova": case "fireworks": + case "friendli": return profile.apiModelId case "litellm": return profile.litellmModelId diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index 9bf913cdc..efeb7a482 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -178,6 +178,7 @@ describe("ProfileValidator", () => { "xai", "sambanova", "fireworks", + "friendli", ] apiModelProviders.forEach((provider) => { diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 70617a1ee..87abe1b6b 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -71,6 +71,7 @@ import { XAI, ZAi, Fireworks, + Friendli, VercelAiGateway, OpenCodeGo, ZooGateway, @@ -655,6 +656,13 @@ const ApiOptions = ({ /> )} + {selectedProvider === "friendli" && ( + + )} + {selectedProvider === "poe" && ( void +} + +export const Friendli = ({ apiConfiguration, setApiConfigurationField }: FriendliProps) => { + const { t } = useAppTranslation() + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration?.friendliApiKey && ( + + {t("settings:providers.getFriendliApiKey")} + + )} + + ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index d5dd0d0de..211f51526 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -21,6 +21,7 @@ export { XAI } from "./XAI" export { ZAi } from "./ZAi" export { LiteLLM } from "./LiteLLM" export { Fireworks } from "./Fireworks" +export { Friendli } from "./Friendli" export { VercelAiGateway } from "./VercelAiGateway" export { OpenCodeGo } from "./OpenCodeGo" export { ZooGateway } from "./ZooGateway" diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index 9cc9dafa0..e5009995e 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -16,6 +16,7 @@ import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId, fireworksDefaultModelId, + friendliDefaultModelId, minimaxDefaultModelId, basetenDefaultModelId, mimoDefaultModelId, @@ -49,6 +50,7 @@ export const PROVIDER_SERVICE_CONFIG: Partial> = sambanova: sambaNovaDefaultModelId, zai: internationalZAiDefaultModelId, fireworks: fireworksDefaultModelId, + friendli: friendliDefaultModelId, minimax: minimaxDefaultModelId, mimo: mimoDefaultModelId, baseten: basetenDefaultModelId, @@ -123,6 +126,7 @@ const PROVIDER_MODEL_CONFIG: Partial> sambanova: { field: "apiModelId", default: sambaNovaDefaultModelId }, zai: { field: "apiModelId" }, fireworks: { field: "apiModelId", default: fireworksDefaultModelId }, + friendli: { field: "apiModelId", default: friendliDefaultModelId }, poe: { field: "apiModelId", default: poeDefaultModelId }, "vercel-ai-gateway": { field: "vercelAiGatewayModelId", default: vercelAiGatewayDefaultModelId }, "opencode-go": { field: "opencodeGoModelId", default: opencodeGoDefaultModelId }, diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index d3ebb6c0d..adf4e6203 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -23,6 +23,7 @@ import { internationalZAiModels, mainlandZAiModels, fireworksModels, + friendliModels, basetenModels, qwenCodeModels, litellmDefaultModelInfo, @@ -323,6 +324,11 @@ function getSelectedModel({ const info = fireworksModels[id as keyof typeof fireworksModels] return { id, info } } + case "friendli": { + const id = apiConfiguration.apiModelId ?? defaultModelId + const info = friendliModels[id as keyof typeof friendliModels] + return { id, info } + } case "poe": { const id = apiConfiguration.apiModelId ?? defaultModelId const info = routerModels.poe?.[id] diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index e43da6298..da0a82aff 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "URL base de Poe", "fireworksApiKey": "Clau API de Fireworks", "getFireworksApiKey": "Obtenir clau API de Fireworks", + "friendliApiKey": "Clau API de Friendli", + "getFriendliApiKey": "Obtenir clau API de Friendli", "deepSeekApiKey": "Clau API de DeepSeek", "getDeepSeekApiKey": "Obtenir clau API de DeepSeek", "moonshotApiKey": "Clau API de Moonshot", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index dce5dce40..8b322bbc6 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "Poe Basis-URL", "fireworksApiKey": "Fireworks API-Schlüssel", "getFireworksApiKey": "Fireworks API-Schlüssel erhalten", + "friendliApiKey": "Friendli API-Schlüssel", + "getFriendliApiKey": "Friendli API-Schlüssel erhalten", "deepSeekApiKey": "DeepSeek API-Schlüssel", "getDeepSeekApiKey": "DeepSeek API-Schlüssel erhalten", "moonshotApiKey": "Moonshot API-Schlüssel", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 4fe5b7a59..d602b132e 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -483,6 +483,8 @@ "poeBaseUrl": "Poe Base URL", "fireworksApiKey": "Fireworks API Key", "getFireworksApiKey": "Get Fireworks API Key", + "friendliApiKey": "Friendli API Key", + "getFriendliApiKey": "Get Friendli API Key", "deepSeekApiKey": "DeepSeek API Key", "getDeepSeekApiKey": "Get DeepSeek API Key", "moonshotApiKey": "Moonshot API Key", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 625db9fff..b9a485c08 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "URL base de Poe", "fireworksApiKey": "Clave API de Fireworks", "getFireworksApiKey": "Obtener clave API de Fireworks", + "friendliApiKey": "Clave API de Friendli", + "getFriendliApiKey": "Obtener clave API de Friendli", "deepSeekApiKey": "Clave API de DeepSeek", "getDeepSeekApiKey": "Obtener clave API de DeepSeek", "moonshotApiKey": "Clave API de Moonshot", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 6afcab808..7265941f1 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "URL de base Poe", "fireworksApiKey": "Clé API Fireworks", "getFireworksApiKey": "Obtenir la clé API Fireworks", + "friendliApiKey": "Clé API Friendli", + "getFriendliApiKey": "Obtenir la clé API Friendli", "deepSeekApiKey": "Clé API DeepSeek", "getDeepSeekApiKey": "Obtenir la clé API DeepSeek", "moonshotApiKey": "Clé API Moonshot", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index a64f1b746..420f6ac91 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "Poe बेस URL", "fireworksApiKey": "Fireworks API कुंजी", "getFireworksApiKey": "Fireworks API कुंजी प्राप्त करें", + "friendliApiKey": "Friendli API कुंजी", + "getFriendliApiKey": "Friendli API कुंजी प्राप्त करें", "deepSeekApiKey": "DeepSeek API कुंजी", "getDeepSeekApiKey": "DeepSeek API कुंजी प्राप्त करें", "moonshotApiKey": "Moonshot API कुंजी", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 429639904..b1fb34510 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "Poe Base URL", "fireworksApiKey": "Fireworks API Key", "getFireworksApiKey": "Dapatkan Fireworks API Key", + "friendliApiKey": "Friendli API Key", + "getFriendliApiKey": "Dapatkan Friendli API Key", "deepSeekApiKey": "DeepSeek API Key", "getDeepSeekApiKey": "Dapatkan DeepSeek API Key", "moonshotApiKey": "Kunci API Moonshot", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 3fd5ebe5d..0bfe32082 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "URL base Poe", "fireworksApiKey": "Chiave API Fireworks", "getFireworksApiKey": "Ottieni chiave API Fireworks", + "friendliApiKey": "Chiave API Friendli", + "getFriendliApiKey": "Ottieni chiave API Friendli", "deepSeekApiKey": "Chiave API DeepSeek", "getDeepSeekApiKey": "Ottieni chiave API DeepSeek", "moonshotApiKey": "Chiave API Moonshot", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 690247dfe..a6d1bac1d 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "Poe ベースURL", "fireworksApiKey": "Fireworks APIキー", "getFireworksApiKey": "Fireworks APIキーを取得", + "friendliApiKey": "Friendli APIキー", + "getFriendliApiKey": "Friendli APIキーを取得", "deepSeekApiKey": "DeepSeek APIキー", "getDeepSeekApiKey": "DeepSeek APIキーを取得", "moonshotApiKey": "Moonshot APIキー", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index ae0761f6c..87a444f2e 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "Poe 기본 URL", "fireworksApiKey": "Fireworks API 키", "getFireworksApiKey": "Fireworks API 키 받기", + "friendliApiKey": "Friendli API 키", + "getFriendliApiKey": "Friendli API 키 받기", "deepSeekApiKey": "DeepSeek API 키", "getDeepSeekApiKey": "DeepSeek API 키 받기", "moonshotApiKey": "Moonshot API 키", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 11e18758a..21fc1b362 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "Poe Basis-URL", "fireworksApiKey": "Fireworks API-sleutel", "getFireworksApiKey": "Fireworks API-sleutel ophalen", + "friendliApiKey": "Friendli API-sleutel", + "getFriendliApiKey": "Friendli API-sleutel ophalen", "deepSeekApiKey": "DeepSeek API-sleutel", "getDeepSeekApiKey": "DeepSeek API-sleutel ophalen", "moonshotApiKey": "Moonshot API-sleutel", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 918bc6b7f..7d08b2bc4 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "Bazowy URL Poe", "fireworksApiKey": "Klucz API Fireworks", "getFireworksApiKey": "Uzyskaj klucz API Fireworks", + "friendliApiKey": "Klucz API Friendli", + "getFriendliApiKey": "Uzyskaj klucz API Friendli", "deepSeekApiKey": "Klucz API DeepSeek", "getDeepSeekApiKey": "Uzyskaj klucz API DeepSeek", "moonshotApiKey": "Klucz API Moonshot", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 78d9a47bc..987f9c536 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "URL base do Poe", "fireworksApiKey": "Chave de API Fireworks", "getFireworksApiKey": "Obter chave de API Fireworks", + "friendliApiKey": "Chave de API Friendli", + "getFriendliApiKey": "Obter chave de API Friendli", "deepSeekApiKey": "Chave de API DeepSeek", "getDeepSeekApiKey": "Obter chave de API DeepSeek", "moonshotApiKey": "Chave de API Moonshot", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index c72b32fbf..61b603137 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "Базовый URL Poe", "fireworksApiKey": "Fireworks API-ключ", "getFireworksApiKey": "Получить Fireworks API-ключ", + "friendliApiKey": "Friendli API-ключ", + "getFriendliApiKey": "Получить Friendli API-ключ", "deepSeekApiKey": "DeepSeek API-ключ", "getDeepSeekApiKey": "Получить DeepSeek API-ключ", "moonshotApiKey": "Moonshot API-ключ", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 48a104ace..b48c2bd0c 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "Poe Temel URL", "fireworksApiKey": "Fireworks API Anahtarı", "getFireworksApiKey": "Fireworks API Anahtarı Al", + "friendliApiKey": "Friendli API Anahtarı", + "getFriendliApiKey": "Friendli API Anahtarı Al", "deepSeekApiKey": "DeepSeek API Anahtarı", "getDeepSeekApiKey": "DeepSeek API Anahtarı Al", "moonshotApiKey": "Moonshot API Anahtarı", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 0f2e843d3..f685fd489 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "URL cơ sở Poe", "fireworksApiKey": "Khóa API Fireworks", "getFireworksApiKey": "Lấy khóa API Fireworks", + "friendliApiKey": "Khóa API Friendli", + "getFriendliApiKey": "Lấy khóa API Friendli", "deepSeekApiKey": "Khóa API DeepSeek", "getDeepSeekApiKey": "Lấy khóa API DeepSeek", "moonshotApiKey": "Khóa API Moonshot", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index e17cf5a0d..136d430ae 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -403,6 +403,8 @@ "poeBaseUrl": "Poe 基础 URL", "fireworksApiKey": "Fireworks API 密钥", "getFireworksApiKey": "获取 Fireworks API 密钥", + "friendliApiKey": "Friendli API 密钥", + "getFriendliApiKey": "获取 Friendli API 密钥", "deepSeekApiKey": "DeepSeek API 密钥", "getDeepSeekApiKey": "获取 DeepSeek API 密钥", "moonshotApiKey": "Moonshot API 密钥", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index e0c5c5833..304a26421 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -430,6 +430,8 @@ "poeBaseUrl": "Poe 基礎 URL", "fireworksApiKey": "Fireworks API 金鑰", "getFireworksApiKey": "取得 Fireworks API 金鑰", + "friendliApiKey": "Friendli API 金鑰", + "getFriendliApiKey": "取得 Friendli API 金鑰", "deepSeekApiKey": "DeepSeek API 金鑰", "getDeepSeekApiKey": "取得 DeepSeek API 金鑰", "moonshotApiKey": "Moonshot API 金鑰", diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 3de648080..c19839672 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -117,6 +117,11 @@ function validateModelsAndKeysProvided( return i18next.t("settings:validation.apiKey") } break + case "friendli": + if (!apiConfiguration.friendliApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case "qwen-code": if (!apiConfiguration.qwenCodeOauthPath) { return i18next.t("settings:validation.qwenCodeOauthPath") From b5d388c05e619dac932fcea9265455ee8013d3c4 Mon Sep 17 00:00:00 2001 From: siyoon Date: Fri, 26 Jun 2026 01:37:03 +0900 Subject: [PATCH 2/3] docs(friendli): add docstrings to handler and UI component + revert bug_report.yml Friendli entry --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 63ac8a97d..87594fb85 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -81,7 +81,6 @@ body: - DeepSeek - Featherless AI - Fireworks AI - - Friendli - Google Gemini - Google Vertex AI - Groq From d687acc35ec1f0c811e1fd756c6e08b93b618f52 Mon Sep 17 00:00:00 2001 From: siyoon Date: Fri, 26 Jun 2026 01:37:43 +0900 Subject: [PATCH 3/3] docs(friendli): add docstrings to handler class and settings component --- src/api/providers/friendli.ts | 7 +++++++ webview-ui/src/components/settings/providers/Friendli.tsx | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index 5ad7f5997..f9aa4fa20 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -4,7 +4,14 @@ import type { ApiHandlerOptions } from "../../shared/api" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +/** + * Handler for the Friendli Model APIs (OpenAI-compatible). + * Routes chat completions to `https://api.friendli.ai/serverless/v1`. + */ export class FriendliHandler extends BaseOpenAiCompatibleProvider { + /** + * @param options Provider settings; `friendliApiKey` is required. + */ constructor(options: ApiHandlerOptions) { super({ ...options, diff --git a/webview-ui/src/components/settings/providers/Friendli.tsx b/webview-ui/src/components/settings/providers/Friendli.tsx index e68100f00..66f4c554e 100644 --- a/webview-ui/src/components/settings/providers/Friendli.tsx +++ b/webview-ui/src/components/settings/providers/Friendli.tsx @@ -13,6 +13,10 @@ type FriendliProps = { setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void } +/** + * Settings form for the Friendli provider. + * Renders an API-key input and a "Get Friendli API Key" link when the key is empty. + */ export const Friendli = ({ apiConfiguration, setApiConfigurationField }: FriendliProps) => { const { t } = useAppTranslation()