Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions alembic/versions/e8a7c6b5d4f3_add_agent_telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Add coding-agent telemetry storage.

Revision ID: e8a7c6b5d4f3
Revises: f0a1b2c3d4e5
Create Date: 2026-08-06 12:00:00.000000

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "e8a7c6b5d4f3"
down_revision: str | Sequence[str] | None = "f0a1b2c3d4e5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Create the content-free coding-agent telemetry table."""
op.create_table(
"agent_telemetry",
sa.Column("id", sa.String(), nullable=False),
sa.Column("api_key_id", sa.String(), nullable=True),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("tool_name", sa.String(), nullable=True),
sa.Column("decision", sa.String(), nullable=True),
sa.Column("success", sa.Boolean(), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column("status_code", sa.Integer(), nullable=True),
sa.Column("prompt_length", sa.Integer(), nullable=True),
sa.Column("source", sa.String(), nullable=False),
sa.Column("session_label", sa.String(), nullable=True),
sa.Column("dedup_key", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["api_key_id"], ["api_keys.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("source", "dedup_key", name="uq_agent_telemetry_source_dedup"),
)
op.create_index("ix_agent_telemetry_api_key_id", "agent_telemetry", ["api_key_id"])
op.create_index("ix_agent_telemetry_user_id", "agent_telemetry", ["user_id"])
op.create_index("ix_agent_telemetry_timestamp", "agent_telemetry", ["timestamp"])
op.create_index("ix_agent_telemetry_source", "agent_telemetry", ["source"])
op.create_index("ix_agent_telemetry_user_id_timestamp", "agent_telemetry", ["user_id", "timestamp"])

# Deliberately nullable with no server_default: NULL is the "inherit the
# gateway setting" state, so existing rows land there on ADD COLUMN and keep
# behaving exactly as they do today.
op.add_column("api_keys", sa.Column("capture_agent_telemetry", sa.Boolean(), nullable=True))


def downgrade() -> None:
"""Drop coding-agent telemetry storage."""
with op.batch_alter_table("api_keys") as batch_op:
batch_op.drop_column("capture_agent_telemetry")
op.drop_table("agent_telemetry")
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ into something a text-only local model can read.
| `POST` | `/v1/keys` | Create an API key. | Master key |
| `GET` | `/v1/keys` | List all API keys. | Master key |
| `GET` | `/v1/keys/{key_id}` | Get a specific key. | Master key |
| `PATCH` | `/v1/keys/{key_id}` | Update a key (name, active status, expiration, allowed models, `exclude_from_budget`, `reject_user_mismatch`, metadata). | Master key |
| `PATCH` | `/v1/keys/{key_id}` | Update a key (name, active status, expiration, allowed models, `exclude_from_budget`, `reject_user_mismatch`, `capture_agent_telemetry`, metadata). | Master key |
| `POST` | `/v1/keys/{key_id}/rotate` | Replace a key's secret in place (id, user, name, expiry, and metadata preserved); returns the new key once. The previous secret stops working immediately. | Master key |
| `DELETE` | `/v1/keys/{key_id}` | Revoke a key. | Master key |

Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ pricing:
| `require_pricing` | bool | `true` | Reject requests for models with no configured pricing (HTTP 402, fail-closed). When `false`, unpriced models are served and logged without cost. Audio and moderation endpoints are always exempt. |
| `default_pricing` | bool | `false` | When a model has no pricing in the database, fall back to community-maintained defaults from the bundled genai-prices dataset. Off by default (opt-in). Database pricing always wins. See [Default pricing](#default-pricing). |
| `reject_user_mismatch` | bool | `true` | When `true`, a non-master key whose request names a `user` other than its own is rejected (HTTP 403). When `false`, the client `user` is still forwarded to the provider but spend is always bound to the key's own user. This is the deployment-wide default; an individual key overrides it in either direction with its own `reject_user_mismatch` (`null` inherits). The master key may always bill an arbitrary user. |
| `capture_agent_telemetry` | bool | `true` | When `true`, a behavioral log event (`tool_result`, `tool_decision`, `user_prompt`, `api_error`) received at `POST /v1/logs` is stored as a content-free `agent_telemetry` row. When `false`, those events are discarded before storage. Usage capture and billing are unaffected either way. This is the deployment-wide default; an individual key overrides it in either direction with its own `capture_agent_telemetry` (`null` inherits). See [Use with Claude Code](use-with-claude-code.md). |
| `stream_missing_usage_policy` | string | `"estimate"` | How to bill a streamed response that completes with no provider usage data: `"estimate"` (charge the up-front estimate), `"fail"` (charge estimate and mark errored), or `"allow_free"` (don't bill). |
| `budget_estimate_default_output_tokens` | int | `1024` | Output-token count assumed when reserving budget for a request with no declared max output; reconciled to actual usage on completion. |
| `streaming_keepalive_interval_ms` | int | `15000` | Idle interval after which a streaming response emits a transport keepalive while it waits on the provider: a `ping` event on `/v1/messages`, an SSE comment line (`: keepalive`) on `/v1/chat/completions` and `/v1/responses`. Keeps an intermediary with a read timeout (Cloudflare's default Proxy Read Timeout is 125s) from severing a connection during a long time-to-first-token. Keepalives start once the provider stream is open and never touch usage accounting or extend a first-chunk/failover deadline. `0` disables. |
Expand Down
207 changes: 207 additions & 0 deletions docs/public/openapi.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,116 @@
{
"components": {
"schemas": {
"AgentTelemetryDeleteRequest": {
"description": "Selection of agent_telemetry rows to delete.",
"properties": {
"api_key_id": {
"anyOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"maxItems": 50,
"type": "array"
},
{
"type": "null"
}
],
"title": "Api Key Id"
},
"by_filter": {
"default": false,
"title": "By Filter",
"type": "boolean"
},
"end_date": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "End Date"
},
"ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"maxItems": 1000,
"type": "array"
},
{
"type": "null"
}
],
"title": "Ids"
},
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Name"
},
"start_date": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Start Date"
},
"user_id": {
"anyOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"maxItems": 50,
"type": "array"
},
{
"type": "null"
}
],
"title": "User Id"
}
},
"title": "AgentTelemetryDeleteRequest",
"type": "object"
},
"AgentTelemetryDeleteResult": {
"description": "How many rows the delete removed.",
"properties": {
"deleted": {
"default": 0,
"title": "Deleted",
"type": "integer"
}
},
"title": "AgentTelemetryDeleteResult",
"type": "object"
},
"AliasRequest": {
"description": "Request to create or update an alias.",
"properties": {
Expand Down Expand Up @@ -1171,6 +1281,18 @@
"description": "Model allow-list: null = any model, [] = deny all, or canonical instance:model entries (with instance:* / instance:prefix* wildcards).",
"title": "Allowed Models"
},
"capture_agent_telemetry": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"description": "Per-key override of the deployment-wide capture_agent_telemetry setting: null (default) inherits it, true always stores behavioral events (tool_result, tool_decision, user_prompt, api_error) from POST /v1/logs, false always discards them. Usage capture and billing are unaffected either way.",
"title": "Capture Agent Telemetry"
},
"exclude_from_budget": {
"default": false,
"description": "When true, requests on this key are logged with cost but never reserved, reconciled into the user's spend, or gated by budget.",
Expand Down Expand Up @@ -1253,6 +1375,17 @@
],
"title": "Allowed Models"
},
"capture_agent_telemetry": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Capture Agent Telemetry"
},
"created_at": {
"title": "Created At",
"type": "string"
Expand Down Expand Up @@ -1346,6 +1479,7 @@
"allowed_models",
"exclude_from_budget",
"reject_user_mismatch",
"capture_agent_telemetry",
"metadata"
],
"title": "CreateKeyResponse",
Expand Down Expand Up @@ -2412,6 +2546,17 @@
],
"title": "Allowed Models"
},
"capture_agent_telemetry": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Capture Agent Telemetry"
},
"created_at": {
"title": "Created At",
"type": "string"
Expand Down Expand Up @@ -2512,6 +2657,7 @@
"allowed_models",
"exclude_from_budget",
"reject_user_mismatch",
"capture_agent_telemetry",
"metadata"
],
"title": "KeyInfo",
Expand Down Expand Up @@ -5522,6 +5668,17 @@
],
"title": "Allowed Models"
},
"capture_agent_telemetry": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Capture Agent Telemetry"
},
"exclude_from_budget": {
"anyOf": [
{
Expand Down Expand Up @@ -7663,6 +7820,56 @@
]
}
},
"/v1/agent-telemetry": {
"delete": {
"description": "Delete agent_telemetry rows by explicit ids or by filter (standalone).\n\nTarget either an explicit selection (`ids`) or everything matching a filter\n(`by_filter: true` plus optional `user_id` / `api_key_id` / `name` / date\nrange). A selection matching zero rows succeeds with `deleted: 0`.\nMaster-key only.",
"operationId": "delete_agent_telemetry_rows_v1_agent_telemetry_delete",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AgentTelemetryDeleteRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AgentTelemetryDeleteResult"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"XApiKeyAuth": []
}
],
"summary": "Delete Agent Telemetry Rows",
"tags": [
"agent-telemetry"
]
}
},
"/v1/aliases": {
"get": {
"description": "List every alias in force, from config.yml and from storage.\n\nEvery scope at once, global and user-scoped alike: this is the master-key\nmanagement view, not what any one caller resolves.",
Expand Down
27 changes: 25 additions & 2 deletions docs/public/otari.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,29 @@
],
"name": "health"
},
{
"item": [
{
"name": "Delete Agent Telemetry Rows",
"request": {
"description": "Delete agent_telemetry rows by explicit ids or by filter (standalone).\n\nTarget either an explicit selection (`ids`) or everything matching a filter\n(`by_filter: true` plus optional `user_id` / `api_key_id` / `name` / date\nrange). A selection matching zero rows succeeds with `deleted: 0`.\nMaster-key only.",
"header": [],
"method": "DELETE",
"url": {
"host": [
"{{baseUrl}}"
],
"path": [
"v1",
"agent-telemetry"
],
"raw": "{{baseUrl}}/v1/agent-telemetry"
}
}
}
],
"name": "agent-telemetry"
},
{
"item": [
{
Expand Down Expand Up @@ -972,7 +995,7 @@
"language": "json"
}
},
"raw": "{\n \"allowed_models\": [\n \"string\"\n ],\n \"exclude_from_budget\": false,\n \"expires_at\": \"2024-01-01T00:00:00Z\",\n \"key_name\": \"string\",\n \"metadata\": {},\n \"reject_user_mismatch\": false,\n \"user_id\": \"string\"\n}"
"raw": "{\n \"allowed_models\": [\n \"string\"\n ],\n \"capture_agent_telemetry\": false,\n \"exclude_from_budget\": false,\n \"expires_at\": \"2024-01-01T00:00:00Z\",\n \"key_name\": \"string\",\n \"metadata\": {},\n \"reject_user_mismatch\": false,\n \"user_id\": \"string\"\n}"
},
"description": "Create a new API key.\n\nRequires master key authentication.\n\nIf user_id is provided, the key will be associated with that user (creates user if it doesn't exist).\nIf user_id is not provided, the key is associated with the shared \"default\" user, which is created\non first use. Keys without an explicit owner therefore share one identity, and so share budget,\nusage, and files.",
"header": [
Expand Down Expand Up @@ -1030,7 +1053,7 @@
"language": "json"
}
},
"raw": "{\n \"allowed_models\": [\n \"string\"\n ],\n \"exclude_from_budget\": false,\n \"expires_at\": \"2024-01-01T00:00:00Z\",\n \"is_active\": false,\n \"key_name\": \"string\",\n \"metadata\": {},\n \"reject_user_mismatch\": false\n}"
"raw": "{\n \"allowed_models\": [\n \"string\"\n ],\n \"capture_agent_telemetry\": false,\n \"exclude_from_budget\": false,\n \"expires_at\": \"2024-01-01T00:00:00Z\",\n \"is_active\": false,\n \"key_name\": \"string\",\n \"metadata\": {},\n \"reject_user_mismatch\": false\n}"
},
"description": "Update an API key.\n\nRequires master key authentication.",
"header": [
Expand Down
Loading
Loading