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 { useLanguage } from "@/context/language"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
import { sortModels } from "@/utils/model-sort"
|
||||||
|
|
||||||
export const DialogManageModels: Component = () => {
|
export const DialogManageModels: Component = () => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
@@ -44,7 +45,7 @@ export const DialogManageModels: Component = () => {
|
|||||||
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
||||||
items={local.model.list()}
|
items={local.model.list()}
|
||||||
filterKeys={["provider.name", "name", "id"]}
|
filterKeys={["provider.name", "name", "id"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={sortModels}
|
||||||
groupBy={(x) => x.provider.id}
|
groupBy={(x) => x.provider.id}
|
||||||
groupHeader={(group) => {
|
groupHeader={(group) => {
|
||||||
const provider = group.items[0].provider
|
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 { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { isFreeModel, sortModels } from "@/utils/model-sort"
|
||||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
|
||||||
provider === "opencode" && (!cost || cost.input === 0)
|
|
||||||
|
|
||||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
|
|
||||||
@@ -44,7 +42,7 @@ const ModelList: Component<{
|
|||||||
items={models}
|
items={models}
|
||||||
current={model.current()}
|
current={model.current()}
|
||||||
filterKeys={["provider.name", "name", "id"]}
|
filterKeys={["provider.name", "name", "id"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={sortModels}
|
||||||
groupBy={(x) => x.provider.name}
|
groupBy={(x) => x.provider.name}
|
||||||
sortGroupsBy={(a, b) => {
|
sortGroupsBy={(a, b) => {
|
||||||
const aProvider = a.items[0].provider.id
|
const aProvider = a.items[0].provider.id
|
||||||
@@ -58,7 +56,7 @@ const ModelList: Component<{
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={12}
|
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}
|
{node}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -73,7 +71,7 @@ const ModelList: Component<{
|
|||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||||
<span class="truncate">{i.name}</span>
|
<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>
|
<Tag>{language.t("model.tag.free")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={i.latest}>
|
<Show when={i.latest}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
|
import { sortModels } from "@/utils/model-sort"
|
||||||
|
|
||||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export const SettingsModels: Component = () => {
|
|||||||
items: (_filter) => models.list(),
|
items: (_filter) => models.list(),
|
||||||
key: (x) => `${x.provider.id}:${x.id}`,
|
key: (x) => `${x.provider.id}:${x.id}`,
|
||||||
filterKeys: ["provider.name", "name", "id"],
|
filterKeys: ["provider.name", "name", "id"],
|
||||||
sortBy: (a, b) => a.name.localeCompare(b.name),
|
sortBy: sortModels,
|
||||||
groupBy: (x) => x.provider.id,
|
groupBy: (x) => x.provider.id,
|
||||||
sortGroupsBy: (a, b) => {
|
sortGroupsBy: (a, b) => {
|
||||||
const aIndex = popularProviders.indexOf(a.category)
|
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 { formatBindings } from "./keymap.shared"
|
||||||
import type { RunFooterTheme } from "./theme"
|
import type { RunFooterTheme } from "./theme"
|
||||||
import type { FooterKeybinds, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
|
import type { FooterKeybinds, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
|
||||||
|
import { sortModelOptions } from "@/cli/util/model-sort"
|
||||||
|
|
||||||
type PanelEntry = RunFooterMenuItem & {
|
type PanelEntry = RunFooterMenuItem & {
|
||||||
category: string
|
category: string
|
||||||
@@ -25,6 +26,8 @@ type ModelEntry = PanelEntry & {
|
|||||||
providerID: string
|
providerID: string
|
||||||
modelID: string
|
modelID: string
|
||||||
providerName: string
|
providerName: string
|
||||||
|
releaseDate: string
|
||||||
|
free: boolean
|
||||||
current: boolean
|
current: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,9 +677,10 @@ export function RunModelSelectBody(props: {
|
|||||||
.map(([modelID, model]) => {
|
.map(([modelID, model]) => {
|
||||||
const title = model.name ?? modelID
|
const title = model.name ?? modelID
|
||||||
const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID
|
const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID
|
||||||
|
const free = model.cost?.input === 0 && provider.id === "opencode"
|
||||||
const footer = current
|
const footer = current
|
||||||
? "current"
|
? "current"
|
||||||
: model.cost?.input === 0 && provider.id === "opencode"
|
: free
|
||||||
? "Free"
|
? "Free"
|
||||||
: title !== modelID
|
: title !== modelID
|
||||||
? modelID
|
? modelID
|
||||||
@@ -688,6 +692,9 @@ export function RunModelSelectBody(props: {
|
|||||||
category: provider.name,
|
category: provider.name,
|
||||||
display: title,
|
display: title,
|
||||||
footer,
|
footer,
|
||||||
|
title,
|
||||||
|
releaseDate: model.release_date,
|
||||||
|
free,
|
||||||
keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`,
|
keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`,
|
||||||
current,
|
current,
|
||||||
}
|
}
|
||||||
@@ -704,7 +711,7 @@ export function RunModelSelectBody(props: {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.display.localeCompare(b.display)
|
return sortModelOptions(a, b)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const items = createMemo<ModelEntry[]>(() => match(query(), entries()))
|
const items = createMemo<ModelEntry[]>(() => match(query(), entries()))
|
||||||
|
|||||||
@@ -70,20 +70,25 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
entries(),
|
entries(),
|
||||||
filter(([_, info]) => info.status !== "deprecated"),
|
filter(([_, info]) => info.status !== "deprecated"),
|
||||||
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
|
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 },
|
value: { providerID: provider.id, modelID: model },
|
||||||
title: info.name ?? model,
|
title: info.name ?? model,
|
||||||
releaseDate: info.release_date,
|
|
||||||
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
|
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
|
||||||
? "(Favorite)"
|
? "(Favorite)"
|
||||||
: undefined,
|
: undefined,
|
||||||
category: connected() ? provider.name : undefined,
|
category: connected() ? provider.name : undefined,
|
||||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
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() {
|
||||||
onSelect(provider.id, model)
|
onSelect(provider.id, model)
|
||||||
},
|
},
|
||||||
})),
|
}
|
||||||
|
}),
|
||||||
filter((x) => {
|
filter((x) => {
|
||||||
if (!showSections) return true
|
if (!showSections) return true
|
||||||
if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
|
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[],
|
options: T[],
|
||||||
newestFirst: boolean,
|
newestFirst: boolean,
|
||||||
) {
|
) {
|
||||||
if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title)
|
if (newestFirst)
|
||||||
return sortBy(
|
return sortBy(
|
||||||
options,
|
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,
|
(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