diff --git a/CHANGELOG.md b/CHANGELOG.md index 2362af6..3fd3f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) - SemVer. ## [Unreleased] +## [1.2.0] - 2026-10-31 + +### Added +- Actionable Command Code errors. JSON failure envelopes keep their HTTP status + and shape, but the message gains a resolution hint: add credits or another key + for 402, pick another model or upgrade for MODEL_NOT_IN_PLAN, replace or rotate + for 401/403, wait or rotate for 429, and the Anthropic-surface note for + unsupported_model. The status is never rewritten (OpenCode's retry + classification depends on it), only JSON bodies are touched, and every + decoration is logged to commandcode-errors.log. + ## [1.1.0] - 2026-10-31 ### Added diff --git a/README.es.md b/README.es.md index faeac02..29a7523 100644 --- a/README.es.md +++ b/README.es.md @@ -92,6 +92,7 @@ Los cooldowns se registran por key segun la respuesta que culpa a la credencial: estan en cooldown gana la que se recupera antes. El estado guarda solo etiquetas (`/commandcode-keys-state.json`, modo 0600) y cada cooldown se registra en `/commandcode-rotation.log`. +| `/commandcode-errors.log` | Fallos decorados: status, codigo upstream, mensaje y la pista mostrada al usuario | En modo rotacion el plugin es dueno de la cabecera `Authorization` para `api.commandcode.ai`, porque la key que inyecto el host puede ser justo la que @@ -138,6 +139,23 @@ parecido que solo comparta `/provider/v1/...` en la ruta. credito mensual restante, ventanas de rate limit de 5h y semanal con temporizador de reset, mas los totales del ciclo (peticiones, tokens, gasto). +### Errores accionables + +Los fallos de Command Code conservan su status HTTP y su envelope JSON, pero el +mensaje gana una pista de resolucion: + +| Fallo | Pista agregada | +| --- | --- | +| `402` creditos insuficientes | agrega creditos u otra cuenta a `COMMANDCODE_API_KEYS` | +| `MODEL_NOT_IN_PLAN` | elige otro modelo o sube de plan | +| `401` / `403` | reemplaza la key o rota el pool | +| `429` / ventana del plan | espera el reset o rota a otra key | +| `unsupported_model` | el modelo es Anthropic-shaped y solo se sirve en `/messages` | + +Solo se tocan cuerpos JSON de error, el status nunca se reescribe (la clasificacion +de reintentos de OpenCode depende de el) y cada decoracion se registra en +`/commandcode-errors.log`. + --- ## Requisitos diff --git a/README.md b/README.md index 762519d..7f85c15 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ Cooldowns are recorded per key from the response that blamed the credential: keys are cooling down the soonest recovery wins. State is labels-only (`/commandcode-keys-state.json`, mode 0600) and every cooldown is logged to `/commandcode-rotation.log`. +| `/commandcode-errors.log` | Decorated failures: status, upstream code, message and the hint shown to the user | In rotation mode the plugin owns the `Authorization` header for `api.commandcode.ai`, because the key that the host config injected may be the @@ -132,6 +133,23 @@ that merely shares `/provider/v1/...` in its path. remaining monthly credit, 5h and weekly rate-limit windows with reset timers, plus cycle totals (requests, tokens, spend). +### Actionable errors + +Command Code failures keep their HTTP status and JSON envelope, but the message +gains a resolution hint: + +| Failure | Hint appended | +| --- | --- | +| `402` insufficient credits | add credits or another account to `COMMANDCODE_API_KEYS` | +| `MODEL_NOT_IN_PLAN` | pick another model or upgrade the plan | +| `401` / `403` | replace the key or rotate the pool | +| `429` / plan window | wait for the reset or rotate to another key | +| `unsupported_model` | the model is Anthropic-shaped and only served on `/messages` | + +Only JSON error bodies are touched, the status is never rewritten (OpenCode's +retry classification depends on it) and every decoration is logged to +`/commandcode-errors.log`. + --- ## Requirements diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0f9bf3f..1d9a45d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -71,6 +71,7 @@ When every key is cooling down the one that recovers first is used, so the request still reaches upstream. State lives in `/commandcode-keys-state.json` (mode 0600, labels only, never keys) and each cooldown is appended to `/commandcode-rotation.log`. +| `commandcode-errors.log` | append | JSON lines, one per decorated failure | ## Transport diff --git a/docs/es/configuration.md b/docs/es/configuration.md index 243bf01..9ed73c2 100644 --- a/docs/es/configuration.md +++ b/docs/es/configuration.md @@ -64,6 +64,7 @@ el host ya haya puesto. Si todas las keys estan en cooldown se usa la que se recupera primero. El estado vive en `/commandcode-keys-state.json` (modo 0600, solo etiquetas) y cada cooldown se registra en `/commandcode-rotation.log`. +| `commandcode-errors.log` | append | JSON lines, uno por fallo decorado | ``` COMMANDCODE_ALLOWED_HOSTS=gateway.internal,proxy.lan diff --git a/index.ts b/index.ts index ee38a4b..0dc45ed 100644 --- a/index.ts +++ b/index.ts @@ -6,4 +6,5 @@ export * from "./src/auth/pool.ts"; export * from "./src/client/api.ts"; export * from "./src/hooks/resilience.ts"; export * from "./src/hooks/zdr.ts"; +export * from "./src/hooks/errors.ts"; export * from "./src/tools/quota.ts"; diff --git a/package.json b/package.json index 01b2222..abcfd14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-commandcode", - "version": "1.1.0", + "version": "1.2.0", "description": "Production-grade OpenCode plugin for Command Code: dynamic model discovery, stability hooks and quota telemetry", "main": "index.ts", "type": "module", diff --git a/src/hooks/errors.ts b/src/hooks/errors.ts new file mode 100644 index 0000000..7b527ee --- /dev/null +++ b/src/hooks/errors.ts @@ -0,0 +1,86 @@ +/** + * Actionable Command Code errors. + * + * The Provider API answers failures with a JSON envelope such as + * + * {"error":{"message":"You have insufficient credits...","type":"...","code":"..."}} + * + * OpenCode surfaces that message verbatim, which leaves the user without the + * one thing that matters: what to do next. This module keeps the status and the + * envelope but appends a short, provider-specific resolution hint. + * + * Only JSON error bodies are touched. SSE streams, successful responses and + * non-JSON payloads keep their bytes untouched, and the status code is never + * rewritten: OpenCode's retry classification depends on it. + */ + +export interface DecoratedError { + body: string; + status: number; + code?: string; + message: string; + hint: string; +} + +function messageOf(data: any): string { + return String(data?.error?.message ?? data?.message ?? ""); +} + +function codeOf(data: any): string | undefined { + const code = data?.error?.code ?? data?.code; + return typeof code === "string" ? code : undefined; +} + +/** Resolution hint for a status/code/message combination, or an empty string. */ +export function errorHint(status: number, message: string, code?: string): string { + const text = message.toLowerCase(); + if (status === 402 || text.includes("insufficient credits")) { + return "add credits at commandcode.ai or add another account to COMMANDCODE_API_KEYS"; + } + if (text.includes("model_not_in_plan") || text.includes("available in pro and above")) { + return "this model is not in your plan; pick another model or upgrade the plan"; + } + if (status === 401) { + return "the key was rejected; replace it in the key file or rotate the pool"; + } + if (status === 403) { + return "the key is not entitled to this request; check the plan or the key permissions"; + } + if (status === 429 || text.includes("rate limit") || text.includes("window")) { + return "rate limit or plan window reached; wait for the reset or rotate to another key"; + } + if (text.includes("unsupported_model")) { + return "this model is Anthropic-shaped and only served on /messages, not /chat/completions"; + } + return ""; +} + +/** + * Decorates a JSON error envelope. Returns null when the body is not a + * recognizable error (the caller then leaves the response untouched). + */ +export function decorateErrorBody(raw: string, status: number): DecoratedError | null { + if (!raw) return null; + let data: any; + try { + data = JSON.parse(raw); + } catch { + return null; + } + if (!data || typeof data !== "object") return null; + + const message = messageOf(data); + if (!message) return null; + + const code = codeOf(data); + const hint = errorHint(status, message, code); + if (!hint) return null; + + if (data.error && typeof data.error === "object") { + data.error.message = `${message} (${hint})`; + } else { + data.message = `${message} (${hint})`; + } + + return { body: JSON.stringify(data), status, code, message, hint }; +} diff --git a/src/index.ts b/src/index.ts index 4abade4..23c5e20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { } from "./hooks/resilience.ts"; import { applyZdrHeader } from "./hooks/zdr.ts"; import { loadKeyPool, loadPoolState, recordResponse, selectKey } from "./auth/pool.ts"; +import { decorateErrorBody } from "./hooks/errors.ts"; import { getQuotaReport } from "./tools/quota.ts"; import { describeRetryEvent } from "./telemetry.ts"; import { buildReleaseDate, freeCostEntry } from "./catalog.ts"; @@ -294,6 +295,38 @@ export default { } } + // Actionable failures: keep the status and envelope, append a + // resolution hint. JSON only; SSE and success bodies stay untouched. + const failed = event.response; + if (failed && failed.status >= 400) { + const failureType = failed.headers?.get?.("content-type") || ""; + if (failureType.includes("application/json")) { + try { + const raw = await failed.clone().text(); + const decorated = decorateErrorBody(raw, failed.status); + if (decorated) { + const headers = new Headers(failed.headers); + headers.delete("content-length"); + event.response = new Response(decorated.body, { + status: failed.status, + statusText: failed.statusText, + headers, + }); + await appendEvent(logPath("commandcode-errors.log"), { + at: new Date().toISOString(), + sessionID: event.sessionID, + status: decorated.status, + code: decorated.code, + message: decorated.message, + hint: decorated.hint, + }); + } + } catch { + // fail-open: an undecorated error is still a usable error + } + } + } + const res = event.response; if (!res || !res.body) return; diff --git a/tests/errors.test.ts b/tests/errors.test.ts new file mode 100644 index 0000000..7e52bb4 --- /dev/null +++ b/tests/errors.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import plugin from "../src/index.ts"; +import { decorateErrorBody, errorHint } from "../src/hooks/errors.ts"; + +const URL_CC = "https://api.commandcode.ai/provider/v1/chat/completions"; + +function envelope(message: string, code?: string) { + return JSON.stringify({ error: { message, type: "authentication_error", ...(code ? { code } : {}) } }); +} + +function makeCtx() { + const hooks: Record Promise)[]> = { + "http.request": [], + "http.response": [], + retry: [], + }; + const ctx: any = { + session: { + hook: async (name: any, cb: any) => { + hooks[name]?.push(cb); + return { dispose: async () => {} }; + }, + synthetic: async () => ({}), + }, + model: { transform: async () => {} }, + tool: { transform: async () => {} }, + command: { transform: async () => {} }, + }; + return { ctx, hooks }; +} + +async function runResponse(hooks: Record Promise)[]>, response: Response) { + const event: any = { + request: new Request(URL_CC, { method: "POST", body: "{}" }), + sessionID: "ses_err", + response, + }; + for (const cb of hooks["http.response"] ?? []) await cb(event); + return event.response as Response; +} + +describe("errorHint", () => { + test("maps the actionable failures", () => { + expect(errorHint(402, "You have insufficient credits")).toContain("COMMANDCODE_API_KEYS"); + expect(errorHint(401, "Invalid 'Authorization' header")).toContain("key was rejected"); + expect(errorHint(403, "Forbidden")).toContain("not entitled"); + expect(errorHint(429, "rate limited")).toContain("rate limit"); + expect(errorHint(400, "MODEL_NOT_IN_PLAN: GPT-5.5 available in Pro")).toContain("not in your plan"); + expect(errorHint(400, "unsupported_model")).toContain("/messages"); + }); + + test("leaves unrelated failures alone", () => { + expect(errorHint(500, "internal error")).toBe(""); + expect(errorHint(400, "invalid request error")).toBe(""); + }); +}); + +describe("decorateErrorBody", () => { + test("keeps the envelope and appends the hint", () => { + const decorated = decorateErrorBody(envelope("You have insufficient credits", "UNAUTHORIZED"), 402)!; + const parsed = JSON.parse(decorated.body); + expect(decorated.status).toBe(402); + expect(decorated.code).toBe("UNAUTHORIZED"); + expect(parsed.error.message).toContain("You have insufficient credits"); + expect(parsed.error.message).toContain("add credits"); + expect(parsed.error.type).toBe("authentication_error"); + }); + + test("handles a flat message envelope", () => { + const decorated = decorateErrorBody(JSON.stringify({ message: "rate limited" }), 429)!; + expect(JSON.parse(decorated.body).message).toContain("rate limit or plan window"); + }); + + test("returns null for anything it cannot improve", () => { + expect(decorateErrorBody("", 402)).toBeNull(); + expect(decorateErrorBody("not json", 402)).toBeNull(); + expect(decorateErrorBody(JSON.stringify({ ok: true }), 402)).toBeNull(); + expect(decorateErrorBody(envelope("internal error"), 500)).toBeNull(); + expect(decorateErrorBody(JSON.stringify({ error: { message: "invalid request error" } }), 400)).toBeNull(); + }); +}); + +describe("plugin error decoration", () => { + let stateDir: string; + const previous = process.env.COMMANDCODE_STATE_DIR; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "cc-err-")); + process.env.COMMANDCODE_STATE_DIR = stateDir; + }); + + afterEach(() => { + if (previous === undefined) delete process.env.COMMANDCODE_STATE_DIR; + else process.env.COMMANDCODE_STATE_DIR = previous; + try { + rmSync(stateDir, { recursive: true, force: true }); + } catch {} + }); + + test("rewrites a JSON 402 with a hint and records it", async () => { + const { ctx, hooks } = makeCtx(); + await plugin.setup(ctx); + + const response = new Response(envelope("You have insufficient credits", "UNAUTHORIZED"), { + status: 402, + headers: { "content-type": "application/json" }, + }); + const outgoing = await runResponse(hooks, response); + + expect(outgoing.status).toBe(402); + const body = JSON.parse(await outgoing.text()); + expect(body.error.message).toContain("add credits"); + + const log = readFileSync(join(stateDir, "commandcode-errors.log"), "utf8"); + expect(log).toContain("insufficient credits"); + }); + + test("never rewrites a successful JSON body", async () => { + const { ctx, hooks } = makeCtx(); + await plugin.setup(ctx); + const payload = JSON.stringify({ choices: [{ message: { content: "ok" } }] }); + const response = new Response(payload, { status: 200, headers: { "content-type": "application/json" } }); + const outgoing = await runResponse(hooks, response); + expect(await outgoing.text()).toBe(payload); + }); + + test("never rewrites an SSE stream", async () => { + const { ctx, hooks } = makeCtx(); + await plugin.setup(ctx); + const response = new Response("data: {}\n\n", { + status: 429, + headers: { "content-type": "text/event-stream" }, + }); + const outgoing = await runResponse(hooks, response); + const text = await outgoing.text(); + expect(text).toBe("data: {}\n\n"); + }); +});