mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
* feat: report usage command quotas as percentages Convert @@om-usage and the HTTP usage endpoint to report personal API key quotas as remaining percentages while keeping USD amounts out of the command output. Scale provider quota remaining percentages by the configured quota cutoff so the protected reserve reads as 0% left. Restore provider USD cost drilldown in the quota dashboard.\n\nAlso sync the 3.8.43 i18n changelog mirrors so the docs-sync pre-commit gate remains green.\n\nTests: DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/internal-usage-command.test.ts; DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/api-key-usage-limits.test.ts; DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/provider-window-costs.test.ts; DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/api-manager-usage-command.test.ts tests/unit/apikeys-usage-command.test.ts; npx eslint <changed files>; npm run typecheck:core; npm run build; npm run check:migration-numbering; npm run check:docs-sync; docker build --target runner-base (cherry picked from commitf66abd2028) * fix: honor observed provider quota resets Detect same-resetAt quota resets when provider usage drops back to the reset floor, and prefer that observed snapshot over stale recorded weekly events for provider USD windows and API-key USD quotas.\n\nTests: npx eslint changed files\nTests: npm run typecheck:core\nTests: DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/lib/quota-reset-events.test.ts tests/unit/provider-window-costs.test.ts tests/unit/api-key-usage-limits.test.ts\nTests: npm run build\nTests: docker build --target runner-base --build-arg OMNIROUTE_BUILD_MEMORY_MB=4096 -t omniroute:quota-reset-window-20260702002300 . (cherry picked from commit39c12a6f17) * docs(changelog): credit usage quota percentages extraction from #5863 Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> --------- Co-authored-by: Wital <wital@example.com>
437 lines
12 KiB
TypeScript
437 lines
12 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-api-key-usage-limits-"));
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "usage-limit-test-secret";
|
|
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
const localDb = await import("../../src/lib/localDb.ts");
|
|
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
|
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
|
|
const usageLimits = await import("../../src/lib/usage/apiKeyUsageLimits.ts");
|
|
|
|
const NOW = Date.parse("2026-06-19T20:00:00.000Z");
|
|
|
|
async function resetStorage() {
|
|
core.resetDbInstance();
|
|
apiKeysDb.resetApiKeyState();
|
|
usageHistory.clearPendingRequests();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
}
|
|
|
|
test.beforeEach(async () => {
|
|
await resetStorage();
|
|
});
|
|
|
|
test.after(() => {
|
|
core.resetDbInstance();
|
|
apiKeysDb.resetApiKeyState();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
test("API key USD usage limits persist and default off", async () => {
|
|
const created = await apiKeysDb.createApiKey("Usage Limit Key", "machine-limit-01");
|
|
|
|
let metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
|
assert.equal(metadata?.usageLimitEnabled, false);
|
|
assert.equal(metadata?.dailyUsageLimitUsd, null);
|
|
assert.equal(metadata?.weeklyUsageLimitUsd, null);
|
|
|
|
await apiKeysDb.updateApiKeyPermissions(created.id, {
|
|
usageLimitEnabled: true,
|
|
dailyUsageLimitUsd: 10.5,
|
|
weeklyUsageLimitUsd: 50,
|
|
});
|
|
apiKeysDb.clearApiKeyCaches();
|
|
|
|
metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
|
assert.equal(metadata?.usageLimitEnabled, true);
|
|
assert.equal(metadata?.dailyUsageLimitUsd, 10.5);
|
|
assert.equal(metadata?.weeklyUsageLimitUsd, 50);
|
|
});
|
|
|
|
test("getApiKeyUsageLimitStatus aligns weekly USD spend with provider resetAt when available", async () => {
|
|
await localDb.updatePricing({
|
|
claude: {
|
|
"claude-opus-4-8": {
|
|
input: 1,
|
|
cached: 1,
|
|
output: 1,
|
|
reasoning: 1,
|
|
cache_creation: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
const created = await apiKeysDb.createApiKey("Metered Key", "machine-limit-02");
|
|
await apiKeysDb.updateApiKeyPermissions(created.id, {
|
|
usageLimitEnabled: true,
|
|
dailyUsageLimitUsd: 10,
|
|
weeklyUsageLimitUsd: 20,
|
|
});
|
|
|
|
await usageHistory.saveRequestUsage({
|
|
provider: "claude",
|
|
model: "claude-opus-4-8",
|
|
apiKeyId: created.id,
|
|
apiKeyName: "Metered Key",
|
|
tokens: { input: 2_000_000, output: 0 },
|
|
success: true,
|
|
timestamp: "2026-06-19T12:00:00.000Z",
|
|
});
|
|
await usageHistory.saveRequestUsage({
|
|
provider: "claude",
|
|
model: "claude-opus-4-8",
|
|
apiKeyId: created.id,
|
|
apiKeyName: "Metered Key",
|
|
tokens: { input: 3_000_000, output: 0 },
|
|
success: true,
|
|
timestamp: "2026-06-18T21:00:00.000Z",
|
|
});
|
|
await usageHistory.saveRequestUsage({
|
|
provider: "claude",
|
|
model: "claude-opus-4-8",
|
|
apiKeyId: created.id,
|
|
apiKeyName: "Metered Key",
|
|
tokens: { input: 7_000_000, output: 0 },
|
|
success: true,
|
|
timestamp: "2026-06-18T12:00:00.000Z",
|
|
});
|
|
|
|
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
|
assert.ok(metadata);
|
|
|
|
const weeklyResetAt = "2026-06-25T20:00:00.000Z";
|
|
const status = await usageLimits.getApiKeyUsageLimitStatus(
|
|
{ ...metadata, allowedConnections: ["conn-claude"] },
|
|
{
|
|
now: () => NOW,
|
|
getProviderConnectionById: async () => ({
|
|
id: "conn-claude",
|
|
provider: "claude",
|
|
isActive: true,
|
|
}),
|
|
getProviderConnections: async () => [],
|
|
getProviderLimitsCache: () => ({
|
|
plan: "Claude Max",
|
|
quotas: {
|
|
"weekly (7d)": {
|
|
used: 27,
|
|
total: 100,
|
|
resetAt: weeklyResetAt,
|
|
},
|
|
},
|
|
message: null,
|
|
fetchedAt: new Date(NOW).toISOString(),
|
|
}),
|
|
getAllProviderLimitsCache: () => ({}),
|
|
}
|
|
);
|
|
|
|
assert.equal(status.enabled, true);
|
|
assert.equal(status.dailySpentUsd, 2);
|
|
assert.equal(status.weeklySpentUsd, 5);
|
|
assert.equal(status.dailyLimitUsd, 10);
|
|
assert.equal(status.weeklyLimitUsd, 20);
|
|
assert.equal(status.dailyResetAtIso, "2026-06-20T03:00:00.000Z");
|
|
assert.equal(status.weeklyWindowStartIso, "2026-06-18T20:00:00.000Z");
|
|
assert.equal(status.weeklyResetAtIso, weeklyResetAt);
|
|
assert.equal(status.dailyExceeded, false);
|
|
assert.equal(status.weeklyExceeded, false);
|
|
});
|
|
|
|
test("getApiKeyUsageLimitStatus cuts weekly USD spend at observed provider quota reset", async () => {
|
|
await localDb.updatePricing({
|
|
claude: {
|
|
"claude-opus-4-8": {
|
|
input: 1,
|
|
cached: 1,
|
|
output: 1,
|
|
reasoning: 1,
|
|
cache_creation: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
const created = await apiKeysDb.createApiKey("Reset Cut Key", "machine-limit-reset");
|
|
await apiKeysDb.updateApiKeyPermissions(created.id, {
|
|
usageLimitEnabled: true,
|
|
dailyUsageLimitUsd: 10,
|
|
weeklyUsageLimitUsd: 20,
|
|
});
|
|
|
|
await usageHistory.saveRequestUsage({
|
|
provider: "claude",
|
|
model: "claude-opus-4-8",
|
|
apiKeyId: created.id,
|
|
apiKeyName: "Reset Cut Key",
|
|
tokens: { input: 7_000_000, output: 0 },
|
|
success: true,
|
|
timestamp: "2026-06-19T23:30:00.000Z",
|
|
});
|
|
await usageHistory.saveRequestUsage({
|
|
provider: "claude",
|
|
model: "claude-opus-4-8",
|
|
apiKeyId: created.id,
|
|
apiKeyName: "Reset Cut Key",
|
|
tokens: { input: 2_000_000, output: 0 },
|
|
success: true,
|
|
timestamp: "2026-06-20T02:00:00.000Z",
|
|
});
|
|
|
|
const db = core.getDbInstance();
|
|
const insertSnapshot = db.prepare(`
|
|
INSERT INTO quota_snapshots (
|
|
provider,
|
|
connection_id,
|
|
window_key,
|
|
remaining_percentage,
|
|
is_exhausted,
|
|
next_reset_at,
|
|
window_duration_ms,
|
|
raw_data,
|
|
created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
insertSnapshot.run(
|
|
"claude",
|
|
"conn-claude",
|
|
"weekly (7d)",
|
|
72,
|
|
0,
|
|
"2026-06-25T23:00:00.000Z",
|
|
null,
|
|
null,
|
|
"2026-06-19T23:55:00.000Z"
|
|
);
|
|
insertSnapshot.run(
|
|
"claude",
|
|
"conn-claude",
|
|
"weekly (7d)",
|
|
100,
|
|
0,
|
|
"2026-06-25T23:00:00.000Z",
|
|
null,
|
|
null,
|
|
"2026-06-20T01:42:52.590Z"
|
|
);
|
|
insertSnapshot.run(
|
|
"claude",
|
|
"conn-claude",
|
|
"weekly (7d)",
|
|
99,
|
|
0,
|
|
"2026-06-25T23:00:00.000Z",
|
|
null,
|
|
null,
|
|
"2026-06-20T02:10:00.000Z"
|
|
);
|
|
db.prepare(
|
|
`
|
|
INSERT INTO provider_quota_reset_events
|
|
(provider, connection_id, window_key, window_started_at, window_resets_at,
|
|
observed_at, previous_remaining_percentage, new_remaining_percentage,
|
|
previous_used_percentage, new_used_percentage, raw_data)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`
|
|
).run(
|
|
"claude",
|
|
"conn-claude",
|
|
"weekly (7d)",
|
|
"2026-06-18T23:00:00.000Z",
|
|
"2026-06-25T23:00:00.000Z",
|
|
"2026-06-18T23:04:00.000Z",
|
|
0,
|
|
100,
|
|
100,
|
|
0,
|
|
null
|
|
);
|
|
|
|
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
|
assert.ok(metadata);
|
|
|
|
const weeklyResetAt = "2026-06-25T23:00:00.000Z";
|
|
const status = await usageLimits.getApiKeyUsageLimitStatus(
|
|
{ ...metadata, allowedConnections: ["conn-claude"] },
|
|
{
|
|
now: () => Date.parse("2026-06-20T14:30:00.000Z"),
|
|
getProviderConnectionById: async () => ({
|
|
id: "conn-claude",
|
|
provider: "claude",
|
|
isActive: true,
|
|
}),
|
|
getProviderConnections: async () => [],
|
|
getProviderLimitsCache: () => ({
|
|
plan: "Claude Max",
|
|
quotas: {
|
|
"weekly (7d)": {
|
|
used: 5,
|
|
total: 100,
|
|
resetAt: weeklyResetAt,
|
|
},
|
|
},
|
|
message: null,
|
|
fetchedAt: "2026-06-20T14:30:00.000Z",
|
|
}),
|
|
getAllProviderLimitsCache: () => ({}),
|
|
}
|
|
);
|
|
|
|
assert.equal(status.weeklyWindowStartIso, "2026-06-20T01:42:52.590Z");
|
|
assert.equal(status.weeklySpentUsd, 2);
|
|
});
|
|
|
|
test("buildApiKeyUsageLimitText returns API-key quota spend percentage and reset lines", async () => {
|
|
const text = usageLimits.buildApiKeyUsageLimitText(
|
|
{
|
|
enabled: true,
|
|
dailyLimitUsd: 10,
|
|
weeklyLimitUsd: 50,
|
|
dailySpentUsd: 2,
|
|
weeklySpentUsd: 5.25,
|
|
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
|
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
|
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
|
weeklyResetAtIso: "2026-06-25T20:00:00.000Z",
|
|
dailyExceeded: false,
|
|
weeklyExceeded: false,
|
|
},
|
|
Date.parse("2026-06-19T20:00:00.000Z")
|
|
);
|
|
|
|
assert.equal(
|
|
text,
|
|
[
|
|
"Daily quota",
|
|
"$10.00",
|
|
"Daily spent",
|
|
"$2.00",
|
|
"Daily used",
|
|
"20%",
|
|
"Resets in 7h 0m",
|
|
"",
|
|
"Weekly quota",
|
|
"$50.00",
|
|
"Weekly spent",
|
|
"$5.25",
|
|
"Weekly used",
|
|
"11%",
|
|
"Resets in 6d 0h 0m",
|
|
].join("\n")
|
|
);
|
|
});
|
|
|
|
test("buildApiKeyUsageLimitPercentText returns remaining percentages only", () => {
|
|
const text = usageLimits.buildApiKeyUsageLimitPercentText(
|
|
{
|
|
enabled: true,
|
|
dailyLimitUsd: 10,
|
|
weeklyLimitUsd: 50,
|
|
dailySpentUsd: 2,
|
|
weeklySpentUsd: 5.25,
|
|
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
|
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
|
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
|
weeklyResetAtIso: "2026-06-25T20:00:00.000Z",
|
|
dailyExceeded: false,
|
|
weeklyExceeded: false,
|
|
},
|
|
Date.parse("2026-06-19T20:00:00.000Z")
|
|
);
|
|
|
|
assert.equal(
|
|
text,
|
|
["Daily", "80% left", "⏱ reset in 7h 0m", "", "Weekly", "90% left", "⏱ reset in 6d 0h 0m"].join(
|
|
"\n"
|
|
)
|
|
);
|
|
});
|
|
|
|
test("buildApiKeyUsageLimitRejection includes over-quota percentage and reset hint", async () => {
|
|
const response = usageLimits.buildApiKeyUsageLimitRejection(
|
|
new Request("http://localhost/v1/messages", {
|
|
headers: { "anthropic-version": "2023-06-01" },
|
|
}),
|
|
{
|
|
enabled: true,
|
|
dailyLimitUsd: 10,
|
|
weeklyLimitUsd: 1,
|
|
dailySpentUsd: 0.25,
|
|
weeklySpentUsd: 1.09,
|
|
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
|
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
|
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
|
weeklyResetAtIso: "2026-06-25T20:00:00.000Z",
|
|
dailyExceeded: false,
|
|
weeklyExceeded: true,
|
|
},
|
|
Date.parse("2026-06-19T20:00:00.000Z")
|
|
);
|
|
|
|
assert.equal(response.status, 400);
|
|
const body = (await response.json()) as { error: { message: string } };
|
|
assert.equal(
|
|
body.error.message,
|
|
"This API key reached its weekly USD usage quota ($1.09 of $1.00, 109%). Resets in 6d 0h 0m. Choose another allowed model after reset."
|
|
);
|
|
});
|
|
|
|
test("buildApiKeyUsageLimitRejection can hide USD amounts for client-facing policy errors", async () => {
|
|
const response = usageLimits.buildApiKeyUsageLimitRejection(
|
|
new Request("http://localhost/v1/messages", {
|
|
headers: { "anthropic-version": "2023-06-01" },
|
|
}),
|
|
{
|
|
enabled: true,
|
|
dailyLimitUsd: 10,
|
|
weeklyLimitUsd: 1,
|
|
dailySpentUsd: 0.25,
|
|
weeklySpentUsd: 1.09,
|
|
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
|
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
|
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
|
weeklyResetAtIso: "2026-06-25T20:00:00.000Z",
|
|
dailyExceeded: false,
|
|
weeklyExceeded: true,
|
|
},
|
|
Date.parse("2026-06-19T20:00:00.000Z"),
|
|
{ showUsd: false }
|
|
);
|
|
|
|
assert.equal(response.status, 400);
|
|
const body = (await response.json()) as { error: { message: string } };
|
|
assert.equal(
|
|
body.error.message,
|
|
"This API key reached its weekly usage quota (109%). Resets in 6d 0h 0m. Choose another allowed model after reset."
|
|
);
|
|
});
|
|
|
|
test("buildApiKeyUsageLimitRejection uses 400 so Claude Code does not trigger login", () => {
|
|
const response = usageLimits.buildApiKeyUsageLimitRejection(
|
|
new Request("http://localhost/v1/messages", {
|
|
headers: { "anthropic-version": "2023-06-01" },
|
|
}),
|
|
{
|
|
enabled: true,
|
|
dailyLimitUsd: 10,
|
|
weeklyLimitUsd: 50,
|
|
dailySpentUsd: 12,
|
|
weeklySpentUsd: 20,
|
|
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
|
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
|
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
|
weeklyResetAtIso: "2026-06-19T20:00:00.000Z",
|
|
dailyExceeded: true,
|
|
weeklyExceeded: false,
|
|
}
|
|
);
|
|
|
|
assert.equal(response.status, 400);
|
|
});
|