Repository navigation
feat(friendli): fetch models from /v1/models #2331
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d50342f
6d1192d
93ac20e
e3c5040
12b4105
877020a
22053fa
29dbf0c
c41b6ff
3c2aa5b
db86b8b
b7e9e97
eed57af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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) | ||
|
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({}) | ||
| }) | ||
| }) | ||
| 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
|
||
| ?.find((option) => option.type === "effort") | ||
|
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
|
||
|
|
||
| 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
|
||
| console.error(`Friendli models response is invalid: ${JSON.stringify(result.error.format())}`) | ||
|
Check warning on line 72 in src/api/providers/fetchers/friendli.ts
|
||
| 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
|
||
| } | ||
|
|
||
| return models | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.