Skip to content

feat(core): per-agent LLM request metadata passthrough (llm_metadata)#100

Open
brentrager wants to merge 1 commit into
mainfrom
llm-request-metadata
Open

feat(core): per-agent LLM request metadata passthrough (llm_metadata)#100
brentrager wants to merge 1 commit into
mainfrom
llm-request-metadata

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

What

Adds a generic passthrough metadata object on the OpenAI-compatible /chat/completions request so a host can tag each LLM turn — the first consumer being per-agent LLM spend attribution.

  • New builder LlmClient::with_metadata(Option<serde_json::Value>) (mirrors the existing with_model_ceiling builder).
  • New metadata: Option<serde_json::Value> field on ChatRequest, #[serde(skip_serializing_if = "Option::is_none")].
  • Both the streaming and non-streaming request bodies now build through one shared build_openai_request helper, so metadata (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 metadata object on its LiteLLM_SpendLogs rows. A monorepo billing cron partitions those rows by metadata.smooai_agent_slug to separate analyst-agent spend from base pass-through spend. Today the published core builds every /chat/completions request 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 an AgentBehaviorConfig per turn) will call .with_metadata({ smooai_agent_slug: "observability-analyst" }) on a cloned per-agent client and core forwards it. Tracking: monorepo pearl th-b8ea4c (epic SMOODEV-2708).

Contract / shape

Outbound /chat/completions body gains an optional top-level object:

{ "model": "...", "messages": [...], "metadata": { "smooai_agent_slug": "observability-analyst" } }
  • None (the default) ⇒ no metadata key on the wire — byte-identical to before.
  • Some(obj) ⇒ forwarded verbatim. LiteLLM ignores unknown top-level metadata, so it's a safe additive.
  • Kept fully generic — no SmooAI-specific naming in the core API; core sets no keys of its own, so a host's keys are never clobbered.

Tests

cargo test -p smooai-smooth-operator-core — 606 passed, 2 ignored. New cases in llm.rs:

  • request_omits_metadata_by_default — default client emits no metadata key (wire-identical guarantee).
  • with_metadata_is_forwarded_verbatim — a host tag round-trips verbatim as top-level metadata.
  • metadata_is_purely_additive — tagged body == untagged body + exactly the metadata key (no other field disturbed).

cargo fmt --check clean; cargo clippy clean on touched code (the only warnings are pre-existing nursery lints in checkpoint.rs, and CI's clippy step is continue-on-error).

Release deliberately NOT included

Per the task, the crate version bump / publish is held for the release ownerno changeset is included (PR checks don't require one). Whoever cuts the release should add a minor changeset (new public API) at that time. The downstream smooth-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

@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: c9e5537

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant