From 366f711310836ab3c6dd276da85d631f1680b726 Mon Sep 17 00:00:00 2001 From: Miran Park Date: Mon, 3 Aug 2026 23:31:20 +0000 Subject: [PATCH 1/3] feat(app): group Forge models by tier --- .../src/components/dialog-select-model.tsx | 65 +++++++++++++++++-- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index b4e8b3b719ca..4428602efcf7 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,9 +1,9 @@ import { Popover as Kobalte } from "@kobalte/core/popover" +import { OWNERLING_MODEL_TIERS } from "@owner/ownerling-models" import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { popularProviders } from "@/hooks/use-providers" import { Button } from "@opencode-ai/ui/button" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tag } from "@opencode-ai/ui/tag" @@ -17,6 +17,42 @@ import { decode64 } from "@/utils/base64" const isFree = (provider: string, cost: { input: number } | undefined) => provider === "opencode" && (!cost || cost.input === 0) +const forgeModelTiers = new Map( + OWNERLING_MODEL_TIERS.flatMap((tier) => + tier.models.map( + (model) => + [ + `${model.providerID}:${model.modelID}`, + { label: tier.label, pricing: model.pricing, tier: tier.tier }, + ] as const, + ), + ), +) + +const tierColor = { + frontier: "var(--oc-tier-frontier)", + balanced: "var(--oc-tier-balanced)", + performance: "var(--oc-tier-performance)", +} + +function forgeModelTier(model: { provider: { id: string }; id: string }) { + return forgeModelTiers.get(`${model.provider.id}:${model.id}`) +} + +function formatPrice(perMillion: number) { + return `$${perMillion.toFixed(2)}` +} + +function ModelPrice(props: { model: Parameters[0] }) { + const tier = forgeModelTier(props.model) + if (!tier) return + return ( + + {formatPrice(tier.pricing.inputPerMillion)} / {formatPrice(tier.pricing.outputPerMillion)} + + ) +} + type ModelState = ReturnType["model"] const ModelList: Component<{ @@ -46,13 +82,27 @@ const ModelList: Component<{ current={model.current()} filterKeys={["provider.name", "name", "id"]} sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} + groupBy={(model) => forgeModelTier(model)?.label ?? "Other models"} sortGroupsBy={(a, b) => { - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + const aTier = forgeModelTier(a.items[0]) + const bTier = forgeModelTier(b.items[0]) + if (!aTier) return bTier ? 1 : 0 + if (!bTier) return -1 + return ( + OWNERLING_MODEL_TIERS.findIndex((tier) => tier.tier === aTier.tier) - + OWNERLING_MODEL_TIERS.findIndex((tier) => tier.tier === bTier.tier) + ) + }} + groupHeader={(group) => { + const tier = forgeModelTier(group.items[0]) + return ( + + {group.category} + + ) }} itemWrapper={(item, node) => ( (
{i.name} + {language.t("model.tag.free")} From bf68400c2c2f25ebab2e8666990f3fe84ce402fb Mon Sep 17 00:00:00 2001 From: Miran Park Date: Mon, 3 Aug 2026 23:50:55 +0000 Subject: [PATCH 2/3] fix(app): keep Forge model tiers self-contained --- .../src/components/dialog-select-model.tsx | 316 +++++++++++++----- 1 file changed, 229 insertions(+), 87 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 4428602efcf7..1583c0c2ca3d 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,40 +1,140 @@ -import { Popover as Kobalte } from "@kobalte/core/popover" -import { OWNERLING_MODEL_TIERS } from "@owner/ownerling-models" -import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js" -import { createStore } from "solid-js/store" -import { useLocal } from "@/context/local" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Button } from "@opencode-ai/ui/button" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tag } from "@opencode-ai/ui/tag" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { Tooltip } from "@opencode-ai/ui/tooltip" -import { ModelTooltip } from "./model-tooltip" -import { useLanguage } from "@/context/language" -import { decode64 } from "@/utils/base64" +import { Popover as Kobalte } from '@kobalte/core/popover' +import { + Component, + ComponentProps, + createMemo, + JSX, + Show, + ValidComponent, +} from 'solid-js' +import { createStore } from 'solid-js/store' +import { useLocal } from '@/context/local' +import { useDialog } from '@opencode-ai/ui/context/dialog' +import { Button } from '@opencode-ai/ui/button' +import { IconButton } from '@opencode-ai/ui/icon-button' +import { Tag } from '@opencode-ai/ui/tag' +import { Dialog } from '@opencode-ai/ui/dialog' +import { List } from '@opencode-ai/ui/list' +import { Tooltip } from '@opencode-ai/ui/tooltip' +import { ModelTooltip } from './model-tooltip' +import { useLanguage } from '@/context/language' +import { decode64 } from '@/utils/base64' const isFree = (provider: string, cost: { input: number } | undefined) => - provider === "opencode" && (!cost || cost.input === 0) - -const forgeModelTiers = new Map( - OWNERLING_MODEL_TIERS.flatMap((tier) => - tier.models.map( - (model) => - [ - `${model.providerID}:${model.modelID}`, - { label: tier.label, pricing: model.pricing, tier: tier.tier }, - ] as const, - ), - ), -) + provider === 'opencode' && (!cost || cost.input === 0) const tierColor = { - frontier: "var(--oc-tier-frontier)", - balanced: "var(--oc-tier-balanced)", - performance: "var(--oc-tier-performance)", + frontier: 'var(--oc-tier-frontier)', + balanced: 'var(--oc-tier-balanced)', + performance: 'var(--oc-tier-performance)', +} + +const tierOrder = { + frontier: 0, + balanced: 1, + performance: 2, } +type ForgeModelTier = keyof typeof tierColor + +const forgeModelTiers = new Map< + string, + { + label: string + pricing: { inputPerMillion: number; outputPerMillion: number } + tier: ForgeModelTier + } +>([ + [ + 'anthropic:claude-opus-5', + { + label: 'Frontier · ~5× price', + pricing: { inputPerMillion: 5, outputPerMillion: 25 }, + tier: 'frontier', + }, + ], + [ + 'anthropic:claude-opus-4-8', + { + label: 'Frontier · ~5× price', + pricing: { inputPerMillion: 5, outputPerMillion: 25 }, + tier: 'frontier', + }, + ], + [ + 'openai:gpt-5.6-sol', + { + label: 'Frontier · ~5× price', + pricing: { inputPerMillion: 5, outputPerMillion: 30 }, + tier: 'frontier', + }, + ], + [ + 'anthropic:claude-sonnet-5', + { + label: 'Balanced · ~2× price', + pricing: { inputPerMillion: 3, outputPerMillion: 15 }, + tier: 'balanced', + }, + ], + [ + 'openai:gpt-5.6-terra', + { + label: 'Balanced · ~2× price', + pricing: { inputPerMillion: 2, outputPerMillion: 12 }, + tier: 'balanced', + }, + ], + [ + 'baseten:zai-org/GLM-5.2-Fast', + { + label: 'Balanced · ~2× price', + pricing: { inputPerMillion: 2.1, outputPerMillion: 6.6 }, + tier: 'balanced', + }, + ], + [ + 'baseten:moonshotai/Kimi-K3', + { + label: 'Balanced · ~2× price', + pricing: { inputPerMillion: 3, outputPerMillion: 15 }, + tier: 'balanced', + }, + ], + [ + 'openai:gpt-5.6-luna', + { + label: 'Performance', + pricing: { inputPerMillion: 0.2, outputPerMillion: 1.2 }, + tier: 'performance', + }, + ], + [ + 'baseten:zai-org/GLM-5.2-1M', + { + label: 'Performance', + pricing: { inputPerMillion: 1.4, outputPerMillion: 4.4 }, + tier: 'performance', + }, + ], + [ + 'baseten:moonshotai/Kimi-K2.7-Code', + { + label: 'Performance', + pricing: { inputPerMillion: 0.95, outputPerMillion: 4 }, + tier: 'performance', + }, + ], + [ + 'baseten:thinkingmachines/inkling', + { + label: 'Performance', + pricing: { inputPerMillion: 1, outputPerMillion: 4.05 }, + tier: 'performance', + }, + ], +]) + function forgeModelTier(model: { provider: { id: string }; id: string }) { return forgeModelTiers.get(`${model.provider.id}:${model.id}`) } @@ -47,13 +147,14 @@ function ModelPrice(props: { model: Parameters[0] }) { const tier = forgeModelTier(props.model) if (!tier) return return ( - - {formatPrice(tier.pricing.inputPerMillion)} / {formatPrice(tier.pricing.outputPerMillion)} + + {formatPrice(tier.pricing.inputPerMillion)} /{' '} + {formatPrice(tier.pricing.outputPerMillion)} ) } -type ModelState = ReturnType["model"] +type ModelState = ReturnType['model'] const ModelList: Component<{ provider?: string @@ -68,37 +169,42 @@ const ModelList: Component<{ const models = createMemo(() => model .list() - .filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id })) - .filter((m) => (props.provider ? m.provider.id === props.provider : true)), + .filter((m) => + model.visible({ modelID: m.id, providerID: m.provider.id }) + ) + .filter((m) => (props.provider ? m.provider.id === props.provider : true)) ) return ( `${x.provider.id}:${x.id}`} items={models} current={model.current()} - filterKeys={["provider.name", "name", "id"]} + filterKeys={['provider.name', 'name', 'id']} sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(model) => forgeModelTier(model)?.label ?? "Other models"} + groupBy={(model) => forgeModelTier(model)?.label ?? 'Other models'} sortGroupsBy={(a, b) => { const aTier = forgeModelTier(a.items[0]) const bTier = forgeModelTier(b.items[0]) if (!aTier) return bTier ? 1 : 0 if (!bTier) return -1 - return ( - OWNERLING_MODEL_TIERS.findIndex((tier) => tier.tier === aTier.tier) - - OWNERLING_MODEL_TIERS.findIndex((tier) => tier.tier === bTier.tier) - ) + return tierOrder[aTier.tier] - tierOrder[bTier.tier] }} groupHeader={(group) => { const tier = forgeModelTier(group.items[0]) return ( {group.category} @@ -110,27 +216,36 @@ const ModelList: Component<{ placement="right-start" gutter={12} openDelay={0} - value={} + value={ + + } > {node} )} onSelect={(x) => { - model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) + model.set( + x ? { modelID: x.id, providerID: x.provider.id } : undefined, + { + recent: true, + } + ) props.onSelect() }} > {(i) => ( -
+
{i.name} - {language.t("model.tag.free")} + {language.t('model.tag.free')} - {language.t("model.tag.latest")} + {language.t('model.tag.latest')}
)} @@ -138,8 +253,11 @@ const ModelList: Component<{ ) } -type ModelSelectorTriggerProps = Omit, "as" | "ref"> -type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" +type ModelSelectorTriggerProps = Omit< + ComponentProps, + 'as' | 'ref' +> +type Dismiss = 'escape' | 'outside' | 'select' | 'manage' | 'provider' export function ModelSelectorPopover(props: { provider?: string @@ -147,7 +265,7 @@ export function ModelSelectorPopover(props: { children?: JSX.Element triggerAs?: ValidComponent triggerProps?: ModelSelectorTriggerProps - onClose?: (cause: "escape" | "select") => void + onClose?: (cause: 'escape' | 'select') => void }) { const [store, setStore] = createStore<{ open: boolean @@ -161,20 +279,20 @@ export function ModelSelectorPopover(props: { const directory = () => decode64(local.slug()) const close = (dismiss: Dismiss) => { - setStore("dismiss", dismiss) - setStore("open", false) + setStore('dismiss', dismiss) + setStore('open', false) } const handleManage = () => { - close("manage") - void import("./dialog-manage-models").then((x) => { + close('manage') + void import('./dialog-manage-models').then((x) => { dialog.show(() => ) }) } const handleConnectProvider = () => { - close("provider") - void import("./dialog-select-provider").then((x) => { + close('provider') + void import('./dialog-select-provider').then((x) => { dialog.show(() => ) }) } @@ -184,61 +302,69 @@ export function ModelSelectorPopover(props: { { - if (next) setStore("dismiss", null) - setStore("open", next) + if (next) setStore('dismiss', null) + setStore('open', next) }} modal={false} placement="top-start" gutter={4} > - + {props.children} { - close("escape") + close('escape') event.preventDefault() event.stopPropagation() }} - onPointerDownOutside={() => close("outside")} - onFocusOutside={() => close("outside")} + onPointerDownOutside={() => close('outside')} + onFocusOutside={() => close('outside')} onCloseAutoFocus={(event) => { const dismiss = store.dismiss - if (dismiss === "outside") event.preventDefault() - if (dismiss === "escape" || dismiss === "select") { + if (dismiss === 'outside') event.preventDefault() + if (dismiss === 'escape' || dismiss === 'select') { event.preventDefault() props.onClose?.(dismiss) } - setStore("dismiss", null) + setStore('dismiss', null) }} > - {language.t("dialog.model.select.title")} + + {language.t('dialog.model.select.title')} + close("select")} + onSelect={() => close('select')} class="p-1" action={
- + - + @@ -251,36 +377,52 @@ export function ModelSelectorPopover(props: { ) } -export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { +export const DialogSelectModel: Component<{ + provider?: string + model?: ModelState +}> = (props) => { const dialog = useDialog() const language = useLanguage() const local = useLocal() const directory = () => decode64(local.slug()) const provider = () => { - void import("./dialog-select-provider").then((x) => { + void import('./dialog-select-provider').then((x) => { dialog.show(() => ) }) } const manage = () => { - void import("./dialog-manage-models").then((x) => { + void import('./dialog-manage-models').then((x) => { dialog.show(() => ) }) } return ( - {language.t("command.provider.connect")} + } > - dialog.close()} /> - ) From 117a2d143ecd1682c847226451280b8a2bf77842 Mon Sep 17 00:00:00 2001 From: Miran Park Date: Tue, 4 Aug 2026 00:19:16 +0000 Subject: [PATCH 3/3] fix(ci): use Node 24 for typecheck --- .github/workflows/typecheck.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index fc9a52797c1d..5588276309cb 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -14,6 +14,11 @@ jobs: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + - name: Setup Bun uses: ./.github/actions/setup-bun