A band above the prompt to pick which model, and which version of it, runs the next requests, with cost, tokens and cache stats.

Elegí el modelo de Claude, y su versión, desde una banda arriba del prompt de Claude Code. Un clic y la próxima request sale con otro modelo: sin /model, sin diálogos, sin tocar tu modelo por defecto. Y debajo, lo que la API informó: qué modelo respondió, cuánto costó, tokens y cache.
▌ MODELO ✦ Fable 5.1 ▐ ◆ Opus 5.5 ▾ ▌ ▲ Sonnet 5.5 ● Haiku 5.5 razonamiento profundo
respondió Opus 5.5 · sesión ~$0.42 · turno ~$0.031 · effort medium · 48k→1.2k tok · cache 94%
Con el dropdown de versiones abierto:
▌ MODELO ✦ Fable 5.1 ▐ ◆ Opus 5.5 ▴ ▌ ▲ Sonnet 5.5 ● Haiku 5.5
● Opus 5.5 $4/$20 por MTok
○ Opus 5 $5/$25 por MTok
○ Opus 4.8 $5/$25 por MTok
○ Opus 4.7 $5/$25 por MTok
○ Opus 4.6 $5/$25 por MTok
/modelo para hacer lo mismo desde el teclado.claude --version.La API de mods de Claude Code es de acceso anticipado y puede cambiar entre versiones. Si una actualización de Claude Code rompe el mod, abrí un issue.
Elegí una sola de las opciones. Instalar por dos caminos a la vez hace que una copia no cargue (ver Problemas frecuentes).
Desde cualquier terminal:
claude plugin marketplace add juampymdd/claude-code-model-picker
claude plugin install model-picker@model-picker
El primer comando registra este repositorio como marketplace; el segundo instala el mod para tu usuario, en todos tus proyectos. Abrí una sesión nueva de Claude Code y la banda aparece arriba del prompt.
Para instalarlo solo en un proyecto, corré el segundo comando dentro de ese proyecto con --scope project.
También se puede instalar desde adentro de una sesión de Claude Code en la terminal:
/plugin install model-picker --marketplace juampymdd/claude-code-model-picker
Respondé y para agregar el marketplace y elegí el alcance.
Claude Code carga solo cualquier mod que esté en tu carpeta ~/.claude/skills/. La carpeta tiene que llamarse model-picker.
macOS / Linux:
git clone https://github.com/juampymdd/claude-code-model-picker.git ~/.claude/skills/model-picker
Windows (PowerShell):
git clone https://github.com/juampymdd/claude-code-model-picker.git "$env:USERPROFILE\.claude\skills\model-picker"
Abrí una sesión nueva de Claude Code. Esta opción sirve si querés editar el mod: los cambios en esa carpeta se recargan solos.
git clone https://github.com/juampymdd/claude-code-model-picker.git
claude --plugin-dir ./claude-code-model-picker
El mod queda cargado solo en esa sesión.
claude plugin list
Tiene que aparecer model-picker con estado enabled o loaded:
❯ model-picker@model-picker
Version: 0.3.0
Scope: user
Status: ✔ enabled
Después, en una sesión nueva, escribí /modelo. Si responde Modelo: Opus 5.5. Uso: … (con tu modelo), el mod está cargado.
| Acción | Resultado |
|---|---|
| Clic en un chip de otra familia | Cambia a la versión más nueva de esa familia |
Clic en el chip activo (▾) | Abre la lista de versiones, con su precio, debajo del chip |
| Clic en una versión de la lista | Cambia a esa versión y cierra la lista (● marca la actual) |
Clic otra vez en el chip activo (▴) | Cierra la lista sin cambiar nada |
| Elegir el modelo que ya usa la sesión | Quita la elección y vuelve a seguir a la sesión |
| Comando | Resultado |
|---|---|
/modelo | Muestra el modelo actual y la ayuda |
/modelo sonnet | Cambia a la versión más nueva de Sonnet |
/modelo opus 4.8 | Cambia a esa versión puntual |
/modelo auto | Vuelve al modelo de la sesión |
Familias válidas: fable, opus, sonnet, haiku.
Debajo de los chips hay una fila con lo que la API informó. Antes de la primera respuesta de la sesión dice costo, tokens y cache: tras la próxima respuesta; después:
respondió Opus 5.5 · sesión ~$0.42 · turno ~$0.031 · effort medium · 48k→1.2k tok · cache 94% · manual
| Dato | Qué es |
|---|---|
respondió Opus 5.5 | El modelo que la API dice que respondió la última request. Sirve para comprobar que el cambio se aplicó |
sesión ~$0.42 | Costo estimado de todas las requests de la sesión, subagentes incluidos |
turno ~$0.031 | Costo estimado del turno actual (tu último mensaje y todas las llamadas que disparó) |
effort medium | Nivel de razonamiento con el que salió la última request |
48k→1.2k tok | Tokens de la última request: prompt completo (cache incluida) → respuesta |
cache 94% | Qué parte de ese prompt salió de la cache. Baja a 0% justo después de cambiar de modelo |
manual | Hay un modelo elegido a mano, distinto al de la sesión |
Si la terminal es angosta, la fila deja primero los datos de la izquierda y descarta los últimos. Mientras el dropdown está abierto, la fila se oculta.
Los costos son una estimación: tokens informados por la API multiplicados por el precio de lista. Si usás una suscripción en vez de pagar por token, tomalos como referencia de consumo, no como tu factura. Ver Limitaciones.
Precios de lista de la API de Anthropic, en dólares por millón de tokens (MTok), al 6 de octubre de 2026.
| Modelo | Entrada | Salida |
|---|---|---|
| ✦ Fable 5.1 | $10 | $50 |
| ✦ Fable 5 | $10 | $50 |
| ◆ Opus 5.5 | $4 | $20 |
| ◆ Opus 5 | $5 | $25 |
| ◆ Opus 4.8 | $5 | $25 |
| ◆ Opus 4.7 | $5 | $25 |
| ◆ Opus 4.6 | $5 | $25 |
| ▲ Sonnet 5.5 | $2 | $10 |
| ▲ Sonnet 5 | $2 | $10 |
| ▲ Sonnet 4.6 | $3 | $15 |
| ● Haiku 5.5 | $0.10 | $0.50 |
| ● Haiku 4.5 | $1 | $5 |
Tu cuenta tiene que tener acceso al modelo que elijas. Los precios pueden cambiar: los vigentes están en la página de precios de Anthropic.
El mod registra cuatro hooks:
| Hook | Para qué |
|---|---|
session.start | Registra el comando /modelo |
command.run | Responde a /modelo |
turn.step | Antes de cada request del loop principal, pone el modelo elegido; después, suma lo que la API informó de la respuesta |
ui.render | Dibuja la banda arriba del prompt |
Además, en session.start lanza en segundo plano la búsqueda de versión nueva (ver Actualizar).
El cambio se hace por request: el mod reescribe el campo model de cada llamada del loop principal. No ejecuta /model.
/model no refleja la elección. El comando nativo sigue mostrando el modelo de la sesión; la fuente de verdad es la banda y su dato respondió ….Todo está en la lista FAMILIES, en hooks/models.ts. Cada familia tiene su nombre, glifo, color, nota y versiones, de la más nueva a la más vieja:
{
choice: 'opus',
label: 'Opus',
glyph: '◆',
color: '#FB923C',
note: 'razonamiento profundo',
versions: [
{ version: '5.5', model: 'claude-opus-5-5', price: [4, 20], cacheRead: 0.2 },
{ version: '5', model: 'claude-opus-5', price: [5, 25] },
],
},
version es lo que se muestra; model es el id exacto de la API.price es [entrada, salida] en dólares por millón de tokens; cacheRead, opcional, el precio de leer cache.choice nuevo en minúsculas: ese es el nombre que acepta /modelo.Con la opción B o C los cambios se recargan solos al guardar. Antes de compartirlos, corré claude plugin validate . y claude plugin test ..
El mod se actualiza solo. Una vez por día, al iniciar una sesión, lee la versión publicada en este repositorio. Si es más nueva que la instalada, la instala según cómo esté instalado:
| Instalación | Qué corre |
|---|---|
| Como plugin (opción A) | claude plugin marketplace update model-picker y claude plugin update model-picker@model-picker |
| Clonado (opción B) | git pull --ff-only en la carpeta del mod |
Otra (por ejemplo --plugin-dir sin git) | Nada: solo avisa que hay versión nueva |
Al terminar muestra model-picker actualizado a X.Y.Z · /reload-plugins o sesión nueva para aplicar. La sesión en curso sigue con la versión que cargó hasta que corras /reload-plugins o abras otra.
No demora el arranque (corre en segundo plano), sin red no hace nada, y si la instalación falla avisa con el comando para correrlo a mano.
Con esto, cada versión nueva que se publique en este repositorio se instala y corre en tu máquina sin que la revises. Es cómodo, y es también la razón para apagarlo si preferís leer los cambios antes: están en CHANGELOG.md.
Dentro de una sesión: /config → Actualización automática (model-picker.autoUpdate) → desactivar.
Apagado, se actualiza a mano:
# opción A
claude plugin marketplace update model-picker
claude plugin update model-picker@model-picker
# opción B
git -C ~/.claude/skills/model-picker pull
La actualización automática existe desde la versión 0.3.0: desde una versión anterior hay que actualizar a mano una vez.
Opción A:
claude plugin uninstall model-picker@model-picker
claude plugin marketplace remove model-picker
Opción B: borrá la carpeta ~/.claude/skills/model-picker.
No veo la banda.
[-] / [+] a la derecha, arriba del prompt, o usá ctrl+x ctrl+a.claude plugin list y mirá el estado de model-picker./modelo: si responde, el mod cargó y el problema es solo de dibujo.claude plugin list dice "the name "model-picker" is already taken". Está instalado por dos caminos (plugin y carpeta en ~/.claude/skills/). Dejá uno solo: desinstalá el plugin o borrá la carpeta.
Elegí un modelo y /model sigue mostrando el anterior. Es lo esperado: el mod no cambia el modelo de la sesión. Mirá el dato respondió … de la fila de datos.
respondió … muestra un modelo distinto al elegido. El cambio no se aplicó. Confirmá que tu cuenta tiene acceso a ese modelo y que el id en FAMILIES es correcto; si sigue pasando, abrí un issue con tu versión de Claude Code.
No aparece la fila de datos. Se oculta mientras el dropdown está abierto. Antes de la primera respuesta de la sesión solo muestra un aviso.
El costo no coincide con mi factura. Es una estimación a precio de lista. Ver Limitaciones.
Los colores se ven raros. Los colores son hexadecimales y dependen del soporte de color de tu terminal. Podés cambiarlos en FAMILIES.
Veo una línea gris que empieza con model-picker:. Es Claude Code avisando que un hook del mod falló. Copiá esa línea en un issue.
git clone https://github.com/juampymdd/claude-code-model-picker.git
cd claude-code-model-picker
claude plugin validate . # revisa el manifiesto y los hooks
claude plugin test . # corre tests/*.test.ts(x)
claude --plugin-dir . # abre una sesión con el mod cargado desde esta carpeta
Estructura:
.claude-plugin/
plugin.json manifiesto del mod
marketplace.json hace que este repo sea instalable como marketplace
hooks/
hooks.json apunta al módulo de hooks
register.tsx los hooks y el dibujo de la banda
models.ts familias, versiones, ids y precios
stats.ts costo, tokens y la fila de datos
update.ts la actualización automática
types/
index.d.ts contrato del estado que guarda el mod
tests/
picker.test.tsx la banda, el dropdown y el comando
stats.test.ts costos y formato
update.test.ts la actualización automática
Al cargar el mod, Claude Code escribe los tipos de su API en .claude-plugin/types/ (ignorada por git); con eso tsc -p . chequea los tipos.
Issues y pull requests son bienvenidos.
MIT © 2026 Juan Pablo Maddoni
hooks/register.tsx 207 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { FAMILIES, latest, pickOfId, pickOfName, pickOfText } from './models'
5import type { Pick } from './models'
6import { priceLabel, statsLine, tally } from './stats'
7import { selfUpdate } from './update'
8import type { Host } from './update'
9
10const COMMAND = 'modelo'
11const NAMES = ['auto', ...FAMILIES.map(f => f.choice)].join(' | ')
12const INK = '#18181B'
13// What the band draws before its first chip.
14const HEAD = '▌ MODELO '
15
16// Below these widths the band drops the note, then the unpicked names.
17const NOTE_COLUMNS = 96
18const NAME_COLUMNS = 70
19
20const WAITING = 'costo, tokens y cache: tras la próxima respuesta'
21
22const choice = atom({ plugin: 'model-picker', key: 'choice' } as const, 'auto')
23// The family whose version dropdown is open; '' while none is.
24const open = atom({ plugin: 'model-picker', key: 'open' } as const, '')
25// What the API reported of the session's responses; null before the first.
26const stats = atom({ plugin: 'model-picker', key: 'stats' } as const, null)
27
28// The session's own model as `/model` names it (`Opus 5.5`, `claude-opus-5-5`).
29const pickOfSession = async ($: EngineInterface): Promise<Pick | undefined> => {
30 try {
31 return pickOfName(await $.session.model())
32 } catch {
33 return undefined
34 }
35}
36
37// The main loop's requests name the picked model (`turn.step` below), which
38// is instant and touches neither `/model` nor the saved default. Picking the
39// session's own model drops the override.
40const switchTo = async ($: EngineInterface, target: Pick): Promise<void> => {
41 const own = await pickOfSession($)
42 const isOwn = own?.family === target.family && own.version.version === target.version.version
43
44 await update($, choice, () => (isOwn ? 'auto' : target.version.model))
45 await update($, open, () => '')
46 $.ui.invalidate('ui.render')
47}
48
49// The host as the self-update reaches it.
50const hostOf = ($: EngineInterface): Host => ({
51 name: $.plugin.name,
52 root: $.plugin.root,
53 now: () => $.clock.now(),
54 get: key => $.store.get(key),
55 set: (key, value) => $.store.set(key, value),
56 read: path => $.fs.read(path),
57 exists: path => $.fs.exists(path),
58 fetch: url => $.http.fetch(url),
59 run: (argv, init) => $.process.run(argv, init),
60 toast: text => $.ui.toast(text),
61})
62
63export const register: Register = (on, options) => {
64 on('session.start', async ($, e, next) => {
65 await $.command.register({
66 name: COMMAND,
67 description: `Elige el modelo de las próximas requests (${NAMES}) y su versión: /${COMMAND} opus 4.8`,
68 })
69
70 // Left running: the session does not wait on the network to start.
71 if (options.autoUpdate !== false) void selfUpdate(hostOf($))
72
73 return next(e)
74 })
75
76 on('command.run', { command: COMMAND }, async ($, e) => {
77 const text = e.args.trim().toLowerCase()
78
79 if (text === '') {
80 const own = await pickOfSession($)
81
82 return { text: `Modelo: ${own ? `${own.family.label} ${own.version.version}` : 'desconocido'}. Uso: /${COMMAND} ${NAMES} [versión]` }
83 }
84
85 if (text === 'auto') {
86 await update($, choice, () => 'auto')
87 $.ui.invalidate('ui.render')
88
89 return { text: 'Modelo: el de la sesión' }
90 }
91
92 const target = pickOfText(text)
93 if (target === undefined) return { text: `Modelo desconocido. Uso: /${COMMAND} ${NAMES} [versión]` }
94
95 await switchTo($, target)
96
97 return { text: `Modelo: ${target.family.label} ${target.version.version}` }
98 })
99
100 // The main loop's requests name the picked model; a subagent keeps its own.
101 // What the API reports of every response is tallied for the band.
102 on('turn.step', async function* ($, e, next) {
103 const isMain = e.agentId === undefined
104 const override = isMain ? pickOfId(await read($, choice)) : undefined
105 const response = yield* next(override === undefined ? e : { ...e, model: override.version.model })
106
107 const usage = response.usage
108 if (usage !== null) await update($, stats, was => tally(was, e, usage, isMain))
109
110 return response
111 })
112
113 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114 if (e.props.hasSurvey) return next(e)
115
116 const { Box, Button, Text } = $.ui.resolve(e)
117 const own = await pickOfSession($)
118 const override = pickOfId(await read($, choice))
119 const picked = override ?? own
120 const shown = await read($, open)
121 const columns = e.props.bodyColumns
122 const hasNames = columns >= NAME_COLUMNS
123 const opened = FAMILIES.find(f => f.choice === shown)
124 const seen = await read($, stats)
125 // Before the session's first response there is nothing to report yet.
126 const line = seen === null ? WAITING : statsLine(seen, override !== undefined, columns - 2)
127
128 const chips = FAMILIES.map(family => {
129 const isActive = picked?.family === family
130 const version = isActive ? picked.version.version : latest(family).version.version
131 const text = isActive
132 ? ` ${family.glyph} ${family.label} ${version} ${family === opened ? '▴' : '▾'} `
133 : ` ${family.glyph}${hasNames ? ` ${family.label} ${version}` : ''} `
134
135 return { family, isActive, text }
136 })
137
138 // The dropdown hangs under its family's chip: the cells drawn before it.
139 const at = chips.findIndex(chip => chip.family === opened)
140 const indent = HEAD.length + chips.slice(0, at).reduce((sum, chip) => sum + [...chip.text].length, 0)
141
142 return (
143 <Box flexDirection="column">
144 <Box>
145 <Text color={picked?.family.color} bold>
146 ▌
147 </Text>
148 <Text dimColor> MODELO </Text>
149 {chips.map(({ family, isActive, text }) => (
150 <Button
151 key={family.choice}
152 plain
153 dimColor={!isActive}
154 // The active chip opens its versions; another switches to its newest.
155 onPress={() =>
156 isActive
157 ? update($, open, now => (now === family.choice ? '' : family.choice))
158 : switchTo($, latest(family))
159 }
160 >
161 {isActive ? (
162 <Text backgroundColor={family.color} color={INK} bold>
163 {text}
164 </Text>
165 ) : (
166 <Text color={family.color}>{text}</Text>
167 )}
168 </Button>
169 ))}
170 {columns >= NOTE_COLUMNS && picked !== undefined && (
171 <Text color={picked.family.color} italic>
172 {' '}
173 {picked.family.note}
174 </Text>
175 )}
176 </Box>
177 {opened?.versions.map(version => {
178 const isCurrent = picked?.family === opened && picked.version.version === version.version
179
180 return (
181 <Box marginLeft={indent}>
182 <Button
183 key={`${opened.choice}-${version.version}`}
184 plain
185 dimColor={!isCurrent}
186 onPress={() => switchTo($, { family: opened, version })}
187 >
188 <Text color={opened.color} bold={isCurrent}>
189 {` ${isCurrent ? '●' : '○'} ${opened.label} ${version.version} `}
190 </Text>
191 </Button>
192 <Text dimColor>{priceLabel(version.price)} por MTok</Text>
193 </Box>
194 )
195 })}
196 {opened === undefined && line !== '' && (
197 <Box marginLeft={2}>
198 <Text dimColor wrap="truncate-end">
199 {line}
200 </Text>
201 </Box>
202 )}
203 </Box>
204 )
205 })
206}
207hooks/models.ts 128 lines1export type Version = {
2 version: string
3 // The Claude API's id of the model.
4 model: string
5 // List price in dollars per million tokens: input, output.
6 price: readonly [input: number, output: number]
7 // Dollars per million cached input tokens read, where the list price names it.
8 cacheRead?: number
9}
10
11export type Family = {
12 choice: string
13 label: string
14 glyph: string
15 color: string
16 note: string
17 // Newest first: a family's chip shows the first, its dropdown lists them all.
18 versions: readonly Version[]
19}
20
21export type Pick = { family: Family; version: Version }
22
23// The versions the Claude API serves, by its own model ids, at its list prices.
24export const FAMILIES: readonly Family[] = [
25 {
26 choice: 'fable',
27 label: 'Fable',
28 glyph: '✦',
29 color: '#C084FC',
30 note: 'el más capaz',
31 versions: [
32 { version: '5.1', model: 'claude-fable-5-1', price: [10, 50], cacheRead: 0.25 },
33 { version: '5', model: 'claude-fable-5', price: [10, 50] },
34 ],
35 },
36 {
37 choice: 'opus',
38 label: 'Opus',
39 glyph: '◆',
40 color: '#FB923C',
41 note: 'razonamiento profundo',
42 versions: [
43 { version: '5.5', model: 'claude-opus-5-5', price: [4, 20], cacheRead: 0.2 },
44 { version: '5', model: 'claude-opus-5', price: [5, 25] },
45 { version: '4.8', model: 'claude-opus-4-8', price: [5, 25] },
46 { version: '4.7', model: 'claude-opus-4-7', price: [5, 25] },
47 { version: '4.6', model: 'claude-opus-4-6', price: [5, 25] },
48 ],
49 },
50 {
51 choice: 'sonnet',
52 label: 'Sonnet',
53 glyph: '▲',
54 color: '#38BDF8',
55 note: 'equilibrado',
56 versions: [
57 { version: '5.5', model: 'claude-sonnet-5-5', price: [2, 10], cacheRead: 0.2 },
58 { version: '5', model: 'claude-sonnet-5', price: [2, 10] },
59 { version: '4.6', model: 'claude-sonnet-4-6', price: [3, 15] },
60 ],
61 },
62 {
63 choice: 'haiku',
64 label: 'Haiku',
65 glyph: '●',
66 color: '#34D399',
67 note: 'rápido y barato',
68 versions: [
69 { version: '5.5', model: 'claude-haiku-5-5', price: [0.1, 0.5] },
70 { version: '4.5', model: 'claude-haiku-4-5', price: [1, 5] },
71 ],
72 },
73]
74
75export const latest = (family: Family): Pick => ({ family, version: family.versions[0] as Version })
76
77/**
78 * The family and version of a model id: the id itself, else the longest listed
79 * id it starts with (an id the API reports with a suffix).
80 */
81export const pickOfId = (id: string): Pick | undefined => {
82 let found: Pick | undefined
83
84 for (const family of FAMILIES) {
85 for (const version of family.versions) {
86 if (version.model === id) return { family, version }
87
88 const isLonger = found === undefined || version.model.length > found.version.model.length
89 if (id.startsWith(`${version.model}-`) && isLonger) found = { family, version }
90 }
91 }
92
93 return found
94}
95
96/** `opus`, `opus 4.8`: a family and, when written, one of its versions. */
97export const pickOfText = (text: string): Pick | undefined => {
98 const [name = '', written] = text.trim().toLowerCase().split(/\s+/)
99 const family = FAMILIES.find(f => f.choice === name)
100 if (family === undefined) return undefined
101
102 const version = family.versions.find(v => v.version === written)
103
104 return written === undefined ? latest(family) : version && { family, version }
105}
106
107/** The family and version a model name holds (`Opus 5.5`, `claude-opus-5-5`). */
108export const pickOfName = (name: string): Pick | undefined => {
109 const lower = name.toLowerCase()
110 const family = FAMILIES.find(f => lower.includes(f.choice))
111 if (family === undefined) return undefined
112
113 const written = /(\d+)(?:[.-](\d{1,2})(?!\d))?/.exec(lower)
114 const text = written === null ? undefined : [written[1], written[2]].filter(Boolean).join('.')
115 const known = family.versions.find(v => v.version === text)
116 const newest = latest(family).version
117
118 // A version the list lacks is still named as written, at no known price.
119 return { family, version: known ?? { ...newest, version: text ?? newest.version, model: '' } }
120}
121
122/** A model id as the band names it (`claude-opus-5-5` -> `Opus 5.5`). */
123export const nameOfId = (id: string): string => {
124 const known = pickOfId(id)
125
126 return known === undefined ? id : `${known.family.label} ${known.version.version}`
127}
128hooks/stats.ts 113 lines1import type { Stats } from '../types'
2import { nameOfId, pickOfId } from './models'
3
4// What a response reports of itself: the API's token counts and model id.
5export type Usage = {
6 model: string
7 input_tokens: number
8 output_tokens: number
9 cache_read_input_tokens: number
10 cache_creation_input_tokens: number
11}
12
13const MILLION = 1_000_000
14// Where a list price names no cache rate: a read at a tenth of the input
15// price, a write at a quarter over it.
16const CACHE_READ = 0.1
17const CACHE_WRITE = 1.25
18
19const EMPTY: Stats = {
20 answeredBy: '',
21 effort: '',
22 turnId: '',
23 input: 0,
24 output: 0,
25 cacheRead: 0,
26 turnCost: 0,
27 sessionCost: 0,
28}
29
30/** A response's cost in dollars at list price; 0 for a model with none listed. */
31export const costOf = (usage: Usage): number => {
32 const version = pickOfId(usage.model)?.version
33 if (version === undefined) return 0
34
35 const [input, output] = version.price
36 const cacheRead = version.cacheRead ?? input * CACHE_READ
37
38 return (
39 (usage.input_tokens * input +
40 usage.output_tokens * output +
41 usage.cache_read_input_tokens * cacheRead +
42 usage.cache_creation_input_tokens * input * CACHE_WRITE) /
43 MILLION
44 )
45}
46
47/**
48 * The stats after one more response. Every loop's response adds to the
49 * session's cost; the main loop's also becomes the latest one shown, and adds
50 * to its turn's cost.
51 */
52export const tally = (
53 was: Stats | null,
54 step: { turnId: string; effort?: string | number },
55 usage: Usage,
56 isMain: boolean,
57): Stats => {
58 const before = was ?? EMPTY
59 const cost = costOf(usage)
60 const sessionCost = before.sessionCost + cost
61
62 if (!isMain) return { ...before, sessionCost }
63
64 return {
65 answeredBy: usage.model,
66 effort: step.effort === undefined ? '' : String(step.effort),
67 turnId: step.turnId,
68 input: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
69 output: usage.output_tokens,
70 cacheRead: usage.cache_read_input_tokens,
71 turnCost: (before.turnId === step.turnId ? before.turnCost : 0) + cost,
72 sessionCost,
73 }
74}
75
76export const money = (dollars: number): string => `$${dollars.toFixed(dollars < 0.1 ? 3 : 2)}`
77
78export const count = (tokens: number): string => {
79 if (tokens >= MILLION) return `${(tokens / MILLION).toFixed(1)}M`
80 if (tokens >= 1000) return `${(tokens / 1000).toFixed(tokens < 10_000 ? 1 : 0)}k`
81
82 return String(tokens)
83}
84
85/** A version's list price as its dropdown row shows it (`$4/$20`). */
86export const priceLabel = ([input, output]: readonly [number, number]): string => {
87 const one = (n: number) => `$${Number.isInteger(n) ? n : n.toFixed(2)}`
88
89 return `${one(input)}/${one(output)}`
90}
91
92/**
93 * The stats row's parts, the most useful first, as many as fit `columns`
94 * joined by ` · `; '' when there is nothing to show.
95 */
96export const statsLine = (stats: Stats | null, isManual: boolean, columns: number): string => {
97 const parts: string[] = []
98
99 if (stats !== null) {
100 if (stats.answeredBy !== '') parts.push(`respondió ${nameOfId(stats.answeredBy)}`)
101 if (stats.sessionCost > 0) parts.push(`sesión ~${money(stats.sessionCost)}`)
102 if (stats.turnCost > 0) parts.push(`turno ~${money(stats.turnCost)}`)
103 if (stats.effort !== '') parts.push(`effort ${stats.effort}`)
104 if (stats.answeredBy !== '') parts.push(`${count(stats.input)}→${count(stats.output)} tok`)
105 if (stats.input > 0) parts.push(`cache ${Math.round((stats.cacheRead / stats.input) * 100)}%`)
106 }
107 if (isManual) parts.push('manual')
108
109 while (parts.length > 1 && [...parts.join(' · ')].length > columns) parts.pop()
110
111 return parts.join(' · ')
112}
113hooks/update.ts 109 lines1// The mod updating itself: once a day it reads the version published on the
2// repository's main branch and, when that is newer than the one loaded,
3// brings the new one in the way this copy was installed.
4
5const REPO = 'juampymdd/claude-code-model-picker'
6const MARKETPLACE = 'model-picker'
7const MANIFEST = '.claude-plugin/plugin.json'
8const PUBLISHED = `https://raw.githubusercontent.com/${REPO}/main/${MANIFEST}`
9const DAY_MS = 86_400_000
10const UPDATE_TIMEOUT_MS = 120_000
11const CHECKED_AT = 'update.checkedAt'
12
13// What the update needs of the host, so a test can stand in for each part.
14export type Host = {
15 // The mod's own name and folder.
16 name: string
17 root: string
18 now: () => Promise<number>
19 get: (key: string) => Promise<unknown>
20 set: (key: string, value: unknown) => Promise<void>
21 read: (path: string) => Promise<string>
22 exists: (path: string) => Promise<boolean>
23 fetch: (url: string) => Promise<{ ok: boolean; text: string }>
24 run: (argv: readonly string[], init: { timeoutMs: number }) => Promise<{ exitCode: number }>
25 toast: (text: string) => void
26}
27
28export type Outcome = 'throttled' | 'unreachable' | 'current' | 'updated' | 'failed' | 'manual'
29
30/** The `version` of a plugin.json's text when it is `1.2.3`-shaped, else undefined. */
31export const versionIn = (manifest: string): string | undefined => {
32 try {
33 const version: unknown = (JSON.parse(manifest) as { version?: unknown }).version
34
35 return typeof version === 'string' && /^\d+\.\d+\.\d+$/.test(version) ? version : undefined
36 } catch {
37 return undefined
38 }
39}
40
41export const isNewer = (published: string, loaded: string): boolean => {
42 const a = published.split('.').map(Number)
43 const b = loaded.split('.').map(Number)
44
45 for (let i = 0; i < 3; i += 1) {
46 if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0)
47 }
48
49 return false
50}
51
52/**
53 * The commands that bring the published version into this copy, by how it was
54 * installed; undefined for a copy neither Claude Code nor git keeps.
55 */
56export const stepsFor = async (host: Host): Promise<readonly (readonly string[])[] | undefined> => {
57 if (host.root.replace(/\\/g, '/').includes('/plugins/cache/')) {
58 return [
59 ['claude', 'plugin', 'marketplace', 'update', MARKETPLACE],
60 ['claude', 'plugin', 'update', `${host.name}@${MARKETPLACE}`],
61 ]
62 }
63
64 if (await host.exists(`${host.root}/.git`)) return [['git', '-C', host.root, 'pull', '--ff-only']]
65
66 return undefined
67}
68
69/**
70 * Checks for a newer published version, at most once a day, and installs it.
71 * Never rejects: offline, or with anything unexpected, it leaves the mod as is.
72 */
73export const selfUpdate = async (host: Host): Promise<Outcome> => {
74 try {
75 const now = await host.now()
76 if (now - Number((await host.get(CHECKED_AT)) ?? 0) < DAY_MS) return 'throttled'
77
78 const response = await host.fetch(PUBLISHED)
79 const published = response.ok ? versionIn(response.text) : undefined
80 const loaded = versionIn(await host.read(`${host.root}/${MANIFEST}`))
81 if (published === undefined || loaded === undefined) return 'unreachable'
82
83 await host.set(CHECKED_AT, now)
84 if (!isNewer(published, loaded)) return 'current'
85
86 const steps = await stepsFor(host)
87 if (steps === undefined) {
88 host.toast(`${host.name} ${published} disponible: github.com/${REPO}`)
89
90 return 'manual'
91 }
92
93 for (const argv of steps) {
94 const { exitCode } = await host.run(argv, { timeoutMs: UPDATE_TIMEOUT_MS })
95 if (exitCode !== 0) {
96 host.toast(`${host.name} ${published} disponible, no se pudo instalar solo: ${argv.join(' ')}`)
97
98 return 'failed'
99 }
100 }
101
102 host.toast(`${host.name} actualizado a ${published} · /reload-plugins o sesión nueva para aplicar`)
103
104 return 'updated'
105 } catch {
106 return 'unreachable'
107 }
108}
109types/index.d.ts 28 lines1export type Choice = string
2
3// What the band shows of the session's requests, from what the API reported.
4export type Stats = {
5 // The model id of the main loop's latest response; '' before the first.
6 answeredBy: string
7 // The effort that request asked for; '' when it named none.
8 effort: string
9 // The turn the latest response belongs to.
10 turnId: string
11 // The latest response's prompt tokens (cached ones included) and output tokens.
12 input: number
13 output: number
14 // How many of those prompt tokens the cache served.
15 cacheRead: number
16 // Dollars at list price: that turn's responses, and every response so far.
17 turnCost: number
18 sessionCost: number
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 // `choice`: 'auto' follows the session's model; else the id the main loop's
24 // requests name. `open`: the family whose versions are listed.
25 'model-picker': { choice: Choice; open: string; stats: Stats | null }
26 }
27}
28