mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
* feat(usage): devin-cli agentic quota + openrouter credits in Provider Limits Two provider families with live quota APIs were missing from the Provider Limits dashboard because their list entries were absent: - devin-cli: new usage leaf querying the Codeium seat-management Connect API (exa.seat_management_pb.SeatManagementService/GetUserStatus, protobuf over POST with the raw `Basic <token>-<token>` auth header the CLI itself uses). Surfaces the plan name plus daily/weekly agentic quota percentages with reset timestamps from the GetUserStatus plan_status payload, via a minimal hand-rolled protobuf encoder/reader (no proto dependency warranted for two fixed messages). - openrouter: the /key + /credits quota fetcher (#6842) was already wired into the dispatcher but gated out of the bulk sync — add it to USAGE_SUPPORTED_PROVIDERS and PROVIDER_LIMITS_APIKEY_PROVIDERS so key limits and account credits actually surface. * fix(build): externalize tiktoken so tiktoken_bg.wasm resolves at runtime The vendored ChatGPT Web connector v4.0.7 (#12181) imports tiktoken (get_encoding) at module level. tiktoken's node build reads tiktoken_bg.wasm via a __dirname-relative fs.readFileSync during import; when Next bundles the package the wasm asset is not traced into the server chunk, and page-data collection for every route reaching the tokenizer (e.g. /api/providers/[id]/chatgpt-web-codex-doctor) aborts with "Missing tiktoken_bg.wasm" — breaking the whole standalone build. Externalize it like the other runtime-resolved native/wasm packages (sql.js, sqlite-vec, better-sqlite3): the require stays at runtime, where node_modules/tiktoken/tiktoken_bg.wasm resolves normally. * fix(openrouter): /credits balance survives a /key failure OpenRouter is credit-based, not subscription-based: the authoritative remaining-credits signal is GET /api/v1/credits (total_credits - total_usage, the documented "get remaining credits" endpoint), while the /key limit fields are optional per-key caps that most accounts never set. fetchOpenrouterQuota previously treated /key as mandatory — any /key failure (429 rate limit, transient error, unexpected shape) discarded the whole payload and the Usage dashboard showed "OpenRouter (usage endpoint unreachable)" even though /credits was reachable. Now: - /key unavailable + /credits OK → credits-only quota (creditBalance = total_credits - total_usage) instead of null - /key 401/403 alone no longer means an invalid token; only a double auth-rejection (both endpoints) does - null is returned only when both endpoints fail, and the dashboard label reflects that ("credits endpoint unreachable") * fix(openrouter): render AI Credits as a USD credit count in Provider Limits The Provider Limits card's dollar renderer only activates on isCredits/creditCount rows (QuotaCardExpanded), but openrouter went through parseGeneric — which drops `currency` and never sets those flags — so the credits balance rendered as a meaningless "100% left" (the unlimited-credits row is always 100%) instead of the actual credit count. Route openrouter's `credits` quota through buildCreditsQuota() like the DeepSeek/AgentRouter credits rows: label "AI Credits", dollar-formatted balance. Free-tier request windows keep the generic percentage treatment. * fix(usage): document DEVIN_SEAT_API_URL and split quota parsers Keep fetchOpenrouterQuota and decodeProtoFields under the complexity ratchets, and add the seat-management URL to the env/docs contract. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(usage): drop duplicated GLM quota-ordering test in provider-limits-ui * test(usage): drop stale openrouter ACCEPTED_DIVERGENCE OpenRouter is now in both USAGE_FETCHER_PROVIDERS and USAGE_SUPPORTED_PROVIDERS, so the recorded aggregator divergence is no longer real. Add the changelog fragment. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
134 lines
5.5 KiB
TypeScript
134 lines
5.5 KiB
TypeScript
/**
|
|
* `usage/fetcherProviders.ts` says of itself that it exists "so the registration
|
|
* list can't drift from the dispatcher's switch statement", and asks whoever adds
|
|
* a case to remember to add it here too. Nothing enforced that, and it drifted:
|
|
* #8006 added `adobe-firefly`/`firefly` to the switch and to
|
|
* `USAGE_SUPPORTED_PROVIDERS` but not to this list, so three consumers
|
|
* (`genericQuotaFetcher.ts`, `freeAccessQuota.ts`, the provider-plugin manifest)
|
|
* were told those providers had no usage fetcher when in fact they do.
|
|
*
|
|
* This test is the enforcement the comment asked for.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { USAGE_FETCHER_PROVIDERS } from "../../open-sse/services/usage/fetcherProviders.ts";
|
|
import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts";
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
/**
|
|
* The provider ids `getUsageForProvider` actually dispatches on. Read from the
|
|
* source rather than by calling the function: importing the dispatcher pulls in
|
|
* its whole fetcher graph (DB, sockets, child_process), which is precisely the
|
|
* weight `fetcherProviders.ts` was extracted to avoid.
|
|
*/
|
|
function dispatchedProviderIds(): string[] {
|
|
const source = fs.readFileSync(path.resolve(here, "../../open-sse/services/usage.ts"), "utf8");
|
|
|
|
// Anchor on the dispatcher itself, not on the first occurrence of the word
|
|
// "switch" -- that one is in a doc comment twenty lines above it. Then stop at
|
|
// the closing brace of that switch, so a second switch added later in the file
|
|
// cannot contribute cases to an invariant that is only about this one.
|
|
const start = source.indexOf("switch (provider) {");
|
|
assert.notEqual(start, -1, "could not find `switch (provider) {` in services/usage.ts");
|
|
assert.equal(
|
|
source.indexOf("switch (provider) {", start + 1),
|
|
-1,
|
|
"more than one `switch (provider)` — this reader would merge them"
|
|
);
|
|
|
|
let depth = 0;
|
|
let end = start;
|
|
for (let i = source.indexOf("{", start); i < source.length; i += 1) {
|
|
if (source[i] === "{") depth += 1;
|
|
else if (source[i] === "}") {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
assert.ok(end > start, "unbalanced braces in the dispatcher switch");
|
|
|
|
const body = source.slice(start, end);
|
|
const ids = [...body.matchAll(/^\s*case ["']([^"']+)["']:/gm)].map((match) => match[1]);
|
|
assert.ok(ids.length > 0, "parsed no cases out of the dispatcher — the reader is broken");
|
|
return [...new Set(ids)];
|
|
}
|
|
|
|
test("every provider the dispatcher handles is declared as having a fetcher", () => {
|
|
const undeclared = dispatchedProviderIds().filter(
|
|
(id) => !(USAGE_FETCHER_PROVIDERS as readonly string[]).includes(id)
|
|
);
|
|
assert.deepEqual(
|
|
undeclared,
|
|
[],
|
|
"these providers have a usage fetcher the dispatcher will happily call, but nothing " +
|
|
"declares it — so genericQuotaFetcher, freeAccessQuota and the plugin manifest all " +
|
|
"report them as having no usage support: " +
|
|
undeclared.join(", ")
|
|
);
|
|
});
|
|
|
|
test("nothing is declared that the dispatcher would not handle", () => {
|
|
const dispatched = dispatchedProviderIds();
|
|
const phantom = (USAGE_FETCHER_PROVIDERS as readonly string[]).filter(
|
|
(id) => !dispatched.includes(id)
|
|
);
|
|
assert.deepEqual(
|
|
phantom,
|
|
[],
|
|
`declared but unreachable, so a quota fetch would silently return nothing: ${phantom.join(", ")}`
|
|
);
|
|
});
|
|
|
|
/**
|
|
* The fetcher list and the "usage supported" connection list are not the same
|
|
* set, and should not be forced to converge — but every difference needs a
|
|
* reason on record, or the next drift hides among the ones we accepted.
|
|
*/
|
|
const ACCEPTED_DIVERGENCE: Record<string, string> = {
|
|
// Aggregators: a usage fetcher exists, but a connection to them is not itself
|
|
// presented as a usage-reporting account in the UI.
|
|
opencode: "aggregator — fetcher exists, not surfaced as a usage-reporting connection",
|
|
"opencode-zen": "aggregator — same as opencode",
|
|
xai: "reached through xai-oauth for connection purposes",
|
|
// Declared supported, no fetcher: a real gap, left alone here on purpose so
|
|
// this PR stays about the two providers whose fetcher already exists.
|
|
"xiaomi-mimo-token-plan": "declared supported with no fetcher — open question, not fixed here",
|
|
};
|
|
|
|
test("every difference between the two lists is one we have written down", () => {
|
|
const fetcher = new Set<string>(USAGE_FETCHER_PROVIDERS as readonly string[]);
|
|
const supported = new Set<string>(USAGE_SUPPORTED_PROVIDERS as readonly string[]);
|
|
const unexplained = [
|
|
...[...fetcher].filter((id) => !supported.has(id)),
|
|
...[...supported].filter((id) => !fetcher.has(id)),
|
|
].filter((id) => !(id in ACCEPTED_DIVERGENCE));
|
|
|
|
assert.deepEqual(
|
|
unexplained,
|
|
[],
|
|
"the two lists differ here for no recorded reason — either wire it up, or add it to " +
|
|
`ACCEPTED_DIVERGENCE with why: ${unexplained.join(", ")}`
|
|
);
|
|
});
|
|
|
|
test("the recorded divergences are still real, so the list does not rot", () => {
|
|
const fetcher = new Set<string>(USAGE_FETCHER_PROVIDERS as readonly string[]);
|
|
const supported = new Set<string>(USAGE_SUPPORTED_PROVIDERS as readonly string[]);
|
|
const stale = Object.keys(ACCEPTED_DIVERGENCE).filter(
|
|
(id) => fetcher.has(id) === supported.has(id)
|
|
);
|
|
assert.deepEqual(
|
|
stale,
|
|
[],
|
|
`these no longer differ; drop them from ACCEPTED_DIVERGENCE: ${stale.join(", ")}`
|
|
);
|
|
});
|