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
46 changes: 35 additions & 11 deletions src/commandcode.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
CommandCodeCredentialRouter,
type CommandCodeCredentialDiagnostic,
isInsufficientCreditsMessage,
NoAvailableCommandCodeCredentialError,
type SelectCredentialOptions,
} from "./credential-router.js";
Expand Down Expand Up @@ -202,8 +203,24 @@ function isClientVisibleEvent(event: CommandCodeEvent): boolean {
return ["text-delta", "reasoning-delta", "tool-call", "finish"].includes(event.type);
}

export function isFatalCredFailure(statusCode: number | undefined): boolean {
return statusCode === 401 || statusCode === 402 || statusCode === 403;
export function isFatalCredFailure(statusCode: number | undefined, errorMessage?: string): boolean {
return (
statusCode === 401 ||
statusCode === 402 ||
statusCode === 403 ||
// A bare 400 is provider-scoped (bad model, invalid body), but the same
// status with an insufficient-credits body blames this credential only.
(statusCode === 400 && isInsufficientCreditsMessage(errorMessage))
);
}

/** Extracts the upstream error text from a parsed HTTP or SSE body for failure classification. */
export function upstreamErrorMessage(body: unknown): string | undefined {
if (typeof body === "string") return body;
if (!isRecord(body)) return undefined;
const error = isRecord(body.error) ? body.error : undefined;
const message = error?.message ?? body.message;
return typeof message === "string" && message.length > 0 ? message : undefined;
}

export function retryBackoff(attempt: number, baseMs: number): Promise<void> {
Expand Down Expand Up @@ -413,10 +430,12 @@ export class CommandCodeClient implements CommandCodeUpstream {
this.router.recordSuccess(credential.id);
finalized = true;
};
const finalizeFailure = (statusCode?: number) => {
const finalizeFailure = (statusCode?: number, errorMessage?: string) => {
if (finalized) return;
if (statusCode === undefined) this.router.recordFailure(credential.id);
else this.router.recordFailure(credential.id, { statusCode });
this.router.recordFailure(credential.id, {
...(statusCode !== undefined ? { statusCode } : {}),
...(errorMessage !== undefined ? { errorMessage } : {}),
});
finalized = true;
};
const finalizeRelease = () => {
Expand All @@ -434,13 +453,14 @@ export class CommandCodeClient implements CommandCodeUpstream {
await responseBody(response),
);
lastError = error;
const fatal = isFatalCredFailure(response.status);
const upstreamMessage = upstreamErrorMessage(error.body);
const fatal = isFatalCredFailure(response.status, upstreamMessage);
const willRetry =
attempt < maxAttempts - 1 && shouldRetry(response.status) && !effectiveSignal.aborted;
// Only credential-scoped failures may start a cooldown. Provider-scoped
// statuses like 429/5xx hit every credential at once, so cooling them
// down would bench the whole pool over a single upstream incident.
if (fatal) finalizeFailure(response.status);
if (fatal) finalizeFailure(response.status, upstreamMessage);
else finalizeRelease();
if (willRetry) {
if (fatal) fatalIds.add(credential.id);
Expand Down Expand Up @@ -476,13 +496,15 @@ export class CommandCodeClient implements CommandCodeUpstream {
"CommandCode stream error",
event,
);
const fatal = statusCode !== undefined && isFatalCredFailure(statusCode);
const upstreamMessage = upstreamErrorMessage(event);
const fatal =
statusCode !== undefined && isFatalCredFailure(statusCode, upstreamMessage);
const willRetry =
!emittedVisibleEvent &&
attempt < maxAttempts - 1 &&
shouldRetry(statusCode) &&
!effectiveSignal.aborted;
if (fatal) finalizeFailure(statusCode);
if (fatal) finalizeFailure(statusCode, upstreamMessage);
else finalizeRelease();
if (willRetry) {
if (fatal) fatalIds.add(credential.id);
Expand All @@ -501,13 +523,15 @@ export class CommandCodeClient implements CommandCodeUpstream {
} catch (error) {
const statusCode = errorStatusCodeFromUnknown(error);
lastError = error;
const fatal = statusCode !== undefined && isFatalCredFailure(statusCode);
const upstreamMessage =
error instanceof CommandCodeHttpError ? upstreamErrorMessage(error.body) : undefined;
const fatal = statusCode !== undefined && isFatalCredFailure(statusCode, upstreamMessage);
const willRetry =
signal?.aborted !== true &&
attempt < maxAttempts - 1 &&
shouldRetry(statusCode) &&
!effectiveSignal.aborted;
if (fatal) finalizeFailure(statusCode);
if (fatal) finalizeFailure(statusCode, upstreamMessage);
else finalizeRelease();
if (willRetry) {
if (fatal) fatalIds.add(credential.id);
Expand Down
29 changes: 26 additions & 3 deletions src/credential-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface CommandCodeCredentialState {
credential: CommandCodeCredential;
billing?: CommandCodeBillingSnapshot;
billingError: string | undefined;
disabledReason: "auth" | "billing" | "cooldown" | "expired" | undefined;
disabledReason: "auth" | "billing" | "cooldown" | "expired" | "insufficient_credits" | undefined;
disabledUntil: number;
inFlight: number;
lastSelectedAt: number;
Expand Down Expand Up @@ -102,6 +102,15 @@ export interface SelectCredentialOptions {

export interface RecordFailureOptions {
statusCode?: number;
/** Upstream error text; lets a generic 400 carry credential-scoped meaning (insufficient credits). */
errorMessage?: string;
}

const INSUFFICIENT_CREDITS_PATTERN = /insufficient (?:credits|balance)/i;

/** CommandCode reports an empty balance as a 400 whose body says "insufficient credits". */
export function isInsufficientCreditsMessage(message: string | undefined): boolean {
return message !== undefined && INSUFFICIENT_CREDITS_PATTERN.test(message);
}

export class NoAvailableCommandCodeCredentialError extends Error {
Expand Down Expand Up @@ -500,6 +509,13 @@ export class CommandCodeCredentialRouter {
} else if (statusCode === 402) {
state.disabledUntil = this.now() + Math.max(this.cooldownMs, this.billingRefreshMs);
state.disabledReason = "billing";
} else if (statusCode === 400 && isInsufficientCreditsMessage(options.errorMessage)) {
// Upstream pre-charges the estimated request cost, so a positive balance
// can still be too small to serve. Billing snapshots cannot see that
// threshold, so this cooldown must survive billing refreshes and expire
// on its own before the key is probed again.
state.disabledUntil = this.now() + Math.max(this.cooldownMs, this.billingRefreshMs);
state.disabledReason = "insufficient_credits";
} else if (
statusCode === undefined ||
statusCode === 429 ||
Expand Down Expand Up @@ -545,10 +561,17 @@ export class CommandCodeCredentialRouter {
if (expired) {
state.disabledUntil = Number.MAX_SAFE_INTEGER;
state.disabledReason = "expired";
} else if (remaining > 0 && state.disabledReason !== "auth") {
} else if (
remaining > 0 &&
state.disabledReason !== "auth" &&
state.disabledReason !== "insufficient_credits"
) {
state.disabledUntil = 0;
state.disabledReason = undefined;
} else if (state.disabledUntil !== Number.MAX_SAFE_INTEGER) {
} else if (
state.disabledUntil !== Number.MAX_SAFE_INTEGER &&
state.disabledReason !== "insufficient_credits"
) {
state.disabledUntil = this.now() + this.billingRefreshMs;
state.disabledReason = "billing";
}
Expand Down
22 changes: 14 additions & 8 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isFatalCredFailure,
responseBody,
retryBackoff,
upstreamErrorMessage,
} from "./commandcode.js";
import {
NoAvailableCommandCodeCredentialError,
Expand Down Expand Up @@ -148,10 +149,12 @@ export class CommandCodeProviderClient {
this.router.recordSuccess(credential.id);
finalized = true;
};
const finalizeFailure = (statusCode?: number) => {
const finalizeFailure = (statusCode?: number, errorMessage?: string) => {
if (finalized) return;
if (statusCode === undefined) this.router.recordFailure(credential.id);
else this.router.recordFailure(credential.id, { statusCode });
this.router.recordFailure(credential.id, {
...(statusCode !== undefined ? { statusCode } : {}),
...(errorMessage !== undefined ? { errorMessage } : {}),
});
finalized = true;
};
const finalizeRelease = () => {
Expand All @@ -168,10 +171,11 @@ export class CommandCodeProviderClient {
response.statusText,
await responseBody(response),
);
const fatal = isFatalCredFailure(response.status);
const upstreamMessage = upstreamErrorMessage(error.body);
const fatal = isFatalCredFailure(response.status, upstreamMessage);
// Only credential-scoped failures may start a cooldown; provider-scoped
// statuses like 429/5xx hit every credential at once.
if (fatal) finalizeFailure(response.status);
if (fatal) finalizeFailure(response.status, upstreamMessage);
else finalizeRelease();
lastError = error;
if (
Expand All @@ -191,16 +195,18 @@ export class CommandCodeProviderClient {
} catch (error) {
if (error instanceof CommandCodeHttpError && finalized) throw error;
const statusCode = error instanceof CommandCodeHttpError ? error.status : undefined;
if (statusCode !== undefined && isFatalCredFailure(statusCode)) finalizeFailure(statusCode);
const upstreamMessage =
error instanceof CommandCodeHttpError ? upstreamErrorMessage(error.body) : undefined;
const fatal = statusCode !== undefined && isFatalCredFailure(statusCode, upstreamMessage);
if (fatal) finalizeFailure(statusCode, upstreamMessage);
else finalizeRelease();
lastError = error;
if (
attempt < maxAttempts - 1 &&
shouldRetryStatus(statusCode) &&
!effectiveSignal.aborted
) {
if (statusCode !== undefined && isFatalCredFailure(statusCode))
fatalIds.add(credential.id);
if (fatal) fatalIds.add(credential.id);
else if (statusCode !== undefined) retryableFailed.add(credential.id);
await retryBackoff(attempt, this.config.commandCodeRetryBackoffMs ?? 250);
continue;
Expand Down
66 changes: 66 additions & 0 deletions tests/commandcode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,72 @@ describe("CommandCode client credential routing", () => {
expect(postCalls(fetchMock)).toHaveLength(2);
});

it("marks the credential as billing when a 400 reports insufficient credits", async () => {
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
const billing = billingResponse(String(input));
if (billing) return billing;
if (init?.method === "POST") {
return Response.json(
{
success: false,
error: {
code: "BAD_REQUEST",
status: 400,
message:
"You have insufficient credits to make this request. Please purchase more credits to continue using the service.",
docs: "https://commandcode.ai/docs/reference/errors/bad_request",
},
},
{ status: 400 },
);
}
throw new Error(`Unexpected fetch ${String(input)}`);
});
vi.stubGlobal("fetch", fetchMock);

const client = new CommandCodeClient({
...baseConfig,
commandCodeCredentials: [{ id: "alpha", apiKey: "alpha-secret", weight: 1 }],
});

await expect(collectEvents(client.generate(generateBody))).rejects.toBeInstanceOf(
CommandCodeHttpError,
);
const diagnostics = await client.getCredentialDiagnostics();
expect(diagnostics[0]?.disabledUntil).not.toBeNull();
});

it("does not cool down the credential for a plain 400 bad request", async () => {
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
const billing = billingResponse(String(input));
if (billing) return billing;
if (init?.method === "POST") {
return Response.json(
{ error: { code: "BAD_REQUEST", message: "Model not allowed for this plan." } },
{ status: 400 },
);
}
throw new Error(`Unexpected fetch ${String(input)}`);
});
vi.stubGlobal("fetch", fetchMock);

const client = new CommandCodeClient({
...baseConfig,
commandCodeCredentials: [{ id: "alpha", apiKey: "alpha-secret", weight: 1 }],
});

await expect(collectEvents(client.generate(generateBody))).rejects.toBeInstanceOf(
CommandCodeHttpError,
);
const diagnostics = await client.getCredentialDiagnostics();
expect(diagnostics[0]?.disabledUntil).toBeNull();

await expect(collectEvents(client.generate(generateBody))).rejects.toBeInstanceOf(
CommandCodeHttpError,
);
expect(postCalls(fetchMock)).toHaveLength(2);
});

it("releases the credential without cooldown when the caller aborts mid-stream", async () => {
let hanging = true;
let postStarted: () => void = () => undefined;
Expand Down
97 changes: 97 additions & 0 deletions tests/credential-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,103 @@ describe("CommandCode credential routing", () => {
).toBeGreaterThan(now);
});

it("marks a 400 insufficient-credits failure as an insufficient-credits cooldown", () => {
const router = new CommandCodeCredentialRouter({
credentials: [credential("alpha")],
policy: "round_robin",
billingRefreshMs: 300_000,
cooldownMs: 60_000,
now: () => now,
});

router.recordFailure("alpha", {
statusCode: 400,
errorMessage:
"You have insufficient credits to make this request. Please purchase more credits to continue using the service.",
});

const entry = router.snapshot().find((state) => state.credential.id === "alpha");
expect(entry?.disabledReason).toBe("insufficient_credits");
expect(entry?.disabledUntil).toBe(now + 300_000);
});

it("keeps an insufficient-credits cooldown across billing refreshes", async () => {
let current = now;
const router = new CommandCodeCredentialRouter({
credentials: [credential("alpha")],
policy: "round_robin",
billingRefreshMs: 300_000,
cooldownMs: 60_000,
now: () => current,
billingProvider: async () => ({
fetchedAt: current,
monthlyCredits: 0.09,
purchasedCredits: 0,
freeCredits: 0,
currentPeriodEnd: new Date(current + 8 * DAY_MS).toISOString(),
}),
});

router.recordFailure("alpha", {
statusCode: 400,
errorMessage:
"You have insufficient credits to make this request. Please purchase more credits to continue using the service.",
});

current = now + 10_000;
await router.refreshAllBilling({ force: true });

const entry = router.snapshot().find((state) => state.credential.id === "alpha");
expect(entry?.disabledReason).toBe("insufficient_credits");
expect(entry?.disabledUntil).toBe(now + 300_000);
});

it("clears a 402 billing cooldown when fresh billing shows credits", async () => {
let current = now;
const router = new CommandCodeCredentialRouter({
credentials: [credential("alpha")],
policy: "round_robin",
billingRefreshMs: 300_000,
cooldownMs: 60_000,
now: () => current,
billingProvider: async () => ({
fetchedAt: current,
monthlyCredits: 5,
purchasedCredits: 0,
freeCredits: 0,
currentPeriodEnd: new Date(current + 8 * DAY_MS).toISOString(),
}),
});

router.recordFailure("alpha", { statusCode: 402 });
current = now + 10_000;
await router.refreshAllBilling({ force: true });

const entry = router.snapshot().find((state) => state.credential.id === "alpha");
expect(entry?.disabledReason).toBeUndefined();
expect(entry?.disabledUntil).toBe(0);
});

it("does not cool down a credential for a plain 400 failure", () => {
const router = new CommandCodeCredentialRouter({
credentials: [credential("alpha")],
policy: "round_robin",
billingRefreshMs: 300_000,
cooldownMs: 60_000,
now: () => now,
});

router.recordFailure("alpha", {
statusCode: 400,
errorMessage: "Model not allowed for this plan.",
});
router.recordFailure("alpha", { statusCode: 400 });

const entry = router.snapshot().find((state) => state.credential.id === "alpha");
expect(entry?.disabledReason).toBeUndefined();
expect(entry?.disabledUntil).toBe(0);
});

it("skips keys with an exhausted rolling window regardless of policy priority", async () => {
const router = new CommandCodeCredentialRouter({
credentials: [{ ...credential("quota-hit"), weight: 100 }, credential("open")],
Expand Down
Loading