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 diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index b4e8b3b719ca..1583c0c2ca3d 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,23 +1,160 @@ -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 { 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" -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) + provider === 'opencode' && (!cost || cost.input === 0) -type ModelState = ReturnType["model"] +const tierColor = { + 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}`) +} + +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<{ provider?: string @@ -32,27 +169,46 @@ 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={(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 tierOrder[aTier.tier] - tierOrder[bTier.tier] + }} + groupHeader={(group) => { + const tier = forgeModelTier(group.items[0]) + return ( + + {group.category} + + ) }} itemWrapper={(item, node) => ( } + 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')}
)} @@ -87,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 @@ -96,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 @@ -110,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(() => ) }) } @@ -133,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={
- + - + @@ -200,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()} /> - )