Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ describe("provider identifiers", () => {
providerIdentifiers.nanogpt,
providerIdentifiers.ioIntelligence,
providerIdentifiers.kimiCode,
providerIdentifiers.friendli,
])
expect(localProviders).toEqual([providerIdentifiers.ollama, providerIdentifiers.lmstudio])
expect(internalProviders).toEqual([providerIdentifiers.vscodeLm, providerIdentifiers.githubCopilot])
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const dynamicProviders = [
providerIdentifiers.nanogpt,
providerIdentifiers.ioIntelligence,
providerIdentifiers.kimiCode,
providerIdentifiers.friendli,
Comment thread
Lee-Si-Yoon marked this conversation as resolved.
Resolved
] as const

export type DynamicProvider = (typeof dynamicProviders)[number]
Expand Down
374 changes: 367 additions & 7 deletions src/api/providers/__tests__/friendli.spec.ts

Large diffs are not rendered by default.

164 changes: 164 additions & 0 deletions src/api/providers/fetchers/__tests__/friendli.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// npx vitest run api/providers/fetchers/__tests__/friendli.spec.ts

import axios from "axios"

import { getFriendliModels } from "../friendli"

vi.mock("axios")

describe("getFriendliModels", () => {
it("maps chat models from /v1/models", async () => {
vi.mocked(axios.get).mockResolvedValueOnce({
data: {
data: [
{
id: "zai-org/GLM-5.3",
mode: "chat",
description: "GLM",
context_length: 1_048_576,
max_completion_tokens: 131_072,
pricing: { input: "0.00000126", output: "0.00000396", input_cache_read: "0.000000234" },
input_modalities: ["text", "image"],
reasoning_options: [
{ type: "effort", values: ["low", "high", "max", "ultracode"] },
{ type: "budget_tokens", min: -1, max: 1_048_576 },
],
},
{
id: "google/gemma-4-31B-it",
mode: "chat",
pricing: { input: "0.00000014", output: "0.0000004" },
},
{ id: "zai-org/GLM-5.2", mode: "chat", input_modalities: ["text"] },
{ id: "some/embedding", mode: "embedding" },
],
},
})

const signal = new AbortController().signal
const models = await getFriendliModels({ signal })

expect(axios.get).toHaveBeenCalledWith("https://api.friendli.ai/serverless/v1/models", { signal })
expect(Object.keys(models)).toEqual(["zai-org/GLM-5.3", "google/gemma-4-31B-it", "zai-org/GLM-5.2"])
expect(models["zai-org/GLM-5.3"]).toEqual({
maxTokens: 131_072,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: true,
supportsMaxTokens: true,
inputPrice: expect.closeTo(1.26),
outputPrice: expect.closeTo(3.96),
cacheReadsPrice: expect.closeTo(0.234),
supportsReasoningEffort: ["low", "high", "max"],
reasoningEffort: "high",
description: "GLM",
})
expect(models["google/gemma-4-31B-it"].supportsReasoningEffort).toBeUndefined()
expect(models["google/gemma-4-31B-it"].supportsPromptCache).toBe(false)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// Missing input_modalities and a text-only list both mean no image input.
expect(models["google/gemma-4-31B-it"].supportsImages).toBe(false)
expect(models["zai-org/GLM-5.2"].supportsImages).toBe(false)
})

it("accepts null reasoning_options and keeps the catalog", async () => {
vi.mocked(axios.get).mockResolvedValueOnce({
data: {
data: [
{ id: "vendor/fetched-only", reasoning_options: null },
{ id: "zai-org/GLM-5.1", reasoning_options: [{ type: "toggle" }] },
],
},
})

const models = await getFriendliModels()

expect(Object.keys(models)).toEqual(["vendor/fetched-only", "zai-org/GLM-5.1"])
expect(models["vendor/fetched-only"].supportsReasoningEffort).toBeUndefined()
expect(models["vendor/fetched-only"].supportsReasoningBinary).toBeUndefined()
})

it("maps toggle-only reasoning to supportsReasoningBinary without inventing an effort", async () => {
vi.mocked(axios.get).mockResolvedValueOnce({
data: {
data: [
{
id: "zai-org/GLM-5.1",
reasoning_options: [{ type: "toggle" }, { type: "budget_tokens", min: -1, max: 202_752 }],
},
{
id: "zai-org/GLM-5.2",
reasoning_options: [{ type: "toggle" }, { type: "effort", values: ["high", "max"] }],
},
],
},
})

const models = await getFriendliModels()

expect(models["zai-org/GLM-5.1"].supportsReasoningBinary).toBe(true)
expect(models["zai-org/GLM-5.1"].supportsReasoningEffort).toBeUndefined()
expect(models["zai-org/GLM-5.1"].reasoningEffort).toBeUndefined()
// A toggle next to effort levels keeps the effort selector.
expect(models["zai-org/GLM-5.2"].supportsReasoningBinary).toBeUndefined()
expect(models["zai-org/GLM-5.2"].supportsReasoningEffort).toEqual(["high", "max"])
})

it("defaults to the first supported effort when high is not offered", async () => {
vi.mocked(axios.get).mockResolvedValueOnce({
data: {
data: [
{
id: "vendor/no-high",
reasoning_options: [{ type: "effort", values: ["ultracode", "low", "medium", "max"] }],
},
],
},
})

const models = await getFriendliModels()

expect(models["vendor/no-high"].supportsReasoningEffort).toEqual(["low", "medium", "max"])
expect(models["vendor/no-high"].reasoningEffort).toBe("low")
})

it("keeps reserved upstream IDs as own keys", async () => {
vi.mocked(axios.get).mockResolvedValueOnce({
data: {
data: [
{ id: "__proto__", context_length: 1000 },
{ id: "toString", context_length: 2000 },
],
},
})

const models = await getFriendliModels()

expect(Object.getPrototypeOf(models)).toBeNull()
expect(Object.keys(models)).toEqual(["__proto__", "toString"])
expect(models["__proto__"].contextWindow).toBe(1000)
expect(models["toString"].contextWindow).toBe(2000)
expect(models["valueOf"]).toBeUndefined()
})

it("rethrows cancellation instead of returning an empty record", async () => {
const controller = new AbortController()
controller.abort()
vi.mocked(axios.get).mockRejectedValueOnce(new Error("canceled"))

await expect(getFriendliModels({ signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" })
})

it("returns an empty record on an invalid response", async () => {
vi.spyOn(console, "error").mockImplementation(() => {})
vi.mocked(axios.get).mockResolvedValueOnce({ data: { invalid: true } })

expect(await getFriendliModels()).toEqual({})
})

it("returns an empty record on request failure", async () => {
vi.spyOn(console, "error").mockImplementation(() => {})
vi.mocked(axios.get).mockRejectedValueOnce(new Error("Network error"))

expect(await getFriendliModels()).toEqual({})
})
})
86 changes: 86 additions & 0 deletions src/api/providers/fetchers/friendli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import axios from "axios"
import { z } from "zod"

import {
type ModelInfo,
type ReasoningEffortExtended,
openAiModelInfoSaneDefaults,
reasoningEffortsExtended,
} from "@roo-code/types"

import { parseApiPrice } from "../../../shared/cost"
import { throwIfAborted } from "../utils/abort-signal"

const friendliModelSchema = z.object({
id: z.string(),
mode: z.string().optional(),
description: z.string().optional(),
context_length: z.number().optional(),
max_completion_tokens: z.number().optional(),
pricing: z
.object({
input: z.string().optional(),
output: z.string().optional(),
input_cache_read: z.string().optional(),
})
.optional(),
input_modalities: z.array(z.string()).optional(),
reasoning_options: z.array(z.object({ type: z.string(), values: z.array(z.string()).optional() })).nullish(),
})

type FriendliModel = z.infer<typeof friendliModelSchema>

const friendliModelsResponseSchema = z.object({ data: z.array(friendliModelSchema) })

const parseFriendliModel = (model: FriendliModel): ModelInfo => {
const efforts = model.reasoning_options

Check warning on line 36 in src/api/providers/fetchers/friendli.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/fetchers/friendli.ts:36: Survived OptionalChaining mutant (replacement: model.reasoning_options?.find(option => option.type === "effort")?.values.filter). See the job summary for the complete list and resolution guidance.
?.find((option) => option.type === "effort")
Comment thread
Lee-Si-Yoon marked this conversation as resolved.
Resolved
?.values?.filter((v): v is ReasoningEffortExtended =>
(reasoningEffortsExtended as readonly string[]).includes(v),
)
// A toggle without effort levels (e.g. GLM-5.1, Gemma) is an on/off switch, not an effort scale.
const toggleOnly = !efforts?.length && !!model.reasoning_options?.some((option) => option.type === "toggle")

Check warning on line 42 in src/api/providers/fetchers/friendli.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/fetchers/friendli.ts:42: 2 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.

return {
maxTokens: model.max_completion_tokens,
contextWindow: model.context_length ?? openAiModelInfoSaneDefaults.contextWindow,
supportsImages: model.input_modalities?.includes("image") ?? false,
supportsPromptCache: model.pricing?.input_cache_read !== undefined,
supportsMaxTokens: true,
inputPrice: parseApiPrice(model.pricing?.input),
outputPrice: parseApiPrice(model.pricing?.output),
cacheReadsPrice: parseApiPrice(model.pricing?.input_cache_read),
...(efforts?.length && {
supportsReasoningEffort: efforts,
reasoningEffort: efforts.includes("high") ? "high" : efforts[0],
}),
...(toggleOnly && { supportsReasoningBinary: true }),
description: model.description,
}
}

/** Fetches the public (no auth) Friendli model catalog. */
export async function getFriendliModels(opts?: { signal?: AbortSignal }): Promise<Record<string, ModelInfo>> {
// Null prototype: upstream IDs such as "__proto__" or "toString" must stay plain keys.
const models: Record<string, ModelInfo> = Object.create(null)

try {
const response = await axios.get("https://api.friendli.ai/serverless/v1/models", { signal: opts?.signal })
const result = friendliModelsResponseSchema.safeParse(response.data)

if (!result.success) {

Check warning on line 71 in src/api/providers/fetchers/friendli.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/fetchers/friendli.ts:71: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
console.error(`Friendli models response is invalid: ${JSON.stringify(result.error.format())}`)

Check warning on line 72 in src/api/providers/fetchers/friendli.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/fetchers/friendli.ts:72: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
return models
}

for (const model of result.data.data) {
if (model.mode && model.mode !== "chat") continue
models[model.id] = parseFriendliModel(model)
}
} catch (error) {
throwIfAborted(opts?.signal)
console.error(`Error fetching Friendli models: ${error instanceof Error ? error.message : String(error)}`)

Check warning on line 82 in src/api/providers/fetchers/friendli.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/fetchers/friendli.ts:82: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
}

return models
}
Loading
Loading