Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/features/11903-usage-fetch-capability.md
Original file line number Diff line number Diff line change
@@ -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
29 changes: 28 additions & 1 deletion docs/reference/PROVIDER_PLUGIN_MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
22 changes: 21 additions & 1 deletion open-sse/config/providerPluginManifest.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string>(USAGE_FETCHER_PROVIDERS);

function compactObject<T extends Record<string, unknown>>(value: T): Partial<T> {
return Object.fromEntries(
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined)
Expand Down Expand Up @@ -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();
}
Expand Down
67 changes: 8 additions & 59 deletions open-sse/services/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions open-sse/services/usage/fetcherProviders.ts
Original file line number Diff line number Diff line change
@@ -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];
63 changes: 63 additions & 0 deletions tests/unit/provider-plugin-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RegistryEntry> = {
openai: {
Expand Down Expand Up @@ -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<string, RegistryEntry> = {
"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"));
});
Loading