Skip to content

Commit 67a6e64

Browse files
committed
feat: turn Command Code failures into actionable errors
Keep the HTTP status and the JSON envelope, but append 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. Only JSON error bodies are touched (SSE and successes keep their bytes) and the status is never rewritten, so OpenCode's retry classification is unaffected.
1 parent 6643275 commit 67a6e64

10 files changed

Lines changed: 311 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) - SemVer.
55

66
## [Unreleased]
77

8+
## [1.2.0] - 2026-10-31
9+
10+
### Added
11+
- Actionable Command Code errors. JSON failure envelopes keep their HTTP status
12+
and shape, but the message gains a resolution hint: add credits or another key
13+
for 402, pick another model or upgrade for MODEL_NOT_IN_PLAN, replace or rotate
14+
for 401/403, wait or rotate for 429, and the Anthropic-surface note for
15+
unsupported_model. The status is never rewritten (OpenCode's retry
16+
classification depends on it), only JSON bodies are touched, and every
17+
decoration is logged to commandcode-errors.log.
18+
819
## [1.1.0] - 2026-10-31
920

1021
### Added

‎README.es.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Los cooldowns se registran por key segun la respuesta que culpa a la credencial:
9292
estan en cooldown gana la que se recupera antes. El estado guarda solo etiquetas
9393
(`<state>/commandcode-keys-state.json`, modo 0600) y cada cooldown se registra en
9494
`<state>/commandcode-rotation.log`.
95+
| `<state>/commandcode-errors.log` | Fallos decorados: status, codigo upstream, mensaje y la pista mostrada al usuario |
9596

9697
En modo rotacion el plugin es dueno de la cabecera `Authorization` para
9798
`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.
138139
credito mensual restante, ventanas de rate limit de 5h y semanal con temporizador de
139140
reset, mas los totales del ciclo (peticiones, tokens, gasto).
140141

142+
### Errores accionables
143+
144+
Los fallos de Command Code conservan su status HTTP y su envelope JSON, pero el
145+
mensaje gana una pista de resolucion:
146+
147+
| Fallo | Pista agregada |
148+
| --- | --- |
149+
| `402` creditos insuficientes | agrega creditos u otra cuenta a `COMMANDCODE_API_KEYS` |
150+
| `MODEL_NOT_IN_PLAN` | elige otro modelo o sube de plan |
151+
| `401` / `403` | reemplaza la key o rota el pool |
152+
| `429` / ventana del plan | espera el reset o rota a otra key |
153+
| `unsupported_model` | el modelo es Anthropic-shaped y solo se sirve en `/messages` |
154+
155+
Solo se tocan cuerpos JSON de error, el status nunca se reescribe (la clasificacion
156+
de reintentos de OpenCode depende de el) y cada decoracion se registra en
157+
`<state>/commandcode-errors.log`.
158+
141159
---
142160

143161
## Requisitos

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ Cooldowns are recorded per key from the response that blamed the credential:
8686
keys are cooling down the soonest recovery wins. State is labels-only
8787
(`<state>/commandcode-keys-state.json`, mode 0600) and every cooldown is logged
8888
to `<state>/commandcode-rotation.log`.
89+
| `<state>/commandcode-errors.log` | Decorated failures: status, upstream code, message and the hint shown to the user |
8990

9091
In rotation mode the plugin owns the `Authorization` header for
9192
`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.
132133
remaining monthly credit, 5h and weekly rate-limit windows with reset timers,
133134
plus cycle totals (requests, tokens, spend).
134135

136+
### Actionable errors
137+
138+
Command Code failures keep their HTTP status and JSON envelope, but the message
139+
gains a resolution hint:
140+
141+
| Failure | Hint appended |
142+
| --- | --- |
143+
| `402` insufficient credits | add credits or another account to `COMMANDCODE_API_KEYS` |
144+
| `MODEL_NOT_IN_PLAN` | pick another model or upgrade the plan |
145+
| `401` / `403` | replace the key or rotate the pool |
146+
| `429` / plan window | wait for the reset or rotate to another key |
147+
| `unsupported_model` | the model is Anthropic-shaped and only served on `/messages` |
148+
149+
Only JSON error bodies are touched, the status is never rewritten (OpenCode's
150+
retry classification depends on it) and every decoration is logged to
151+
`<state>/commandcode-errors.log`.
152+
135153
---
136154

