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
111 changes: 110 additions & 1 deletion apps/server/src/usage/cliproxyApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientResponse } from "effect/http";
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http";

import { makeCliproxyApi } from "./cliproxyApi.ts";

Expand Down Expand Up @@ -114,6 +115,114 @@ function fixture(
};
}

/** A hub API whose every request fails the way `fail` says. */
const unreachableHub = (
fail: (
request: Parameters<Parameters<typeof HttpClient.make>[0]>[0],
) => HttpClientError.HttpClientError,
) =>
makeCliproxyApi.pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make((request) => Effect.fail(fail(request))),
),
Effect.provide(NodeCrypto.layer),
);

describe("CLIProxyAPI hub failures", () => {
it.effect("names the TLS code when the hub's certificate is not trusted", () =>
Effect.gen(function* () {
const api = yield* unreachableHub(
(request) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request,
// Node's fetch wraps the TLS error, which carries the code, in a generic TypeError.
cause: new TypeError("fetch failed", {
cause: Object.assign(new Error("unable to get local issuer certificate"), {
code: "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
}),
}),
}),
}),
);
const error = yield* Effect.flip(api.readAccounts(config));
expect(error.detail).toContain("UNABLE_TO_GET_ISSUER_CERT_LOCALLY");
expect(error.detail).toContain("NODE_EXTRA_CA_CERTS");
}),
);

it.effect("ignores a transport code that is not a short Node error code", () =>
Effect.gen(function* () {
const api = yield* unreachableHub(
(request) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request,
cause: Object.assign(new Error("odd"), { code: "X".repeat(200) }),
}),
}),
);
const error = yield* Effect.flip(api.readAccounts(config));
expect(error.detail).toBe("The hub management request failed.");
}),
);

it.effect("names the status, not the body, when the hub rejects the request", () =>
Effect.gen(function* () {
const test = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({ error: "management-secret is wrong" }, { status: 401 }),
),
),
);
const api = yield* makeCliproxyApi.pipe(
Effect.provideService(HttpClient.HttpClient, test),
Effect.provide(NodeCrypto.layer),
);
const error = yield* Effect.flip(api.readAccounts(config));
expect(error.detail).toContain("401");
expect(error.detail).not.toContain("management-secret");
}),
);

it.effect("says when the hub does not answer in time", () =>
Effect.gen(function* () {
const api = yield* makeCliproxyApi.pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make(() => Effect.never),
),
Effect.provide(NodeCrypto.layer),
);
const pending = yield* Effect.flip(api.readAccounts(config)).pipe(
Effect.forkChild({ startImmediately: true }),
);
yield* TestClock.adjust("15 seconds");
const error = yield* Fiber.join(pending);
expect(error.detail).toContain("did not answer within 15 seconds");
}),
);

it.effect("keeps a malformed account list generic", () =>
Effect.gen(function* () {
const test = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(request, Response.json({ nope: "hub-internal" })),
),
);
const api = yield* makeCliproxyApi.pipe(
Effect.provideService(HttpClient.HttpClient, test),
Effect.provide(NodeCrypto.layer),
);
const error = yield* Effect.flip(api.readAccounts(config));
expect(error.detail).toBe("The hub could not list accounts.");
}),
);
});

describe("CLIProxyAPI built-in management API", () => {
it.effect(
"reads both accounts and their earliest unexpired credits without plugin endpoints",
Expand Down
48 changes: 44 additions & 4 deletions apps/server/src/usage/cliproxyApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import {
type UsageLimitSourceAccount,
type UsageLimitSourceConfig,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Hex from "effect/encoding/Hex";
import * as Schema from "effect/Schema";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";

import { codexPlanLabel } from "../provider/CodexProvider.ts";
import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts";
Expand Down Expand Up @@ -88,6 +89,43 @@ const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(Credit
const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeUsage));
const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage));
const isUsageLimitSourceError = Schema.is(UsageLimitSourceError);

// Codes that mean the certificate chain is not trusted, which an extra CA can fix. Expired or
// mismatched certificates are reported by code only.
const UNTRUSTED_CERTIFICATE_CODES = new Set([
"DEPTH_ZERO_SELF_SIGNED_CERT",
"SELF_SIGNED_CERT_IN_CHAIN",
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
]);

/** The Node error code under a failed fetch, which wraps it in a generic `TypeError`. */
const nodeErrorCode = (cause: unknown) => {
for (let error = cause, depth = 0; error instanceof Error && depth < 3; depth += 1) {
const code = (error as { readonly code?: unknown }).code;
if (typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code)) return code;
error = error.cause;
}
return undefined;
};

// Hub responses and nested error messages can carry keys or tokens, so only the failure's kind,
// HTTP status, or Node error code reaches the client.
const describeManagementFailure = (error: unknown) => {
if (Cause.isTimeoutError(error)) return "The hub did not answer within 15 seconds.";
if (HttpClientError.isHttpClientError(error)) {
const reason = error.reason;
if (reason._tag === "StatusCodeError") {
return `The hub answered with HTTP ${reason.response.status}.`;
}
const code = reason._tag === "TransportError" ? nodeErrorCode(reason.cause) : undefined;
if (code !== undefined && UNTRUSTED_CERTIFICATE_CODES.has(code)) {
return `The hub's TLS certificate is not trusted (${code}). If it comes from a private CA, add that CA to NODE_EXTRA_CA_CERTS.`;
}
if (code !== undefined) return `The hub request failed (${code}).`;
}
return "The hub management request failed.";
};
const decodeConsumeResponse = Schema.decodeUnknownEffect(
Schema.fromJsonString(
Schema.Struct({
Expand Down Expand Up @@ -144,7 +182,7 @@ export const makeCliproxyApi = Effect.gen(function* () {
Effect.flatMap((response) => response.json),
Effect.timeout("15 seconds"),
Effect.mapError(
() => new UsageLimitSourceError({ detail: "The hub management request failed." }),
(error) => new UsageLimitSourceError({ detail: describeManagementFailure(error) }),
),
);
return response;
Expand Down Expand Up @@ -309,8 +347,10 @@ export const makeCliproxyApi = Effect.gen(function* () {
config: UsageLimitSourceConfig,
): Effect.fn.Return<ReadonlyArray<UsageLimitSourceAccount>, UsageLimitSourceError> {
const accounts = yield* authFiles(config).pipe(
Effect.mapError(
() => new UsageLimitSourceError({ detail: "The hub could not list accounts." }),
Effect.mapError((error) =>
isUsageLimitSourceError(error)
? error
: new UsageLimitSourceError({ detail: "The hub could not list accounts." }),
),
);
return yield* Effect.forEach(
Expand Down
Loading