Skip to content
Closed
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
8 changes: 6 additions & 2 deletions apps/server/src/settingsSecrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
CodexSettings,
EasDeviceHostConfig,
type ServerSettings,
UsageLimitSourceConfig,
CliproxyUsageLimitSourceConfig,
CommandUsageLimitSourceConfig,
secretSettingsFields,
} from "@t3tools/contracts";
import { acpRegistryClient } from "@t3tools/provider-acp-registry/client";
Expand All @@ -31,7 +32,10 @@ const providerSecretFields = new Map([
[definition.driverKind, secretSettingsFields(definition.settingsSchema)] as const,
),
]);
const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig);
const usageLimitSecretFields = [
...secretSettingsFields(CliproxyUsageLimitSourceConfig),
...secretSettingsFields(CommandUsageLimitSourceConfig),
];
const easDeviceHostSecretFields = secretSettingsFields(EasDeviceHostConfig);
const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url");

Expand Down
195 changes: 195 additions & 0 deletions apps/server/src/usage/UsageLimitSources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { HttpClient } from "effect/http";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as ServerSettings from "../serverSettings.ts";
Expand Down Expand Up @@ -78,6 +81,198 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () =>
HttpClient.HttpClient,
HttpClient.make(() => Effect.die("Unexpected network request")),
),
commandSpawnerLayer({}),
NodeCrypto.layer,
]),
Effect.scoped,
),
);

const VALID_COMMAND_OUTPUT = JSON.stringify({
accounts: [
{
id: "droid-b",
driver: "droid",
email: "support@scenextras.com",
plan: "pro",
usageLimits: {
checkedAt: "2026-10-23T00:00:00.000Z",
windows: [
{
id: "monthly",
kind: "monthly",
label: "Monthly quota",
usedPercent: 41,
},
],
},
},
],
});

const commandSpawnerLayer = (
outputs: Readonly<
Record<string, { stdout: string | Uint8Array; stderr?: string; exitCode: number }>
>,
) =>
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
const result = "command" in command ? outputs[command.command] : undefined;
if (result === undefined) {
return Effect.die(
`Unexpected command: ${"command" in command ? command.command : "piped"}`,
);
}
const encode = new TextEncoder();
const stdoutBytes =
typeof result.stdout === "string" ? encode.encode(result.stdout) : result.stdout;
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: stdoutBytes.length > 0 ? Stream.make(stdoutBytes) : Stream.empty,
stderr:
(result.stderr ?? "").length > 0
? Stream.make(encode.encode(result.stderr ?? ""))
: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}),
);
}),
);

