SOLWYN

Reading cost views

Query cost ranges, agent runs, tags, grouping, modality, and blocked-call savings.

A project's Costs tab shows what the project spent and where. Every number on it comes from bounded metadata — token counts, media quantities, run identifiers, and selectors the SDK reported. Nothing here was computed from your prompts or responses, because Solwyn never saw them.

Ranges and grouping

The range toggle drives the whole tab: 7d, 30d, 90d, or 365d. Group the dashboard breakdown by day, model, provider, or agent run — model groups are split by model and provider, because the same model id can price differently on different providers — or choose Tag and enter a tag key.

The cost API accepts these group_by values:

  • model, provider, day
  • status, service_tier, provider_region, failover_reason
  • agent_run
  • tag:<key>, such as tag:environment

Availability is tier-dependent: Free groups by day; Production adds model and provider; Team and Scale unlock the rest, including tag and agent-run grouping. An unavailable dimension answers 402 with the tier that unlocks it. Modality is deliberately not a grouping axis. There are no per-modality cost roll-ups — the product is model-first, and a model's row already carries its modality.

For every grouping dimension except day, results are ordered by total_cost descending and return at most 100 groups. The JSON response sets truncated: true when more groups matched. group_by=day instead returns every day that has data in the window, in chronological order, and never truncates; days with no spend produce no row. Grouped exports report truncation in an X-Solwyn-Truncated header; the JSON endpoints use the body field only.

Preset windows are tier-gated: the Free tier has a 7-day range; wider ranges unlock by tier, up to 365 days on Scale. Cost endpoints also accept a custom from + to pair of timezone-aware datetimes. Both bounds are required, to must be later than from, the duration cannot exceed the tier's analytics window, and the effective result is always clamped to retained data. Custom bounds override range. Every response carries a window object with the requested and effective bounds and whether retention clamped them. The default range is 7d everywhere except /costs/export, where it is 30d. See Plans & billing for the exact gates.

Every cost and tag read below accepts either a project API key (sk_proj_…) or a signed-in session token on the same Authorization: Bearer header; the examples use a session token. Only tag retirement is session-only.

Filter cost data

The aggregate, total, event-list, and export surfaces all use the same tag-filter convention:

tag.<key>=<exact-value>

<key> is a placeholder, not literal text. For example, filter production traffic for an enterprise customer tier like this:

curl --get \
  "https://api.solwyn.ai/api/v1/projects/${SOLWYN_PROJECT_ID}/costs" \
  --header "Authorization: Bearer ${SOLWYN_ACCESS_TOKEN}" \
  --data-urlencode "range=30d" \
  --data-urlencode "group_by=agent_run" \
  --data-urlencode "tag.environment=production" \
  --data-urlencode "tag.customer_tier=enterprise"

Multiple tag filters use AND semantics. Each comparison is exact and case-sensitive; an empty value is a legal filter. Repeating the same tag.<key> parameter, a key outside 1–64 characters, a value over 256, or a NUL in either returns 422. Tag filtering is available on Team and Scale on:

  • GET /api/v1/projects/{project_id}/costs
  • GET /api/v1/projects/{project_id}/costs/total
  • GET /api/v1/projects/{project_id}/costs/events
  • GET /api/v1/projects/{project_id}/costs/export

These cost surfaces also accept two explicit run filters:

  • agent_run=<raw-id> takes the caller-visible id yielded by solwyn.run(...); the server namespaces it before matching.
  • agent_run_id=<stored-id> takes the stored run_ id returned by /agent-runs and cost-event responses.

Do not send both in one request; the API returns 422 rather than guessing which identity you meant. A raw id can happen to resemble a stored id, so the parameter name — not a format heuristic — defines the interpretation.

/costs/events returns each event's tags on tag-entitled tiers, its agent_run_id, and — for a denied call — its denied_cost, deny_source, and deny_reason. The dashboard's events table shows the denied cost and a denial-source badge only; it does not yet filter or display individual tags or run ids. Raw CSV and NDJSON exports carry those fields as columns, including cost_usd, denied_cost_usd, deny_source, deny_reason, agent_run_id, and tags.