137155
## Requirements

‎docs/CONFIGURATION.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ When every key is cooling down the one that recovers first is used, so the
7171
request still reaches upstream. State lives in
7272
`<state>/commandcode-keys-state.json` (mode 0600, labels only, never keys) and
7373
each cooldown is appended to `<state>/commandcode-rotation.log`.
74+
| `commandcode-errors.log` | append | JSON lines, one per decorated failure |
7475

7576
## Transport
7677

‎docs/es/configuration.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ el host ya haya puesto.
6464
Si todas las keys estan en cooldown se usa la que se recupera primero. El estado vive en
6565
`<state>/commandcode-keys-state.json` (modo 0600, solo etiquetas) y cada cooldown se
6666
registra en `<state>/commandcode-rotation.log`.
67+
| `commandcode-errors.log` | append | JSON lines, uno por fallo decorado |
6768

6869
```
6970
COMMANDCODE_ALLOWED_HOSTS=gateway.internal,proxy.lan

‎index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export * from "./src/auth/pool.ts";
66
export * from "./src/client/api.ts";
77
export * from "./src/hooks/resilience.ts";
88
export * from "./src/hooks/zdr.ts";
9+
export * from "./src/hooks/errors.ts";
910
export * from "./src/tools/quota.ts";

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-commandcode",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"description": "Production-grade OpenCode plugin for Command Code: dynamic model discovery, stability hooks and quota telemetry",
55
"main": "index.ts",
66
"type": "module",

‎src/hooks/errors.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Actionable Command Code errors.
3+
*
4+
* The Provider API answers failures with a JSON envelope such as
5+
*
6+
* {"error":{"message":"You have insufficient credits...","type":"...","code":"..."}}
7+
*
8+
* OpenCode surfaces that message verbatim, which leaves the user without the
9+
* one thing that matters: what to do next. This module keeps the status and the
10+
* envelope but appends a short, provider-specific resolution hint.
11+
*
12+
* Only JSON error bodies are touched. SSE streams, successful responses and
13+
* non-JSON payloads keep their bytes untouched, and the status code is never
14+
* rewritten: OpenCode's retry classification depends on it.
15+
*/
16+
17+
export interface DecoratedError {
18+
body: string;
19+
status: number;
20+
code?: string;
21+
message: string;
22+
hint: string;
23+
}
24+
25+
function messageOf(data: any): string {
26+
return String(data?.error?.message ?? data?.message ?? "");
27+
}
28+
29+
function codeOf(data: any): string | undefined {
30+
const code = data?.error?.code ?? data?.code;
31+
return typeof code === "string" ? code : undefined;
32+
}
33+
34+
/** Resolution hint for a status/code/message combination, or an empty string. */
35+
export function errorHint(status: number, message: string, code?: string): string {
36+
const text = message.toLowerCase();
37+
if (status === 402 || text.includes("insufficient credits")) {
38+
return "add credits at commandcode.ai or add another account to COMMANDCODE_API_KEYS";
39+
}
40+
if (text.includes("model_not_in_plan") || text.includes("available in pro and above")) {
41+
return "this model is not in your plan; pick another model or upgrade the plan";
42+
}
43+
if (status === 401) {
44+
return "the key was rejected; replace it in the key file or rotate the pool";
45+
}
46+
if (status === 403) {
47+
return "the key is not entitled to this request; check the plan or the key permissions";
48+
}
49+
if (status === 429 || text.includes("rate limit") || text.includes("window")) {
50+
return "rate limit or plan window reached; wait for the reset or rotate to another key";
51+
}
52+
if (text.includes("unsupported_model")) {
53+
return "this model is Anthropic-shaped and only served on /messages, not /chat/completions";
54+
}
55+
return "";
56+
}
57+
58+
/**
59+
* Decorates a JSON error envelope. Returns null when the body is not a
60+
* recognizable error (the caller then leaves the response untouched).
61+
*/
62+
export function decorateErrorBody(raw: string, status: number): DecoratedError | null {
63+
if (!raw) return null;
64+
let data: any;
65+
try {
66+
data = JSON.parse(raw);
67+
} catch {
68+
return null;
69+
}
70+
if (!data || typeof data !== "object") return null;
71+
72+
const message = messageOf(data);
73+
if (!message) return null;
74+
75+
const code = codeOf(data);
76+
const hint = errorHint(status, message, code);
77+
if (!hint) return null;
78+
79+
if (data.error && typeof data.error === "object") {
80+
data.error.message = `${message} (${hint})`;
81+
} else {
82+
data.message = `${message} (${hint})`;
83+
}
84+
85+
return { body: JSON.stringify(data), status, code, message, hint };
86+
}

‎src/index.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "./hooks/resilience.ts";
1616
import { applyZdrHeader } from "./hooks/zdr.ts";
1717
import { loadKeyPool, loadPoolState, recordResponse, selectKey } from "./auth/pool.ts";
18+
import { decorateErrorBody } from "./hooks/errors.ts";
1819
import { getQuotaReport } from "./tools/quota.ts";
1920
import { describeRetryEvent } from "./telemetry.ts";
2021
import { buildReleaseDate, freeCostEntry } from "./catalog.ts";
@@ -294,6 +295,38 @@ export default {
294295
}
295296
}
296297

298+
// Actionable failures: keep the status and envelope, append a
299+
// resolution hint. JSON only; SSE and success bodies stay untouched.
300+
const failed = event.response;
301+
if (failed && failed.status >= 400) {
302+
const failureType = failed.headers?.get?.("content-type") || "";
303+
if (failureType.includes("application/json")) {
304+
try {
305+
const raw = await failed.clone().text();
306+
const decorated = decorateErrorBody(raw, failed.status);
307+
if (decorated) {
308+
const headers = new Headers(failed.headers);
309+
headers.delete("content-length");
310+
event.response = new Response(decorated.body, {
311+
status: failed.status,
312+
statusText: failed.statusText,
313+
headers,
314+
});
315+
await appendEvent(logPath("commandcode-errors.log"), {
316+
at: new Date().toISOString(),
317+
sessionID: event.sessionID,
318+
status: decorated.status,
319+
code: decorated.code,
320+
message: decorated.message,
321+
hint: decorated.hint,
322+
});
323+
}
324+
} catch {
325+
// fail-open: an undecorated error is still a usable error
326+
}
327+
}
328+
}
329+
297330
const res = event.response;
298331
if (!res || !res.body) return;
299332

‎tests/errors.test.ts‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import plugin from "../src/index.ts";
6+
import { decorateErrorBody, errorHint } from "../src/hooks/errors.ts";
7+
8+
const URL_CC = "https://api.commandcode.ai/provider/v1/chat/completions";
9+
10+
function envelope(message: string, code?: string) {
11+
return JSON.stringify({ error: { message, type: "authentication_error", ...(code ? { code } : {}) } });
12+
}
13+
14+
function makeCtx() {
15+
const hooks: Record<string, ((event: any) => Promise<void>)[]> = {
16+
"http.request": [],
17+
"http.response": [],
18+
retry: [],
19+
};
20+
const ctx: any = {
21+
session: {
22+
hook: async (name: any, cb: any) => {
23+
hooks[name]?.push(cb);
24+
return { dispose: async () => {} };
25+
},
26+
synthetic: async () => ({}),
27+
},
28+
model: { transform: async () => {} },
29+
tool: { transform: async () => {} },
30+
command: { transform: async () => {} },
31+
};
32+
return { ctx, hooks };
33+
}
34+
35+
async function runResponse(hooks: Record<string, ((event: any) => Promise<void>)[]>, response: Response) {
36+
const event: any = {
37+
request: new Request(URL_CC, { method: "POST", body: "{}" }),
38+
sessionID: "ses_err",
39+
response,
40+
};
41+
for (const cb of hooks["http.response"] ?? []) await cb(event);
42+
return event.response as Response;
43+
}
44+
45+
describe("errorHint", () => {
46+
test("maps the actionable failures", () => {
47+
expect(errorHint(402, "You have insufficient credits")).toContain("COMMANDCODE_API_KEYS");
48+
expect(errorHint(401, "Invalid 'Authorization' header")).toContain("key was rejected");
49+
expect(errorHint(403, "Forbidden")).toContain("not entitled");
50+
expect(errorHint(429, "rate limited")).toContain("rate limit");
51+
expect(errorHint(400, "MODEL_NOT_IN_PLAN: GPT-5.5 available in Pro")).toContain("not in your plan");
52+
expect(errorHint(400, "unsupported_model")).toContain("/messages");
53+
});
54+
55+
test("leaves unrelated failures alone", () => {
56+
expect(errorHint(500, "internal error")).toBe("");
57+
expect(errorHint(400, "invalid request error")).toBe("");
58+
});
59+
});
60+
61+
describe("decorateErrorBody", () => {
62+
test("keeps the envelope and appends the hint", () => {
63+
const decorated = decorateErrorBody(envelope("You have insufficient credits", "UNAUTHORIZED"), 402)!;
64+
const parsed = JSON.parse(decorated.body);
65+
expect(decorated.status).toBe(402);
66+
expect(decorated.code).toBe("UNAUTHORIZED");
67+
expect(parsed.error.message).toContain("You have insufficient credits");
68+
expect(parsed.error.message).toContain("add credits");
69+
expect(parsed.error.type).toBe("authentication_error");
70+
});
71+
72+
test("handles a flat message envelope", () => {
73+
const decorated = decorateErrorBody(JSON.stringify({ message: "rate limited" }), 429)!;
74+
expect(JSON.parse(decorated.body).message).toContain("rate limit or plan window");
75+
});
76+
77+
test("returns null for anything it cannot improve", () => {
78+
expect(decorateErrorBody("", 402)).toBeNull();
79+
expect(decorateErrorBody("not json", 402)).toBeNull();
80+
expect(decorateErrorBody(JSON.stringify({ ok: true }), 402)).toBeNull();
81+
expect(decorateErrorBody(envelope("internal error"), 500)).toBeNull();
82+
expect(decorateErrorBody(JSON.stringify({ error: { message: "invalid request error" } }), 400)).toBeNull();
83+
});
84+
});
85+
86+
describe("plugin error decoration", () => {
87+
let stateDir: string;
88+
const previous = process.env.COMMANDCODE_STATE_DIR;
89+
90+
beforeEach(() => {
91+
stateDir = mkdtempSync(join(tmpdir(), "cc-err-"));
92+
process.env.COMMANDCODE_STATE_DIR = stateDir;
93+
});
94+
95+
afterEach(() => {
96+
if (previous === undefined) delete process.env.COMMANDCODE_STATE_DIR;
97+
else process.env.COMMANDCODE_STATE_DIR = previous;
98+
try {
99+
rmSync(stateDir, { recursive: true, force: true });
100+
} catch {}
101+
});
102+
103+
test("rewrites a JSON 402 with a hint and records it", async () => {
104+
const { ctx, hooks } = makeCtx();
105+
await plugin.setup(ctx);
106+
107+
const response = new Response(envelope("You have insufficient credits", "UNAUTHORIZED"), {
108+
status: 402,
109+
headers: { "content-type": "application/json" },
110+
});
111+
const outgoing = await runResponse(hooks, response);
112+
113+
expect(outgoing.status).toBe(402);
114+
const body = JSON.parse(await outgoing.text());
115+
expect(body.error.message).toContain("add credits");
116+
117+
const log = readFileSync(join(stateDir, "commandcode-errors.log"), "utf8");
118+
expect(log).toContain("insufficient credits");
119+
});
120+
121+
test("never rewrites a successful JSON body", async () => {
122+
const { ctx, hooks } = makeCtx();
123+
await plugin.setup(ctx);
124+
const payload = JSON.stringify({ choices: [{ message: { content: "ok" } }] });
125+
const response = new Response(payload, { status: 200, headers: { "content-type": "application/json" } });
126+
const outgoing = await runResponse(hooks, response);
127+
expect(await outgoing.text()).toBe(payload);
128+
});
129+
130+
test("never rewrites an SSE stream", async () => {
131+
const { ctx, hooks } = makeCtx();
132+
await plugin.setup(ctx);
133+
const response = new Response("data: {}\n\n", {
134+
status: 429,
135+
headers: { "content-type": "text/event-stream" },
136+
});
137+
const outgoing = await runResponse(hooks, response);
138+
const text = await outgoing.text();
139+
expect(text).toBe("data: {}\n\n");
140+
});
141+
});

0 commit comments

Comments
 (0)