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-dimensionslists 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-dimensionslists 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.
External: OpenAI-compatible REST + SSE
Section titled “External: OpenAI-compatible REST + SSE”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).
Native-field preservation envelope
Section titled “Native-field preservation envelope”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.
External: Gemini-compatible REST
Section titled “External: Gemini-compatible REST”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.
External: SCIM 2.0
Section titled “External: SCIM 2.0”Not implemented.
External: OIDC / SAML SSO endpoints
Section titled “External: OIDC / SAML SSO endpoints”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.
External: console query HTTP
Section titled “External: console 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>.
External: console control-plane HTTP
Section titled “External: console control-plane HTTP”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. |
Traceability and Auditability
Section titled “Traceability and Auditability”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.
Internal: nexus-proto gRPC services
Section titled “Internal: nexus-proto gRPC services”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 |
Internal: async event bus topics
Section titled “Internal: async event bus topics”NATS runs in Compose.
- Audit events:
gateway,auth,control-plane, andingestpublish canonical audit-event bytes to tenant-scoped subjectsnexus.audit.events.<tenant_id>throughnexus_runtime::NatsAuditSinkor equivalent producer helpers. - Request analytics: gateway publishes one JSON
request.completedenvelope per request on subjectsnexus.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 ontonexus.requests.<org_id>.<request_id>.body.{req,res}.chunk.<n>and referenced from the parent envelope with chunk metadata + blake3 hash. - Trace records:
trace-ingestpublishes tenant-scoped trace lifecycle events on subjects such asnexus.traces.<org_id>.<trace_id>.<event_id>. Event payloads carryTraceSpan,TraceEvent, orTraceArtifactRefrecords normalized bytrace-ingestor gateway-owned producers.
Internal: Nexus trace record contract
Section titled “Internal: Nexus trace record contract”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
Section titled “TraceSpan”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_namellm.providerllm.systemllm.token_count.promptllm.token_count.completionllm.cost.totaltool.nameagent.namesession.iduser.id- Nexus attributes such as
nexus.request_id,nexus.classification, andnexus.trace_source
TraceEvent
Section titled “TraceEvent”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
Section titled “TraceArtifactRef”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. |
Example examples/agent-cli turn
Section titled “Example examples/agent-cli turn”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.
Internal: audit event schema
Section titled “Internal: audit event schema”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.
