Skip to content
↑↓Navigate↵SelectescClose

Wire Contracts

Complete API surface documentation for all external and internal contracts

Lists every external and internal API surface. No contract exists without a section in this file.

External contracts are client-facing wires (SDKs, provider-compatible APIs, telemetry ingress). Internal contracts are service-to-service (protobuf + gRPC via tonic, or named async event types on the internal bus).

Every shipped surface has a section here; a section that says “not implemented” is authoritative.

Internal: reporting-dimension control-plane REST

Section titled “Internal: reporting-dimension control-plane REST”

Workspace managers use these routes:

  • GET|POST /api/workspaces/{workspace_id}/reporting-dimensions lists and defines string dimensions.
  • DELETE /api/workspaces/{workspace_id}/reporting-dimensions/{definition_id} archives a dimension.
  • GET|PUT /api/api-keys/{api_key_id}/reporting-dimensions lists or sets a dimension value for an API key.
  • DELETE /api/api-keys/{api_key_id}/reporting-dimensions/{definition_id} end-dates an active assignment.

The API returns safe guidance that reporting metadata must not contain secrets. Workspace reports accept optional parameter-bound dimension and dimension_value query parameters. When a dimension is requested, each row returns dimension_value or null when the request did not snapshot that key.

Gateway’s primary edge. Contract reference: ../decisions/0005-openai-compatible-edge.md. Shipped today on the gateway binary and nexus ops serve: POST /v1/chat/completions, POST /v1/responses, POST /v1/embeddings, POST /v1/moderations, POST /v1/images/generations, POST /v1/images/edits, POST /v1/images/variations, GET /v1/models, GET /v1/models/{id}, file lifecycle routes under /v1/files, vector-store lifecycle/search/file-attachment routes under /v1/vector_stores, and POST /v1/traces/ingest (see the trace-ingest acknowledgment section below). Unknown /v1/* routes return HTTP 404 with the OpenAI error envelope. The paragraphs below describe the chat-completions contract in detail.

The routes whose operation is a capability a model either advertises or does not — /v1/embeddings, /v1/moderations, and the three /v1/images/* operations — filter the route’s candidate endpoints against that capability before dispatch, as does /anthropic/v1/messages when the request enables extended thinking. When no candidate survives, the request is rejected with HTTP 400 naming the operation the candidates do not support. On the OpenAI edge that rejection carries error.type / error.code both no_compatible_candidate; on /anthropic/v1/messages it is rendered in Anthropic’s own shape as error.type invalid_request_error with no code field, and the reason in error.message. This is a pre-dispatch rejection: no upstream call is made and no attempt is recorded. The same no_compatible_candidate rejection is returned on any route, including /v1/chat/completions and /v1/responses, when the organization’s provider disallow list excludes every endpoint in the route — the same code with a different cause, distinguished by the reason text in error.message.

The route accepts OpenAI-shaped JSON with model, provider/model, comma-separated fallback lists, or the reserved nexus/auto alias. Non-streaming requests return OpenAI-shaped JSON. For embeddings, request-global malformed input is rejected before dispatch. Candidate-specific validation failures may route to another compatible endpoint; Titan multi-input fan-out becomes terminal once any upstream call has completed, preserving the OpenAI error envelope while accounting for the observed successful calls.

Requests with stream: true return text/event-stream; charset=utf-8, normalize upstream SSE into OpenAI-shaped chat.completion.chunk JSON frames that each carry stable top-level id, object, created, and model, append a terminal error SSE event — followed by data: [DONE] — when an upstream stream read fails, when an upstream sends an in-band {"error": ...} chunk on an otherwise-200 stream, or when a size cap trips; in all three cases the stream ends there and no later upstream content is relayed, and expose x-nexus-request-id. The gateway adds upstream stream_options.include_usage = true for streaming requests so usage can be recorded from the final provider chunk. When the gateway synthesizes the terminal usage chunk itself (Anthropic, Bedrock, and Responses-bridged upstreams), it carries completion_tokens_details.reasoning_tokens whenever the upstream reported a reasoning share.

Requests served by an Anthropic upstream translate the OpenAI chat shape into the Messages API: system and developer messages hoist into the top-level system, and a cache_control hint on such a message object is carried onto the last block that message contributes, the way the other roles carry theirs onto their last content block, since the Messages API accepts the hint only on a block and dropping it would re-bill the whole prefix at full rate on every call. The hoisted system renders as a block array when any block carries a hint and as a joined string otherwise, and both spell the same prompt: every hoisted message after the first opens with the newline dividing it from the one before, while the content parts within one message are concatenated untouched. A caching breakpoint on a message’s final block, and any breakpoint in the first hoisted message, is left byte-identical; one on the block a later message opens with carries that message’s divider. A response_format instruction is appended after a blank line in both forms.

Translation continues: tool_choice maps auto→auto, required→any, none→none, and a named function→{type: "tool", name}; parallel_tool_calls: false becomes disable_parallel_tool_use: true on the tool_choice object (auto when no choice was given), but only when the request declares usable tools and the choice is not none, where no call is permitted at all; and an n other than absent, null, or 1 is refused with HTTP 400 invalid_request_error and error.param: "n": an integer greater than 1 because the Messages API returns one choice per request, and a value that is not a positive integer at all (2.0, "2", 0) rather than read as absent and answered with one choice. The Bedrock adapter refuses n at any non-null value, n: 1 included. The refusal covers the whole route: when any candidate is an Anthropic endpoint the request is refused before any attempt, even where another candidate could serve the field, and the message names those other candidates so the caller can retry with one explicitly.

Responses served by an Anthropic upstream surface provider-specific detail through the OpenAI shape: extended-thinking text appears as reasoning_content on the assistant message (and as reasoning_content stream deltas), the thinking-block signature as reasoning_signature beside it (and as reasoning_signature stream deltas), and cached prompt tokens are reported as usage.prompt_tokens_details.cached_tokens. It is surfaced for OpenAI-shaped clients that want the signature (the native Anthropic edge rebuilds its blocks from the internal content_blocks sidecar, not from this field); clients may ignore it. On the buffered assistant message it is carried only when the response had exactly one thinking block, so the pair (reasoning_content, reasoning_signature) always reconstructs a replayable block; a multi-block buffered response carries the joined reasoning_content alone. The streamed shape is different: it delivers one reasoning_signature delta per thinking block, in order, with no boundary between the blocks, so a multi-block streamed response gives an OpenAI-shaped client signatures it cannot pair with block text. The remaining reasoning-channel fields the Anthropic adapter produces for the native edge (the ordered content_blocks list, tool_calls[].content_index, the matched stop_sequence, and the streamed text_block_start / reasoning_block_start / reasoning_block_stop / reasoning_redacted deltas) are provider-internal and are removed before an OpenAI-shaped body or stream reaches a client; redacted thinking blocks therefore have no representation on the OpenAI shape. An Anthropic-served chat stream carries role on its opening delta, the way OpenAI’s own streams do; what else rides that delta depends on which block the upstream opened with. An echoed reasoning_content or reasoning_signature is dropped from assistant history on an Anthropic-targeted attempt, so it is never replayed to an Anthropic upstream; an OpenAI-shaped provider receives the fields unchanged, and the Bedrock translation carries neither.

External: Anthropic Messages-compatible REST + SSE

Section titled “External: Anthropic Messages-compatible REST + SSE”

Gateway native Anthropic Messages edge, mounted under the /anthropic prefix so /v1/* stays the OpenAI-compatible contract. Contract reference: ../decisions/0012-anthropic-native-edge.md. Primary OpenAI edge: ../decisions/0005-openai-compatible-edge.md. Shipped today: POST /anthropic/v1/messages for text, tools, images, and tool-result turns (buffered or streaming), plus POST /anthropic/v1/messages/count_tokens.

Anthropic SDK clients set their base URL to the gateway’s /anthropic prefix (for example ANTHROPIC_BASE_URL=https://nexus.example.com/anthropic), and the SDK appends /v1/messages itself. The nexus API key is read from x-api-key: nxs_... (the Anthropic SDK default) or Authorization: Bearer nxs_...; upstream Anthropic credentials are never accepted.

The edge translates the request into the internal contract and dispatches it through the same attempt pipeline as /v1/chat/completions, so the request can be served by any provider the router selects (Anthropic or otherwise) and the response is returned in the Anthropic Messages shape regardless of which provider answered. max_tokens is required: its absence returns an Anthropic-shaped invalid_request_error with HTTP 400 (it is not defaulted the way the OpenAI edge defaults it). system (string) becomes a leading system message, stop_sequences maps to the internal stop, temperature and top_p pass through, and metadata.user_id maps to the end-user identifier.

The response carries id, type: "message", role: "assistant", content blocks (reasoning, text, and tool_use blocks in the order the upstream generated them; see the extended-thinking paragraph below), the canonical model that served the request, stop_reason (stop→end_turn, length→max_tokens, tool_calls→tool_use, pause_turn→pause_turn, refusal→refusal; OpenAI content_filter and any unrecognized reason map to end_turn, with refusal text preserved in content), stop_sequence, and usage with input_tokens/output_tokens.

Errors render in the Anthropic envelope {"type": "error", "error": {"type": ..., "message": ...}} with Anthropic statuses (invalid_request_error 400, authentication_error 401, permission_error 403, not_found_error 404, rate_limit_error 429, api_error 500, overloaded_error 529). When more than one attempt was made and all of them failed, the envelope additionally carries per-attempt detail under a vendor-namespaced error.nexus object — {"attempts": [{"source", "kind", "status_code", "message"}]} — rather than at a bare error.attempts, so the payload stays forward-compatible with an attempts field Anthropic could plausibly introduce themselves. The request id is header-only on this edge (x-nexus-request-id); the OpenAI edge exposes the same per-attempt array at error.attempts alongside an error.request_id in the body, because that envelope is Nexus’s own contract rather than a borrowed one. attempts[].message is the upstream response body for a failure that carried an HTTP status — verbatim up to 64 KiB (or the operator’s NEXUS_GATEWAY_MAX_RESPONSE_BYTES, if tighter), beyond which it is cut and marked with a trailing … (truncated by nexus), since every candidate’s body is cloned into the same envelope; and normalized rather than verbatim on Bedrock, whose message is rendered into an OpenAI-shaped envelope and redacted of the caller’s IAM identifiers (see ../security/threat-model.md row I9) — a fixed failure category for a transport failure or a gateway-side timeout, and a gateway-authored size message for an oversized-response rejection. It is never the raw reqwest error, whose Display would embed the upstream URL. attempts[].kind is the failure category a caller can branch on without parsing that free-form message: MissingProviderKey, UpstreamStatus, UpstreamError, Timeout, ResponseTooLarge, or Internal. When exactly one attempt was made there is no attempts array on either edge — the single failure is rendered directly, and an upstream rejection surfaces the provider’s own response body (error.message from it where present, the raw body otherwise) so the detail does not depend on how many candidates the route happened to have. Timeout and ResponseTooLarge are gateway-side verdicts rather than upstream ones, and they differ in what they imply about a retry — a timeout is worth another try, an over-limit body is not, because the same candidate would return the same body.

Unknown routes under /anthropic (for example telemetry probes from agent clients) return the same envelope with HTTP 404 rather than a generic HTML or plain-text 404. Gateway rate limits include a Retry-After header on HTTP 429; upstream Anthropic rate-limit headers are not forwarded because nexus terminates client auth before upstream calls complete.

Successful responses include x-nexus-request-id for correlation with request analytics; error responses include it once a request id has been assigned (request-scoped failures such as policy denials, exhausted-attempt failures, and upstream errors). Failures that reject the request before a request id is assigned — authentication failures, body-shape rejects, and unknown-route 404s — do not carry the header. The Retry-After header on HTTP 429 is the retry window in whole seconds (rounded up, minimum one). Inbound anthropic-version headers from SDK clients are accepted and ignored at the edge; outbound Anthropic upstream calls set their own version on provider-targeted attempts.

With stream: true the response is Content-Type: text/event-stream carrying the Anthropic Messages event sequence as event: <name>\ndata: <json>\n\n frames: message_start, content_block_start, repeated content_block_delta (text_delta and input_json_delta for tool calls), content_block_stop, message_delta (carrying stop_reason and the output usage), and message_stop. There is no [DONE] marker; the stream ends after message_stop.

message_start.message.usage carries the input-side counts the upstream reported before any content — input_tokens (excluding the cache classes), cache_creation_input_tokens, cache_read_input_tokens, the cache_creation tier breakdown, and service_tier when the upstream reports a tier other than the standard one — with output_tokens 0; it reads input_tokens: 0 when no usage is known at the moment the message opens (an upstream that reports usage only at stream end and has content to send, unreadable opening counts, or a non-Anthropic provider that reports usage late); a stream with no content opens at the end with whatever terminal counts are known. message_delta.usage is the authoritative count and is built by the same serializer as the non-streaming usage object, so the streamed and buffered shapes carry the same fields and the same cache arithmetic for the same upstream report. That makes message_delta.usage a superset of Anthropic’s own terminal frame, which omits the cache_creation tiers and service_tier; clients that read only the final frame therefore get the whole accounting from it. A stream cut off before its terminal usage closes with the input-side counts from message_start and output_tokens 0. An upstream failure after the stream has started surfaces as an in-band Anthropic error event (event: error with {"type": "error", "error": {"type": "api_error", ...}}) rather than the OpenAI error frame, and terminates the stream.

Tool-use requests translate tools, tool_choice, assistant tool_use blocks, and user tool_result blocks through the internal OpenAI contract. tool_choice maps auto→auto, any→required, none→none, and {type: "tool", name}→a named function choice; disable_parallel_tool_use: true sets parallel_tool_calls: false independently of the choice value. Anthropic any and OpenAI required both force a tool call but are not identical (Anthropic forces exactly one), and the mapping is not pre-validated. tool_use.id (assistant) and tool_result.tool_use_id (user) must be non-empty strings and are preserved byte-for-byte on the round trip; a missing or empty identifier is rejected as an invalid_request_error. Image content blocks (base64 and http(s) URL sources) translate to OpenAI image_url parts inbound and back to Anthropic image blocks when the selected upstream is Anthropic; a malformed image block (missing source fields, or a non-HTTP(S) URL) is rejected as an invalid_request_error.

POST /anthropic/v1/messages/count_tokens returns {"input_tokens": N} and does not require max_tokens (it is dropped before the upstream proxy call). When the resolved route targets Anthropic, the gateway proxies to upstream count_tokens with BYOK credentials (no PTB budget or cost accounting). When a non-Anthropic provider serves the route, the response is a byte-length estimate (ceil(serialized_internal_body_bytes / 4)) that may disagree with the serving provider’s tokenizer; clients should treat it as an approximate bound for context-window planning, not an exact count.

Request analytics, audit events, and cost accounting use the same schema as /v1/chat/completions, distinguished by the /anthropic/v1/messages route value. The count_tokens route records request analytics only (no usage or cost lines).

Anthropic-only request fields (thinking, per-block cache_control, block-array system, and inbound anthropic-beta header values) have no OpenAI-shaped equivalent. The edge captures them in a preservation envelope attached to the internal request alongside the raw inbound Anthropic Messages JSON, then translates a clean OpenAI-shaped internal contract for routing, classification, and any cross-provider attempt.

An attempt targeting an Anthropic upstream reconstructs the upstream body from the envelope’s raw inbound JSON — only the model id is rewritten, max_tokens is clamped to the endpoint ceiling, and the streaming flag is set — so thinking config, signed thinking/redacted_thinking history blocks, per-block cache_control, and block-array system ride through JSON-equivalent (no Anthropic→OpenAI→Anthropic re-derivation that could drop fields or break signatures). An attempt targeting any other provider uses the translated OpenAI-shaped body with the native fields stripped. The two paths are mutually exclusive: an Anthropic target never re-derives the body, and a non-Anthropic target never sees the native fields.

Cross-provider field behavior:

Field Anthropic target Non-Anthropic target
thinking (config) Preserved verbatim from the envelope. Routing pins the request to Anthropic upstreams; cross-provider fallback is disabled. Not reachable (request is pinned). Stripped from the internal body regardless.
thinking / redacted_thinking history blocks (with signature) Replayed byte-equivalent from the envelope. Dropped: a turn with no thinking field is not pinned, and a non-Anthropic provider serving it receives the assistant text and tool_use blocks only.
per-block cache_control Preserved on the exact block from the envelope. Stripped silently (caching is an optimization, not semantics); fallback succeeds with no cache usage reported.
block-array system Preserved verbatim from the envelope. Flattened to a single joined system string with cache hints removed.
anthropic-beta (header) Forwarded verbatim to the Anthropic upstream and recorded in request analytics. Never forwarded.

Extended thinking pins to Anthropic upstreams because thinking-block signatures cannot be fabricated; a thinking-enabled request whose route resolves to no Anthropic endpoint returns an invalid_request_error. Prompt caching does not pin: a cached request may fall back to a non-Anthropic provider with cache_control stripped.

When an Anthropic upstream serves the request, usage surfaces cache tokens natively (cache_creation_input_tokens and cache_read_input_tokens, with input_tokens excluding both classes, plus the cache_creation tier breakdown) on the non-streaming usage object and on the streamed message_start and message_delta usage alike, and cost accounting prices the cache-read and cache-write classes at their registry rates. The same serializer renders server_tool_use (web_search_requests and web_fetch_requests) when the upstream ran a server tool and output_tokens_details.thinking_tokens when it reported the thinking share of the output, on both paths; the thinking share is recorded as reasoning attribution (reasoning_tokens) and surfaces on the OpenAI shape as completion_tokens_details.reasoning_tokens. Extended-thinking responses carry every reasoning block the upstream returned, in upstream order, on both paths: signed thinking blocks and redacted_thinking blocks (data only), interleaved with text and tool_use exactly as generated, so a client that echoes the content list of a response without server-tool blocks replays a sequence the upstream accepts. Without streaming, content carries {"type": "thinking", "thinking": ..., "signature": ...} and {"type": "redacted_thinking", "data": ...} blocks at their original positions (the thinking text is empty when the upstream omits it, as current models do by default; the signature is what makes the block replayable on the next turn). With stream: true, each reasoning block is its own content block: a thinking block opens with content_block_start, carries thinking_delta frames and a signature_delta frame, and closes with content_block_stop before the next block opens; a redacted_thinking block carries its data on content_block_start and closes immediately with no deltas; adjacent upstream text blocks (for example around citations or server-tool results) are streamed as separate text content blocks rather than merged. Thinking text from a non-Anthropic upstream has no signature: the buffered response renders no thinking block for it, while the streamed writer opens a thinking block for any reasoning text it receives, so such a streamed block carries no signature_delta and cannot be replayed; the serializer skips unrecognized content-block types and delta kinds rather than erroring, so beta wire-shape changes do not break the stream. A skipped block’s own frames are not relayed, with one exception: the argument deltas of a server-tool block (such as web search) are relayed as a tool call without an id or name, which the native edge renders as a tool_use block the client did not request.

Route stub registered at POST /v1beta/models/gemini-pro:generateContent. Returns 501; native Gemini pass-through is not implemented.

External: OpenAI Agents SDK trace-ingest acknowledgment

Section titled “External: OpenAI Agents SDK trace-ingest acknowledgment”

POST /v1/traces/ingest on the gateway accepts trace-export payloads from the OpenAI Agents SDK under a Nexus API key and returns a success envelope. The payload is acknowledged but not persisted; native trace storage is the OTLP trace-ingest service below.

External: OTLP ingress (Tier 2 operator telemetry)

Section titled “External: OTLP ingress (Tier 2 operator telemetry)”

Collector configuration ships in Compose; per-service OTLP depth varies. Canonical attribute rules: ../../vortex-common-crates/contracts/telemetry.md.

External: OTLP / OpenInference trace ingest

Section titled “External: OTLP / OpenInference trace ingest”

The dedicated trace-ingest HTTP service accepts tenant-visible agentic traces at POST /v1/traces. The endpoint accepts OTLP/HTTP protobuf ExportTraceServiceRequest payloads authenticated by a project-level trace ingest key. The authenticated project is the source of truth for org_id and project_id; any tenant fields in the submitted span attributes are hints and must match that authenticated scope.

The contract requires accepted spans to be normalized into Nexus trace records before they become product-visible. OpenTelemetry trace IDs and span IDs are preserved. OpenInference attributes are preserved in attributes_json and selected fields are promoted for query and UI use. Full inputs, outputs, tool arguments, tool results, retrieved document content, image payloads, audio payloads, and raw provider payloads are represented by TraceArtifactRef values and are stored only when full payload trace capture is enabled and policy allows capture.

Not implemented.

Deployment-wide OIDC sign-in is implemented through console same-origin routes that call the auth service REST API. SAML is not implemented.

Surface Method Path Purpose
Console GET /api/session/oidc/:provider/start Creates an auth-service OIDC login state, binds the returned state in an HTTP-only cookie, and redirects the browser to the IdP authorization URL.
Console GET /api/session/oidc/:provider/callback Verifies the browser-bound state, exchanges the callback with auth, sets the console session cookies, and redirects to the safe relative return path.
Auth POST /api/auth/oidc/:provider/start Starts Authorization Code + PKCE for the configured provider and returns an authorization URL.
Auth POST /api/auth/oidc/:provider/callback Exchanges and validates the provider callback, resolves or creates a Nexus user according to registration policy, and returns an auth session.

Registration status checks are exposed for invite-only sign-up forms. They reveal only validity, expiry, and remaining uses; they do not return email addresses or stored digests.

Surface Method Path Purpose
Console GET /api/auth/registration-invite/status?invite=... Same-origin proxy for registration invite status.
Console GET /api/auth/registration-code/status?code=... Same-origin proxy for registration code status.
Auth GET /api/auth/registration-invite/status?invite=... Returns { valid, expired } for a registration invite token.
Auth GET /api/auth/registration-code/status?code=... Returns { valid, expired, uses_remaining } for a registration code.

External: personal developer keys (developer access tokens)

Section titled “External: personal developer keys (developer access tokens)”

User-bound credentials for non-browser Query/Trace clients. Console UI: Account → Personal developer keys. Operator guide with examples: ../dev/personal-developer-keys.md.

Service Method Path Purpose
Auth POST /api/auth/developer-tokens Create an nxd_… token for one org and optional project; returns the secret once. Requires a console session bearer. Validation failures return 400/403 with {"message":"…"}; missing/invalid session returns 401.
Auth GET /api/auth/developer-tokens?org_id= List the caller’s tokens for one org (metadata only).
Auth DELETE /api/auth/developer-tokens/:token_id Revoke one of the caller’s tokens. Unknown or already-revoked ids return 404 with {"message":"…"}; missing/invalid session returns 401.
Console POST / GET / DELETE /api/auth/developer-tokens… Same-origin BFF proxies that attach the HTTP-only session access token. The console auth rewrite uses fallback so dynamic routes such as DELETE …/:token_id are not shadowed by the auth reverse-proxy.

Verified nxd_… bearers are accepted on Query HTTP routes documented in the console query HTTP section below. They are rejected on the gateway model edge; gateway nxs_… keys are rejected on Query HTTP.

The query service exposes a small HTTP read API consumed by the console via Next.js API proxies under apps/console/app/api/query/**. All routes require ?org_id=<id>; the console proxy enforces session-scoped access before forwarding.

Method Path Returns
GET /api/requests Paginated request rows for the org/project (+ optional q, provider, model, status_class, is_stream, session_id, status_code, time window). Response includes total and has_more.
GET /api/requests/facets Distinct providers and models for the org/project (filter dropdowns).
GET /api/requests/:request_id Single request row with authenticated query-proxy body URLs when payload access is authorized.
GET /api/sessions Aggregated session summaries (count, cost, tokens, last model/provider) with optional q / provider / model / time filters. Response includes total and has_more.
GET /api/sessions/facets Distinct last-request providers and models across sessions for the org/project.
GET /api/sessions/:session_id/timeline Ordered request rows for the session (limit/offset; has_more when another page exists).
GET /api/sessions/:session_id/live text/event-stream of request.completed events bridged from NATS nexus.requests.<org>.*, filtered by org_id+session_id.
POST /api/requests/:request_id/feedback Record thumbs-style feedback on one request row.
GET /api/usage Usage aggregates for the org/project + time window.
GET /api/metrics/overview Overview metrics powering the console usage dashboard.
GET /api/reports/workspace Exact spend for one workspace and one closed calendar month, grouped by organization, project, API key, provider, and model. The caller names only the workspace; its organizations are resolved through the control plane with the caller’s own credential.
GET /api/exports/panel Export attachment for an absolute from/to window of at most 366 days. Scope with either org_id (optional project_id / api_key_id) or workspace_id (organizations resolved via the control plane). panel is cost_detail (default), usage_daily, or usage_breakdown; format is csv or json. cost_detail is the finance path: flat rows at grain usage_date_utc × organization × project × api_key × provider × model × cost_band × [selected reporting labels], with a single money column cost_usd. Columns include display names when resolvable and api_key_prefix. Optional dimension is a comma-separated list of reporting-dimension keys (max 8); each key becomes its own wide value column on the same row so SUM(cost_usd) cannot double-count. Summing cost_usd equals priced request spend for the range; when cost-line evidence is incomplete, an unitemized band closes the gap. Do not add other money columns. Dates are UTC. Idle days are omitted. Row count is hard-capped (400 if exceeded). Activity panels (usage_daily / usage_breakdown) remain available for charts and still use the legacy manifest CSV shape for org scope only.
GET /api/body/by-request/:request_id Authenticated body fetch by request id. Packed references are served through one exact object range, pinned to the row’s organization prefix, and verified against the row’s byte count and BLAKE3 hash.
GET /api/traces/:trace_id/tree Tenant-scoped trace tree with sorted spans, events, artifact refs, and request-row metadata joined onto matching LLM spans.
GET /api/sessions/:session_id/traces Trace summaries for one session, grouped by trace id with request ids and full-trace availability.
GET /api/requests/:request_id/trace Trace tree for the trace containing the LLM span linked to the request row.
GET /api/traces/:trace_id/spans/:span_id Span detail with attached events, artifact refs, and joined request metadata when applicable.
GET /api/traces/:trace_id/artifacts/:artifact_id Trace artifact metadata and a signed object URL when an object key exists and payload access is authorized.
GET /api/sessions/:session_id/traces/live text/event-stream of trace lifecycle events from NATS nexus.traces.<org>.> plus joined request.completed events from nexus.requests.<org>.*, filtered by org/project/session and deduped by stable ids.
GET /api/traces/requests/:request_id Request trace metadata, trace availability, body URLs when full tracing is enabled, and safe tool summary.
GET /api/traces/sessions/:session_id Ordered per-request trace summaries for a session.
GET /api/traces/tools/:request_id Tool-call summary and full tool trace ref when full tracing is enabled.

The console exposes same-origin proxy routes under /api/query/** for these Query HTTP routes and renders trace inspector pages under /o/<org>/traces/<trace_id> and /o/<org>/p/<project>/traces/<trace_id>.

The console proxies selected control-plane APIs so the server-side BFF can attach the HTTP-only session access token.

Method Path Purpose
POST /api/trace-ingest-keys Create a project-scoped trace ingest key and return the secret once.
GET /api/trace-ingest-keys List trace ingest key metadata without secrets.
POST /api/trace-ingest-keys/:trace_ingest_key_id/rotate Rotate one trace ingest key and return the new secret once.
DELETE /api/trace-ingest-keys/:trace_ingest_key_id Revoke one trace ingest key.
PATCH /api/orgs/:org_id/tracing Toggle full payload tracing for an organization.
PATCH /api/projects/:project_id/tracing Toggle full payload tracing for a project.
PATCH /api/api-keys/:api_key_id/tracing Toggle full payload tracing for one Nexus API key.

Nexus treats audit evidence, tenant analytics metadata, and full trace payloads as separate data classes.

  • Auditability is always on: gateway/control-plane/query lifecycle and security events continue to emit even when full trace capture is disabled.
  • Tenant analytics metadata is always on: request/session rows keep identifiers, model/provider, status, latency, cost/tokens, attempt trail, trace flags, and safe tool summaries.
  • Full trace payloads are opt-in debug artifacts: request bodies, response bodies, tool arguments, tool results, and replayable context are stored only when tracing is enabled by org/project/API-key configuration or nexus-enable-tracing: 1.

Product stance: every request is accountable; full payload traces are opt-in.

Proto definitions live under proto. Regenerate Rust/TS clients with cargo build -p nexus-proto and just proto.

Service Server Client
OrgService / ProjectService / ApiKeyService / ProviderKeyService control-plane CLI, internal tests
AuthService auth Internal callers (VerifyApiKey, session ops)
QueryService (ListRequests, GetRequest, MonthlyUsageSnapshot) query Internal callers needing authenticated query-proxy body URLs; the gateway’s spend-cap cache calls MonthlyUsageSnapshot
AuditService (ListEvents, GetEvent, GetInclusionProof) audit Console read facade backing, verifier tooling
PolicyService (Evaluate) policy Gateway (when POLICY_GRPC_ENDPOINT is set) and query read authorization
HealthService (Ping) shared service skeleton Internal health probes

NATS runs in Compose.

  • Audit events: gateway, auth, control-plane, and ingest publish canonical audit-event bytes to tenant-scoped subjects nexus.audit.events.<tenant_id> through nexus_runtime::NatsAuditSink or equivalent producer helpers.
  • Request analytics: gateway publishes one JSON request.completed envelope per request on subjects nexus.requests.<org_id>.<request_id>. The envelope carries normalized usage lines, qualified cost lines, exact decimal strings, rate-card provenance, and billing status/reason. Bodies <= 256 KB are inline in the envelope; larger bodies are chunked onto nexus.requests.<org_id>.<request_id>.body.{req,res}.chunk.<n> and referenced from the parent envelope with chunk metadata + blake3 hash.
  • Trace records: trace-ingest publishes tenant-scoped trace lifecycle events on subjects such as nexus.traces.<org_id>.<trace_id>.<event_id>. Event payloads carry TraceSpan, TraceEvent, or TraceArtifactRef records normalized by trace-ingest or gateway-owned producers.

These wire shapes define the normalized trace record contract. The ingest service persists these records into ClickHouse trace tables. They are Nexus-owned records that preserve OpenTelemetry identity and OpenInference semantics without making incoming attributes the authority for tenancy or authorization.

TraceSpan represents a unit of work in an agentic trace. Required fields:

Field Description
trace_id OpenTelemetry-compatible trace identifier.
span_id OpenTelemetry-compatible span identifier.
parent_span_id Parent span identifier, empty for a root span.
org_id Nexus organization scope from authenticated project context.
project_id Nexus project scope from authenticated trace ingest key or internal producer.
session_id Nexus session grouping key; mirrors OpenInference session.id when present.
request_id Optional Nexus request row join key for gateway-backed LLM spans.
span_name Low-cardinality operation name.
span_kind OpenTelemetry span kind such as internal, client, server, producer, or consumer.
openinference_span_kind OpenInference kind: AGENT, CHAIN, LLM, TOOL, RETRIEVER, RERANKER, EMBEDDING, GUARDRAIL, PROMPT; EVALUATOR is reserved for evaluator workflows.
service_name Originating service or SDK name.
source Nexus source category, for example gateway, sdk, trace-ingest, or integration.
started_at Span start timestamp.
ended_at Span end timestamp when complete; pending spans use the start timestamp for ClickHouse compatibility.
duration_ms Span duration in milliseconds when complete, otherwise 0.
status ok, error, unset, or pending.
status_message Optional status details.
attributes_json Full normalized attributes JSON after privacy filtering.
payload_state summary_only, full, omitted, or redacted.

Promoted query fields are derived from attributes when present:

  • llm.model_name
  • llm.provider
  • llm.system
  • llm.token_count.prompt
  • llm.token_count.completion
  • llm.cost.total
  • tool.name
  • agent.name
  • session.id
  • user.id
  • Nexus attributes such as nexus.request_id, nexus.classification, and nexus.trace_source

TraceEvent represents a point-in-time event attached to a trace or span. Required fields:

Field Description
event_id Stable event identifier for live-stream dedupe.
trace_id Trace identifier.
span_id Span identifier the event belongs to.
org_id Nexus organization scope.
project_id Nexus project scope.
session_id Nexus session grouping key.
event_name Low-cardinality event name such as trace.span.started, trace.span.updated, trace.span.ended, trace.event, or provider/framework event names.
occurred_at Event timestamp.
level Optional log level or severity.
message Optional human-readable event message.
attributes_json Event attributes after privacy filtering.
artifact_ref Optional TraceArtifactRef identifier for a large payload associated with the event.

TraceArtifactRef represents large or sensitive trace payload content stored outside hot trace rows. Required fields:

Field Description
artifact_id Stable artifact identifier.
trace_id Trace identifier.
span_id Span identifier associated with the artifact.
kind input, output, tool_arguments, tool_result, retrieved_document, image, audio, or raw_provider_payload.
object_key Object-store key when a full artifact is stored. Empty when omitted or redacted.
mime_type Artifact MIME type.
compression Compression applied to stored bytes.
byte_count Original byte count when known.
blake3_hash Hash of original bytes when available.
payload_state summary_only, full, omitted, or redacted.
redaction_metadata_json Metadata describing applied redaction without exposing redacted values.
TraceSpan(openinference_span_kind=AGENT, span_name="agent-cli turn", session_id="sess_123")
TraceSpan(openinference_span_kind=LLM, span_name="Nexus chat completion stream", request_id="req_456")
TraceSpan(openinference_span_kind=TOOL, span_name="local stress-test tool call")

The session_id groups the turn in live session monitoring. The request_id joins the LLM span to the Nexus request row. Tool input and result artifacts use summary_only unless full tracing is enabled and policy allows artifact storage. The authenticated project trace ingest key supplies the Nexus org/project scope; OpenInference attributes can mirror that scope but do not authorize it.

Canonical shape: ../../vortex-common-crates/contracts/audit-event.md. The audit service consumes the tenant-scoped NATS stream, persists canonical hashes in Postgres, seals per-tenant Merkle batches, signs batch envelopes, and writes anchor manifests plus compressed event archives to the configured audit archive.

Internal: policy decision contract (PolicyQuery / PolicyDecision)

Section titled “Internal: policy decision contract (PolicyQuery / PolicyDecision)”

Defined in ../../vortex-common-crates/contracts/policy-obligations.md. The policy service serves PolicyService.Evaluate over gRPC behind service-token auth; the gateway can also evaluate against the same engine in-process.