mcpField
O
openrouter-analytics-query

openrouter-analytics-query

github.com/openrouterteamData & databases309289 installsUpdated 1d ago
Repository

About openrouter-analytics-query

openrouter-analytics-query is a skill for building and running OpenRouter analytics queries, intended for users examining API usage, cost, or performance. It documents query parameters, classifier dimensions and filters, and a CLI script that returns JSON.

Key features

  1. 01Builds queries from metrics and dimensions
  2. 02Sets time granularity, ranges, and filters
  3. 03Supports classifier dimensions and filters
  4. 04Provides CLI query parameters
  5. 05Returns results and metadata as JSON

Use cases

  1. 01Summarize request volume or cost by model
  2. 02Filter data for a specific model or provider
  3. 03Inspect metric changes over time
  4. 04Check whether row limits truncated results

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/openrouterteam/skills --skill openrouter-analytics-query

After installing, type/openrouter-analytics-queryin Claude Code to confirm it loaded.

Bundled filesREADME.md

Details

From the project README

OpenRouter Analytics Query Execution

Full reference for constructing and executing analytics queries against the OpenRouter API.

Prerequisites

Query Endpoint

POST https://openrouter.ai/api/v1/analytics/query
Authorization: Bearer sk-or-v1-...
Content-Type: application/json

Or via the openrouter-analytics skill scripts:

cd <openrouter-analytics-skill-path>/scripts && npx tsx query-analytics.ts --metrics request_count

Source of truth (avoid drift): the metric/dimension names and the request schema below are illustrative. The live, authoritative list comes from the meta endpoint — call GET /api/v1/analytics/meta (or discover-schema.ts) and treat its response as canonical. For agent-readable docs, OpenRouter publishes llms.txt. Prefer those over the inline examples in this file if they ever disagree.

Request Schema

{
  "metrics": ["request_count", "total_usage"],
  "dimensions": ["model"],
  "granularity": "day",
  "time_range": {
    "start": "2026-05-01T00:00:00Z",
    "end": "2026-05-20T00:00:00Z"
  },
  "filters": [
    { "field": "model", "operator": "eq", "value": "anthropic/claude-sonnet-4" }
  ],
  "order_by": { "field": "total_usage", "direction": "desc" },
  "limit": 100,
  "group_limit": 20,
  "classifier_dimensions": {
    "classifier_id": "<uuid>",
    "dimension_names": ["category"],
    "include_nulls": false
  },
  "classifier_filters": {
    "classifier_id": "<uuid>",
    "filters": [
      { "field": "sentiment", "operator": "eq", "value": "positive" }
    ]
  }
}
Required Fields
FieldTypeDescription
metricsstring[]At least one metric to compute. Call the meta endpoint to see available metrics.
Optional Fields
FieldTypeDefaultDescription
dimensionsstring[][]Up to 2 dimensions to group by
granularitystringnoneTime bucketing: minute, hour, day, week, month
time_rangeobjectlast 7 days{ start, end } as ISO 8601 UTC datetime strings. Seconds are required (YYYY-MM-DDTHH:MM:SSZ; fractional seconds allowed) — minute-precision values such as 2026-05-01T00:00Z are rejected with 400.
filtersobject[][]Up to 20 filter conditions
order_byobjecttime desc (if granularity set){ field, direction } where field is a metric, dimension, or "date" (short-form alias — maps to date__day, date__hour, etc. based on granularity)
limitinteger1000Maximum total rows to return (1–10,000). On time-series queries with dimensions and no explicit group_limit, the server may raise this to accommodate the expected number of time-bucket/dimension combinations.
group_limitintegerauto-computedMaximum rows per distinct dimension combination (ClickHouse LIMIT n BY). When omitted on time-series queries (granularity + dimensions), auto-computed from the time range to guarantee full time-window coverage per group. Explicit values override the default. Ignored when no dimensions are specified.
classifier_dimensionsobjectnoneGroup by dynamic classifier-produced dimensions. See Classifier Dimensions below.
classifier_filtersobjectnoneFilter on classifier-produced dimension values. See Classifier Filters below.
Filter Object Shape
{ "field": "<dimension_name>", "operator": "<op>", "value": "<value>" }
  • Scalar operators (eq, neq, gt, gte, lt, lte): value is a string or number
  • Array operators (in, not_in): value is an array of strings or numbers
  • in / not_in accept an optional include_unset: true, which also matches rows where the dimension has no value. Supported only on dimensions with an unset bucket (api_key_id, app, user) — otherwise the request is rejected with 400.
  • Several dimensions are label-resolved in query results (returned as human-readable names), but filters must use the underlying ID:
    • api_key_id — numeric ID (from generation metadata) or 64-char SHA-256 hash (from GET /api/v1/keys). Hashes are auto-resolved to numeric IDs before querying.
    • user — Clerk user ID (e.g. user_abc123), not the display name/email shown in results.
    • workspace — workspace UUID, not the workspace name shown in results.
    • app — numeric app ID, not the app title shown in results.
    • model — permaslug (e.g. openai/gpt-4o), not the display name.
  • Other dimensions (provider, origin, country, finish_reason, external_user, etc.) are not enriched — filter values match what's returned in results.
