Skip to content
Open
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
38 changes: 38 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
resolveProviderInstanceEnabled,
ServerSettings,
ServerSettingsPatch,
UsageLimitSourceId,
} from "@t3tools/contracts";
import { createModelSelection } from "@t3tools/shared/model";
import { assert, it } from "@effect/vitest";
Expand Down Expand Up @@ -1181,6 +1182,43 @@ it.layer(NodeServices.layer)("server settings", (it) => {
);
}

// The secret path keys off `managementKey`, which every source kind carries,
// so a credit source must travel the same route as a hub without its own code.
it.effect("stores an OpenRouter key outside settings.json and restores it on edit", () =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const sourceId = UsageLimitSourceId.make("openrouter");

const next = yield* serverSettings.updateSettings({
usageLimitSources: {
[sourceId]: { kind: "openrouter", managementKey: "sk-or-provisioning", enabled: true },
},
});
assert.equal(next.usageLimitSources[sourceId]?.kind, "openrouter");
assert.equal(next.usageLimitSources[sourceId]?.managementKey, "sk-or-provisioning");

const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
assert.notInclude(raw, "sk-or-provisioning");

// A client edits the label while echoing the redaction marker back; the
// stored key must survive rather than being overwritten with the marker.
const relabelled = yield* serverSettings.updateSettings({
usageLimitSources: {
[sourceId]: {
kind: "openrouter",
label: "Work account",
managementKey: "••••••",
enabled: true,
},
},
});
assert.equal(relabelled.usageLimitSources[sourceId]?.managementKey, "sk-or-provisioning");
assert.equal(relabelled.usageLimitSources[sourceId]?.label, "Work account");
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("stores sensitive provider instance environment values outside settings.json", () =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
Expand Down
26 changes: 25 additions & 1 deletion apps/server/src/usage/UsageLimitSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import * as Stream from "effect/Stream";
import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import { ServerSettingsService } from "../serverSettings.ts";
import { makeCliproxyApi } from "./cliproxyApi.ts";
import { makeOpenRouterApi } from "./openrouterApi.ts";

export class UsageLimitSources extends Context.Service<
UsageLimitSources,
Expand All @@ -53,6 +54,7 @@ export class UsageLimitSources extends Context.Service<

function sourceLabel(id: string, config: UsageLimitSourceConfig): string {
if (config.label) return config.label;
if (config.kind === "openrouter") return "OpenRouter";
try {
return new URL(config.url).host;
} catch {
Expand All @@ -63,6 +65,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 openrouter = yield* makeOpenRouterApi;
const settingsService = yield* ServerSettingsService;
const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
const stateRef = yield* Ref.make<ReadonlyArray<UsageLimitSourceSnapshot>>([]);
Expand All @@ -78,7 +81,22 @@ 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) {
return { ...base, accounts: [], error: "No management key configured." };
return {
...base,
accounts: [],
error:
config.kind === "openrouter" ? "No API key configured." : "No management key configured.",
};
}
// A credit source reports money left rather than pooled accounts, so it
// fills `credits` and leaves `accounts` empty.
if (config.kind === "openrouter") {
const credits = yield* openrouter.readCredits(config).pipe(Effect.result);
if (credits._tag === "Failure") {
yield* Effect.logDebug("usage limit source read failed", { id, cause: credits.failure });
return { ...base, accounts: [], error: credits.failure.detail };
}
return { ...base, accounts: [], credits: credits.success };
}
const accounts = yield* api.readAccounts(config).pipe(Effect.result);
if (accounts._tag === "Failure") {
Expand Down Expand Up @@ -129,6 +147,12 @@ export const make = Effect.gen(function* () {
detail: "The usage limit source is missing or disabled.",
});
}
// Only a hub banks redeemable reset credits; a credit balance has none.
if (config.kind !== "cliproxy") {
return yield* new UsageLimitSourceError({
detail: "This usage limit source has no reset credits.",
});
}
const result = yield* api.consume(config, input.accountId, input.creditId);
const snapshot = yield* readSource(input.sourceId, config);
const previous = yield* Ref.get(stateRef);
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 @@ -5,7 +5,7 @@ import {
UsageLimitSourceError,
type ProviderConsumeResetCreditResult,
type UsageLimitSourceAccount,
type UsageLimitSourceConfig,
type CliproxyUsageLimitSourceConfig,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -116,7 +116,7 @@ export const makeCliproxyApi = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;

const management = Effect.fn("CliproxyApi.management")(function* (
config: UsageLimitSourceConfig,
config: CliproxyUsageLimitSourceConfig,
path: string,
body?: unknown,
) {
Expand All @@ -140,13 +140,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 @@ -180,7 +182,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 @@ -197,7 +199,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 @@ -293,7 +295,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(
Expand All @@ -311,7 +313,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
170 changes: 170 additions & 0 deletions apps/server/src/usage/openrouterApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { describe, expect, it } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";

import { makeOpenRouterApi } from "./openrouterApi.ts";

const config = {
kind: "openrouter",
managementKey: "sk-or-secret",
enabled: true,
} as const;

/** Headers arrive, the body never does: the shape that used to hang forever. */
const STALLED_BODY = Symbol("stalled-body");
type Reply = { status: number; body: unknown } | typeof STALLED_BODY;

/** One canned reply per endpoint path, plus the paths actually requested. */
function fixture(replies: Record<string, Reply>) {
const paths: string[] = [];
const http = HttpClient.make((request) =>
Effect.sync(() => {
expect(request.headers.authorization).toBe("Bearer sk-or-secret");
const path = new URL(request.url).pathname;
paths.push(path);
const reply = replies[path] ?? { status: 404, body: {} };
if (reply === STALLED_BODY) {
return HttpClientResponse.fromWeb(
request,
new Response(new ReadableStream({ start() {} }), { status: 200 }),
);
}
return HttpClientResponse.fromWeb(
request,
Response.json(reply.body, { status: reply.status }),
);
}),
);
return { paths, api: makeOpenRouterApi.pipe(Effect.provideService(HttpClient.HttpClient, http)) };
}

describe("OpenRouter credit reads", () => {
it.effect("reports the account balance a provisioning key can see", () =>
Effect.gen(function* () {
const test = fixture({
"/api/v1/credits": {
status: 200,
body: { data: { total_credits: 100.5, total_usage: 25.75 } },
},
});
const api = yield* test.api;

expect(yield* api.readCredits(config)).toEqual({
scope: "account",
usedUsd: 25.75,
purchasedUsd: 100.5,
remainingUsd: 74.75,
});
// The narrower read is never made when the account answers.
expect(test.paths).toEqual(["/api/v1/credits"]);
}),
);

it.effect("falls back to the key's own allowance when OpenRouter refuses with 403", () =>
Effect.gen(function* () {
const test = fixture({
"/api/v1/credits": {
status: 403,
body: {
error: { code: 403, message: "Only management keys can perform this operation" },
},
},
"/api/v1/key": {
status: 200,
body: { data: { usage: 1.42, limit: 10, limit_remaining: 8.58, is_free_tier: false } },
},
});
const api = yield* test.api;

expect(yield* api.readCredits(config)).toEqual({
scope: "key",
usedUsd: 1.42,
limitUsd: 10,
remainingUsd: 8.58,
isFreeTier: false,
});
expect(test.paths).toEqual(["/api/v1/credits", "/api/v1/key"]);
}),
);

it.effect("leaves an uncapped key with spend only, so no balance is invented", () =>
Effect.gen(function* () {
const test = fixture({
"/api/v1/credits": { status: 403, body: {} },
"/api/v1/key": {
status: 200,
body: { data: { usage: 3.5, limit: null, limit_remaining: null, is_free_tier: true } },
},
});
const api = yield* test.api;

expect(yield* api.readCredits(config)).toEqual({
scope: "key",
usedUsd: 3.5,
isFreeTier: true,
});
}),
);

it.effect("reports a rejected key rather than retrying it against the other endpoint", () =>
Effect.gen(function* () {
const test = fixture({ "/api/v1/credits": { status: 401, body: {} } });
const api = yield* test.api;
const result = yield* api.readCredits(config).pipe(Effect.result);

expect(result._tag).toBe("Failure");
if (result._tag === "Failure") {
expect(result.failure.detail).toBe("OpenRouter rejected the API key.");
}
expect(test.paths).toEqual(["/api/v1/credits"]);
}),
);

it.effect("reports a body it cannot read instead of publishing a partial balance", () =>
Effect.gen(function* () {
const test = fixture({
"/api/v1/credits": { status: 200, body: { data: { total_credits: "lots" } } },
});
const api = yield* test.api;
const result = yield* api.readCredits(config).pipe(Effect.result);

expect(result._tag).toBe("Failure");
if (result._tag === "Failure") {
expect(result.failure.detail).toBe("OpenRouter returned an unexpected balance.");
}
}),
);

// The read runs while UsageLimitSources holds its refresh lock, so a body
// that never completes would starve every later refresh and redemption.
it.effect("gives up on a response whose body never arrives", () =>
Effect.gen(function* () {
const test = fixture({ "/api/v1/credits": STALLED_BODY });
const api = yield* test.api;
const fiber = yield* api.readCredits(config).pipe(Effect.result, Effect.forkScoped);

yield* Effect.yieldNow;
yield* TestClock.adjust(Duration.seconds(16));
const result = yield* Fiber.join(fiber);

expect(result._tag).toBe("Failure");
if (result._tag === "Failure") {
expect(result.failure.detail).toBe("Could not reach OpenRouter.");
}
}),
);

it.effect("never calls OpenRouter without a key", () =>
Effect.gen(function* () {
const test = fixture({});
const api = yield* test.api;
const result = yield* api.readCredits({ ...config, managementKey: "" }).pipe(Effect.result);

expect(result._tag).toBe("Failure");
expect(test.paths).toEqual([]);
}),
);
});
Loading
Loading