feat(core): per-agent LLM request metadata passthrough (llm_metadata)#100
Open
brentrager wants to merge 1 commit into
Open
feat(core): per-agent LLM request metadata passthrough (llm_metadata)#100brentrager wants to merge 1 commit into
brentrager wants to merge 1 commit into
Conversation
|
The published core builds every /chat/completions request (streaming and non-streaming) with no way to attach passthrough metadata, so a consumer can't tag LLM spend per agent. The LiteLLM gateway records a top-level `metadata` object on its LiteLLM_SpendLogs rows; without a seam here a host (e.g. copilot-ws's per-agent config resolver) has nowhere to stamp an attribution key like `smooai_agent_slug`. Seam (mirrors the existing `with_model_ceiling` at both layers): - `ChatRequest.metadata: Option<serde_json::Map<String, Value>>`, `skip_serializing_if None`. A JSON object (not a bare Value) so it always merges as top-level `metadata`; empty normalizes to None at the builder, so None AND empty are byte-identical on the wire. Some is a safe additive (LiteLLM ignores unknown top-level metadata). - `LlmClient::with_metadata(Option<Map>)` — the judge/direct-client path. - `AgentConfig::with_metadata(Option<Map>)` + `request_metadata` field, threaded onto the LlmClient the agent builds in both the streaming and non-streaming run loops. This is the path the main chat turn uses, so without it the tag would only reach the judge. Both OpenAI-compat request bodies now build through one shared `build_openai_request` helper, so metadata (and every other field) can't drift between the streaming and non-streaming paths. Anthropic-native (/v1/messages) is left unchanged — the spend-attribution consumer routes through the OpenAI-compat gateway. Kept fully generic: core sets no metadata keys of its own, so a host's keys are never clobbered. Tests: default omits `metadata`; empty map is wire-identical to None; with_metadata forwarded verbatim; metadata purely additive (no other field disturbed); AgentConfig builder defaults/sets/normalizes-empty. Release/version bump deliberately NOT included — held for release owner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tRq2Rge3gGHCjJdNx6xDD
brentrager
force-pushed
the
llm-request-metadata
branch
from
July 22, 2026 23:57
42003fa to
c9e5537
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a generic passthrough
metadataobject on the OpenAI-compatible/chat/completionsrequest so a host can tag each LLM turn — the first consumer being per-agent LLM spend attribution.LlmClient::with_metadata(Option<serde_json::Value>)(mirrors the existingwith_model_ceilingbuilder).metadata: Option<serde_json::Value>field onChatRequest,#[serde(skip_serializing_if = "Option::is_none")].build_openai_requesthelper, sometadata(and every other field) can't be attached to one path and silently dropped on the other. The analyst main turn is streamed, so this parity matters.Why
The LiteLLM gateway records a top-level
metadataobject on itsLiteLLM_SpendLogsrows. A monorepo billing cron partitions those rows bymetadata.smooai_agent_slugto separate analyst-agent spend from base pass-through spend. Today the published core builds every/chat/completionsrequest with no metadata seam, so that attribution key has nowhere to be set. The consumer (copilot-ws's per-agent config resolver, which already resolves anAgentBehaviorConfigper turn) will call.with_metadata({ smooai_agent_slug: "observability-analyst" })on a cloned per-agent client and core forwards it. Tracking: monorepo pearlth-b8ea4c(epic SMOODEV-2708).Contract / shape
Outbound
/chat/completionsbody gains an optional top-level object:{ "model": "...", "messages": [...], "metadata": { "smooai_agent_slug": "observability-analyst" } }None(the default) ⇒ nometadatakey on the wire — byte-identical to before.Some(obj)⇒ forwarded verbatim. LiteLLM ignores unknown top-levelmetadata, so it's a safe additive.Tests
cargo test -p smooai-smooth-operator-core— 606 passed, 2 ignored. New cases inllm.rs:request_omits_metadata_by_default— default client emits nometadatakey (wire-identical guarantee).with_metadata_is_forwarded_verbatim— a host tag round-trips verbatim as top-levelmetadata.metadata_is_purely_additive— tagged body == untagged body + exactly themetadatakey (no other field disturbed).cargo fmt --checkclean;cargo clippyclean on touched code (the only warnings are pre-existing nursery lints incheckpoint.rs, and CI's clippy step iscontinue-on-error).Release deliberately NOT included
Per the task, the crate version bump / publish is held for the release owner — no changeset is included (PR checks don't require one). Whoever cuts the release should add a
minorchangeset (new public API) at that time. The downstreamsmooth-operator(server) wiring —AgentBehaviorConfig.llm_metadata→ per-agent client.with_metadata(...)→ bump the core dep — is a stacked follow-up that can't land until this publishes.🤖 Generated with Claude Code
https://claude.ai/code/session_018tRq2Rge3gGHCjJdNx6xDD