fix(models): sort free models by launch priority

This commit is contained in:
opencode-agent[bot]
2026-05-30 20:33:24 +00:00
parent ac8e686f33
commit e97308cd57
9 changed files with 142 additions and 27 deletions
@@ -9,6 +9,7 @@ import { popularProviders } from "@/hooks/use-providers"
import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectProvider } from "./dialog-select-provider"
import { sortModels } from "@/utils/model-sort"
export const DialogManageModels: Component = () => {
const local = useLocal()
@@ -44,7 +45,7 @@ export const DialogManageModels: Component = () => {
key={(x) => `${x?.provider?.id}:${x?.id}`}
items={local.model.list()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
sortBy={sortModels}
groupBy={(x) => x.provider.id}
groupHeader={(group) => {
const provider = group.items[0].provider
@@ -12,9 +12,7 @@ import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
import { isFreeModel, sortModels } from "@/utils/model-sort"
type ModelState = ReturnType<typeof useLocal>["model"]
@@ -44,7 +42,7 @@ const ModelList: Component<{
items={models}
current={model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
sortBy={sortModels}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
const aProvider = a.items[0].provider.id
@@ -58,7 +56,7 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} />}
>
{node}
</Tooltip>
@@ -73,7 +71,7 @@ const ModelList: Component<{
{(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={isFree(i.provider.id, i.cost)}>
<Show when={isFreeModel(i)}>
<Tag>{language.t("model.tag.free")}</Tag>
</Show>
<Show when={i.latest}>
@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list"
import { sortModels } from "@/utils/model-sort"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
@@ -39,7 +40,7 @@ export const SettingsModels: Component = () => {
items: (_filter) => models.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
sortBy: sortModels,
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aIndex = popularProviders.indexOf(a.category)
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test"
import { sortModels } from "./model-sort"
function model(input: { id: string; name: string; release_date: string; cost?: number }) {
return {
id: input.id,
name: input.name,
release_date: input.release_date,
provider: { id: "opencode" },
cost: { input: input.cost ?? 0 },
}
}
describe("sortModels", () => {
test("pins Big Pickle before free models sorted by release date", () => {
expect(
[
model({ id: "older-free", name: "Older Free", release_date: "2026-01-01" }),
model({ id: "paid", name: "Paid", release_date: "2026-05-01", cost: 1 }),
model({ id: "newer-free", name: "Newer Free", release_date: "2026-05-01" }),
model({ id: "big-pickle", name: "Big Pickle", release_date: "2025-10-17" }),
].sort(sortModels).map((item) => item.id),
).toEqual(["big-pickle", "newer-free", "older-free", "paid"])
})
})
+26
View File
@@ -0,0 +1,26 @@
type SortableModel = {
id: string
name: string
release_date: string
provider: { id: string }
cost?: { input: number }
}
export function isFreeModel(model: { provider: { id: string }; cost?: { input: number } }) {
return model.provider.id === "opencode" && (!model.cost || model.cost.input === 0)
}
export function sortModels(a: SortableModel, b: SortableModel) {
const aFree = isFreeModel(a)
const bFree = isFreeModel(b)
if (aFree && !bFree) return -1
if (!aFree && bFree) return 1
if (aFree && bFree) {
if (a.id !== b.id) {
if (a.id === "big-pickle") return -1
if (b.id === "big-pickle") return 1
}
return b.release_date.localeCompare(a.release_date) || a.name.localeCompare(b.name)
}
return a.name.localeCompare(b.name)
}
@@ -7,6 +7,7 @@ import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./
import { formatBindings } from "./keymap.shared"
import type { RunFooterTheme } from "./theme"
import type { FooterKeybinds, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
import { sortModelOptions } from "@/cli/util/model-sort"
type PanelEntry = RunFooterMenuItem & {
category: string
@@ -25,6 +26,8 @@ type ModelEntry = PanelEntry & {
providerID: string
modelID: string
providerName: string
releaseDate: string
free: boolean
current: boolean
}
@@ -674,9 +677,10 @@ export function RunModelSelectBody(props: {
.map(([modelID, model]) => {
const title = model.name ?? modelID
const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID
const free = model.cost?.input === 0 && provider.id === "opencode"
const footer = current
? "current"
: model.cost?.input === 0 && provider.id === "opencode"
: free
? "Free"
: title !== modelID
? modelID
@@ -688,6 +692,9 @@ export function RunModelSelectBody(props: {
category: provider.name,
display: title,
footer,
title,
releaseDate: model.release_date,
free,
keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`,
current,
}
@@ -704,7 +711,7 @@ export function RunModelSelectBody(props: {
return name
}
return a.display.localeCompare(b.display)
return sortModelOptions(a, b)
}),
)
const items = createMemo<ModelEntry[]>(() => match(query(), entries()))
@@ -70,20 +70,25 @@ export function DialogModel(props: { providerID?: string }) {
entries(),
filter(([_, info]) => info.status !== "deprecated"),
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
map(([model, info]) => ({
map(([model, info]) => {
const free = info.cost?.input === 0 && provider.id === "opencode"
return {
value: { providerID: provider.id, modelID: model },
title: info.name ?? model,
releaseDate: info.release_date,
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
footer: free ? "Free" : undefined,
modelID: model,
releaseDate: info.release_date,
free,
onSelect() {
onSelect(provider.id, model)
},
})),
}
}),
filter((x) => {
if (!showSections) return true
if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
@@ -172,14 +177,23 @@ export function DialogModel(props: { providerID?: string }) {
)
}
export function sortModelOptions<T extends { footer?: string; releaseDate: string; title: string }>(
export function sortModelOptions<
T extends { footer?: string; releaseDate: string; title: string; modelID?: string; free?: boolean },
>(
options: T[],
newestFirst: boolean,
) {
if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title)
if (newestFirst)
return sortBy(
options,
(option) => option.footer !== "Free",
(option) => (option.modelID === "big-pickle" ? 0 : 1),
[(option) => option.releaseDate, "desc"],
(option) => option.title,
)
return sortBy(
options,
(option) => (option.modelID === "big-pickle" ? 0 : option.free || option.footer === "Free" ? 1 : 2),
[(option) => (option.free || option.footer === "Free" ? option.releaseDate : ""), "desc"],
(option) => option.title,
)
}
@@ -0,0 +1,19 @@
type SortableModelOption = {
modelID: string
title: string
releaseDate: string
free: boolean
}
export function sortModelOptions(a: SortableModelOption, b: SortableModelOption) {
if (a.free && !b.free) return -1
if (!a.free && b.free) return 1
if (a.free && b.free) {
if (a.modelID !== b.modelID) {
if (a.modelID === "big-pickle") return -1
if (b.modelID === "big-pickle") return 1
}
return b.releaseDate.localeCompare(a.releaseDate) || a.title.localeCompare(b.title)
}
return a.title.localeCompare(b.title)
}
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { sortModelOptions } from "@/cli/util/model-sort"
function model(input: { modelID: string; title: string; releaseDate: string; free?: boolean }) {
return {
modelID: input.modelID,
title: input.title,
releaseDate: input.releaseDate,
free: input.free ?? true,
}
}
describe("sortModelOptions", () => {
test("pins Big Pickle before free models sorted by release date", () => {
expect(
[
model({ modelID: "older-free", title: "Older Free", releaseDate: "2026-01-01" }),
model({ modelID: "paid", title: "Paid", releaseDate: "2026-05-01", free: false }),
model({ modelID: "newer-free", title: "Newer Free", releaseDate: "2026-05-01" }),
model({ modelID: "big-pickle", title: "Big Pickle", releaseDate: "2025-10-17" }),
].sort(sortModelOptions).map((item) => item.modelID),
).toEqual(["big-pickle", "newer-free", "older-free", "paid"])
})
})