LLM Ingester#
The LLM ingester is an HTTP proxy that sits between your LLM clients and the model provider. Clients are pointed at the ingester instead of at the provider; the ingester forwards each request upstream unmodified, streams the response back, and ingests the conversation as structured Gravwell entries along the way.
Two provider APIs are supported: the OpenAI Chat Completions API, spoken by most hosted providers and local model servers, and the Anthropic Messages API, spoken by Claude Code and the Anthropic SDKs. A listener speaks one of them, and both are normalized into the same set of ingested events.
Because the ingester is a proxy rather than a log collector, it captures data that is not normally written anywhere: the user’s prompt, the system prompt, the model’s reply, any reasoning the provider exposes, every tool call the model makes along with its arguments and results, and the token accounting for each response. Each event is a separate entry with enumerated values describing the model, session, request, and timing, which makes it possible to answer questions like “which tools is this agent actually calling”, “how many tokens did this team spend today”, and “what did anyone ask the model about production credentials”.
Note
The LLM ingester pairs naturally with the vector preprocessor and the semantic search module: attach an embedding to each prompt and response at ingest time, then search the conversations by meaning rather than by keyword.
Installation#
To install the Debian package, make sure the Gravwell Debian repository is configured as described in the quickstart. Then run the following command as root:
apt update && apt install gravwell-llm-ingester
To install the Redhat package, make sure the Gravwell Redhat repository is configured as described in the quickstart. Then run the following command as root:
yum install gravwell-llm-ingester
To install via the standalone shell installer, download the installer from the downloads page, then run the following command as root, replacing X.X.X with the appropriate version:
bash gravwell_llm_ingester_installer_X.X.X.sh
You may be prompted for additional configuration during the installation.
There is currently no Docker image for this ingester.
Basic Configuration#
The LLM ingester uses the unified global configuration block described in the ingester section. Like most other Gravwell ingesters, the LLM ingester supports multiple upstream indexers, TLS, cleartext, and named pipe connections, a local cache, and local logging.
The configuration file is at /opt/gravwell/etc/gravwell_llm_ingester.conf. The ingester will also read configuration snippets from its configuration overlay directory (/opt/gravwell/etc/gravwell_llm_ingester.conf.d).
At least one Listener block must be defined. Each listener binds a port, forwards to one upstream provider, and ingests into one tag. Here is a minimal configuration that proxies OpenAI and ingests into the tag llm:
[Global]
Ingest-Secret = IngestSecrets
Connection-Timeout = 0
Insecure-Skip-TLS-Verify = false
Pipe-Backend-Target=/opt/gravwell/comms/pipe #a named pipe connection, this should be used when the ingester is on the same machine as a backend
Log-Level = INFO
Log-File = /opt/gravwell/log/llm_ingester.log
State-Store-Location = /opt/gravwell/etc/llm_ingester.state
[Listener "openai"]
Bind = ":4180"
Upstream-URL = "https://api.openai.com"
Protocol = "openai-chat"
Tag-Name = llm
Log-Mode = "delta"
Log-Tool-Calls = true
Log-Usage = true
Session-TTL = "30m"
Clients then use http://<ingester>:4180/v1 as their API base URL. See Client Configuration for examples.
A listener speaking the Anthropic Messages API is configured the same way, with a different Protocol and the auth style the Messages API expects:
[Listener "anthropic"]
Bind = ":4181"
Upstream-URL = "https://api.anthropic.com"
Protocol = "anthropic-messages"
Tag-Name = llm
Auth-Style = "x-api-key"
Anthropic-Version = "2023-06-01"
Session-ID-Header = "x-claude-code-session-id"
Log-Mode = "delta"
Log-Tool-Calls = true
Log-Usage = true
See Anthropic Messages for what each of those parameters does and Claude Code for pointing a client at it.
Additional Global Configuration Parameters#
Config Parameter |
Type |
Required |
Default Value |
Description |
|---|---|---|---|---|
State-Store-Location |
string |
NO |
Path to a persistent state file for the session tracker. When set, session state survives an ingester restart. Only message hashes are written, never prompt content. Leaving this unset keeps session state in memory only. |
|
Session-Match-Window |
integer |
NO |
10 |
How many of a request’s most recent messages the session tracker compares when deciding whether a request continues an existing conversation. Larger values are more precise but do more work per request. |
Listener Configuration#
Listener blocks support the following configuration parameters:
Parameter |
Type |
Required |
Default Value |
Description |
|---|---|---|---|---|
Bind |
string |
YES |
Host:port pair the proxy listens on, e.g. |
|
Upstream-URL |
string |
YES |
Base URL requests are forwarded to, e.g. |
|
Protocol |
string |
YES |
Protocol module used to parse traffic. See Protocols. |
|
Tag-Name |
string |
NO |
|
Tag assigned to ingested entries. |
Log-Mode |
string |
NO |
|
How much of each request is ingested: |
Log-Tool-Calls |
boolean |
NO |
false |
Capture tool invocations made by the model and the tool results sent back by the client. |
Log-Usage |
boolean |
NO |
false |
Ingest the token accounting record returned with each response. |
Auth-Style |
string |
NO |
|
Which header carries the API credential: |
Client-Authorization |
string |
NO |
Bare token that inbound clients must present in the header named by |
|
Upstream-Authorization |
string |
NO |
Bare token injected into the header named by |
|
Anthropic-Version |
string |
NO |
Value injected as the |
|
Session-ID-Header |
string |
NO |
Name of a request header carrying the client’s own conversation identifier, used as the |
|
Session-TTL |
string |
NO |
|
How long idle session-matching state is retained, as a Go duration string, e.g. |
Allow-Unknown-Paths |
boolean |
NO |
false |
Forward every path to the upstream rather than answering 404 for paths the protocol neither parses nor declares a passthrough. Insecure; see Protocols. |
Max-Body |
integer |
NO |
16777216 (16MB) |
Maximum size of an inbound request body. Larger requests are rejected with a 413. |
TLS-Certificate-File |
string |
NO |
Certificate PEM file used to run the proxy listener as HTTPS. Must be set together with |
|
TLS-Key-File |
string |
NO |
Key PEM file used to run the proxy listener as HTTPS. Must be set together with |
|
Insecure-Skip-TLS-Verify-Upstream |
boolean |
NO |
false |
Do not verify the upstream provider’s TLS certificate. Intended for lab use with self-signed certificates. |
Preprocessor |
string array |
NO |
Set of preprocessors to apply to ingested entries. |
Multiple listeners can be defined to proxy several providers, or to expose the same provider with different capture settings. Each listener maintains its own tag, log mode, credentials, and preprocessor chain:
[Listener "openai"]
Bind = ":4180"
Upstream-URL = "https://api.openai.com"
Protocol = "openai-chat"
Tag-Name = llm-openai
Log-Mode = "delta"
Log-Tool-Calls = true
Log-Usage = true
[Listener "local"]
Bind = ":4181"
Upstream-URL = "http://127.0.0.1:11434"
Protocol = "openai-chat"
Tag-Name = llm-local
Log-Mode = "full"
Log-Usage = true
Protocols#
A protocol module tells the ingester how to parse the traffic flowing through a listener and which URL paths to expose. The following protocols are supported:
Protocol |
Paths |
Description |
|---|---|---|
|
|
OpenAI Chat Completions API, both buffered and streaming (server-sent events) responses. |
|
|
Anthropic Messages API, both buffered and streaming (server-sent events) responses. This is what Claude Code and the Anthropic SDKs speak. |
The ingester logs the set of registered protocols to the gravwell tag at startup.
A provider’s API is wider than the one endpoint a protocol parses, and a client will call the neighbors. Each protocol therefore declares the sibling endpoints its clients legitimately need, and the proxy forwards those untouched without ingesting them: the anthropic-messages protocol passes /v1/messages/count_tokens through, because Claude Code calls it on every request. Nothing needs configuring for this to work.
Requests to any other path receive a 404; the proxy is not a general-purpose forwarder. Setting Allow-Unknown-Paths on a listener forwards every path upstream instead.
Warning
Allow-Unknown-Paths is insecure. The proxy attaches the listener’s Upstream-Authorization to whatever it forwards, so an open path list lets any client that can reach the listener aim it at a request the proxy never inspects and read the upstream credential back out of the result. Enable it only for a specific client that needs an endpoint the ingester does not know about, and give that client a listener of its own.
openai-chat#
The openai-chat protocol is compatible with any provider that implements the OpenAI Chat Completions API, which includes most hosted providers and local model servers such as Ollama, vLLM, and llama.cpp. Provider-specific request and response fields are forwarded untouched even when the ingester does not parse them.
Both the string and the multimodal array forms of the content field are handled. For the array form, text parts are extracted and joined with newlines so that the ingested entry reads the way the user wrote it. Content arrays that carry no text parts at all, such as an image-only turn, are ingested as the raw JSON so that nothing is silently dropped.
anthropic-messages#
The anthropic-messages protocol handles Anthropic’s Messages API. It is shaped differently from Chat Completions in several ways, all of which the ingester normalizes into the same events produced by openai-chat:
The system prompt is a top-level
systemfield rather than a leadingsystemmessage. It is ingested as arequest.system_messageevent either way. Newer models also acceptsystementries inside themessagesarray, which Claude Code uses to inject tool and subagent context; those are ingested as system messages too.Message content is either a string or an array of typed content blocks. Text blocks are extracted and joined with newlines. Blocks the ingester does not model, such as images and documents, are folded into the entry as their raw JSON rather than dropped, which is bounded by
Max-Bodybut can be bulky when a client inlines an image.There is no separate
toolrole. A tool call is atool_useblock on an assistant turn and a tool result is atool_resultblock on a user turn; both are ingested asresponse.tool_callandrequest.tool_resultrespectively, correlated bytool_call_idas usual.Extended thinking arrives as
thinkingblocks and is ingested asresponse.reasoning, in generation order, so reasoning precedes the reply it belongs to.
Two listener parameters exist for this protocol. Auth-Style should be set to x-api-key for clients credentialed with an API key, since the Messages API authenticates with a bare x-api-key header rather than Authorization: Bearer; see Authorization. Anthropic-Version supplies the anthropic-version header the Messages API requires, and is injected only when a request arrives without one, which is what happens when the proxy holds the upstream key and the client was never configured to talk to Anthropic directly.
Log Modes#
An LLM conversation is stateless on the wire: each request re-sends the entire conversation so far. Logging every request in full therefore re-ingests the whole conversation on every turn, which is occasionally what you want and usually not. Log-Mode controls that tradeoff.
Mode |
Description |
|---|---|
|
(Default) Ingest only what is new in each request: the latest user turn, plus any tool results the client appended after the model’s last reply. The system prompt is ingested once, on the first request of a new session. This produces one entry per logical event and does not duplicate earlier turns. |
|
Ingest only the most recent user message from each request. Assistant replies, system prompts, and tool traffic on the request side are skipped. Response-side events still honor |
|
Ingest every message in every request body, including all previously-logged turns. Useful for capturing a complete conversation snapshot per request, at the cost of substantial duplication. |
Response-side events — the model’s reply, its reasoning, its tool calls, and the usage record — are ingested in all three modes, subject to Log-Tool-Calls and Log-Usage.
Note
delta mode relies on session matching to know which turns have already been ingested. See Session Tracking.
Ingested Events#
Each logical event becomes its own entry. The entry’s DATA field holds the text of the event, and the metadata is attached as intrinsic enumerated values. The SRC field is set to the client’s IP address, and the timestamp is the time the event was ingested.
|
DATA contents |
Gated by |
|---|---|---|
|
Text of the user’s message. |
|
|
Text of the system prompt. |
|
|
Result the client returned for a tool call. |
|
|
Reasoning text, when the provider exposes it. |
|
|
Text of the model’s reply. |
|
|
JSON arguments the model passed to the tool. |
|
|
Empty; the counts are carried as enumerated values. |
|
Which request-side events are ingested also depends on Log-Mode, as described under Log Modes.
Note
Reasoning is captured from either the reasoning or the reasoning_content field, since providers disagree on the name. Reasoning events are emitted before the reply they precede.
Enumerated Values#
Enumerated Value |
Type |
Description |
|---|---|---|
|
string |
The event type, as listed under Ingested Events. Always present. |
|
string |
Message role: |
|
string |
Name of the tool the model invoked. Present on |
|
string |
Provider-assigned identifier correlating a tool call with its result. |
|
int |
Tokens consumed by the request. Present on |
|
int |
Tokens generated in the response. Present on |
|
int |
Total tokens billed for the exchange. Present on |
|
string |
Identifier of the conversation this event belongs to. See Session Tracking. |
|
string |
Provider-assigned response identifier. Present on response-side events. |
|
string |
Model name, taken from the response when the provider reports one and from the request otherwise. |
|
string |
Name of the protocol module that parsed the traffic, either |
|
string |
Name of the listener config block that handled the request. Always present. |
|
int |
HTTP status code returned by the provider. Present on response-side events. |
|
int |
Milliseconds from receipt of the request to completion of the response. Present on response-side events. |
|
boolean |
Whether the client requested a streaming response. Always present. |
|
boolean |
Attached, with the value |
Request-side events are ingested before the provider is contacted, so they carry no upstream_status, request_id, or duration_ms. Correlate them with the response using session_id.
Token Usage and Streaming#
Providers return the token accounting record at the end of a response. For buffered responses this is automatic. For streaming responses it depends on the protocol.
On an openai-chat listener, most providers omit the record from a stream unless the client asks for it. To capture usage on streamed requests, the client must set stream_options.include_usage to true; the ingester cannot add it because rewriting the request would change what the client receives.
Note
When Log-Usage is enabled but no usage records are appearing for streaming traffic from an OpenAI-compatible client, this is almost always the cause.
On an anthropic-messages listener the counts are part of the stream itself and need no opt-in: input and cache tokens arrive with the opening message_start event and the output count with the closing message_delta, and the ingester assembles them into a single response.usage event.
The Messages API reports cache reads and cache writes separately and reports no total. To keep prompt_tokens, completion_tokens, and total_tokens comparable across both protocols, the ingester folds the cache counts into the prompt count and computes the total:
prompt_tokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens
completion_tokens = output_tokens
total_tokens = prompt_tokens + completion_tokens
Note
Cached input tokens are billed at a different rate than uncached ones, so prompt_tokens from an Anthropic listener counts the tokens the model was given rather than tracking cost directly.
Session Tracking#
Neither the Chat Completions API nor the Messages API has a concept of a session, so the ingester derives one unless the client supplies it. Every event is stamped with a session_id that groups the turns of a single conversation, which is what makes it possible to reconstruct an exchange, follow an agent through a long chain of tool calls, or sum the token spend of one conversation.
Client-Supplied Session IDs#
Some clients stamp their own conversation identifier on every request. Naming that header with Session-ID-Header makes the ingester adopt the client’s identifier as the session_id instead of deriving one, which is both more accurate and more useful: an identifier the client owns survives the history rewrites that prefix matching cannot follow, and it lines the ingested session up with the client’s own transcript.
The parameter is not tied to a provider or a protocol. It works on any listener, with any header name, including one your own application sets. Claude Code sends x-claude-code-session-id:
[Listener "anthropic"]
Bind = ":4181"
Upstream-URL = "https://api.anthropic.com"
Protocol = "anthropic-messages"
Tag-Name = llm
Auth-Style = "x-api-key"
Session-ID-Header = "x-claude-code-session-id"
Values are held to what a session identifier plausibly is: at most 128 bytes of printable ASCII with no spaces. A request whose header is missing, empty, or fails that check falls back to prefix matching, and the listener logs a warning so a wrong header name degrades loudly rather than quietly.
Derived Sessions#
When no Session-ID-Header is configured, or a request arrives without a usable value, sessions are derived by matching each incoming request against the requests seen before it. The ingester hashes every message in a request and remembers the trailing window of hashes, bounded by Session-Match-Window. A later request continues an existing session when it carries that session’s previous messages unchanged in the same positions; anything appended past that point — one new user turn, several stacked turns, tool results, or nothing at all in the case of a retry — still matches. When more than one session qualifies, the longest match wins as the most specific continuation. A request that matches nothing mints a new session, and its events are marked with new_session.
Session state, derived and client-supplied alike, is partitioned by client IP so that traffic from different clients never cross-matches, and is capped at 256 concurrent sessions per client, with the oldest dropped first. Entries idle for longer than Session-TTL are evicted. When several listeners are configured, the session store is shared and uses the longest Session-TTL among them.
Setting State-Store-Location in the [Global] block persists session state across restarts, so conversations in flight when the ingester restarts are not split into two sessions.
Note
Only message hashes are stored, in memory and on disk — never prompt or response content. The state file cannot be used to recover conversation text.
The client IP is taken from the leading entry of the X-Forwarded-For header when that value parses as an IP address, and from the connecting peer otherwise. This keeps sessions attributed to the real client when the ingester sits behind a load balancer, without letting a malformed header collapse every client onto one address. The ingester appends its own peer to the X-Forwarded-For chain on the upstream request.
Configuring TLS#
By default a listener runs a cleartext HTTP server. To run it as HTTPS, provide a certificate and key PEM file with the TLS-Certificate-File and TLS-Key-File parameters. Both must be set, and the keypair is validated at startup, so a bad path or a mismatched pair prevents the ingester from starting rather than failing later.
[Listener "openai"]
Bind = ":4180"
Upstream-URL = "https://api.openai.com"
Protocol = "openai-chat"
Tag-Name = llm
TLS-Certificate-File = /opt/gravwell/etc/llm_ingester.crt
TLS-Key-File = /opt/gravwell/etc/llm_ingester.key
TLS on the listener is separate from TLS to the provider. Connections to the upstream URL are verified normally; Insecure-Skip-TLS-Verify-Upstream disables that verification and should be used only against a lab endpoint with a self-signed certificate.
Client Configuration#
A client is pointed at the ingester by overriding the API base URL it talks to. No other change is required, and the client continues to talk to the same models. For an OpenAI-compatible client the base URL includes /v1 (http://<ingester>:4180/v1); for Claude Code it does not, because Claude Code appends the whole path itself.
Whichever client is in use, the key it presents depends on which side holds the provider credential:
Listener |
Client’s key |
|
|---|---|---|
Client’s key passes through (default) |
neither |
the real provider key |
Proxy holds the real key |
|
the gate token, or any placeholder when |
Warning
Over plain HTTP both the prompts and the API key cross the wire in the clear. For anything but a proxy on localhost, set TLS-Certificate-File and TLS-Key-File on the listener and give clients an https:// base URL. See Configuring TLS.
curl#
# openai-chat listener
curl http://localhost:4180/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $YOUR_API_KEY" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hello"}]}'
# anthropic-messages listener
curl http://localhost:4181/v1/messages \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-H "x-api-key: $YOUR_ANTHROPIC_API_KEY" \
-d '{"model":"claude-opus-5","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}'
opencode#
Add a custom provider pointing at the listener to opencode.json or opencode.jsonc:
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"llm-ingester": {
"name": "Gravwell LLM Ingester",
"options": {
"baseURL": "http://localhost:4180/v1"
},
"models": {
"<your-model>": {}
}
}
}
}
crush#
Add a custom provider to crush.json or .crush.json:
{
"$schema": "https://charm.land/crush.json",
"providers": {
"llm-ingester": {
"name": "Gravwell LLM Ingester",
"type": "openai",
"base_url": "http://localhost:4180/v1",
"api_key": "$YOUR_API_KEY",
"models": [
{ "id": "<your-model>", "name": "<your-model>" }
]
}
}
}
Codex#
Codex supports pointing at an alternate endpoint through its own configuration; see the Codex configuration documentation.
Claude Code#
Claude Code talks to the Messages API, so it needs an anthropic-messages listener like the one shown under Basic Configuration. Point it at that listener with the ANTHROPIC_BASE_URL environment variable, either per invocation:
ANTHROPIC_BASE_URL=http://localhost:4181 \
ANTHROPIC_API_KEY=$YOUR_ANTHROPIC_API_KEY \
claude
or persistently in an env block in Claude Code’s settings, in .claude/settings.json inside a project or ~/.claude/settings.json to cover every project:
{
"env": {
"ANTHROPIC_BASE_URL": "http://localhost:4181",
"ANTHROPIC_API_KEY": "sk-ant-your-key-or-the-gate-token"
}
}
A few things are worth knowing on the client side:
The base URL carries no
/v1. Claude Code appends the full path itself, and it covers every API call the client makes, including the/v1/messages/count_tokenssibling the listener passes through without ingesting.Claude Code always wants some credential in its environment and falls back to a claude.ai login when it finds none. When the proxy holds the real key, give the client the gate token rather than nothing.
ANTHROPIC_API_KEYsendsx-api-key, which is whatAuth-Style = "x-api-key"expects.ANTHROPIC_AUTH_TOKENsendsAuthorization: Bearerinstead and needsAuth-Style = "bearer"on the listener.Setting
Session-ID-Header = "x-claude-code-session-id"on the listener stamps each entry with Claude Code’s own session identifier, which is what makes an ingested conversation line up with the client’s transcript. See Session Tracking.
Attaching Embeddings#
Because every ingested event is a discrete entry of natural-language text, LLM traffic is a good fit for embedding at ingest time. Adding a vector preprocessor to a listener attaches an embeddings enumerated value to each prompt, reply, and tool call, which can then be searched by meaning with the semantic module:
[Listener "openai"]
Bind = ":4180"
Upstream-URL = "https://api.openai.com"
Protocol = "openai-chat"
Tag-Name = llm
Preprocessor = embed
[Preprocessor "embed"]
Type=vector
Model="text-embedding-3-small"
Endpoint="https://api.example.com/v1/embeddings"
Token=`sk-example-token`
Warning
Embedding requests are made synchronously as entries flow through, so the throughput of the listener becomes bound to the throughput of the embeddings endpoint. Consider whether that endpoint meters requests or charges per token before enabling this on busy traffic.
Example Queries#
Show the conversation for a single session in order:
tag=llm intrinsic session_id == "0f0d5f13-2c9e-4a97-9b0e-0f2e0f19a37e" event_type role | sort by TIMESTAMP asc | table TIMESTAMP event_type role DATA
Count the tools an agent is calling:
tag=llm intrinsic event_type == "response.tool_call" tool_name | count by tool_name | sort by count desc | table tool_name count
Total token spend by model:
tag=llm intrinsic event_type == "response.usage" model total_tokens | stats sum(total_tokens) by model | table model sum
Chart prompt volume over time:
tag=llm intrinsic event_type == "request.user_message" | count | chart count
Find the slowest exchanges:
tag=llm intrinsic event_type == "response.assistant_message" duration_ms model | sort by duration_ms desc | table duration_ms model DATA
Search prompts by meaning rather than by keyword, using embeddings attached by the vector preprocessor:
tag=llm intrinsic event_type == "request.user_message" embeddings | semantic -t 70 "asking the model for production credentials" | sort by score desc | table score DATA