feat(costs): add billing, quota, and budget control plane
This commit is contained in:
468
doc/plans/2026-03-14-billing-ledger-and-reporting.md
Normal file
468
doc/plans/2026-03-14-billing-ledger-and-reporting.md
Normal file
@@ -0,0 +1,468 @@
|
||||
# Billing Ledger and Reporting
|
||||
|
||||
## Context
|
||||
|
||||
Paperclip currently stores model spend in `cost_events` and operational run state in `heartbeat_runs`.
|
||||
That split is fine, but the current reporting code tries to infer billing semantics by mixing both tables:
|
||||
|
||||
- `cost_events` knows provider, model, tokens, and dollars
|
||||
- `heartbeat_runs.usage_json` knows some per-run billing metadata
|
||||
- `heartbeat_runs.usage_json` does **not** currently carry enough normalized billing dimensions to support honest provider-level reporting
|
||||
|
||||
This becomes incorrect as soon as a company uses more than one provider, more than one billing channel, or more than one billing mode.
|
||||
|
||||
Examples:
|
||||
|
||||
- direct OpenAI API usage
|
||||
- Claude subscription usage with zero marginal dollars
|
||||
- subscription overage with dollars and tokens
|
||||
- OpenRouter billing where the biller is OpenRouter but the upstream provider is Anthropic or OpenAI
|
||||
|
||||
The system needs to support:
|
||||
|
||||
- dollar reporting
|
||||
- token reporting
|
||||
- subscription-included usage
|
||||
- subscription overage
|
||||
- direct metered API usage
|
||||
- future aggregator billing such as OpenRouter
|
||||
|
||||
## Product Decision
|
||||
|
||||
`cost_events` becomes the canonical billing and usage ledger for reporting.
|
||||
|
||||
`heartbeat_runs` remains an operational execution log. It may keep mirrored billing metadata for debugging and transcripts, but reporting must not reconstruct billing semantics from `heartbeat_runs.usage_json`.
|
||||
|
||||
## Decision: One Ledger Or Two
|
||||
|
||||
We do **not** need two tables to solve the current PR's problem.
|
||||
For request-level inference reporting, `cost_events` is enough if it carries the right dimensions:
|
||||
|
||||
- upstream provider
|
||||
- biller
|
||||
- billing type
|
||||
- model
|
||||
- token fields
|
||||
- billed amount
|
||||
|
||||
That is why the first implementation pass extends `cost_events` instead of introducing a second table immediately.
|
||||
|
||||
However, if Paperclip needs to account for the full billing surface of aggregators and managed AI platforms, then `cost_events` alone is not enough.
|
||||
Some charges are not cleanly representable as a single model inference event:
|
||||
|
||||
- account top-ups and credit purchases
|
||||
- platform fees charged at purchase time
|
||||
- BYOK platform fees that are account-level or threshold-based
|
||||
- prepaid credit expirations, refunds, and adjustments
|
||||
- provisioned throughput commitments
|
||||
- fine-tuning, training, model import, and storage charges
|
||||
- gateway logging or other platform overhead that is not attributable to one prompt/response pair
|
||||
|
||||
So the decision is:
|
||||
|
||||
- near term: keep `cost_events` as the inference and usage ledger
|
||||
- next phase: add `finance_events` for non-inference financial events
|
||||
|
||||
This is a deliberate split between:
|
||||
|
||||
- usage and inference accounting
|
||||
- account-level and platform-level financial accounting
|
||||
|
||||
That separation keeps request reporting honest without forcing us to fake invoice semantics onto rows that were never request-scoped.
|
||||
|
||||
## External Motivation And Sources
|
||||
|
||||
The need for this model is not theoretical.
|
||||
It follows directly from the billing systems of providers and aggregators Paperclip needs to support.
|
||||
|
||||
### OpenRouter
|
||||
|
||||
Source URLs:
|
||||
|
||||
- https://openrouter.ai/docs/faq#credit-and-billing-systems
|
||||
- https://openrouter.ai/pricing
|
||||
|
||||
Relevant billing behavior as of March 14, 2026:
|
||||
|
||||
- OpenRouter passes through underlying inference pricing and deducts request cost from purchased credits.
|
||||
- OpenRouter charges a 5.5% fee with a $0.80 minimum when purchasing credits.
|
||||
- Crypto payments are charged a 5% fee.
|
||||
- BYOK has its own fee model after a free request threshold.
|
||||
- OpenRouter billing is aggregated at the OpenRouter account level even when the upstream provider is Anthropic, OpenAI, Google, or another provider.
|
||||
|
||||
Implication for Paperclip:
|
||||
|
||||
- request usage belongs in `cost_events`
|
||||
- credit purchases, purchase fees, BYOK fees, refunds, and expirations belong in `finance_events`
|
||||
- `biller=openrouter` must remain distinct from `provider=anthropic|openai|google|...`
|
||||
|
||||
### Cloudflare AI Gateway Unified Billing
|
||||
|
||||
Source URL:
|
||||
|
||||
- https://developers.cloudflare.com/ai-gateway/features/unified-billing/
|
||||
|
||||
Relevant billing behavior as of March 14, 2026:
|
||||
|
||||
- Unified Billing lets users call multiple upstream providers while receiving a single Cloudflare bill.
|
||||
- Usage is paid from Cloudflare-loaded credits.
|
||||
- Cloudflare supports manual top-ups and auto top-up thresholds.
|
||||
- Spend limits can stop request processing on daily, weekly, or monthly boundaries.
|
||||
- Unified Billing traffic can use Cloudflare-managed credentials rather than the user's direct provider key.
|
||||
|
||||
Implication for Paperclip:
|
||||
|
||||
- request usage needs `biller=cloudflare`
|
||||
- upstream provider still needs to be preserved separately
|
||||
- Cloudflare credit loads and related account-level events are not inference rows and should not be forced into `cost_events`
|
||||
- quota and limits reporting must support biller-level controls, not just upstream provider limits
|
||||
|
||||
### Amazon Bedrock
|
||||
|
||||
Source URL:
|
||||
|
||||
- https://aws.amazon.com/bedrock/pricing/
|
||||
|
||||
Relevant billing behavior as of March 14, 2026:
|
||||
|
||||
- Bedrock supports on-demand and batch pricing.
|
||||
- Bedrock pricing varies by region.
|
||||
- some pricing tiers add premiums or discounts relative to standard pricing
|
||||
- provisioned throughput is commitment-based rather than request-based
|
||||
- custom model import uses Custom Model Units billed per minute, with monthly storage charges
|
||||
- imported model copies are billed in 5-minute windows once active
|
||||
- customization and fine-tuning introduce training and hosted-model charges beyond normal inference
|
||||
|
||||
Implication for Paperclip:
|
||||
|
||||
- normal tokenized inference fits in `cost_events`
|
||||
- provisioned throughput, custom model unit charges, training, and storage charges require `finance_events`
|
||||
- region and pricing tier need to be first-class dimensions in the financial model
|
||||
|
||||
## Ledger Boundary
|
||||
|
||||
To keep the system coherent, the table boundary should be explicit.
|
||||
|
||||
### `cost_events`
|
||||
|
||||
Use `cost_events` for request-scoped usage and inference charges:
|
||||
|
||||
- one row per billable or usage-bearing run event
|
||||
- provider/model/biller/billingType/tokens/cost
|
||||
- optionally tied to `heartbeat_run_id`
|
||||
- supports direct APIs, subscriptions, overage, OpenRouter-routed inference, Cloudflare-routed inference, and Bedrock on-demand inference
|
||||
|
||||
### `finance_events`
|
||||
|
||||
Use `finance_events` for account-scoped or platform-scoped financial events:
|
||||
|
||||
- credit purchase
|
||||
- top-up
|
||||
- refund
|
||||
- fee
|
||||
- expiry
|
||||
- provisioned capacity
|
||||
- training
|
||||
- model import
|
||||
- storage
|
||||
- invoice adjustment
|
||||
|
||||
These rows may or may not have a related model, provider, or run id.
|
||||
Trying to force them into `cost_events` would either create fake request rows or create null-heavy rows that mean something fundamentally different from inference usage.
|
||||
|
||||
## Canonical Billing Dimensions
|
||||
|
||||
Every persisted billing event should model four separate axes:
|
||||
|
||||
1. Usage provider
|
||||
The upstream provider whose model performed the work.
|
||||
Examples: `openai`, `anthropic`, `google`.
|
||||
|
||||
2. Biller
|
||||
The system that charged for the usage.
|
||||
Examples: `openai`, `anthropic`, `openrouter`, `cursor`, `chatgpt`.
|
||||
|
||||
3. Billing type
|
||||
The pricing mode applied to the event.
|
||||
Initial canonical values:
|
||||
- `metered_api`
|
||||
- `subscription_included`
|
||||
- `subscription_overage`
|
||||
- `credits`
|
||||
- `fixed`
|
||||
- `unknown`
|
||||
|
||||
4. Measures
|
||||
Usage and billing must both be storable:
|
||||
- `input_tokens`
|
||||
- `output_tokens`
|
||||
- `cached_input_tokens`
|
||||
- `cost_cents`
|
||||
|
||||
These dimensions are independent.
|
||||
For example, an event may be:
|
||||
|
||||
- provider: `anthropic`
|
||||
- biller: `openrouter`
|
||||
- billing type: `metered_api`
|
||||
- tokens: non-zero
|
||||
- cost cents: non-zero
|
||||
|
||||
Or:
|
||||
|
||||
- provider: `anthropic`
|
||||
- biller: `anthropic`
|
||||
- billing type: `subscription_included`
|
||||
- tokens: non-zero
|
||||
- cost cents: `0`
|
||||
|
||||
## Schema Changes
|
||||
|
||||
Extend `cost_events` with:
|
||||
|
||||
- `heartbeat_run_id uuid null references heartbeat_runs.id`
|
||||
- `biller text not null default 'unknown'`
|
||||
- `billing_type text not null default 'unknown'`
|
||||
- `cached_input_tokens int not null default 0`
|
||||
|
||||
Keep `provider` as the upstream usage provider.
|
||||
Do not overload `provider` to mean biller.
|
||||
|
||||
Add a future `finance_events` table for account-level financial events with fields along these lines:
|
||||
|
||||
- `company_id`
|
||||
- `occurred_at`
|
||||
- `event_kind`
|
||||
- `direction`
|
||||
- `biller`
|
||||
- `provider nullable`
|
||||
- `execution_adapter_type nullable`
|
||||
- `pricing_tier nullable`
|
||||
- `region nullable`
|
||||
- `model nullable`
|
||||
- `quantity nullable`
|
||||
- `unit nullable`
|
||||
- `amount_cents`
|
||||
- `currency`
|
||||
- `estimated`
|
||||
- `related_cost_event_id nullable`
|
||||
- `related_heartbeat_run_id nullable`
|
||||
- `external_invoice_id nullable`
|
||||
- `metadata_json nullable`
|
||||
|
||||
Add indexes:
|
||||
|
||||
- `(company_id, biller, occurred_at)`
|
||||
- `(company_id, provider, occurred_at)`
|
||||
- `(company_id, heartbeat_run_id)` if distinct-run reporting remains common
|
||||
|
||||
## Shared Contract Changes
|
||||
|
||||
### Shared types
|
||||
|
||||
Add a shared billing type union and enrich cost types with:
|
||||
|
||||
- `heartbeatRunId`
|
||||
- `biller`
|
||||
- `billingType`
|
||||
- `cachedInputTokens`
|
||||
|
||||
Update reporting response types so the provider breakdown reflects the ledger directly rather than inferred run metadata.
|
||||
|
||||
### Validators
|
||||
|
||||
Extend `createCostEventSchema` to accept:
|
||||
|
||||
- `heartbeatRunId`
|
||||
- `biller`
|
||||
- `billingType`
|
||||
- `cachedInputTokens`
|
||||
|
||||
Defaults:
|
||||
|
||||
- `biller` defaults to `provider`
|
||||
- `billingType` defaults to `unknown`
|
||||
- `cachedInputTokens` defaults to `0`
|
||||
|
||||
## Adapter Contract Changes
|
||||
|
||||
Extend adapter execution results so they can report:
|
||||
|
||||
- `biller`
|
||||
- richer billing type values
|
||||
|
||||
Backwards compatibility:
|
||||
|
||||
- existing adapter values `api` and `subscription` are treated as legacy aliases
|
||||
- map `api -> metered_api`
|
||||
- map `subscription -> subscription_included`
|
||||
|
||||
Future adapters may emit the canonical values directly.
|
||||
|
||||
OpenRouter support will use:
|
||||
|
||||
- `provider` = upstream provider when known
|
||||
- `biller` = `openrouter`
|
||||
- `billingType` = `metered_api` unless OpenRouter later exposes another billing mode
|
||||
|
||||
Cloudflare Unified Billing support will use:
|
||||
|
||||
- `provider` = upstream provider when known
|
||||
- `biller` = `cloudflare`
|
||||
- `billingType` = `credits` or `metered_api` depending on the normalized request billing contract
|
||||
|
||||
Bedrock support will use:
|
||||
|
||||
- `provider` = upstream provider or `aws_bedrock` depending on adapter shape
|
||||
- `biller` = `aws_bedrock`
|
||||
- `billingType` = request-scoped mode for inference rows
|
||||
- `finance_events` for provisioned, training, import, and storage charges
|
||||
|
||||
## Write Path Changes
|
||||
|
||||
### Heartbeat-created events
|
||||
|
||||
When a heartbeat run produces usage or spend:
|
||||
|
||||
1. normalize adapter billing metadata
|
||||
2. write a ledger row to `cost_events`
|
||||
3. attach `heartbeat_run_id`
|
||||
4. set `provider`, `biller`, `billing_type`, token fields, and `cost_cents`
|
||||
|
||||
The write path should no longer depend on later inference from `heartbeat_runs`.
|
||||
|
||||
### Manual API-created events
|
||||
|
||||
Manual cost event creation remains supported.
|
||||
These events may have `heartbeatRunId = null`.
|
||||
|
||||
Rules:
|
||||
|
||||
- `provider` remains required
|
||||
- `biller` defaults to `provider`
|
||||
- `billingType` defaults to `unknown`
|
||||
|
||||
## Reporting Changes
|
||||
|
||||
### Server
|
||||
|
||||
Refactor reporting queries to use `cost_events` only.
|
||||
|
||||
#### `summary`
|
||||
|
||||
- sum `cost_cents`
|
||||
|
||||
#### `by-agent`
|
||||
|
||||
- sum costs and token fields from `cost_events`
|
||||
- use `count(distinct heartbeat_run_id)` filtered by billing type for run counts
|
||||
- use token sums filtered by billing type for subscription usage
|
||||
|
||||
#### `by-provider`
|
||||
|
||||
- group by `provider`, `model`
|
||||
- sum costs and token fields directly from the ledger
|
||||
- derive billing-type slices from `cost_events.billing_type`
|
||||
- never pro-rate from unrelated `heartbeat_runs`
|
||||
|
||||
#### future `by-biller`
|
||||
|
||||
- group by `biller`
|
||||
- this is the right view for invoice and subscription accountability
|
||||
|
||||
#### `window-spend`
|
||||
|
||||
- continue to use `cost_events`
|
||||
|
||||
#### project attribution
|
||||
|
||||
Keep current project attribution logic for now, but prefer `cost_events.heartbeat_run_id` as the join anchor whenever possible.
|
||||
|
||||
## UI Changes
|
||||
|
||||
### Principles
|
||||
|
||||
- Spend, usage, and quota are related but distinct
|
||||
- a missing quota fetch is not the same as “no quota”
|
||||
- provider and biller are different dimensions
|
||||
|
||||
### Immediate UI changes
|
||||
|
||||
1. Keep the current costs page structure.
|
||||
2. Make the provider cards accurate by reading only ledger-backed values.
|
||||
3. Show provider quota fetch errors explicitly instead of dropping them.
|
||||
|
||||
### Follow-up UI direction
|
||||
|
||||
The long-term board UI should expose:
|
||||
|
||||
- Spend
|
||||
Dollars by biller, provider, model, agent, project
|
||||
- Usage
|
||||
Tokens by provider, model, agent, project
|
||||
- Quotas
|
||||
Live provider or biller limits, credits, and reset windows
|
||||
- Financial events
|
||||
Credit purchases, top-ups, fees, refunds, commitments, storage, and other non-inference charges
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Migration behavior:
|
||||
|
||||
- add new non-destructive columns with defaults
|
||||
- backfill existing rows:
|
||||
- `biller = provider`
|
||||
- `billing_type = 'unknown'`
|
||||
- `cached_input_tokens = 0`
|
||||
- `heartbeat_run_id = null`
|
||||
|
||||
Do **not** attempt to backfill historical provider-level subscription attribution from `heartbeat_runs`.
|
||||
That data was never stored with the required dimensions.
|
||||
|
||||
## Testing Plan
|
||||
|
||||
Add or update tests for:
|
||||
|
||||
1. heartbeat-created ledger rows persist `heartbeatRunId`, `biller`, `billingType`, and cached tokens
|
||||
2. legacy adapter billing values map correctly
|
||||
3. provider reporting uses ledger data only
|
||||
4. mixed-provider companies do not cross-attribute subscription usage
|
||||
5. zero-dollar subscription usage still appears in token reporting
|
||||
6. quota fetch failures render explicit UI state
|
||||
7. manual cost events still validate and write correctly
|
||||
8. biller reporting keeps upstream provider breakdowns separate
|
||||
9. OpenRouter-style rows can show `biller=openrouter` with non-OpenRouter upstream providers
|
||||
10. Cloudflare-style rows can show `biller=cloudflare` with preserved upstream provider identity
|
||||
11. future `finance_events` aggregation handles non-request charges without requiring a model or run id
|
||||
|
||||
## Delivery Plan
|
||||
|
||||
### Step 1
|
||||
|
||||
- land the ledger contract and query rewrite
|
||||
- make the current costs page correct
|
||||
|
||||
### Step 2
|
||||
|
||||
- add biller-oriented reporting endpoints and UI
|
||||
|
||||
### Step 3
|
||||
|
||||
- wire OpenRouter and any future aggregator adapters to the same contract
|
||||
|
||||
### Step 4
|
||||
|
||||
- add `executionAdapterType` to persisted cost reporting if adapter-level grouping becomes a product requirement
|
||||
|
||||
### Step 5
|
||||
|
||||
- introduce `finance_events`
|
||||
- add non-inference accounting endpoints
|
||||
- add UI for platform/account charges alongside inference spend and usage
|
||||
|
||||
## Non-Goals For This Change
|
||||
|
||||
- multi-currency support
|
||||
- invoice reconciliation
|
||||
- provider-specific cost estimation beyond persisted billed cost
|
||||
- replacing `heartbeat_runs` as the operational run record
|
||||
611
doc/plans/2026-03-14-budget-policies-and-enforcement.md
Normal file
611
doc/plans/2026-03-14-budget-policies-and-enforcement.md
Normal file
@@ -0,0 +1,611 @@
|
||||
# Budget Policies and Enforcement
|
||||
|
||||
## Context
|
||||
|
||||
Paperclip already treats budgets as a core control-plane responsibility:
|
||||
|
||||
- `doc/SPEC.md` gives the Board authority to set budgets, pause agents, pause work, and override any budget.
|
||||
- `doc/SPEC-implementation.md` says V1 must support monthly UTC budget windows, soft alerts, and hard auto-pause.
|
||||
- the current code only partially implements that intent.
|
||||
|
||||
Today the system has narrow money-budget behavior:
|
||||
|
||||
- companies track `budgetMonthlyCents` and `spentMonthlyCents`
|
||||
- agents track `budgetMonthlyCents` and `spentMonthlyCents`
|
||||
- `cost_events` ingestion increments those counters
|
||||
- when an agent exceeds its monthly budget, the agent is paused
|
||||
|
||||
That leaves major product gaps:
|
||||
|
||||
- no project budget model
|
||||
- no approval generated when budget is hit
|
||||
- no generic budget policy system
|
||||
- no project pause semantics tied to budget
|
||||
- no durable incident tracking to prevent duplicate alerts
|
||||
- no separation between enforceable spend budgets and advisory usage quotas
|
||||
|
||||
This plan defines the concrete budgeting model Paperclip should implement next.
|
||||
|
||||
## Product Goals
|
||||
|
||||
Paperclip should let operators:
|
||||
|
||||
1. Set budgets on agents and projects.
|
||||
2. Understand whether a budget is based on money or usage.
|
||||
3. Be warned before a budget is exhausted.
|
||||
4. Automatically pause work when a hard budget is hit.
|
||||
5. Approve, raise, or resume from a budget stop using obvious UI.
|
||||
6. See budget state on the dashboard, `/costs`, and scope detail pages.
|
||||
|
||||
The system should make one thing very clear:
|
||||
|
||||
- budgets are policy controls
|
||||
- quotas are usage visibility
|
||||
|
||||
They are related, but they are not the same concept.
|
||||
|
||||
## Product Decisions
|
||||
|
||||
### V1 Budget Defaults
|
||||
|
||||
For the next implementation pass, Paperclip should enforce these defaults:
|
||||
|
||||
- agent budgets are recurring monthly budgets
|
||||
- project budgets are lifetime total budgets
|
||||
- hard-stop enforcement uses billed dollars, not tokens
|
||||
- monthly windows use UTC calendar months
|
||||
- project total budgets do not reset automatically
|
||||
|
||||
This gives a clean mental model:
|
||||
|
||||
- agents are ongoing workers, so monthly recurring budget is natural
|
||||
- projects are bounded workstreams, so lifetime cap is natural
|
||||
|
||||
### Metric To Enforce First
|
||||
|
||||
The first enforceable metric should be `billed_cents`.
|
||||
|
||||
Reasoning:
|
||||
|
||||
- it works across providers, billers, and models
|
||||
- it maps directly to real financial risk
|
||||
- it handles overage and metered usage consistently
|
||||
- it avoids cross-provider token normalization problems
|
||||
- it applies cleanly even when future finance events are not token-based
|
||||
|
||||
Token budgets should not be the first hard-stop policy.
|
||||
They should come later as advisory usage controls once the money-based system is solid.
|
||||
|
||||
### Subscription Usage Decision
|
||||
|
||||
Paperclip should separate subscription-included usage from billed spend:
|
||||
|
||||
- `subscription_included`
|
||||
- visible in reporting
|
||||
- visible in usage summaries
|
||||
- does not count against money budget
|
||||
- `subscription_overage`
|
||||
- visible in reporting
|
||||
- counts against money budget
|
||||
- `metered_api`
|
||||
- visible in reporting
|
||||
- counts against money budget
|
||||
|
||||
This keeps the budget system honest:
|
||||
|
||||
- users should not see "spend" rise for usage that did not incur marginal billed cost
|
||||
- users should still see the token usage and provider quota state
|
||||
|
||||
### Soft Alert Versus Hard Stop
|
||||
|
||||
Paperclip should have two threshold classes:
|
||||
|
||||
- soft alert
|
||||
- creates visible notification state
|
||||
- does not create an approval
|
||||
- does not pause work
|
||||
- hard stop
|
||||
- pauses the affected scope automatically
|
||||
- creates an approval requiring human resolution
|
||||
- prevents additional heartbeats or task pickup in that scope
|
||||
|
||||
Default thresholds:
|
||||
|
||||
- soft alert at `80%`
|
||||
- hard stop at `100%`
|
||||
|
||||
These should be configurable per policy later, but they are good defaults now.
|
||||
|
||||
## Scope Model
|
||||
|
||||
### Supported Scope Types
|
||||
|
||||
Budget policies should support:
|
||||
|
||||
- `company`
|
||||
- `agent`
|
||||
- `project`
|
||||
|
||||
This plan focuses on finishing `agent` and `project` first while preserving the existing company budget behavior.
|
||||
|
||||
### Recommended V1.5 Policy Presets
|
||||
|
||||
- Company
|
||||
- metric: `billed_cents`
|
||||
- window: `calendar_month_utc`
|
||||
- Agent
|
||||
- metric: `billed_cents`
|
||||
- window: `calendar_month_utc`
|
||||
- Project
|
||||
- metric: `billed_cents`
|
||||
- window: `lifetime`
|
||||
|
||||
Future extensions can add:
|
||||
|
||||
- token advisory policies
|
||||
- daily or weekly spend windows
|
||||
- provider- or biller-scoped budgets
|
||||
- inherited delegated budgets down the org tree
|
||||
|
||||
## Current Implementation Baseline
|
||||
|
||||
The current codebase is not starting from zero, but the existing shape is too ad hoc to extend safely.
|
||||
|
||||
### What Exists Today
|
||||
|
||||
- company and agent monthly cents counters
|
||||
- cost ingestion that updates those counters
|
||||
- agent hard-stop pause on monthly budget overrun
|
||||
|
||||
### What Is Missing
|
||||
|
||||
- project budgets
|
||||
- generic budget policy persistence
|
||||
- generic threshold crossing detection
|
||||
- incident deduplication per scope/window
|
||||
- approval creation on hard-stop
|
||||
- project execution blocking
|
||||
- budget timeline and incident UI
|
||||
- distinction between advisory quota and enforceable budget
|
||||
|
||||
## Proposed Data Model
|
||||
|
||||
### 1. `budget_policies`
|
||||
|
||||
Create a new table for canonical budget definitions.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`
|
||||
- `company_id`
|
||||
- `scope_type`
|
||||
- `scope_id`
|
||||
- `metric`
|
||||
- `window_kind`
|
||||
- `amount`
|
||||
- `warn_percent`
|
||||
- `hard_stop_enabled`
|
||||
- `notify_enabled`
|
||||
- `is_active`
|
||||
- `created_by_user_id`
|
||||
- `updated_by_user_id`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Notes:
|
||||
|
||||
- `scope_type` is one of `company | agent | project`
|
||||
- `scope_id` is nullable only for company-level policy if company is implied; otherwise keep it explicit
|
||||
- `metric` should start with `billed_cents`
|
||||
- `window_kind` starts with `calendar_month_utc | lifetime`
|
||||
- `amount` is stored in the natural unit of the metric
|
||||
|
||||
### 2. `budget_incidents`
|
||||
|
||||
Create a durable record of threshold crossings.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`
|
||||
- `company_id`
|
||||
- `policy_id`
|
||||
- `scope_type`
|
||||
- `scope_id`
|
||||
- `metric`
|
||||
- `window_kind`
|
||||
- `window_start`
|
||||
- `window_end`
|
||||
- `threshold_type`
|
||||
- `amount_limit`
|
||||
- `amount_observed`
|
||||
- `status`
|
||||
- `approval_id` nullable
|
||||
- `activity_id` nullable
|
||||
- `resolved_at` nullable
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Notes:
|
||||
|
||||
- `threshold_type`: `soft | hard`
|
||||
- `status`: `open | acknowledged | resolved | dismissed`
|
||||
- one open incident per policy per threshold per window prevents duplicate approvals and alert spam
|
||||
|
||||
### 3. Project Pause State
|
||||
|
||||
Projects need explicit pause semantics.
|
||||
|
||||
Recommended approach:
|
||||
|
||||
- extend project status or add a pause field so a project can be blocked by budget
|
||||
- preserve whether the project is paused due to budget versus manually paused
|
||||
|
||||
Preferred shape:
|
||||
|
||||
- keep project workflow status as-is
|
||||
- add execution-state fields:
|
||||
- `execution_status`: `active | paused | archived`
|
||||
- `pause_reason`: `manual | budget | system | null`
|
||||
|
||||
If that is too large for the immediate pass, a smaller version is:
|
||||
|
||||
- add `paused_at`
|
||||
- add `pause_reason`
|
||||
|
||||
The key requirement is behavioral, not cosmetic:
|
||||
Paperclip must know that a project is budget-paused and enforce it.
|
||||
|
||||
### 4. Compatibility With Existing Budget Columns
|
||||
|
||||
Existing company and agent monthly budget columns should remain temporarily for compatibility.
|
||||
|
||||
Migration plan:
|
||||
|
||||
1. keep reading existing columns during transition
|
||||
2. create equivalent `budget_policies` rows
|
||||
3. switch enforcement and UI to policies
|
||||
4. later remove or deprecate legacy columns
|
||||
|
||||
## Budget Engine
|
||||
|
||||
Budget enforcement should move into a dedicated service.
|
||||
|
||||
Current logic is buried inside cost ingestion.
|
||||
That is too narrow because budget checks must apply at more than one execution boundary.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
New service: `budgetService`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- resolve applicable policies for a cost event
|
||||
- compute current window totals
|
||||
- detect threshold crossings
|
||||
- create incidents, activities, and approvals
|
||||
- pause affected scopes on hard-stop
|
||||
- provide preflight enforcement checks for execution entry points
|
||||
|
||||
### Canonical Evaluation Flow
|
||||
|
||||
When a new `cost_event` is written:
|
||||
|
||||
1. persist the `cost_event`
|
||||
2. identify affected scopes
|
||||
- company
|
||||
- agent
|
||||
- project
|
||||
3. fetch active policies for those scopes
|
||||
4. compute current observed amount for each policy window
|
||||
5. compare to thresholds
|
||||
6. create soft incident if soft threshold crossed for first time in window
|
||||
7. create hard incident if hard threshold crossed for first time in window
|
||||
8. if hard incident:
|
||||
- pause the scope
|
||||
- create approval
|
||||
- create activity event
|
||||
- emit notification state
|
||||
|
||||
### Preflight Enforcement Checks
|
||||
|
||||
Budget enforcement cannot rely only on post-hoc cost ingestion.
|
||||
|
||||
Paperclip must also block execution before new work starts.
|
||||
|
||||
Add budget checks to:
|
||||
|
||||
- scheduler heartbeat dispatch
|
||||
- manual invoke endpoints
|
||||
- assignment-driven wakeups
|
||||
- queued run promotion
|
||||
- issue checkout or pickup paths where applicable
|
||||
|
||||
If a scope is budget-paused:
|
||||
|
||||
- do not start a new heartbeat
|
||||
- do not let the agent pick up additional work
|
||||
- present a clear reason in API and UI
|
||||
|
||||
### Active Run Behavior
|
||||
|
||||
When a hard-stop is triggered while a run is already active:
|
||||
|
||||
- mark scope paused immediately for future work
|
||||
- request graceful cancellation of the current run
|
||||
- allow normal cancellation timeout behavior
|
||||
- write activity explaining that pause came from budget enforcement
|
||||
|
||||
This mirrors the general pause semantics already expected by the product.
|
||||
|
||||
## Approval Model
|
||||
|
||||
Budget hard-stops should create a first-class approval.
|
||||
|
||||
### New Approval Type
|
||||
|
||||
Add approval type:
|
||||
|
||||
- `budget_override_required`
|
||||
|
||||
Payload should include:
|
||||
|
||||
- `scopeType`
|
||||
- `scopeId`
|
||||
- `scopeName`
|
||||
- `metric`
|
||||
- `windowKind`
|
||||
- `thresholdType`
|
||||
- `budgetAmount`
|
||||
- `observedAmount`
|
||||
- `windowStart`
|
||||
- `windowEnd`
|
||||
- `topDrivers`
|
||||
- `paused`
|
||||
|
||||
### Resolution Actions
|
||||
|
||||
The approval UI should support:
|
||||
|
||||
- raise budget and resume
|
||||
- resume once without changing policy
|
||||
- keep paused
|
||||
|
||||
Optional later action:
|
||||
|
||||
- disable budget policy
|
||||
|
||||
### Soft Alerts Do Not Need Approval
|
||||
|
||||
Soft alerts should create:
|
||||
|
||||
- activity event
|
||||
- dashboard alert
|
||||
- inbox notification or similar board-visible signal
|
||||
|
||||
They should not create an approval by default.
|
||||
|
||||
## Notification And Activity Model
|
||||
|
||||
Budget events need obvious operator visibility.
|
||||
|
||||
Required outputs:
|
||||
|
||||
- activity log entry on threshold crossings
|
||||
- dashboard surface for active budget incidents
|
||||
- detail page banner on paused agent or project
|
||||
- `/costs` summary of active incidents and policy health
|
||||
|
||||
Later channels:
|
||||
|
||||
- email
|
||||
- webhook
|
||||
- Slack or other integrations
|
||||
|
||||
## API Plan
|
||||
|
||||
### Policy Management
|
||||
|
||||
Add routes for:
|
||||
|
||||
- list budget policies for company
|
||||
- create budget policy
|
||||
- update budget policy
|
||||
- archive or disable budget policy
|
||||
|
||||
### Incident Surfaces
|
||||
|
||||
Add routes for:
|
||||
|
||||
- list active budget incidents
|
||||
- list incident history
|
||||
- get incident detail for a scope
|
||||
|
||||
### Approval Resolution
|
||||
|
||||
Budget approvals should use the existing approval system once the new approval type is added.
|
||||
|
||||
Expected flows:
|
||||
|
||||
- create approval on hard-stop
|
||||
- resolve approval by changing policy and resuming
|
||||
- resolve approval by resuming once
|
||||
|
||||
### Execution Errors
|
||||
|
||||
When work is blocked by budget, the API should return explicit errors.
|
||||
|
||||
Examples:
|
||||
|
||||
- agent invocation blocked because agent budget is paused
|
||||
- issue execution blocked because project budget is paused
|
||||
|
||||
Do not silently no-op.
|
||||
|
||||
## UI Plan
|
||||
|
||||
Budgeting should be visible in the places where operators make decisions.
|
||||
|
||||
### `/costs`
|
||||
|
||||
Add a budget section that includes:
|
||||
|
||||
- active budget incidents
|
||||
- policy list with scope, window, metric, and threshold state
|
||||
- progress bars for current period or total
|
||||
- clear distinction between:
|
||||
- spend budget
|
||||
- subscription quota
|
||||
- quick actions:
|
||||
- raise budget
|
||||
- open approval
|
||||
- resume scope if permitted
|
||||
|
||||
The page should make this visual distinction obvious:
|
||||
|
||||
- Budget
|
||||
- enforceable spend policy
|
||||
- Quota
|
||||
- provider or subscription usage window
|
||||
|
||||
### Agent Detail
|
||||
|
||||
Add an agent budget card:
|
||||
|
||||
- monthly budget amount
|
||||
- current month spend
|
||||
- remaining spend
|
||||
- status
|
||||
- warning or paused banner
|
||||
- link to approval if blocked
|
||||
|
||||
### Project Detail
|
||||
|
||||
Add a project budget card:
|
||||
|
||||
- total budget amount
|
||||
- total spend to date
|
||||
- remaining spend
|
||||
- pause status
|
||||
- approval link
|
||||
|
||||
Project detail should also show if issue execution is blocked because the project is budget-paused.
|
||||
|
||||
### Dashboard
|
||||
|
||||
Add a high-signal budget section:
|
||||
|
||||
- active budget breaches
|
||||
- upcoming soft alerts
|
||||
- counts of paused agents and paused projects due to budget
|
||||
|
||||
The operator should not have to visit `/costs` to learn that work has stopped.
|
||||
|
||||
## Budget Math
|
||||
|
||||
### What Counts Toward Budget
|
||||
|
||||
For V1.5 enforcement, include:
|
||||
|
||||
- `metered_api` cost events
|
||||
- `subscription_overage` cost events
|
||||
- any future request-scoped cost event with non-zero billed cents
|
||||
|
||||
Do not include:
|
||||
|
||||
- `subscription_included` cost events with zero billed cents
|
||||
- advisory quota rows
|
||||
- account-level finance events unless and until company-level financial budgets are added explicitly
|
||||
|
||||
### Why Not Tokens First
|
||||
|
||||
Token budgets should not be the first hard-stop because:
|
||||
|
||||
- providers count tokens differently
|
||||
- cached tokens complicate simple totals
|
||||
- some future charges are not token-based
|
||||
- subscription tokens do not necessarily imply spend
|
||||
- money remains the cleanest cross-provider enforcement metric
|
||||
|
||||
### Future Budget Metrics
|
||||
|
||||
Future policy metrics can include:
|
||||
|
||||
- `total_tokens`
|
||||
- `input_tokens`
|
||||
- `output_tokens`
|
||||
- `requests`
|
||||
- `finance_amount_cents`
|
||||
|
||||
But they should enter only after the money-budget path is stable.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Foundation
|
||||
|
||||
- add `budget_policies`
|
||||
- add `budget_incidents`
|
||||
- add new approval type
|
||||
- add project pause metadata
|
||||
|
||||
### Phase 2: Compatibility
|
||||
|
||||
- backfill policies from existing company and agent monthly budget columns
|
||||
- keep legacy columns readable during migration
|
||||
|
||||
### Phase 3: Enforcement
|
||||
|
||||
- move budget logic into dedicated service
|
||||
- add hard-stop incident creation
|
||||
- add activity and approval creation
|
||||
- add execution guards on heartbeat and invoke paths
|
||||
|
||||
### Phase 4: UI
|
||||
|
||||
- `/costs` budget section
|
||||
- agent detail budget card
|
||||
- project detail budget card
|
||||
- dashboard incident summary
|
||||
|
||||
### Phase 5: Cleanup
|
||||
|
||||
- move all reads/writes to `budget_policies`
|
||||
- reduce legacy column reliance
|
||||
- decide whether to remove old budget columns
|
||||
|
||||
## Tests
|
||||
|
||||
Required coverage:
|
||||
|
||||
- agent monthly budget soft alert at 80%
|
||||
- agent monthly budget hard-stop at 100%
|
||||
- project lifetime budget soft alert
|
||||
- project lifetime budget hard-stop
|
||||
- `subscription_included` usage does not consume money budget
|
||||
- `subscription_overage` does consume money budget
|
||||
- hard-stop creates one incident per threshold per window
|
||||
- hard-stop creates approval and pauses correct scope
|
||||
- paused project blocks new issue execution
|
||||
- paused agent blocks new heartbeat dispatch
|
||||
- policy update and resume clears or resolves active incident correctly
|
||||
- dashboard and `/costs` surface active incidents
|
||||
|
||||
## Open Questions
|
||||
|
||||
These should be explicitly deferred unless they block implementation:
|
||||
|
||||
- Should project budgets also support monthly mode, or is lifetime enough for the first release?
|
||||
- Should company-level budgets eventually include `finance_events` such as OpenRouter top-up fees and Bedrock provisioned charges?
|
||||
- Should delegated budget editing be limited by org hierarchy in V1, or remain board-only in the UI even if the data model can support delegation later?
|
||||
- Do we need "resume once" immediately, or can first approval resolution be "raise budget and resume" plus "keep paused"?
|
||||
|
||||
## Recommendation
|
||||
|
||||
Implement the first coherent budgeting system with these rules:
|
||||
|
||||
- Agent budget = monthly billed dollars
|
||||
- Project budget = lifetime billed dollars
|
||||
- Hard-stop = auto-pause + approval
|
||||
- Soft alert = visible warning, no approval
|
||||
- Subscription usage = visible quota and token reporting, not money-budget enforcement
|
||||
|
||||
This solves the real operator problem without mixing together spend control, provider quota windows, and token accounting.
|
||||
28
packages/adapter-utils/src/billing.test.ts
Normal file
28
packages/adapter-utils/src/billing.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inferOpenAiCompatibleBiller } from "./billing.js";
|
||||
|
||||
describe("inferOpenAiCompatibleBiller", () => {
|
||||
it("returns openrouter when OPENROUTER_API_KEY is present", () => {
|
||||
expect(
|
||||
inferOpenAiCompatibleBiller({ OPENROUTER_API_KEY: "sk-or-123" } as NodeJS.ProcessEnv, "openai"),
|
||||
).toBe("openrouter");
|
||||
});
|
||||
|
||||
it("returns openrouter when OPENAI_BASE_URL points at OpenRouter", () => {
|
||||
expect(
|
||||
inferOpenAiCompatibleBiller(
|
||||
{ OPENAI_BASE_URL: "https://openrouter.ai/api/v1" } as NodeJS.ProcessEnv,
|
||||
"openai",
|
||||
),
|
||||
).toBe("openrouter");
|
||||
});
|
||||
|
||||
it("returns fallback when no OpenRouter markers are present", () => {
|
||||
expect(
|
||||
inferOpenAiCompatibleBiller(
|
||||
{ OPENAI_BASE_URL: "https://api.openai.com/v1" } as NodeJS.ProcessEnv,
|
||||
"openai",
|
||||
),
|
||||
).toBe("openai");
|
||||
});
|
||||
});
|
||||
20
packages/adapter-utils/src/billing.ts
Normal file
20
packages/adapter-utils/src/billing.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
function readEnv(env: NodeJS.ProcessEnv, key: string): string | null {
|
||||
const value = env[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function inferOpenAiCompatibleBiller(
|
||||
env: NodeJS.ProcessEnv,
|
||||
fallback: string | null = "openai",
|
||||
): string | null {
|
||||
const explicitOpenRouterKey = readEnv(env, "OPENROUTER_API_KEY");
|
||||
if (explicitOpenRouterKey) return "openrouter";
|
||||
|
||||
const baseUrl =
|
||||
readEnv(env, "OPENAI_BASE_URL") ??
|
||||
readEnv(env, "OPENAI_API_BASE") ??
|
||||
readEnv(env, "OPENAI_API_BASE_URL");
|
||||
if (baseUrl && /openrouter\.ai/i.test(baseUrl)) return "openrouter";
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -30,3 +30,4 @@ export {
|
||||
redactHomePathUserSegmentsInValue,
|
||||
redactTranscriptEntryPaths,
|
||||
} from "./log-redaction.js";
|
||||
export { inferOpenAiCompatibleBiller } from "./billing.js";
|
||||
|
||||
@@ -30,7 +30,15 @@ export interface UsageSummary {
|
||||
cachedInputTokens?: number;
|
||||
}
|
||||
|
||||
export type AdapterBillingType = "api" | "subscription" | "unknown";
|
||||
export type AdapterBillingType =
|
||||
| "api"
|
||||
| "subscription"
|
||||
| "metered_api"
|
||||
| "subscription_included"
|
||||
| "subscription_overage"
|
||||
| "credits"
|
||||
| "fixed"
|
||||
| "unknown";
|
||||
|
||||
export interface AdapterRuntimeServiceReport {
|
||||
id?: string | null;
|
||||
@@ -68,6 +76,7 @@ export interface AdapterExecutionResult {
|
||||
sessionParams?: Record<string, unknown> | null;
|
||||
sessionDisplayId?: string | null;
|
||||
provider?: string | null;
|
||||
biller?: string | null;
|
||||
model?: string | null;
|
||||
billingType?: AdapterBillingType | null;
|
||||
costUsd?: number | null;
|
||||
@@ -185,12 +194,16 @@ export interface QuotaWindow {
|
||||
resetsAt: string | null;
|
||||
/** free-form value label for credit-style windows, e.g. "$4.20 remaining" */
|
||||
valueLabel: string | null;
|
||||
/** optional supporting text, e.g. reset details or provider-specific notes */
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
/** result for one provider from getQuotaWindows() */
|
||||
export interface ProviderQuotaResult {
|
||||
/** provider slug, e.g. "anthropic", "openai" */
|
||||
provider: string;
|
||||
/** source label when the provider reports where the quota data came from */
|
||||
source?: string | null;
|
||||
/** true when the fetch succeeded and windows is populated */
|
||||
ok: boolean;
|
||||
/** error message when ok is false */
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json",
|
||||
"probe:quota:raw": "pnpm exec tsx src/cli/quota-probe.ts --json --raw-cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
|
||||
124
packages/adapters/claude-local/src/cli/quota-probe.ts
Normal file
124
packages/adapters/claude-local/src/cli/quota-probe.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
captureClaudeCliUsageText,
|
||||
fetchClaudeCliQuota,
|
||||
fetchClaudeQuota,
|
||||
getQuotaWindows,
|
||||
parseClaudeCliUsageText,
|
||||
readClaudeAuthStatus,
|
||||
readClaudeToken,
|
||||
} from "../server/quota.js";
|
||||
|
||||
interface ProbeArgs {
|
||||
json: boolean;
|
||||
includeRawCli: boolean;
|
||||
oauthOnly: boolean;
|
||||
cliOnly: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ProbeArgs {
|
||||
return {
|
||||
json: argv.includes("--json"),
|
||||
includeRawCli: argv.includes("--raw-cli"),
|
||||
oauthOnly: argv.includes("--oauth-only"),
|
||||
cliOnly: argv.includes("--cli-only"),
|
||||
};
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.oauthOnly && args.cliOnly) {
|
||||
throw new Error("Choose either --oauth-only or --cli-only, not both.");
|
||||
}
|
||||
|
||||
const authStatus = await readClaudeAuthStatus();
|
||||
const token = await readClaudeToken();
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
authStatus,
|
||||
tokenAvailable: token != null,
|
||||
};
|
||||
|
||||
if (!args.cliOnly) {
|
||||
if (!token) {
|
||||
result.oauth = {
|
||||
ok: false,
|
||||
error: "No Claude OAuth access token found in local credentials files.",
|
||||
windows: [],
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
result.oauth = {
|
||||
ok: true,
|
||||
windows: await fetchClaudeQuota(token),
|
||||
};
|
||||
} catch (error) {
|
||||
result.oauth = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.oauthOnly) {
|
||||
try {
|
||||
const rawCliText = args.includeRawCli ? await captureClaudeCliUsageText() : null;
|
||||
const windows = rawCliText ? parseClaudeCliUsageText(rawCliText) : await fetchClaudeCliQuota();
|
||||
result.cli = rawCliText
|
||||
? {
|
||||
ok: true,
|
||||
windows,
|
||||
rawText: rawCliText,
|
||||
}
|
||||
: {
|
||||
ok: true,
|
||||
windows,
|
||||
};
|
||||
} catch (error) {
|
||||
result.cli = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.oauthOnly && !args.cliOnly) {
|
||||
try {
|
||||
result.aggregated = await getQuotaWindows();
|
||||
} catch (error) {
|
||||
result.aggregated = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const oauthOk = (result.oauth as { ok?: boolean } | undefined)?.ok === true;
|
||||
const cliOk = (result.cli as { ok?: boolean } | undefined)?.ok === true;
|
||||
const aggregatedOk = (result.aggregated as { ok?: boolean } | undefined)?.ok === true;
|
||||
const ok = oauthOk || cliOk || aggregatedOk;
|
||||
|
||||
if (args.json || process.stdout.isTTY === false) {
|
||||
console.log(JSON.stringify({ ok, ...result }, null, 2));
|
||||
} else {
|
||||
console.log(`timestamp: ${result.timestamp}`);
|
||||
console.log(`auth: ${JSON.stringify(authStatus)}`);
|
||||
console.log(`tokenAvailable: ${token != null}`);
|
||||
if (result.oauth) console.log(`oauth: ${JSON.stringify(result.oauth, null, 2)}`);
|
||||
if (result.cli) console.log(`cli: ${JSON.stringify(result.cli, null, 2)}`);
|
||||
if (result.aggregated) console.log(`aggregated: ${JSON.stringify(result.aggregated, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -340,7 +340,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
graceSec,
|
||||
extraArgs,
|
||||
} = runtimeConfig;
|
||||
const billingType = resolveClaudeBillingType(env);
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const billingType = resolveClaudeBillingType(effectiveEnv);
|
||||
const skillsDir = await buildSkillsDir();
|
||||
|
||||
// When instructionsFilePath is configured, create a combined temp file that
|
||||
@@ -547,6 +552,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "anthropic",
|
||||
biller: "anthropic",
|
||||
model: parsedStream.model || asString(parsed.model, model),
|
||||
billingType,
|
||||
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
||||
|
||||
@@ -8,8 +8,12 @@ export {
|
||||
} from "./parse.js";
|
||||
export {
|
||||
getQuotaWindows,
|
||||
readClaudeAuthStatus,
|
||||
readClaudeToken,
|
||||
fetchClaudeQuota,
|
||||
fetchClaudeCliQuota,
|
||||
captureClaudeCliUsageText,
|
||||
parseClaudeCliUsageText,
|
||||
toPercent,
|
||||
fetchWithTimeout,
|
||||
claudeConfigDir,
|
||||
|
||||
@@ -1,16 +1,91 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { ProviderQuotaResult, QuotaWindow } from "@paperclipai/adapter-utils";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CLAUDE_USAGE_SOURCE_OAUTH = "anthropic-oauth";
|
||||
const CLAUDE_USAGE_SOURCE_CLI = "claude-cli";
|
||||
|
||||
export function claudeConfigDir(): string {
|
||||
const fromEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||
return path.join(os.homedir(), ".claude");
|
||||
}
|
||||
|
||||
export async function readClaudeToken(): Promise<string | null> {
|
||||
const credPath = path.join(claudeConfigDir(), "credentials.json");
|
||||
function hasNonEmptyProcessEnv(key: string): boolean {
|
||||
const value = process.env[key];
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function createClaudeQuotaEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (typeof value !== "string") continue;
|
||||
if (key.startsWith("ANTHROPIC_")) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function stripBackspaces(text: string): string {
|
||||
let out = "";
|
||||
for (const char of text) {
|
||||
if (char === "\b") {
|
||||
out = out.slice(0, -1);
|
||||
} else {
|
||||
out += char;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text
|
||||
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
|
||||
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
||||
}
|
||||
|
||||
function cleanTerminalText(text: string): string {
|
||||
return stripAnsi(stripBackspaces(text))
|
||||
.replace(/\u0000/g, "")
|
||||
.replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
function normalizeForLabelSearch(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function trimToLatestUsagePanel(text: string): string | null {
|
||||
const lower = text.toLowerCase();
|
||||
const settingsIndex = lower.lastIndexOf("settings:");
|
||||
if (settingsIndex < 0) return null;
|
||||
let tail = text.slice(settingsIndex);
|
||||
const tailLower = tail.toLowerCase();
|
||||
if (!tailLower.includes("usage")) return null;
|
||||
if (!tailLower.includes("current session") && !tailLower.includes("loading usage")) return null;
|
||||
const stopMarkers = [
|
||||
"status dialog dismissed",
|
||||
"checking for updates",
|
||||
"press ctrl-c again to exit",
|
||||
];
|
||||
let stopIndex = -1;
|
||||
for (const marker of stopMarkers) {
|
||||
const markerIndex = tailLower.indexOf(marker);
|
||||
if (markerIndex >= 0 && (stopIndex === -1 || markerIndex < stopIndex)) {
|
||||
stopIndex = markerIndex;
|
||||
}
|
||||
}
|
||||
if (stopIndex >= 0) {
|
||||
tail = tail.slice(0, stopIndex);
|
||||
}
|
||||
return tail;
|
||||
}
|
||||
|
||||
async function readClaudeTokenFromFile(credPath: string): Promise<string | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(credPath, "utf8");
|
||||
@@ -31,22 +106,93 @@ export async function readClaudeToken(): Promise<string | null> {
|
||||
return typeof token === "string" && token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
interface ClaudeAuthStatus {
|
||||
loggedIn: boolean;
|
||||
authMethod: string | null;
|
||||
subscriptionType: string | null;
|
||||
}
|
||||
|
||||
export async function readClaudeAuthStatus(): Promise<ClaudeAuthStatus | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("claude", ["auth", "status"], {
|
||||
env: process.env,
|
||||
timeout: 5_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as Record<string, unknown>;
|
||||
return {
|
||||
loggedIn: parsed.loggedIn === true,
|
||||
authMethod: typeof parsed.authMethod === "string" ? parsed.authMethod : null,
|
||||
subscriptionType: typeof parsed.subscriptionType === "string" ? parsed.subscriptionType : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function describeClaudeSubscriptionAuth(status: ClaudeAuthStatus | null): string | null {
|
||||
if (!status?.loggedIn || status.authMethod !== "claude.ai") return null;
|
||||
return status.subscriptionType
|
||||
? `Claude is logged in via claude.ai (${status.subscriptionType})`
|
||||
: "Claude is logged in via claude.ai";
|
||||
}
|
||||
|
||||
export async function readClaudeToken(): Promise<string | null> {
|
||||
const configDir = claudeConfigDir();
|
||||
for (const filename of [".credentials.json", "credentials.json"]) {
|
||||
const token = await readClaudeTokenFromFile(path.join(configDir, filename));
|
||||
if (token) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface AnthropicUsageWindow {
|
||||
utilization?: number | null;
|
||||
resets_at?: string | null;
|
||||
}
|
||||
|
||||
interface AnthropicExtraUsage {
|
||||
is_enabled?: boolean | null;
|
||||
monthly_limit?: number | null;
|
||||
used_credits?: number | null;
|
||||
utilization?: number | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
interface AnthropicUsageResponse {
|
||||
five_hour?: AnthropicUsageWindow | null;
|
||||
seven_day?: AnthropicUsageWindow | null;
|
||||
seven_day_sonnet?: AnthropicUsageWindow | null;
|
||||
seven_day_opus?: AnthropicUsageWindow | null;
|
||||
extra_usage?: AnthropicExtraUsage | null;
|
||||
}
|
||||
|
||||
function formatCurrencyAmount(value: number, currency: string | null | undefined): string {
|
||||
const code = typeof currency === "string" && currency.trim().length > 0 ? currency.trim().toUpperCase() : "USD";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatExtraUsageLabel(extraUsage: AnthropicExtraUsage): string | null {
|
||||
const monthlyLimit = extraUsage.monthly_limit;
|
||||
const usedCredits = extraUsage.used_credits;
|
||||
if (
|
||||
typeof monthlyLimit !== "number" ||
|
||||
!Number.isFinite(monthlyLimit) ||
|
||||
typeof usedCredits !== "number" ||
|
||||
!Number.isFinite(usedCredits)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `${formatCurrencyAmount(usedCredits, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit, extraUsage.currency)}`;
|
||||
}
|
||||
|
||||
/** Convert a 0-1 utilization fraction to a 0-100 integer percent. Returns null for null/undefined input. */
|
||||
export function toPercent(utilization: number | null | undefined): number | null {
|
||||
if (utilization == null) return null;
|
||||
// utilization is 0-1 fraction; clamp to 100 in case of floating-point overshoot
|
||||
return Math.min(100, Math.round(utilization * 100));
|
||||
}
|
||||
|
||||
@@ -64,7 +210,7 @@ export async function fetchWithTimeout(url: string, init: RequestInit, ms = 8000
|
||||
export async function fetchClaudeQuota(token: string): Promise<QuotaWindow[]> {
|
||||
const resp = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
});
|
||||
@@ -74,44 +220,312 @@ export async function fetchClaudeQuota(token: string): Promise<QuotaWindow[]> {
|
||||
|
||||
if (body.five_hour != null) {
|
||||
windows.push({
|
||||
label: "5h",
|
||||
label: "Current session",
|
||||
usedPercent: toPercent(body.five_hour.utilization),
|
||||
resetsAt: body.five_hour.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (body.seven_day != null) {
|
||||
windows.push({
|
||||
label: "7d",
|
||||
label: "Current week (all models)",
|
||||
usedPercent: toPercent(body.seven_day.utilization),
|
||||
resetsAt: body.seven_day.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (body.seven_day_sonnet != null) {
|
||||
windows.push({
|
||||
label: "Sonnet 7d",
|
||||
label: "Current week (Sonnet only)",
|
||||
usedPercent: toPercent(body.seven_day_sonnet.utilization),
|
||||
resetsAt: body.seven_day_sonnet.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (body.seven_day_opus != null) {
|
||||
windows.push({
|
||||
label: "Opus 7d",
|
||||
label: "Current week (Opus only)",
|
||||
usedPercent: toPercent(body.seven_day_opus.utilization),
|
||||
resetsAt: body.seven_day_opus.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (body.extra_usage != null) {
|
||||
windows.push({
|
||||
label: "Extra usage",
|
||||
usedPercent: body.extra_usage.is_enabled === false ? null : toPercent(body.extra_usage.utilization),
|
||||
resetsAt: null,
|
||||
valueLabel:
|
||||
body.extra_usage.is_enabled === false
|
||||
? "Not enabled"
|
||||
: formatExtraUsageLabel(body.extra_usage),
|
||||
detail:
|
||||
body.extra_usage.is_enabled === false
|
||||
? "Extra usage not enabled"
|
||||
: "Monthly extra usage pool",
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
|
||||
const token = await readClaudeToken();
|
||||
if (!token) {
|
||||
return { provider: "anthropic", ok: false, error: "no local claude auth token", windows: [] };
|
||||
}
|
||||
const windows = await fetchClaudeQuota(token);
|
||||
return { provider: "anthropic", ok: true, windows };
|
||||
function usageOutputLooksRelevant(text: string): boolean {
|
||||
const normalized = normalizeForLabelSearch(text);
|
||||
return normalized.includes("currentsession")
|
||||
|| normalized.includes("currentweek")
|
||||
|| normalized.includes("loadingusage")
|
||||
|| normalized.includes("failedtoloadusagedata")
|
||||
|| normalized.includes("tokenexpired")
|
||||
|| normalized.includes("authenticationerror")
|
||||
|| normalized.includes("ratelimited");
|
||||
}
|
||||
|
||||
function usageOutputLooksComplete(text: string): boolean {
|
||||
const normalized = normalizeForLabelSearch(text);
|
||||
if (
|
||||
normalized.includes("failedtoloadusagedata")
|
||||
|| normalized.includes("tokenexpired")
|
||||
|| normalized.includes("authenticationerror")
|
||||
|| normalized.includes("ratelimited")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return normalized.includes("currentsession")
|
||||
&& (normalized.includes("currentweek") || normalized.includes("extrausage"))
|
||||
&& /[0-9]{1,3}(?:\.[0-9]+)?%/i.test(text);
|
||||
}
|
||||
|
||||
function extractUsageError(text: string): string | null {
|
||||
const lower = text.toLowerCase();
|
||||
const compact = lower.replace(/\s+/g, "");
|
||||
if (lower.includes("token_expired") || lower.includes("token has expired")) {
|
||||
return "Claude CLI token expired. Run `claude login` to refresh.";
|
||||
}
|
||||
if (lower.includes("authentication_error")) {
|
||||
return "Claude CLI authentication error. Run `claude login`.";
|
||||
}
|
||||
if (lower.includes("rate_limit_error") || lower.includes("rate limited") || compact.includes("ratelimited")) {
|
||||
return "Claude CLI usage endpoint is rate limited right now. Please try again later.";
|
||||
}
|
||||
if (lower.includes("failed to load usage data") || compact.includes("failedtoloadusagedata")) {
|
||||
return "Claude CLI could not load usage data. Open the CLI and retry `/usage`.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function percentFromLine(line: string): number | null {
|
||||
const match = line.match(/([0-9]{1,3}(?:\.[0-9]+)?)\s*%/i);
|
||||
if (!match) return null;
|
||||
const rawValue = Number(match[1]);
|
||||
if (!Number.isFinite(rawValue)) return null;
|
||||
const clamped = Math.min(100, Math.max(0, rawValue));
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.includes("remaining") || lower.includes("left") || lower.includes("available")) {
|
||||
return Math.max(0, Math.min(100, Math.round(100 - clamped)));
|
||||
}
|
||||
return Math.round(clamped);
|
||||
}
|
||||
|
||||
function isQuotaLabel(line: string): boolean {
|
||||
const normalized = normalizeForLabelSearch(line);
|
||||
return normalized === "currentsession"
|
||||
|| normalized === "currentweekallmodels"
|
||||
|| normalized === "currentweeksonnetonly"
|
||||
|| normalized === "currentweeksonnet"
|
||||
|| normalized === "currentweekopusonly"
|
||||
|| normalized === "currentweekopus"
|
||||
|| normalized === "extrausage";
|
||||
}
|
||||
|
||||
function canonicalQuotaLabel(line: string): string {
|
||||
switch (normalizeForLabelSearch(line)) {
|
||||
case "currentsession":
|
||||
return "Current session";
|
||||
case "currentweekallmodels":
|
||||
return "Current week (all models)";
|
||||
case "currentweeksonnetonly":
|
||||
case "currentweeksonnet":
|
||||
return "Current week (Sonnet only)";
|
||||
case "currentweekopusonly":
|
||||
case "currentweekopus":
|
||||
return "Current week (Opus only)";
|
||||
case "extrausage":
|
||||
return "Extra usage";
|
||||
default:
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
function formatClaudeCliDetail(label: string, lines: string[]): string | null {
|
||||
const normalizedLabel = normalizeForLabelSearch(label);
|
||||
if (normalizedLabel === "extrausage") {
|
||||
const compact = lines.join(" ").replace(/\s+/g, "").toLowerCase();
|
||||
if (compact.includes("extrausagenotenabled")) {
|
||||
return "Extra usage not enabled • /extra-usage to enable";
|
||||
}
|
||||
const firstLine = lines.find((line) => line.trim().length > 0) ?? null;
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
const resetLine = lines.find((line) => /^resets/i.test(line) || normalizeForLabelSearch(line).startsWith("resets"));
|
||||
if (!resetLine) return null;
|
||||
return resetLine
|
||||
.replace(/^Resets/i, "Resets ")
|
||||
.replace(/([A-Z][a-z]{2})(\d)/g, "$1 $2")
|
||||
.replace(/(\d)at(\d)/g, "$1 at $2")
|
||||
.replace(/(am|pm)\(/gi, "$1 (")
|
||||
.replace(/([A-Za-z])\(/g, "$1 (")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseClaudeCliUsageText(text: string): QuotaWindow[] {
|
||||
const cleaned = trimToLatestUsagePanel(cleanTerminalText(text)) ?? cleanTerminalText(text);
|
||||
const usageError = extractUsageError(cleaned);
|
||||
if (usageError) throw new Error(usageError);
|
||||
|
||||
const lines = cleaned
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
const sections: Array<{ label: string; lines: string[] }> = [];
|
||||
let current: { label: string; lines: string[] } | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (isQuotaLabel(line)) {
|
||||
if (current) sections.push(current);
|
||||
current = { label: canonicalQuotaLabel(line), lines: [] };
|
||||
continue;
|
||||
}
|
||||
if (current) current.lines.push(line);
|
||||
}
|
||||
if (current) sections.push(current);
|
||||
|
||||
const windows = sections.map<QuotaWindow>((section) => {
|
||||
const usedPercent = section.lines.map(percentFromLine).find((value) => value != null) ?? null;
|
||||
return {
|
||||
label: section.label,
|
||||
usedPercent,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: formatClaudeCliDetail(section.label, section.lines),
|
||||
};
|
||||
});
|
||||
|
||||
if (!windows.some((window) => normalizeForLabelSearch(window.label) === "currentsession")) {
|
||||
throw new Error("Could not parse Claude CLI usage output.");
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function quoteForShell(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function buildClaudeCliShellProbeCommand(): string {
|
||||
const feed = "(sleep 2; printf '/usage\\r'; sleep 6; printf '\\033'; sleep 1; printf '\\003')";
|
||||
const claudeCommand = "claude --tools \"\"";
|
||||
if (process.platform === "darwin") {
|
||||
return `${feed} | script -q /dev/null ${claudeCommand}`;
|
||||
}
|
||||
return `${feed} | script -q -e -f -c ${quoteForShell(claudeCommand)} /dev/null`;
|
||||
}
|
||||
|
||||
export async function captureClaudeCliUsageText(timeoutMs = 12_000): Promise<string> {
|
||||
const command = buildClaudeCliShellProbeCommand();
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync("sh", ["-c", command], {
|
||||
env: createClaudeQuotaEnv(),
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
const output = `${stdout}${stderr}`;
|
||||
const cleaned = cleanTerminalText(output);
|
||||
if (usageOutputLooksComplete(cleaned)) return output;
|
||||
throw new Error("Claude CLI usage probe ended before rendering usage.");
|
||||
} catch (error) {
|
||||
const stdout =
|
||||
typeof error === "object" && error !== null && "stdout" in error && typeof error.stdout === "string"
|
||||
? error.stdout
|
||||
: "";
|
||||
const stderr =
|
||||
typeof error === "object" && error !== null && "stderr" in error && typeof error.stderr === "string"
|
||||
? error.stderr
|
||||
: "";
|
||||
const output = `${stdout}${stderr}`;
|
||||
const cleaned = cleanTerminalText(output);
|
||||
if (usageOutputLooksComplete(cleaned)) return output;
|
||||
if (usageOutputLooksRelevant(cleaned)) {
|
||||
throw new Error("Claude CLI usage probe ended before rendering usage.");
|
||||
}
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClaudeCliQuota(): Promise<QuotaWindow[]> {
|
||||
const rawText = await captureClaudeCliUsageText();
|
||||
return parseClaudeCliUsageText(rawText);
|
||||
}
|
||||
|
||||
function formatProviderError(source: string, error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `${source}: ${message}`;
|
||||
}
|
||||
|
||||
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
|
||||
const authStatus = await readClaudeAuthStatus();
|
||||
const authDescription = describeClaudeSubscriptionAuth(authStatus);
|
||||
const token = await readClaudeToken();
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const windows = await fetchClaudeQuota(token);
|
||||
return { provider: "anthropic", source: CLAUDE_USAGE_SOURCE_OAUTH, ok: true, windows };
|
||||
} catch (error) {
|
||||
errors.push(formatProviderError("Anthropic OAuth usage", error));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const windows = await fetchClaudeCliQuota();
|
||||
return { provider: "anthropic", source: CLAUDE_USAGE_SOURCE_CLI, ok: true, windows };
|
||||
} catch (error) {
|
||||
errors.push(formatProviderError("Claude CLI /usage", error));
|
||||
}
|
||||
|
||||
if (hasNonEmptyProcessEnv("ANTHROPIC_API_KEY") && !authDescription) {
|
||||
return {
|
||||
provider: "anthropic",
|
||||
ok: false,
|
||||
error:
|
||||
errors[0]
|
||||
?? "ANTHROPIC_API_KEY is set and no local Claude subscription session is available for quota polling",
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (authDescription) {
|
||||
return {
|
||||
provider: "anthropic",
|
||||
ok: false,
|
||||
error:
|
||||
errors.length > 0
|
||||
? `${authDescription}, but quota polling failed (${errors.join("; ")})`
|
||||
: `${authDescription}, but Paperclip could not load subscription quota data`,
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "anthropic",
|
||||
ok: false,
|
||||
error: errors[0] ?? "no local claude auth token",
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
|
||||
112
packages/adapters/codex-local/src/cli/quota-probe.ts
Normal file
112
packages/adapters/codex-local/src/cli/quota-probe.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
fetchCodexQuota,
|
||||
fetchCodexRpcQuota,
|
||||
getQuotaWindows,
|
||||
readCodexAuthInfo,
|
||||
readCodexToken,
|
||||
} from "../server/quota.js";
|
||||
|
||||
interface ProbeArgs {
|
||||
json: boolean;
|
||||
rpcOnly: boolean;
|
||||
whamOnly: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ProbeArgs {
|
||||
return {
|
||||
json: argv.includes("--json"),
|
||||
rpcOnly: argv.includes("--rpc-only"),
|
||||
whamOnly: argv.includes("--wham-only"),
|
||||
};
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.rpcOnly && args.whamOnly) {
|
||||
throw new Error("Choose either --rpc-only or --wham-only, not both.");
|
||||
}
|
||||
|
||||
const auth = await readCodexAuthInfo();
|
||||
const token = await readCodexToken();
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
auth,
|
||||
tokenAvailable: token != null,
|
||||
};
|
||||
|
||||
if (!args.whamOnly) {
|
||||
try {
|
||||
result.rpc = {
|
||||
ok: true,
|
||||
...(await fetchCodexRpcQuota()),
|
||||
};
|
||||
} catch (error) {
|
||||
result.rpc = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.rpcOnly) {
|
||||
if (!token) {
|
||||
result.wham = {
|
||||
ok: false,
|
||||
error: "No local Codex auth token found in ~/.codex/auth.json.",
|
||||
windows: [],
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
result.wham = {
|
||||
ok: true,
|
||||
windows: await fetchCodexQuota(token.token, token.accountId),
|
||||
};
|
||||
} catch (error) {
|
||||
result.wham = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.rpcOnly && !args.whamOnly) {
|
||||
try {
|
||||
result.aggregated = await getQuotaWindows();
|
||||
} catch (error) {
|
||||
result.aggregated = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rpcOk = (result.rpc as { ok?: boolean } | undefined)?.ok === true;
|
||||
const whamOk = (result.wham as { ok?: boolean } | undefined)?.ok === true;
|
||||
const aggregatedOk = (result.aggregated as { ok?: boolean } | undefined)?.ok === true;
|
||||
const ok = rpcOk || whamOk || aggregatedOk;
|
||||
|
||||
if (args.json || process.stdout.isTTY === false) {
|
||||
console.log(JSON.stringify({ ok, ...result }, null, 2));
|
||||
} else {
|
||||
console.log(`timestamp: ${result.timestamp}`);
|
||||
console.log(`auth: ${JSON.stringify(auth)}`);
|
||||
console.log(`tokenAvailable: ${token != null}`);
|
||||
if (result.rpc) console.log(`rpc: ${JSON.stringify(result.rpc, null, 2)}`);
|
||||
if (result.wham) console.log(`wham: ${JSON.stringify(result.wham, null, 2)}`);
|
||||
if (result.aggregated) console.log(`aggregated: ${JSON.stringify(result.aggregated, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
@@ -61,6 +61,12 @@ function resolveCodexBillingType(env: Record<string, string>): "api" | "subscrip
|
||||
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
function resolveCodexBiller(env: Record<string, string>, billingType: "api" | "subscription"): string {
|
||||
const openAiCompatibleBiller = inferOpenAiCompatibleBiller(env, "openai");
|
||||
if (openAiCompatibleBiller === "openrouter") return "openrouter";
|
||||
return billingType === "subscription" ? "chatgpt" : openAiCompatibleBiller ?? "openai";
|
||||
}
|
||||
|
||||
async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {
|
||||
const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([
|
||||
pathExists(path.join(candidate, "pnpm-workspace.yaml")),
|
||||
@@ -315,8 +321,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveCodexBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const billingType = resolveCodexBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
@@ -508,6 +519,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "openai",
|
||||
biller: resolveCodexBiller(effectiveEnv, billingType),
|
||||
model,
|
||||
billingType,
|
||||
costUsd: null,
|
||||
|
||||
@@ -3,8 +3,11 @@ export { testEnvironment } from "./test.js";
|
||||
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||
export {
|
||||
getQuotaWindows,
|
||||
readCodexAuthInfo,
|
||||
readCodexToken,
|
||||
fetchCodexQuota,
|
||||
fetchCodexRpcQuota,
|
||||
mapCodexRpcQuota,
|
||||
secondsToWindowLabel,
|
||||
fetchWithTimeout,
|
||||
codexHomeDir,
|
||||
|
||||
@@ -1,20 +1,113 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ProviderQuotaResult, QuotaWindow } from "@paperclipai/adapter-utils";
|
||||
|
||||
const CODEX_USAGE_SOURCE_RPC = "codex-rpc";
|
||||
const CODEX_USAGE_SOURCE_WHAM = "codex-wham";
|
||||
|
||||
export function codexHomeDir(): string {
|
||||
const fromEnv = process.env.CODEX_HOME;
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||
return path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
interface CodexAuthFile {
|
||||
interface CodexLegacyAuthFile {
|
||||
accessToken?: string | null;
|
||||
accountId?: string | null;
|
||||
}
|
||||
|
||||
export async function readCodexToken(): Promise<{ token: string; accountId: string | null } | null> {
|
||||
interface CodexTokenBlock {
|
||||
id_token?: string | null;
|
||||
access_token?: string | null;
|
||||
refresh_token?: string | null;
|
||||
account_id?: string | null;
|
||||
}
|
||||
|
||||
interface CodexModernAuthFile {
|
||||
OPENAI_API_KEY?: string | null;
|
||||
tokens?: CodexTokenBlock | null;
|
||||
last_refresh?: string | null;
|
||||
}
|
||||
|
||||
export interface CodexAuthInfo {
|
||||
accessToken: string;
|
||||
accountId: string | null;
|
||||
refreshToken: string | null;
|
||||
idToken: string | null;
|
||||
email: string | null;
|
||||
planType: string | null;
|
||||
lastRefresh: string | null;
|
||||
}
|
||||
|
||||
function base64UrlDecode(input: string): string | null {
|
||||
try {
|
||||
let normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const remainder = normalized.length % 4;
|
||||
if (remainder > 0) normalized += "=".repeat(4 - remainder);
|
||||
return Buffer.from(normalized, "base64").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string | null | undefined): Record<string, unknown> | null {
|
||||
if (typeof token !== "string" || token.trim().length === 0) return null;
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
const decoded = base64UrlDecode(parts[1] ?? "");
|
||||
if (!decoded) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(decoded) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readNestedString(record: Record<string, unknown>, pathSegments: string[]): string | null {
|
||||
let current: unknown = record;
|
||||
for (const segment of pathSegments) {
|
||||
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return typeof current === "string" && current.trim().length > 0 ? current.trim() : null;
|
||||
}
|
||||
|
||||
function parsePlanAndEmailFromToken(idToken: string | null, accessToken: string | null): {
|
||||
email: string | null;
|
||||
planType: string | null;
|
||||
} {
|
||||
const payloads = [decodeJwtPayload(idToken), decodeJwtPayload(accessToken)].filter(
|
||||
(value): value is Record<string, unknown> => value != null,
|
||||
);
|
||||
for (const payload of payloads) {
|
||||
const directEmail = typeof payload.email === "string" ? payload.email : null;
|
||||
const authBlock =
|
||||
typeof payload["https://api.openai.com/auth"] === "object" &&
|
||||
payload["https://api.openai.com/auth"] !== null &&
|
||||
!Array.isArray(payload["https://api.openai.com/auth"])
|
||||
? payload["https://api.openai.com/auth"] as Record<string, unknown>
|
||||
: null;
|
||||
const profileBlock =
|
||||
typeof payload["https://api.openai.com/profile"] === "object" &&
|
||||
payload["https://api.openai.com/profile"] !== null &&
|
||||
!Array.isArray(payload["https://api.openai.com/profile"])
|
||||
? payload["https://api.openai.com/profile"] as Record<string, unknown>
|
||||
: null;
|
||||
const email =
|
||||
directEmail
|
||||
?? (typeof profileBlock?.email === "string" ? profileBlock.email : null)
|
||||
?? (typeof authBlock?.chatgpt_user_email === "string" ? authBlock.chatgpt_user_email : null);
|
||||
const planType =
|
||||
typeof authBlock?.chatgpt_plan_type === "string" ? authBlock.chatgpt_plan_type : null;
|
||||
if (email || planType) return { email: email ?? null, planType };
|
||||
}
|
||||
return { email: null, planType: null };
|
||||
}
|
||||
|
||||
export async function readCodexAuthInfo(): Promise<CodexAuthInfo | null> {
|
||||
const authPath = path.join(codexHomeDir(), "auth.json");
|
||||
let raw: string;
|
||||
try {
|
||||
@@ -29,18 +122,55 @@ export async function readCodexToken(): Promise<{ token: string; accountId: stri
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const obj = parsed as CodexAuthFile;
|
||||
const token = obj.accessToken;
|
||||
if (typeof token !== "string" || token.length === 0) return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const modern = obj as CodexModernAuthFile;
|
||||
const legacy = obj as CodexLegacyAuthFile;
|
||||
|
||||
const accessToken =
|
||||
legacy.accessToken
|
||||
?? modern.tokens?.access_token
|
||||
?? readNestedString(obj, ["tokens", "access_token"]);
|
||||
if (typeof accessToken !== "string" || accessToken.length === 0) return null;
|
||||
|
||||
const accountId =
|
||||
typeof obj.accountId === "string" && obj.accountId.length > 0 ? obj.accountId : null;
|
||||
return { token, accountId };
|
||||
legacy.accountId
|
||||
?? modern.tokens?.account_id
|
||||
?? readNestedString(obj, ["tokens", "account_id"]);
|
||||
const refreshToken =
|
||||
modern.tokens?.refresh_token
|
||||
?? readNestedString(obj, ["tokens", "refresh_token"]);
|
||||
const idToken =
|
||||
modern.tokens?.id_token
|
||||
?? readNestedString(obj, ["tokens", "id_token"]);
|
||||
const { email, planType } = parsePlanAndEmailFromToken(idToken, accessToken);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
accountId:
|
||||
typeof accountId === "string" && accountId.trim().length > 0 ? accountId.trim() : null,
|
||||
refreshToken:
|
||||
typeof refreshToken === "string" && refreshToken.trim().length > 0 ? refreshToken.trim() : null,
|
||||
idToken:
|
||||
typeof idToken === "string" && idToken.trim().length > 0 ? idToken.trim() : null,
|
||||
email,
|
||||
planType,
|
||||
lastRefresh:
|
||||
typeof modern.last_refresh === "string" && modern.last_refresh.trim().length > 0
|
||||
? modern.last_refresh.trim()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readCodexToken(): Promise<{ token: string; accountId: string | null } | null> {
|
||||
const auth = await readCodexAuthInfo();
|
||||
if (!auth) return null;
|
||||
return { token: auth.accessToken, accountId: auth.accountId };
|
||||
}
|
||||
|
||||
interface WhamWindow {
|
||||
used_percent?: number | null;
|
||||
limit_window_seconds?: number | null;
|
||||
reset_at?: string | null;
|
||||
reset_at?: string | number | null;
|
||||
}
|
||||
|
||||
interface WhamCredits {
|
||||
@@ -49,6 +179,7 @@ interface WhamCredits {
|
||||
}
|
||||
|
||||
interface WhamUsageResponse {
|
||||
plan_type?: string | null;
|
||||
rate_limit?: {
|
||||
primary_window?: WhamWindow | null;
|
||||
secondary_window?: WhamWindow | null;
|
||||
@@ -69,7 +200,6 @@ export function secondsToWindowLabel(
|
||||
if (hours < 6) return "5h";
|
||||
if (hours <= 24) return "24h";
|
||||
if (hours <= 168) return "7d";
|
||||
// for windows larger than 7d, show the actual day count rather than silently mislabelling
|
||||
return `${Math.round(hours / 24)}d`;
|
||||
}
|
||||
|
||||
@@ -88,6 +218,11 @@ export async function fetchWithTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCodexUsedPercent(rawPct: number | null | undefined): number | null {
|
||||
if (rawPct == null) return null;
|
||||
return Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct));
|
||||
}
|
||||
|
||||
export async function fetchCodexQuota(
|
||||
token: string,
|
||||
accountId: string | null,
|
||||
@@ -105,30 +240,28 @@ export async function fetchCodexQuota(
|
||||
const rateLimit = body.rate_limit;
|
||||
if (rateLimit?.primary_window != null) {
|
||||
const w = rateLimit.primary_window;
|
||||
// wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case.
|
||||
// use < 1 (not <= 1) so that 1% usage (rawPct=1) is not misclassified as 100%.
|
||||
const rawPct = w.used_percent ?? null;
|
||||
const usedPercent =
|
||||
rawPct != null ? Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct)) : null;
|
||||
windows.push({
|
||||
label: secondsToWindowLabel(w.limit_window_seconds, "Primary"),
|
||||
usedPercent,
|
||||
resetsAt: w.reset_at ?? null,
|
||||
label: "5h limit",
|
||||
usedPercent: normalizeCodexUsedPercent(w.used_percent),
|
||||
resetsAt:
|
||||
typeof w.reset_at === "number"
|
||||
? unixSecondsToIso(w.reset_at)
|
||||
: (w.reset_at ?? null),
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (rateLimit?.secondary_window != null) {
|
||||
const w = rateLimit.secondary_window;
|
||||
// wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case.
|
||||
// use < 1 (not <= 1) so that 1% usage (rawPct=1) is not misclassified as 100%.
|
||||
const rawPct = w.used_percent ?? null;
|
||||
const usedPercent =
|
||||
rawPct != null ? Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct)) : null;
|
||||
windows.push({
|
||||
label: secondsToWindowLabel(w.limit_window_seconds, "Secondary"),
|
||||
usedPercent,
|
||||
resetsAt: w.reset_at ?? null,
|
||||
label: "Weekly limit",
|
||||
usedPercent: normalizeCodexUsedPercent(w.used_percent),
|
||||
resetsAt:
|
||||
typeof w.reset_at === "number"
|
||||
? unixSecondsToIso(w.reset_at)
|
||||
: (w.reset_at ?? null),
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (body.credits != null && body.credits.unlimited !== true) {
|
||||
@@ -139,16 +272,285 @@ export async function fetchCodexQuota(
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
|
||||
const auth = await readCodexToken();
|
||||
if (!auth) {
|
||||
return { provider: "openai", ok: false, error: "no local codex auth token", windows: [] };
|
||||
}
|
||||
const windows = await fetchCodexQuota(auth.token, auth.accountId);
|
||||
return { provider: "openai", ok: true, windows };
|
||||
interface CodexRpcWindow {
|
||||
usedPercent?: number | null;
|
||||
windowDurationMins?: number | null;
|
||||
resetsAt?: number | null;
|
||||
}
|
||||
|
||||
interface CodexRpcCredits {
|
||||
hasCredits?: boolean | null;
|
||||
unlimited?: boolean | null;
|
||||
balance?: string | number | null;
|
||||
}
|
||||
|
||||
interface CodexRpcLimit {
|
||||
limitId?: string | null;
|
||||
limitName?: string | null;
|
||||
primary?: CodexRpcWindow | null;
|
||||
secondary?: CodexRpcWindow | null;
|
||||
credits?: CodexRpcCredits | null;
|
||||
planType?: string | null;
|
||||
}
|
||||
|
||||
interface CodexRpcRateLimitsResult {
|
||||
rateLimits?: CodexRpcLimit | null;
|
||||
rateLimitsByLimitId?: Record<string, CodexRpcLimit> | null;
|
||||
}
|
||||
|
||||
interface CodexRpcAccountResult {
|
||||
account?: {
|
||||
type?: string | null;
|
||||
email?: string | null;
|
||||
planType?: string | null;
|
||||
} | null;
|
||||
requiresOpenaiAuth?: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexRpcQuotaSnapshot {
|
||||
windows: QuotaWindow[];
|
||||
email: string | null;
|
||||
planType: string | null;
|
||||
}
|
||||
|
||||
function unixSecondsToIso(value: number | null | undefined): string | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
return new Date(value * 1000).toISOString();
|
||||
}
|
||||
|
||||
function buildCodexRpcWindow(label: string, window: CodexRpcWindow | null | undefined): QuotaWindow | null {
|
||||
if (!window) return null;
|
||||
return {
|
||||
label,
|
||||
usedPercent: normalizeCodexUsedPercent(window.usedPercent),
|
||||
resetsAt: unixSecondsToIso(window.resetsAt),
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCreditBalance(value: string | number | null | undefined): string | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return `$${value.toFixed(2)} remaining`;
|
||||
}
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return `$${parsed.toFixed(2)} remaining`;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mapCodexRpcQuota(result: CodexRpcRateLimitsResult, account?: CodexRpcAccountResult | null): CodexRpcQuotaSnapshot {
|
||||
const windows: QuotaWindow[] = [];
|
||||
const limitOrder = ["codex"];
|
||||
const limitsById = result.rateLimitsByLimitId ?? {};
|
||||
for (const key of Object.keys(limitsById)) {
|
||||
if (!limitOrder.includes(key)) limitOrder.push(key);
|
||||
}
|
||||
|
||||
const rootLimit = result.rateLimits ?? null;
|
||||
const allLimits = new Map<string, CodexRpcLimit>();
|
||||
if (rootLimit?.limitId) allLimits.set(rootLimit.limitId, rootLimit);
|
||||
for (const [key, value] of Object.entries(limitsById)) {
|
||||
allLimits.set(key, value);
|
||||
}
|
||||
if (!allLimits.has("codex") && rootLimit) allLimits.set("codex", rootLimit);
|
||||
|
||||
for (const limitId of limitOrder) {
|
||||
const limit = allLimits.get(limitId);
|
||||
if (!limit) continue;
|
||||
const prefix =
|
||||
limitId === "codex"
|
||||
? ""
|
||||
: `${limit.limitName ?? limitId} · `;
|
||||
const primary = buildCodexRpcWindow(`${prefix}5h limit`, limit.primary);
|
||||
if (primary) windows.push(primary);
|
||||
const secondary = buildCodexRpcWindow(`${prefix}Weekly limit`, limit.secondary);
|
||||
if (secondary) windows.push(secondary);
|
||||
if (limitId === "codex" && limit.credits && limit.credits.unlimited !== true) {
|
||||
windows.push({
|
||||
label: "Credits",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: parseCreditBalance(limit.credits.balance) ?? "N/A",
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
windows,
|
||||
email:
|
||||
typeof account?.account?.email === "string" && account.account.email.trim().length > 0
|
||||
? account.account.email.trim()
|
||||
: null,
|
||||
planType:
|
||||
typeof account?.account?.planType === "string" && account.account.planType.trim().length > 0
|
||||
? account.account.planType.trim()
|
||||
: (typeof rootLimit?.planType === "string" && rootLimit.planType.trim().length > 0 ? rootLimit.planType.trim() : null),
|
||||
};
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: Record<string, unknown>) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
class CodexRpcClient {
|
||||
private proc = spawn(
|
||||
"codex",
|
||||
["-s", "read-only", "-a", "untrusted", "app-server"],
|
||||
{ stdio: ["pipe", "pipe", "pipe"], env: process.env },
|
||||
);
|
||||
|
||||
private nextId = 1;
|
||||
private buffer = "";
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private stderr = "";
|
||||
|
||||
constructor() {
|
||||
this.proc.stdout.setEncoding("utf8");
|
||||
this.proc.stderr.setEncoding("utf8");
|
||||
this.proc.stdout.on("data", (chunk: string) => this.onStdout(chunk));
|
||||
this.proc.stderr.on("data", (chunk: string) => {
|
||||
this.stderr += chunk;
|
||||
});
|
||||
this.proc.on("exit", () => {
|
||||
for (const request of this.pending.values()) {
|
||||
clearTimeout(request.timer);
|
||||
request.reject(new Error(this.stderr.trim() || "codex app-server closed unexpectedly"));
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
}
|
||||
|
||||
private onStdout(chunk: string) {
|
||||
this.buffer += chunk;
|
||||
while (true) {
|
||||
const newlineIndex = this.buffer.indexOf("\n");
|
||||
if (newlineIndex < 0) break;
|
||||
const line = this.buffer.slice(0, newlineIndex).trim();
|
||||
this.buffer = this.buffer.slice(newlineIndex + 1);
|
||||
if (!line) continue;
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const id = typeof parsed.id === "number" ? parsed.id : null;
|
||||
if (id == null) continue;
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) continue;
|
||||
this.pending.delete(id);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
private request(method: string, params: Record<string, unknown> = {}, timeoutMs = 6_000): Promise<Record<string, unknown>> {
|
||||
const id = this.nextId++;
|
||||
const payload = JSON.stringify({ id, method, params }) + "\n";
|
||||
return new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`codex app-server timed out on ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.proc.stdin.write(payload);
|
||||
});
|
||||
}
|
||||
|
||||
private notify(method: string, params: Record<string, unknown> = {}) {
|
||||
this.proc.stdin.write(JSON.stringify({ method, params }) + "\n");
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.request("initialize", {
|
||||
clientInfo: {
|
||||
name: "paperclip",
|
||||
version: "0.0.0",
|
||||
},
|
||||
});
|
||||
this.notify("initialized", {});
|
||||
}
|
||||
|
||||
async fetchRateLimits(): Promise<CodexRpcRateLimitsResult> {
|
||||
const message = await this.request("account/rateLimits/read");
|
||||
return (message.result as CodexRpcRateLimitsResult | undefined) ?? {};
|
||||
}
|
||||
|
||||
async fetchAccount(): Promise<CodexRpcAccountResult | null> {
|
||||
try {
|
||||
const message = await this.request("account/read");
|
||||
return (message.result as CodexRpcAccountResult | undefined) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.proc.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCodexRpcQuota(): Promise<CodexRpcQuotaSnapshot> {
|
||||
const client = new CodexRpcClient();
|
||||
try {
|
||||
await client.initialize();
|
||||
const [limits, account] = await Promise.all([
|
||||
client.fetchRateLimits(),
|
||||
client.fetchAccount(),
|
||||
]);
|
||||
return mapCodexRpcQuota(limits, account);
|
||||
} finally {
|
||||
await client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
function formatProviderError(source: string, error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `${source}: ${message}`;
|
||||
}
|
||||
|
||||
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
const rpc = await fetchCodexRpcQuota();
|
||||
if (rpc.windows.length > 0) {
|
||||
return { provider: "openai", source: CODEX_USAGE_SOURCE_RPC, ok: true, windows: rpc.windows };
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(formatProviderError("Codex app-server", error));
|
||||
}
|
||||
|
||||
const auth = await readCodexToken();
|
||||
if (auth) {
|
||||
try {
|
||||
const windows = await fetchCodexQuota(auth.token, auth.accountId);
|
||||
return { provider: "openai", source: CODEX_USAGE_SOURCE_WHAM, ok: true, windows };
|
||||
} catch (error) {
|
||||
errors.push(formatProviderError("ChatGPT WHAM usage", error));
|
||||
}
|
||||
} else {
|
||||
errors.push("no local codex auth token");
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "openai",
|
||||
ok: false,
|
||||
error: errors.join("; "),
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
@@ -47,6 +47,17 @@ function resolveCursorBillingType(env: Record<string, string>): "api" | "subscri
|
||||
: "subscription";
|
||||
}
|
||||
|
||||
function resolveCursorBiller(
|
||||
env: Record<string, string>,
|
||||
billingType: "api" | "subscription",
|
||||
provider: string | null,
|
||||
): string {
|
||||
const openAiCompatibleBiller = inferOpenAiCompatibleBiller(env, null);
|
||||
if (openAiCompatibleBiller === "openrouter") return "openrouter";
|
||||
if (billingType === "subscription") return "cursor";
|
||||
return provider ?? "cursor";
|
||||
}
|
||||
|
||||
function resolveProviderFromModel(model: string): string | null {
|
||||
const trimmed = model.trim().toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
@@ -243,8 +254,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveCursorBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const billingType = resolveCursorBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
@@ -474,6 +490,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: providerFromModel,
|
||||
biller: resolveCursorBiller(effectiveEnv, billingType, providerFromModel),
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
|
||||
@@ -206,8 +206,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveGeminiBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const billingType = resolveGeminiBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
@@ -420,6 +425,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "google",
|
||||
biller: "google",
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
@@ -42,6 +42,10 @@ function parseModelProvider(model: string | null): string | null {
|
||||
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
|
||||
}
|
||||
|
||||
function resolveOpenCodeBiller(env: Record<string, string>, provider: string | null): string {
|
||||
return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown";
|
||||
}
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
@@ -361,6 +365,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: parseModelProvider(modelId),
|
||||
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
|
||||
model: modelId,
|
||||
billingType: "unknown",
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
@@ -50,6 +50,10 @@ function parseModelId(model: string | null): string | null {
|
||||
return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null;
|
||||
}
|
||||
|
||||
function resolvePiBiller(env: Record<string, string>, provider: string | null): string {
|
||||
return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown";
|
||||
}
|
||||
|
||||
async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
|
||||
const skillsEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
if (skillsEntries.length === 0) return;
|
||||
@@ -447,6 +451,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: provider,
|
||||
biller: resolvePiBiller(runtimeEnv, provider),
|
||||
model: model,
|
||||
billingType: "unknown",
|
||||
costUsd: attempt.parsed.usage.costUsd,
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"seed": "tsx src/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"postgres": "^3.4.5"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { createServer } from "node:net";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { ensurePostgresDatabase } from "./client.js";
|
||||
@@ -28,6 +29,18 @@ export type MigrationConnection = {
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
function toError(error: unknown, fallbackMessage: string): Error {
|
||||
if (error instanceof Error) return error;
|
||||
if (error === undefined) return new Error(fallbackMessage);
|
||||
if (typeof error === "string") return new Error(`${fallbackMessage}: ${error}`);
|
||||
|
||||
try {
|
||||
return new Error(`${fallbackMessage}: ${JSON.stringify(error)}`);
|
||||
} catch {
|
||||
return new Error(`${fallbackMessage}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
|
||||
if (!existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
@@ -51,6 +64,31 @@ function readPidFilePort(postmasterPidFile: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortInUse(port: number): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.once("error", (error: NodeJS.ErrnoException) => {
|
||||
resolve(error.code === "EADDRINUSE");
|
||||
});
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort(startPort: number): Promise<number> {
|
||||
const maxLookahead = 20;
|
||||
let port = startPort;
|
||||
for (let i = 0; i < maxLookahead; i += 1, port += 1) {
|
||||
if (!(await isPortInUse(port))) return port;
|
||||
}
|
||||
throw new Error(
|
||||
`Embedded PostgreSQL could not find a free port from ${startPort} to ${startPort + maxLookahead - 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const require = createRequire(import.meta.url);
|
||||
const resolveCandidates = [
|
||||
@@ -76,6 +114,7 @@ async function ensureEmbeddedPostgresConnection(
|
||||
preferredPort: number,
|
||||
): Promise<MigrationConnection> {
|
||||
const EmbeddedPostgres = await loadEmbeddedPostgresCtor();
|
||||
const selectedPort = await findAvailablePort(preferredPort);
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
const runningPort = readPidFilePort(postmasterPidFile);
|
||||
@@ -95,7 +134,7 @@ async function ensureEmbeddedPostgresConnection(
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port: preferredPort,
|
||||
port: selectedPort,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
onLog: () => {},
|
||||
@@ -103,19 +142,30 @@ async function ensureEmbeddedPostgresConnection(
|
||||
});
|
||||
|
||||
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||
await instance.initialise();
|
||||
try {
|
||||
await instance.initialise();
|
||||
} catch (error) {
|
||||
throw toError(
|
||||
error,
|
||||
`Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${selectedPort}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (existsSync(postmasterPidFile)) {
|
||||
rmSync(postmasterPidFile, { force: true });
|
||||
}
|
||||
await instance.start();
|
||||
try {
|
||||
await instance.start();
|
||||
} catch (error) {
|
||||
throw toError(error, `Failed to start embedded PostgreSQL on port ${selectedPort}`);
|
||||
}
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`;
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${selectedPort}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
|
||||
return {
|
||||
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/paperclip`,
|
||||
source: `embedded-postgres@${preferredPort}`,
|
||||
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${selectedPort}/paperclip`,
|
||||
source: `embedded-postgres@${selectedPort}`,
|
||||
stop: async () => {
|
||||
await instance.stop();
|
||||
},
|
||||
|
||||
@@ -3,6 +3,18 @@ import { resolveMigrationConnection } from "./migration-runtime.js";
|
||||
|
||||
const jsonMode = process.argv.includes("--json");
|
||||
|
||||
function toError(error: unknown, context = "Migration status check failed"): Error {
|
||||
if (error instanceof Error) return error;
|
||||
if (error === undefined) return new Error(context);
|
||||
if (typeof error === "string") return new Error(`${context}: ${error}`);
|
||||
|
||||
try {
|
||||
return new Error(`${context}: ${JSON.stringify(error)}`);
|
||||
} catch {
|
||||
return new Error(`${context}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const connection = await resolveMigrationConnection();
|
||||
|
||||
@@ -42,4 +54,8 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
main().catch((error) => {
|
||||
const err = toError(error, "Migration status check failed");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
51
packages/db/src/migrations/0031_zippy_magma.sql
Normal file
51
packages/db/src/migrations/0031_zippy_magma.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE "finance_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid,
|
||||
"issue_id" uuid,
|
||||
"project_id" uuid,
|
||||
"goal_id" uuid,
|
||||
"heartbeat_run_id" uuid,
|
||||
"cost_event_id" uuid,
|
||||
"billing_code" text,
|
||||
"description" text,
|
||||
"event_kind" text NOT NULL,
|
||||
"direction" text DEFAULT 'debit' NOT NULL,
|
||||
"biller" text NOT NULL,
|
||||
"provider" text,
|
||||
"execution_adapter_type" text,
|
||||
"pricing_tier" text,
|
||||
"region" text,
|
||||
"model" text,
|
||||
"quantity" integer,
|
||||
"unit" text,
|
||||
"amount_cents" integer NOT NULL,
|
||||
"currency" text DEFAULT 'USD' NOT NULL,
|
||||
"estimated" boolean DEFAULT false NOT NULL,
|
||||
"external_invoice_id" text,
|
||||
"metadata_json" jsonb,
|
||||
"occurred_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "cost_events" ADD COLUMN "heartbeat_run_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "cost_events" ADD COLUMN "biller" text DEFAULT 'unknown' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "cost_events" ADD COLUMN "billing_type" text DEFAULT 'unknown' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "cost_events" ADD COLUMN "cached_input_tokens" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_goal_id_goals_id_fk" FOREIGN KEY ("goal_id") REFERENCES "public"."goals"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "finance_events_company_occurred_idx" ON "finance_events" USING btree ("company_id","occurred_at");--> statement-breakpoint
|
||||
CREATE INDEX "finance_events_company_biller_occurred_idx" ON "finance_events" USING btree ("company_id","biller","occurred_at");--> statement-breakpoint
|
||||
CREATE INDEX "finance_events_company_kind_occurred_idx" ON "finance_events" USING btree ("company_id","event_kind","occurred_at");--> statement-breakpoint
|
||||
CREATE INDEX "finance_events_company_direction_occurred_idx" ON "finance_events" USING btree ("company_id","direction","occurred_at");--> statement-breakpoint
|
||||
CREATE INDEX "finance_events_company_heartbeat_run_idx" ON "finance_events" USING btree ("company_id","heartbeat_run_id");--> statement-breakpoint
|
||||
CREATE INDEX "finance_events_company_cost_event_idx" ON "finance_events" USING btree ("company_id","cost_event_id");--> statement-breakpoint
|
||||
ALTER TABLE "cost_events" ADD CONSTRAINT "cost_events_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "cost_events_company_provider_occurred_idx" ON "cost_events" USING btree ("company_id","provider","occurred_at");--> statement-breakpoint
|
||||
CREATE INDEX "cost_events_company_biller_occurred_idx" ON "cost_events" USING btree ("company_id","biller","occurred_at");--> statement-breakpoint
|
||||
CREATE INDEX "cost_events_company_heartbeat_run_idx" ON "cost_events" USING btree ("company_id","heartbeat_run_id");
|
||||
102
packages/db/src/migrations/0032_pretty_doctor_octopus.sql
Normal file
102
packages/db/src/migrations/0032_pretty_doctor_octopus.sql
Normal file
@@ -0,0 +1,102 @@
|
||||
CREATE TABLE "budget_incidents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"policy_id" uuid NOT NULL,
|
||||
"scope_type" text NOT NULL,
|
||||
"scope_id" uuid NOT NULL,
|
||||
"metric" text NOT NULL,
|
||||
"window_kind" text NOT NULL,
|
||||
"window_start" timestamp with time zone NOT NULL,
|
||||
"window_end" timestamp with time zone NOT NULL,
|
||||
"threshold_type" text NOT NULL,
|
||||
"amount_limit" integer NOT NULL,
|
||||
"amount_observed" integer NOT NULL,
|
||||
"status" text DEFAULT 'open' NOT NULL,
|
||||
"approval_id" uuid,
|
||||
"resolved_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "budget_policies" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"scope_type" text NOT NULL,
|
||||
"scope_id" uuid NOT NULL,
|
||||
"metric" text DEFAULT 'billed_cents' NOT NULL,
|
||||
"window_kind" text NOT NULL,
|
||||
"amount" integer DEFAULT 0 NOT NULL,
|
||||
"warn_percent" integer DEFAULT 80 NOT NULL,
|
||||
"hard_stop_enabled" boolean DEFAULT true NOT NULL,
|
||||
"notify_enabled" boolean DEFAULT true NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_by_user_id" text,
|
||||
"updated_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "pause_reason" text;--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "paused_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "projects" ADD COLUMN "pause_reason" text;--> statement-breakpoint
|
||||
ALTER TABLE "projects" ADD COLUMN "paused_at" timestamp with time zone;--> statement-breakpoint
|
||||
INSERT INTO "budget_policies" (
|
||||
"company_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"metric",
|
||||
"window_kind",
|
||||
"amount",
|
||||
"warn_percent",
|
||||
"hard_stop_enabled",
|
||||
"notify_enabled",
|
||||
"is_active"
|
||||
)
|
||||
SELECT
|
||||
"id",
|
||||
'company',
|
||||
"id",
|
||||
'billed_cents',
|
||||
'calendar_month_utc',
|
||||
"budget_monthly_cents",
|
||||
80,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
FROM "companies"
|
||||
WHERE "budget_monthly_cents" > 0;--> statement-breakpoint
|
||||
INSERT INTO "budget_policies" (
|
||||
"company_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"metric",
|
||||
"window_kind",
|
||||
"amount",
|
||||
"warn_percent",
|
||||
"hard_stop_enabled",
|
||||
"notify_enabled",
|
||||
"is_active"
|
||||
)
|
||||
SELECT
|
||||
"company_id",
|
||||
'agent',
|
||||
"id",
|
||||
'billed_cents',
|
||||
'calendar_month_utc',
|
||||
"budget_monthly_cents",
|
||||
80,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
FROM "agents"
|
||||
WHERE "budget_monthly_cents" > 0;--> statement-breakpoint
|
||||
ALTER TABLE "budget_incidents" ADD CONSTRAINT "budget_incidents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "budget_incidents" ADD CONSTRAINT "budget_incidents_policy_id_budget_policies_id_fk" FOREIGN KEY ("policy_id") REFERENCES "public"."budget_policies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "budget_incidents" ADD CONSTRAINT "budget_incidents_approval_id_approvals_id_fk" FOREIGN KEY ("approval_id") REFERENCES "public"."approvals"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "budget_policies" ADD CONSTRAINT "budget_policies_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "budget_incidents_company_status_idx" ON "budget_incidents" USING btree ("company_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "budget_incidents_company_scope_idx" ON "budget_incidents" USING btree ("company_id","scope_type","scope_id","status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "budget_incidents_policy_window_threshold_idx" ON "budget_incidents" USING btree ("policy_id","window_start","threshold_type");--> statement-breakpoint
|
||||
CREATE INDEX "budget_policies_company_scope_active_idx" ON "budget_policies" USING btree ("company_id","scope_type","scope_id","is_active");--> statement-breakpoint
|
||||
CREATE INDEX "budget_policies_company_window_idx" ON "budget_policies" USING btree ("company_id","window_kind","metric");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "budget_policies_company_scope_metric_unique_idx" ON "budget_policies" USING btree ("company_id","scope_type","scope_id","metric","window_kind");
|
||||
7242
packages/db/src/migrations/meta/0031_snapshot.json
Normal file
7242
packages/db/src/migrations/meta/0031_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7733
packages/db/src/migrations/meta/0032_snapshot.json
Normal file
7733
packages/db/src/migrations/meta/0032_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -218,6 +218,20 @@
|
||||
"when": 1773670925214,
|
||||
"tag": "0030_rich_magneto",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1773511922713,
|
||||
"tag": "0031_zippy_magma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1773542934499,
|
||||
"tag": "0032_pretty_doctor_octopus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export const agents = pgTable(
|
||||
runtimeConfig: jsonb("runtime_config").$type<Record<string, unknown>>().notNull().default({}),
|
||||
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
|
||||
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
|
||||
pauseReason: text("pause_reason"),
|
||||
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
||||
permissions: jsonb("permissions").$type<Record<string, unknown>>().notNull().default({}),
|
||||
lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
|
||||
41
packages/db/src/schema/budget_incidents.ts
Normal file
41
packages/db/src/schema/budget_incidents.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { index, integer, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { approvals } from "./approvals.js";
|
||||
import { budgetPolicies } from "./budget_policies.js";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
export const budgetIncidents = pgTable(
|
||||
"budget_incidents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
policyId: uuid("policy_id").notNull().references(() => budgetPolicies.id),
|
||||
scopeType: text("scope_type").notNull(),
|
||||
scopeId: uuid("scope_id").notNull(),
|
||||
metric: text("metric").notNull(),
|
||||
windowKind: text("window_kind").notNull(),
|
||||
windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
|
||||
windowEnd: timestamp("window_end", { withTimezone: true }).notNull(),
|
||||
thresholdType: text("threshold_type").notNull(),
|
||||
amountLimit: integer("amount_limit").notNull(),
|
||||
amountObserved: integer("amount_observed").notNull(),
|
||||
status: text("status").notNull().default("open"),
|
||||
approvalId: uuid("approval_id").references(() => approvals.id),
|
||||
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyStatusIdx: index("budget_incidents_company_status_idx").on(table.companyId, table.status),
|
||||
companyScopeIdx: index("budget_incidents_company_scope_idx").on(
|
||||
table.companyId,
|
||||
table.scopeType,
|
||||
table.scopeId,
|
||||
table.status,
|
||||
),
|
||||
policyWindowIdx: uniqueIndex("budget_incidents_policy_window_threshold_idx").on(
|
||||
table.policyId,
|
||||
table.windowStart,
|
||||
table.thresholdType,
|
||||
),
|
||||
}),
|
||||
);
|
||||
43
packages/db/src/schema/budget_policies.ts
Normal file
43
packages/db/src/schema/budget_policies.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { boolean, index, integer, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
export const budgetPolicies = pgTable(
|
||||
"budget_policies",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
scopeType: text("scope_type").notNull(),
|
||||
scopeId: uuid("scope_id").notNull(),
|
||||
metric: text("metric").notNull().default("billed_cents"),
|
||||
windowKind: text("window_kind").notNull(),
|
||||
amount: integer("amount").notNull().default(0),
|
||||
warnPercent: integer("warn_percent").notNull().default(80),
|
||||
hardStopEnabled: boolean("hard_stop_enabled").notNull().default(true),
|
||||
notifyEnabled: boolean("notify_enabled").notNull().default(true),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
updatedByUserId: text("updated_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyScopeActiveIdx: index("budget_policies_company_scope_active_idx").on(
|
||||
table.companyId,
|
||||
table.scopeType,
|
||||
table.scopeId,
|
||||
table.isActive,
|
||||
),
|
||||
companyWindowIdx: index("budget_policies_company_window_idx").on(
|
||||
table.companyId,
|
||||
table.windowKind,
|
||||
table.metric,
|
||||
),
|
||||
companyScopeMetricUniqueIdx: uniqueIndex("budget_policies_company_scope_metric_unique_idx").on(
|
||||
table.companyId,
|
||||
table.scopeType,
|
||||
table.scopeId,
|
||||
table.metric,
|
||||
table.windowKind,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -4,6 +4,7 @@ import { agents } from "./agents.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { projects } from "./projects.js";
|
||||
import { goals } from "./goals.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
|
||||
export const costEvents = pgTable(
|
||||
"cost_events",
|
||||
@@ -14,10 +15,14 @@ export const costEvents = pgTable(
|
||||
issueId: uuid("issue_id").references(() => issues.id),
|
||||
projectId: uuid("project_id").references(() => projects.id),
|
||||
goalId: uuid("goal_id").references(() => goals.id),
|
||||
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id),
|
||||
billingCode: text("billing_code"),
|
||||
provider: text("provider").notNull(),
|
||||
biller: text("biller").notNull().default("unknown"),
|
||||
billingType: text("billing_type").notNull().default("unknown"),
|
||||
model: text("model").notNull(),
|
||||
inputTokens: integer("input_tokens").notNull().default(0),
|
||||
cachedInputTokens: integer("cached_input_tokens").notNull().default(0),
|
||||
outputTokens: integer("output_tokens").notNull().default(0),
|
||||
costCents: integer("cost_cents").notNull(),
|
||||
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
|
||||
@@ -30,5 +35,19 @@ export const costEvents = pgTable(
|
||||
table.agentId,
|
||||
table.occurredAt,
|
||||
),
|
||||
companyProviderOccurredIdx: index("cost_events_company_provider_occurred_idx").on(
|
||||
table.companyId,
|
||||
table.provider,
|
||||
table.occurredAt,
|
||||
),
|
||||
companyBillerOccurredIdx: index("cost_events_company_biller_occurred_idx").on(
|
||||
table.companyId,
|
||||
table.biller,
|
||||
table.occurredAt,
|
||||
),
|
||||
companyHeartbeatRunIdx: index("cost_events_company_heartbeat_run_idx").on(
|
||||
table.companyId,
|
||||
table.heartbeatRunId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
67
packages/db/src/schema/finance_events.ts
Normal file
67
packages/db/src/schema/finance_events.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { pgTable, uuid, text, timestamp, integer, index, boolean, jsonb } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { projects } from "./projects.js";
|
||||
import { goals } from "./goals.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { costEvents } from "./cost_events.js";
|
||||
|
||||
export const financeEvents = pgTable(
|
||||
"finance_events",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
agentId: uuid("agent_id").references(() => agents.id),
|
||||
issueId: uuid("issue_id").references(() => issues.id),
|
||||
projectId: uuid("project_id").references(() => projects.id),
|
||||
goalId: uuid("goal_id").references(() => goals.id),
|
||||
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id),
|
||||
costEventId: uuid("cost_event_id").references(() => costEvents.id),
|
||||
billingCode: text("billing_code"),
|
||||
description: text("description"),
|
||||
eventKind: text("event_kind").notNull(),
|
||||
direction: text("direction").notNull().default("debit"),
|
||||
biller: text("biller").notNull(),
|
||||
provider: text("provider"),
|
||||
executionAdapterType: text("execution_adapter_type"),
|
||||
pricingTier: text("pricing_tier"),
|
||||
region: text("region"),
|
||||
model: text("model"),
|
||||
quantity: integer("quantity"),
|
||||
unit: text("unit"),
|
||||
amountCents: integer("amount_cents").notNull(),
|
||||
currency: text("currency").notNull().default("USD"),
|
||||
estimated: boolean("estimated").notNull().default(false),
|
||||
externalInvoiceId: text("external_invoice_id"),
|
||||
metadataJson: jsonb("metadata_json").$type<Record<string, unknown> | null>(),
|
||||
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyOccurredIdx: index("finance_events_company_occurred_idx").on(table.companyId, table.occurredAt),
|
||||
companyBillerOccurredIdx: index("finance_events_company_biller_occurred_idx").on(
|
||||
table.companyId,
|
||||
table.biller,
|
||||
table.occurredAt,
|
||||
),
|
||||
companyKindOccurredIdx: index("finance_events_company_kind_occurred_idx").on(
|
||||
table.companyId,
|
||||
table.eventKind,
|
||||
table.occurredAt,
|
||||
),
|
||||
companyDirectionOccurredIdx: index("finance_events_company_direction_occurred_idx").on(
|
||||
table.companyId,
|
||||
table.direction,
|
||||
table.occurredAt,
|
||||
),
|
||||
companyHeartbeatRunIdx: index("finance_events_company_heartbeat_run_idx").on(
|
||||
table.companyId,
|
||||
table.heartbeatRunId,
|
||||
),
|
||||
companyCostEventIdx: index("finance_events_company_cost_event_idx").on(
|
||||
table.companyId,
|
||||
table.costEventId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -7,6 +7,8 @@ export { companyMemberships } from "./company_memberships.js";
|
||||
export { principalPermissionGrants } from "./principal_permission_grants.js";
|
||||
export { invites } from "./invites.js";
|
||||
export { joinRequests } from "./join_requests.js";
|
||||
export { budgetPolicies } from "./budget_policies.js";
|
||||
export { budgetIncidents } from "./budget_incidents.js";
|
||||
export { agentConfigRevisions } from "./agent_config_revisions.js";
|
||||
export { agentApiKeys } from "./agent_api_keys.js";
|
||||
export { agentRuntimeState } from "./agent_runtime_state.js";
|
||||
@@ -31,6 +33,7 @@ export { issueDocuments } from "./issue_documents.js";
|
||||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
||||
export { costEvents } from "./cost_events.js";
|
||||
export { financeEvents } from "./finance_events.js";
|
||||
export { approvals } from "./approvals.js";
|
||||
export { approvalComments } from "./approval_comments.js";
|
||||
export { activityLog } from "./activity_log.js";
|
||||
|
||||
@@ -15,6 +15,8 @@ export const projects = pgTable(
|
||||
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
||||
targetDate: date("target_date"),
|
||||
color: text("color"),
|
||||
pauseReason: text("pause_reason"),
|
||||
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
||||
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -137,6 +137,9 @@ export const PROJECT_STATUSES = [
|
||||
] as const;
|
||||
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
||||
|
||||
export const PAUSE_REASONS = ["manual", "budget", "system"] as const;
|
||||
export type PauseReason = (typeof PAUSE_REASONS)[number];
|
||||
|
||||
export const PROJECT_COLORS = [
|
||||
"#6366f1", // indigo
|
||||
"#8b5cf6", // violet
|
||||
@@ -150,7 +153,7 @@ export const PROJECT_COLORS = [
|
||||
"#3b82f6", // blue
|
||||
] as const;
|
||||
|
||||
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy"] as const;
|
||||
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy", "budget_override_required"] as const;
|
||||
export type ApprovalType = (typeof APPROVAL_TYPES)[number];
|
||||
|
||||
export const APPROVAL_STATUSES = [
|
||||
@@ -173,6 +176,73 @@ export type SecretProvider = (typeof SECRET_PROVIDERS)[number];
|
||||
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
|
||||
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
|
||||
|
||||
export const BILLING_TYPES = [
|
||||
"metered_api",
|
||||
"subscription_included",
|
||||
"subscription_overage",
|
||||
"credits",
|
||||
"fixed",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type BillingType = (typeof BILLING_TYPES)[number];
|
||||
|
||||
export const FINANCE_EVENT_KINDS = [
|
||||
"inference_charge",
|
||||
"platform_fee",
|
||||
"credit_purchase",
|
||||
"credit_refund",
|
||||
"credit_expiry",
|
||||
"byok_fee",
|
||||
"gateway_overhead",
|
||||
"log_storage_charge",
|
||||
"logpush_charge",
|
||||
"provisioned_capacity_charge",
|
||||
"training_charge",
|
||||
"custom_model_import_charge",
|
||||
"custom_model_storage_charge",
|
||||
"manual_adjustment",
|
||||
] as const;
|
||||
export type FinanceEventKind = (typeof FINANCE_EVENT_KINDS)[number];
|
||||
|
||||
export const FINANCE_DIRECTIONS = ["debit", "credit"] as const;
|
||||
export type FinanceDirection = (typeof FINANCE_DIRECTIONS)[number];
|
||||
|
||||
export const FINANCE_UNITS = [
|
||||
"input_token",
|
||||
"output_token",
|
||||
"cached_input_token",
|
||||
"request",
|
||||
"credit_usd",
|
||||
"credit_unit",
|
||||
"model_unit_minute",
|
||||
"model_unit_hour",
|
||||
"gb_month",
|
||||
"train_token",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type FinanceUnit = (typeof FINANCE_UNITS)[number];
|
||||
|
||||
export const BUDGET_SCOPE_TYPES = ["company", "agent", "project"] as const;
|
||||
export type BudgetScopeType = (typeof BUDGET_SCOPE_TYPES)[number];
|
||||
|
||||
export const BUDGET_METRICS = ["billed_cents"] as const;
|
||||
export type BudgetMetric = (typeof BUDGET_METRICS)[number];
|
||||
|
||||
export const BUDGET_WINDOW_KINDS = ["calendar_month_utc", "lifetime"] as const;
|
||||
export type BudgetWindowKind = (typeof BUDGET_WINDOW_KINDS)[number];
|
||||
|
||||
export const BUDGET_THRESHOLD_TYPES = ["soft", "hard"] as const;
|
||||
export type BudgetThresholdType = (typeof BUDGET_THRESHOLD_TYPES)[number];
|
||||
|
||||
export const BUDGET_INCIDENT_STATUSES = ["open", "resolved", "dismissed"] as const;
|
||||
export type BudgetIncidentStatus = (typeof BUDGET_INCIDENT_STATUSES)[number];
|
||||
|
||||
export const BUDGET_INCIDENT_RESOLUTION_ACTIONS = [
|
||||
"keep_paused",
|
||||
"raise_budget_and_resume",
|
||||
] as const;
|
||||
export type BudgetIncidentResolutionAction = (typeof BUDGET_INCIDENT_RESOLUTION_ACTIONS)[number];
|
||||
|
||||
export const HEARTBEAT_INVOCATION_SOURCES = [
|
||||
"timer",
|
||||
"assignment",
|
||||
|
||||
@@ -13,11 +13,22 @@ export {
|
||||
GOAL_LEVELS,
|
||||
GOAL_STATUSES,
|
||||
PROJECT_STATUSES,
|
||||
PAUSE_REASONS,
|
||||
PROJECT_COLORS,
|
||||
APPROVAL_TYPES,
|
||||
APPROVAL_STATUSES,
|
||||
SECRET_PROVIDERS,
|
||||
STORAGE_PROVIDERS,
|
||||
BILLING_TYPES,
|
||||
FINANCE_EVENT_KINDS,
|
||||
FINANCE_DIRECTIONS,
|
||||
FINANCE_UNITS,
|
||||
BUDGET_SCOPE_TYPES,
|
||||
BUDGET_METRICS,
|
||||
BUDGET_WINDOW_KINDS,
|
||||
BUDGET_THRESHOLD_TYPES,
|
||||
BUDGET_INCIDENT_STATUSES,
|
||||
BUDGET_INCIDENT_RESOLUTION_ACTIONS,
|
||||
HEARTBEAT_INVOCATION_SOURCES,
|
||||
HEARTBEAT_RUN_STATUSES,
|
||||
WAKEUP_TRIGGER_DETAILS,
|
||||
@@ -61,10 +72,21 @@ export {
|
||||
type GoalLevel,
|
||||
type GoalStatus,
|
||||
type ProjectStatus,
|
||||
type PauseReason,
|
||||
type ApprovalType,
|
||||
type ApprovalStatus,
|
||||
type SecretProvider,
|
||||
type StorageProvider,
|
||||
type BillingType,
|
||||
type FinanceEventKind,
|
||||
type FinanceDirection,
|
||||
type FinanceUnit,
|
||||
type BudgetScopeType,
|
||||
type BudgetMetric,
|
||||
type BudgetWindowKind,
|
||||
type BudgetThresholdType,
|
||||
type BudgetIncidentStatus,
|
||||
type BudgetIncidentResolutionAction,
|
||||
type HeartbeatInvocationSource,
|
||||
type HeartbeatRunStatus,
|
||||
type WakeupTriggerDetail,
|
||||
@@ -129,13 +151,24 @@ export type {
|
||||
Goal,
|
||||
Approval,
|
||||
ApprovalComment,
|
||||
BudgetPolicy,
|
||||
BudgetPolicySummary,
|
||||
BudgetIncident,
|
||||
BudgetOverview,
|
||||
BudgetPolicyUpsertInput,
|
||||
BudgetIncidentResolutionInput,
|
||||
CostEvent,
|
||||
CostSummary,
|
||||
CostByAgent,
|
||||
CostByProviderModel,
|
||||
CostByBiller,
|
||||
CostByAgentModel,
|
||||
CostWindowSpendRow,
|
||||
CostByProject,
|
||||
FinanceEvent,
|
||||
FinanceSummary,
|
||||
FinanceByBiller,
|
||||
FinanceByKind,
|
||||
HeartbeatRun,
|
||||
HeartbeatRunEvent,
|
||||
AgentRuntimeState,
|
||||
@@ -253,11 +286,15 @@ export {
|
||||
type CreateGoal,
|
||||
type UpdateGoal,
|
||||
createApprovalSchema,
|
||||
upsertBudgetPolicySchema,
|
||||
resolveBudgetIncidentSchema,
|
||||
resolveApprovalSchema,
|
||||
requestApprovalRevisionSchema,
|
||||
resubmitApprovalSchema,
|
||||
addApprovalCommentSchema,
|
||||
type CreateApproval,
|
||||
type UpsertBudgetPolicy,
|
||||
type ResolveBudgetIncident,
|
||||
type ResolveApproval,
|
||||
type RequestApprovalRevision,
|
||||
type ResubmitApproval,
|
||||
@@ -273,6 +310,7 @@ export {
|
||||
type RotateSecret,
|
||||
type UpdateSecret,
|
||||
createCostEventSchema,
|
||||
createFinanceEventSchema,
|
||||
updateBudgetSchema,
|
||||
createAssetImageMetadataSchema,
|
||||
createCompanyInviteSchema,
|
||||
@@ -283,6 +321,7 @@ export {
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCostEvent,
|
||||
type CreateFinanceEvent,
|
||||
type UpdateBudget,
|
||||
type CreateAssetImageMetadata,
|
||||
type CreateCompanyInvite,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AgentAdapterType,
|
||||
PauseReason,
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
} from "../constants.js";
|
||||
@@ -24,6 +25,8 @@ export interface Agent {
|
||||
runtimeConfig: Record<string, unknown>;
|
||||
budgetMonthlyCents: number;
|
||||
spentMonthlyCents: number;
|
||||
pauseReason: PauseReason | null;
|
||||
pausedAt: Date | null;
|
||||
permissions: AgentPermissions;
|
||||
lastHeartbeatAt: Date | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
|
||||
99
packages/shared/src/types/budget.ts
Normal file
99
packages/shared/src/types/budget.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type {
|
||||
BudgetIncidentResolutionAction,
|
||||
BudgetIncidentStatus,
|
||||
BudgetMetric,
|
||||
BudgetScopeType,
|
||||
BudgetThresholdType,
|
||||
BudgetWindowKind,
|
||||
PauseReason,
|
||||
} from "../constants.js";
|
||||
|
||||
export interface BudgetPolicy {
|
||||
id: string;
|
||||
companyId: string;
|
||||
scopeType: BudgetScopeType;
|
||||
scopeId: string;
|
||||
metric: BudgetMetric;
|
||||
windowKind: BudgetWindowKind;
|
||||
amount: number;
|
||||
warnPercent: number;
|
||||
hardStopEnabled: boolean;
|
||||
notifyEnabled: boolean;
|
||||
isActive: boolean;
|
||||
createdByUserId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface BudgetPolicySummary {
|
||||
policyId: string;
|
||||
companyId: string;
|
||||
scopeType: BudgetScopeType;
|
||||
scopeId: string;
|
||||
scopeName: string;
|
||||
metric: BudgetMetric;
|
||||
windowKind: BudgetWindowKind;
|
||||
amount: number;
|
||||
observedAmount: number;
|
||||
remainingAmount: number;
|
||||
utilizationPercent: number;
|
||||
warnPercent: number;
|
||||
hardStopEnabled: boolean;
|
||||
notifyEnabled: boolean;
|
||||
isActive: boolean;
|
||||
status: "ok" | "warning" | "hard_stop";
|
||||
paused: boolean;
|
||||
pauseReason: PauseReason | null;
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
}
|
||||
|
||||
export interface BudgetIncident {
|
||||
id: string;
|
||||
companyId: string;
|
||||
policyId: string;
|
||||
scopeType: BudgetScopeType;
|
||||
scopeId: string;
|
||||
scopeName: string;
|
||||
metric: BudgetMetric;
|
||||
windowKind: BudgetWindowKind;
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
thresholdType: BudgetThresholdType;
|
||||
amountLimit: number;
|
||||
amountObserved: number;
|
||||
status: BudgetIncidentStatus;
|
||||
approvalId: string | null;
|
||||
approvalStatus: string | null;
|
||||
resolvedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface BudgetOverview {
|
||||
companyId: string;
|
||||
policies: BudgetPolicySummary[];
|
||||
activeIncidents: BudgetIncident[];
|
||||
pausedAgentCount: number;
|
||||
pausedProjectCount: number;
|
||||
pendingApprovalCount: number;
|
||||
}
|
||||
|
||||
export interface BudgetPolicyUpsertInput {
|
||||
scopeType: BudgetScopeType;
|
||||
scopeId: string;
|
||||
metric?: BudgetMetric;
|
||||
windowKind?: BudgetWindowKind;
|
||||
amount: number;
|
||||
warnPercent?: number;
|
||||
hardStopEnabled?: boolean;
|
||||
notifyEnabled?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface BudgetIncidentResolutionInput {
|
||||
action: BudgetIncidentResolutionAction;
|
||||
amount?: number;
|
||||
decisionNote?: string | null;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { BillingType } from "../constants.js";
|
||||
|
||||
export interface CostEvent {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -5,10 +7,14 @@ export interface CostEvent {
|
||||
issueId: string | null;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
heartbeatRunId: string | null;
|
||||
billingCode: string | null;
|
||||
provider: string;
|
||||
biller: string;
|
||||
billingType: BillingType;
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
costCents: number;
|
||||
occurredAt: Date;
|
||||
@@ -28,45 +34,71 @@ export interface CostByAgent {
|
||||
agentStatus: string | null;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
apiRunCount: number;
|
||||
subscriptionRunCount: number;
|
||||
subscriptionCachedInputTokens: number;
|
||||
subscriptionInputTokens: number;
|
||||
subscriptionOutputTokens: number;
|
||||
}
|
||||
|
||||
export interface CostByProviderModel {
|
||||
provider: string;
|
||||
biller: string;
|
||||
billingType: BillingType;
|
||||
model: string;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
apiRunCount: number;
|
||||
subscriptionRunCount: number;
|
||||
subscriptionCachedInputTokens: number;
|
||||
subscriptionInputTokens: number;
|
||||
subscriptionOutputTokens: number;
|
||||
}
|
||||
|
||||
export interface CostByBiller {
|
||||
biller: string;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
apiRunCount: number;
|
||||
subscriptionRunCount: number;
|
||||
subscriptionCachedInputTokens: number;
|
||||
subscriptionInputTokens: number;
|
||||
subscriptionOutputTokens: number;
|
||||
providerCount: number;
|
||||
modelCount: number;
|
||||
}
|
||||
|
||||
/** per-agent breakdown by provider + model, for identifying token-hungry agents */
|
||||
export interface CostByAgentModel {
|
||||
agentId: string;
|
||||
agentName: string | null;
|
||||
provider: string;
|
||||
biller: string;
|
||||
billingType: BillingType;
|
||||
model: string;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
/** spend per provider for a fixed rolling time window */
|
||||
export interface CostWindowSpendRow {
|
||||
provider: string;
|
||||
biller: string;
|
||||
/** duration label, e.g. "5h", "24h", "7d" */
|
||||
window: string;
|
||||
/** rolling window duration in hours */
|
||||
windowHours: number;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
@@ -76,5 +108,6 @@ export interface CostByProject {
|
||||
projectName: string | null;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
@@ -18,4 +18,10 @@ export interface DashboardSummary {
|
||||
monthUtilizationPercent: number;
|
||||
};
|
||||
pendingApprovals: number;
|
||||
budgets: {
|
||||
activeIncidents: number;
|
||||
pendingApprovals: number;
|
||||
pausedAgents: number;
|
||||
pausedProjects: number;
|
||||
};
|
||||
}
|
||||
|
||||
60
packages/shared/src/types/finance.ts
Normal file
60
packages/shared/src/types/finance.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { AgentAdapterType, FinanceDirection, FinanceEventKind, FinanceUnit } from "../constants.js";
|
||||
|
||||
export interface FinanceEvent {
|
||||
id: string;
|
||||
companyId: string;
|
||||
agentId: string | null;
|
||||
issueId: string | null;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
heartbeatRunId: string | null;
|
||||
costEventId: string | null;
|
||||
billingCode: string | null;
|
||||
description: string | null;
|
||||
eventKind: FinanceEventKind;
|
||||
direction: FinanceDirection;
|
||||
biller: string;
|
||||
provider: string | null;
|
||||
executionAdapterType: AgentAdapterType | null;
|
||||
pricingTier: string | null;
|
||||
region: string | null;
|
||||
model: string | null;
|
||||
quantity: number | null;
|
||||
unit: FinanceUnit | null;
|
||||
amountCents: number;
|
||||
currency: string;
|
||||
estimated: boolean;
|
||||
externalInvoiceId: string | null;
|
||||
metadataJson: Record<string, unknown> | null;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface FinanceSummary {
|
||||
companyId: string;
|
||||
debitCents: number;
|
||||
creditCents: number;
|
||||
netCents: number;
|
||||
estimatedDebitCents: number;
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
export interface FinanceByBiller {
|
||||
biller: string;
|
||||
debitCents: number;
|
||||
creditCents: number;
|
||||
netCents: number;
|
||||
estimatedDebitCents: number;
|
||||
eventCount: number;
|
||||
kindCount: number;
|
||||
}
|
||||
|
||||
export interface FinanceByKind {
|
||||
eventKind: FinanceEventKind;
|
||||
debitCents: number;
|
||||
creditCents: number;
|
||||
netCents: number;
|
||||
estimatedDebitCents: number;
|
||||
eventCount: number;
|
||||
billerCount: number;
|
||||
}
|
||||
@@ -36,6 +36,14 @@ export type {
|
||||
} from "./issue.js";
|
||||
export type { Goal } from "./goal.js";
|
||||
export type { Approval, ApprovalComment } from "./approval.js";
|
||||
export type {
|
||||
BudgetPolicy,
|
||||
BudgetPolicySummary,
|
||||
BudgetIncident,
|
||||
BudgetOverview,
|
||||
BudgetPolicyUpsertInput,
|
||||
BudgetIncidentResolutionInput,
|
||||
} from "./budget.js";
|
||||
export type {
|
||||
SecretProvider,
|
||||
SecretVersionSelector,
|
||||
@@ -46,7 +54,8 @@ export type {
|
||||
CompanySecret,
|
||||
SecretProviderDescriptor,
|
||||
} from "./secrets.js";
|
||||
export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js";
|
||||
export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js";
|
||||
export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js";
|
||||
export type {
|
||||
HeartbeatRun,
|
||||
HeartbeatRunEvent,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProjectStatus } from "../constants.js";
|
||||
import type { PauseReason, ProjectStatus } from "../constants.js";
|
||||
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
|
||||
|
||||
export interface ProjectGoalRef {
|
||||
@@ -35,6 +35,8 @@ export interface Project {
|
||||
leadAgentId: string | null;
|
||||
targetDate: string | null;
|
||||
color: string | null;
|
||||
pauseReason: PauseReason | null;
|
||||
pausedAt: Date | null;
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
|
||||
@@ -8,12 +8,16 @@ export interface QuotaWindow {
|
||||
resetsAt: string | null;
|
||||
/** free-form value label for credit-style windows, e.g. "$4.20 remaining" */
|
||||
valueLabel: string | null;
|
||||
/** optional supporting text, e.g. reset details or provider-specific notes */
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
/** result for one provider from the quota-windows endpoint */
|
||||
export interface ProviderQuotaResult {
|
||||
/** provider slug, e.g. "anthropic", "openai" */
|
||||
provider: string;
|
||||
/** source label when the provider reports where the quota data came from */
|
||||
source?: string | null;
|
||||
/** true when the fetch succeeded and windows is populated */
|
||||
ok: boolean;
|
||||
/** error message when ok is false */
|
||||
|
||||
37
packages/shared/src/validators/budget.ts
Normal file
37
packages/shared/src/validators/budget.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
BUDGET_INCIDENT_RESOLUTION_ACTIONS,
|
||||
BUDGET_METRICS,
|
||||
BUDGET_SCOPE_TYPES,
|
||||
BUDGET_WINDOW_KINDS,
|
||||
} from "../constants.js";
|
||||
|
||||
export const upsertBudgetPolicySchema = z.object({
|
||||
scopeType: z.enum(BUDGET_SCOPE_TYPES),
|
||||
scopeId: z.string().uuid(),
|
||||
metric: z.enum(BUDGET_METRICS).optional().default("billed_cents"),
|
||||
windowKind: z.enum(BUDGET_WINDOW_KINDS).optional().default("calendar_month_utc"),
|
||||
amount: z.number().int().nonnegative(),
|
||||
warnPercent: z.number().int().min(1).max(99).optional().default(80),
|
||||
hardStopEnabled: z.boolean().optional().default(true),
|
||||
notifyEnabled: z.boolean().optional().default(true),
|
||||
isActive: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export type UpsertBudgetPolicy = z.infer<typeof upsertBudgetPolicySchema>;
|
||||
|
||||
export const resolveBudgetIncidentSchema = z.object({
|
||||
action: z.enum(BUDGET_INCIDENT_RESOLUTION_ACTIONS),
|
||||
amount: z.number().int().nonnegative().optional(),
|
||||
decisionNote: z.string().optional().nullable(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.action === "raise_budget_and_resume" && typeof value.amount !== "number") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "amount is required when raising a budget",
|
||||
path: ["amount"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ResolveBudgetIncident = z.infer<typeof resolveBudgetIncidentSchema>;
|
||||
@@ -1,18 +1,26 @@
|
||||
import { z } from "zod";
|
||||
import { BILLING_TYPES } from "../constants.js";
|
||||
|
||||
export const createCostEventSchema = z.object({
|
||||
agentId: z.string().uuid(),
|
||||
issueId: z.string().uuid().optional().nullable(),
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
goalId: z.string().uuid().optional().nullable(),
|
||||
heartbeatRunId: z.string().uuid().optional().nullable(),
|
||||
billingCode: z.string().optional().nullable(),
|
||||
provider: z.string().min(1),
|
||||
biller: z.string().min(1).optional(),
|
||||
billingType: z.enum(BILLING_TYPES).optional().default("unknown"),
|
||||
model: z.string().min(1),
|
||||
inputTokens: z.number().int().nonnegative().optional().default(0),
|
||||
cachedInputTokens: z.number().int().nonnegative().optional().default(0),
|
||||
outputTokens: z.number().int().nonnegative().optional().default(0),
|
||||
costCents: z.number().int().nonnegative(),
|
||||
occurredAt: z.string().datetime(),
|
||||
});
|
||||
}).transform((value) => ({
|
||||
...value,
|
||||
biller: value.biller ?? value.provider,
|
||||
}));
|
||||
|
||||
export type CreateCostEvent = z.infer<typeof createCostEventSchema>;
|
||||
|
||||
|
||||
34
packages/shared/src/validators/finance.ts
Normal file
34
packages/shared/src/validators/finance.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
import { AGENT_ADAPTER_TYPES, FINANCE_DIRECTIONS, FINANCE_EVENT_KINDS, FINANCE_UNITS } from "../constants.js";
|
||||
|
||||
export const createFinanceEventSchema = z.object({
|
||||
agentId: z.string().uuid().optional().nullable(),
|
||||
issueId: z.string().uuid().optional().nullable(),
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
goalId: z.string().uuid().optional().nullable(),
|
||||
heartbeatRunId: z.string().uuid().optional().nullable(),
|
||||
costEventId: z.string().uuid().optional().nullable(),
|
||||
billingCode: z.string().optional().nullable(),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
eventKind: z.enum(FINANCE_EVENT_KINDS),
|
||||
direction: z.enum(FINANCE_DIRECTIONS).optional().default("debit"),
|
||||
biller: z.string().min(1),
|
||||
provider: z.string().min(1).optional().nullable(),
|
||||
executionAdapterType: z.enum(AGENT_ADAPTER_TYPES).optional().nullable(),
|
||||
pricingTier: z.string().min(1).optional().nullable(),
|
||||
region: z.string().min(1).optional().nullable(),
|
||||
model: z.string().min(1).optional().nullable(),
|
||||
quantity: z.number().int().nonnegative().optional().nullable(),
|
||||
unit: z.enum(FINANCE_UNITS).optional().nullable(),
|
||||
amountCents: z.number().int().nonnegative(),
|
||||
currency: z.string().length(3).optional().default("USD"),
|
||||
estimated: z.boolean().optional().default(false),
|
||||
externalInvoiceId: z.string().optional().nullable(),
|
||||
metadataJson: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
occurredAt: z.string().datetime(),
|
||||
}).transform((value) => ({
|
||||
...value,
|
||||
currency: value.currency.toUpperCase(),
|
||||
}));
|
||||
|
||||
export type CreateFinanceEvent = z.infer<typeof createFinanceEventSchema>;
|
||||
@@ -1,3 +1,10 @@
|
||||
export {
|
||||
upsertBudgetPolicySchema,
|
||||
resolveBudgetIncidentSchema,
|
||||
type UpsertBudgetPolicy,
|
||||
type ResolveBudgetIncident,
|
||||
} from "./budget.js";
|
||||
|
||||
export {
|
||||
createCompanySchema,
|
||||
updateCompanySchema,
|
||||
@@ -121,6 +128,11 @@ export {
|
||||
type UpdateBudget,
|
||||
} from "./cost.js";
|
||||
|
||||
export {
|
||||
createFinanceEventSchema,
|
||||
type CreateFinanceEvent,
|
||||
} from "./finance.js";
|
||||
|
||||
export {
|
||||
createAssetImageMetadataSchema,
|
||||
type CreateAssetImageMetadata,
|
||||
|
||||
@@ -46,6 +46,30 @@ if (tailscaleAuth) {
|
||||
|
||||
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
|
||||
function toError(error, context = "Dev runner command failed") {
|
||||
if (error instanceof Error) return error;
|
||||
if (error === undefined) return new Error(context);
|
||||
if (typeof error === "string") return new Error(`${context}: ${error}`);
|
||||
|
||||
try {
|
||||
return new Error(`${context}: ${JSON.stringify(error)}`);
|
||||
} catch {
|
||||
return new Error(`${context}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
const err = toError(error, "Uncaught exception in dev runner");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
const err = toError(reason, "Unhandled promise rejection in dev runner");
|
||||
process.stderr.write(`${err.stack ?? err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
function formatPendingMigrationSummary(migrations) {
|
||||
if (migrations.length === 0) return "none";
|
||||
return migrations.length > 3
|
||||
@@ -96,7 +120,11 @@ async function maybePreflightMigrations() {
|
||||
{ env },
|
||||
);
|
||||
if (status.code !== 0) {
|
||||
process.stderr.write(status.stderr || status.stdout);
|
||||
process.stderr.write(
|
||||
status.stderr ||
|
||||
status.stdout ||
|
||||
`[paperclip] Command failed with code ${status.code}: pnpm --filter @paperclipai/db exec tsx src/migration-status.ts --json\n`,
|
||||
);
|
||||
process.exit(status.code);
|
||||
}
|
||||
|
||||
@@ -104,8 +132,12 @@ async function maybePreflightMigrations() {
|
||||
try {
|
||||
payload = JSON.parse(status.stdout.trim());
|
||||
} catch (error) {
|
||||
process.stderr.write(status.stderr || status.stdout);
|
||||
throw error;
|
||||
process.stderr.write(
|
||||
status.stderr ||
|
||||
status.stdout ||
|
||||
"[paperclip] migration-status returned invalid JSON payload\n",
|
||||
);
|
||||
throw toError(error, "Unable to parse migration-status JSON output");
|
||||
}
|
||||
|
||||
if (payload.status !== "needsMigrations" || payload.pendingMigrations.length === 0) {
|
||||
|
||||
@@ -22,6 +22,9 @@ vi.mock("../services/index.js", () => ({
|
||||
canUser: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
}),
|
||||
budgetService: () => ({
|
||||
upsertPolicy: vi.fn(),
|
||||
}),
|
||||
logActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { costRoutes } from "../routes/costs.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseDateRange — tested via the route handler since it's a private function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Minimal db stub — just enough for costService() not to throw at construction
|
||||
function makeDb(overrides: Record<string, unknown> = {}) {
|
||||
const selectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
@@ -17,9 +12,10 @@ function makeDb(overrides: Record<string, unknown> = {}) {
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
groupBy: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
// Make it thenable so Drizzle query chains resolve to []
|
||||
|
||||
const thenableChain = Object.assign(Promise.resolve([]), selectChain);
|
||||
|
||||
return {
|
||||
@@ -43,17 +39,40 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockFetchAllQuotaWindows = vi.hoisted(() => vi.fn());
|
||||
const mockCostService = vi.hoisted(() => ({
|
||||
createEvent: vi.fn(),
|
||||
summary: vi.fn().mockResolvedValue({ spendCents: 0 }),
|
||||
byAgent: vi.fn().mockResolvedValue([]),
|
||||
byAgentModel: vi.fn().mockResolvedValue([]),
|
||||
byProvider: vi.fn().mockResolvedValue([]),
|
||||
byBiller: vi.fn().mockResolvedValue([]),
|
||||
windowSpend: vi.fn().mockResolvedValue([]),
|
||||
byProject: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
const mockFinanceService = vi.hoisted(() => ({
|
||||
createEvent: vi.fn(),
|
||||
summary: vi.fn().mockResolvedValue({ debitCents: 0, creditCents: 0, netCents: 0, estimatedDebitCents: 0, eventCount: 0 }),
|
||||
byBiller: vi.fn().mockResolvedValue([]),
|
||||
byKind: vi.fn().mockResolvedValue([]),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
const mockBudgetService = vi.hoisted(() => ({
|
||||
overview: vi.fn().mockResolvedValue({
|
||||
companyId: "company-1",
|
||||
policies: [],
|
||||
activeIncidents: [],
|
||||
pausedAgentCount: 0,
|
||||
pausedProjectCount: 0,
|
||||
pendingApprovalCount: 0,
|
||||
}),
|
||||
upsertPolicy: vi.fn(),
|
||||
resolveIncident: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
costService: () => ({
|
||||
createEvent: vi.fn(),
|
||||
summary: vi.fn().mockResolvedValue({ spendCents: 0 }),
|
||||
byAgent: vi.fn().mockResolvedValue([]),
|
||||
byAgentModel: vi.fn().mockResolvedValue([]),
|
||||
byProvider: vi.fn().mockResolvedValue([]),
|
||||
windowSpend: vi.fn().mockResolvedValue([]),
|
||||
byProject: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
budgetService: () => mockBudgetService,
|
||||
costService: () => mockCostService,
|
||||
financeService: () => mockFinanceService,
|
||||
companyService: () => mockCompanyService,
|
||||
agentService: () => mockAgentService,
|
||||
logActivity: mockLogActivity,
|
||||
@@ -75,8 +94,12 @@ function createApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("parseDateRange — date validation via route", () => {
|
||||
it("accepts valid ISO date strings and passes them to the service", async () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("cost routes", () => {
|
||||
it("accepts valid ISO date strings and passes them to cost summary routes", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/summary")
|
||||
@@ -102,138 +125,30 @@ describe("parseDateRange — date validation via route", () => {
|
||||
expect(res.body.error).toMatch(/invalid 'to' date/i);
|
||||
});
|
||||
|
||||
it("treats missing 'from' and 'to' as no range (passes undefined to service)", async () => {
|
||||
it("returns finance summary rows for valid requests", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app).get("/api/companies/company-1/costs/summary");
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-summary")
|
||||
.query({ from: "2026-02-01T00:00:00.000Z", to: "2026-02-28T23:59:59.999Z" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// byProvider pro-rata subscription split — pure math, no DB needed
|
||||
// ---------------------------------------------------------------------------
|
||||
// The split logic operates on arrays returned by DB queries.
|
||||
// We test it by calling the actual costService with a mock DB that yields
|
||||
// controlled query results and verifying the output proportions.
|
||||
|
||||
import { costService } from "../services/index.js";
|
||||
|
||||
describe("byProvider — pro-rata subscription attribution", () => {
|
||||
it("splits subscription counts proportionally by token share", async () => {
|
||||
// Two models: modelA has 75% of tokens, modelB has 25%.
|
||||
// Total subscription runs = 100, sub input tokens = 1000, sub output tokens = 400.
|
||||
// Expected: modelA gets 75% of each, modelB gets 25%.
|
||||
|
||||
// We bypass the DB by directly exercising the accumulator math.
|
||||
// Inline the accumulation logic from costs.ts to verify the arithmetic is correct.
|
||||
const costRows = [
|
||||
{ provider: "anthropic", model: "claude-sonnet", costCents: 300, inputTokens: 600, outputTokens: 150 },
|
||||
{ provider: "anthropic", model: "claude-haiku", costCents: 100, inputTokens: 200, outputTokens: 50 },
|
||||
];
|
||||
const subscriptionTotals = {
|
||||
apiRunCount: 20,
|
||||
subscriptionRunCount: 100,
|
||||
subscriptionInputTokens: 1000,
|
||||
subscriptionOutputTokens: 400,
|
||||
};
|
||||
|
||||
const totalTokens = costRows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
|
||||
// totalTokens = (600+150) + (200+50) = 750 + 250 = 1000
|
||||
|
||||
const result = costRows.map((row) => {
|
||||
const rowTokens = row.inputTokens + row.outputTokens;
|
||||
const share = totalTokens > 0 ? rowTokens / totalTokens : 0;
|
||||
return {
|
||||
...row,
|
||||
apiRunCount: Math.round(subscriptionTotals.apiRunCount * share),
|
||||
subscriptionRunCount: Math.round(subscriptionTotals.subscriptionRunCount * share),
|
||||
subscriptionInputTokens: Math.round(subscriptionTotals.subscriptionInputTokens * share),
|
||||
subscriptionOutputTokens: Math.round(subscriptionTotals.subscriptionOutputTokens * share),
|
||||
};
|
||||
});
|
||||
|
||||
// modelA: 750/1000 = 75%
|
||||
expect(result[0]!.subscriptionRunCount).toBe(75); // 100 * 0.75
|
||||
expect(result[0]!.subscriptionInputTokens).toBe(750); // 1000 * 0.75
|
||||
expect(result[0]!.subscriptionOutputTokens).toBe(300); // 400 * 0.75
|
||||
expect(result[0]!.apiRunCount).toBe(15); // 20 * 0.75
|
||||
|
||||
// modelB: 250/1000 = 25%
|
||||
expect(result[1]!.subscriptionRunCount).toBe(25); // 100 * 0.25
|
||||
expect(result[1]!.subscriptionInputTokens).toBe(250); // 1000 * 0.25
|
||||
expect(result[1]!.subscriptionOutputTokens).toBe(100); // 400 * 0.25
|
||||
expect(result[1]!.apiRunCount).toBe(5); // 20 * 0.25
|
||||
});
|
||||
|
||||
it("assigns share=0 to all rows when totalTokens is zero (avoids divide-by-zero)", () => {
|
||||
const costRows = [
|
||||
{ provider: "anthropic", model: "claude-sonnet", costCents: 0, inputTokens: 0, outputTokens: 0 },
|
||||
{ provider: "openai", model: "gpt-5", costCents: 0, inputTokens: 0, outputTokens: 0 },
|
||||
];
|
||||
const subscriptionTotals = { apiRunCount: 10, subscriptionRunCount: 5, subscriptionInputTokens: 100, subscriptionOutputTokens: 50 };
|
||||
const totalTokens = 0;
|
||||
|
||||
const result = costRows.map((row) => {
|
||||
const rowTokens = row.inputTokens + row.outputTokens;
|
||||
const share = totalTokens > 0 ? rowTokens / totalTokens : 0;
|
||||
return {
|
||||
subscriptionRunCount: Math.round(subscriptionTotals.subscriptionRunCount * share),
|
||||
subscriptionInputTokens: Math.round(subscriptionTotals.subscriptionInputTokens * share),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result[0]!.subscriptionRunCount).toBe(0);
|
||||
expect(result[0]!.subscriptionInputTokens).toBe(0);
|
||||
expect(result[1]!.subscriptionRunCount).toBe(0);
|
||||
expect(result[1]!.subscriptionInputTokens).toBe(0);
|
||||
});
|
||||
|
||||
it("attribution rounds to nearest integer (no fractional run counts)", () => {
|
||||
// 3 models, 10 runs to split — rounding may not sum to exactly 10, that's expected
|
||||
const costRows = [
|
||||
{ inputTokens: 1, outputTokens: 0 }, // 1/3
|
||||
{ inputTokens: 1, outputTokens: 0 }, // 1/3
|
||||
{ inputTokens: 1, outputTokens: 0 }, // 1/3
|
||||
];
|
||||
const totalTokens = 3;
|
||||
const subscriptionRunCount = 10;
|
||||
|
||||
const result = costRows.map((row) => {
|
||||
const share = row.inputTokens / totalTokens;
|
||||
return Math.round(subscriptionRunCount * share);
|
||||
});
|
||||
|
||||
// Each should be Math.round(10/3) = Math.round(3.33) = 3
|
||||
expect(result).toEqual([3, 3, 3]);
|
||||
for (const count of result) {
|
||||
expect(Number.isInteger(count)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// windowSpend — verify shape of rolling window results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("windowSpend — rolling window labels and hours", () => {
|
||||
it("returns results for the three standard windows (5h, 24h, 7d)", async () => {
|
||||
// The windowSpend method computes three rolling windows internally.
|
||||
// We verify the expected window labels exist in a real call by checking
|
||||
// the service contract shape. Since we're not connecting to a DB here,
|
||||
// we verify the window definitions directly from service source by
|
||||
// exercising the label computation inline.
|
||||
|
||||
const windows = [
|
||||
{ label: "5h", hours: 5 },
|
||||
{ label: "24h", hours: 24 },
|
||||
{ label: "7d", hours: 168 },
|
||||
] as const;
|
||||
|
||||
// All three standard windows must be present
|
||||
expect(windows.map((w) => w.label)).toEqual(["5h", "24h", "7d"]);
|
||||
// Hours must match expected durations
|
||||
expect(windows[0]!.hours).toBe(5);
|
||||
expect(windows[1]!.hours).toBe(24);
|
||||
expect(windows[2]!.hours).toBe(168); // 7 * 24
|
||||
expect(mockFinanceService.summary).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid finance event list limits", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-events")
|
||||
.query({ limit: "0" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid 'limit'/i);
|
||||
});
|
||||
|
||||
it("accepts valid finance event list limits", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-events")
|
||||
.query({ limit: "25" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockFinanceService.list).toHaveBeenCalledWith("company-1", undefined, 25);
|
||||
});
|
||||
});
|
||||
|
||||
56
server/src/__tests__/quota-windows-service.test.ts
Normal file
56
server/src/__tests__/quota-windows-service.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../adapters/registry.js", () => ({
|
||||
listServerAdapters: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listServerAdapters } from "../adapters/registry.js";
|
||||
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
||||
|
||||
describe("fetchAllQuotaWindows", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns adapter results without waiting for a slower provider to finish forever", async () => {
|
||||
vi.mocked(listServerAdapters).mockReturnValue([
|
||||
{
|
||||
type: "codex_local",
|
||||
getQuotaWindows: vi.fn().mockResolvedValue({
|
||||
provider: "openai",
|
||||
source: "codex-rpc",
|
||||
ok: true,
|
||||
windows: [{ label: "5h limit", usedPercent: 2, resetsAt: null, valueLabel: null, detail: null }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "claude_local",
|
||||
getQuotaWindows: vi.fn(() => new Promise(() => {})),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const promise = fetchAllQuotaWindows();
|
||||
await vi.advanceTimersByTimeAsync(20_001);
|
||||
const results = await promise;
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
provider: "openai",
|
||||
source: "codex-rpc",
|
||||
ok: true,
|
||||
windows: [{ label: "5h limit", usedPercent: 2, resetsAt: null, valueLabel: null, detail: null }],
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
ok: false,
|
||||
error: "quota polling timed out after 20s",
|
||||
windows: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,17 @@ import {
|
||||
toPercent,
|
||||
fetchWithTimeout,
|
||||
fetchClaudeQuota,
|
||||
parseClaudeCliUsageText,
|
||||
readClaudeToken,
|
||||
claudeConfigDir,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
import {
|
||||
secondsToWindowLabel,
|
||||
readCodexAuthInfo,
|
||||
readCodexToken,
|
||||
fetchCodexQuota,
|
||||
mapCodexRpcQuota,
|
||||
codexHomeDir,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
@@ -271,13 +274,86 @@ describe("readClaudeToken", () => {
|
||||
expect(token).toBe("my-test-token");
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("reads the token from .credentials.json when that is the available Claude auth file", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
const creds = { claudeAiOauth: { accessToken: "dotfile-token" } };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, ".credentials.json"), JSON.stringify(creds)),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe("dotfile-token");
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClaudeCliUsageText", () => {
|
||||
it("parses the Claude usage panel layout into quota windows", () => {
|
||||
const raw = `
|
||||
Settings: Status Config Usage
|
||||
Current session
|
||||
2% used
|
||||
Resets 5pm (America/Chicago)
|
||||
|
||||
Current week (all models)
|
||||
47% used
|
||||
Resets Mar 18 at 7:59am (America/Chicago)
|
||||
|
||||
Current week (Sonnet only)
|
||||
0% used
|
||||
Resets Mar 18 at 8:59am (America/Chicago)
|
||||
|
||||
Extra usage
|
||||
Extra usage not enabled • /extra-usage to enable
|
||||
`;
|
||||
|
||||
expect(parseClaudeCliUsageText(raw)).toEqual([
|
||||
{
|
||||
label: "Current session",
|
||||
usedPercent: 2,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets 5pm (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Current week (all models)",
|
||||
usedPercent: 47,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets Mar 18 at 7:59am (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Current week (Sonnet only)",
|
||||
usedPercent: 0,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets Mar 18 at 8:59am (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Extra usage",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Extra usage not enabled • /extra-usage to enable",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws a useful error when the Claude CLI panel reports a usage load failure", () => {
|
||||
expect(() => parseClaudeCliUsageText("Failed to load usage data")).toThrow(
|
||||
"Claude CLI could not load usage data. Open the CLI and retry `/usage`.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readCodexToken — filesystem paths
|
||||
// readCodexAuthInfo / readCodexToken — filesystem paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readCodexToken", () => {
|
||||
describe("readCodexAuthInfo", () => {
|
||||
const savedEnv = process.env.CODEX_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -290,7 +366,7 @@ describe("readCodexToken", () => {
|
||||
|
||||
it("returns null when auth.json does not exist", async () => {
|
||||
process.env.CODEX_HOME = "/tmp/__no_such_paperclip_codex_dir__";
|
||||
const result = await readCodexToken();
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
@@ -302,7 +378,7 @@ describe("readCodexToken", () => {
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexToken();
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
@@ -315,12 +391,12 @@ describe("readCodexToken", () => {
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexToken();
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns token and accountId when both are present", async () => {
|
||||
it("reads the legacy flat auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
const auth = { accessToken: "codex-token", accountId: "acc-123" };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
@@ -329,21 +405,81 @@ describe("readCodexToken", () => {
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexToken();
|
||||
expect(result).toEqual({ token: "codex-token", accountId: "acc-123" });
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toMatchObject({
|
||||
accessToken: "codex-token",
|
||||
accountId: "acc-123",
|
||||
email: null,
|
||||
planType: null,
|
||||
});
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns token with null accountId when accountId is absent", async () => {
|
||||
it("reads the modern nested auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
const jwtPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
email: "codex@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_plan_type: "pro",
|
||||
chatgpt_user_email: "codex@example.com",
|
||||
},
|
||||
}),
|
||||
).toString("base64url");
|
||||
const auth = {
|
||||
tokens: {
|
||||
access_token: `header.${jwtPayload}.sig`,
|
||||
account_id: "acc-modern",
|
||||
refresh_token: "refresh-me",
|
||||
id_token: `header.${jwtPayload}.sig`,
|
||||
},
|
||||
last_refresh: "2026-03-14T12:00:00Z",
|
||||
};
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify(auth)),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toMatchObject({
|
||||
accessToken: `header.${jwtPayload}.sig`,
|
||||
accountId: "acc-modern",
|
||||
refreshToken: "refresh-me",
|
||||
email: "codex@example.com",
|
||||
planType: "pro",
|
||||
lastRefresh: "2026-03-14T12:00:00Z",
|
||||
});
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCodexToken", () => {
|
||||
const savedEnv = process.env.CODEX_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = savedEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns token and accountId from the nested auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({ accessToken: "tok" })),
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "nested-token",
|
||||
account_id: "acc-nested",
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexToken();
|
||||
expect(result).toEqual({ token: "tok", accountId: null });
|
||||
expect(result).toEqual({ token: "nested-token", accountId: "acc-nested" });
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
@@ -384,14 +520,22 @@ describe("fetchClaudeQuota", () => {
|
||||
mockFetch({ five_hour: { utilization: 0.4, resets_at: "2026-01-01T00:00:00Z" } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({ label: "5h", usedPercent: 40, resetsAt: "2026-01-01T00:00:00Z" });
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Current session",
|
||||
usedPercent: 40,
|
||||
resetsAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses seven_day window", async () => {
|
||||
mockFetch({ seven_day: { utilization: 0.75, resets_at: null } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({ label: "7d", usedPercent: 75, resetsAt: null });
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Current week (all models)",
|
||||
usedPercent: 75,
|
||||
resetsAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses seven_day_sonnet and seven_day_opus windows", async () => {
|
||||
@@ -401,8 +545,8 @@ describe("fetchClaudeQuota", () => {
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0]!.label).toBe("Sonnet 7d");
|
||||
expect(windows[1]!.label).toBe("Opus 7d");
|
||||
expect(windows[0]!.label).toBe("Current week (Sonnet only)");
|
||||
expect(windows[1]!.label).toBe("Current week (Opus only)");
|
||||
});
|
||||
|
||||
it("sets usedPercent to null when utilization is absent", async () => {
|
||||
@@ -421,7 +565,31 @@ describe("fetchClaudeQuota", () => {
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(4);
|
||||
const labels = windows.map((w: QuotaWindow) => w.label);
|
||||
expect(labels).toEqual(["5h", "7d", "Sonnet 7d", "Opus 7d"]);
|
||||
expect(labels).toEqual([
|
||||
"Current session",
|
||||
"Current week (all models)",
|
||||
"Current week (Sonnet only)",
|
||||
"Current week (Opus only)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses extra usage when the OAuth response includes it", async () => {
|
||||
mockFetch({
|
||||
extra_usage: {
|
||||
is_enabled: false,
|
||||
utilization: null,
|
||||
},
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toEqual([
|
||||
{
|
||||
label: "Extra usage",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: "Not enabled",
|
||||
detail: "Extra usage not enabled",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -471,15 +639,15 @@ describe("fetchCodexQuota", () => {
|
||||
expect(windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses primary_window with 24h label", async () => {
|
||||
it("normalizes numeric reset timestamps from WHAM", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 30, limit_window_seconds: 86400, reset_at: "2026-01-02T00:00:00Z" },
|
||||
primary_window: { used_percent: 30, limit_window_seconds: 86400, reset_at: 1_767_312_000 },
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({ label: "24h", usedPercent: 30, resetsAt: "2026-01-02T00:00:00Z" });
|
||||
expect(windows[0]).toMatchObject({ label: "5h limit", usedPercent: 30, resetsAt: "2026-01-02T00:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("parses secondary_window alongside primary_window", async () => {
|
||||
@@ -491,8 +659,8 @@ describe("fetchCodexQuota", () => {
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0]!.label).toBe("5h");
|
||||
expect(windows[1]!.label).toBe("7d");
|
||||
expect(windows[0]!.label).toBe("5h limit");
|
||||
expect(windows[1]!.label).toBe("Weekly limit");
|
||||
});
|
||||
|
||||
it("includes Credits window when credits present and not unlimited", async () => {
|
||||
@@ -521,6 +689,90 @@ describe("fetchCodexQuota", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapCodexRpcQuota", () => {
|
||||
it("maps account and model-specific Codex limits into quota windows", () => {
|
||||
const snapshot = mapCodexRpcQuota(
|
||||
{
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
primary: { usedPercent: 1, windowDurationMins: 300, resetsAt: 1_763_500_000 },
|
||||
secondary: { usedPercent: 27, windowDurationMins: 10_080 },
|
||||
planType: "pro",
|
||||
},
|
||||
rateLimitsByLimitId: {
|
||||
codex_bengalfox: {
|
||||
limitId: "codex_bengalfox",
|
||||
limitName: "GPT-5.3-Codex-Spark",
|
||||
primary: { usedPercent: 8, windowDurationMins: 300 },
|
||||
secondary: { usedPercent: 20, windowDurationMins: 10_080 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
account: {
|
||||
email: "codex@example.com",
|
||||
planType: "pro",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(snapshot.email).toBe("codex@example.com");
|
||||
expect(snapshot.planType).toBe("pro");
|
||||
expect(snapshot.windows).toEqual([
|
||||
{
|
||||
label: "5h limit",
|
||||
usedPercent: 1,
|
||||
resetsAt: "2025-11-18T21:06:40.000Z",
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "Weekly limit",
|
||||
usedPercent: 27,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "GPT-5.3-Codex-Spark · 5h limit",
|
||||
usedPercent: 8,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "GPT-5.3-Codex-Spark · Weekly limit",
|
||||
usedPercent: 20,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes a credits row when the root Codex limit reports finite credits", () => {
|
||||
const snapshot = mapCodexRpcQuota({
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
credits: {
|
||||
unlimited: false,
|
||||
balance: "12.34",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.windows).toEqual([
|
||||
{
|
||||
label: "Credits",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: "$12.34 remaining",
|
||||
detail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchWithTimeout — abort on timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
agentService,
|
||||
accessService,
|
||||
approvalService,
|
||||
budgetService,
|
||||
heartbeatService,
|
||||
issueApprovalService,
|
||||
issueService,
|
||||
@@ -57,6 +58,7 @@ export function agentRoutes(db: Db) {
|
||||
const svc = agentService(db);
|
||||
const access = accessService(db);
|
||||
const approvalsSvc = approvalService(db);
|
||||
const budgets = budgetService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
@@ -941,6 +943,19 @@ export function agentRoutes(db: Db) {
|
||||
details: { name: agent.name, role: agent.role },
|
||||
});
|
||||
|
||||
if (agent.budgetMonthlyCents > 0) {
|
||||
await budgets.upsertPolicy(
|
||||
companyId,
|
||||
{
|
||||
scopeType: "agent",
|
||||
scopeId: agent.id,
|
||||
amount: agent.budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
actor.actorType === "user" ? actor.actorId : null,
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json(agent);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, companyPortabilityService, companyService, logActivity } from "../services/index.js";
|
||||
import {
|
||||
accessService,
|
||||
budgetService,
|
||||
companyPortabilityService,
|
||||
companyService,
|
||||
logActivity,
|
||||
} from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
export function companyRoutes(db: Db) {
|
||||
@@ -17,6 +23,7 @@ export function companyRoutes(db: Db) {
|
||||
const svc = companyService(db);
|
||||
const portability = companyPortabilityService(db);
|
||||
const access = accessService(db);
|
||||
const budgets = budgetService(db);
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
assertBoard(req);
|
||||
@@ -122,6 +129,18 @@ export function companyRoutes(db: Db) {
|
||||
entityId: company.id,
|
||||
details: { name: company.name },
|
||||
});
|
||||
if (company.budgetMonthlyCents > 0) {
|
||||
await budgets.upsertPolicy(
|
||||
company.id,
|
||||
{
|
||||
scopeType: "company",
|
||||
scopeId: company.id,
|
||||
amount: company.budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
req.actor.userId ?? "board",
|
||||
);
|
||||
}
|
||||
res.status(201).json(company);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { createCostEventSchema, updateBudgetSchema } from "@paperclipai/shared";
|
||||
import {
|
||||
createCostEventSchema,
|
||||
createFinanceEventSchema,
|
||||
resolveBudgetIncidentSchema,
|
||||
updateBudgetSchema,
|
||||
upsertBudgetPolicySchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { costService, companyService, agentService, logActivity } from "../services/index.js";
|
||||
import {
|
||||
budgetService,
|
||||
costService,
|
||||
financeService,
|
||||
companyService,
|
||||
agentService,
|
||||
logActivity,
|
||||
} from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
||||
import { badRequest } from "../errors.js";
|
||||
@@ -10,6 +23,8 @@ import { badRequest } from "../errors.js";
|
||||
export function costRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const costs = costService(db);
|
||||
const finance = financeService(db);
|
||||
const budgets = budgetService(db);
|
||||
const companies = companyService(db);
|
||||
const agents = agentService(db);
|
||||
|
||||
@@ -42,6 +57,36 @@ export function costRoutes(db: Db) {
|
||||
res.status(201).json(event);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/finance-events", validate(createFinanceEventSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
assertBoard(req);
|
||||
|
||||
const event = await finance.createEvent(companyId, {
|
||||
...req.body,
|
||||
occurredAt: new Date(req.body.occurredAt),
|
||||
});
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "finance_event.reported",
|
||||
entityType: "finance_event",
|
||||
entityId: event.id,
|
||||
details: {
|
||||
amountCents: event.amountCents,
|
||||
biller: event.biller,
|
||||
eventKind: event.eventKind,
|
||||
direction: event.direction,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(event);
|
||||
});
|
||||
|
||||
function parseDateRange(query: Record<string, unknown>) {
|
||||
const fromRaw = query.from as string | undefined;
|
||||
const toRaw = query.to as string | undefined;
|
||||
@@ -52,6 +97,16 @@ export function costRoutes(db: Db) {
|
||||
return (from || to) ? { from, to } : undefined;
|
||||
}
|
||||
|
||||
function parseLimit(query: Record<string, unknown>) {
|
||||
const raw = query.limit as string | undefined;
|
||||
if (!raw) return 100;
|
||||
const limit = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
|
||||
throw badRequest("invalid 'limit' value");
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/costs/summary", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -84,6 +139,47 @@ export function costRoutes(db: Db) {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/by-biller", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const rows = await costs.byBiller(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/finance-summary", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const summary = await finance.summary(companyId, range);
|
||||
res.json(summary);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/finance-by-biller", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const rows = await finance.byBiller(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/finance-by-kind", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const rows = await finance.byKind(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/finance-events", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const limit = parseLimit(req.query);
|
||||
const rows = await finance.list(companyId, range, limit);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/window-spend", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -106,6 +202,38 @@ export function costRoutes(db: Db) {
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/budgets/overview", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const overview = await budgets.overview(companyId);
|
||||
res.json(overview);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/budgets/policies",
|
||||
validate(upsertBudgetPolicySchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const summary = await budgets.upsertPolicy(companyId, req.body, req.actor.userId ?? "board");
|
||||
res.json(summary);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/budget-incidents/:incidentId/resolve",
|
||||
validate(resolveBudgetIncidentSchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
const incidentId = req.params.incidentId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const incident = await budgets.resolveIncident(companyId, incidentId, req.body, req.actor.userId ?? "board");
|
||||
res.json(incident);
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -133,6 +261,17 @@ export function costRoutes(db: Db) {
|
||||
details: { budgetMonthlyCents: req.body.budgetMonthlyCents },
|
||||
});
|
||||
|
||||
await budgets.upsertPolicy(
|
||||
companyId,
|
||||
{
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
amount: req.body.budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
req.actor.userId ?? "board",
|
||||
);
|
||||
|
||||
res.json(company);
|
||||
});
|
||||
|
||||
@@ -169,6 +308,17 @@ export function costRoutes(db: Db) {
|
||||
details: { budgetMonthlyCents: updated.budgetMonthlyCents },
|
||||
});
|
||||
|
||||
await budgets.upsertPolicy(
|
||||
updated.companyId,
|
||||
{
|
||||
scopeType: "agent",
|
||||
scopeId: updated.id,
|
||||
amount: updated.budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
);
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
|
||||
@@ -921,6 +921,19 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
|
||||
if (issue.projectId) {
|
||||
const project = await projectsSvc.getById(issue.projectId);
|
||||
if (project?.pausedAt) {
|
||||
res.status(409).json({
|
||||
error:
|
||||
project.pauseReason === "budget"
|
||||
? "Project is paused because its budget hard-stop was reached"
|
||||
: "Project is paused",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {
|
||||
res.status(403).json({ error: "Agent can only checkout as itself" });
|
||||
return;
|
||||
|
||||
@@ -360,14 +360,19 @@ export function agentService(db: Db) {
|
||||
|
||||
update: updateAgent,
|
||||
|
||||
pause: async (id: string) => {
|
||||
pause: async (id: string, reason: "manual" | "budget" | "system" = "manual") => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status === "terminated") throw conflict("Cannot pause terminated agent");
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({ status: "paused", updatedAt: new Date() })
|
||||
.set({
|
||||
status: "paused",
|
||||
pauseReason: reason,
|
||||
pausedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
@@ -384,7 +389,12 @@ export function agentService(db: Db) {
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({ status: "idle", updatedAt: new Date() })
|
||||
.set({
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
@@ -397,7 +407,12 @@ export function agentService(db: Db) {
|
||||
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ status: "terminated", updatedAt: new Date() })
|
||||
.set({
|
||||
status: "terminated",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id));
|
||||
|
||||
await db
|
||||
|
||||
@@ -4,6 +4,7 @@ import { approvalComments, approvals } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { redactCurrentUserText } from "../log-redaction.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
import { notifyHireApproved } from "./hire-hook.js";
|
||||
|
||||
function redactApprovalComment<T extends { body: string }>(comment: T): T {
|
||||
@@ -15,6 +16,7 @@ function redactApprovalComment<T extends { body: string }>(comment: T): T {
|
||||
|
||||
export function approvalService(db: Db) {
|
||||
const agentsSvc = agentService(db);
|
||||
const budgets = budgetService(db);
|
||||
const canResolveStatuses = new Set(["pending", "revision_requested"]);
|
||||
const resolvableStatuses = Array.from(canResolveStatuses);
|
||||
type ApprovalRecord = typeof approvals.$inferSelect;
|
||||
@@ -137,6 +139,20 @@ export function approvalService(db: Db) {
|
||||
hireApprovedAgentId = created?.id ?? null;
|
||||
}
|
||||
if (hireApprovedAgentId) {
|
||||
const budgetMonthlyCents =
|
||||
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0;
|
||||
if (budgetMonthlyCents > 0) {
|
||||
await budgets.upsertPolicy(
|
||||
updated.companyId,
|
||||
{
|
||||
scopeType: "agent",
|
||||
scopeId: hireApprovedAgentId,
|
||||
amount: budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
decidedByUserId,
|
||||
);
|
||||
}
|
||||
void notifyHireApproved(db, {
|
||||
companyId: updated.companyId,
|
||||
agentId: hireApprovedAgentId,
|
||||
|
||||
919
server/src/services/budgets.ts
Normal file
919
server/src/services/budgets.ts
Normal file
@@ -0,0 +1,919 @@
|
||||
import { and, desc, eq, gte, inArray, lt, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
budgetIncidents,
|
||||
budgetPolicies,
|
||||
companies,
|
||||
costEvents,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
BudgetIncident,
|
||||
BudgetIncidentResolutionInput,
|
||||
BudgetMetric,
|
||||
BudgetOverview,
|
||||
BudgetPolicy,
|
||||
BudgetPolicySummary,
|
||||
BudgetPolicyUpsertInput,
|
||||
BudgetScopeType,
|
||||
BudgetThresholdType,
|
||||
BudgetWindowKind,
|
||||
} from "@paperclipai/shared";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
|
||||
type ScopeRecord = {
|
||||
companyId: string;
|
||||
name: string;
|
||||
paused: boolean;
|
||||
pauseReason: "manual" | "budget" | "system" | null;
|
||||
};
|
||||
|
||||
type PolicyRow = typeof budgetPolicies.$inferSelect;
|
||||
type IncidentRow = typeof budgetIncidents.$inferSelect;
|
||||
|
||||
function currentUtcMonthWindow(now = new Date()) {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
const start = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0));
|
||||
const end = new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function resolveWindow(windowKind: BudgetWindowKind, now = new Date()) {
|
||||
if (windowKind === "lifetime") {
|
||||
return {
|
||||
start: new Date(Date.UTC(1970, 0, 1, 0, 0, 0, 0)),
|
||||
end: new Date(Date.UTC(9999, 0, 1, 0, 0, 0, 0)),
|
||||
};
|
||||
}
|
||||
return currentUtcMonthWindow(now);
|
||||
}
|
||||
|
||||
function budgetStatusFromObserved(
|
||||
observedAmount: number,
|
||||
amount: number,
|
||||
warnPercent: number,
|
||||
): BudgetPolicySummary["status"] {
|
||||
if (amount <= 0) return "ok";
|
||||
if (observedAmount >= amount) return "hard_stop";
|
||||
if (observedAmount >= Math.ceil((amount * warnPercent) / 100)) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function normalizeScopeName(scopeType: BudgetScopeType, name: string) {
|
||||
if (scopeType === "company") return name;
|
||||
return name.trim().length > 0 ? name : scopeType;
|
||||
}
|
||||
|
||||
async function resolveScopeRecord(db: Db, scopeType: BudgetScopeType, scopeId: string): Promise<ScopeRecord> {
|
||||
if (scopeType === "company") {
|
||||
const row = await db
|
||||
.select({
|
||||
companyId: companies.id,
|
||||
name: companies.name,
|
||||
status: companies.status,
|
||||
})
|
||||
.from(companies)
|
||||
.where(eq(companies.id, scopeId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Company not found");
|
||||
return {
|
||||
companyId: row.companyId,
|
||||
name: row.name,
|
||||
paused: row.status === "paused",
|
||||
pauseReason: row.status === "paused" ? "budget" : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (scopeType === "agent") {
|
||||
const row = await db
|
||||
.select({
|
||||
companyId: agents.companyId,
|
||||
name: agents.name,
|
||||
status: agents.status,
|
||||
pauseReason: agents.pauseReason,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, scopeId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Agent not found");
|
||||
return {
|
||||
companyId: row.companyId,
|
||||
name: row.name,
|
||||
paused: row.status === "paused",
|
||||
pauseReason: (row.pauseReason as ScopeRecord["pauseReason"]) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const row = await db
|
||||
.select({
|
||||
companyId: projects.companyId,
|
||||
name: projects.name,
|
||||
pauseReason: projects.pauseReason,
|
||||
pausedAt: projects.pausedAt,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, scopeId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Project not found");
|
||||
return {
|
||||
companyId: row.companyId,
|
||||
name: row.name,
|
||||
paused: Boolean(row.pausedAt),
|
||||
pauseReason: (row.pauseReason as ScopeRecord["pauseReason"]) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function computeObservedAmount(
|
||||
db: Db,
|
||||
policy: Pick<PolicyRow, "companyId" | "scopeType" | "scopeId" | "windowKind" | "metric">,
|
||||
) {
|
||||
if (policy.metric !== "billed_cents") return 0;
|
||||
|
||||
const conditions = [eq(costEvents.companyId, policy.companyId)];
|
||||
if (policy.scopeType === "agent") conditions.push(eq(costEvents.agentId, policy.scopeId));
|
||||
if (policy.scopeType === "project") conditions.push(eq(costEvents.projectId, policy.scopeId));
|
||||
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
|
||||
if (policy.windowKind === "calendar_month_utc") {
|
||||
conditions.push(gte(costEvents.occurredAt, start));
|
||||
conditions.push(lt(costEvents.occurredAt, end));
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions));
|
||||
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
function buildApprovalPayload(input: {
|
||||
policy: PolicyRow;
|
||||
scopeName: string;
|
||||
thresholdType: BudgetThresholdType;
|
||||
amountObserved: number;
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
}) {
|
||||
return {
|
||||
scopeType: input.policy.scopeType,
|
||||
scopeId: input.policy.scopeId,
|
||||
scopeName: input.scopeName,
|
||||
metric: input.policy.metric,
|
||||
windowKind: input.policy.windowKind,
|
||||
thresholdType: input.thresholdType,
|
||||
budgetAmount: input.policy.amount,
|
||||
observedAmount: input.amountObserved,
|
||||
warnPercent: input.policy.warnPercent,
|
||||
windowStart: input.windowStart.toISOString(),
|
||||
windowEnd: input.windowEnd.toISOString(),
|
||||
policyId: input.policy.id,
|
||||
guidance: "Raise the budget and resume the scope, or keep the scope paused.",
|
||||
};
|
||||
}
|
||||
|
||||
async function markApprovalStatus(
|
||||
db: Db,
|
||||
approvalId: string | null,
|
||||
status: "approved" | "rejected",
|
||||
decisionNote: string | null | undefined,
|
||||
decidedByUserId: string,
|
||||
) {
|
||||
if (!approvalId) return;
|
||||
await db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedByUserId,
|
||||
decidedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(approvals.id, approvalId));
|
||||
}
|
||||
|
||||
export function budgetService(db: Db) {
|
||||
async function pauseScopeForBudget(policy: PolicyRow) {
|
||||
const now = new Date();
|
||||
if (policy.scopeType === "agent") {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(agents.id, policy.scopeId), inArray(agents.status, ["active", "idle", "running", "error"])));
|
||||
return;
|
||||
}
|
||||
|
||||
if (policy.scopeType === "project") {
|
||||
await db
|
||||
.update(projects)
|
||||
.set({
|
||||
pauseReason: "budget",
|
||||
pausedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(projects.id, policy.scopeId));
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(companies)
|
||||
.set({
|
||||
status: "paused",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companies.id, policy.scopeId));
|
||||
}
|
||||
|
||||
async function resumeScopeFromBudget(policy: PolicyRow) {
|
||||
const now = new Date();
|
||||
if (policy.scopeType === "agent") {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(agents.id, policy.scopeId), eq(agents.pauseReason, "budget")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (policy.scopeType === "project") {
|
||||
await db
|
||||
.update(projects)
|
||||
.set({
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(projects.id, policy.scopeId), eq(projects.pauseReason, "budget")));
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(companies)
|
||||
.set({
|
||||
status: "active",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companies.id, policy.scopeId));
|
||||
}
|
||||
|
||||
async function getPolicyRow(policyId: string) {
|
||||
const policy = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(eq(budgetPolicies.id, policyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!policy) throw notFound("Budget policy not found");
|
||||
return policy;
|
||||
}
|
||||
|
||||
async function listPolicyRows(companyId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(eq(budgetPolicies.companyId, companyId))
|
||||
.orderBy(desc(budgetPolicies.updatedAt));
|
||||
}
|
||||
|
||||
async function buildPolicySummary(policy: PolicyRow): Promise<BudgetPolicySummary> {
|
||||
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
|
||||
const observedAmount = await computeObservedAmount(db, policy);
|
||||
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
|
||||
const amount = policy.isActive ? policy.amount : 0;
|
||||
const utilizationPercent =
|
||||
amount > 0 ? Number(((observedAmount / amount) * 100).toFixed(2)) : 0;
|
||||
return {
|
||||
policyId: policy.id,
|
||||
companyId: policy.companyId,
|
||||
scopeType: policy.scopeType as BudgetScopeType,
|
||||
scopeId: policy.scopeId,
|
||||
scopeName: normalizeScopeName(policy.scopeType as BudgetScopeType, scope.name),
|
||||
metric: policy.metric as BudgetMetric,
|
||||
windowKind: policy.windowKind as BudgetWindowKind,
|
||||
amount,
|
||||
observedAmount,
|
||||
remainingAmount: amount > 0 ? Math.max(0, amount - observedAmount) : 0,
|
||||
utilizationPercent,
|
||||
warnPercent: policy.warnPercent,
|
||||
hardStopEnabled: policy.hardStopEnabled,
|
||||
notifyEnabled: policy.notifyEnabled,
|
||||
isActive: policy.isActive,
|
||||
status: policy.isActive
|
||||
? budgetStatusFromObserved(observedAmount, amount, policy.warnPercent)
|
||||
: "ok",
|
||||
paused: scope.paused,
|
||||
pauseReason: scope.pauseReason,
|
||||
windowStart: start,
|
||||
windowEnd: end,
|
||||
};
|
||||
}
|
||||
|
||||
async function createIncidentIfNeeded(
|
||||
policy: PolicyRow,
|
||||
thresholdType: BudgetThresholdType,
|
||||
amountObserved: number,
|
||||
) {
|
||||
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(budgetIncidents)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetIncidents.policyId, policy.id),
|
||||
eq(budgetIncidents.windowStart, start),
|
||||
eq(budgetIncidents.thresholdType, thresholdType),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return existing;
|
||||
|
||||
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
|
||||
const payload = buildApprovalPayload({
|
||||
policy,
|
||||
scopeName: normalizeScopeName(policy.scopeType as BudgetScopeType, scope.name),
|
||||
thresholdType,
|
||||
amountObserved,
|
||||
windowStart: start,
|
||||
windowEnd: end,
|
||||
});
|
||||
|
||||
const approval = thresholdType === "hard"
|
||||
? await db
|
||||
.insert(approvals)
|
||||
.values({
|
||||
companyId: policy.companyId,
|
||||
type: "budget_override_required",
|
||||
requestedByUserId: null,
|
||||
requestedByAgentId: null,
|
||||
status: "pending",
|
||||
payload,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
|
||||
return db
|
||||
.insert(budgetIncidents)
|
||||
.values({
|
||||
companyId: policy.companyId,
|
||||
policyId: policy.id,
|
||||
scopeType: policy.scopeType,
|
||||
scopeId: policy.scopeId,
|
||||
metric: policy.metric,
|
||||
windowKind: policy.windowKind,
|
||||
windowStart: start,
|
||||
windowEnd: end,
|
||||
thresholdType,
|
||||
amountLimit: policy.amount,
|
||||
amountObserved,
|
||||
status: "open",
|
||||
approvalId: approval?.id ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function resolveOpenSoftIncidents(policyId: string) {
|
||||
await db
|
||||
.update(budgetIncidents)
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(budgetIncidents.policyId, policyId),
|
||||
eq(budgetIncidents.thresholdType, "soft"),
|
||||
eq(budgetIncidents.status, "open"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveOpenIncidentsForPolicy(
|
||||
policyId: string,
|
||||
approvalStatus: "approved" | "rejected" | null,
|
||||
decidedByUserId: string | null,
|
||||
) {
|
||||
const openRows = await db
|
||||
.select()
|
||||
.from(budgetIncidents)
|
||||
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
|
||||
|
||||
await db
|
||||
.update(budgetIncidents)
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
|
||||
|
||||
if (!approvalStatus || !decidedByUserId) return;
|
||||
for (const row of openRows) {
|
||||
await markApprovalStatus(db, row.approvalId ?? null, approvalStatus, "Resolved via budget update", decidedByUserId);
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateIncidentRows(rows: IncidentRow[]): Promise<BudgetIncident[]> {
|
||||
const approvalIds = rows.map((row) => row.approvalId).filter((value): value is string => Boolean(value));
|
||||
const approvalRows = approvalIds.length > 0
|
||||
? await db
|
||||
.select({ id: approvals.id, status: approvals.status })
|
||||
.from(approvals)
|
||||
.where(inArray(approvals.id, approvalIds))
|
||||
: [];
|
||||
const approvalStatusById = new Map(approvalRows.map((row) => [row.id, row.status]));
|
||||
|
||||
return Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const scope = await resolveScopeRecord(db, row.scopeType as BudgetScopeType, row.scopeId);
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
policyId: row.policyId,
|
||||
scopeType: row.scopeType as BudgetScopeType,
|
||||
scopeId: row.scopeId,
|
||||
scopeName: normalizeScopeName(row.scopeType as BudgetScopeType, scope.name),
|
||||
metric: row.metric as BudgetMetric,
|
||||
windowKind: row.windowKind as BudgetWindowKind,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
thresholdType: row.thresholdType as BudgetThresholdType,
|
||||
amountLimit: row.amountLimit,
|
||||
amountObserved: row.amountObserved,
|
||||
status: row.status as BudgetIncident["status"],
|
||||
approvalId: row.approvalId ?? null,
|
||||
approvalStatus: row.approvalId ? approvalStatusById.get(row.approvalId) ?? null : null,
|
||||
resolvedAt: row.resolvedAt ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
listPolicies: async (companyId: string): Promise<BudgetPolicy[]> => {
|
||||
const rows = await listPolicyRows(companyId);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
scopeType: row.scopeType as BudgetScopeType,
|
||||
metric: row.metric as BudgetMetric,
|
||||
windowKind: row.windowKind as BudgetWindowKind,
|
||||
}));
|
||||
},
|
||||
|
||||
upsertPolicy: async (
|
||||
companyId: string,
|
||||
input: BudgetPolicyUpsertInput,
|
||||
actorUserId: string | null,
|
||||
): Promise<BudgetPolicySummary> => {
|
||||
const scope = await resolveScopeRecord(db, input.scopeType, input.scopeId);
|
||||
if (scope.companyId !== companyId) {
|
||||
throw unprocessable("Budget scope does not belong to company");
|
||||
}
|
||||
|
||||
const metric = input.metric ?? "billed_cents";
|
||||
const windowKind = input.windowKind ?? (input.scopeType === "project" ? "lifetime" : "calendar_month_utc");
|
||||
const amount = Math.max(0, Math.floor(input.amount));
|
||||
const nextIsActive = amount > 0 && (input.isActive ?? true);
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, companyId),
|
||||
eq(budgetPolicies.scopeType, input.scopeType),
|
||||
eq(budgetPolicies.scopeId, input.scopeId),
|
||||
eq(budgetPolicies.metric, metric),
|
||||
eq(budgetPolicies.windowKind, windowKind),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
const now = new Date();
|
||||
const row = existing
|
||||
? await db
|
||||
.update(budgetPolicies)
|
||||
.set({
|
||||
amount,
|
||||
warnPercent: input.warnPercent ?? existing.warnPercent,
|
||||
hardStopEnabled: input.hardStopEnabled ?? existing.hardStopEnabled,
|
||||
notifyEnabled: input.notifyEnabled ?? existing.notifyEnabled,
|
||||
isActive: nextIsActive,
|
||||
updatedByUserId: actorUserId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(budgetPolicies.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0])
|
||||
: await db
|
||||
.insert(budgetPolicies)
|
||||
.values({
|
||||
companyId,
|
||||
scopeType: input.scopeType,
|
||||
scopeId: input.scopeId,
|
||||
metric,
|
||||
windowKind,
|
||||
amount,
|
||||
warnPercent: input.warnPercent ?? 80,
|
||||
hardStopEnabled: input.hardStopEnabled ?? true,
|
||||
notifyEnabled: input.notifyEnabled ?? true,
|
||||
isActive: nextIsActive,
|
||||
createdByUserId: actorUserId,
|
||||
updatedByUserId: actorUserId,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (input.scopeType === "company" && windowKind === "calendar_month_utc") {
|
||||
await db
|
||||
.update(companies)
|
||||
.set({
|
||||
budgetMonthlyCents: amount,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companies.id, input.scopeId));
|
||||
}
|
||||
|
||||
if (input.scopeType === "agent" && windowKind === "calendar_month_utc") {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
budgetMonthlyCents: amount,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(agents.id, input.scopeId));
|
||||
}
|
||||
|
||||
if (amount > 0) {
|
||||
const observedAmount = await computeObservedAmount(db, row);
|
||||
if (observedAmount < amount) {
|
||||
await resumeScopeFromBudget(row);
|
||||
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
|
||||
} else {
|
||||
const softThreshold = Math.ceil((row.amount * row.warnPercent) / 100);
|
||||
if (row.notifyEnabled && observedAmount >= softThreshold) {
|
||||
await createIncidentIfNeeded(row, "soft", observedAmount);
|
||||
}
|
||||
if (row.hardStopEnabled && observedAmount >= row.amount) {
|
||||
await resolveOpenSoftIncidents(row.id);
|
||||
await createIncidentIfNeeded(row, "hard", observedAmount);
|
||||
await pauseScopeForBudget(row);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await resumeScopeFromBudget(row);
|
||||
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: actorUserId ?? "board",
|
||||
action: "budget.policy_upserted",
|
||||
entityType: "budget_policy",
|
||||
entityId: row.id,
|
||||
details: {
|
||||
scopeType: row.scopeType,
|
||||
scopeId: row.scopeId,
|
||||
amount: row.amount,
|
||||
windowKind: row.windowKind,
|
||||
},
|
||||
});
|
||||
|
||||
return buildPolicySummary(row);
|
||||
},
|
||||
|
||||
overview: async (companyId: string): Promise<BudgetOverview> => {
|
||||
const rows = await listPolicyRows(companyId);
|
||||
const policies = await Promise.all(rows.map((row) => buildPolicySummary(row)));
|
||||
const activeIncidentRows = await db
|
||||
.select()
|
||||
.from(budgetIncidents)
|
||||
.where(and(eq(budgetIncidents.companyId, companyId), eq(budgetIncidents.status, "open")))
|
||||
.orderBy(desc(budgetIncidents.createdAt));
|
||||
const activeIncidents = await hydrateIncidentRows(activeIncidentRows);
|
||||
return {
|
||||
companyId,
|
||||
policies,
|
||||
activeIncidents,
|
||||
pausedAgentCount: policies.filter((policy) => policy.scopeType === "agent" && policy.paused).length,
|
||||
pausedProjectCount: policies.filter((policy) => policy.scopeType === "project" && policy.paused).length,
|
||||
pendingApprovalCount: activeIncidents.filter((incident) => incident.approvalStatus === "pending").length,
|
||||
};
|
||||
},
|
||||
|
||||
evaluateCostEvent: async (event: typeof costEvents.$inferSelect) => {
|
||||
const candidatePolicies = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, event.companyId),
|
||||
eq(budgetPolicies.isActive, true),
|
||||
inArray(budgetPolicies.scopeType, ["company", "agent", "project"]),
|
||||
),
|
||||
);
|
||||
|
||||
const relevantPolicies = candidatePolicies.filter((policy) => {
|
||||
if (policy.scopeType === "company") return policy.scopeId === event.companyId;
|
||||
if (policy.scopeType === "agent") return policy.scopeId === event.agentId;
|
||||
if (policy.scopeType === "project") return Boolean(event.projectId) && policy.scopeId === event.projectId;
|
||||
return false;
|
||||
});
|
||||
|
||||
for (const policy of relevantPolicies) {
|
||||
if (policy.metric !== "billed_cents" || policy.amount <= 0) continue;
|
||||
const observedAmount = await computeObservedAmount(db, policy);
|
||||
const softThreshold = Math.ceil((policy.amount * policy.warnPercent) / 100);
|
||||
|
||||
if (policy.notifyEnabled && observedAmount >= softThreshold) {
|
||||
const softIncident = await createIncidentIfNeeded(policy, "soft", observedAmount);
|
||||
if (softIncident) {
|
||||
await logActivity(db, {
|
||||
companyId: policy.companyId,
|
||||
actorType: "system",
|
||||
actorId: "budget_service",
|
||||
action: "budget.soft_threshold_crossed",
|
||||
entityType: "budget_incident",
|
||||
entityId: softIncident.id,
|
||||
details: {
|
||||
scopeType: policy.scopeType,
|
||||
scopeId: policy.scopeId,
|
||||
amountObserved: observedAmount,
|
||||
amountLimit: policy.amount,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (policy.hardStopEnabled && observedAmount >= policy.amount) {
|
||||
await resolveOpenSoftIncidents(policy.id);
|
||||
const hardIncident = await createIncidentIfNeeded(policy, "hard", observedAmount);
|
||||
await pauseScopeForBudget(policy);
|
||||
if (hardIncident) {
|
||||
await logActivity(db, {
|
||||
companyId: policy.companyId,
|
||||
actorType: "system",
|
||||
actorId: "budget_service",
|
||||
action: "budget.hard_threshold_crossed",
|
||||
entityType: "budget_incident",
|
||||
entityId: hardIncident.id,
|
||||
details: {
|
||||
scopeType: policy.scopeType,
|
||||
scopeId: policy.scopeId,
|
||||
amountObserved: observedAmount,
|
||||
amountLimit: policy.amount,
|
||||
approvalId: hardIncident.approvalId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getInvocationBlock: async (
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
context?: { issueId?: string | null; projectId?: string | null },
|
||||
) => {
|
||||
const agent = await db
|
||||
.select({
|
||||
status: agents.status,
|
||||
pauseReason: agents.pauseReason,
|
||||
companyId: agents.companyId,
|
||||
name: agents.name,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!agent || agent.companyId !== companyId) throw notFound("Agent not found");
|
||||
|
||||
const company = await db
|
||||
.select({
|
||||
status: companies.status,
|
||||
name: companies.name,
|
||||
})
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!company) throw notFound("Company not found");
|
||||
if (company.status === "paused") {
|
||||
return {
|
||||
scopeType: "company" as const,
|
||||
scopeId: companyId,
|
||||
scopeName: company.name,
|
||||
reason: "Company is paused and cannot start new work.",
|
||||
};
|
||||
}
|
||||
|
||||
const companyPolicy = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, companyId),
|
||||
eq(budgetPolicies.scopeType, "company"),
|
||||
eq(budgetPolicies.scopeId, companyId),
|
||||
eq(budgetPolicies.isActive, true),
|
||||
eq(budgetPolicies.metric, "billed_cents"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (companyPolicy && companyPolicy.hardStopEnabled && companyPolicy.amount > 0) {
|
||||
const observed = await computeObservedAmount(db, companyPolicy);
|
||||
if (observed >= companyPolicy.amount) {
|
||||
return {
|
||||
scopeType: "company" as const,
|
||||
scopeId: companyId,
|
||||
scopeName: company.name,
|
||||
reason: "Company cannot start new work because its budget hard-stop is exceeded.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (agent.status === "paused" && agent.pauseReason === "budget") {
|
||||
return {
|
||||
scopeType: "agent" as const,
|
||||
scopeId: agentId,
|
||||
scopeName: agent.name,
|
||||
reason: "Agent is paused because its budget hard-stop was reached.",
|
||||
};
|
||||
}
|
||||
|
||||
const agentPolicy = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, companyId),
|
||||
eq(budgetPolicies.scopeType, "agent"),
|
||||
eq(budgetPolicies.scopeId, agentId),
|
||||
eq(budgetPolicies.isActive, true),
|
||||
eq(budgetPolicies.metric, "billed_cents"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (agentPolicy && agentPolicy.hardStopEnabled && agentPolicy.amount > 0) {
|
||||
const observed = await computeObservedAmount(db, agentPolicy);
|
||||
if (observed >= agentPolicy.amount) {
|
||||
return {
|
||||
scopeType: "agent" as const,
|
||||
scopeId: agentId,
|
||||
scopeName: agent.name,
|
||||
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const candidateProjectId = context?.projectId ?? null;
|
||||
if (!candidateProjectId) return null;
|
||||
|
||||
const project = await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
companyId: projects.companyId,
|
||||
pauseReason: projects.pauseReason,
|
||||
pausedAt: projects.pausedAt,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, candidateProjectId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!project || project.companyId !== companyId) return null;
|
||||
const projectPolicy = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, companyId),
|
||||
eq(budgetPolicies.scopeType, "project"),
|
||||
eq(budgetPolicies.scopeId, project.id),
|
||||
eq(budgetPolicies.isActive, true),
|
||||
eq(budgetPolicies.metric, "billed_cents"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (projectPolicy && projectPolicy.hardStopEnabled && projectPolicy.amount > 0) {
|
||||
const observed = await computeObservedAmount(db, projectPolicy);
|
||||
if (observed >= projectPolicy.amount) {
|
||||
return {
|
||||
scopeType: "project" as const,
|
||||
scopeId: project.id,
|
||||
scopeName: project.name,
|
||||
reason: "Project cannot start work because its budget hard-stop is still exceeded.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!project.pausedAt || project.pauseReason !== "budget") return null;
|
||||
return {
|
||||
scopeType: "project" as const,
|
||||
scopeId: project.id,
|
||||
scopeName: project.name,
|
||||
reason: "Project is paused because its budget hard-stop was reached.",
|
||||
};
|
||||
},
|
||||
|
||||
resolveIncident: async (
|
||||
companyId: string,
|
||||
incidentId: string,
|
||||
input: BudgetIncidentResolutionInput,
|
||||
actorUserId: string,
|
||||
): Promise<BudgetIncident> => {
|
||||
const incident = await db
|
||||
.select()
|
||||
.from(budgetIncidents)
|
||||
.where(eq(budgetIncidents.id, incidentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!incident) throw notFound("Budget incident not found");
|
||||
if (incident.companyId !== companyId) throw notFound("Budget incident not found");
|
||||
|
||||
const policy = await getPolicyRow(incident.policyId);
|
||||
if (input.action === "raise_budget_and_resume") {
|
||||
const nextAmount = Math.max(0, Math.floor(input.amount ?? 0));
|
||||
if (nextAmount <= incident.amountObserved) {
|
||||
throw unprocessable("New budget must exceed current observed spend");
|
||||
}
|
||||
|
||||
await db
|
||||
.update(budgetPolicies)
|
||||
.set({
|
||||
amount: nextAmount,
|
||||
isActive: true,
|
||||
updatedByUserId: actorUserId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(budgetPolicies.id, policy.id));
|
||||
|
||||
if (policy.scopeType === "agent" && policy.windowKind === "calendar_month_utc") {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ budgetMonthlyCents: nextAmount, updatedAt: new Date() })
|
||||
.where(eq(agents.id, policy.scopeId));
|
||||
}
|
||||
|
||||
await resumeScopeFromBudget(policy);
|
||||
await db
|
||||
.update(budgetIncidents)
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(budgetIncidents.policyId, policy.id), eq(budgetIncidents.status, "open")));
|
||||
|
||||
await markApprovalStatus(db, incident.approvalId ?? null, "approved", input.decisionNote, actorUserId);
|
||||
} else {
|
||||
await db
|
||||
.update(budgetIncidents)
|
||||
.set({
|
||||
status: "dismissed",
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(budgetIncidents.id, incident.id));
|
||||
await markApprovalStatus(db, incident.approvalId ?? null, "rejected", input.decisionNote, actorUserId);
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: incident.companyId,
|
||||
actorType: "user",
|
||||
actorId: actorUserId,
|
||||
action: "budget.incident_resolved",
|
||||
entityType: "budget_incident",
|
||||
entityId: incident.id,
|
||||
details: {
|
||||
action: input.action,
|
||||
amount: input.amount ?? null,
|
||||
scopeType: incident.scopeType,
|
||||
scopeId: incident.scopeId,
|
||||
},
|
||||
});
|
||||
|
||||
const [updated] = await hydrateIncidentRows([{
|
||||
...incident,
|
||||
status: input.action === "raise_budget_and_resume" ? "resolved" : "dismissed",
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}]);
|
||||
return updated!;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
heartbeatRuns,
|
||||
heartbeatRunEvents,
|
||||
costEvents,
|
||||
financeEvents,
|
||||
approvalComments,
|
||||
approvals,
|
||||
activityLog,
|
||||
@@ -206,6 +207,7 @@ export function companyService(db: Db) {
|
||||
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
|
||||
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
|
||||
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
|
||||
await tx.delete(financeEvents).where(eq(financeEvents.companyId, id));
|
||||
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
|
||||
await tx.delete(approvals).where(eq(approvals.companyId, id));
|
||||
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { and, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { activityLog, agents, companies, costEvents, issues, projects } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
|
||||
export interface CostDateRange {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
const METERED_BILLING_TYPE = "metered_api";
|
||||
const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const;
|
||||
|
||||
export function costService(db: Db) {
|
||||
const budgets = budgetService(db);
|
||||
return {
|
||||
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
|
||||
const agent = await db
|
||||
@@ -24,7 +29,13 @@ export function costService(db: Db) {
|
||||
|
||||
const event = await db
|
||||
.insert(costEvents)
|
||||
.values({ ...data, companyId })
|
||||
.values({
|
||||
...data,
|
||||
companyId,
|
||||
biller: data.biller ?? data.provider,
|
||||
billingType: data.billingType ?? "unknown",
|
||||
cachedInputTokens: data.cachedInputTokens ?? 0,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
@@ -63,6 +74,8 @@ export function costService(db: Db) {
|
||||
.where(eq(agents.id, updatedAgent.id));
|
||||
}
|
||||
|
||||
await budgets.evaluateCostEvent(event);
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
@@ -105,52 +118,31 @@ export function costService(db: Db) {
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
const costRows = await db
|
||||
return db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
agentName: agents.name,
|
||||
agentStatus: agents.status,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
apiRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionCachedInputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
|
||||
subscriptionInputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
|
||||
subscriptionOutputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.leftJoin(agents, eq(costEvents.agentId, agents.id))
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.agentId, agents.name, agents.status)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||
|
||||
const runConditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
|
||||
if (range?.from) runConditions.push(gte(heartbeatRuns.startedAt, range.from));
|
||||
if (range?.to) runConditions.push(lte(heartbeatRuns.startedAt, range.to));
|
||||
|
||||
const runRows = await db
|
||||
.select({
|
||||
agentId: heartbeatRuns.agentId,
|
||||
apiRunCount:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'api' then 1 else 0 end), 0)::int`,
|
||||
subscriptionRunCount:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then 1 else 0 end), 0)::int`,
|
||||
subscriptionInputTokens:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0) else 0 end), 0)::int`,
|
||||
subscriptionOutputTokens:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0) else 0 end), 0)::int`,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(and(...runConditions))
|
||||
.groupBy(heartbeatRuns.agentId);
|
||||
|
||||
const runRowsByAgent = new Map(runRows.map((row) => [row.agentId, row]));
|
||||
return costRows.map((row) => {
|
||||
const runRow = runRowsByAgent.get(row.agentId);
|
||||
return {
|
||||
...row,
|
||||
apiRunCount: runRow?.apiRunCount ?? 0,
|
||||
subscriptionRunCount: runRow?.subscriptionRunCount ?? 0,
|
||||
subscriptionInputTokens: runRow?.subscriptionInputTokens ?? 0,
|
||||
subscriptionOutputTokens: runRow?.subscriptionOutputTokens ?? 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
byProvider: async (companyId: string, range?: CostDateRange) => {
|
||||
@@ -158,68 +150,62 @@ export function costService(db: Db) {
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
const costRows = await db
|
||||
return db
|
||||
.select({
|
||||
provider: costEvents.provider,
|
||||
biller: costEvents.biller,
|
||||
billingType: costEvents.billingType,
|
||||
model: costEvents.model,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
apiRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionCachedInputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
|
||||
subscriptionInputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
|
||||
subscriptionOutputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.provider, costEvents.model)
|
||||
.groupBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||
},
|
||||
|
||||
const runConditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
|
||||
if (range?.from) runConditions.push(gte(heartbeatRuns.startedAt, range.from));
|
||||
if (range?.to) runConditions.push(lte(heartbeatRuns.startedAt, range.to));
|
||||
byBiller: async (companyId: string, range?: CostDateRange) => {
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
const runRows = await db
|
||||
return db
|
||||
.select({
|
||||
agentId: heartbeatRuns.agentId,
|
||||
biller: costEvents.biller,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
apiRunCount:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'api' then 1 else 0 end), 0)::int`,
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionRunCount:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then 1 else 0 end), 0)::int`,
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionCachedInputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
|
||||
subscriptionInputTokens:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0) else 0 end), 0)::int`,
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
|
||||
subscriptionOutputTokens:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0) else 0 end), 0)::int`,
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
|
||||
providerCount: sql<number>`count(distinct ${costEvents.provider})::int`,
|
||||
modelCount: sql<number>`count(distinct ${costEvents.model})::int`,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(and(...runConditions))
|
||||
.groupBy(heartbeatRuns.agentId);
|
||||
|
||||
// aggregate run billing splits across all agents (runs don't carry model info so we can't go per-model)
|
||||
const totals = runRows.reduce(
|
||||
(acc, r) => ({
|
||||
apiRunCount: acc.apiRunCount + r.apiRunCount,
|
||||
subscriptionRunCount: acc.subscriptionRunCount + r.subscriptionRunCount,
|
||||
subscriptionInputTokens: acc.subscriptionInputTokens + r.subscriptionInputTokens,
|
||||
subscriptionOutputTokens: acc.subscriptionOutputTokens + r.subscriptionOutputTokens,
|
||||
}),
|
||||
{ apiRunCount: 0, subscriptionRunCount: 0, subscriptionInputTokens: 0, subscriptionOutputTokens: 0 },
|
||||
);
|
||||
|
||||
// pro-rate billing split across models by token share
|
||||
const totalTokens = costRows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
|
||||
|
||||
return costRows.map((row) => {
|
||||
const rowTokens = row.inputTokens + row.outputTokens;
|
||||
const share = totalTokens > 0 ? rowTokens / totalTokens : 0;
|
||||
return {
|
||||
provider: row.provider,
|
||||
model: row.model,
|
||||
costCents: row.costCents,
|
||||
inputTokens: row.inputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
apiRunCount: Math.round(totals.apiRunCount * share),
|
||||
subscriptionRunCount: Math.round(totals.subscriptionRunCount * share),
|
||||
subscriptionInputTokens: Math.round(totals.subscriptionInputTokens * share),
|
||||
subscriptionOutputTokens: Math.round(totals.subscriptionOutputTokens * share),
|
||||
};
|
||||
});
|
||||
.from(costEvents)
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.biller)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -240,8 +226,10 @@ export function costService(db: Db) {
|
||||
const rows = await db
|
||||
.select({
|
||||
provider: costEvents.provider,
|
||||
biller: sql<string>`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
@@ -256,10 +244,12 @@ export function costService(db: Db) {
|
||||
|
||||
return rows.map((row) => ({
|
||||
provider: row.provider,
|
||||
biller: row.biller,
|
||||
window: label as string,
|
||||
windowHours: hours,
|
||||
costCents: row.costCents,
|
||||
inputTokens: row.inputTokens,
|
||||
cachedInputTokens: row.cachedInputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
}));
|
||||
}),
|
||||
@@ -282,16 +272,26 @@ export function costService(db: Db) {
|
||||
agentId: costEvents.agentId,
|
||||
agentName: agents.name,
|
||||
provider: costEvents.provider,
|
||||
biller: costEvents.biller,
|
||||
billingType: costEvents.billingType,
|
||||
model: costEvents.model,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.leftJoin(agents, eq(costEvents.agentId, agents.id))
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.agentId, agents.name, costEvents.provider, costEvents.model)
|
||||
.orderBy(costEvents.provider, costEvents.model);
|
||||
.groupBy(
|
||||
costEvents.agentId,
|
||||
agents.name,
|
||||
costEvents.provider,
|
||||
costEvents.biller,
|
||||
costEvents.billingType,
|
||||
costEvents.model,
|
||||
)
|
||||
.orderBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model);
|
||||
},
|
||||
|
||||
byProject: async (companyId: string, range?: CostDateRange) => {
|
||||
@@ -320,25 +320,27 @@ export function costService(db: Db) {
|
||||
.orderBy(activityLog.runId, issues.projectId, desc(activityLog.createdAt))
|
||||
.as("run_project_links");
|
||||
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(heartbeatRuns.startedAt, range.from));
|
||||
if (range?.to) conditions.push(lte(heartbeatRuns.startedAt, range.to));
|
||||
const effectiveProjectId = sql<string | null>`coalesce(${costEvents.projectId}, ${runProjectLinks.projectId})`;
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
const costCentsExpr = sql<number>`coalesce(sum(round(coalesce((${heartbeatRuns.usageJson} ->> 'costUsd')::numeric, 0) * 100)), 0)::int`;
|
||||
const costCentsExpr = sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`;
|
||||
|
||||
return db
|
||||
.select({
|
||||
projectId: runProjectLinks.projectId,
|
||||
projectId: effectiveProjectId,
|
||||
projectName: projects.name,
|
||||
costCents: costCentsExpr,
|
||||
inputTokens: sql<number>`coalesce(sum(coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0)), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0)), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
})
|
||||
.from(runProjectLinks)
|
||||
.innerJoin(heartbeatRuns, eq(runProjectLinks.runId, heartbeatRuns.id))
|
||||
.innerJoin(projects, eq(runProjectLinks.projectId, projects.id))
|
||||
.where(and(...conditions))
|
||||
.groupBy(runProjectLinks.projectId, projects.name)
|
||||
.from(costEvents)
|
||||
.leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId))
|
||||
.innerJoin(projects, sql`${projects.id} = ${effectiveProjectId}`)
|
||||
.where(and(...conditions, sql`${effectiveProjectId} is not null`))
|
||||
.groupBy(effectiveProjectId, projects.name)
|
||||
.orderBy(desc(costCentsExpr));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,8 +2,10 @@ import { and, eq, gte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, approvals, companies, costEvents, issues } from "@paperclipai/db";
|
||||
import { notFound } from "../errors.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
|
||||
export function dashboardService(db: Db) {
|
||||
const budgets = budgetService(db);
|
||||
return {
|
||||
summary: async (companyId: string) => {
|
||||
const company = await db
|
||||
@@ -78,6 +80,7 @@ export function dashboardService(db: Db) {
|
||||
company.budgetMonthlyCents > 0
|
||||
? (monthSpendCents / company.budgetMonthlyCents) * 100
|
||||
: 0;
|
||||
const budgetOverview = await budgets.overview(companyId);
|
||||
|
||||
return {
|
||||
companyId,
|
||||
@@ -94,6 +97,12 @@ export function dashboardService(db: Db) {
|
||||
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
||||
},
|
||||
pendingApprovals,
|
||||
budgets: {
|
||||
activeIncidents: budgetOverview.activeIncidents.length,
|
||||
pendingApprovals: budgetOverview.pendingApprovalCount,
|
||||
pausedAgents: budgetOverview.pausedAgentCount,
|
||||
pausedProjects: budgetOverview.pausedProjectCount,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
134
server/src/services/finance.ts
Normal file
134
server/src/services/finance.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, costEvents, financeEvents, goals, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export interface FinanceDateRange {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
async function assertBelongsToCompany(
|
||||
db: Db,
|
||||
table: any,
|
||||
id: string,
|
||||
companyId: string,
|
||||
label: string,
|
||||
) {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!row) throw notFound(`${label} not found`);
|
||||
if ((row as unknown as { companyId: string }).companyId !== companyId) {
|
||||
throw unprocessable(`${label} does not belong to company`);
|
||||
}
|
||||
}
|
||||
|
||||
function rangeConditions(companyId: string, range?: FinanceDateRange) {
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(financeEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(financeEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(financeEvents.occurredAt, range.to));
|
||||
return conditions;
|
||||
}
|
||||
|
||||
export function financeService(db: Db) {
|
||||
const debitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`;
|
||||
const creditExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`;
|
||||
const estimatedDebitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::int`;
|
||||
|
||||
return {
|
||||
createEvent: async (companyId: string, data: Omit<typeof financeEvents.$inferInsert, "companyId">) => {
|
||||
if (data.agentId) await assertBelongsToCompany(db, agents, data.agentId, companyId, "Agent");
|
||||
if (data.issueId) await assertBelongsToCompany(db, issues, data.issueId, companyId, "Issue");
|
||||
if (data.projectId) await assertBelongsToCompany(db, projects, data.projectId, companyId, "Project");
|
||||
if (data.goalId) await assertBelongsToCompany(db, goals, data.goalId, companyId, "Goal");
|
||||
if (data.heartbeatRunId) await assertBelongsToCompany(db, heartbeatRuns, data.heartbeatRunId, companyId, "Heartbeat run");
|
||||
if (data.costEventId) await assertBelongsToCompany(db, costEvents, data.costEventId, companyId, "Cost event");
|
||||
|
||||
const event = await db
|
||||
.insert(financeEvents)
|
||||
.values({
|
||||
...data,
|
||||
companyId,
|
||||
currency: data.currency ?? "USD",
|
||||
direction: data.direction ?? "debit",
|
||||
estimated: data.estimated ?? false,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
summary: async (companyId: string, range?: FinanceDateRange) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
const [row] = await db
|
||||
.select({
|
||||
debitCents: debitExpr,
|
||||
creditCents: creditExpr,
|
||||
estimatedDebitCents: estimatedDebitExpr,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions));
|
||||
|
||||
return {
|
||||
companyId,
|
||||
debitCents: Number(row?.debitCents ?? 0),
|
||||
creditCents: Number(row?.creditCents ?? 0),
|
||||
netCents: Number(row?.debitCents ?? 0) - Number(row?.creditCents ?? 0),
|
||||
estimatedDebitCents: Number(row?.estimatedDebitCents ?? 0),
|
||||
eventCount: Number(row?.eventCount ?? 0),
|
||||
};
|
||||
},
|
||||
|
||||
byBiller: async (companyId: string, range?: FinanceDateRange) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
return db
|
||||
.select({
|
||||
biller: financeEvents.biller,
|
||||
debitCents: debitExpr,
|
||||
creditCents: creditExpr,
|
||||
estimatedDebitCents: estimatedDebitExpr,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
kindCount: sql<number>`count(distinct ${financeEvents.eventKind})::int`,
|
||||
netCents: sql<number>`(${debitExpr} - ${creditExpr})::int`,
|
||||
})
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions))
|
||||
.groupBy(financeEvents.biller)
|
||||
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.biller);
|
||||
},
|
||||
|
||||
byKind: async (companyId: string, range?: FinanceDateRange) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
return db
|
||||
.select({
|
||||
eventKind: financeEvents.eventKind,
|
||||
debitCents: debitExpr,
|
||||
creditCents: creditExpr,
|
||||
estimatedDebitCents: estimatedDebitExpr,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
billerCount: sql<number>`count(distinct ${financeEvents.biller})::int`,
|
||||
netCents: sql<number>`(${debitExpr} - ${creditExpr})::int`,
|
||||
})
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions))
|
||||
.groupBy(financeEvents.eventKind)
|
||||
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.eventKind);
|
||||
},
|
||||
|
||||
list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
return db
|
||||
.select()
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(financeEvents.occurredAt), desc(financeEvents.createdAt))
|
||||
.limit(limit);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { BillingType } from "@paperclipai/shared";
|
||||
import {
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
@@ -22,6 +23,7 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||
import { costService } from "./costs.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
||||
@@ -170,6 +172,67 @@ function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function normalizeLedgerBillingType(value: unknown): BillingType {
|
||||
const raw = readNonEmptyString(value);
|
||||
switch (raw) {
|
||||
case "api":
|
||||
case "metered_api":
|
||||
return "metered_api";
|
||||
case "subscription":
|
||||
case "subscription_included":
|
||||
return "subscription_included";
|
||||
case "subscription_overage":
|
||||
return "subscription_overage";
|
||||
case "credits":
|
||||
return "credits";
|
||||
case "fixed":
|
||||
return "fixed";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLedgerBiller(result: AdapterExecutionResult): string {
|
||||
return readNonEmptyString(result.biller) ?? readNonEmptyString(result.provider) ?? "unknown";
|
||||
}
|
||||
|
||||
function normalizeBilledCostCents(costUsd: number | null | undefined, billingType: BillingType): number {
|
||||
if (billingType === "subscription_included") return 0;
|
||||
if (typeof costUsd !== "number" || !Number.isFinite(costUsd)) return 0;
|
||||
return Math.max(0, Math.round(costUsd * 100));
|
||||
}
|
||||
|
||||
async function resolveLedgerScopeForRun(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
) {
|
||||
const context = parseObject(run.contextSnapshot);
|
||||
const contextIssueId = readNonEmptyString(context.issueId);
|
||||
const contextProjectId = readNonEmptyString(context.projectId);
|
||||
|
||||
if (!contextIssueId) {
|
||||
return {
|
||||
issueId: null,
|
||||
projectId: contextProjectId,
|
||||
};
|
||||
}
|
||||
|
||||
const issue = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
projectId: issues.projectId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, contextIssueId), eq(issues.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
return {
|
||||
issueId: issue?.id ?? null,
|
||||
projectId: issue?.projectId ?? contextProjectId,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null {
|
||||
if (!usage) return null;
|
||||
return {
|
||||
@@ -554,6 +617,7 @@ function resolveNextSessionState(input: {
|
||||
|
||||
export function heartbeatService(db: Db) {
|
||||
const runLogStore = getRunLogStore();
|
||||
const budgets = budgetService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
const issuesSvc = issueService(db);
|
||||
const activeRunExecutions = new Set<string>();
|
||||
@@ -1294,8 +1358,12 @@ export function heartbeatService(db: Db) {
|
||||
const inputTokens = usage?.inputTokens ?? 0;
|
||||
const outputTokens = usage?.outputTokens ?? 0;
|
||||
const cachedInputTokens = usage?.cachedInputTokens ?? 0;
|
||||
const additionalCostCents = Math.max(0, Math.round((result.costUsd ?? 0) * 100));
|
||||
const billingType = normalizeLedgerBillingType(result.billingType);
|
||||
const additionalCostCents = normalizeBilledCostCents(result.costUsd, billingType);
|
||||
const hasTokenUsage = inputTokens > 0 || outputTokens > 0 || cachedInputTokens > 0;
|
||||
const provider = result.provider ?? "unknown";
|
||||
const biller = resolveLedgerBiller(result);
|
||||
const ledgerScope = await resolveLedgerScopeForRun(db, agent.companyId, run);
|
||||
|
||||
await db
|
||||
.update(agentRuntimeState)
|
||||
@@ -1316,10 +1384,16 @@ export function heartbeatService(db: Db) {
|
||||
if (additionalCostCents > 0 || hasTokenUsage) {
|
||||
const costs = costService(db);
|
||||
await costs.createEvent(agent.companyId, {
|
||||
heartbeatRunId: run.id,
|
||||
agentId: agent.id,
|
||||
provider: result.provider ?? "unknown",
|
||||
issueId: ledgerScope.issueId,
|
||||
projectId: ledgerScope.projectId,
|
||||
provider,
|
||||
biller,
|
||||
billingType,
|
||||
model: result.model ?? "unknown",
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
costCents: additionalCostCents,
|
||||
occurredAt: new Date(),
|
||||
@@ -1875,8 +1949,11 @@ export function heartbeatService(db: Db) {
|
||||
freshSession: runtimeForAdapter.sessionId == null && runtimeForAdapter.sessionDisplayId == null,
|
||||
sessionRotated: sessionCompaction.rotate,
|
||||
sessionRotationReason: sessionCompaction.reason,
|
||||
provider: readNonEmptyString(adapterResult.provider) ?? "unknown",
|
||||
biller: resolveLedgerBiller(adapterResult),
|
||||
model: readNonEmptyString(adapterResult.model) ?? "unknown",
|
||||
...(adapterResult.costUsd != null ? { costUsd: adapterResult.costUsd } : {}),
|
||||
...(adapterResult.billingType ? { billingType: adapterResult.billingType } : {}),
|
||||
billingType: normalizeLedgerBillingType(adapterResult.billingType),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
@@ -2226,6 +2303,43 @@ export function heartbeatService(db: Db) {
|
||||
const agent = await getAgent(agentId);
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
|
||||
const writeSkippedRequest = async (skipReason: string) => {
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId: agent.companyId,
|
||||
agentId,
|
||||
source,
|
||||
triggerDetail,
|
||||
reason: skipReason,
|
||||
payload,
|
||||
status: "skipped",
|
||||
requestedByActorType: opts.requestedByActorType ?? null,
|
||||
requestedByActorId: opts.requestedByActorId ?? null,
|
||||
idempotencyKey: opts.idempotencyKey ?? null,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
let projectId = readNonEmptyString(enrichedContextSnapshot.projectId);
|
||||
if (!projectId && issueId) {
|
||||
projectId = await db
|
||||
.select({ projectId: issues.projectId })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
||||
.then((rows) => rows[0]?.projectId ?? null);
|
||||
}
|
||||
|
||||
const budgetBlock = await budgets.getInvocationBlock(agent.companyId, agentId, {
|
||||
issueId,
|
||||
projectId,
|
||||
});
|
||||
if (budgetBlock) {
|
||||
await writeSkippedRequest("budget.blocked");
|
||||
throw conflict(budgetBlock.reason, {
|
||||
scopeType: budgetBlock.scopeType,
|
||||
scopeId: budgetBlock.scopeId,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
agent.status === "paused" ||
|
||||
agent.status === "terminated" ||
|
||||
@@ -2235,21 +2349,6 @@ export function heartbeatService(db: Db) {
|
||||
}
|
||||
|
||||
const policy = parseHeartbeatPolicy(agent);
|
||||
const writeSkippedRequest = async (reason: string) => {
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId: agent.companyId,
|
||||
agentId,
|
||||
source,
|
||||
triggerDetail,
|
||||
reason,
|
||||
payload,
|
||||
status: "skipped",
|
||||
requestedByActorType: opts.requestedByActorType ?? null,
|
||||
requestedByActorId: opts.requestedByActorId ?? null,
|
||||
idempotencyKey: opts.idempotencyKey ?? null,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
if (source === "timer" && !policy.enabled) {
|
||||
await writeSkippedRequest("heartbeat.disabled");
|
||||
|
||||
@@ -8,8 +8,10 @@ export { issueApprovalService } from "./issue-approvals.js";
|
||||
export { goalService } from "./goals.js";
|
||||
export { activityService, type ActivityFilters } from "./activity.js";
|
||||
export { approvalService } from "./approvals.js";
|
||||
export { budgetService } from "./budgets.js";
|
||||
export { secretService } from "./secrets.js";
|
||||
export { costService } from "./costs.js";
|
||||
export { financeService } from "./finance.js";
|
||||
export { heartbeatService } from "./heartbeat.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import type { ProviderQuotaResult } from "@paperclipai/shared";
|
||||
import { listServerAdapters } from "../adapters/registry.js";
|
||||
|
||||
const QUOTA_PROVIDER_TIMEOUT_MS = 20_000;
|
||||
|
||||
function providerSlugForAdapterType(type: string): string {
|
||||
switch (type) {
|
||||
case "claude_local":
|
||||
return "anthropic";
|
||||
case "codex_local":
|
||||
return "openai";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks each registered adapter for its provider quota windows and aggregates the results.
|
||||
* Adapters that don't implement getQuotaWindows() are silently skipped.
|
||||
@@ -11,19 +24,41 @@ export async function fetchAllQuotaWindows(): Promise<ProviderQuotaResult[]> {
|
||||
const adapters = listServerAdapters().filter((a) => a.getQuotaWindows != null);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
adapters.map((adapter) => adapter.getQuotaWindows!()),
|
||||
adapters.map((adapter) => withQuotaTimeout(adapter.type, adapter.getQuotaWindows!())),
|
||||
);
|
||||
|
||||
return settled.map((result, i) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
// Determine provider slug from the fulfilled value if available, otherwise fall back
|
||||
// to the adapter type so the error is still attributable to the right provider.
|
||||
const adapterType = adapters[i]!.type;
|
||||
return {
|
||||
provider: adapterType,
|
||||
provider: providerSlugForAdapterType(adapterType),
|
||||
ok: false,
|
||||
error: String(result.reason),
|
||||
windows: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function withQuotaTimeout(
|
||||
adapterType: string,
|
||||
task: Promise<ProviderQuotaResult>,
|
||||
): Promise<ProviderQuotaResult> {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
task,
|
||||
new Promise<ProviderQuotaResult>((resolve) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
resolve({
|
||||
provider: providerSlugForAdapterType(adapterType),
|
||||
ok: false,
|
||||
error: `quota polling timed out after ${Math.round(QUOTA_PROVIDER_TIMEOUT_MS / 1000)}s`,
|
||||
windows: [],
|
||||
});
|
||||
}, QUOTA_PROVIDER_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
20
ui/src/api/budgets.ts
Normal file
20
ui/src/api/budgets.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
BudgetIncident,
|
||||
BudgetIncidentResolutionInput,
|
||||
BudgetOverview,
|
||||
BudgetPolicySummary,
|
||||
BudgetPolicyUpsertInput,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const budgetsApi = {
|
||||
overview: (companyId: string) =>
|
||||
api.get<BudgetOverview>(`/companies/${companyId}/budgets/overview`),
|
||||
upsertPolicy: (companyId: string, data: BudgetPolicyUpsertInput) =>
|
||||
api.post<BudgetPolicySummary>(`/companies/${companyId}/budgets/policies`, data),
|
||||
resolveIncident: (companyId: string, incidentId: string, data: BudgetIncidentResolutionInput) =>
|
||||
api.post<BudgetIncident>(
|
||||
`/companies/${companyId}/budget-incidents/${encodeURIComponent(incidentId)}/resolve`,
|
||||
data,
|
||||
),
|
||||
};
|
||||
@@ -1,4 +1,17 @@
|
||||
import type { CostSummary, CostByAgent, CostByProviderModel, CostByAgentModel, CostByProject, CostWindowSpendRow, ProviderQuotaResult } from "@paperclipai/shared";
|
||||
import type {
|
||||
CostSummary,
|
||||
CostByAgent,
|
||||
CostByProviderModel,
|
||||
CostByBiller,
|
||||
CostByAgentModel,
|
||||
CostByProject,
|
||||
CostWindowSpendRow,
|
||||
FinanceSummary,
|
||||
FinanceByBiller,
|
||||
FinanceByKind,
|
||||
FinanceEvent,
|
||||
ProviderQuotaResult,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
function dateParams(from?: string, to?: string): string {
|
||||
@@ -20,8 +33,27 @@ export const costsApi = {
|
||||
api.get<CostByProject[]>(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`),
|
||||
byProvider: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<CostByProviderModel[]>(`/companies/${companyId}/costs/by-provider${dateParams(from, to)}`),
|
||||
byBiller: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<CostByBiller[]>(`/companies/${companyId}/costs/by-biller${dateParams(from, to)}`),
|
||||
financeSummary: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<FinanceSummary>(`/companies/${companyId}/costs/finance-summary${dateParams(from, to)}`),
|
||||
financeByBiller: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<FinanceByBiller[]>(`/companies/${companyId}/costs/finance-by-biller${dateParams(from, to)}`),
|
||||
financeByKind: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<FinanceByKind[]>(`/companies/${companyId}/costs/finance-by-kind${dateParams(from, to)}`),
|
||||
financeEvents: (companyId: string, from?: string, to?: string, limit: number = 100) =>
|
||||
api.get<FinanceEvent[]>(`/companies/${companyId}/costs/finance-events${dateParamsWithLimit(from, to, limit)}`),
|
||||
windowSpend: (companyId: string) =>
|
||||
api.get<CostWindowSpendRow[]>(`/companies/${companyId}/costs/window-spend`),
|
||||
quotaWindows: (companyId: string) =>
|
||||
api.get<ProviderQuotaResult[]>(`/companies/${companyId}/costs/quota-windows`),
|
||||
};
|
||||
|
||||
function dateParamsWithLimit(from?: string, to?: string, limit?: number): string {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
if (to) params.set("to", to);
|
||||
if (limit) params.set("limit", String(limit));
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
69
ui/src/components/AccountingModelCard.tsx
Normal file
69
ui/src/components/AccountingModelCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Database, Gauge, ReceiptText } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
const SURFACES = [
|
||||
{
|
||||
title: "Inference ledger",
|
||||
description: "Request-scoped usage and billed runs from cost_events.",
|
||||
icon: Database,
|
||||
points: ["tokens + billed dollars", "provider, biller, model", "subscription and overage aware"],
|
||||
tone: "from-sky-500/12 via-sky-500/6 to-transparent",
|
||||
},
|
||||
{
|
||||
title: "Finance ledger",
|
||||
description: "Account-level charges that are not one prompt-response pair.",
|
||||
icon: ReceiptText,
|
||||
points: ["top-ups, refunds, fees", "Bedrock provisioned or training charges", "credit expiries and adjustments"],
|
||||
tone: "from-amber-500/14 via-amber-500/6 to-transparent",
|
||||
},
|
||||
{
|
||||
title: "Live quotas",
|
||||
description: "Provider or biller windows that can stop traffic in real time.",
|
||||
icon: Gauge,
|
||||
points: ["provider quota windows", "biller credit systems", "errors surfaced directly"],
|
||||
tone: "from-emerald-500/14 via-emerald-500/6 to-transparent",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function AccountingModelCard() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-border/70">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.08),transparent_35%),radial-gradient(circle_at_bottom_right,rgba(56,189,248,0.1),transparent_32%)]" />
|
||||
<CardHeader className="relative px-5 pt-5 pb-2">
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Accounting model
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-2xl text-sm leading-6">
|
||||
Paperclip now separates request-level inference usage from account-level finance events.
|
||||
That keeps provider reporting honest when the biller is OpenRouter, Cloudflare, Bedrock, or another intermediary.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative grid gap-3 px-5 pb-5 md:grid-cols-3">
|
||||
{SURFACES.map((surface) => {
|
||||
const Icon = surface.icon;
|
||||
return (
|
||||
<div
|
||||
key={surface.title}
|
||||
className={`rounded-2xl border border-border/70 bg-gradient-to-br ${surface.tone} p-4 shadow-sm`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-border/70 bg-background/80">
|
||||
<Icon className="h-4 w-4 text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{surface.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{surface.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs text-muted-foreground">
|
||||
{surface.points.map((point) => (
|
||||
<div key={point}>{point}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export function ApprovalCard({
|
||||
}) {
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const label = typeLabel[approval.type] ?? approval.type;
|
||||
const showResolutionButtons =
|
||||
approval.type !== "budget_override_required" &&
|
||||
(approval.status === "pending" || approval.status === "revision_requested");
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 space-y-0">
|
||||
@@ -67,7 +70,7 @@ export function ApprovalCard({
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{(approval.status === "pending" || approval.status === "revision_requested") && (
|
||||
{showResolutionButtons && (
|
||||
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { UserPlus, Lightbulb, ShieldCheck } from "lucide-react";
|
||||
import { UserPlus, Lightbulb, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { formatCents } from "../lib/utils";
|
||||
|
||||
export const typeLabel: Record<string, string> = {
|
||||
hire_agent: "Hire Agent",
|
||||
approve_ceo_strategy: "CEO Strategy",
|
||||
budget_override_required: "Budget Override",
|
||||
};
|
||||
|
||||
export const typeIcon: Record<string, typeof UserPlus> = {
|
||||
hire_agent: UserPlus,
|
||||
approve_ceo_strategy: Lightbulb,
|
||||
budget_override_required: ShieldAlert,
|
||||
};
|
||||
|
||||
export const defaultTypeIcon = ShieldCheck;
|
||||
@@ -69,7 +72,28 @@ export function CeoStrategyPayload({ payload }: { payload: Record<string, unknow
|
||||
);
|
||||
}
|
||||
|
||||
export function BudgetOverridePayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
const budgetAmount = typeof payload.budgetAmount === "number" ? payload.budgetAmount : null;
|
||||
const observedAmount = typeof payload.observedAmount === "number" ? payload.observedAmount : null;
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<PayloadField label="Scope" value={payload.scopeName ?? payload.scopeType} />
|
||||
<PayloadField label="Window" value={payload.windowKind} />
|
||||
<PayloadField label="Metric" value={payload.metric} />
|
||||
{(budgetAmount !== null || observedAmount !== null) ? (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
Limit {budgetAmount !== null ? formatCents(budgetAmount) : "—"} · Observed {observedAmount !== null ? formatCents(observedAmount) : "—"}
|
||||
</div>
|
||||
) : null}
|
||||
{!!payload.guidance && (
|
||||
<p className="text-muted-foreground">{String(payload.guidance)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record<string, unknown> }) {
|
||||
if (type === "hire_agent") return <HireAgentPayload payload={payload} />;
|
||||
if (type === "budget_override_required") return <BudgetOverridePayload payload={payload} />;
|
||||
return <CeoStrategyPayload payload={payload} />;
|
||||
}
|
||||
|
||||
145
ui/src/components/BillerSpendCard.tsx
Normal file
145
ui/src/components/BillerSpendCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CostByBiller, CostByProviderModel } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { QuotaBar } from "./QuotaBar";
|
||||
import { billingTypeDisplayName, formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
|
||||
|
||||
interface BillerSpendCardProps {
|
||||
row: CostByBiller;
|
||||
weekSpendCents: number;
|
||||
budgetMonthlyCents: number;
|
||||
totalCompanySpendCents: number;
|
||||
providerRows: CostByProviderModel[];
|
||||
}
|
||||
|
||||
export function BillerSpendCard({
|
||||
row,
|
||||
weekSpendCents,
|
||||
budgetMonthlyCents,
|
||||
totalCompanySpendCents,
|
||||
providerRows,
|
||||
}: BillerSpendCardProps) {
|
||||
const providerBreakdown = useMemo(() => {
|
||||
const map = new Map<string, { provider: string; costCents: number; inputTokens: number; outputTokens: number }>();
|
||||
for (const entry of providerRows) {
|
||||
const current = map.get(entry.provider) ?? {
|
||||
provider: entry.provider,
|
||||
costCents: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
current.costCents += entry.costCents;
|
||||
current.inputTokens += entry.inputTokens + entry.cachedInputTokens;
|
||||
current.outputTokens += entry.outputTokens;
|
||||
map.set(entry.provider, current);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.costCents - a.costCents);
|
||||
}, [providerRows]);
|
||||
|
||||
const billingTypeBreakdown = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const entry of providerRows) {
|
||||
map.set(entry.billingType, (map.get(entry.billingType) ?? 0) + entry.costCents);
|
||||
}
|
||||
return Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
|
||||
}, [providerRows]);
|
||||
|
||||
const providerBudgetShare =
|
||||
budgetMonthlyCents > 0 && totalCompanySpendCents > 0
|
||||
? (row.costCents / totalCompanySpendCents) * budgetMonthlyCents
|
||||
: budgetMonthlyCents;
|
||||
const budgetPct =
|
||||
providerBudgetShare > 0
|
||||
? Math.min(100, (row.costCents / providerBudgetShare) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-0 gap-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
{providerDisplayName(row.biller)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-0.5">
|
||||
<span className="font-mono">{formatTokens(row.inputTokens + row.cachedInputTokens)}</span> in
|
||||
{" · "}
|
||||
<span className="font-mono">{formatTokens(row.outputTokens)}</span> out
|
||||
{" · "}
|
||||
{row.providerCount} provider{row.providerCount === 1 ? "" : "s"}
|
||||
{" · "}
|
||||
{row.modelCount} model{row.modelCount === 1 ? "" : "s"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<span className="text-xl font-bold tabular-nums shrink-0">
|
||||
{formatCents(row.costCents)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pb-4 pt-3 space-y-4">
|
||||
{budgetMonthlyCents > 0 && (
|
||||
<QuotaBar
|
||||
label="Period spend"
|
||||
percentUsed={budgetPct}
|
||||
leftLabel={formatCents(row.costCents)}
|
||||
rightLabel={`${Math.round(budgetPct)}% of allocation`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.apiRunCount > 0 ? `${row.apiRunCount} metered run${row.apiRunCount === 1 ? "" : "s"}` : "0 metered runs"}
|
||||
{" · "}
|
||||
{row.subscriptionRunCount > 0
|
||||
? `${row.subscriptionRunCount} subscription run${row.subscriptionRunCount === 1 ? "" : "s"}`
|
||||
: "0 subscription runs"}
|
||||
{" · "}
|
||||
{formatCents(weekSpendCents)} this week
|
||||
</div>
|
||||
|
||||
{billingTypeBreakdown.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Billing types
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{billingTypeBreakdown.map(([billingType, costCents]) => (
|
||||
<div key={billingType} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{billingTypeDisplayName(billingType as any)}</span>
|
||||
<span className="font-medium tabular-nums">{formatCents(costCents)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerBreakdown.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Upstream providers
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{providerBreakdown.map((entry) => (
|
||||
<div key={entry.provider} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{providerDisplayName(entry.provider)}</span>
|
||||
<div className="text-right tabular-nums">
|
||||
<div className="font-medium">{formatCents(entry.costCents)}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{formatTokens(entry.inputTokens + entry.outputTokens)} tok
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
100
ui/src/components/BudgetIncidentCard.tsx
Normal file
100
ui/src/components/BudgetIncidentCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import type { BudgetIncident } from "@paperclipai/shared";
|
||||
import { AlertOctagon, ArrowUpRight, PauseCircle } from "lucide-react";
|
||||
import { formatCents } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function centsInputValue(value: number) {
|
||||
return (value / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function parseDollarInput(value: string) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||||
return Math.round(parsed * 100);
|
||||
}
|
||||
|
||||
export function BudgetIncidentCard({
|
||||
incident,
|
||||
onRaiseAndResume,
|
||||
onKeepPaused,
|
||||
isMutating,
|
||||
}: {
|
||||
incident: BudgetIncident;
|
||||
onRaiseAndResume: (amountCents: number) => void;
|
||||
onKeepPaused: () => void;
|
||||
isMutating?: boolean;
|
||||
}) {
|
||||
const [draftAmount, setDraftAmount] = useState(
|
||||
centsInputValue(Math.max(incident.amountObserved + 1000, incident.amountLimit)),
|
||||
);
|
||||
const parsed = parseDollarInput(draftAmount);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-red-500/20 bg-[linear-gradient(180deg,rgba(255,70,70,0.10),rgba(255,255,255,0.02))]">
|
||||
<CardHeader className="px-5 pt-5 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-red-200/80">
|
||||
{incident.scopeType} hard stop
|
||||
</div>
|
||||
<CardTitle className="mt-1 text-base text-red-50">{incident.scopeName}</CardTitle>
|
||||
<CardDescription className="mt-1 text-red-100/70">
|
||||
Spending reached {formatCents(incident.amountObserved)} against a limit of {formatCents(incident.amountLimit)}.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="rounded-full border border-red-400/30 bg-red-500/10 p-2 text-red-200">
|
||||
<AlertOctagon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-5 pb-5 pt-0">
|
||||
<div className="flex items-start gap-2 rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-50/90">
|
||||
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
{incident.scopeType === "project"
|
||||
? "Project execution is paused. New work in this project will not start until you resolve the budget incident."
|
||||
: "This scope is paused. New heartbeats will not start until you resolve the budget incident."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-background/60 p-3">
|
||||
<label className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
New budget (USD)
|
||||
</label>
|
||||
<div className="mt-2 flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
value={draftAmount}
|
||||
onChange={(event) => setDraftAmount(event.target.value)}
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={isMutating || parsed === null || parsed <= incident.amountObserved}
|
||||
onClick={() => {
|
||||
if (typeof parsed === "number") onRaiseAndResume(parsed);
|
||||
}}
|
||||
>
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
{isMutating ? "Applying..." : "Raise budget & resume"}
|
||||
</Button>
|
||||
</div>
|
||||
{parsed !== null && parsed <= incident.amountObserved ? (
|
||||
<p className="mt-2 text-xs text-red-200/80">
|
||||
The new budget must exceed current observed spend.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" className="text-muted-foreground" disabled={isMutating} onClick={onKeepPaused}>
|
||||
Keep paused
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
153
ui/src/components/BudgetPolicyCard.tsx
Normal file
153
ui/src/components/BudgetPolicyCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BudgetPolicySummary } from "@paperclipai/shared";
|
||||
import { AlertTriangle, PauseCircle, ShieldAlert, Wallet } from "lucide-react";
|
||||
import { cn, formatCents } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function centsInputValue(value: number) {
|
||||
return (value / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function parseDollarInput(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0) return 0;
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||||
return Math.round(parsed * 100);
|
||||
}
|
||||
|
||||
function windowLabel(windowKind: BudgetPolicySummary["windowKind"]) {
|
||||
return windowKind === "lifetime" ? "Lifetime budget" : "Monthly UTC budget";
|
||||
}
|
||||
|
||||
function statusTone(status: BudgetPolicySummary["status"]) {
|
||||
if (status === "hard_stop") return "text-red-300 border-red-500/30 bg-red-500/10";
|
||||
if (status === "warning") return "text-amber-200 border-amber-500/30 bg-amber-500/10";
|
||||
return "text-emerald-200 border-emerald-500/30 bg-emerald-500/10";
|
||||
}
|
||||
|
||||
export function BudgetPolicyCard({
|
||||
summary,
|
||||
onSave,
|
||||
isSaving,
|
||||
compact = false,
|
||||
}: {
|
||||
summary: BudgetPolicySummary;
|
||||
onSave?: (amountCents: number) => void;
|
||||
isSaving?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [draftBudget, setDraftBudget] = useState(centsInputValue(summary.amount));
|
||||
|
||||
useEffect(() => {
|
||||
setDraftBudget(centsInputValue(summary.amount));
|
||||
}, [summary.amount]);
|
||||
|
||||
const parsedDraft = parseDollarInput(draftBudget);
|
||||
const canSave = typeof parsedDraft === "number" && parsedDraft !== summary.amount && Boolean(onSave);
|
||||
const progress = summary.amount > 0 ? Math.min(100, summary.utilizationPercent) : 0;
|
||||
const StatusIcon = summary.status === "hard_stop" ? ShieldAlert : summary.status === "warning" ? AlertTriangle : Wallet;
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden border-border/70 bg-card/80", compact ? "" : "shadow-[0_20px_80px_-40px_rgba(0,0,0,0.55)]")}>
|
||||
<CardHeader className={cn("gap-3", compact ? "px-4 pt-4 pb-2" : "px-5 pt-5 pb-3")}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{summary.scopeType}
|
||||
</div>
|
||||
<CardTitle className="mt-1 text-base">{summary.scopeName}</CardTitle>
|
||||
<CardDescription className="mt-1">{windowLabel(summary.windowKind)}</CardDescription>
|
||||
</div>
|
||||
<div className={cn("inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] uppercase tracking-[0.18em]", statusTone(summary.status))}>
|
||||
<StatusIcon className="h-3.5 w-3.5" />
|
||||
{summary.paused ? "Paused" : summary.status === "warning" ? "Warning" : summary.status === "hard_stop" ? "Hard stop" : "Healthy"}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={cn("space-y-4", compact ? "px-4 pb-4 pt-0" : "px-5 pb-5 pt-0")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border/70 bg-black/[0.18] px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Observed</div>
|
||||
<div className="mt-2 text-xl font-semibold tabular-nums">{formatCents(summary.observedAmount)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-black/[0.18] px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Budget</div>
|
||||
<div className="mt-2 text-xl font-semibold tabular-nums">
|
||||
{summary.amount > 0 ? formatCents(summary.amount) : "Disabled"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Soft alert at {summary.warnPercent}%{summary.paused && summary.pauseReason ? ` · ${summary.pauseReason} pause` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Remaining</span>
|
||||
<span>{summary.amount > 0 ? formatCents(summary.remainingAmount) : "Unlimited"}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted/70">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width,background-color] duration-200",
|
||||
summary.status === "hard_stop"
|
||||
? "bg-red-400"
|
||||
: summary.status === "warning"
|
||||
? "bg-amber-300"
|
||||
: "bg-emerald-300",
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary.paused ? (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-100">
|
||||
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
{summary.scopeType === "project"
|
||||
? "Execution is paused for this project until the budget is raised or the incident is dismissed."
|
||||
: "Heartbeats are paused for this scope until the budget is raised or the incident is dismissed."}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{onSave ? (
|
||||
<div className="rounded-xl border border-border/70 bg-background/50 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Budget (USD)
|
||||
</label>
|
||||
<Input
|
||||
value={draftBudget}
|
||||
onChange={(event) => setDraftBudget(event.target.value)}
|
||||
className="mt-2"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (typeof parsedDraft === "number" && onSave) onSave(parsedDraft);
|
||||
}}
|
||||
disabled={!canSave || isSaving || parsedDraft === null}
|
||||
>
|
||||
{isSaving ? "Saving..." : summary.amount > 0 ? "Update budget" : "Set budget"}
|
||||
</Button>
|
||||
</div>
|
||||
{parsedDraft === null ? (
|
||||
<p className="mt-2 text-xs text-destructive">Enter a valid non-negative dollar amount.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
140
ui/src/components/ClaudeSubscriptionPanel.tsx
Normal file
140
ui/src/components/ClaudeSubscriptionPanel.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { QuotaWindow } from "@paperclipai/shared";
|
||||
import { cn, quotaSourceDisplayName } from "@/lib/utils";
|
||||
|
||||
interface ClaudeSubscriptionPanelProps {
|
||||
windows: QuotaWindow[];
|
||||
source?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const WINDOW_ORDER = [
|
||||
"currentsession",
|
||||
"currentweekallmodels",
|
||||
"currentweeksonnetonly",
|
||||
"currentweeksonnet",
|
||||
"currentweekopusonly",
|
||||
"currentweekopus",
|
||||
"extrausage",
|
||||
] as const;
|
||||
|
||||
function normalizeLabel(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function detailText(window: QuotaWindow): string | null {
|
||||
if (typeof window.detail === "string" && window.detail.trim().length > 0) return window.detail.trim();
|
||||
if (window.resetsAt) {
|
||||
const formatted = new Date(window.resetsAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
return `Resets ${formatted}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function orderedWindows(windows: QuotaWindow[]): QuotaWindow[] {
|
||||
return [...windows].sort((a, b) => {
|
||||
const aIndex = WINDOW_ORDER.indexOf(normalizeLabel(a.label) as (typeof WINDOW_ORDER)[number]);
|
||||
const bIndex = WINDOW_ORDER.indexOf(normalizeLabel(b.label) as (typeof WINDOW_ORDER)[number]);
|
||||
return (aIndex === -1 ? WINDOW_ORDER.length : aIndex) - (bIndex === -1 ? WINDOW_ORDER.length : bIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function fillClass(usedPercent: number | null): string {
|
||||
if (usedPercent == null) return "bg-zinc-700";
|
||||
if (usedPercent >= 90) return "bg-red-400";
|
||||
if (usedPercent >= 70) return "bg-amber-400";
|
||||
return "bg-primary/70";
|
||||
}
|
||||
|
||||
export function ClaudeSubscriptionPanel({
|
||||
windows,
|
||||
source = null,
|
||||
error = null,
|
||||
}: ClaudeSubscriptionPanelProps) {
|
||||
const ordered = orderedWindows(windows);
|
||||
|
||||
return (
|
||||
<div className="border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Anthropic subscription
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Live Claude quota windows.
|
||||
</div>
|
||||
</div>
|
||||
{source ? (
|
||||
<span className="shrink-0 border border-border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{quotaSourceDisplayName(source)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{ordered.map((window) => {
|
||||
const normalized = normalizeLabel(window.label);
|
||||
const detail = detailText(window);
|
||||
if (normalized === "extrausage") {
|
||||
return (
|
||||
<div
|
||||
key={window.label}
|
||||
className="border border-border px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{window.valueLabel ? (
|
||||
<div className="text-sm font-medium text-foreground">{window.valueLabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{detail ? (
|
||||
<div className="mt-2 text-sm text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const width = Math.min(100, Math.max(0, window.usedPercent ?? 0));
|
||||
return (
|
||||
<div
|
||||
key={window.label}
|
||||
className="border border-border px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{detail ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{window.usedPercent != null ? (
|
||||
<div className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
|
||||
{window.usedPercent}% used
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2 overflow-hidden bg-muted">
|
||||
<div
|
||||
className={cn("h-full transition-[width] duration-200", fillClass(window.usedPercent))}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
ui/src/components/CodexSubscriptionPanel.tsx
Normal file
157
ui/src/components/CodexSubscriptionPanel.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { QuotaWindow } from "@paperclipai/shared";
|
||||
import { cn, quotaSourceDisplayName } from "@/lib/utils";
|
||||
|
||||
interface CodexSubscriptionPanelProps {
|
||||
windows: QuotaWindow[];
|
||||
source?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const WINDOW_PRIORITY = [
|
||||
"5hlimit",
|
||||
"weeklylimit",
|
||||
"credits",
|
||||
] as const;
|
||||
|
||||
function normalizeLabel(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function orderedWindows(windows: QuotaWindow[]): QuotaWindow[] {
|
||||
return [...windows].sort((a, b) => {
|
||||
const aBase = normalizeLabel(a.label).replace(/^gpt53codexspark/, "");
|
||||
const bBase = normalizeLabel(b.label).replace(/^gpt53codexspark/, "");
|
||||
const aIndex = WINDOW_PRIORITY.indexOf(aBase as (typeof WINDOW_PRIORITY)[number]);
|
||||
const bIndex = WINDOW_PRIORITY.indexOf(bBase as (typeof WINDOW_PRIORITY)[number]);
|
||||
return (aIndex === -1 ? WINDOW_PRIORITY.length : aIndex) - (bIndex === -1 ? WINDOW_PRIORITY.length : bIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function detailText(window: QuotaWindow): string | null {
|
||||
if (typeof window.detail === "string" && window.detail.trim().length > 0) return window.detail.trim();
|
||||
if (!window.resetsAt) return null;
|
||||
const formatted = new Date(window.resetsAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
return `Resets ${formatted}`;
|
||||
}
|
||||
|
||||
function fillClass(usedPercent: number | null): string {
|
||||
if (usedPercent == null) return "bg-zinc-700";
|
||||
if (usedPercent >= 90) return "bg-red-400";
|
||||
if (usedPercent >= 70) return "bg-amber-400";
|
||||
return "bg-primary/70";
|
||||
}
|
||||
|
||||
function isModelSpecific(label: string): boolean {
|
||||
const normalized = normalizeLabel(label);
|
||||
return normalized.includes("gpt53codexspark") || normalized.includes("gpt5");
|
||||
}
|
||||
|
||||
export function CodexSubscriptionPanel({
|
||||
windows,
|
||||
source = null,
|
||||
error = null,
|
||||
}: CodexSubscriptionPanelProps) {
|
||||
const ordered = orderedWindows(windows);
|
||||
const accountWindows = ordered.filter((window) => !isModelSpecific(window.label));
|
||||
const modelWindows = ordered.filter((window) => isModelSpecific(window.label));
|
||||
|
||||
return (
|
||||
<div className="border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Codex subscription
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Live Codex quota windows.
|
||||
</div>
|
||||
</div>
|
||||
{source ? (
|
||||
<span className="shrink-0 border border-border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{quotaSourceDisplayName(source)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 space-y-5">
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Account windows
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{accountWindows.map((window) => (
|
||||
<QuotaWindowRow key={window.label} window={window} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modelWindows.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Model windows
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{modelWindows.map((window) => (
|
||||
<QuotaWindowRow key={window.label} window={window} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaWindowRow({ window }: { window: QuotaWindow }) {
|
||||
const detail = detailText(window);
|
||||
if (window.usedPercent == null) {
|
||||
return (
|
||||
<div className="border border-border px-3.5 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{window.valueLabel ? (
|
||||
<div className="text-sm font-semibold tabular-nums text-foreground">{window.valueLabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{detail ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border px-3.5 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{detail ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
|
||||
{window.usedPercent}% used
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2 overflow-hidden bg-muted">
|
||||
<div
|
||||
className={cn("h-full transition-[width] duration-200", fillClass(window.usedPercent))}
|
||||
style={{ width: `${Math.max(0, Math.min(100, window.usedPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
ui/src/components/FinanceBillerCard.tsx
Normal file
44
ui/src/components/FinanceBillerCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { FinanceByBiller } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatCents, providerDisplayName } from "@/lib/utils";
|
||||
|
||||
interface FinanceBillerCardProps {
|
||||
row: FinanceByBiller;
|
||||
}
|
||||
|
||||
export function FinanceBillerCard({ row }: FinanceBillerCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">{providerDisplayName(row.biller)}</CardTitle>
|
||||
<CardDescription className="mt-1 text-xs">
|
||||
{row.eventCount} event{row.eventCount === 1 ? "" : "s"} across {row.kindCount} kind{row.kindCount === 1 ? "" : "s"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold tabular-nums">{formatCents(row.netCents)}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">net</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-4 pt-3">
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div className="border border-border p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">debits</div>
|
||||
<div className="mt-1 font-medium tabular-nums">{formatCents(row.debitCents)}</div>
|
||||
</div>
|
||||
<div className="border border-border p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">credits</div>
|
||||
<div className="mt-1 font-medium tabular-nums">{formatCents(row.creditCents)}</div>
|
||||
</div>
|
||||
<div className="border border-border p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">estimated</div>
|
||||
<div className="mt-1 font-medium tabular-nums">{formatCents(row.estimatedDebitCents)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
43
ui/src/components/FinanceKindCard.tsx
Normal file
43
ui/src/components/FinanceKindCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { FinanceByKind } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { financeEventKindDisplayName, formatCents } from "@/lib/utils";
|
||||
|
||||
interface FinanceKindCardProps {
|
||||
rows: FinanceByKind[];
|
||||
}
|
||||
|
||||
export function FinanceKindCard({ rows }: FinanceKindCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-1">
|
||||
<CardTitle className="text-base">Financial event mix</CardTitle>
|
||||
<CardDescription>Account-level charges grouped by event kind.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-4 pb-4 pt-3">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No finance events in this period.</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<div
|
||||
key={row.eventKind}
|
||||
className="flex items-center justify-between gap-3 border border-border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{financeEventKindDisplayName(row.eventKind)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.eventCount} event{row.eventCount === 1 ? "" : "s"} · {row.billerCount} biller{row.billerCount === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
<div className="text-sm font-medium">{formatCents(row.netCents)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatCents(row.debitCents)} debits
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
71
ui/src/components/FinanceTimelineCard.tsx
Normal file
71
ui/src/components/FinanceTimelineCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { FinanceEvent } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
financeDirectionDisplayName,
|
||||
financeEventKindDisplayName,
|
||||
formatCents,
|
||||
formatDateTime,
|
||||
providerDisplayName,
|
||||
} from "@/lib/utils";
|
||||
|
||||
interface FinanceTimelineCardProps {
|
||||
rows: FinanceEvent[];
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function FinanceTimelineCard({
|
||||
rows,
|
||||
emptyMessage = "No financial events in this period.",
|
||||
}: FinanceTimelineCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-1">
|
||||
<CardTitle className="text-base">Recent financial events</CardTitle>
|
||||
<CardDescription>Top-ups, fees, credits, commitments, and other non-request charges.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-4 pt-3">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="border border-border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{financeEventKindDisplayName(row.eventKind)}</Badge>
|
||||
<Badge variant={row.direction === "credit" ? "outline" : "secondary"}>
|
||||
{financeDirectionDisplayName(row.direction)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{formatDateTime(row.occurredAt)}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{providerDisplayName(row.biller)}
|
||||
{row.provider ? ` -> ${providerDisplayName(row.provider)}` : ""}
|
||||
{row.model ? <span className="ml-1 font-mono text-xs text-muted-foreground">{row.model}</span> : null}
|
||||
</div>
|
||||
{(row.description || row.externalInvoiceId || row.region || row.pricingTier) && (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{row.description ? <div>{row.description}</div> : null}
|
||||
{row.externalInvoiceId ? <div>invoice {row.externalInvoiceId}</div> : null}
|
||||
{row.region ? <div>region {row.region}</div> : null}
|
||||
{row.pricingTier ? <div>tier {row.pricingTier}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
<div className="text-sm font-semibold">{formatCents(row.amountCents)}</div>
|
||||
<div className="text-xs text-muted-foreground">{row.currency}</div>
|
||||
{row.estimated ? <div className="text-[11px] uppercase tracking-[0.12em] text-amber-600">estimated</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QuotaBar } from "./QuotaBar";
|
||||
import { formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
|
||||
import { ClaudeSubscriptionPanel } from "./ClaudeSubscriptionPanel";
|
||||
import { CodexSubscriptionPanel } from "./CodexSubscriptionPanel";
|
||||
import {
|
||||
billingTypeDisplayName,
|
||||
formatCents,
|
||||
formatTokens,
|
||||
providerDisplayName,
|
||||
quotaSourceDisplayName,
|
||||
} from "@/lib/utils";
|
||||
|
||||
// ordered display labels for rolling-window rows
|
||||
const ROLLING_WINDOWS = ["5h", "24h", "7d"] as const;
|
||||
@@ -21,6 +30,9 @@ interface ProviderQuotaCardProps {
|
||||
showDeficitNotch: boolean;
|
||||
/** live subscription quota windows from the provider's own api */
|
||||
quotaWindows?: QuotaWindow[];
|
||||
quotaError?: string | null;
|
||||
quotaSource?: string | null;
|
||||
quotaLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderQuotaCard({
|
||||
@@ -32,6 +44,9 @@ export function ProviderQuotaCard({
|
||||
windowRows,
|
||||
showDeficitNotch,
|
||||
quotaWindows = [],
|
||||
quotaError = null,
|
||||
quotaSource = null,
|
||||
quotaLoading = false,
|
||||
}: ProviderQuotaCardProps) {
|
||||
// single-pass aggregation over rows — memoized so the 8 derived values are not
|
||||
// recomputed on every parent render tick (providers tab polls every 30s, and each
|
||||
@@ -108,6 +123,11 @@ export function ProviderQuotaCard({
|
||||
() => Math.max(...windowRows.map((r) => r.costCents), 0),
|
||||
[windowRows],
|
||||
);
|
||||
const isClaudeQuotaPanel = provider === "anthropic";
|
||||
const isCodexQuotaPanel = provider === "openai" && quotaSource?.startsWith("codex-");
|
||||
const supportsSubscriptionQuota = provider === "anthropic" || provider === "openai";
|
||||
const showSubscriptionQuotaSection =
|
||||
supportsSubscriptionQuota && (quotaLoading || quotaWindows.length > 0 || quotaError != null);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -183,7 +203,7 @@ export function ProviderQuotaCard({
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{formatCents(cents)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full border border-border overflow-hidden">
|
||||
<div className="h-2 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary/60 transition-[width] duration-150"
|
||||
style={{ width: `${barPct}%` }}
|
||||
@@ -197,56 +217,6 @@ export function ProviderQuotaCard({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* subscription quota windows from provider api — shown when data is available */}
|
||||
{quotaWindows.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Subscription quota
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
{quotaWindows.map((qw) => {
|
||||
const fillColor =
|
||||
qw.usedPercent == null
|
||||
? null
|
||||
: qw.usedPercent >= 90
|
||||
? "bg-red-400"
|
||||
: qw.usedPercent >= 70
|
||||
? "bg-yellow-400"
|
||||
: "bg-green-400";
|
||||
return (
|
||||
<div key={qw.label} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
|
||||
<span className="flex-1" />
|
||||
{qw.valueLabel != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.valueLabel}</span>
|
||||
) : qw.usedPercent != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.usedPercent}% used</span>
|
||||
) : null}
|
||||
</div>
|
||||
{qw.usedPercent != null && fillColor != null && (
|
||||
<div className="h-1.5 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-[width] duration-150 ${fillColor}`}
|
||||
style={{ width: `${qw.usedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{qw.resetsAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
resets {new Date(qw.resetsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* subscription usage — shown when any subscription-billed runs exist */}
|
||||
{totalSubRuns > 0 && (
|
||||
<>
|
||||
@@ -258,6 +228,12 @@ export function ProviderQuotaCard({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-mono text-foreground">{totalSubRuns}</span> runs
|
||||
{" · "}
|
||||
{totalSubTokens > 0 && (
|
||||
<>
|
||||
<span className="font-mono text-foreground">{formatTokens(totalSubTokens)}</span> total
|
||||
{" · "}
|
||||
</>
|
||||
)}
|
||||
<span className="font-mono text-foreground">{formatTokens(totalSubInputTokens)}</span> in
|
||||
{" · "}
|
||||
<span className="font-mono text-foreground">{formatTokens(totalSubOutputTokens)}</span> out
|
||||
@@ -292,9 +268,14 @@ export function ProviderQuotaCard({
|
||||
<div key={`${row.provider}:${row.model}`} className="space-y-1.5">
|
||||
{/* model name and cost */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground truncate font-mono">
|
||||
{row.model}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs text-muted-foreground truncate font-mono block">
|
||||
{row.model}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground truncate block">
|
||||
{providerDisplayName(row.biller)} · {billingTypeDisplayName(row.billingType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 tabular-nums text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{formatTokens(rowTokens)} tok
|
||||
@@ -303,7 +284,7 @@ export function ProviderQuotaCard({
|
||||
</div>
|
||||
</div>
|
||||
{/* token share bar */}
|
||||
<div className="relative h-1.5 w-full border border-border overflow-hidden">
|
||||
<div className="relative h-2 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-primary/60 transition-[width] duration-150"
|
||||
style={{ width: `${tokenPct}%` }}
|
||||
@@ -322,7 +303,114 @@ export function ProviderQuotaCard({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* subscription quota windows from provider api — shown when data is available */}
|
||||
{showSubscriptionQuotaSection && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Subscription quota
|
||||
</p>
|
||||
{quotaSource && !isClaudeQuotaPanel && !isCodexQuotaPanel ? (
|
||||
<span className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{quotaSourceDisplayName(quotaSource)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{quotaLoading ? (
|
||||
<QuotaPanelSkeleton />
|
||||
) : isClaudeQuotaPanel ? (
|
||||
<ClaudeSubscriptionPanel windows={quotaWindows} source={quotaSource} error={quotaError} />
|
||||
) : isCodexQuotaPanel ? (
|
||||
<CodexSubscriptionPanel windows={quotaWindows} source={quotaSource} error={quotaError} />
|
||||
) : (
|
||||
<>
|
||||
{quotaError ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{quotaError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-2.5">
|
||||
{quotaWindows.map((qw) => {
|
||||
const fillColor =
|
||||
qw.usedPercent == null
|
||||
? null
|
||||
: qw.usedPercent >= 90
|
||||
? "bg-red-400"
|
||||
: qw.usedPercent >= 70
|
||||
? "bg-yellow-400"
|
||||
: "bg-green-400";
|
||||
return (
|
||||
<div key={qw.label} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
|
||||
<span className="flex-1" />
|
||||
{qw.valueLabel != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.valueLabel}</span>
|
||||
) : qw.usedPercent != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.usedPercent}% used</span>
|
||||
) : null}
|
||||
</div>
|
||||
{qw.usedPercent != null && fillColor != null && (
|
||||
<div className="h-2 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-[width] duration-150 ${fillColor}`}
|
||||
style={{ width: `${qw.usedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{qw.detail ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{qw.detail}
|
||||
</p>
|
||||
) : qw.resetsAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
resets {new Date(qw.resetsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaPanelSkeleton() {
|
||||
return (
|
||||
<div className="border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 w-36" />
|
||||
<Skeleton className="h-4 w-64 max-w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-7 w-28" />
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-border px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-44 max-w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<Skeleton className="mt-3 h-2 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +164,12 @@ const dashboard: DashboardSummary = {
|
||||
monthUtilizationPercent: 90,
|
||||
},
|
||||
pendingApprovals: 1,
|
||||
budgets: {
|
||||
activeIncidents: 0,
|
||||
pendingApprovals: 0,
|
||||
pausedAgents: 0,
|
||||
pausedProjects: 0,
|
||||
},
|
||||
};
|
||||
|
||||
describe("inbox helpers", () => {
|
||||
|
||||
@@ -43,6 +43,9 @@ export const queryKeys = {
|
||||
list: (companyId: string) => ["goals", companyId] as const,
|
||||
detail: (id: string) => ["goals", "detail", id] as const,
|
||||
},
|
||||
budgets: {
|
||||
overview: (companyId: string) => ["budgets", "overview", companyId] as const,
|
||||
},
|
||||
approvals: {
|
||||
list: (companyId: string, status?: string) =>
|
||||
["approvals", companyId, status] as const,
|
||||
@@ -73,6 +76,16 @@ export const queryKeys = {
|
||||
["costs", companyId, from, to] as const,
|
||||
usageByProvider: (companyId: string, from?: string, to?: string) =>
|
||||
["usage-by-provider", companyId, from, to] as const,
|
||||
usageByBiller: (companyId: string, from?: string, to?: string) =>
|
||||
["usage-by-biller", companyId, from, to] as const,
|
||||
financeSummary: (companyId: string, from?: string, to?: string) =>
|
||||
["finance-summary", companyId, from, to] as const,
|
||||
financeByBiller: (companyId: string, from?: string, to?: string) =>
|
||||
["finance-by-biller", companyId, from, to] as const,
|
||||
financeByKind: (companyId: string, from?: string, to?: string) =>
|
||||
["finance-by-kind", companyId, from, to] as const,
|
||||
financeEvents: (companyId: string, from?: string, to?: string, limit: number = 100) =>
|
||||
["finance-events", companyId, from, to, limit] as const,
|
||||
usageWindowSpend: (companyId: string) =>
|
||||
["usage-window-spend", companyId] as const,
|
||||
usageQuotaWindows: (companyId: string) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { deriveAgentUrlKey, deriveProjectUrlKey } from "@paperclipai/shared";
|
||||
import type { BillingType, FinanceDirection, FinanceEventKind } from "@paperclipai/shared";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -53,6 +54,8 @@ export function providerDisplayName(provider: string): string {
|
||||
const map: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
openrouter: "OpenRouter",
|
||||
chatgpt: "ChatGPT",
|
||||
google: "Google",
|
||||
cursor: "Cursor",
|
||||
jetbrains: "JetBrains AI",
|
||||
@@ -60,6 +63,84 @@ export function providerDisplayName(provider: string): string {
|
||||
return map[provider.toLowerCase()] ?? provider;
|
||||
}
|
||||
|
||||
export function billingTypeDisplayName(billingType: BillingType): string {
|
||||
const map: Record<BillingType, string> = {
|
||||
metered_api: "Metered API",
|
||||
subscription_included: "Subscription",
|
||||
subscription_overage: "Subscription overage",
|
||||
credits: "Credits",
|
||||
fixed: "Fixed",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
return map[billingType];
|
||||
}
|
||||
|
||||
export function quotaSourceDisplayName(source: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"anthropic-oauth": "Anthropic OAuth",
|
||||
"claude-cli": "Claude CLI",
|
||||
"codex-rpc": "Codex app server",
|
||||
"codex-wham": "ChatGPT WHAM",
|
||||
};
|
||||
return map[source] ?? source;
|
||||
}
|
||||
|
||||
function coerceBillingType(value: unknown): BillingType | null {
|
||||
if (
|
||||
value === "metered_api" ||
|
||||
value === "subscription_included" ||
|
||||
value === "subscription_overage" ||
|
||||
value === "credits" ||
|
||||
value === "fixed" ||
|
||||
value === "unknown"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readRunCostUsd(payload: Record<string, unknown> | null): number {
|
||||
if (!payload) return 0;
|
||||
for (const key of ["costUsd", "cost_usd", "total_cost_usd"] as const) {
|
||||
const value = payload[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function visibleRunCostUsd(
|
||||
usage: Record<string, unknown> | null,
|
||||
result: Record<string, unknown> | null = null,
|
||||
): number {
|
||||
const billingType = coerceBillingType(usage?.billingType) ?? coerceBillingType(result?.billingType);
|
||||
if (billingType === "subscription_included") return 0;
|
||||
return readRunCostUsd(usage) || readRunCostUsd(result);
|
||||
}
|
||||
|
||||
export function financeEventKindDisplayName(eventKind: FinanceEventKind): string {
|
||||
const map: Record<FinanceEventKind, string> = {
|
||||
inference_charge: "Inference charge",
|
||||
platform_fee: "Platform fee",
|
||||
credit_purchase: "Credit purchase",
|
||||
credit_refund: "Credit refund",
|
||||
credit_expiry: "Credit expiry",
|
||||
byok_fee: "BYOK fee",
|
||||
gateway_overhead: "Gateway overhead",
|
||||
log_storage_charge: "Log storage",
|
||||
logpush_charge: "Logpush",
|
||||
provisioned_capacity_charge: "Provisioned capacity",
|
||||
training_charge: "Training",
|
||||
custom_model_import_charge: "Custom model import",
|
||||
custom_model_storage_charge: "Custom model storage",
|
||||
manual_adjustment: "Manual adjustment",
|
||||
};
|
||||
return map[eventKind];
|
||||
}
|
||||
|
||||
export function financeDirectionDisplayName(direction: FinanceDirection): string {
|
||||
return direction === "credit" ? "Credit" : "Debit";
|
||||
}
|
||||
|
||||
/** Build an issue URL using the human-readable identifier when available. */
|
||||
export function issueUrl(issue: { id: string; identifier?: string | null }): string {
|
||||
return `/issues/${issue.identifier ?? issue.id}`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { ApiError } from "../api/client";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
@@ -24,8 +25,9 @@ import { CopyText } from "../components/CopyText";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
||||
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
@@ -58,7 +60,15 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared";
|
||||
import {
|
||||
isUuidLike,
|
||||
type Agent,
|
||||
type BudgetPolicySummary,
|
||||
type HeartbeatRun,
|
||||
type HeartbeatRunEvent,
|
||||
type AgentRuntimeState,
|
||||
type LiveEvent,
|
||||
} from "@paperclipai/shared";
|
||||
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
|
||||
import { agentRouteRef } from "../lib/utils";
|
||||
|
||||
@@ -204,8 +214,7 @@ function runMetrics(run: HeartbeatRun) {
|
||||
"cache_read_input_tokens",
|
||||
);
|
||||
const cost =
|
||||
usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") ||
|
||||
usageNumber(result, "total_cost_usd", "cost_usd", "costUsd");
|
||||
visibleRunCostUsd(usage, result);
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
@@ -294,11 +303,50 @@ export function AgentDetail() {
|
||||
enabled: !!resolvedCompanyId,
|
||||
});
|
||||
|
||||
const { data: budgetOverview } = useQuery({
|
||||
queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
|
||||
queryFn: () => budgetsApi.overview(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const assignedIssues = (allIssues ?? [])
|
||||
.filter((i) => i.assigneeAgentId === agent?.id)
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
|
||||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
|
||||
const agentBudgetSummary = useMemo(() => {
|
||||
const matched = budgetOverview?.policies.find(
|
||||
(policy) => policy.scopeType === "agent" && policy.scopeId === (agent?.id ?? routeAgentRef),
|
||||
);
|
||||
if (matched) return matched;
|
||||
const budgetMonthlyCents = agent?.budgetMonthlyCents ?? 0;
|
||||
const spentMonthlyCents = agent?.spentMonthlyCents ?? 0;
|
||||
return {
|
||||
policyId: "",
|
||||
companyId: resolvedCompanyId ?? "",
|
||||
scopeType: "agent",
|
||||
scopeId: agent?.id ?? routeAgentRef,
|
||||
scopeName: agent?.name ?? "Agent",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: budgetMonthlyCents,
|
||||
observedAmount: spentMonthlyCents,
|
||||
remainingAmount: Math.max(0, budgetMonthlyCents - spentMonthlyCents),
|
||||
utilizationPercent:
|
||||
budgetMonthlyCents > 0 ? Number(((spentMonthlyCents / budgetMonthlyCents) * 100).toFixed(2)) : 0,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: budgetMonthlyCents > 0,
|
||||
status: budgetMonthlyCents > 0 && spentMonthlyCents >= budgetMonthlyCents ? "hard_stop" : "ok",
|
||||
paused: agent?.status === "paused",
|
||||
pauseReason: agent?.pauseReason ?? null,
|
||||
windowStart: new Date(),
|
||||
windowEnd: new Date(),
|
||||
} satisfies BudgetPolicySummary;
|
||||
}, [agent, budgetOverview?.policies, resolvedCompanyId, routeAgentRef]);
|
||||
const mobileLiveRun = useMemo(
|
||||
() => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
|
||||
[heartbeats],
|
||||
@@ -360,6 +408,24 @@ export function AgentDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const budgetMutation = useMutation({
|
||||
mutationFn: (amount: number) =>
|
||||
budgetsApi.upsertPolicy(resolvedCompanyId!, {
|
||||
scopeType: "agent",
|
||||
scopeId: agent?.id ?? routeAgentRef,
|
||||
amount,
|
||||
windowKind: "calendar_month_utc",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
if (!resolvedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
|
||||
},
|
||||
});
|
||||
|
||||
const updateIcon = useMutation({
|
||||
mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
@@ -579,6 +645,15 @@ export function AgentDetail() {
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{!urlRunId && resolvedCompanyId ? (
|
||||
<BudgetPolicyCard
|
||||
summary={agentBudgetSummary}
|
||||
isSaving={budgetMutation.isPending}
|
||||
compact
|
||||
onSave={(amount) => budgetMutation.mutate(amount)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
{isPendingApproval && (
|
||||
<p className="text-sm text-amber-500">
|
||||
@@ -849,8 +924,8 @@ function CostsSection({
|
||||
}) {
|
||||
const runsWithCost = runs
|
||||
.filter((r) => {
|
||||
const u = r.usageJson as Record<string, unknown> | null;
|
||||
return u && (u.cost_usd || u.total_cost_usd || u.input_tokens);
|
||||
const metrics = runMetrics(r);
|
||||
return metrics.cost > 0 || metrics.input > 0 || metrics.output > 0 || metrics.cached > 0;
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
@@ -892,16 +967,16 @@ function CostsSection({
|
||||
</thead>
|
||||
<tbody>
|
||||
{runsWithCost.slice(0, 10).map((run) => {
|
||||
const u = run.usageJson as Record<string, unknown>;
|
||||
const metrics = runMetrics(run);
|
||||
return (
|
||||
<tr key={run.id} className="border-b border-border last:border-b-0">
|
||||
<td className="px-3 py-2">{formatDate(run.createdAt)}</td>
|
||||
<td className="px-3 py-2 font-mono">{run.id.slice(0, 8)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.input_tokens ?? 0))}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.output_tokens ?? 0))}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(metrics.input)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(metrics.output)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{(u.cost_usd || u.total_cost_usd)
|
||||
? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}`
|
||||
{metrics.cost > 0
|
||||
? `$${metrics.cost.toFixed(4)}`
|
||||
: "-"
|
||||
}
|
||||
</td>
|
||||
|
||||
@@ -147,6 +147,7 @@ export function ApprovalDetail() {
|
||||
const payload = approval.payload as Record<string, unknown>;
|
||||
const linkedAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
const isActionable = approval.status === "pending" || approval.status === "revision_requested";
|
||||
const isBudgetApproval = approval.type === "budget_override_required";
|
||||
const TypeIcon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const showApprovedBanner = searchParams.get("resolved") === "approved" && approval.status === "approved";
|
||||
const primaryLinkedIssue = linkedIssues?.[0] ?? null;
|
||||
@@ -260,7 +261,7 @@ export function ApprovalDetail() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isActionable && (
|
||||
{isActionable && !isBudgetApproval && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -280,6 +281,11 @@ export function ApprovalDetail() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isBudgetApproval && approval.status === "pending" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Resolve this budget stop from the budget controls on <Link to="/costs" className="underline underline-offset-2">/costs</Link>.
|
||||
</p>
|
||||
)}
|
||||
{approval.status === "pending" && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ import { ActivityRow } from "../components/ActivityRow";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn, formatCents } from "../lib/utils";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, PauseCircle } from "lucide-react";
|
||||
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
@@ -210,6 +210,25 @@ export function Dashboard() {
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{data.budgets.activeIncidents > 0 ? (
|
||||
<div className="flex items-start justify-between gap-3 rounded-xl border border-red-500/20 bg-[linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02))] px-4 py-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-300" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-50">
|
||||
{data.budgets.activeIncidents} active budget incident{data.budgets.activeIncidents === 1 ? "" : "s"}
|
||||
</p>
|
||||
<p className="text-xs text-red-100/70">
|
||||
{data.budgets.pausedAgents} agents paused · {data.budgets.pausedProjects} projects paused · {data.budgets.pendingApprovals} pending budget approvals
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/costs" className="text-sm underline underline-offset-2 text-red-100">
|
||||
Open budgets
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-1 sm:gap-2">
|
||||
<MetricCard
|
||||
icon={Bot}
|
||||
@@ -251,12 +270,14 @@ export function Dashboard() {
|
||||
/>
|
||||
<MetricCard
|
||||
icon={ShieldCheck}
|
||||
value={data.pendingApprovals}
|
||||
value={data.pendingApprovals + data.budgets.pendingApprovals}
|
||||
label="Pending Approvals"
|
||||
to="/approvals"
|
||||
description={
|
||||
<span>
|
||||
Awaiting board review
|
||||
{data.budgets.pendingApprovals > 0
|
||||
? `${data.budgets.pendingApprovals} budget overrides awaiting board review`
|
||||
: "Awaiting board review"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { CommentThread } from "../components/CommentThread";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
@@ -417,9 +417,7 @@ export function IssueDetail() {
|
||||
"cached_input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
);
|
||||
const runCost =
|
||||
usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") ||
|
||||
usageNumber(result, "total_cost_usd", "cost_usd", "costUsd");
|
||||
const runCost = visibleRunCostUsd(usage, result);
|
||||
if (runCost > 0) hasCost = true;
|
||||
if (runInput + runOutput + runCached > 0) hasTokens = true;
|
||||
input += runInput;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS, isUuidLike } from "@paperclipai/shared";
|
||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -14,6 +15,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
@@ -296,6 +298,14 @@ export function ProjectDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: budgetOverview } = useQuery({
|
||||
queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
|
||||
queryFn: () => budgetsApi.overview(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Projects", href: "/projects" },
|
||||
@@ -377,6 +387,53 @@ export function ProjectDetail() {
|
||||
}
|
||||
}, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]);
|
||||
|
||||
const projectBudgetSummary = useMemo(() => {
|
||||
const matched = budgetOverview?.policies.find(
|
||||
(policy) => policy.scopeType === "project" && policy.scopeId === (project?.id ?? routeProjectRef),
|
||||
);
|
||||
if (matched) return matched;
|
||||
return {
|
||||
policyId: "",
|
||||
companyId: resolvedCompanyId ?? "",
|
||||
scopeType: "project",
|
||||
scopeId: project?.id ?? routeProjectRef,
|
||||
scopeName: project?.name ?? "Project",
|
||||
metric: "billed_cents",
|
||||
windowKind: "lifetime",
|
||||
amount: 0,
|
||||
observedAmount: 0,
|
||||
remainingAmount: 0,
|
||||
utilizationPercent: 0,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: false,
|
||||
status: "ok",
|
||||
paused: Boolean(project?.pausedAt),
|
||||
pauseReason: project?.pauseReason ?? null,
|
||||
windowStart: new Date(),
|
||||
windowEnd: new Date(),
|
||||
} satisfies BudgetPolicySummary;
|
||||
}, [budgetOverview?.policies, project, resolvedCompanyId, routeProjectRef]);
|
||||
|
||||
const budgetMutation = useMutation({
|
||||
mutationFn: (amount: number) =>
|
||||
budgetsApi.upsertPolicy(resolvedCompanyId!, {
|
||||
scopeType: "project",
|
||||
scopeId: project?.id ?? routeProjectRef,
|
||||
amount,
|
||||
windowKind: "lifetime",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
if (!resolvedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
|
||||
},
|
||||
});
|
||||
|
||||
if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
}
|
||||
@@ -469,6 +526,15 @@ export function ProjectDetail() {
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{resolvedCompanyId ? (
|
||||
<BudgetPolicyCard
|
||||
summary={projectBudgetSummary}
|
||||
compact
|
||||
isSaving={budgetMutation.isPending}
|
||||
onSave={(amount) => budgetMutation.mutate(amount)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === "overview" && (
|
||||
<OverviewContent
|
||||
project={project}
|
||||
|
||||
Reference in New Issue
Block a user