SLOPSHOPPER

model-picker

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

newbandcommandtoastprocessnetwork
v0.3.0MITupdated 2026-10-08juampymdd/claude-code-model-picker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-picker
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /modelo ⎿ model-picker: Modelo: Opus 5.5. Uso: /modelo auto | fable | opus | sonnet | haiku [versión] ▌ MODELO fableopussonnethaiku razonamiento profundo costo, tokens y cache: tras la próxima respuesta ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▌ MODELO fableopussonnethaiku razonamiento profundo costo, tokens y cache: tras la próxima respuesta
README

model-picker

test license: MIT

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.

🇬🇧 Read this in English

▌ 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

Contenido

Qué hace

  • Un chip por familia (Fable, Opus, Sonnet, Haiku), cada uno con su color. El activo va relleno.
  • Dropdown de versiones con precio: un clic en el chip activo lista todas las versiones de esa familia y su precio de lista.
  • Arranca en tu modelo real: al abrir una sesión marca el modelo que la sesión ya usa.
  • Cambio instantáneo: la elección se aplica a la request siguiente.
  • Fila de datos: modelo que respondió, costo estimado de la sesión y del turno, effort, tokens y porcentaje de cache.
  • Comando /modelo para hacer lo mismo desde el teclado.
  • Se actualiza solo: una vez por día busca una versión nueva y la instala. Se puede apagar.

Requisitos

  • Claude Code 2.1.295 o más nuevo, con soporte de mods (hooks de función). Verificá tu versión con claude --version.
  • Una terminal con color de 24 bits para ver los colores tal cual (Windows Terminal, iTerm2, la terminal de VS Code, etc.).
  • La banda se dibuja en la terminal y en la app de escritorio. Está probada en la terminal.

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.

Instalación

Elegí una sola de las opciones. Instalar por dos caminos a la vez hace que una copia no cargue (ver Problemas frecuentes).

Opción A: como plugin (recomendada)

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.

Opción B: clonando el repositorio

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.

Opción C: probarlo sin instalar

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.

Comprobar que quedó instalado

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.

Uso

Con el mouse

AcciónResultado
Clic en un chip de otra familiaCambia 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 listaCambia 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ónQuita la elección y vuelve a seguir a la sesión

Con el comando

ComandoResultado
/modeloMuestra el modelo actual y la ayuda
/modelo sonnetCambia a la versión más nueva de Sonnet
/modelo opus 4.8Cambia a esa versión puntual
/modelo autoVuelve al modelo de la sesión

Familias válidas: fable, opus, sonnet, haiku.

La fila de datos

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
DatoQué es
respondió Opus 5.5El modelo que la API dice que respondió la última request. Sirve para comprobar que el cambio se aplicó
sesión ~$0.42Costo estimado de todas las requests de la sesión, subagentes incluidos
turno ~$0.031Costo estimado del turno actual (tu último mensaje y todas las llamadas que disparó)
effort mediumNivel de razonamiento con el que salió la última request
48k→1.2k tokTokens 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
manualHay 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.

Modelos y precios

Precios de lista de la API de Anthropic, en dólares por millón de tokens (MTok), al 6 de octubre de 2026.

ModeloEntradaSalida
✦ 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.

Cómo funciona

El mod registra cuatro hooks:

HookPara qué
session.startRegistra el comando /modelo
command.runResponde a /modelo
turn.stepAntes de cada request del loop principal, pone el modelo elegido; después, suma lo que la API informó de la respuesta
ui.renderDibuja 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.

Limitaciones

  • /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ó ….
  • La elección dura la sesión. Cada sesión nueva arranca siguiendo el modelo de la sesión.
  • Solo el loop principal. Los subagentes conservan su propio modelo (su costo sí se suma al de la sesión).
  • El costo es estimado.
  • Usa precios de lista escritos en el código; no conoce descuentos, suscripciones ni cambios de precio.
  • La lectura de cache se calcula con la tarifa publicada del modelo o, si no hay, al 10% del precio de entrada; la escritura de cache, al 125%.
  • Haiku 5.5 se calcula siempre a su tarifa base, aunque los prompts de más de 100K tokens se cobran más caro.
  • Cuenta solo las requests que pasaron por el mod desde que cargó, y una respuesta de un modelo que no está en la lista suma $0.
  • Los modelos y precios están escritos en el código. Cuando sale un modelo nuevo hay que agregarlo (ver abajo).
  • Cambiar de modelo a mitad de una conversación cuesta más en esa request: la cache de la conversación es por modelo, así que el modelo nuevo relee todo el historial.
  • En terminales angostas la banda se compacta: con menos de 96 columnas oculta la nota de la derecha, con menos de 70 deja solo el glifo de los modelos no elegidos, y la fila de datos descarta sus últimos datos.
  • La interfaz está en español.

Agregar o cambiar modelos

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.
  • La primera versión de la lista es la que se elige al hacer clic en la familia.
  • Para sumar una familia, agregá otro bloque con un 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 ..

Actualizar

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ónQué 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.

Qué implica

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.

Apagarlo

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.

Desinstalar

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.

Problemas frecuentes

No veo la banda.

  • Abrí una sesión nueva: los mods instalados cargan al iniciar.
  • Puede estar colapsada: buscá [-] / [+] a la derecha, arriba del prompt, o usá ctrl+x ctrl+a.
  • Corré claude plugin list y mirá el estado de model-picker.
  • Probá /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.

Desarrollo

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.

Licencia

MIT © 2026 Juan Pablo Maddoni

Source 5 files
hooks/register.tsx 207 lines
1import { 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}
207
hooks/models.ts 128 lines
1export 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}
128
hooks/stats.ts 113 lines
1import 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}
113
hooks/update.ts 109 lines
1// 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}
109
types/index.d.ts 28 lines
1export 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