Files
PaperClipAI/server/src/services/ai-connection-usage.ts
T
DottaandPaperclip 9f7057e122 feat(connections): configure custom model providers across agent harnesses (#14970)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents use a harness, a model, and a credential to run tasks.
> - Connections already store credentials and control who can use them.
> - Custom providers also need an endpoint and a supported API format.
> - A per-agent endpoint would duplicate credentials and access rules.
> - This pull request stores routing on the connection and projects it
into the harness.
> - Isolated credentials and protocol checks keep the selected
connection authoritative.

## Linked Issues or Issue Description

Refs #37, #13083, #14104, #14565, #12692.

#14016 is a reference only. This PR has its own schema, vault
persistence, routing validation, runtime projection, and tests. None of
the five commits in #14016 is an ancestor of this branch. We do not
depend on or plan to merge it. #14967 addresses task-pinned account
pools. #14422 addresses another provider integration.

This is the first of two linked PRs. Merge this connection change before
#15341, which refines agent setup and adds the qualification harness.
The split keeps each review below 100 changed files. Provider catalog
entries have usable setup forms in this PR. Local browser subscription
sign-in is included.

## What Changed

- Store non-secret routing metadata on AI connections. Vault provider
API keys, including Bedrock bearer API keys. Reject general AWS access
keys.
- Enforce company, owner, human audience, agent access, connection
status, and protocol checks before resolving credentials. Keep reconnect
destinations immutable and retain connection identity during key
rotation.
- Project OpenRouter and compatible custom endpoints into Codex, Claude,
OpenCode, and local Hermes. Carry these settings through both legacy and
native runner transports. Clear conflicting host credentials and redact
keys from diagnostics.
- Preserve older OpenRouter accounts and native personal defaults. Add
Google API-key accounts and migration `0306` for the two
provider-default constraints.
- Run local Claude and Codex subscription sign-in behind the existing
browser sign-in card. Use private attempt homes and owner-bound
completion instead of a copied terminal command.
- Seed isolated Gemini authentication and preserve OpenCode workspace
permissions. Keep the selected connection authoritative. The independent
Gemini and Grok workflow fixes are in #15341.
- Keep native OpenCode custom gateway keys in a runner-owned
selected-model proxy; the harness config contains only a session-scoped
capability. Honor runtime outgoing proxy and certificate settings.
Preserve streamed responses and revoke the proxy on close or startup
failure.
- Allow ordinary members to connect native personal accounts before an
agent exists.
- Repair routed accounts from task cards using the saved provider
destination, protocol, model aliases, and connection identity.
- Add provider catalog definitions, model discovery, pinned logos, and
complete native and routed setup forms. Allow a personal routed
connection before a new agent exists. Keep endpoint authentication keys
out of Hermes terminal children.
- Recover cancelled or restarted browser sign-in with a clear restart
action. Support no-auth endpoints without a vault credential. Add
isolation and recovery regressions and runtime documentation.

## Verification

- Updated with `origin/master` at `22a3ea341`. Migration `0306` follows
the new master migration and passes migration and snapshot checks.
- The integrated connection regressions passed 152 tests and 50 native
OpenCode driver tests, including key-free child-shell configuration
reads, authenticated/no-auth forwarding, streaming, model/path
restrictions, cancellation, outgoing proxy routing, and NO_PROXY bypass.
Provider setup has 14 passing tests. The pinned real OpenCode 1.18.34
executable also completed a turn through the proxy against a local
synthetic provider; the reusable key was absent from its config. A
second real-executable smoke passed with an HTTPS CONNECT proxy and
runtime-specific synthetic certificate trust. Certificate-file and
certificate-directory regressions pass.
- Task-card repair passed 48 tests, including OpenRouter, Bedrock, and
custom gateway reconnect cases. UI typecheck and token gates passed.
- The prior core regression set passed 133 tests across new-agent setup,
provider forms, browser sign-in, routing projection, and connection
authorization. Token gates and UI typecheck passed.
- Full workspace typecheck and production build passed again after the
latest integration and credential-proxy fix. The merged deterministic
runner E2E suite passed 1,400 Vitest tests and 128 Node tests.
- Full workspace typecheck passed on the prior linked combined
implementation. Production build, Storybook build, 1,316 browser-harness
Vitest tests, and 128 Node tests passed. Head `9d964c8d9` includes the
latest master integration and regenerated migration. This exact head
passed 54 remote checks with four expected skips and Greptile 5/5; no
review threads remain open. An unchanged server fixture had a random
six-character issue-prefix collision on its first attempt. All 245 tests
passed locally and the single CI retry passed.
- A provider-free terminal check used the cited supported Hermes source
and dummy keys. Gateway and OpenRouter terminal children could not read
the selected key.
- The broad local Vitest attempt passed 15,442 tests but was not green.
It had an embedded-Postgres startup failure, an HTTP logger timeout, an
origin socket error, and a browser cancellation wait timeout. The
cancellation wait was corrected. The relevant connection tests and the
full origin test file passed separately. Latest-head CI must pass before
merge.
- Prior credential-backed acceptance exercised task creation, tool use,
artifact delivery, completion, and context-dependent follow-up. Claude
legacy and native runners passed Bedrock with `us-east-1` and
`us.anthropic.claude-sonnet-4-6`.
- Historical local qualification retained 43 passing API/gateway cells
out of 46. Those attempts span earlier builds. They do not qualify this
exact commit or staging. All subscription combinations and staging
remain unqualified.
- Verify native subscription and API-key setup. Connect a regular
provider catalog row. Verify an incompatible harness and a changed
reconnect URL are rejected. Use #15341 for the complete browser
campaign.

## Risks

- Migration `0306` changes two check constraints. It preserves rows and
is safe to reapply. It takes normal constraint-change locks.
- Credential projection touches several harnesses. CLI upgrades can
change provider configuration and session behavior.
- The native OpenCode proxy adds a loopback hop, pins requests to the
selected model, limits request bodies to 16 MiB, rejects redirects, and
expires at session close. It prevents reusable keys in the child
configuration; it is not an OS isolation boundary against a process
debugger running as the same user.
- Custom endpoints must be reachable from the agent environment. Saving
a connection does not prove connectivity. Bedrock keys require rotation
before expiry.
- Gemini CLI 0.58.0 has an upstream ACP new-file error conversion
defect. Provider overloads and an unresolved follow-up timeout also
affect live Gemini qualification. We have not patched the installed CLI
or marked those cases as passing.
- OpenClaw Gateway, Hermes Gateway, Claude Managed, AWS AgentCore,
Process, HTTP, and legacy ACPX local are excluded. Vertex, ambient AWS
identity, arbitrary auth headers, and custom routing for other harnesses
are excluded.
- These PRs do not establish production or staging qualification for
every provider and login method.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, code
execution, and browser testing. The exact deployment model ID and
context window size were not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-06 13:29:03 -05:00

348 lines
19 KiB
TypeScript

import {
supportsAiConnectionUsage,
type AiConnectionMetadata,
type AiConnectionUsage,
type AiConnectionUsageLimit,
type AiConnectionUsageOverage,
} from "@paperclipai/shared";
import { parseGrokAuthPayload } from "@paperclipai/adapter-grok-local/server";
type Observation = Pick<AiConnectionUsage, "source" | "planType" | "limits" | "overage">;
type ObjectValue = Record<string, unknown>;
const object = (value: unknown): ObjectValue =>
value !== null && typeof value === "object" && !Array.isArray(value) ? value as ObjectValue : {};
const number = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
const signedNumber = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
const bool = (value: unknown): boolean | null => typeof value === "boolean" ? value : null;
const string = (value: unknown): string | null => typeof value === "string" && value.length > 0 ? value : null;
// Grok's Cent messages use proto3 JSON: a present {} means zero; an absent
// message remains unknown. Other missing scalars (including percentages) do not.
const amount = (value: unknown): number | null => {
if (value != null && typeof value === "object" && !Array.isArray(value)) {
return Object.keys(value).length === 0 ? 0 : number(object(value).val);
}
return number(value);
};
const balance = (value: unknown): number | null => {
if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)) return signedNumber(Number(value));
return signedNumber(value);
};
function timestamp(value: unknown): string | null {
if (typeof value !== "string" && typeof value !== "number") return null;
const date = new Date(typeof value === "number" ? value * 1000 : value);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
function limit(input: Partial<AiConnectionUsageLimit> & Pick<AiConnectionUsageLimit, "id" | "label">): AiConnectionUsageLimit {
const usedPercent = input.usedPercent ?? (
input.used != null && input.limit != null && input.limit > 0 ? input.used / input.limit * 100 : null
);
return {
scope: null, windowDurationSeconds: null, resetsAt: null,
used: null, limit: null, remaining: null, unit: null, allowed: null,
...input,
// Provider percentages are percentages, including values below 1 and above 100.
usedPercent,
remainingPercent: usedPercent == null ? null : Math.max(0, 100 - usedPercent),
limitReached: input.limitReached ?? (usedPercent != null ? usedPercent >= 100
: input.used != null && input.limit != null ? input.used >= input.limit : null),
};
}
function overage(input: Partial<AiConnectionUsageOverage>): AiConnectionUsageOverage {
return {
enabled: null, available: null, unlimited: null, used: null,
limit: null, remaining: null, balance: null, unit: null, ...input,
};
}
class ProbeFailure extends Error {
constructor(readonly code: NonNullable<AiConnectionUsage["errorCode"]>) { super(code); }
}
async function readUsage(url: string, headers: Record<string, string>, request: typeof fetch): Promise<ObjectValue> {
// Fixed origins, no redirects, and a deadline covering headers AND response bytes.
const response = await request(url, {
headers, redirect: "error", signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
await response.body?.cancel();
throw new ProbeFailure(response.status === 401 ? "authentication_required"
: response.status === 403 ? "permission_denied"
: response.status === 429 ? "rate_limited" : "provider_unavailable");
}
// Provider errors and raw responses must never be returned to the caller.
const reader = response.body?.getReader();
if (!reader) throw new ProbeFailure("invalid_response");
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > 256 * 1024) throw new ProbeFailure("invalid_response");
chunks.push(value);
}
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) throw new ProbeFailure("invalid_response");
return parsed as ObjectValue;
} catch (error) {
await reader.cancel().catch(() => undefined);
throw error instanceof ProbeFailure ? error : new ProbeFailure("invalid_response");
} finally {
reader.releaseLock();
}
}
function parseCredential(credential: string): ObjectValue {
try { return object(JSON.parse(credential)); }
catch { throw new ProbeFailure("authentication_required"); }
}
function requireToken(value: unknown): string {
const token = string(value);
if (!token?.trim()) throw new ProbeFailure("authentication_required");
return token;
}
function codexLimits(body: ObjectValue): AiConnectionUsageLimit[] {
const limits: AiConnectionUsageLimit[] = [];
function add(id: string, label: string, scope: string | null, raw: unknown) {
const group = object(raw);
for (const key of ["primary_window", "secondary_window"]) {
if (!group[key] || typeof group[key] !== "object") continue;
const window = object(group[key]);
limits.push(limit({
id: `${id}.${key}`, label: `${label} · ${key === "primary_window" ? "Primary" : "Secondary"}`,
scope, windowDurationSeconds: number(window.limit_window_seconds),
resetsAt: timestamp(window.reset_at), usedPercent: number(window.used_percent),
// Admission describes the whole group; utilization belongs to this window.
allowed: bool(group.allowed), limitReached: number(window.used_percent) == null ? bool(group.limit_reached) : null, unit: "percent",
}));
}
if (limits.every((entry) => !entry.id.startsWith(`${id}.`)) && (bool(group.limit_reached) != null || bool(group.allowed) != null)) {
limits.push(limit({ id, label, scope, allowed: bool(group.allowed), limitReached: bool(group.limit_reached) }));
}
}
add("codex", "Codex", null, body.rate_limit);
if (body.code_review_rate_limit) add("code_review", "Code review", "code_review", body.code_review_rate_limit);
if (Array.isArray(body.additional_rate_limits)) {
body.additional_rate_limits.forEach((value, index) => {
const additional = object(value);
const id = string(additional.metered_feature) ?? `additional_${index}`;
add(id, string(additional.limit_name) ?? id, id, additional.rate_limit);
});
}
return limits;
}
async function codex(credential: string, request: typeof fetch): Promise<Observation> {
const auth = parseCredential(credential);
const tokens = object(auth.tokens);
const headers: Record<string, string> = { Authorization: `Bearer ${requireToken(tokens.access_token ?? auth.accessToken)}` };
const accountId = string(tokens.account_id ?? auth.accountId);
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
const body = await readUsage("https://chatgpt.com/backend-api/wham/usage", headers, request);
const credits = object(body.credits);
const unlimited = bool(credits.unlimited);
const hasCredits = bool(credits.has_credits);
const spendControl = object(body.spend_control);
const spendLimit = object(spendControl.individual_limit);
const limits = codexLimits(body);
if (bool(spendControl.reached) != null) {
limits.push(limit({ id: "spend_control", label: "Workspace spend control", limitReached: bool(spendControl.reached),
usedPercent: number(spendLimit.used_percent), used: balance(spendLimit.used), limit: balance(spendLimit.limit),
remaining: balance(spendLimit.remaining), resetsAt: timestamp(spendLimit.reset_at),
}));
}
return {
source: "codex_wham", planType: string(body.plan_type), limits,
overage: body.credits == null ? null : overage({
available: unlimited === true ? true : hasCredits,
unlimited, balance: balance(credits.balance), unit: "credits",
}),
};
}
async function claude(credential: string, request: typeof fetch): Promise<Observation> {
const body = await readUsage("https://api.anthropic.com/api/oauth/usage", {
Authorization: `Bearer ${requireToken(credential)}`, "anthropic-beta": "oauth-2025-04-20",
}, request);
const limits: AiConnectionUsageLimit[] = [];
const labels: Record<string, string> = {
five_hour: "5 hour limit", seven_day: "Weekly limit", seven_day_sonnet: "Sonnet weekly limit",
seven_day_opus: "Opus weekly limit", seven_day_oauth_apps: "OAuth apps weekly limit",
};
for (const [id, value] of Object.entries(body)) {
const window = object(value);
if (id === "extra_usage" || !("utilization" in window || "resets_at" in window)) continue;
limits.push(limit({
id, label: labels[id] ?? id.replaceAll("_", " "),
scope: id === "five_hour" || id === "seven_day" ? null : id,
windowDurationSeconds: id === "five_hour" ? 18000 : id.startsWith("seven_day") ? 604800 : null,
resetsAt: timestamp(window.resets_at), usedPercent: number(window.utilization), unit: "percent",
}));
}
if (Array.isArray(body.limits)) {
const seen = new Set<string>();
body.limits.forEach((value, index) => {
const window = object(value);
const kind = string(window.kind);
if (!kind || !("percent" in window || "resets_at" in window)) return;
const rawScope = object(window.scope);
const group = string(window.group);
const model = object(rawScope.model);
const modelName = string(model.id) ?? string(model.display_name);
const surface = string(rawScope.surface) ?? string(object(rawScope.surface).id)
?? string(object(rawScope.surface).display_name);
const knownModel = !surface && modelName?.match(/\b(sonnet|opus)\b/i)?.[1].toLowerCase();
const legacyId = kind === "session" ? "five_hour" : kind === "weekly_all" ? "seven_day"
: kind === "weekly_scoped" && knownModel ? `seven_day_${knownModel}` : `${kind}.${index}`;
const scopeParts = [group && `group:${group}`, modelName && `model:${modelName}`, surface && `surface:${surface}`].filter(Boolean);
// A named group is its own allowance, even for session/weekly_all kinds.
// Merge a legacy alias only when the structured window has the same scope.
const baseId = group || ((kind === "session" || kind === "weekly_all") && scopeParts.length)
? `${legacyId}.scope.${encodeURIComponent(scopeParts.join("|"))}` : legacyId;
const id = seen.has(baseId) ? `${baseId}.${index}` : baseId;
seen.add(baseId);
const scope = scopeParts.join(" · ") || (kind === "session" || kind === "weekly_all" ? null : id);
const previousIndex = limits.findIndex((entry) => entry.id === id);
const previous = limits[previousIndex];
const entry = limit({ id, label: `${labels[legacyId] ?? kind.replaceAll("_", " ")}${group ? ` · ${group}` : ""}${modelName && (!labels[legacyId] || legacyId === "five_hour" || legacyId === "seven_day") ? ` · ${modelName}` : ""}${surface ? ` · ${surface}` : ""}`,
scope, windowDurationSeconds: kind === "session" ? 18000 : kind.startsWith("weekly_") ? 604800 : null,
usedPercent: number(window.percent) ?? previous?.usedPercent,
resetsAt: timestamp(window.resets_at) ?? previous?.resetsAt ?? null, unit: "percent",
// is_active selects the dashboard's current meter, not admission.
});
if (previousIndex < 0) limits.push(entry);
else limits[previousIndex] = entry;
});
}
const extra = object(body.extra_usage);
const spend = object(body.spend);
const money = (value: unknown) => {
const raw = object(value);
const minor = number(raw.amount_minor);
const exponent = number(raw.exponent);
const currency = string(raw.currency);
return minor != null && exponent != null && Number.isInteger(exponent) && exponent <= 10 && currency
? { value: minor / 10 ** exponent, currency } : null;
};
const spendLimit = money(spend.limit);
const spendUsed = money(spend.used);
const spendBalance = money(spend.balance);
const legacyExtra = body.extra_usage != null;
const unit = legacyExtra ? "cents" : spendLimit?.currency ?? spendUsed?.currency ?? spendBalance?.currency ?? null;
const enabled = legacyExtra ? bool(extra.is_enabled) : bool(spend.enabled);
const cap = legacyExtra ? number(extra.monthly_limit) : spendLimit?.value ?? null;
const used = legacyExtra ? number(extra.used_credits) : spendUsed?.currency === unit ? spendUsed.value : null;
const prepaidBalance = spendBalance?.currency === unit ? spendBalance.value : null;
const remaining = cap == null || used == null ? null : Math.max(0, cap - used);
const hasExtra = legacyExtra || body.spend != null;
if (hasExtra) {
limits.push(limit({ id: "extra_usage", label: "Monthly extra usage", scope: "overage",
usedPercent: number(legacyExtra ? extra.utilization : spend.percent), used, limit: cap, remaining, unit,
allowed: enabled === false ? false : null,
}));
}
return {
source: "anthropic_oauth", planType: null, limits,
overage: !hasExtra ? null : overage({
enabled, available: enabled === false || remaining === 0 ? false : null,
used, limit: cap, remaining, balance: prepaidBalance, unit,
}),
};
}
async function grok(credential: string, request: typeof fetch): Promise<Observation> {
const payload = parseGrokAuthPayload(parseCredential(credential));
const body = await readUsage("https://cli-chat-proxy.grok.com/v1/billing?format=credits", {
Authorization: `Bearer ${requireToken(payload?.value.key)}`,
"x-xai-token-auth": "xai-grok-cli", Accept: "application/json",
}, request);
const config = object(body.config);
const period = object(config.currentPeriod);
const start = timestamp(period.start ?? config.billingPeriodStart);
const end = timestamp(period.end ?? config.billingPeriodEnd);
const duration = start && end ? (Date.parse(end) - Date.parse(start)) / 1000 : null;
const used = amount(config.used);
const cap = amount(config.monthlyLimit);
const usedPercent = number(config.creditUsagePercent);
// Do not derive included-plan consumption from the separate on-demand pool.
const limits = [limit({ id: "grok_credits", label: "Grok plan credits",
usedPercent, used, limit: cap, remaining: cap == null || used == null ? null : Math.max(0, cap - used),
unit: "cents", resetsAt: end, windowDurationSeconds: duration != null && duration > 0 ? duration : null,
})];
const extraUsed = amount(config.onDemandUsed);
const extraCap = amount(config.onDemandCap);
const remaining = extraUsed == null || extraCap == null ? null : Math.max(0, extraCap - extraUsed);
const enabled = bool(body.on_demand_enabled ?? body.onDemandEnabled);
const prepaidBalance = amount(config.prepaidBalance);
const extra = enabled == null && extraUsed == null && extraCap == null && prepaidBalance == null ? null
: overage({ enabled, available: enabled === false || (remaining === 0 && prepaidBalance === 0) ? false : null,
used: extraUsed, limit: extraCap, remaining, balance: prepaidBalance, unit: "cents" });
if (extra) limits.push(limit({ id: "grok_overage", label: "Grok on-demand credits", scope: "overage",
used: extraUsed, limit: extraCap, remaining, unit: "cents", resetsAt: timestamp(config.billingPeriodEnd) ?? end }));
return { source: "grok_cli_billing", planType: string(body.subscription_tier ?? body.subscriptionTier), limits, overage: extra };
}
async function openrouter(credential: string, request: typeof fetch): Promise<Observation> {
const body = await readUsage("https://openrouter.ai/api/v1/key", {
Authorization: `Bearer ${requireToken(credential)}`,
}, request);
const data = object(body.data);
const cap = number(data.limit);
const remaining = signedNumber(data.limit_remaining);
const resetInterval = string(data.limit_reset);
const limits = [limit({ id: "key_credits", label: "API key credit cap", scope: "api_key",
limit: cap, remaining, used: cap != null
? remaining == null ? null : Math.max(0, cap - remaining)
: number(data.usage),
unit: "USD", resetInterval, windowDurationSeconds: resetInterval === "daily" ? 86400 : resetInterval === "weekly" ? 604800 : null,
limitReached: remaining == null ? null : remaining <= 0,
})];
const free = object(data.free_model_daily_requests);
if (Object.keys(free).length) limits.push(limit({ id: "free_requests", label: "Free model daily requests", scope: "free_models",
used: number(free.used), limit: number(free.limit), remaining: number(free.remaining),
unit: "requests", windowDurationSeconds: 86400,
limitReached: number(free.remaining) == null ? null : number(free.remaining) === 0,
}));
// An unlimited per-key cap says nothing about the account's funded balance.
return { source: "openrouter_key", planType: null, limits, overage: null };
}
const probes = { openai: codex, anthropic: claude, xai: grok, openrouter };
const messages: Record<NonNullable<AiConnectionUsage["errorCode"]>, string> = {
unsupported: "This provider does not expose usage limits through this sign-in method.",
connection_unavailable: "Reconnect or enable this AI connection before checking usage.",
authentication_required: "The provider could not authenticate this usage check. Reconnect the account.",
permission_denied: "The provider denied access to usage limits for this credential. Its sign-in permissions may not include usage access.",
rate_limited: "The provider rate limited the usage check. Try again later.",
provider_unavailable: "Could not read provider usage. Try again later.",
invalid_response: "The provider did not return usable limit data. Try again later.",
};
export async function probeAiConnectionUsage(
metadata: AiConnectionMetadata,
credential: string,
options: { request?: typeof fetch } = {},
): Promise<Omit<AiConnectionUsage, "connectionId" | "grantId">> {
const base = { ...metadata, checkedAt: new Date().toISOString(), source: null, planType: null, limits: [], overage: null };
if (metadata.provider === "google" || !supportsAiConnectionUsage(metadata.provider, metadata.method)) {
return { ...base, status: "unsupported", errorCode: "unsupported", message: messages.unsupported };
}
try {
const observation = await probes[metadata.provider](credential, options.request ?? fetch);
if (!observation.limits.some((entry) => entry.usedPercent != null || entry.used != null || entry.limit != null || entry.limitReached != null || entry.allowed != null)
&& !Object.values(observation.overage ?? {}).some((value) => typeof value === "number" || typeof value === "boolean")) throw new ProbeFailure("invalid_response");
return { ...base, ...observation, status: "ok", checkedAt: new Date().toISOString() };
} catch (error) {
const errorCode = error instanceof ProbeFailure ? error.code : "provider_unavailable";
return { ...base, status: errorCode === "authentication_required" || errorCode === "permission_denied" ? "unavailable" : "error", errorCode, message: messages[errorCode] };
}
}