SLOPSHOPPER

kiro

Recupera los cortes de Kiro, relanza kiro-gateway y añade /kiro.

newcommandtoastprocessnetwork
★ 1v0.1.0AGPL-3.0updated 2026-10-06marr-cloud/kiro-gateway-go/claude-mod
A shopper browsing a rack in a slop shop
README

👻 kiro-gateway-go

Español • 🇬🇧 English

Licencia: AGPL v3 Go 1.27 Port de

Port a Go de kiro-gateway como binario único, sin dependencias de runtime.

Un proxy local que expone las APIs de OpenAI y Anthropic y traduce las peticiones a la API de Kiro (Amazon Q Developer / AWS CodeWhisperer), de forma que cualquier cliente compatible con esas APIs —Claude Code, Cursor, Cline, Roo Code, el SDK de OpenAI, LangChain, Continue, etc.— pueda usar los modelos de Kiro.

Objetivo del port: paridad byte a byte con el original en las fronteras de red (los bytes SSE/JSON que ven los clientes). Los conversores, el tokenizer, el streaming y los formatters se validan contra un corpus golden grabado del upstream fijado (commit a5292ca, v2.4.dev.13). Ver docs/CORPUS.md y docs/DIFFERENCES.md.

Índice


Modelos

La disponibilidad de modelos depende de tu plan de Kiro (gratuito o de pago): el gateway da acceso a los modelos disponibles en tu IDE o CLI según tu suscripción. Consulta la lista real en tiempo de ejecución con GET /v1/models.

Modelos habituales en el plan gratuito: Claude Sonnet 4.5, Claude Haiku 4.5, Claude Sonnet 4, y varios modelos abiertos (GLM, DeepSeek, MiniMax, Qwen).

💡 Resolución flexible de nombres: puedes usar cualquier formato —claude-sonnet-4-5, claude-sonnet-4.5 o nombres versionados como claude-sonnet-4-5-20250929—; el gateway los normaliza automáticamente.


Características

CaracterísticaDescripción
🔌 API compatible con OpenAI/v1/chat/completions para cualquier herramienta compatible
🔌 API compatible con AnthropicEndpoint nativo /v1/messages
🔀 Multi-cuenta con failoverConmutación automática entre varias cuentas de Kiro
🌐 Soporte VPN/proxyProxy HTTP/SOCKS5 para redes restringidas
🧠 Extended thinkingRazonamiento (reasoning) en respuestas
👁️ VisiónEnvío de imágenes al modelo
🔍 Búsqueda webHerramienta web_search vía MCP de Kiro
🛠️ Tool callingFunction calling en ambos dialectos
💬 Historial completoSe pasa todo el contexto de la conversación
📡 StreamingSSE completo en ambos dialectos
🔄 ReintentosReintentos automáticos ante errores (403, 429, 5xx)
🔐 Gestión de tokensRefresco automático antes de expirar
🧮 Conteo de tokens fieltiktoken cl100k_base embebido; /v1/messages/count_tokens

Inicio rápido

Hace falta la sesión iniciada en una de estas fuentes de credenciales de Kiro: Kiro CLI (AWS SSO: Builder ID gratuito o cuenta corporativa) o Kiro IDE.

Windows + Claude Code, en una línea

Con PowerShell 7 (winget install Microsoft.PowerShell) y Claude Code instalados:

irm https://raw.githubusercontent.com/marr-cloud/kiro-gateway-go/main/scripts/get.ps1 | iex
kclaude   # en una terminal nueva de pwsh

scripts/get.ps1 baja el zip de Windows de la última release, comprueba su SHA256, lo deja en %LOCALAPPDATA%\Programs\kiro-gateway y ejecuta scripts/install.ps1, que:

  • crea .env con una PROXY_API_KEY aleatoria (no se muestra);
  • crea credentials.json con la cuenta que encuentre (kiro-cli; si no, Kiro IDE);
  • añade a tu $PROFILE una función kclaude entre dos marcas # >>> kiro-gateway >>>, o la actualiza (si ya tienes un kclaude propio, no lo toca).

