Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d50342f
feat(friendli): fetch models from /v1/models
Lee-Si-Yoon Nov 6, 2026
6d1192d
test(friendli): cover model catalog paths
Lee-Si-Yoon Nov 6, 2026
93ac20e
fix(friendli): load model catalog lazily before requests
Lee-Si-Yoon Nov 6, 2026
e3c5040
fix(friendli): stop on cancellation during catalog loading
Lee-Si-Yoon Nov 6, 2026
12b4105
fix(friendli): keep toggle-only reasoning and prototype-safe model IDs
Lee-Si-Yoon Nov 7, 2026
877020a
fix(friendli): use the fetched catalog in the chat model picker
Lee-Si-Yoon Nov 7, 2026
22053fa
Merge branch 'main' into feat/friendli-models-api
Lee-Si-Yoon Nov 8, 2026
29dbf0c
fix(friendli): share catalog loading with task preparation and honor …
Lee-Si-Yoon Nov 8, 2026
c41b6ff
fix(friendli): bridge the caller's abort signal per stream
Lee-Si-Yoon Nov 8, 2026
3c2aa5b
fix(friendli): bridge the caller's abort signal in completePrompt
Lee-Si-Yoon Nov 8, 2026
db86b8b
fix(friendli): guard webview model lookups against inherited properties
Lee-Si-Yoon Nov 8, 2026
b7e9e97
fix(friendli): default empty model IDs and keep __proto__ IDs across …
Lee-Si-Yoon Nov 8, 2026
eed57af
test(friendli): cover ordinary IDs beside __proto__ and fetched defau…
Lee-Si-Yoon Nov 8, 2026
bacc23a
test(friendli): cover spent-deadline guard, provider filter, and stat…
Lee-Si-Yoon Nov 9, 2026
2330128
test(friendli): assert default context window when context_length is …
Lee-Si-Yoon Nov 9, 2026
4123aae
Merge branch 'main' into feat/friendli-models-api
Lee-Si-Yoon Nov 9, 2026
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
Comment thread
Lee-Si-Yoon marked this conversation as resolved.
Resolved
] as const

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

Large diffs are not rendered by default.

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

import axios from "axios"

import { openAiModelInfoSaneDefaults } from "@roo-code/types"

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
// A model without context_length gets the sane default window.
expect(models["google/gemma-4-31B-it"].contextWindow).toBe(openAiModelInfoSaneDefaults.contextWindow)
// 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