mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 15:34:02 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b6a37e7ff |
@@ -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" })
|
||||
})
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
@@ -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>
|
||||
@@ -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}>
|
||||
@@ -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)}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
@@ -8,8 +9,12 @@ type ModelInfo = {
|
||||
id: string
|
||||
name: string
|
||||
provider: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
cost?: {
|
||||
input: number
|
||||
}
|
||||
capabilities?: {
|
||||
reasoning: boolean
|
||||
input: InputMap
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
@@ -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) => {
|
||||
@@ -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
|
||||
@@ -2217,6 +2225,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
}
|
||||
|
||||
function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Show when={!props.state.loading}>
|
||||
<Show
|
||||
@@ -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>
|
||||
@@ -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}>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user