Nunca pisa un .env ni un credentials.json existentes. Volver a ejecutar la línea actualiza a la última release y conserva tu configuración.

Desde un clon es igual, compilando con Go 1.27+:

git clone https://github.com/marr-cloud/kiro-gateway-go.git
pwsh kiro-gateway-go/scripts/install.ps1   # -Build recompila tras un git pull
kclaude

Después, ver Con Claude Code.

Compilar y ejecutar

Para otras plataformas o para usar el gateway sin Claude Code. Requiere Go 1.27+ (solo para compilar; el binario resultante no necesita nada instalado).

# Clona el repositorio
git clone https://github.com/marr-cloud/kiro-gateway-go.git
cd kiro-gateway-go

# Compila el binario (usa Taskfile; equivale a `go build ./cmd/kiro-gateway`)
task build
# o directamente:
go build -o kiro-gateway ./cmd/kiro-gateway

# Configura las credenciales: un credentials.json con tus cuentas y un .env con
# PROXY_API_KEY (ver sección Configuración)

# Arranca el servidor
./kiro-gateway

# Con host/puerto personalizados (si el 8000 está ocupado)
./kiro-gateway --port 9000

El servidor queda disponible en http://localhost:8000.

Con Docker

# Prepara credentials.json + un .env con PROXY_API_KEY (ver Configuración)
docker compose up -d
docker compose logs -f
curl http://localhost:8000/health

La imagen es multi-stage sobre distroless/static:nonroot (~27 MB, sin shell, usuario no-root) y su healthcheck usa el propio binario (--health). Ver Dockerfile y docker-compose.yml.

También se publica una imagen multi-arch (linux amd64/arm64) en cada release:

docker pull ghcr.io/marr-cloud/kiro-gateway-go:latest

Binarios de release para las cinco plataformas (Windows/Linux/macOS × amd64/arm64), con SHA256SUMS, están disponibles en la página de Releases. Descarga el binario de tu plataforma en lugar de compilar, si lo prefieres.

Con Claude Code (kclaude y /kiro)

En Windows, scripts/kiro-claude.ps1 arranca el gateway si no está corriendo y abre Claude Code apuntando a él, con el mod claude-mod/ cargado. La key sale de PROXY_API_KEY en el .env y solo vive en el entorno de ese proceso. Hace falta PowerShell 7 (pwsh): el mod lo usa para lanzar scripts/kiro-gateway.ps1. kclaude es la función que deja scripts/install.ps1 en tu $PROFILE; acepta los mismos argumentos que claude (kclaude -p "hola", kclaude --continue).

Cuando un modelo corta la respuesta, el mod reintenta con el modelo de respaldo que declara Kiro (por ejemplo claude-sonnet-5.5 → claude-sonnet-5) una vez por turno, y el resto del turno sigue con el respaldo. Un corte cuesta 2 peticiones a Kiro si el respaldo responde, o 3 si también corta; sin el mod, Claude Code ya hace 2 por su cuenta. El mod también relanza el gateway si se cayó y añade /kiro:

ComandoHace
/kiroVersión, uptime, cuenta activa, debug y modelo de la sesión
/kiro restartReinicia el gateway conservando su DEBUG_MODE
/kiro logs [n]Últimas n líneas (20) de gateway.log y gateway.err.log
`/kiro debug all\errors\off`Reinicia con ese DEBUG_MODE y muestra la carpeta de debug
/kiro models [id]Tabla de modelos (thinking nativo, effort, respaldo); con id, cómo cambiar a ese modelo solo en esta sesión (/model y s: /model <id> lo guardaría en tus settings globales)

