mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-02 06:16:48 +02:00
fix(models): sort free models by launch priority
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user