fix: preserve full OpenCode provider cost shape in auth.loader

The normalizeProviderModelCosts function was only setting { input: 0, output: 0 }
which doesn't match the full OpenCode provider cost shape that includes cache fields.

This fix:
- Preserves existing cost fields (input, output, cache.read, cache.write) if valid
- Adds missing cache: { read: 0, write: 0 } structure when not present
- Validates provider and model objects before modification to avoid poisoning
  invalid provider metadata
- Updates ProviderModel type to include optional cache field

Fixes provider cost normalization to maintain cache field compatibility.
This commit is contained in:
Developer
2026-05-09 15:28:13 -04:00
parent 6fea0178eb
commit 47d6fc7382
7 changed files with 429 additions and 1 deletions
@@ -275,6 +275,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
const api = createTuiApi({
command,
tuiConfig,
dialog,
keymap,
@@ -1,4 +1,10 @@
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type {
TuiCommand,
TuiDialogSelectOption,
TuiPluginApi,
TuiRouteDefinition,
TuiSlotProps,
} from "@opencode-ai/plugin/tui"
import type { useEvent } from "@tui/context/event"
import type { useRoute } from "@tui/context/route"
import type { useSDK } from "@tui/context/sdk"
@@ -17,6 +23,7 @@ import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Keymap from "../keymap"
import type { useCommandPalette } from "../context/command-palette"
type RouteEntry = {
key: symbol
@@ -26,6 +33,7 @@ type RouteEntry = {
export type RouteMap = Map<string, RouteEntry[]>
type Input = {
command: ReturnType<typeof useCommandPalette>
tuiConfig: TuiConfig.Resolved
dialog: ReturnType<typeof useDialog>
keymap: ReturnType<typeof useOpencodeKeymap>
@@ -41,6 +49,54 @@ type Input = {
renderer: TuiPluginApi["renderer"]
}
let warnedLegacyCommand = false
function warnLegacyCommandApi() {
if (warnedLegacyCommand) return
warnedLegacyCommand = true
console.warn("[tui.plugin] api.command is deprecated; use api.keymap.registerLayer({ commands, bindings }) instead")
}
function commandBinding(command: TuiCommand, input: Input) {
if (!command.keybind) return []
return input.tuiConfig.keybinds.get(command.keybind).map((binding) => ({ ...binding, cmd: command.value }))
}
function legacyCommandApi(input: Input): TuiPluginApi["command"] {
return {
register(cb) {
warnLegacyCommandApi()
const list = cb()
return input.keymap.registerLayer({
commands: list.map((command) => ({
name: command.value,
title: command.title,
desc: command.description,
category: command.category,
namespace: "palette",
suggested: command.suggested,
hidden: command.hidden,
enabled: command.enabled,
slashName: command.slash?.name,
slashAliases: command.slash?.aliases,
run() {
command.onSelect?.()
},
})),
bindings: list.flatMap((command) => commandBinding(command, input)),
})
},
trigger(value) {
warnLegacyCommandApi()
input.keymap.dispatchCommand(value)
},
show() {
warnLegacyCommandApi()
input.command.show()
},
}
}
function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
const key = Symbol()
for (const item of list) {
@@ -209,6 +265,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
},
},
keymap: input.keymap,
command: legacyCommandApi(input),
route: {
register(list) {
return routeRegister(input.routes, list, input.bump)
@@ -563,6 +563,18 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
const keymap = createScopedKeymap(api.keymap, scope)
const command: TuiPluginApi["command"] = {
register(cb) {
return scope.track(api.command.register(cb))
},
trigger(value) {
api.command.trigger(value)
},
show() {
api.command.show()
},
}
let count = 0
const slots: TuiPluginApi["slots"] = {
@@ -578,6 +590,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
app: api.app,
keys: api.keys,
keymap,
command,
route,
ui: api.ui,
tuiConfig: api.tuiConfig,
@@ -776,6 +776,79 @@ test("auto-disposes plugin keymap layers", async () => {
}
})
test("supports legacy plugin command API", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "legacy-command-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "legacy-command.txt")
await Bun.write(
file,
`export default {
id: "demo.command.legacy",
tui: async (api) => {
api.command.register(() => [{
title: "Legacy command",
value: "demo.command.legacy.run",
onSelect() {
Bun.write(${JSON.stringify(marker)}, "called")
},
}])
api.command.trigger("demo.command.legacy.run")
api.command.show()
},
}
`,
)
return { spec, marker }
},
})
let add = 0
let drop = 0
let show = 0
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({
api: createTuiPluginApi({
command: {
register(cb) {
add += 1
const list = cb()
return () => {
drop += list.length
}
},
trigger(value) {
expect(value).toBe("demo.command.legacy.run")
Bun.write(tmp.extra.marker, "called")
},
show() {
show += 1
},
},
}),
config: createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
}),
})
expect(add).toBe(1)
expect(show).toBe(1)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
} finally {
await TuiPluginRuntime.dispose()
expect(drop).toBe(1)
cwd.mockRestore()
wait.mockRestore()
}
})
test("plugin keymap proxy preserves real keymap receiver", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -82,6 +82,7 @@ function themeCurrent(): HostPluginApi["theme"]["current"] {
type Opts = {
client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
command?: Partial<HostPluginApi["command"]>
renderer?: HostPluginApi["renderer"]
count?: Count
keymap?: HostPluginApi["keymap"]
@@ -131,6 +132,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
? () => opts.client as HostPluginApi["client"]
: fallback
const client = () => read()
const commands: ReturnType<Parameters<HostPluginApi["command"]["register"]>[0]> = []
let depth = 0
let size: "medium" | "large" | "xlarge" = "medium"
const has = opts.theme?.has ?? (() => false)
@@ -187,6 +189,25 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
formatSequence: () => "",
formatBindings: () => undefined,
},
command: {
register(cb) {
if (opts.command?.register) return opts.command.register(cb)
const list = cb()
commands.push(...list)
if (count) count.command_add += 1
return () => {
if (count) count.command_drop += 1
commands.splice(0, commands.length, ...commands.filter((command) => !list.includes(command)))
}
},
trigger(value) {
if (opts.command?.trigger) return opts.command.trigger(value)
commands.find((command) => command.value === value)?.onSelect?.()
},
show() {
opts.command?.show?.()
},
},
get client() {
return client()
},
+23
View File
@@ -70,6 +70,23 @@ export type TuiRouteDefinition = {
render: (input: { params?: Record<string, unknown> }) => JSX.Element
}
/** @deprecated Use api.keymap.registerLayer({ commands, bindings }) instead. */
export type TuiCommand = {
title: string
value: string
description?: string
category?: string
keybind?: string
suggested?: boolean
hidden?: boolean
enabled?: boolean
slash?: {
name: string
aliases?: string[]
}
onSelect?: () => void
}
export type TuiKeys = {
formatSequence: (parts: readonly KeySequenceFormatPart[] | undefined) => string
formatBindings: (bindings: readonly SequenceBindingLike[] | undefined) => string | undefined
@@ -463,6 +480,12 @@ export type TuiPluginApi = {
app: TuiApp
keys: TuiKeys
keymap: TuiKeymap
/** @deprecated Use api.keymap.registerLayer({ commands, bindings }) instead. */
command: {
register: (cb: () => TuiCommand[]) => () => void
trigger: (value: string) => void
show: () => void
}
route: {
register: (routes: TuiRouteDefinition[]) => () => void
navigate: (name: string, params?: Record<string, unknown>) => void