Grouping costs by tag

Tags attached via the SDK — client defaults, solwyn.run(name, tags={...}) scope tags, or per-call solwyn_tags={...} — power tag-based showback. Use group_by=tag:<key> to group spend by that key's values:

curl --get \
  "https://api.solwyn.ai/api/v1/projects/${SOLWYN_PROJECT_ID}/costs" \
  --header "Authorization: Bearer ${SOLWYN_ACCESS_TOKEN}" \
  --data-urlencode "range=30d" \
  --data-urlencode "group_by=tag:customer_tier"

Events without the key fall under (untagged). A literal tag value of (untagged) shares that same group, so avoid using the sentinel as a real value. Empty-string values are legal and form their own group; they mean "present, empty," which is distinct from an absent key.

Tag discovery and limits

Tag limits protect both event size and the account-wide discovery namespace:

BoundLimit
Keys on one captured event10
Tag key length1–64 characters
Tag value length0–256 characters
Active tag keys across an account100
Distinct values for one key across an account1,000

The 100-key and 1,000-value caps are account-scoped and durable: analytics retention does not make registry identities disappear. An event that would introduce a key or value beyond those caps is rejected from ingest with the code tag_cardinality_exceeded (an event breaking the per-event bounds is invalid_tags); the SDK logs the rejection and the rest of the batch is recorded. SDK calls follow the clamp-never-abort behavior (SDK v0.5.0+) when individually valid client, scope, and call mappings combine to more than 10 keys.

Discover active keys from any project in the account. Discovery, values, filtering, and grouping are available on Team and Scale:

curl --get \
  "https://api.solwyn.ai/api/v1/projects/${SOLWYN_PROJECT_ID}/tags" \
  --header "Authorization: Bearer ${SOLWYN_ACCESS_TOKEN}"

The response returns keys with values_count, first_seen_at, and last_seen_at, plus usage: {keys_used, keys_cap, values_cap_per_key} and scope: "account". A key's similar list surfaces active case/whitespace variants. It does not merge them: keys are stored and queried verbatim.

Prefer one lowercase_snake_case convention for keys, such as environment, customer_tier, and agent_name. Solwyn does not normalize case or whitespace because silently merging distinct keys would change customer intent.

List the values for one active key with keyset pagination:

curl --get \
  "https://api.solwyn.ai/api/v1/projects/${SOLWYN_PROJECT_ID}/tags/values" \
  --header "Authorization: Bearer ${SOLWYN_ACCESS_TOKEN}" \
  --data-urlencode "key=customer_tier" \
  --data-urlencode "q=enter" \
  --data-urlencode "limit=25"

q is an optional, case-sensitive value-prefix filter. limit is 1–100 (default 25); continue with the opaque next_cursor while has_more is true — a cursor is bound to its key and prefix. The endpoint takes the key as a query parameter because legal keys may contain /, ?, #, or spaces; only NUL is forbidden. An unknown or retired key answers 404.

Account owners and admins can retire a key with a session-authenticated request:

curl --get --request DELETE \
  "https://api.solwyn.ai/api/v1/account/tags" \
  --header "Authorization: Bearer ${SOLWYN_ACCESS_TOKEN}" \
  --data-urlencode "key=temporary_experiment"

Retirement is idempotent and available on every tier: retiring an already-retired key answers 204 again, a key the account never registered answers 404, and a member without the admin role 403. It hides the key from discovery and frees one active-key slot, but leaves historical events and registered values untouched. A later successful ingest can reactivate the key when the account has capacity.

Agent-run analytics

"Spend per agent, last 30 days, with names" is one request:

curl --get \
  "https://api.solwyn.ai/api/v1/projects/${SOLWYN_PROJECT_ID}/costs" \
  --header "Authorization: Bearer ${SOLWYN_ACCESS_TOKEN}" \
  --data-urlencode "range=30d" \
  --data-urlencode "group_by=agent_run"