En auto mode, el modo de permisos por defecto desde Claude Code v2.1.283, Claude Code pide al servidor que revise las acciones. Kiro no hace esa revisión, así que kclaude pone CLAUDE_CODE_AUTO_MODE_SERVER=0 y el clasificador corre desde el cliente, como petición normal al gateway (gasta créditos de Kiro). Si lanzas claude contra el gateway sin kclaude, pon esa variable tú mismo (en la shell o en env de tus settings). Si no, al primer comando que revise el clasificador aparece «this session isn't eligible» y la acción espera a que pulses Enter.

scripts/kiro-gateway.ps1 start|stop|restart|logs hace lo mismo desde la terminal. El gateway que lanza el script no hereda el entorno de la terminal: solo cuentan su .env y el -DebugMode que se le pase.

Flags de línea de comandos

FlagAbreviaturaDescripción
--host-HInterfaz de escucha (por defecto SERVER_HOST o 0.0.0.0)
--port-pPuerto de escucha (por defecto SERVER_PORT o 8000)
--version-vImprime la versión y sale
--help-hImprime la ayuda y sale
--healthConsulta GET /health del servidor en marcha y sale con 0 (sano) o 1

Configuración

Dos piezas: credentials.json define las cuentas de Kiro; el .env guarda la clave del proxy y los ajustes de comportamiento. Protege siempre tu proxy con PROXY_API_KEY: es la clave que usarán los clientes al conectarse.

Cuentas: credentials.json

El gateway carga las cuentas de un array JSON. Por defecto busca credentials.json en el directorio de trabajo; puedes apuntar a otra ruta con ACCOUNTS_CONFIG_FILE. Cada entrada lleva un type (json, sqlite o refresh_token), enabled: true, y la ruta o el token correspondiente. Ver credentials.json.example.

[
  { "type": "json",   "enabled": true, "path": "C:/Users/tu-usuario/.aws/sso/cache/kiro-auth-token.json" },
  { "type": "sqlite", "enabled": true, "path": "C:/Users/tu-usuario/AppData/Local/kiro-cli/data.sqlite3" }
]
  • json — token de Kiro IDE / Enterprise (válido si contiene refreshToken o clientId).
  • sqlite — base de datos de kiro-cli (válida si tiene la tabla auth_kv).
  • refresh_token — un token de refresco directo ("refresh_token": "eyJ...", opcional profile_arn).

Con varias entradas el gateway hace failover automático: cuando una devuelve un error (429, 402) pasa a la siguiente; si una falla varias veces seguidas la aparta y la reintenta periódicamente. Con una sola cuenta no hay conmutación (se devuelve el error real de Kiro).

El port no implementa el modo de "cuenta única" del original (REFRESH_TOKEN / KIRO_CREDS_FILE / KIRO_CLI_DB_FILE sueltos en el .env como fuente de cuenta): las cuentas se definen siempre en credentials.json. Ver docs/DIFFERENCES.md.

Modelos: descubrimiento dinámico y models.json (override opcional)

Por defecto, GET /v1/models descubre los modelos dinámicamente consultando tu cuenta de Kiro (igual que hace el propio Kiro CLI), así que la lista refleja lo que hoy tienes disponible sin tocar nada. Si el descubrimiento falla, cae a una lista estática incorporada.

Para fijar o filtrar la lista, crea un models.json (array JSON de IDs); si existe, es la lista autoritativa y anula el descubrimiento. Apunta a otra ruta con MODELS_CONFIG_FILE. Ver models.json.example.

["auto", "claude-opus-5", "claude-sonnet-5", "claude-opus-4.8", "claude-haiku-4.5"]

No hace falta listar un modelo para usarlo. El gateway es passthrough ("gateway, not gatekeeper"): puedes pedir cualquier model en tus peticiones y se pasa tal cual a Kiro, esté o no en /v1/models. models.json solo controla lo que aparece en el listado.

.env

Copia .env.example a .env y ajústalo. Lo mínimo es PROXY_API_KEY:

# Contraseña para proteger TU proxy (inventa una cadena segura)
PROXY_API_KEY="mi-contraseña-super-secreta-123"

