Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/app/e2e/regression/free-model-badge.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"

const directory = "C:/OpenCode/FreeModelBadge"
const projectID = "proj_free_model_badge"
const sessionID = "ses_free_model_badge"

test("shows the Free badge instead of repeating Free in an OpenCode model name", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "free-model-badge",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"glm-5-free": {
id: "glm-5-free",
name: "GLM 5 Free",
cost: { input: 0 },
limit: { context: 200_000 },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "glm-5-free" },
},
sessions: [
{
id: sessionID,
slug: "free-model-badge",
projectID,
directory,
title: "Free model badge",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})

await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="session-composer"]')
await expectAppVisible(composer)

const trigger = composer.locator('[data-action="prompt-model"]')
await expect(trigger).toContainText("GLM 5")
await expect(trigger).toContainText("Free")
await expect(trigger).not.toContainText("GLM 5 Free")

await trigger.click()
const option = page.getByRole("dialog").getByRole("button", { name: /GLM 5/ })
await expect(option).toBeVisible()
await expect(option).toContainText("GLM 5")
await expect(option).toContainText("Free")
await expect(option).not.toContainText("GLM 5 Free")

await page.screenshot({ path: "e2e/test-results/free-model-badge.png" })
})
10 changes: 4 additions & 6 deletions packages/app/src/components/dialog-select-model-unpaid-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
import { isFreeModel, modelDisplayName } from "./model-display"

type ModelState = ReturnType<typeof useLocal>["model"]

Expand All @@ -26,9 +27,6 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
const c = model.current()
return c ? `${c.provider.id}:${c.id}` : undefined
})
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)

const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController()
Expand Down Expand Up @@ -83,15 +81,15 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
placement="right-start"
gutter={6}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} v2 />}
>
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item)}>
<span class="min-w-0 truncate">{modelDisplayName(item)}</span>
<Show when={isFreeModel(item)}>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
</Show>
<Show when={item.latest}>
Expand Down
11 changes: 3 additions & 8 deletions packages/app/src/components/dialog-select-model-unpaid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
import { isFreeModel, modelDisplayName } from "./model-display"

type ModelState = ReturnType<typeof useLocal>["model"]

Expand Down Expand Up @@ -57,13 +58,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
class="w-full"
placement="right-start"
gutter={12}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
/>
}
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} />}
>
{node}
</Tooltip>
Expand All @@ -77,7 +72,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<span>{modelDisplayName(i)}</span>
<Tag>{language.t("model.tag.free")}</Tag>
<Show when={i.latest}>
<Tag>{language.t("model.tag.latest")}</Tag>
Expand Down
23 changes: 7 additions & 16 deletions packages/app/src/components/dialog-select-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ import { decode64 } from "@/utils/base64"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./dialog-select-model-search"

const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
import { isFreeModel, modelDisplayName } from "./model-display"

