lakehouse/auditor 1 blocking issue: todo!() macro call in tests/real-world/scrum_master_pipeline.ts
Modern OpenAI clients (pi-ai, openai SDK 6.x, langchain-js, the official
agents) send `messages[].content` as an array of content parts:
`[{type:"text", text:"..."}, {type:"image_url", ...}]`. Our gateway
typed `content` as plain `String` and 422'd those calls.
Fix: `Message.content` is now `serde_json::Value` so requests
deserialize regardless of shape. `Message::text()` flattens
content-parts arrays (concat'd `text` fields, non-text parts skipped)
for places that need a plain string — Ollama prompt assembly, char
counts, the assistant's own response synthesis. `Message::new_text()`
constructs string-content messages without writing the wrapper at
each call site. Forwarders (openrouter) clone content through
verbatim so providers see exactly what the client sent.
Verified end-to-end: Pi CLI (`pi --print --provider openrouter`)
landed a clean 1902-token request through `/v1/chat/completions`,
routed to OpenRouter as `openai/gpt-oss-120b:free`, response in
1.62s, Langfuse trace `v1.chat:openrouter` recorded with provider
tag. Same path that any tool using the official openai SDK takes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
7.7 KiB
Rust
221 lines
7.7 KiB
Rust
//! OpenRouter adapter — free-tier rescue rung for /v1/chat.
|
|
//!
|
|
//! Direct HTTPS call to `https://openrouter.ai/api/v1/chat/completions`
|
|
//! with Bearer auth. Mirrors the OpenAI-compatible shape so the model
|
|
//! list can be expanded without code changes. Added 2026-04-24 after
|
|
//! iter 5 hit repeated Ollama Cloud 502s on kimi-k2:1t — OpenRouter
|
|
//! free-tier models give us a different provider backbone as fallback.
|
|
//!
|
|
//! Key sourcing priority:
|
|
//! 1. Env var `OPENROUTER_API_KEY`
|
|
//! 2. `/home/profit/.env` (LLM Team convention)
|
|
//! 3. `/root/llm_team_config.json` → providers.openrouter.api_key
|
|
//!
|
|
//! First hit wins. Key is resolved once at gateway startup and stored
|
|
//! on `V1State.openrouter_key`.
|
|
|
|
use std::time::Duration;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
|
|
|
|
const OR_BASE_URL: &str = "https://openrouter.ai/api/v1";
|
|
const OR_TIMEOUT_SECS: u64 = 180;
|
|
|
|
pub fn resolve_openrouter_key() -> Option<String> {
|
|
if let Ok(k) = std::env::var("OPENROUTER_API_KEY") {
|
|
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
|
}
|
|
// LLM Team UI writes its key to ~/.env on the host user — pick it up
|
|
// from the same source so the free-tier rescue path works without
|
|
// an explicit systemd Environment= line.
|
|
for path in ["/home/profit/.env", "/root/.env"] {
|
|
if let Ok(raw) = std::fs::read_to_string(path) {
|
|
for line in raw.lines() {
|
|
if let Some(rest) = line.strip_prefix("OPENROUTER_API_KEY=") {
|
|
let k = rest.trim().trim_matches('"').trim_matches('\'');
|
|
if !k.is_empty() { return Some(k.to_string()); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Ok(raw) = std::fs::read_to_string("/root/llm_team_config.json") {
|
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
|
|
if let Some(k) = v.pointer("/providers/openrouter/api_key").and_then(|x| x.as_str()) {
|
|
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub async fn chat(
|
|
key: &str,
|
|
req: &ChatRequest,
|
|
) -> Result<ChatResponse, String> {
|
|
// Strip the "openrouter/" prefix if the caller used the namespaced
|
|
// form so OpenRouter sees the raw model id (e.g. "openai/gpt-oss-120b:free").
|
|
let model = req.model.strip_prefix("openrouter/").unwrap_or(&req.model).to_string();
|
|
|
|
let body = ORChatBody {
|
|
model: model.clone(),
|
|
// Pass content through verbatim — preserves OpenAI's multimodal
|
|
// content-parts shape (`[{type:"text",text:"..."}, ...]`) so the
|
|
// upstream provider sees exactly what the client sent.
|
|
messages: req.messages.iter().map(|m| ORMessage {
|
|
role: m.role.clone(),
|
|
content: m.content.clone(),
|
|
}).collect(),
|
|
max_tokens: req.max_tokens.unwrap_or(800),
|
|
temperature: req.temperature.unwrap_or(0.3),
|
|
stream: false,
|
|
};
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(OR_TIMEOUT_SECS))
|
|
.build()
|
|
.map_err(|e| format!("build client: {e}"))?;
|
|
|
|
let t0 = std::time::Instant::now();
|
|
let resp = client
|
|
.post(format!("{}/chat/completions", OR_BASE_URL))
|
|
.bearer_auth(key)
|
|
// OpenRouter recommends Referer + Title for attribution; absent
|
|
// headers do not fail the call but help us see our traffic in
|
|
// their dashboard.
|
|
.header("HTTP-Referer", "https://vcp.devop.live")
|
|
.header("X-Title", "Lakehouse Scrum")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("openrouter.ai unreachable: {e}"))?;
|
|
|
|
let status = resp.status();
|
|
if !status.is_success() {
|
|
let body = resp.text().await.unwrap_or_else(|_| "?".into());
|
|
return Err(format!("openrouter.ai {}: {}", status, body));
|
|
}
|
|
|
|
let parsed: ORChatResponse = resp.json().await
|
|
.map_err(|e| format!("invalid openrouter response: {e}"))?;
|
|
|
|
let latency_ms = t0.elapsed().as_millis();
|
|
let choice = parsed.choices.into_iter().next()
|
|
.ok_or_else(|| "openrouter returned no choices".to_string())?;
|
|
let text = choice.message.content;
|
|
|
|
let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| {
|
|
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum();
|
|
((chars + 3) / 4) as u32
|
|
});
|
|
let completion_tokens = parsed.usage.as_ref().map(|u| u.completion_tokens).unwrap_or_else(|| {
|
|
((text.chars().count() + 3) / 4) as u32
|
|
});
|
|
|
|
tracing::info!(
|
|
target: "v1.chat",
|
|
provider = "openrouter",
|
|
model = %model,
|
|
prompt_tokens,
|
|
completion_tokens,
|
|
latency_ms = latency_ms as u64,
|
|
"openrouter chat completed",
|
|
);
|
|
|
|
Ok(ChatResponse {
|
|
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
|
object: "chat.completion",
|
|
created: chrono::Utc::now().timestamp(),
|
|
model,
|
|
choices: vec![Choice {
|
|
index: 0,
|
|
message: Message { role: "assistant".into(), content: serde_json::Value::String(text) },
|
|
finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()),
|
|
}],
|
|
usage: UsageBlock {
|
|
prompt_tokens,
|
|
completion_tokens,
|
|
total_tokens: prompt_tokens + completion_tokens,
|
|
},
|
|
})
|
|
}
|
|
|
|
// -- OpenRouter wire shapes (OpenAI-compatible) --
|
|
|
|
#[derive(Serialize)]
|
|
struct ORChatBody {
|
|
model: String,
|
|
messages: Vec<ORMessage>,
|
|
max_tokens: u32,
|
|
temperature: f64,
|
|
stream: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ORMessage { role: String, content: serde_json::Value }
|
|
|
|
#[derive(Deserialize)]
|
|
struct ORChatResponse {
|
|
choices: Vec<ORChoice>,
|
|
#[serde(default)]
|
|
usage: Option<ORUsage>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ORChoice {
|
|
message: ORMessageResp,
|
|
#[serde(default)]
|
|
finish_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ORMessageResp { content: String }
|
|
|
|
#[derive(Deserialize)]
|
|
struct ORUsage { prompt_tokens: u32, completion_tokens: u32 }
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn resolve_openrouter_key_does_not_panic() {
|
|
// Smoke test — all three sources may or may not be set depending
|
|
// on environment; just confirm the call returns cleanly.
|
|
let _ = resolve_openrouter_key();
|
|
}
|
|
|
|
#[test]
|
|
fn chat_body_serializes_to_openai_shape() {
|
|
let body = ORChatBody {
|
|
model: "openai/gpt-oss-120b:free".into(),
|
|
messages: vec![
|
|
ORMessage { role: "user".into(), content: "review this".into() },
|
|
],
|
|
max_tokens: 800,
|
|
temperature: 0.3,
|
|
stream: false,
|
|
};
|
|
let json = serde_json::to_string(&body).unwrap();
|
|
assert!(json.contains("\"model\":\"openai/gpt-oss-120b:free\""));
|
|
assert!(json.contains("\"messages\""));
|
|
assert!(json.contains("\"max_tokens\":800"));
|
|
assert!(json.contains("\"stream\":false"));
|
|
}
|
|
|
|
#[test]
|
|
fn model_prefix_strip_preserves_unprefixed() {
|
|
// If caller passes "openrouter/openai/gpt-oss-120b:free" we strip.
|
|
// If caller passes "openai/gpt-oss-120b:free" unchanged, we keep.
|
|
let cases = [
|
|
("openrouter/openai/gpt-oss-120b:free", "openai/gpt-oss-120b:free"),
|
|
("openai/gpt-oss-120b:free", "openai/gpt-oss-120b:free"),
|
|
("google/gemma-3-27b-it:free", "google/gemma-3-27b-it:free"),
|
|
];
|
|
for (input, expected) in cases {
|
|
let out = input.strip_prefix("openrouter/").unwrap_or(input);
|
|
assert_eq!(out, expected, "{input} should become {expected}");
|
|
}
|
|
}
|
|
}
|