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:
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user