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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Add optional policy model allowlists across discovery and inference, Claude Opus 5.5 pricing, and opt-in policy-scoped pool status with shared subscription eligibility and seven-day usage.

- Make Claude subscription pools model-aware, retain scoped weekly quota and account facts, add an included-quota reserve and explicit paid environment fallback, and use the Claude Code 2.1.288 identity.

- Attribute usage to the final upstream grant and lane with additive ledger storage, a bounded admin daily-model aggregation API, and a Subscriptions page for account headroom, extra usage and traffic.
Expand Down
2 changes: 2 additions & 0 deletions admin/src/hooks/access/use-policy-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export function usePolicyAdmin({ request, allowDemo, gatewayOrigin, session, dem
policyId: policyForm.policyId,
enabled: policyForm.enabled,
providers: policyForm.allProviders ? [] : policyProviders,
models: policyForm.allModels ? null : unique(policyForm.models.split(",").map(id => id.trim()).filter(Boolean)),
poolStatus: policyForm.poolStatus,
tenantId: policyForm.tenantId || "default",
tokenRole: policyForm.tokenRole || null,
monthlyBudgetMicros: optionalCurrencyMicros(policyForm.monthlyBudgetMicros) ?? null,
Expand Down
3 changes: 3 additions & 0 deletions admin/src/screens/access.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,9 @@ export function PolicyPanel({ keys, selected, providers, form, setForm, error, d
<label><span>status</span><select value={form.enabled ? "active" : "disabled"} onChange={(event) => setForm({ enabled: event.target.value === "active" })}><option value="active">active</option><option value="disabled">disabled</option></select></label>
<label><span>monthly budget ($)</span><input inputMode="decimal" value={form.monthlyBudgetMicros} onChange={(event) => setForm({ monthlyBudgetMicros: event.target.value })} placeholder="unlimited" /></label>
<label><span>fixed request cost (micros)</span><input inputMode="decimal" value={form.requestCostMicros} onChange={(event) => setForm({ requestCostMicros: event.target.value })} placeholder="blank = manifest-priced routes only" title="Required for any budgeted route without manifest pricing; blank uses versioned list pricing where available." /></label>
<label className="full"><span>model access</span><select value={form.allModels ? "all" : "selected"} onChange={event => setForm({ allModels: event.target.value === "all" })}><option value="all">all models of allowed services</option><option value="selected">selected catalog models only</option></select></label>
{!form.allModels ? <label className="full"><span>allowed model ids · comma separated</span><textarea value={form.models} onChange={event => setForm({ models: event.target.value })} placeholder="anthropic/claude-opus-5-5" /><small>Empty denies every model. Use full catalog IDs.</small></label> : null}
<label className="full"><span>pool status for policy holders</span><select value={form.poolStatus} onChange={event => setForm({ poolStatus: event.target.value as PolicyForm["poolStatus"] })}><option value="hidden">hidden</option><option value="visible">visible · includes pool account emails</option></select></label>
<label className="full"><span>budget quota</span><select value={form.budgetScope} onChange={(event) => setForm({ budgetScope: event.target.value as PolicyForm["budgetScope"] })}><option value="policy">shared by policy</option><option value="principal">per maintainer</option></select></label>
<label className="full"><span>request content retention</span><select value={form.retainRequestContent ? "enabled" : "disabled"} onChange={(event) => setForm({ retainRequestContent: event.target.value === "enabled" })}><option value="enabled">enabled · retain 30 days</option><option value="disabled">disabled</option></select></label>
</div>
Expand Down
36 changes: 2 additions & 34 deletions admin/src/subscriptions-data.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,6 @@
import type { AccessPolicy, ProviderRow, UpstreamGrant } from "./ui-types";
import type { ProviderRow, UpstreamGrant } from "./ui-types";

export function policyGrants(policy: AccessPolicy | undefined, grants: UpstreamGrant[]): UpstreamGrant[] {
if (!policy) return [];
return grants.filter(grant => !!grant.provider && (grant.scope === "policies" ? grant.scopeId === policy.policyId : grant.scopeId === (policy.tenantId ?? "default"))
&& (!policy.providers.length || policy.providers.includes(grant.provider))
&& (!Object.hasOwn(policy.grantRouting.eligibleGrants, grant.provider) || policy.grantRouting.eligibleGrants[grant.provider].includes(grant.tokenRef)));
}

export function planLabel(tier?: string): string {
if (!tier) return "Plan unknown";
const max = tier.match(/claude_max_(\d+)x/);
return max ? `Max ${max[1]}x` : /claude_pro/.test(tier) ? "Pro" : tier.replace(/^default_/, "").replaceAll("_", " ");
}

export function quotaFree(window: UpstreamGrant["quotaWindows"][number], now = Date.now()): number | null {
if (window.resetAt && Date.parse(window.resetAt) <= now) return null;
return window.limit && window.remaining !== null ? Math.max(0, Math.min(100, window.remaining / window.limit * 100)) : null;
}

export function modelHeadroom(grant: UpstreamGrant, family: string, policy: AccessPolicy, now = Date.now()) {
const applicable = grant.quotaWindows.filter(window => !window.models?.length || window.models.some(token => family.includes(token)));
const ratios = applicable.map(window => quotaFree(window, now)).filter((value): value is number => value !== null);
const free = ratios.length ? Math.min(...ratios) : null;
const fresh = (value: string | null | undefined) => !!value && Number.isFinite(Date.parse(value)) && Date.parse(value) + policy.grantRouting.staleAfterSeconds * 1000 > now;
const live = applicable.filter(window => !window.resetAt || Date.parse(window.resetAt) > now);
const stale = !live.length || !fresh(grant.quotaObservedAt) || live.some(window => !fresh(window.observedAt ?? grant.quotaObservedAt));
const reserve = grant.kind === "subscription" ? policy.grantRouting.includedQuotaReservePercent ?? 0 : 0;
const exhausted = applicable.some(window => (!window.resetAt || Date.parse(window.resetAt) > now)
&& (window.remaining === 0 || (quotaFree(window, now) ?? Infinity) <= reserve));
const eligible = policy.enabled !== false && grant.enabled && grant.usable && grant.credentialStatus !== "reauth_required"
&& !(grant.cooldownUntil && Date.parse(grant.cooldownUntil) > now)
&& !(stale && policy.grantRouting.staleState === "deny") && !exhausted;
return { free, eligible, stale };
}
export { policyGrants, planLabel, quotaFree, modelHeadroom } from "../../shared/subscription-pool";

export function subscriptionFamilies(grants: UpstreamGrant[]): string[] {
return [...new Set(["fable", "opus", "sonnet", ...grants.flatMap(grant => grant.quotaWindows.flatMap(window => window.models ?? []))])].sort();
Expand Down
3 changes: 3 additions & 0 deletions admin/src/ui-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export const defaultPolicy: PolicyForm = {
budgetScope: "policy",
providers: ["openai", "tavily"],
allProviders: false,
models: "",
allModels: true,
poolStatus: "hidden",
retainRequestContent: true,
grantStrategy: "most_remaining",
grantStickiness: "none",
Expand Down
3 changes: 3 additions & 0 deletions admin/src/ui-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@ export function policyFormFromPolicy(key: AccessPolicy): PolicyForm {
budgetScope: key.budgetScope ?? "policy",
providers: key.providers,
allProviders: key.providers.length === 0,
models: key.models?.join(", ") ?? "",
allModels: key.models == null,
poolStatus: key.poolStatus ?? "hidden",
retainRequestContent: key.retainRequestContent,
grantStrategy: key.grantRouting.strategy,
grantStickiness: key.grantRouting.stickiness,
Expand Down
3 changes: 3 additions & 0 deletions admin/src/ui-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ export interface PolicyForm {
budgetScope: NonNullable<AccessPolicy["budgetScope"]>;
providers: string[];
allProviders: boolean;
models: string;
allModels: boolean;
poolStatus: "hidden" | "visible";
retainRequestContent: boolean;
grantStrategy: AccessPolicy["grantRouting"]["strategy"];
grantStickiness: AccessPolicy["grantRouting"]["stickiness"];
Expand Down
7 changes: 6 additions & 1 deletion admin/test/subscriptions-data.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import { canProbeGrant, modelHeadroom, planLabel, policyGrants, quotaFree, resetLabel, subscriptionFamilies } from "../src/subscriptions-data.ts";
import { registerHooks } from "node:module";
import { extname } from "node:path";
registerHooks({ resolve(specifier, context, nextResolve) {
return nextResolve(specifier.startsWith(".") && context.parentURL && !extname(new URL(specifier, context.parentURL).pathname) ? `${specifier}.ts` : specifier, context);
} });
const { canProbeGrant, modelHeadroom, planLabel, policyGrants, quotaFree, resetLabel, subscriptionFamilies } = await import("../src/subscriptions-data.ts");

const now = Date.parse("2026-10-11T12:00:00Z");
const policy = { policyId: "pool", tenantId: "tenant", providers: ["anthropic"], grantRouting: { eligibleGrants: {}, staleAfterSeconds: 300, staleState: "allow", includedQuotaReservePercent: 10 } };
Expand Down
17 changes: 17 additions & 0 deletions admin/test/use-policy-admin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -838,3 +838,20 @@ function mount(demoMode = false) {
};
return fixture;
}

test("model allowlist and pool visibility round-trip and preserve later field edits", async () => {
const fixture = ready();
change(fixture, { allModels: false, models: "anthropic/claude-opus-5-5, anthropic/claude-opus-5-5", poolStatus: "visible" });
const save = fixture.render().policies.save(event);
const submitted = JSON.parse(fixture.requests[0].init.body);
assert.deepEqual(submitted.models, ["anthropic/claude-opus-5-5"]); assert.equal(submitted.poolStatus, "visible");
change(fixture, { models: "anthropic/claude-sonnet-5", poolStatus: "hidden" });
fixture.requests[0].resolve(submitted); await save;
assert.equal(fixture.render().policies.form.models, "anthropic/claude-sonnet-5");
assert.equal(fixture.render().policies.form.poolStatus, "hidden");
change(fixture, { allModels: true });
const reset = fixture.render().policies.save(event);
const next = JSON.parse(fixture.requests[1].init.body);
assert.equal(next.models, null); assert.equal(next.poolStatus, "hidden");
fixture.requests[1].resolve(next); await reset;
});
62 changes: 62 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The optional `/private/v1/{models,catalog,responses}` facade has its own pinned
| `GET` | `/v1/catalog` | Authorized configured providers and operation/model/transport offers |
| `GET` | `/v1/me` | Proxy-credential identity and policy summary |
| `GET` | `/v1/usage` | Caller policy or principal budget and usage summary |
| `GET` | `/v1/pool` | Opt-in policy pool inventory, quota eligibility and seven-day grant/lane totals |
| `GET` | `/v1/key/inspect` | Proxy-credential verification and readiness status |

Usage summaries, provider totals, and daily totals remain shared across each
Expand Down Expand Up @@ -484,6 +485,67 @@ When both the owner and KV record are missing, either PUT mode creates an accoun
only after confirming an empty attachment index. Retained legacy membership or
generation history returns HTTP 409, including generation-zero legacy rows.

### Policy model access

Policies optionally set `models` to an array of full catalog IDs, for example
`["anthropic/claude-opus-5-5"]`. Omitted or `null` preserves access to every
model of the allowed providers; `[]` denies every model. Provider restrictions
still apply. Writes accept at most 1,024 entries, reject unknown IDs, and
normalize duplicates. Omitting either `models` or `poolStatus` from an update
preserves the existing value.

`/v1/catalog`, `/v1/models` (including Anthropic format), session entitlements,
and Fusion offers honor these scopes. Access sessions can use models allowed by
any held policy, with the normal ordered policy choice among matching policies.
Every concrete inference call checks the resolved catalog ID before grant
selection, budget reservation or upstream contact. A denied model returns HTTP
403 `model_not_allowed` on native Messages and count_tokens, manifest routes,
OpenAI chat/embeddings/speech, HTTP Responses, and each WebSocket response.create.
Fusion checks its synthesizer and each adviser separately; denied advisers fail
open like other unavailable advisers. Its virtual name does not grant access to
underlying models. Restricted policies reject opaque universal gateway requests
because their downstream model selections cannot be verified.

### Policy pool status

`GET /v1/pool` accepts a proxy key or verified Access session. Policy
`poolStatus: "visible"` opts in; omitted or `"hidden"` returns HTTP 404
`pool_status_hidden`. Responses use `Cache-Control: private, no-store`.
A proxy key always selects its own policy, regardless of query parameters.
An Access session may select one of its held policies with `?policyId=<id>`;
without it, the first active policy is used. An unheld policy returns HTTP 403.

The response contains `version: "clawrouter.pool.v1"`, ISO `observedAt`,
`policyId`, `routing` (`strategy`, `includedQuotaReservePercent`,
`environmentFallback`), allowed catalog `models`, `grants`, and `usage`.
Each grant includes only its token-reference `key`, label, provider, kind,
enabled and credential status, plan, account (`email`, `rateLimitTier`,
`subscriptionStatus`), quota `windows` (`id`, `window`, `models`,
`remainingPercent`, `resetAt`, `observedAt`), extra usage (`enabled`,
`usedCredits`, `monthlyLimit`, `currency`, `spendLimitReached`), per-model
`eligibility`, `lastSelectedAt`, and `selectedCount`. Unknown facts are null.
Account emails are intentionally visible to holders when the administrator opts
in. No access/refresh tokens, credential fields, raw provider payloads, or full
storage keys are returned.

Inventory includes the selected policy's grants and shared tenant grants,
filtered by its provider and eligible-grant restrictions. It is bounded to
1,024 scanned grant records (HTTP 422 `pool_too_large` beyond that limit).
`usage` contains `days: 7` and the policy's aggregate `lanes` and `grants` from
the existing grant usage ledger, with token references replacing storage keys;
it contains no individual request events. These are policy-wide totals, including
other credentials on the same policy, and retain historical attribution.

Eligibility shares the Subscriptions page and dispatch quota rules: scoped
windows match upstream model family names, reserve applies to subscriptions,
active cooldown blocks selection, and stale observations block only under
`staleState: "deny"`. Status is `eligible`, `exhausted`, `cooldown`, `stale`, or
`reauth_required`. Models belonging to another provider use `exhausted` (no
usable capacity); disabled grants are never eligible, and missing or invalid
credentials use `reauth_required`. Eligibility reports quota/credential availability, not priority
selection, transport readiness or a budget reservation. Observations and usage
are advisory snapshots, not an atomic upstream quota guarantee.

### Subscription routing and quota

Policy `grantRouting` accepts two opt-in controls in addition to the existing
Expand Down
9 changes: 8 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ one request's identity, correlation, pricing, and timing, then builds its final
usage event and delegates independent settlement and delivery to `accounting.ts`.

1. Authenticate the Access session or proxy credential against canonical state.
2. Resolve the selected policy and provider-scoped readiness. Tenant grants are
2. Resolve the selected policy, its catalog-model allowlist, and provider-scoped readiness. Tenant grants are
visible only to policies in that tenant; policy grants are exact-scope.
3. Reserve the conservative budget before provider work.
4. Retain eligible LLM request content in R2 when policy requires it. Storage
Expand Down Expand Up @@ -261,3 +261,10 @@ Object bindings are unchanged.
configured dead-letter queue.
- Raw requests live only in the retention archive; usage ledgers contain metadata
and content references, never prompts or completions.

Policy model admission lives at `prepareSelected`, shared by native, manifest,
unified HTTP, Fusion calls and WebSocket creates. Discovery uses the same model
predicate. `shared/grant-quota.ts` owns quota applicability, freshness and reserve
rules; `shared/subscription-pool.ts` owns pool presentation and model eligibility
for both the admin Subscriptions page and opt-in `/v1/pool`. The latter exposes
only explicitly projected fields from the caller's policy and tenant scopes.
10 changes: 9 additions & 1 deletion docs/deploy-cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,14 @@ manual token import does not establish provider approval or compatibility.
See [OpenAI setup and subscription limits](openai-subscriptions.md) for direct
Codex login and the private facade's separate requirements.

The catalog includes `anthropic/claude-opus-5-5` with the October 11, 2026
standard prices and one-million-token input limit. Its upstream name matches
`opus` quota windows. A policy can restrict the pool to this model with
`models: ["anthropic/claude-opus-5-5"]`; omitted/null leaves existing access
unchanged. Set `poolStatus: "visible"` only when policy holders should see the
pool's account emails, quota readings and policy-wide seven-day usage at
`GET /v1/pool`. The default is hidden. See the [API contract](api-reference.md#policy-pool-status).

The bundled Anthropic manifest supports Claude subscription OAuth credentials,
including one-year inference-only tokens generated by `claude setup-token` and
refreshable access/refresh pairs. It uses Bearer authentication, merges
Expand Down Expand Up @@ -1004,7 +1012,7 @@ remains unknown. Choose `staleState: "deny"` if stale observations must block
admission; the existing stale-state default is `allow`.

`environmentFallback` defaults to false. With it enabled, exhaustion, reserve,
model restrictions, or one-shot failover exclusion of the last grant can select
model-scoped quota exhaustion, or one-shot failover exclusion of the last grant can select
`ANTHROPIC_API_KEY` using normal API-key transport and budget accounting. If that
credential is absent the unavailable pool still returns HTTP 503. This setting
does not enable disabled providers, bypass budgets, or change pinned continuation
Expand Down
14 changes: 14 additions & 0 deletions providers/anthropic.provider.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,20 @@ endpoints:
timeoutMs: 120000
models:
entries:
- id: anthropic/claude-opus-5-5
upstream: claude-opus-5-5
capabilities: [llm.messages, llm.count_tokens]
pricingRef: anthropic-claude-opus-5-5-standard-2026-10-11
pricing:
effectiveAt: "2026-10-11"
source: https://platform.claude.com/docs/en/about-claude/pricing
inputMicrosPerMillion: 4000000
cachedInputMicrosPerMillion: 200000
cacheWrite5mInputMicrosPerMillion: 5000000
cacheWrite1hInputMicrosPerMillion: 8000000
outputMicrosPerMillion: 20000000
maxInputTokens: 1000000
defaultMaxOutputTokens: 128000
- id: anthropic/claude-opus-5
upstream: claude-opus-5
capabilities: [llm.messages, llm.count_tokens]
Expand Down
2 changes: 2 additions & 0 deletions shared/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface AccessPolicy {
policyId: string;
enabled: boolean;
providers: string[];
models?: string[] | null;
poolStatus?: "hidden" | "visible";
tenantId?: string | null;
tokenRole?: string | null;
monthlyBudgetMicros?: number | null;
Expand Down
Loading
Loading