# Opcional: ruta al fichero de cuentas (por defecto: credentials.json en el cwd)
ACCOUNTS_CONFIG_FILE=C:/Users/tu-usuario/kiro/kiro-gateway/credentials.json

VPN / proxy

Para redes restringidas o problemas de conectividad con AWS:

VPN_PROXY_URL=http://127.0.0.1:7890     # HTTP
# VPN_PROXY_URL=socks5://127.0.0.1:1080 # SOCKS5

Otras variables útiles

VariablePor defectoDescripción
ACCOUNTS_CONFIG_FILEcredentials.jsonRuta al array de cuentas
MODELS_CONFIG_FILEmodels.jsonLista opcional de modelos para /v1/models (si el fichero existe)
SERVER_HOST / SERVER_PORT0.0.0.0 / 8000Interfaz y puerto de escucha
KIRO_REGION / KIRO_API_REGIONus-east-1Región de OIDC / de la API de Kiro
WEB_SEARCH_ENABLEDtrueHabilita la herramienta de búsqueda web
TRUNCATION_RECOVERYtrueRecuperación ante respuestas truncadas
DEBUG_MODEoffModo de log de depuración a ficheros (off / errors / all)
LOG_LEVELINFOVerbosidad del log de peticiones en stdout (DEBUG / INFO / WARN / ERROR / OFF)

Referencia de API

EndpointMétodoDescripción
/GETEstado básico (JSON)
/healthGETEstado detallado (JSON)
/v1/modelsGETLista de modelos disponibles
/v1/chat/completionsPOSTAPI Chat Completions de OpenAI
/v1/messagesPOSTAPI Messages de Anthropic
/v1/messages/count_tokensPOSTConteo de tokens (Anthropic)
/kiro/statusGETEstado para el mod de Claude Code: versión, cuenta, debug y modelos con su respaldo por refusal

Autenticación: Authorization: Bearer <PROXY_API_KEY> (dialecto OpenAI) o x-api-key: <PROXY_API_KEY> (dialecto Anthropic).


Ejemplos de uso

OpenAI (cURL)

curl http://localhost:8000/v1/chat/completions \
  -H "Authorization: Bearer mi-contraseña-super-secreta-123" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-sonnet-4-5",
    "messages": [{"role": "user", "content": "¡Hola!"}],
    "stream": true
  }'

Anthropic (cURL)

curl http://localhost:8000/v1/messages \
  -H "x-api-key: mi-contraseña-super-secreta-123" \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-sonnet-4-5",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "¡Hola!"}]
  }'

SDK de OpenAI (Python)

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="mi-contraseña-super-secreta-123",  # tu PROXY_API_KEY
)

response = client.chat.completions.create(
    model="claude-sonnet-4-5",
    messages=[{"role": "user", "content": "¡Hola!"}],
    stream=True,
)
for chunk in response:
    if chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="")

Depuración

El log de depuración está desactivado por defecto. Para activarlo:

# off:    desactivado (por defecto)
# errors: guarda logs solo de peticiones fallidas (4xx, 5xx) — recomendado
# all:    guarda logs de todas las peticiones (se sobrescriben en cada una)
DEBUG_MODE=errors

Los ficheros se escriben en debug_logs/ (request_body.json, kiro_request_body.json, response_stream_raw.txt, response_stream_modified.txt, etc.).


Diferencias con el original

Este port replica el comportamiento del upstream en las fronteras de red, con desviaciones deliberadas y acotadas (p. ej. no expone /docs, /redoc ni /openapi.json de FastAPI; los endpoints / y /health devuelven JSON con campos extra; añade el flag --health). La lista completa y el porqué de cada una está en docs/DIFFERENCES.md.

Documentación

Licencia y atribución

AGPL-3.0. Este proyecto es obra derivada de jwadow/kiro-gateway (v2.4.dev.13, commit a5292ca), distribuido bajo la misma licencia. Ver LICENSE y NOTICE para la atribución completa y la lista de cambios. Si este proyecto te resulta útil, considera apoyar el proyecto original.

