From 665cef1487d7c0548e66bffa389515d61055a7c5 Mon Sep 17 00:00:00 2001 From: Paco Cartones Date: Fri, 28 Aug 2026 14:52:22 +0000 Subject: [PATCH 1/2] feat(providers): expose a usage-fetch capability in the provider plugin manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external dashboard that wants to show, per provider, whether OmniRoute can read usage or quota has no API for it today — it has to read `open-sse/services/usage.ts` and re-check the file after every release. The manifest already answers "what can this provider do" for auth, executor, Responses and sidecar eligibility, so this adds the missing tag rather than a new surface. `capabilitiesFor()` now emits `usage-fetch` for registry entries listed in `USAGE_FETCHER_PROVIDERS`, resolving on the entry id and on its alias: the list is keyed by the strings the usage dispatcher accepts, so it mixes canonical ids ("hyperagent") with aliases ("ha"), the same way `getProviderPluginManifestEntryFromRegistry` already resolves a lookup. 40 of the 267 registry providers carry the tag. Discovery only — no new fetcher, no quota change, no activation. Nothing reads the tag yet, and the Dashboard quota widget stays gated by `USAGE_SUPPORTED_PROVIDERS`. `USAGE_FETCHER_PROVIDERS` moves to a new zero-dependency leaf, `open-sse/services/usage/fetcherProviders.ts`, and is re-exported from `services/usage.ts` so every existing import path keeps working. The move is what keeps the manifest a light leaf: `services/usage.ts` is the dispatcher and pulls ~490 modules (DB, sockets, child_process), while `config/providerPluginManifest.ts` is a JSON-safe config module served over HTTP at `GET /api/v1/provider-plugin-manifest`. Importing the dispatcher there would have grown its graph from 17 modules to ~490; importing the leaf leaves it at 18 with no new external dependency, and duplicating the list would have broken the single-source-of-truth invariant the list exists to protect. Closes #11722 --- docs/reference/PROVIDER_PLUGIN_MANIFEST.md | 29 +++++++- open-sse/config/providerPluginManifest.ts | 22 +++++- open-sse/services/usage.ts | 67 +++--------------- open-sse/services/usage/fetcherProviders.ts | 78 +++++++++++++++++++++ tests/unit/provider-plugin-manifest.test.ts | 63 +++++++++++++++++ 5 files changed, 198 insertions(+), 61 deletions(-) create mode 100644 open-sse/services/usage/fetcherProviders.ts diff --git a/docs/reference/PROVIDER_PLUGIN_MANIFEST.md b/docs/reference/PROVIDER_PLUGIN_MANIFEST.md index 9db239b371d1..04df55a86f92 100644 --- a/docs/reference/PROVIDER_PLUGIN_MANIFEST.md +++ b/docs/reference/PROVIDER_PLUGIN_MANIFEST.md @@ -48,7 +48,7 @@ The manifest contains: - JSON-safe model metadata such as context length, vision/reasoning flags, and unsupported params - capability tags including `apikey`, `oauth`, `custom-executor`, - `passthrough-models`, `responses`, and `sidecar-candidate` + `passthrough-models`, `responses`, `sidecar-candidate`, and `usage-fetch` The manifest intentionally excludes: @@ -59,6 +59,33 @@ The manifest intentionally excludes: - executor functions - session pool internals +## Capability Tags + +`capabilities` is a sorted array of tags derived from the registry entry. Integrators +should treat it as the machine-readable answer to "what can this provider do", instead of +re-reading the TypeScript sources. + +| Tag | Meaning | +| -------------------- | ----------------------------------------------------------------- | +| `apikey` | Accepts an API key (`authType` is `apikey` or `optional`). | +| `oauth` | Uses an OAuth or session flow. | +| `responses` | Exposes an OpenAI Responses-API base URL. | +| `passthrough-models` | Serves models straight from upstream instead of a static catalog. | +| `custom-executor` | Runs a non-default executor, so it stays on the TypeScript path. | +| `sidecar-candidate` | Mirrors `sidecar.eligible` — safe to consider for sidecar import. | +| `usage-fetch` | Has a wired usage or quota fetcher (`getUsageForProvider`). | + +`usage-fetch` is discovery only. It reports that OmniRoute knows how to read usage for the +provider; it does not activate fetching, change quota semantics, or imply that the +Dashboard quota widget is enabled for the provider — that widget is gated separately by +`USAGE_SUPPORTED_PROVIDERS`. The source of truth is `USAGE_FETCHER_PROVIDERS` in +`open-sse/services/usage/fetcherProviders.ts`. + +That list is keyed by the strings the usage dispatcher accepts, so it mixes canonical ids +with aliases and is slightly longer than the number of tagged providers: entries that are +not chat providers in the manifest registry (for example the `firecrawl` search provider +and the `amazon-q` ACP provider) have no manifest entry to tag. + ## Sidecar Use Sidecars should treat `sidecar.eligible` as a conservative candidate signal, not diff --git a/open-sse/config/providerPluginManifest.ts b/open-sse/config/providerPluginManifest.ts index b0925b126fca..0a3899f6742c 100644 --- a/open-sse/config/providerPluginManifest.ts +++ b/open-sse/config/providerPluginManifest.ts @@ -1,7 +1,14 @@ import type { RegistryEntry, RegistryModel } from "./providers/shared.ts"; +import { USAGE_FETCHER_PROVIDERS } from "../services/usage/fetcherProviders.ts"; export type ProviderPluginCapability = - "apikey" | "custom-executor" | "oauth" | "passthrough-models" | "responses" | "sidecar-candidate"; + | "apikey" + | "custom-executor" + | "oauth" + | "passthrough-models" + | "responses" + | "sidecar-candidate" + | "usage-fetch"; export interface ProviderPluginModel { id: string; @@ -52,6 +59,13 @@ export interface ProviderPluginManifest { const SIDECAR_COMPATIBLE_EXECUTORS = new Set(["default"]); +/** + * Providers with a wired `getUsageForProvider` implementation (#11722). The list mixes + * canonical ids ("hyperagent") with aliases ("ha") because it is keyed by the strings the + * usage dispatcher accepts, so `capabilitiesFor` resolves an entry on both. + */ +const USAGE_FETCHER_PROVIDER_SET = new Set(USAGE_FETCHER_PROVIDERS); + function compactObject>(value: T): Partial { return Object.fromEntries( Object.entries(value).filter(([, entryValue]) => entryValue !== undefined) @@ -122,6 +136,12 @@ function capabilitiesFor(entry: RegistryEntry, eligible: boolean): ProviderPlugi if (eligible) { capabilities.add("sidecar-candidate"); } + if ( + USAGE_FETCHER_PROVIDER_SET.has(entry.id) || + (entry.alias !== undefined && USAGE_FETCHER_PROVIDER_SET.has(entry.alias)) + ) { + capabilities.add("usage-fetch"); + } return [...capabilities].sort(); } diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 45b2e8c4e0e5..1bc537d9f662 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -87,67 +87,16 @@ type UsageProviderConnection = JsonRecord & { /** * Single source of truth for which providers have a `getUsageForProvider` - * implementation. Consumers like `genericQuotaFetcher.ts` reference this so - * the registration list can't drift from the switch statement below. + * implementation — consumers like `genericQuotaFetcher.ts` reference it so the + * registration list can't drift from the switch statement below. * - * If you add a new provider to the switch, add it here too. + * If you add a new provider to the switch, add it to the list too. The list now lives in + * `./usage/fetcherProviders.ts` (a zero-dependency leaf) so that consumers which only need + * to know *whether* a fetcher exists — the provider-plugin manifest — can read it without + * importing this dispatcher. Re-exported here so this stays the public import path. */ -export const USAGE_FETCHER_PROVIDERS = [ - "github", - "antigravity", - "agy", - "claude", - "codex", - "cursor", - "kiro", - "amazon-q", - "kimi-coding", - "kimi-coding-apikey", - "qoder", - "glm", - "glm-cn", - "zai", - "glmt", - "opencode-go", - "ollama-cloud", - "minimax", - "minimax-cn", - "crof", - "bailian-coding-plan", - "qwen-cloud-token-plan", - "nanogpt", - "deepseek", - "opencode", - "opencode-zen", - "xiaomi-mimo", - "xai", - "xai-oauth", - "xao", - "grok-cli", - "vertex", - "vertex-partner", - "codebuddy-cn", - "openrouter", - // PromptQL playground credits (data.pro.ql.app getCreditSummary) - "promptql", - "pql", - // HyperAgent billing usage (creditBlocks USD) - "hyperagent", - "ha", - // Firecrawl team credits (GET /v2/team/credit-usage) - "firecrawl", - // Volcano Ark Plan subscriptions (agent-plan / coding-plan) - "volcengine-agent-plan", - "volcengine-coding-plan", - // Command Code credits + 5h/weekly windows (GET /alpha/billing/credits) - "command-code", - "conol-web", - "cnl", - // AgentRouter (New-API) console balance (GET /api/user/self) - "agentrouter", -] as const; - -export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number]; +export { USAGE_FETCHER_PROVIDERS } from "./usage/fetcherProviders.ts"; +export type { UsageFetcherProvider } from "./usage/fetcherProviders.ts"; /** * Get usage data for a provider connection diff --git a/open-sse/services/usage/fetcherProviders.ts b/open-sse/services/usage/fetcherProviders.ts new file mode 100644 index 000000000000..1ddfd74cc2d9 --- /dev/null +++ b/open-sse/services/usage/fetcherProviders.ts @@ -0,0 +1,78 @@ +/** + * usage/fetcherProviders.ts — the registration list of providers that have a + * `getUsageForProvider` implementation. + * + * Extracted from services/usage.ts (god-file decomposition) so that consumers which only + * need to know *whether* a provider has a wired fetcher can read the list without pulling + * in the dispatcher and its ~490-module fetcher graph (DB, sockets, child_process). The + * provider-plugin manifest (`config/providerPluginManifest.ts`) is one such consumer: it + * is a JSON-safe config module served over HTTP and must stay a light leaf. Pure data — + * no imports, no module state — so it cannot introduce a cycle. Behavior-preserving move; + * `services/usage.ts` re-exports both the value and the derived type, so every existing + * import path (`services/usage.ts`) keeps working unchanged. + */ + +/** + * Single source of truth for which providers have a `getUsageForProvider` + * implementation. Consumers like `genericQuotaFetcher.ts` and the provider-plugin + * manifest reference this so the registration list can't drift from the dispatcher's + * switch statement. + * + * If you add a new provider to the switch in `services/usage.ts`, add it here too. + */ +export const USAGE_FETCHER_PROVIDERS = [ + "github", + "antigravity", + "agy", + "claude", + "codex", + "cursor", + "kiro", + "amazon-q", + "kimi-coding", + "kimi-coding-apikey", + "qoder", + "glm", + "glm-cn", + "zai", + "glmt", + "opencode-go", + "ollama-cloud", + "minimax", + "minimax-cn", + "crof", + "bailian-coding-plan", + "qwen-cloud-token-plan", + "nanogpt", + "deepseek", + "opencode", + "opencode-zen", + "xiaomi-mimo", + "xai", + "xai-oauth", + "xao", + "grok-cli", + "vertex", + "vertex-partner", + "codebuddy-cn", + "openrouter", + // PromptQL playground credits (data.pro.ql.app getCreditSummary) + "promptql", + "pql", + // HyperAgent billing usage (creditBlocks USD) + "hyperagent", + "ha", + // Firecrawl team credits (GET /v2/team/credit-usage) + "firecrawl", + // Volcano Ark Plan subscriptions (agent-plan / coding-plan) + "volcengine-agent-plan", + "volcengine-coding-plan", + // Command Code credits + 5h/weekly windows (GET /alpha/billing/credits) + "command-code", + "conol-web", + "cnl", + // AgentRouter (New-API) console balance (GET /api/user/self) + "agentrouter", +] as const; + +export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number]; diff --git a/tests/unit/provider-plugin-manifest.test.ts b/tests/unit/provider-plugin-manifest.test.ts index 37ce937b1cfb..7768b70cc93f 100644 --- a/tests/unit/provider-plugin-manifest.test.ts +++ b/tests/unit/provider-plugin-manifest.test.ts @@ -6,6 +6,7 @@ import { getProviderPluginManifestEntryFromRegistry, } from "../../open-sse/config/providerPluginManifest.ts"; import type { RegistryEntry } from "../../open-sse/config/providers/shared.ts"; +import { USAGE_FETCHER_PROVIDERS } from "../../open-sse/services/usage/fetcherProviders.ts"; const registryFixture: Record = { openai: { @@ -119,3 +120,65 @@ test("manifest does not export OAuth client secrets or dynamic functions", () => assert.equal("requestDefaults" in provider, false); } }); + +test("manifest advertises usage-fetch for providers with a wired usage fetcher (#11722)", () => { + const claude = getProviderPluginManifestEntryFromRegistry(registryFixture, "claude"); + + assert.ok(claude); + assert.ok( + (USAGE_FETCHER_PROVIDERS as readonly string[]).includes("claude"), + "fixture guard: claude must stay in USAGE_FETCHER_PROVIDERS for this test to mean anything" + ); + assert.ok( + claude.capabilities.includes("usage-fetch"), + "claude has a wired usage fetcher, so the manifest must advertise usage-fetch" + ); +}); + +test("manifest omits usage-fetch for providers without a usage fetcher (#11722)", () => { + for (const providerId of ["openai", "anthropic", "claude-web"]) { + const entry = getProviderPluginManifestEntryFromRegistry(registryFixture, providerId); + + assert.ok(entry, `fixture guard: ${providerId} must resolve`); + assert.equal( + (USAGE_FETCHER_PROVIDERS as readonly string[]).includes(entry.id), + false, + `fixture guard: ${entry.id} must stay out of USAGE_FETCHER_PROVIDERS` + ); + assert.equal( + entry.capabilities.includes("usage-fetch"), + false, + `${entry.id} has no wired usage fetcher, so usage-fetch must not be advertised` + ); + } +}); + +test("usage-fetch matches the fetcher list by alias too (#11722)", () => { + // USAGE_FETCHER_PROVIDERS is keyed by the strings `getUsageForProvider` accepts, which + // mixes canonical ids ("hyperagent") with aliases ("ha"). Resolve on both, the same way + // getProviderPluginManifestEntryFromRegistry already resolves a lookup. + const aliasOnlyFixture: Record = { + "hyperagent-eu": { + id: "hyperagent-eu", + alias: "ha", + format: "openai", + executor: "default", + baseUrl: "https://eu.hyperagent.example/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + models: [{ id: "ha-1", name: "HyperAgent 1" }], + }, + }; + + assert.equal( + (USAGE_FETCHER_PROVIDERS as readonly string[]).includes("hyperagent-eu"), + false, + "fixture guard: the id must NOT be in the list, only the alias" + ); + assert.ok((USAGE_FETCHER_PROVIDERS as readonly string[]).includes("ha")); + + const entry = getProviderPluginManifestEntryFromRegistry(aliasOnlyFixture, "hyperagent-eu"); + + assert.ok(entry); + assert.ok(entry.capabilities.includes("usage-fetch")); +}); From 7f522baf31d2fc7653db7f376a668c7b93d9bf3c Mon Sep 17 00:00:00 2001 From: Paco Cartones Date: Fri, 28 Aug 2026 14:53:41 +0000 Subject: [PATCH 2/2] docs(changelog): add fragment for the usage-fetch manifest capability --- changelog.d/features/11903-usage-fetch-capability.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/features/11903-usage-fetch-capability.md diff --git a/changelog.d/features/11903-usage-fetch-capability.md b/changelog.d/features/11903-usage-fetch-capability.md new file mode 100644 index 000000000000..933cf41b242f --- /dev/null +++ b/changelog.d/features/11903-usage-fetch-capability.md @@ -0,0 +1 @@ +- **feat(providers):** the provider plugin manifest now advertises a `usage-fetch` capability for the 40 providers that have a wired usage/quota fetcher, so external dashboards can read it from `GET /api/v1/provider-plugin-manifest` instead of parsing `open-sse/services/usage.ts` after every release. Discovery only — no new fetcher, no quota change, and the Dashboard quota widget stays gated by `USAGE_SUPPORTED_PROVIDERS`. `USAGE_FETCHER_PROVIDERS` moved to a zero-dependency leaf (`open-sse/services/usage/fetcherProviders.ts`) and is re-exported from `services/usage.ts`, keeping the manifest module a light leaf instead of pulling the ~490-module usage dispatcher into the manifest route. ([#11903](https://github.com/diegosouzapw/OmniRoute/pull/11903)) — thanks @maxmad64bis