type ModelState = ReturnType<typeof useLocal>["model"]
type ModelItem = ReturnType<ModelState["list"]>[number]
Expand Down Expand Up @@ -94,7 +92,7 @@ const ModelList: Component<{
placement="right-start"
gutter={12}
openDelay={0}
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>
Expand All @@ -108,8 +106,8 @@ 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)}>
<span class="truncate">{modelDisplayName(i)}</span>
<Show when={isFreeModel(i)}>
<Tag>{language.t("model.tag.free")}</Tag>
</Show>
<Show when={i.latest}>
Expand Down Expand Up @@ -453,14 +451,7 @@ export function ModelSelectorPopoverV2(props: {
placement="right-start"
gutter={6}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} v2 />}
>
<MenuV2.RadioItem
value={modelKey(item)}
Expand All @@ -474,8 +465,8 @@ export function ModelSelectorPopoverV2(props: {
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate leading-5">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<span class="min-w-0 truncate leading-5">{modelDisplayName(item)}</span>
<Show when={isFreeModel(item)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
Expand Down
27 changes: 27 additions & 0 deletions packages/app/src/components/model-display.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import { isFreeModel, modelDisplayName } from "./model-display"

const model = (name: string, provider = "opencode", input = 0) => ({
name,
provider: { id: provider },
cost: { input },
})

describe("model display", () => {
test("recognizes free models provided by OpenCode", () => {
expect(isFreeModel(model("GLM 5 Free"))).toBe(true)
expect(isFreeModel(model("GLM 5 Free", "openrouter"))).toBe(false)
expect(isFreeModel(model("GLM 5 Free", "opencode", 1))).toBe(false)
})

test("removes the standalone free label from free model names", () => {
expect(modelDisplayName(model("GLM 5 Free"))).toBe("GLM 5")
expect(modelDisplayName(model("GLM 5 (Free)"))).toBe("GLM 5")
expect(modelDisplayName(model("Free GLM 5"))).toBe("GLM 5")
})

test("preserves names that are not OpenCode free models", () => {
expect(modelDisplayName(model("GLM 5 Free", "openrouter"))).toBe("GLM 5 Free")
expect(modelDisplayName(model("FreeModel"))).toBe("FreeModel")
})
})
17 changes: 17 additions & 0 deletions packages/app/src/components/model-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
type ModelDisplay = {
name: string
provider: { id: string }
cost?: { input: number }
}

export const isFreeModel = (model: ModelDisplay) =>
model.provider.id === "opencode" && (!model.cost || model.cost.input === 0)

export const modelDisplayName = (model: ModelDisplay) => {
if (!isFreeModel(model)) return model.name
return model.name
.replace(/\(\s*free\s*\)/gi, " ")
.replace(/\bfree\b/gi, " ")
.replace(/\s+/g, " ")
.trim()
}
9 changes: 7 additions & 2 deletions packages/app/src/components/model-tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Show, type Component, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { modelDisplayName } from "./model-display"

type InputKey = "text" | "image" | "audio" | "video" | "pdf"
type InputMap = Record<InputKey, boolean>
Expand All @@ -8,8 +9,12 @@ type ModelInfo = {
id: string
name: string
provider: {
id: string
name: string
}
cost?: {
input: number
}
capabilities?: {
reasoning: boolean
input: InputMap
Expand Down Expand Up @@ -60,14 +65,14 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${sourceName(props.model)} ${props.model.name}${suffix}`
return `${sourceName(props.model)} ${modelDisplayName(props.model)}${suffix}`
}
const name = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${props.model.name}${suffix}`
return `${modelDisplayName(props.model)}${suffix}`
}
const inputs = () => {
if (props.model.capabilities) {
Expand Down
58 changes: 38 additions & 20 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,15 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Tag } from "@opencode-ai/ui/tag"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import { isFreeModel, modelDisplayName } from "@/components/model-display"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
Expand Down Expand Up @@ -1541,26 +1544,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return "Ask anything, / for commands, @ for context..."
}

const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(),
shouldAnimate: providersShouldFadeIn(),
paid: props.controls.model.paid,
title: language.t("command.model.choose"),
keybind: command.keybindParts("model.choose"),
model: props.controls.model.selection,
providerID: props.controls.model.selection.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
newLayoutDesigns: props.controls.newLayoutDesigns,
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
if (props.controls.newLayoutDesigns) {
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
return
}
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
},
}))
const modelControlState = createMemo<ComposerModelControlState>(() => {
const current = props.controls.model.selection.current()
return {
loading: providersLoading(),
shouldAnimate: providersShouldFadeIn(),
paid: props.controls.model.paid,
title: language.t("command.model.choose"),
keybind: command.keybindParts("model.choose"),
model: props.controls.model.selection,
providerID: current?.provider?.id,
modelName: current ? modelDisplayName(current) : language.t("dialog.model.select.title"),
free: current ? isFreeModel(current) : false,
newLayoutDesigns: props.controls.newLayoutDesigns,
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
if (props.controls.newLayoutDesigns) {
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
return
}
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
},
}
})

const newSession = () => props.variant === "new-session"
const bindEditorRef = (el: HTMLDivElement) => {
Expand Down Expand Up @@ -2178,6 +2185,7 @@ type ComposerModelControlState = {
model: ReturnType<typeof useLocal>["model"]
providerID?: string
modelName: string
free: boolean
newLayoutDesigns: boolean
style: JSX.CSSProperties | undefined
onClose: () => void
Expand Down Expand Up @@ -2217,6 +2225,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
}

function ComposerModelControl(props: { state: ComposerModelControlState }) {
const language = useLanguage()
return (
<Show when={!props.state.loading}>
<Show
Expand Down Expand Up @@ -2255,6 +2264,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Show when={props.state.free}>
<Tag>{language.t("model.tag.free")}</Tag>
</Show>
<span class="-ml-1 shrink-0 flex size-fit">
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
Expand Down Expand Up @@ -2330,6 +2342,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
}

function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
const language = useLanguage()
return (
<>
<Show when={props.state.providerID}>
Expand All @@ -2342,6 +2355,11 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Show when={props.state.free}>
<Show when={props.v2} fallback={<Tag>{language.t("model.tag.free")}</Tag>}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
</Show>
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
Expand Down
Loading