Each group includes the stored id as group_key and the attached agent_run_name. Events that carry no run id at all group under the sentinel (unattributed) with a null agent_run_name — rare in practice, because the SDK's ingest normally assigns unscoped calls an _auto-… per-day run. Each event returned by /costs/events also includes its stored agent_run_id.

Use GET /api/v1/projects/{project_id}/agent-runs to explore run records and hierarchy:

  • q= is a case-insensitive literal substring search on run name.
  • parent= lists direct children and accepts either a raw caller-visible id or a stored id.
  • limit= is 1–100 (default 25); paginate with the opaque cursor.
  • sort= accepts last_seen_at, total_cost, or total_calls.
  • range= accepts 7d, 30d, 90d, or 365d, gated by the tier's analytics ranges like every cost surface; custom from + to bounds follow the same tier-window and retention clamp.

Run entries include the stored id, name, parent_run_id, timestamps, lifetime totals, advisory velocity_flags, and the stop state (stopped_at, stopped_by_user_id, stoppable). The window selects which runs qualify by last_seen_at; the entry's total_cost and call/token counters are lifetime aggregates for that run, not recalculated window totals. Parent filtering is one level at a time; there is no recursive roll-up endpoint. GET …/agent-runs/{run_id} returns one entry. The dashboard's Agents tab is the visual face of this listing, and where a run is stopped.

Tag-scoped budgets

A scoped_budget rule can cap every call carrying one exact tag pair. In the rule entry, match is the tag key and match_value is the tag value:

{
  "type": "scoped_budget",
  "scope": "tag",
  "match": "customer_tier",
  "match_value": "enterprise",
  "limit": 50.0,
  "mode": "hard_deny",
  "channel_ids": []
}

This object is one entry in the thresholds array sent to PUT /api/v1/projects/{project_id}/budget. match_value is required for scope: "tag" and is invalid for the other scoped-budget types. The rule response reports current_usage alongside its limit, or null when live usage is temporarily unavailable.

The SDK sends the captured tag snapshot with budget admission. A check without tags cannot be denied by a tag rule; the later ingest still settles matching spend so usage converges. See Scoped budgets for the other scopes, the per-rule mode, and the entitlement and duplicate-selector rules.

Modality on the events table

The cost-events table shows modality two ways:

  • A per-row badge on the model cell — Image, Audio, Video, or Embedding. Text is the baseline and carries no badge.
  • A filter above the table: All modalities / Text / Image / Audio / Video / Embedding.

Expanding a non-text event shows its Media Units — the non-token quantities the call was priced on, like 3 images · 1024x1024 · hd or a video's seconds. When a quantity is an SDK-side estimate rather than a provider-reported count, it renders with a ~ prefix and a tooltip saying so — the same convention estimated token counts use.

The two kinds of $0

Two different facts can put a $0 on this page, and the UI keeps them apart — each has its own tooltip:

  • Local — $0 by design. Calls to self-hosted providers (ollama, vllm, lmstudio). They genuinely cost nothing; the neutral note exists for visibility, and they are included in call counts.
  • Unpriced — real spend Solwyn declined to guess at. A call that cost real money but exposed no observable quantity to price on. It is flagged with a warning tone and excluded from cost totals — the hero total says so whenever unpriced events exist in the range. Never a fabricated $0.

Every cost group carries both counts — local_call_count and unpriced_call_count — alongside its call_count. This is the dashboard face of the surface-coverage honesty lane: recorded, visible, explicitly not costed.

Blocked-call savings

When a budget, a per-run cap, a scoped rule, or a run stop blocks calls, the Costs tab shows a savings callout — the blocked call count and an estimated ~$ saved. The figure is built from the SDK's denial receipts: saved_cost prices the input tokens the denied calls declared, and saved_cost_upper adds the output allowance each would have reserved, when at least one receipt in the range carries an output bound and none of them is a replayed aggregate — receipts without a bound contribute zero. Blocked calls never appear in spend; they never happened. A denied call counts as the number of receipts it stands for, so an aggregate the SDK replayed after an outage still counts every call. Denied events remain visible on the events table with their denied cost and denial source; /costs/events and the exports add deny_reason. See Budgets.

Next

On this page