Aviso

Este proyecto no está afiliado ni respaldado por AWS, Anthropic ni Kiro IDE. Úsalo bajo tu propia responsabilidad y respetando los términos de servicio de las APIs subyacentes.

Source 2 files
hooks/register.ts 160 lines
1// Mod `kiro`: hace que Claude Code sobre kiro-gateway no se corte.
2//  - Un refusal reintenta con el modelo de respaldo que declara Kiro, una vez por turno;
3//    el resto de ese turno sigue con el respaldo.
4//  - Un gateway caído se relanza con scripts/kiro-gateway.ps1.
5//  - /kiro controla el gateway (estado, restart, logs, debug, models).
6// Fuera de un gateway local (ANTHROPIC_BASE_URL) queda inerte.
7import type { EngineInterface, Register } from 'claude-code'
8import { USAGE, fallbackMap, formatModels, formatStatus, localBase, modelKey, parseArgs, type Status } from './kiro.ts'
9
10// Estado del módulo: un reload vuelve a lanzar session.start y lo rehace.
11// Los helpers viven a nivel de módulo: el cargador de hooks exige que toda función que reciba $ se declare ahí.
12const state = { base: '', port: '', token: '', fallbacks: new Map<string, string>(), rescued: new Map<string, string>() }
13
14const scriptPath = ($: EngineInterface) =>
15  `${$.plugin.root.replace(/[\\/]\.claude-plugin[\\/]?$/, '')}/../scripts/kiro-gateway.ps1`
16
17async function healthy($: EngineInterface): Promise<boolean> {
18  try {
19    return (await $.http.fetch(`${state.base}/health`)).ok
20  } catch {
21    return false
22  }
23}
24
25async function fetchStatus($: EngineInterface): Promise<Status> {
26  const r = await $.http.fetch(`${state.base}/kiro/status`, { headers: { Authorization: `Bearer ${state.token}` } })
27  if (r.status === 401) throw new Error('401: la key de kclaude no coincide con PROXY_API_KEY del .env del gateway.')
28  if (!r.ok) throw new Error(`/kiro/status respondió ${r.status}`)
29  const st = JSON.parse(r.text) as Status
30  state.fallbacks = fallbackMap(st.models)
31  return st
32}
33
34async function runScript($: EngineInterface, args: string[]): Promise<{ ok: boolean; text: string }> {
35  const r = await $.process.run(
36    ['pwsh', '-NoProfile', '-NonInteractive', '-File', scriptPath($), ...args, '-Port', state.port],
37    { timeoutMs: 60_000 },
38  )
39  return { ok: r.exitCode === 0, text: `${r.stdout}${r.stderr}`.trim() }
40}
41
42const DEBUG_MODES = ['all', 'errors', 'off']
43
44export const register: Register = on => {
45  on('session.start', async ($, e, next) => {
46    const started = await next(e)
47    // Reinicio: el estado es del módulo y sobrevive entre sesiones; fuera de un gateway local debe quedar vacío.
48    state.base = ''
49    state.port = ''
50    state.token = ''
51    state.fallbacks = new Map()
52    state.rescued = new Map()
53    const local = localBase(await $.env.get('ANTHROPIC_BASE_URL'))
54    if (!local) return started
55    state.base = local.base
56    state.port = local.port
57    state.token = (await $.env.get('ANTHROPIC_AUTH_TOKEN')) ?? ''
58    await $.command.register({
59      name: 'kiro',
60      description: 'Estado y control de kiro-gateway',
61      argumentHint: '[restart | logs [n] | debug all|errors|off | models [id]]',
62    })
63    await fetchStatus($).catch(() => undefined) // sin estado: /kiro lo dirá
64    return started
65  })
66
67  on('turn.step', async function* ($, e, next) {
68    if (!state.base) return yield* next(e)
69
70    if (!(await healthy($))) {
71      const r = await runScript($, ['start'])
72      $.ui.toast(r.ok ? 'kiro-gateway relanzado' : `kiro-gateway no arrancó: ${r.text}`, { timeoutMs: 8000 })
73      if (r.ok) await fetchStatus($).catch(() => undefined)
74    }
75
76    // Turno ya rescatado: el original volvería a cortar, así que el resto del turno va directo
77    // al respaldo, sin toast. Incluye el reintento propio de CC (mismo turnId, otro index).
78    // Si el respaldo también corta, el mod no reintenta más.
79    const sticky = state.rescued.get(e.turnId)
80    if (sticky) return yield* next({ ...e, model: sticky })
81
82    // El motor real entrega el refusal en el chunk 'stop' y deja stopReason en null
83    // en el resultado de next(); se mira en ambos sitios.
84    const gen = next(e)
85    let refused = false
86    let first
87    let done = false
88    try {
89      for (;;) {
90        const r = await gen.next()
91        if (r.done) {
92          done = true
93          first = r.value
94          break
95        }
96        if (r.value.kind === 'stop' && r.value.stopReason === 'refusal') refused = true
97        yield r.value
98      }
99    } finally {
100      // yield* delegaba return()/throw(); el bucle manual debe cerrar el stream de abajo.
101      if (!done) await gen.return(undefined as never)
102    }
103    const fallback = state.fallbacks.get(modelKey(e.model))
104    if (!(refused || first.stopReason === 'refusal') || !fallback || next.signal.aborted) return first
105    // Un Map por turnId y no un único hueco: los subagentes en paralelo intercalan turnos.
106    state.rescued.set(e.turnId, fallback)
107    $.ui.toast(`${e.model} cortó → reintento con ${fallback}`, { timeoutMs: 8000 })
108    return yield* next({ ...e, model: fallback })
109  })
110
111  on('command.run', { command: 'kiro' }, async ($, e) => {
112    if (!state.base) return { text: 'El mod kiro solo actúa con ANTHROPIC_BASE_URL apuntando a un gateway local (kclaude).' }
113    const { sub, rest } = parseArgs(e.args)
114    try {
115      switch (sub) {
116        case '':
117          return { text: formatStatus(await fetchStatus($), await $.session.model()) }
118        case 'restart': {
119          const st = await fetchStatus($).catch(() => undefined)
120          const r = await runScript($, ['restart', ...(st ? ['-DebugMode', st.debug.mode] : [])])
121          if (r.ok) await fetchStatus($).catch(() => undefined)
122          return { text: r.text }
123        }
124        case 'logs': {
125          const n = rest[0] === undefined ? 20 : Number(rest[0])
126          if (!Number.isInteger(n) || n <= 0) return { text: 'Uso: /kiro logs [n]' }
127          return { text: (await runScript($, ['logs', '-Lines', String(n)])).text }
128        }
129        case 'debug': {
130          const mode = rest[0] ?? ''
131          if (!DEBUG_MODES.includes(mode)) return { text: 'Uso: /kiro debug all|errors|off' }
132          const r = await runScript($, ['restart', '-DebugMode', mode])
133          if (!r.ok) return { text: r.text }
134          const st = await fetchStatus($)
135          return { text: `${r.text}\ndebug: ${st.debug.mode} → ${st.debug.dir}` }
136        }
137        case 'models': {
138          const st = await fetchStatus($)
139          const id = rest[0]
140          if (!id) return { text: formatModels(st.models) }
141          if (!st.models.some(m => modelKey(m.id) === modelKey(id))) {
142            return { text: `${id} no está en la lista de Kiro. /kiro models para verla.` }
143          }
144          // No se lanza /model: `/model <id>` escrito guarda el modelo en los settings globales
145          // (~/.claude/settings.json) y el `claude` normal arrancaría con un id de Kiro. El selector
146          // de /model con `s` lo cambia solo para esta sesión, y un mod no tiene otra forma de hacerlo.
147          return {
148            text: `Para usar ${id} solo en esta sesión: /model, elige ${id} y pulsa s.\n(/model ${id} escrito así lo guarda como predeterminado de todas tus sesiones de Claude Code.)`,
149          }
150        }
151        default:
152          return { text: USAGE }
153      }
154    } catch (err) {
155      if (!(await healthy($))) return { text: `kiro-gateway no responde en ${state.base}. Prueba /kiro restart.` }
156      return { text: err instanceof Error ? err.message : String(err) }
157    }
158  })
159}
160
hooks/kiro.ts 76 lines
1// Funciones puras del mod: sin `$`, para que los tests las prueben sueltas.
2
3/** Un modelo tal como lo devuelve GET /kiro/status. */
4export type ModelInfo = {
5  id: string
6  native_thinking: string[]
7  effort_levels: string[]
8  refusal_fallback: string
9}
10
11/** El cuerpo de GET /kiro/status (DIFFERENCES §20 del gateway). */
12export type Status = {
13  version: string
14  uptime_seconds: number
15  active_account: string | null
16  debug: { mode: string; dir: string }
17  models: ModelInfo[]
18}
19
20export const USAGE = 'Uso: /kiro [restart | logs [n] | debug all|errors|off | models [id]]'
21
22/**
23 * Clave común para los ids de Claude Code (claude-sonnet-5-5, con o sin
24 * sufijo [1m]) y los de Kiro (claude-sonnet-5.5).
25 */
26export function modelKey(id: string): string {
27  return id.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').replace(/\./g, '-')
28}
29
30/** modelKey(id) → modelo de respaldo, solo para los modelos que lo declaran. */
31export function fallbackMap(models: readonly ModelInfo[]): Map<string, string> {
32  const map = new Map<string, string>()
33  for (const m of models) if (m.refusal_fallback) map.set(modelKey(m.id), m.refusal_fallback)
34  return map
35}
36
37/** La base y el puerto si url es un gateway local; undefined en otro caso. */
38export function localBase(url: string | undefined): { base: string; port: string } | undefined {
39  const m = /^http:\/\/(127\.0\.0\.1|localhost):(\d+)\/?$/.exec(url ?? '')
40  if (!m) return undefined
41  return { base: `http://${m[1]}:${m[2]}`, port: m[2]! }
42}
43
44export function parseArgs(args: string): { sub: string; rest: string[] } {
45  const parts = args.trim().split(/\s+/).filter(Boolean)
46  return { sub: parts[0] ?? '', rest: parts.slice(1) }
47}
48
49function formatUptime(seconds: number): string {
50  const h = Math.floor(seconds / 3600)
51  const m = Math.floor((seconds % 3600) / 60)
52  return h > 0 ? `${h} h ${m} min` : `${m} min`
53}
54
55export function formatStatus(st: Status, sessionModel: string): string {
56  return [
57    `kiro-gateway ${st.version} · activo hace ${formatUptime(st.uptime_seconds)}`,
58    `cuenta: ${st.active_account ?? '(ninguna)'}`,
59    `debug: ${st.debug.mode} (${st.debug.dir})`,
60    `modelo de la sesión: ${sessionModel}`,
61  ].join('\n')
62}
63
64export function formatModels(models: readonly ModelInfo[]): string {
65  const head = ['modelo', 'thinking nativo', 'effort', 'respaldo']
66  const rows = models.map(m => [
67    m.id,
68    m.native_thinking.join(',') || '-',
69    m.effort_levels.join(',') || '-',
70    m.refusal_fallback || '-',
71  ])
72  const all = [head, ...rows]
73  const widths = head.map((_, i) => Math.max(...all.map(r => r[i]!.length)))
74  return all.map(r => r.map((c, i) => c.padEnd(widths[i]!)).join('  ').trimEnd()).join('\n')
75}
76