it.effect("reads accounts from command sources and reports command failures", () =>
Effect.gen(function* () {
const sources = yield* UsageLimitSources.make;
yield* sources.refresh;
const snapshots = yield* sources.current;

const byId = new Map(snapshots.map((snapshot) => [snapshot.id, snapshot]));
expect(snapshots.length).toBe(5);

const ok = byId.get(UsageLimitSourceId.make("command-ok"));
expect(ok?.kind).toBe("command");
expect(ok?.label).toBe("/usr/local/bin/droidpool");
expect(ok?.error).toBeUndefined();
expect(ok?.accounts).toEqual([
{
id: "droid-b",
driver: "droid",
email: "support@scenextras.com",
plan: "pro",
usageLimits: {
checkedAt: "2026-10-23T00:00:00.000Z",
windows: [
{
id: "monthly",
kind: "monthly",
label: "Monthly quota",
usedPercent: 41,
},
],
},
},
]);

const named = byId.get(UsageLimitSourceId.make("command-named"));
expect(named?.label).toBe("Pool status");
expect(named?.accounts.length).toBe(1);

const malformed = byId.get(UsageLimitSourceId.make("command-bad-json"));
expect(malformed?.accounts).toEqual([]);
expect(malformed?.error).toBe(
'The command\'s output is not valid usage-limit JSON (expected { "accounts": [...] }).',
);

const failing = byId.get(UsageLimitSourceId.make("command-fails"));
expect(failing?.accounts).toEqual([]);
expect(failing?.error).toBe(
"The command failed with exit code 3. pool is locked by another process",
);

const overflow = byId.get(UsageLimitSourceId.make("command-overflow"));
expect(overflow?.accounts).toEqual([]);
expect(overflow?.error).toBe("The command produced more than 4 MiB of output.");

const reset = yield* sources
.consumeResetCredit({
sourceId: UsageLimitSourceId.make("command-ok"),
accountId: "droid-b",
creditId: "credit-1",
})
.pipe(Effect.result);
expect(reset._tag).toBe("Failure");
if (reset._tag === "Failure") {
expect(reset.failure.detail).toBe(
"Reset credits are only available for CLIProxyAPI hub sources.",
);
}
}).pipe(
Effect.provide([
ServerSettings.layerTest({
usageLimitSources: {
[UsageLimitSourceId.make("command-ok")]: {
kind: "command",
command: "/usr/local/bin/droidpool",
args: ["list", "--json"],
enabled: true,
},
[UsageLimitSourceId.make("command-named")]: {
kind: "command",
label: "Pool status",
command: "/usr/local/bin/droidpool2",
enabled: true,
},
[UsageLimitSourceId.make("command-bad-json")]: {
kind: "command",
command: "/usr/local/bin/bad-json",
enabled: true,
},
[UsageLimitSourceId.make("command-fails")]: {
kind: "command",
command: "/usr/local/bin/fails",
enabled: true,
},
[UsageLimitSourceId.make("command-overflow")]: {
kind: "command",
command: "/usr/local/bin/overflow",
enabled: true,
},
[UsageLimitSourceId.make("command-off")]: {
kind: "command",
command: "/usr/local/bin/droidpool",
enabled: false,
},
},
}),
Layer.mock(BackgroundPolicy.BackgroundPolicy)({
shouldRunScopeWork: () => Effect.succeed(false),
}),
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make(() => Effect.die("Unexpected network request")),
),
commandSpawnerLayer({
"/usr/local/bin/droidpool": { stdout: VALID_COMMAND_OUTPUT, exitCode: 0 },
"/usr/local/bin/droidpool2": { stdout: VALID_COMMAND_OUTPUT, exitCode: 0 },
"/usr/local/bin/bad-json": { stdout: "not json at all", exitCode: 0 },
"/usr/local/bin/fails": {
stdout: "",
stderr: "warning: something minor\npool is locked by another process\n",
exitCode: 3,
},
"/usr/local/bin/overflow": {
stdout: new Uint8Array(5 * 1024 * 1024),
exitCode: 0,
},
}),
NodeCrypto.layer,
]),
Effect.scoped,
Expand Down
25 changes: 22 additions & 3 deletions apps/server/src/usage/UsageLimitSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import * as Stream from "effect/Stream";
import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as Settings from "../serverSettings.ts";
import { makeCliproxyApi } from "./cliproxyApi.ts";
import { makeCommandApi } from "./commandApi.ts";

export class UsageLimitSources extends Context.Service<
UsageLimitSources,
Expand All @@ -39,6 +40,7 @@ export class UsageLimitSources extends Context.Service<
function sourceLabel(id: string, config: UsageLimitSourceConfig): string {
if (config.label) return config.label;
const fallback = id.trim() || "Usage limit source";
if (config.kind === "command") return config.command || fallback;
try {
return new URL(config.url).host || fallback;
} catch {
Expand All @@ -49,6 +51,7 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string {
/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* () {
const api = yield* makeCliproxyApi;
const commandApi = yield* makeCommandApi;
const settingsService = yield* Settings.ServerSettingsService;
const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
const stateRef = yield* Ref.make<ReadonlyArray<UsageLimitSourceSnapshot>>([]);
Expand All @@ -63,10 +66,16 @@ export const make = Effect.gen(function* () {
) {
const checkedAt = DateTime.formatIso(yield* DateTime.now);
const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const;
if (config.managementKey.length === 0) {
const read =
config.kind === "command"
? commandApi.readAccounts(config)
: config.managementKey.length === 0
? null
: api.readAccounts(config);
if (read === null) {
return { ...base, accounts: [], error: "No management key configured." };
}
const accounts = yield* api.readAccounts(config).pipe(Effect.result);
const accounts = yield* read.pipe(Effect.result);
if (accounts._tag === "Failure") {
yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure });
return { ...base, accounts: [], error: accounts.failure.detail };
Expand Down Expand Up @@ -110,7 +119,17 @@ export const make = Effect.gen(function* () {
Effect.mapError(() => new UsageLimitSourceError({ detail: "Could not read hub settings." })),
);
const config = settings.usageLimitSources[input.sourceId];
if (!config?.enabled || !config.managementKey) {
if (!config?.enabled) {
return yield* new UsageLimitSourceError({
detail: "The usage limit source is missing or disabled.",
});
}
if (config.kind !== "cliproxy") {
return yield* new UsageLimitSourceError({
detail: "Reset credits are only available for CLIProxyAPI hub sources.",
});
}
if (!config.managementKey) {
return yield* new UsageLimitSourceError({
detail: "The usage limit source is missing or disabled.",
});
Expand Down
18 changes: 10 additions & 8 deletions apps/server/src/usage/cliproxyApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
UsageLimitSourceError,
type ProviderConsumeResetCreditResult,
type UsageLimitSourceAccount,
type UsageLimitSourceConfig,
type CliproxyUsageLimitSourceConfig,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -164,7 +164,7 @@ export const makeCliproxyApi = Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;

const management = Effect.fn("CliproxyApi.management")(function* (
config: UsageLimitSourceConfig,
config: CliproxyUsageLimitSourceConfig,
path: string,
body?: unknown,
) {
Expand Down Expand Up @@ -192,13 +192,15 @@ export const makeCliproxyApi = Effect.gen(function* () {
return response;
});

const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (config: UsageLimitSourceConfig) {
const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (
config: CliproxyUsageLimitSourceConfig,
) {
const response = yield* management(config, "auth-files");
return (yield* decodeAuthFiles(response)).files;
});

const apiCall = Effect.fn("CliproxyApi.apiCall")(function* (
config: UsageLimitSourceConfig,
config: CliproxyUsageLimitSourceConfig,
account: typeof AuthFile.Type,
url: string,
data?: unknown,
Expand Down Expand Up @@ -237,7 +239,7 @@ export const makeCliproxyApi = Effect.gen(function* () {
});

const credits = Effect.fn("CliproxyApi.credits")(function* (
config: UsageLimitSourceConfig,
config: CliproxyUsageLimitSourceConfig,
account: typeof AuthFile.Type,
) {
const body = yield* apiCall(config, account, CREDIT_URL);
Expand All @@ -254,7 +256,7 @@ export const makeCliproxyApi = Effect.gen(function* () {
});

const readAccount = Effect.fn("CliproxyApi.readAccount")(function* (
config: UsageLimitSourceConfig,
config: CliproxyUsageLimitSourceConfig,
account: typeof AuthFile.Type,
) {
const checkedAt = DateTime.formatIso(yield* DateTime.now);
Expand Down Expand Up @@ -354,7 +356,7 @@ export const makeCliproxyApi = Effect.gen(function* () {
});

const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* (
config: UsageLimitSourceConfig,
config: CliproxyUsageLimitSourceConfig,
): Effect.fn.Return<ReadonlyArray<UsageLimitSourceAccount>, UsageLimitSourceError> {
const accounts = yield* authFiles(config).pipe(
Effect.mapError((error) =>
Expand All @@ -374,7 +376,7 @@ export const makeCliproxyApi = Effect.gen(function* () {
});

const consume = Effect.fn("CliproxyApi.consume")(function* (
config: UsageLimitSourceConfig,
config: CliproxyUsageLimitSourceConfig,
accountId: string,
creditId: string,
): Effect.fn.Return<ProviderConsumeResetCreditResult, UsageLimitSourceError> {
Expand Down
Loading
Loading