Skip to content

Commit 2b20dfa

Browse files
enriquephlclaude
andauthored
feat: OpenRouter audit passthrough + model_config hotfix (#20)
* feat(core): add LlmAudit type and audit field on Event::UserMessage Introduces `LlmAudit` (user/session_id/metadata, all optional) as a transport-only passthrough struct; extends `Event::UserMessage` with `#[serde(default)] audit: Option<LlmAudit>` for zero-churn on legacy wire bodies. Extends `ChatResponse` with generation_id/model/usage fields (all None for now; Task 7 fills them from LLM responses). Updates pde.rs test helper and both companion.rs construction sites. Refactors pipeline::run to own the full llm_resp rather than destructure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(llm): add AppAttribution headers to OpenRouterClient Introduces `AppAttribution` struct with optional `HTTP-Referer` and `X-Title` fields. `OpenRouterClient::new` now takes attribution as a second argument; invalid header values are warned and dropped rather than panicking. Adds `with_base_url` constructor for wiremock-based unit tests. Production call site reads `OPENROUTER_APP_REFERER` / `OPENROUTER_APP_TITLE` env vars at startup. * feat(llm): add user/session_id/metadata passthrough to ChatRequest Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(llm): parse generation_id/model/usage from OpenRouter response Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(pipeline): thread LlmAudit from Event into ChatRequest Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(pipeline): propagate usage/generation_id/model into ChatResponse + tracing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(api): add audit passthrough on send_message + usage on response - Add `LlmAuditDto` DTO and `validate_llm_audit` helper with four constants (string chars ≤ 256, metadata keys ≤ 16, key chars ≤ 64, value chars ≤ 512). - Wire validated `audit` into both `send_message` and `send_message_async` handlers (replaces the Task-1 `audit: None` placeholder). - Extend `CompanionReplyResponse` with optional `usage`, `generation_id`, and `model` fields sourced from the pipeline `ChatResponse`. - Add 8 `validate_llm_audit_*` unit tests (all pass without DB) and 4 `send_message_async_*` sqlx integration tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(env): document OPENROUTER_APP_REFERER and OPENROUTER_APP_TITLE Two optional env vars added in Task 2 of the openrouter-audit plan. When set, the engine emits HTTP-Referer / X-Title headers on every outbound OpenRouter call so the deployment shows up in OpenRouter's app analytics. Both unset preserves today's anonymous behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(api): document audit passthrough + usage on CompanionReplyResponse Updated the success-response JSON example to show the three new optional echo fields (usage / generation_id / model). Added two new sub-sections after the prompt_traits section describing the audit passthrough body field and the size/shape caps. Notes that the async route accepts audit but does not surface the echo fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: add llm-audit reference (EN + ZH) One-page docs for the OpenRouter audit passthrough surface. Covers the inbound audit request field with cap table, the outbound usage echo on sync responses, the two App-Attribution env vars, observability behaviour, and the explicit non-goals (no persistence, no hashing, no sanitisation, no interpretation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(openapi): regenerate snapshot for audit + usage fields Picks up LlmAuditDto, the audit field on SendMessageRequest, and the three optional usage / generation_id / model fields on CompanionReplyResponse added in Task 8 of the openrouter-audit plan. CI drift check will be green after this. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(openapi): strip cargo stderr lines from snapshot The regeneration script was run without `--quiet`, which caused two cargo progress lines to be prepended to the JSON file. The CI `openapi-snapshot` job uses `--quiet` and diffs against this file, so the snapshot would have failed on merge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(model_config): retire stale OpenRouter model IDs (#19) OpenRouter retired several model IDs that were hard-coded in the example config, causing prod `POST /comp/chat/:id/message` calls to fail with 400 "is not a valid model ID": - x-ai/grok-4-fast → x-ai/grok-4.20 - x-ai/grok-4-mini → x-ai/grok-4.1-fast - deepseek/deepseek-chat-v3.2 → x-ai/grok-4.1-fast (or deepseek/deepseek-v4-flash on insight tasks) Also leaves a TODO inline noting that `fallback` should grow into a preference-ordered list in a future iteration; for now one fallback per task is all we wire. Hardcoded defaults in `crates/eros-engine-llm/src/model_config.rs` still reference the retired IDs — out of scope for this hotfix and should be a separate follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 657291c commit 2b20dfa

18 files changed

Lines changed: 1437 additions & 38 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ DATABASE_URL=postgres://postgres:<password>@<host>:5432/postgres
77
# OpenRouter (chat completions). Same key works for both apps.
88
OPENROUTER_API_KEY=sk-or-...
99

10+
# OpenRouter app attribution (optional). When set, the engine adds
11+
# these headers to every outbound OpenRouter call so the deployment
12+
# shows up on OpenRouter's app analytics dashboard. Leave unset to
13+
# remain anonymous.
14+
# OPENROUTER_APP_REFERER=https://eros.example
15+
# OPENROUTER_APP_TITLE=Eros
16+
1017
# Supabase project — same as eros-gateway.
1118
# Setting SUPABASE_URL is enough on modern projects: the engine derives the
1219
# JWKS URL (${SUPABASE_URL}/auth/v1/.well-known/jwks.json) and validates

Cargo.lock

Lines changed: 135 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/eros-engine-core/src/pde.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ mod tests {
207207
content: content.into(),
208208
message_id: Uuid::new_v4(),
209209
prompt_traits: Vec::new(),
210+
audit: None,
210211
}
211212
}
212213

crates/eros-engine-core/src/types.rs

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,21 @@ pub struct PromptTrait {
1717
pub text: String,
1818
}
1919

20+
/// Caller-supplied OpenRouter passthrough for per-request audit /
21+
/// analytics. Engine never inspects these fields; they ride straight to
22+
/// `openrouter.ai/api/v1/chat/completions` as wire-level `user`,
23+
/// `session_id`, `metadata`. The HTTP layer applies size/shape caps;
24+
/// content remains opaque to the engine.
25+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
26+
pub struct LlmAudit {
27+
#[serde(default, skip_serializing_if = "Option::is_none")]
28+
pub user: Option<String>,
29+
#[serde(default, skip_serializing_if = "Option::is_none")]
30+
pub session_id: Option<String>,
31+
#[serde(default, skip_serializing_if = "Option::is_none")]
32+
pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
33+
}
34+
2035
/// Events that drive the engine pipeline.
2136
#[derive(Debug, Clone, Serialize, Deserialize)]
2237
pub enum Event {
@@ -28,6 +43,12 @@ pub enum Event {
2843
/// byte-for-byte.
2944
#[serde(default)]
3045
prompt_traits: Vec<PromptTrait>,
46+
/// Optional caller-supplied OpenRouter audit passthrough.
47+
/// `None` for clients that don't send the field. Engine carries it
48+
/// opaquely from request → handler → ChatRequest; content is never
49+
/// inspected.
50+
#[serde(default)]
51+
audit: Option<LlmAudit>,
3152
},
3253
Gift {
3354
gift_id: Uuid,
@@ -75,10 +96,16 @@ pub struct ConversationSignals {
7596
pub hours_since_last_ghost: Option<f64>,
7697
}
7798

78-
/// Response from the chat engine — pure text reply.
79-
#[derive(Debug, Clone)]
99+
/// Response from the chat engine — text reply plus opaque OpenRouter
100+
/// wire echoes (`generation_id`, `model`, `usage`). The three echo
101+
/// fields are `None` when no LLM call was made (handler returned None)
102+
/// or when upstream omitted them.
103+
#[derive(Debug, Clone, Default)]
80104
pub struct ChatResponse {
81105
pub reply: String,
106+
pub generation_id: Option<String>,
107+
pub model: Option<String>,
108+
pub usage: Option<serde_json::Value>,
82109
}
83110

84111
/// Input bundle consumed by the PDE.
@@ -116,4 +143,46 @@ mod tests {
116143
let back: PromptTrait = serde_json::from_str(&json).unwrap();
117144
assert_eq!(back, t);
118145
}
146+
147+
#[test]
148+
fn llm_audit_serde_roundtrip_full() {
149+
let mut metadata = serde_json::Map::new();
150+
metadata.insert("plan".into(), serde_json::Value::String("pro".into()));
151+
let a = LlmAudit {
152+
user: Some("u_abc".into()),
153+
session_id: Some("conv_xyz".into()),
154+
metadata: Some(metadata),
155+
};
156+
let json = serde_json::to_string(&a).unwrap();
157+
let back: LlmAudit = serde_json::from_str(&json).unwrap();
158+
assert_eq!(back, a);
159+
}
160+
161+
#[test]
162+
fn llm_audit_serde_roundtrip_empty_yields_all_none() {
163+
let a: LlmAudit = serde_json::from_str("{}").unwrap();
164+
assert!(a.user.is_none());
165+
assert!(a.session_id.is_none());
166+
assert!(a.metadata.is_none());
167+
}
168+
169+
#[test]
170+
fn event_user_message_defaults_audit_to_none() {
171+
let raw = r#"{"UserMessage":{"content":"hi","message_id":"00000000-0000-0000-0000-000000000001"}}"#;
172+
let ev: Event = serde_json::from_str(raw).expect("legacy body deserialises");
173+
match ev {
174+
Event::UserMessage { audit, .. } => {
175+
assert!(audit.is_none(), "missing audit field must default to None");
176+
}
177+
_ => panic!("expected UserMessage"),
178+
}
179+
}
180+
181+
#[test]
182+
fn chat_response_defaults_audit_fields_to_none() {
183+
let r = ChatResponse { reply: "hi".into(), ..Default::default() };
184+
assert!(r.usage.is_none());
185+
assert!(r.generation_id.is_none());
186+
assert!(r.model.is_none());
187+
}
119188
}

crates/eros-engine-llm/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,7 @@ thiserror = { workspace = true }
2121
tracing = { workspace = true }
2222
tokio = { workspace = true }
2323
toml = "0.8"
24+
25+
[dev-dependencies]
26+
wiremock = "0.6"
27+
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }

0 commit comments

Comments
 (0)