Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
(`<state>/commandcode-keys-state.json`, modo 0600) y cada cooldown se registra en
`<state>/commandcode-rotation.log`.
| `<state>/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
Expand Down Expand Up @@ -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
`<state>/commandcode-errors.log`.

---

## Requisitos
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
(`<state>/commandcode-keys-state.json`, mode 0600) and every cooldown is logged
to `<state>/commandcode-rotation.log`.
| `<state>/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
Expand Down Expand Up @@ -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
`<state>/commandcode-errors.log`.

---

## Requirements
Expand Down
1 change: 1 addition & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<state>/commandcode-keys-state.json` (mode 0600, labels only, never keys) and
each cooldown is appended to `<state>/commandcode-rotation.log`.
| `commandcode-errors.log` | append | JSON lines, one per decorated failure |

## Transport

Expand Down
1 change: 1 addition & 0 deletions docs/es/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<state>/commandcode-keys-state.json` (modo 0600, solo etiquetas) y cada cooldown se
registra en `<state>/commandcode-rotation.log`.
| `commandcode-errors.log` | append | JSON lines, uno por fallo decorado |

```
COMMANDCODE_ALLOWED_HOSTS=gateway.internal,proxy.lan
Expand Down
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
86 changes: 86 additions & 0 deletions src/hooks/errors.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
33 changes: 33 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down
141 changes: 141 additions & 0 deletions tests/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ((event: any) => Promise<void>)[]> = {
"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<string, ((event: any) => Promise<void>)[]>, 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");
});
});
Loading