Order By
{ "field": "<metric_or_dimension_or_date>", "direction": "asc" | "desc" }

When granularity is set and no order_by is specified, results are ordered by time descending.

Classifier Dimensions

Classifier dimensions allow grouping by dynamic, user-defined classification labels (e.g., topic, sentiment, category) produced by a classifier attached to your account.

{
  "classifier_dimensions": {
    "classifier_id": "550e8400-e29b-41d4-a716-446655440000",
    "dimension_names": ["category"],
    "include_nulls": false
  }
}
FieldTypeRequiredDescription
classifier_idstring (UUID)YesID of the classifier (must belong to the caller's account)
dimension_namesstring[]NoSpecific dimension names to group by (max 2). If omitted, all classifier dimensions are included. Names must be valid identifiers (letters, digits, underscores; max 64 chars).
include_nullsbooleanNoWhen true, unclassified rows are included in results. Default false (only classified rows).

Constraints:

  • Limits the query time range to 31 days
  • Single dimension name → result column is aliased to that name (e.g., category)
  • Two dimension names → each becomes its own result column, cross-grouping results by both values
  • dimension_names omitted → result uses generic clf_dimension_name / clf_dimension_value columns (one row per dimension/value pair)

Classifier Filters

Classifier filters narrow results to generations matching specific classification values. They can be used independently or alongside classifier_dimensions.

{
  "classifier_filters": {
    "classifier_id": "550e8400-e29b-41d4-a716-446655440000",
    "filters": [
      { "field": "category", "operator": "eq", "value": "billing" },
      { "field": "sentiment", "operator": "in", "value": ["positive", "neutral"] }
    ]
  }
}
FieldTypeRequiredDescription
classifier_idstring (UUID)YesID of the classifier (must belong to the caller's account)
filtersobject[]Yes1–10 filter conditions on classifier dimensions
filters[].fieldstringYesClassifier dimension name to filter on
filters[].operatorstringYesOne of: eq, neq, in, not_in (no gt/lt — values are strings)
filters[].valuestring | string[]YesScalar for eq/neq, array for in/not_in

Constraints:

  • Limits the query time range to 31 days
  • Only equality/set operators supported (classification values are strings — ordered comparisons would be lexicographic)
  • All filter field names must be configured dimensions on the classifier

Response Schema

{
  "data": {
    "data": [
      { "date__day": "2026-05-19", "model": "anthropic/claude-sonnet-4", "request_count": "1523", "total_usage": 4.27 },
      { "date__day": "2026-05-18", "model": "openai/gpt-4o", "request_count": "892", "total_usage": 2.15 }
    ],
    "metadata": {
      "query_time_ms": 142,
      "row_count": 2,
      "truncated": false
    },
    "cachedAt": 1747699200000,
    "warnings": ["Could not resolve api_key_id hash: abc123..."]
  }
}
Response Fields
FieldDescription
data.dataArray of result rows. Each row has keys for requested metrics, dimensions, and date__<granularity> (when granularity is set). For classifier_dimensions queries with a single dimension_name, a column is aliased to that name (e.g., category). With two names, each name gets its own column and rows are cross-grouped by both. With dimension_names omitted, rows include clf_dimension_name and clf_dimension_value columns, one row per dimension/value pair.
data.metadata.query_time_msQuery execution time in milliseconds
data.metadata.row_countNumber of rows returned
data.metadata.truncatedtrue if results were truncated at the limit
data.cachedAtUnix timestamp (ms) when the result was cached. Present when the response was served from cache
data.warningsOptional array of non-fatal warnings (e.g., unresolvable api_key_id hashes). The query still executes normally; these inform the caller about filter resolution issues.

Numeric types: Count metrics (request_count, tokens_*, etc.) are returned as strings ("1523"). Cost and rate metrics (total_usage, cache_hit_rate, latency, throughput) are returned as numbers (4.27). Parse count values with Number() or parseInt() before arithmetic.

Label resolution: Dimensions api_key_id, app, user, and workspace return human-readable labels in data rows (key names, app titles, user names, workspace names), not raw IDs. Sentinel buckets are labelled rather than returned raw: api_key_id -1 → Chatroom, app -1 → Unknown, and the max-UUID workspace sentinel → Unattributed. model is never relabelled — it stays a permaslug.

CLI Reference

The query-analytics.ts script in the openrouter-analytics skill accepts these flags:

FlagDescriptionExample
--api-keyAPI key (falls back to OPENROUTER_API_KEY env var)--api-key sk-or-v1-...
--metricsComma-separated metric names (required)--metrics request_count,total_usage
--dimensionsComma-separated dimension names--dimensions model,provider
--granularityTime bucket size--granularity day
--startTime range start (ISO 8601 UTC, seconds required)--start 2026-05-01T00:00:00Z
--endTime range end (ISO 8601 UTC, seconds required)--end 2026-05-20T00:00:00Z
--filter-fieldFilter dimension name (first filter; see notes below)--filter-field model
--filter-opFilter operator (first filter)--filter-op eq
--filter-valueFilter value (comma-separated for in/not_in)--filter-value anthropic/claude-sonnet-4
--filter-field-NDimension name for the Nth additional filter (N = 1–19)--filter-field-1 provider
--filter-op-NOperator for the Nth additional filter--filter-op-1 eq
--filter-value-NValue for the Nth additional filter--filter-value-1 anthropic
--order-byField to sort by--order-by total_usage
--order-dirSort direction--order-dir desc
--limitMax total rows (1–10000)--limit 100
--group-limitMax rows per dimension combination (1–10000). When omitted on time-series queries with dimensions, auto-computed server-side.--group-limit 50

The CLI prints a single JSON object to stdout with two keys — data (the result rows) and metadata:

{
  "data": [ { "model": "anthropic/claude-sonnet-4", "total_usage": 4.27 } ],
  "metadata": { "query_time_ms": 142, "row_count": 2, "truncated": false }
}

A human-readable stats line (row count, query time, truncation/cache flags) is written to stderr for terminal use only.

When parsing output programmatically, always check metadata.truncated. If true, the result was capped at --limit and is a partial dataset — increase --limit or paginate before reporting totals/rankings. Dimensions api_key_id, user, app, and workspace are already resolved to human-readable names in the data rows.

Multi-filter queries: the CLI builds a multi-element filters array (ANDed together) from the unindexed base flag (--filter-field/--filter-op/--filter-value) plus the indexed --filter-field-N/--filter-op-N/--filter-value-N flags. Each filter must supply all three parts (field, op, value); a partial triplet is rejected. Up to 20 filters total (the base flag plus indices 1–19), matching the API cap. Indices may be sparse (e.g. base + -2 with -1 omitted is fine — gaps are skipped, not silently dropped). For a query like model = X AND provider = Y:

npx tsx query-analytics.ts --metrics request_count \
  --filter-field model --filter-op eq --filter-value anthropic/claude-sonnet-4 \
  --filter-field-1 provider --filter-op-1 eq --filter-value-1 anthropic

Flag-value caveat: the CLI's argument parser treats any token starting with -- as a new flag, so a filter value that begins with -- cannot be passed via the CLI flags. Dimension values (model IDs, provider names, etc.) do not start with --, so this is rarely an issue in practice — but if you need such a value, use the direct curl form below instead.

Direct API Usage (curl)

If you prefer calling the API directly instead of using the scripts:

curl -X POST https://openrouter.ai/api/v1/analytics/query \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "metrics": ["total_usage", "request_count"],
    "dimensions": ["model"],
    "granularity": "day",
    "time_range": {
      "start": "2026-05-13T00:00:00Z",
      "end": "2026-05-20T00:00:00Z"
    },
    "order_by": { "field": "total_usage", "direction": "desc" },
    "limit": 10,
    "group_limit": 7
  }'

Query Construction Guide

Aggregates (no time series)

Omit granularity to get a single aggregate row per dimension combination:

{
  "metrics": ["total_usage", "request_count"],
  "dimensions": ["model"],
  "order_by": { "field": "total_usage", "direction": "desc" },
  "limit": 10
}
Time Series (with granularity)

Add granularity to get one row per time bucket (and per dimension combination if dimensions are set):

{
  "metrics": ["request_count"],
  "granularity": "day",
  "time_range": {
    "start": "2026-05-01T00:00:00Z",
    "end": "2026-05-20T00:00:00Z"
  }
}
Filtered Queries

Narrow results with filters. Multiple filters are ANDed:

{
  "metrics": ["total_usage", "avg_latency"],
  "dimensions": ["provider"],
  "filters": [
    { "field": "model", "operator": "eq", "value": "anthropic/claude-sonnet-4" }
  ]
}
Multi-Dimension Queries

Combine up to 2 dimensions for cross-tabulation:

{
  "metrics": ["request_count"],
  "dimensions": ["model", "provider"],
  "order_by": { "field": "request_count", "direction": "desc" },
  "limit": 20
}

Error Handling

StatusMeaningAction
400Invalid query (malformed JSON body, bad metric name, too many dimensions, invalid time range)Check the meta endpoint for valid values. Verify time range start < end and that both timestamps include seconds. Max 2 dimensions, 20 filters. Possible-cache metrics combined with an unsupported dimension, filter, classifier, or minute granularity also return 400.
401Invalid or missing API keyCheck OPENROUTER_API_KEY is set correctly
403Not a management keyThe key must be a provisioning/management key. Create one at openrouter.ai/settings/management-keys
408Query timed outNarrow the time range, reduce dimensions, or add filters to scan less data
429Rate limited (64 RPM)Wait and retry
500Server errorRetry after a moment

Time Range Behavior

start and end must be ISO 8601 UTC timestamps that include seconds (2026-05-01T00:00:00Z). Fractional seconds are accepted; minute-precision timestamps and non-UTC offsets are rejected before the query runs.

Some metric/dimension combinations support time ranges up to 367 days (with daily granularity), while others are limited to 31 days. The server resolves this automatically based on the requested metrics and dimensions.

Usage breakdown metrics follow the same pattern: credits_usage, usage_upstream, usage_cache, usage_data, usage_web, and usage_upstream_web support up to 367 days, while openrouter_usage, byok_fees, usage_file, usage_upstream_file, usage_web_fetch, and usage_upstream_web_fetch are limited to 31 days.

Possible-cache metrics (possible_cached_tokens, possible_cache_hit_rate, cache_capture_rate) always read an hourly rollup. They are limited to 31 days regardless of granularity, support only model and provider dimensions and filters, and are rejected with 400 when combined with minute granularity, classifier dimensions, or classifier filters.

hour granularity reads the minute materialized view, so it caps the range at 31 days even for metrics that otherwise allow 367.

Classifier dimensions and classifier filters always force the 31-day time range limit.

If a query times out, try:

  • Narrowing the time range
  • Removing latency/throughput metrics
  • Removing per-generation dimensions (provider, origin, country, finish_reason, etc.)
  • Removing classifier dimensions/filters (they are more expensive to compute)

Comments

Markdown supported