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

View File

@@ -0,0 +1,240 @@
# Denotational Design: Part 1
## The Basic Idea
Denotational design means designing an abstraction by first asking:
> What does this thing mean?
Before choosing data structures, algorithms, callbacks, queues, caches, or runtime behavior, we give each core type a simple meaning. Then we define operations in terms of that meaning.
The implementation can be clever later. The meaning should be simple now.
## API vs Implementation vs Meaning
When designing a library, there are three related but different things:
| Layer | Question | Example |
| --- | --- | --- |
| API | What can users call? | `map(signal, f)` |
| Implementation | How does it run? | subscriptions, graphs, caching |
| Denotation | What does it mean? | a value changing over time |
Most designs jump between API and implementation. Denotational design adds the missing middle: a precise meaning.
## A Tiny FRP Example
Functional Reactive Programming is a good example because it starts with a very simple denotation.
A time-varying value, often called a `Behavior`, can be understood as:
```ts
Behavior<A> = Time -> A
```
That says:
> A `Behavior<A>` means: give me a time, and I can tell you the `A` value at that time.
This does not mean the implementation literally stores an infinite function. It means this is the specification. The implementation may use events, subscriptions, incremental recomputation, dependency graphs, or caching.
## Operations Follow From Meaning
Once we know what `Behavior<A>` means, operations become easier to define.
For example, `map` transforms the value inside a behavior:
```ts
map: (A -> B) -> Behavior<A> -> Behavior<B>
```
Its meaning is:
```ts
map(f, behavior)(time) = f(behavior(time))
```
That is the whole specification.
Similarly, a constant behavior:
```ts
constant: A -> Behavior<A>
```
means:
```ts
constant(value)(time) = value
```
The definitions are simple because the denotation is simple.
## Why This Helps
Denotational design is useful because it separates essence from machinery.
It helps library users because the abstraction has a clear mental model.
It helps implementers because correctness has a target independent of implementation details.
It helps API design because awkward operations become easier to spot. If an operation has no clean meaning, it may be exposing implementation machinery instead of domain meaning.
## The Design Loop
A practical denotational design loop looks like this:
1. Name the core type.
2. Write down what values of that type mean.
3. Define each operation by how it transforms meanings.
4. Notice what laws naturally follow.
5. Choose an implementation that preserves the meaning.
The key discipline is to delay implementation concerns until after the meaning is clear.
## Denotation Is Not Implementation
A denotation can look like an implementation because it is concrete enough to write down:
```ts
Behavior<A> = Time -> A
```
But this equation is not saying that a real FRP system must store a function from every possible time to an `A` value.
It is saying that this is the model we use to understand a behavior.
The implementation might use callbacks, mutable cells, event queues, dependency graphs, caching, sampling, or incremental recomputation. Those choices are representation. The denotation is the meaning those representations must preserve.
So there are two different questions:
| Question | Answer |
| --- | --- |
| What does a behavior mean? | A function from time to value |
| How do we run it efficiently? | Some concrete representation and algorithm |
A denotation should be simple and precise. An implementation should be executable and efficient. They do not have to be the same thing.
## Operations At Two Levels
Conal Elliott describes a useful pattern:
> The meaning of each method corresponds to the same method for the meaning.
This sentence is subtle because there are three things in play:
1. An abstract type, like `Behavior<A>`.
2. A meaning type, like `Time -> A`.
3. A meaning function that translates from the abstract type to the meaning type.
For behaviors, Conal often calls the meaning function `at`:
```ts
at: Behavior<A> -> (Time -> A)
```
Read this as:
> `at(behavior)` gives the meaning of `behavior`.
So `at` is just the specific FRP name for the more general idea:
```ts
meaning: Abstract<A> -> Model<A>
```
Now we can talk about operations.
There are two versions of "the same" operation.
One operation belongs to the abstract API:
```ts
mapBehavior: (A -> B) -> Behavior<A> -> Behavior<B>
```
The other operation belongs to the semantic model:
```ts
mapFunction: (A -> B) -> (Time -> A) -> (Time -> B)
```
They are not literally the same function. They live at different levels. But they are the same conceptual operation: mapping a pure function over a value inside some structure.
The homomorphism law says that translating meanings should not care which order we take these steps.
Path 1: operate first, then take the meaning.
```ts
behavior
-> mapBehavior(f, behavior)
-> at(mapBehavior(f, behavior))
```
Path 2: take the meaning first, then operate on the meaning.
```ts
behavior
-> at(behavior)
-> mapFunction(f, at(behavior))
```
The law says both paths produce the same meaning:
```ts
at(mapBehavior(f, behavior))
=
mapFunction(f, at(behavior))
```
Using the generic word `meaning`, the same law is:
```ts
meaning(operationOnAbstraction(x))
=
operationOnMeaning(meaning(x))
```
In pointwise form:
```ts
at(mapBehavior(f, behavior))(time)
=
f(at(behavior)(time))
```
So the operation on the abstraction must mean the corresponding operation on the model.
That is the homomorphism idea: the meaning function preserves structure. It translates the abstract operation into the corresponding model operation.
```ts
// Same shape, different levels:
mapBehavior(f, behavior) // abstract level
mapFunction(f, at(behavior)) // meaning/model level
// Connected by the meaning function:
at(mapBehavior(f, behavior))
=
mapFunction(f, at(behavior))
```
This gives us a correctness rule. If `Behavior` claims to support `map`, its `map` should behave like `map` on its denotation, which is a function of time.
## The Main Lesson
Denotational design does not ask, "How do we build this?" first.
It asks:
> What are we talking about?
For FRP, the answer begins with:
```ts
Behavior<A> = Time -> A
```
That small equation gives the abstraction a center of gravity. Everything else can be designed around it.

View File

@@ -275,6 +275,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
const api = createTuiApi({
command,
tuiConfig,
dialog,
keymap,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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()
},

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