Harden Rust custom provider routing and mock coverage (#1958)

This commit is contained in:
Steven Enamakel
2026-05-16 13:18:10 -07:00
committed by GitHub
parent 91634d48c1
commit 30a4a0706f
12 changed files with 1622 additions and 623 deletions
@@ -0,0 +1,172 @@
import test from "node:test";
import assert from "node:assert/strict";
import { handleLlmCompletions } from "../llm.mjs";
import { resetMockBehavior, setMockBehaviors } from "../../state.mjs";
function createMockResponse() {
return {
headers: {},
statusCode: null,
body: "",
chunks: [],
ended: false,
setHeader(name, value) {
this.headers[name] = value;
},
writeHead(status, headers = {}) {
this.statusCode = status;
Object.assign(this.headers, headers);
},
write(chunk) {
const text = String(chunk);
this.chunks.push(text);
this.body += text;
},
end(chunk = "") {
if (chunk) this.write(chunk);
this.ended = true;
},
};
}
function makeCtx({
method = "POST",
url = "/chat/completions",
parsedBody = { model: "gpt-oss", messages: [{ role: "user", content: "hello" }] },
headers = {},
} = {}) {
return {
method,
url,
parsedBody,
req: { headers },
res: createMockResponse(),
};
}
test.beforeEach(() => {
resetMockBehavior();
});
test("handles root chat completions path with default fallback", () => {
const ctx = makeCtx({ url: "/chat/completions" });
const handled = handleLlmCompletions(ctx);
assert.equal(handled, true);
assert.equal(ctx.res.statusCode, 200);
const body = JSON.parse(ctx.res.body);
assert.equal(body.model, "gpt-oss");
assert.equal(
body.choices[0].message.content,
"Hello from e2e mock agent",
);
});
test("matches request rules against path and authorization header", () => {
setMockBehaviors(
{
llmRequestRules: JSON.stringify([
{
path: "/v1/chat/completions",
model: "gpt-4.1-mini",
authorization: "Bearer sk-test",
content: "matched via request rule",
},
]),
},
"replace",
);
const ctx = makeCtx({
url: "/v1/chat/completions",
parsedBody: {
model: "gpt-4.1-mini",
messages: [{ role: "user", content: "hello" }],
},
headers: { authorization: "Bearer sk-test" },
});
const handled = handleLlmCompletions(ctx);
assert.equal(handled, true);
assert.equal(ctx.res.statusCode, 200);
const body = JSON.parse(ctx.res.body);
assert.equal(body.choices[0].message.content, "matched via request rule");
});
test("streams request-rule scripts for root chat completions path", async () => {
setMockBehaviors(
{
llmRequestRules: JSON.stringify([
{
path: "/chat/completions",
stream: true,
streamScript: [{ text: "hello" }, { finish: "stop" }],
},
]),
},
"replace",
);
const ctx = makeCtx({
url: "/chat/completions",
parsedBody: {
model: "gpt-oss",
stream: true,
messages: [{ role: "user", content: "stream please" }],
},
});
const handled = handleLlmCompletions(ctx);
assert.equal(handled, true);
await new Promise((resolve) => setTimeout(resolve, 80));
assert.equal(ctx.res.statusCode, 200);
assert.match(ctx.res.body, /data: .*hello/);
assert.match(ctx.res.body, /data: \[DONE\]/);
assert.equal(ctx.res.ended, true);
});
test("returns HTTP error for streaming rules with status >= 400", () => {
setMockBehaviors(
{
llmRequestRules: JSON.stringify([
{
path: "/chat/completions",
stream: true,
status: 401,
error: "unauthorized",
type: "auth_error",
},
]),
},
"replace",
);
const ctx = makeCtx({
url: "/chat/completions",
parsedBody: {
model: "gpt-oss",
stream: true,
messages: [{ role: "user", content: "stream please" }],
},
});
const handled = handleLlmCompletions(ctx);
assert.equal(handled, true);
assert.equal(ctx.res.statusCode, 401);
assert.equal(ctx.res.headers["Content-Type"], "application/json");
const body = JSON.parse(ctx.res.body);
assert.equal(body.error.message, "unauthorized");
assert.equal(body.error.type, "auth_error");
assert.doesNotMatch(ctx.res.body, /^data:/m);
});
test("returns false for non-LLM routes", () => {
const ctx = makeCtx({ method: "GET", url: "/chat/completions" });
assert.equal(handleLlmCompletions(ctx), false);
});
+141 -9
View File
@@ -1,6 +1,81 @@
import { json, setCors } from "../http.mjs";
import { behavior, parseBehaviorJson, setMockBehavior } from "../state.mjs";
function headerValue(headers, name) {
const raw = headers?.[name];
if (Array.isArray(raw)) return raw.join(", ");
return typeof raw === "string" ? raw : "";
}
function requestRuleMatches(rule, ctx) {
if (!rule || typeof rule !== "object") return false;
const { url, parsedBody, req } = ctx;
const model =
typeof parsedBody?.model === "string" ? parsedBody.model : "e2e-mock-model";
const stream = parsedBody?.stream === true;
const authorization = headerValue(req?.headers, "authorization");
const xApiKey = headerValue(req?.headers, "x-api-key");
if (typeof rule.path === "string" && rule.path !== url) return false;
if (typeof rule.model === "string" && rule.model !== model) return false;
if (typeof rule.stream === "boolean" && rule.stream !== stream) return false;
if (typeof rule.authorization === "string") {
if (rule.authorization === "present" && !authorization) return false;
if (rule.authorization === "missing" && authorization) return false;
if (
rule.authorization !== "present" &&
rule.authorization !== "missing" &&
authorization !== rule.authorization
) {
return false;
}
}
if (typeof rule.xApiKey === "string") {
if (rule.xApiKey === "present" && !xApiKey) return false;
if (rule.xApiKey === "missing" && xApiKey) return false;
if (
rule.xApiKey !== "present" &&
rule.xApiKey !== "missing" &&
xApiKey !== rule.xApiKey
) {
return false;
}
}
if (typeof rule.keyword === "string") {
const probe = pickProbeText(parsedBody).toLowerCase();
if (!probe.includes(rule.keyword.toLowerCase())) return false;
}
return true;
}
function resolveRequestRule(ctx) {
const rules = parseBehaviorJson("llmRequestRules", []);
if (!Array.isArray(rules)) return null;
for (const rule of rules) {
if (requestRuleMatches(rule, ctx)) return rule;
}
return null;
}
function sendRuleError(res, rule) {
const status = Number.isInteger(rule?.status) ? rule.status : 401;
const message =
typeof rule?.error === "string" && rule.error.length > 0
? rule.error
: "mock LLM request rejected";
json(res, status, {
error: {
message,
type: rule?.type || "invalid_request_error",
code: rule?.code || null,
},
});
}
// ── Streaming helpers ─────────────────────────────────────────────
//
// When the agent harness calls the OpenAI-compatible endpoint with
@@ -146,11 +221,15 @@ function defaultStreamScript({ content, toolCalls }) {
return script;
}
function handleStreamingCompletion({ res, model, mockBehavior, parsedBody }) {
function handleStreamingCompletion({ res, model, mockBehavior, parsedBody, rule }) {
writeSseHead(res);
// 1. Explicit streaming script overrides everything.
let script = parseBehaviorJson("llmStreamScript", null);
let script = Array.isArray(rule?.streamScript) ? rule.streamScript : null;
if (!Array.isArray(script)) {
script = parseBehaviorJson("llmStreamScript", null);
}
if (!Array.isArray(script)) {
// 2. Forced queue: pop the next entry and convert it into a script.
@@ -186,11 +265,16 @@ function handleStreamingCompletion({ res, model, mockBehavior, parsedBody }) {
if (!Array.isArray(script)) {
// 4. Default: stream a short greeting in a few chunks.
const fallback =
typeof mockBehavior.llmFallbackContent === "string" &&
mockBehavior.llmFallbackContent.length > 0
? mockBehavior.llmFallbackContent
typeof rule?.content === "string" && rule.content.length > 0
? rule.content
: typeof mockBehavior.llmFallbackContent === "string" &&
mockBehavior.llmFallbackContent.length > 0
? mockBehavior.llmFallbackContent
: "Hello from e2e mock agent";
script = defaultStreamScript({ content: fallback });
script = defaultStreamScript({
content: fallback,
toolCalls: Array.isArray(rule?.toolCalls) ? rule.toolCalls : undefined,
});
}
const defaultDelayMs = Number.isFinite(
@@ -411,14 +495,17 @@ function buildResponse({ model, content, toolCalls }) {
}
/**
* Drive a mock OpenAI-compatible /v1/chat/completions endpoint with
* keyword-based responses. Returns true if the request was handled.
* Drive a mock OpenAI-compatible chat-completions endpoint with
* keyword-based responses. Accepts both `/v1/chat/completions` and
* root-based `/chat/completions` URLs because custom providers often
* let users enter either the API root or an explicit `/v1` base.
* Returns true if the request was handled.
*/
export function handleLlmCompletions(ctx) {
const { method, url, parsedBody, res } = ctx;
if (
method !== "POST" ||
!/^\/openai\/v1\/chat\/completions\/?$/.test(url)
!/^(\/openai)?(\/v1)?\/chat\/completions\/?$/.test(url)
) {
return false;
}
@@ -426,6 +513,29 @@ export function handleLlmCompletions(ctx) {
const mockBehavior = behavior();
const model =
typeof parsedBody?.model === "string" ? parsedBody.model : "e2e-mock-model";
const requestRule = resolveRequestRule(ctx);
if (requestRule?.error || (requestRule?.status && requestRule.status >= 400)) {
if (
parsedBody?.stream === true &&
requestRule?.error &&
!(Number.isInteger(requestRule?.status) && requestRule.status >= 400)
) {
writeSseHead(res);
writeSseEvent(res, {
error: {
message:
requestRule?.error || "mock LLM streaming request rejected",
type: requestRule?.type || "invalid_request_error",
code: requestRule?.code || null,
},
});
res.end();
return true;
}
sendRuleError(res, requestRule);
return true;
}
// ── Streaming branch ────────────────────────────────────────────
// Drive the OpenAI SSE protocol when the caller requested it. The
@@ -438,9 +548,31 @@ export function handleLlmCompletions(ctx) {
model,
mockBehavior,
parsedBody,
rule: requestRule,
});
}
if (requestRule?.body && typeof requestRule.body === "object") {
json(res, Number.isInteger(requestRule.status) ? requestRule.status : 200, requestRule.body);
return true;
}
if (
Array.isArray(requestRule?.toolCalls) ||
typeof requestRule?.content === "string"
) {
json(
res,
Number.isInteger(requestRule?.status) ? requestRule.status : 200,
buildResponse({
model,
content: requestRule?.content ?? "",
toolCalls: requestRule?.toolCalls ?? [],
}),
);
return true;
}
// 1. Forced queue — replay exact ChatResponse objects in order.
const forced = parseBehaviorJson("llmForcedResponses", []);
if (Array.isArray(forced) && forced.length > 0) {
@@ -768,8 +768,14 @@ impl Agent {
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
};
let provider_role = match config.default_model.as_deref().map(str::trim) {
Some("hint:agentic") | Some("agentic-v1") => "agentic",
Some("hint:coding") | Some("coding-v1") => "coding",
Some("hint:summarization") | Some("summarization-v1") => "summarization",
_ => "reasoning",
};
let (provider, model_name): (Box<dyn Provider>, String) =
crate::openhuman::providers::create_chat_provider("reasoning", config)?;
crate::openhuman::providers::create_chat_provider(provider_role, config)?;
// Dispatcher selection is deferred until after the tool list is
// finalised (orchestrator tools are appended below). We capture
+26 -15
View File
@@ -59,17 +59,16 @@ struct SessionCacheFingerprint {
/// turn read the cached welcome agent instead of invoking
/// `build_session_agent` to re-resolve the target.
target_agent_id: String,
/// `reasoning_provider` config value at build time. The cached
/// agent's provider was constructed from this string via
/// `create_chat_provider("reasoning", &config)`; without it the
/// next turn would reuse the stale provider after a Settings →
/// AI → LLM routing change until the cache evicts.
/// Bound provider string at build time for the selected workload
/// role (`reasoning`, `agentic`, `coding`, `summarization`).
///
/// Other workloads (`agentic`, `coding`, `memory`, …) are read
/// per call inside the factory, so they don't need to participate
/// in cache invalidation — only the orchestrator's reasoning
/// provider is bound to the cached `Agent`.
reasoning_provider: Option<String>,
/// Web-chat sessions cache a fully constructed `Agent`, which in
/// turn holds a concrete provider instance chosen up front by the
/// session builder. If the bound provider string changes in
/// Settings, the cache must invalidate so the next turn rebuilds
/// against the updated provider rather than silently reusing the
/// stale instance.
provider_binding: String,
}
struct SessionEntry {
@@ -677,11 +676,12 @@ async fn run_chat_task(
// turn from the cached welcome agent — the cache hit predicate
// didn't know about the routing decision before Commit 13.
let target_agent_id = pick_target_agent_id(&config, &profile);
let provider_role = provider_role_for_model_override(model_override.as_deref());
let current_fp = SessionCacheFingerprint {
model_override: model_override.clone(),
temperature,
target_agent_id: target_agent_id.clone(),
reasoning_provider: config.reasoning_provider.clone(),
provider_binding: crate::openhuman::providers::provider_for_role(provider_role, &config),
};
let prior = {
@@ -702,12 +702,12 @@ async fn run_chat_task(
Some(prior_entry) => {
log::info!(
"[web-channel] cache miss — rebuilding session agent \
(was id={}, now id={}; prior_reasoning_provider={:?}, now={:?}) \
(was id={}, now id={}; prior_provider_binding={}, now={}) \
for client={} thread={}",
prior_entry.fingerprint.target_agent_id,
target_agent_id,
prior_entry.fingerprint.reasoning_provider,
current_fp.reasoning_provider,
prior_entry.fingerprint.provider_binding,
current_fp.provider_binding,
client_id,
thread_id
);
@@ -1332,6 +1332,15 @@ fn normalize_model_override(model_override: Option<String>) -> Option<String> {
.filter(|model| !model.is_empty())
}
fn provider_role_for_model_override(model_override: Option<&str>) -> &'static str {
match model_override.map(str::trim) {
Some("hint:agentic") | Some("agentic-v1") => "agentic",
Some("hint:coding") | Some("coding-v1") => "coding",
Some("hint:summarization") | Some("summarization-v1") => "summarization",
_ => "reasoning",
}
}
fn build_session_agent(
config: &Config,
client_id: &str,
@@ -1345,6 +1354,7 @@ fn build_session_agent(
if let Some(model) = model_override {
effective.default_model = Some(model);
}
let provider_role = provider_role_for_model_override(effective.default_model.as_deref());
if let Some(temp) = temperature {
effective.default_temperature = temp;
}
@@ -1374,9 +1384,10 @@ fn build_session_agent(
// both flags reflect the current persisted state — no cache to
// invalidate.
log::info!(
"[web-channel] routing chat turn to '{}' via profile '{}' (chat_onboarding_completed={}, ui_onboarding_completed={}, client_id={}, thread_id={})",
"[web-channel] routing chat turn to '{}' via profile '{}' provider_role='{}' (chat_onboarding_completed={}, ui_onboarding_completed={}, client_id={}, thread_id={})",
target_agent_id,
profile.id,
provider_role,
effective.chat_onboarding_completed,
effective.onboarding_completed,
client_id,
+48 -36
View File
@@ -3,8 +3,8 @@ use super::{
classify_inference_error, event_session_id_for, extract_provider_error_detail,
generic_inference_error_user_message, inference_budget_exceeded_user_message,
is_inference_budget_exceeded_error, json_output, key_for, normalize_model_override,
optional_f64, optional_string, required_string, schemas, set_test_forced_run_chat_task_error,
start_chat, subscribe_web_channel_events,
optional_f64, optional_string, provider_role_for_model_override, required_string, schemas,
set_test_forced_run_chat_task_error, start_chat, subscribe_web_channel_events,
};
use crate::core::TypeSchema;
use tokio::time::{timeout, Duration};
@@ -316,30 +316,20 @@ fn fp(
model_override: Option<&str>,
temperature: Option<f64>,
target: &str,
reasoning_provider: Option<&str>,
provider_binding: &str,
) -> SessionCacheFingerprint {
SessionCacheFingerprint {
model_override: model_override.map(String::from),
temperature,
target_agent_id: target.to_string(),
reasoning_provider: reasoning_provider.map(String::from),
provider_binding: provider_binding.to_string(),
}
}
#[test]
fn fingerprint_identical_inputs_are_cache_hit() {
let a = fp(
None,
None,
"orchestrator",
Some("anthropic:claude-sonnet-4-6"),
);
let b = fp(
None,
None,
"orchestrator",
Some("anthropic:claude-sonnet-4-6"),
);
let a = fp(None, None, "orchestrator", "anthropic:claude-sonnet-4-6");
let b = fp(None, None, "orchestrator", "anthropic:claude-sonnet-4-6");
assert_eq!(
a, b,
"identical fingerprints must compare equal (cache hit)"
@@ -347,54 +337,76 @@ fn fingerprint_identical_inputs_are_cache_hit() {
}
#[test]
fn fingerprint_reasoning_provider_change_forces_rebuild() {
// The whole point of adding reasoning_provider to the fingerprint:
fn fingerprint_provider_binding_change_forces_rebuild() {
// The whole point of adding provider_binding to the fingerprint:
// changing the workload routing in Settings → AI → LLM mid-thread
// must invalidate the cached agent so the next turn rebuilds with
// the new provider.
let warm = fp(None, None, "orchestrator", Some("cloud"));
let after_settings_change = fp(
None,
None,
"orchestrator",
Some("anthropic:claude-sonnet-4-6"),
);
let warm = fp(None, None, "orchestrator", "cloud");
let after_settings_change = fp(None, None, "orchestrator", "anthropic:claude-sonnet-4-6");
assert_ne!(
warm, after_settings_change,
"reasoning_provider change must produce a different fingerprint (cache miss → rebuild)"
"provider binding change must produce a different fingerprint (cache miss → rebuild)"
);
}
#[test]
fn fingerprint_reasoning_provider_none_vs_some_differs() {
// Unset → explicitly-set is also a routing change (None resolves to
// 'cloud' via the factory, an explicit value does not).
let unset = fp(None, None, "orchestrator", None);
let set = fp(None, None, "orchestrator", Some("cloud"));
fn fingerprint_provider_binding_variants_differ() {
let unset = fp(None, None, "orchestrator", "openhuman");
let set = fp(None, None, "orchestrator", "cloud");
assert_ne!(unset, set);
}
#[test]
fn provider_role_override_routes_hint_workloads() {
assert_eq!(
provider_role_for_model_override(Some("hint:agentic")),
"agentic"
);
assert_eq!(
provider_role_for_model_override(Some("agentic-v1")),
"agentic"
);
assert_eq!(
provider_role_for_model_override(Some("hint:coding")),
"coding"
);
assert_eq!(
provider_role_for_model_override(Some("summarization-v1")),
"summarization"
);
assert_eq!(
provider_role_for_model_override(Some("hint:reasoning")),
"reasoning"
);
assert_eq!(
provider_role_for_model_override(Some("gpt-4.1-mini")),
"reasoning"
);
assert_eq!(provider_role_for_model_override(None), "reasoning");
}
#[test]
fn fingerprint_target_agent_flip_forces_rebuild() {
// welcome → orchestrator routing flip (onboarding completion) must
// still invalidate — regression guard for the original cache bug
// this struct also protects.
let welcome = fp(None, None, "welcome", Some("cloud"));
let orchestrator = fp(None, None, "orchestrator", Some("cloud"));
let welcome = fp(None, None, "welcome", "cloud");
let orchestrator = fp(None, None, "orchestrator", "cloud");
assert_ne!(welcome, orchestrator);
}
#[test]
fn fingerprint_model_override_and_temperature_participate() {
let base = fp(None, None, "orchestrator", Some("cloud"));
let base = fp(None, None, "orchestrator", "cloud");
assert_ne!(
base,
fp(Some("gpt-4o"), None, "orchestrator", Some("cloud")),
fp(Some("gpt-4o"), None, "orchestrator", "cloud"),
"per-message model_override must invalidate"
);
assert_ne!(
base,
fp(None, Some(0.9), "orchestrator", Some("cloud")),
fp(None, Some(0.9), "orchestrator", "cloud"),
"per-message temperature must invalidate"
);
}
+27
View File
@@ -156,6 +156,16 @@ async fn store_provider_credentials_rejects_when_no_credentials_supplied() {
assert!(err.contains("at least one credential"));
}
#[tokio::test]
async fn store_provider_credentials_rejects_blank_token_without_fields() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let err = store_provider_credentials(&config, "openai", None, Some(" ".into()), None, None)
.await
.unwrap_err();
assert!(err.contains("at least one credential"));
}
#[tokio::test]
async fn store_provider_credentials_stores_token_and_persists_to_disk() {
let tmp = TempDir::new().unwrap();
@@ -196,6 +206,23 @@ async fn store_provider_credentials_extracts_token_from_fields() {
assert!(result.value.has_token);
}
#[tokio::test]
async fn store_provider_credentials_extracts_api_key_from_fields() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let result = store_provider_credentials(
&config,
"openai",
None,
None,
Some(json!({ "api_key": "from-api-key-field" })),
None,
)
.await
.unwrap();
assert!(result.value.has_token);
}
#[tokio::test]
async fn store_provider_credentials_accepts_fields_only_without_token() {
let tmp = TempDir::new().unwrap();
+28
View File
@@ -23,3 +23,31 @@ fn truncate_for_display_long() {
assert!(truncated.starts_with("abcde"));
assert!(truncated.ends_with("..."));
}
#[test]
fn embedding_provider_validation_accepts_standard_values() {
assert_eq!(embedding_provider_validation_error("none"), None);
assert_eq!(embedding_provider_validation_error("openai"), None);
assert_eq!(
embedding_provider_validation_error("custom:https://example.com"),
None
);
}
#[test]
fn embedding_provider_validation_rejects_empty_custom_url() {
let err = embedding_provider_validation_error("custom: ").expect("should fail");
assert!(err.contains("non-empty URL"), "{err}");
}
#[test]
fn embedding_provider_validation_rejects_non_http_scheme() {
let err = embedding_provider_validation_error("custom:file:///tmp/model").expect("should fail");
assert!(err.contains("http/https"), "{err}");
}
#[test]
fn embedding_provider_validation_rejects_malformed_url() {
let err = embedding_provider_validation_error("custom:not a url").expect("should fail");
assert!(err.contains("invalid custom provider URL"), "{err}");
}
+4 -267
View File
@@ -213,7 +213,7 @@ fn make_ollama_provider(
model,
redact_endpoint(&endpoint)
);
let p = make_openai_compatible_provider(&endpoint, "", CompatAuthStyle::Bearer)?;
let p = make_openai_compatible_provider(&endpoint, "", CompatAuthStyle::None)?;
Ok((p, model.to_string()))
}
@@ -276,7 +276,7 @@ fn make_cloud_provider_by_slug(
make_openhuman_backend(config)
}
AuthStyle::None => {
let p = make_openai_compatible_provider(&entry.endpoint, "", CompatAuthStyle::Bearer)?;
let p = make_openai_compatible_provider(&entry.endpoint, "", CompatAuthStyle::None)?;
Ok((p, effective_model))
}
AuthStyle::Bearer => {
@@ -358,268 +358,5 @@ fn redact_endpoint(url: &str) -> String {
// ── Unit tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::config::schema::cloud_providers::{AuthStyle, CloudProviderCreds};
use crate::openhuman::config::Config;
fn config_with_providers(providers: Vec<CloudProviderCreds>) -> Config {
let mut c = Config::default();
c.cloud_providers = providers;
c
}
fn oh_entry(id: &str) -> CloudProviderCreds {
CloudProviderCreds {
id: id.to_string(),
slug: "openhuman".to_string(),
label: "OpenHuman".to_string(),
endpoint: "https://api.openhuman.ai/v1".to_string(),
auth_style: AuthStyle::OpenhumanJwt,
..Default::default()
}
}
fn openai_entry(id: &str, slug: &str) -> CloudProviderCreds {
CloudProviderCreds {
id: id.to_string(),
slug: slug.to_string(),
label: "OpenAI".to_string(),
endpoint: "https://api.openai.com/v1".to_string(),
auth_style: AuthStyle::Bearer,
default_model: Some("gpt-4o".to_string()),
..Default::default()
}
}
fn anthropic_entry(id: &str, slug: &str) -> CloudProviderCreds {
CloudProviderCreds {
id: id.to_string(),
slug: slug.to_string(),
label: "Anthropic".to_string(),
endpoint: "https://api.anthropic.com/v1".to_string(),
auth_style: AuthStyle::Anthropic,
default_model: Some("claude-sonnet-4-6".to_string()),
..Default::default()
}
}
// ── Grammar: all recognised forms ────────────────────────────────────────
#[test]
fn openhuman_literal() {
let config = Config::default();
let (_, model) = create_chat_provider_from_string("reasoning", "openhuman", &config)
.expect("openhuman literal must build");
assert!(!model.is_empty(), "model must not be empty");
}
#[test]
fn cloud_no_providers_falls_back_to_openhuman() {
let config = Config::default();
// "cloud" sentinel still works — routes to openhuman.
let result = create_chat_provider_from_string("reasoning", "cloud", &config);
assert!(
result.is_ok(),
"cloud fallback must succeed: {:?}",
result.err()
);
}
#[test]
fn openhuman_slug_routes_to_backend() {
let config = config_with_providers(vec![oh_entry("p_oh")]);
let (_, model) = create_chat_provider_from_string("reasoning", "openhuman:", &config)
.expect("openhuman: must build");
assert!(!model.is_empty());
}
#[test]
fn openai_slug_model() {
let config = config_with_providers(vec![openai_entry("p_oai", "openai")]);
let (_, model) = create_chat_provider_from_string("agentic", "openai:gpt-4o-mini", &config)
.expect("openai:<model> must build");
assert_eq!(model, "gpt-4o-mini");
}
#[test]
fn anthropic_slug_model() {
let config = config_with_providers(vec![anthropic_entry("p_ant", "anthropic")]);
let (_, model) =
create_chat_provider_from_string("coding", "anthropic:claude-sonnet-4-6", &config)
.expect("anthropic:<model> must build");
assert_eq!(model, "claude-sonnet-4-6");
}
#[test]
fn openrouter_slug_model() {
let mut config = Config::default();
config.cloud_providers.push(CloudProviderCreds {
id: "p_or".to_string(),
slug: "openrouter".to_string(),
label: "OpenRouter".to_string(),
endpoint: "https://openrouter.ai/api/v1".to_string(),
auth_style: AuthStyle::Bearer,
default_model: Some("openai/gpt-4o".to_string()),
..Default::default()
});
let (_, model) = create_chat_provider_from_string(
"agentic",
"openrouter:meta-llama/llama-3.1-8b",
&config,
)
.expect("openrouter:<model> must build");
assert_eq!(model, "meta-llama/llama-3.1-8b");
}
#[test]
fn ollama_prefix() {
let config = Config::default();
let (_, model) =
create_chat_provider_from_string("heartbeat", "ollama:llama3.1:8b", &config)
.expect("ollama:<model> must build");
assert_eq!(model, "llama3.1:8b");
}
// ── Workload routing ──────────────────────────────────────────────────────
#[test]
fn all_workloads_default_to_openhuman() {
let config = Config::default();
for role in &[
"reasoning",
"agentic",
"coding",
"memory",
"embeddings",
"heartbeat",
"learning",
"subconscious",
] {
assert_eq!(
provider_for_role(role, &config),
"openhuman",
"role={role} must default to openhuman"
);
}
}
#[test]
fn workload_override_respected() {
let mut config = Config::default();
config.heartbeat_provider = Some("ollama:llama3.2:3b".to_string());
assert_eq!(
provider_for_role("heartbeat", &config),
"ollama:llama3.2:3b"
);
assert_eq!(provider_for_role("reasoning", &config), "openhuman");
}
#[test]
fn create_chat_provider_uses_role() {
let mut config = Config::default();
config.cloud_providers.push(openai_entry("p_oai", "openai"));
config.reasoning_provider = Some("openai:gpt-4o-mini".to_string());
let (_, model) =
create_chat_provider("reasoning", &config).expect("create_chat_provider must succeed");
assert_eq!(model, "gpt-4o-mini");
}
// ── Error cases ───────────────────────────────────────────────────────────
#[test]
fn unknown_slug_rejected() {
let config = Config::default();
let err = create_chat_provider_from_string("reasoning", "groq:llama3", &config)
.err()
.expect("unknown slug must fail");
assert!(
err.to_string()
.contains("no cloud provider configured for slug"),
"{err}"
);
}
#[test]
fn bare_string_without_colon_rejected() {
let config = Config::default();
let err = create_chat_provider_from_string("reasoning", "openai", &config)
.err()
.expect("bare string must fail");
assert!(
err.to_string().contains("unrecognised provider string"),
"{err}"
);
}
#[test]
fn empty_model_in_ollama_rejected() {
let config = Config::default();
let err = create_chat_provider_from_string("reasoning", "ollama:", &config)
.err()
.expect("empty model must fail");
assert!(err.to_string().contains("empty model"), "{err}");
}
#[test]
fn missing_slug_for_openai_gives_clear_error() {
// No openai entry in cloud_providers.
let config = Config::default();
let err = create_chat_provider_from_string("reasoning", "openai:gpt-4o", &config)
.err()
.expect("missing slug must fail");
let msg = err.to_string();
assert!(
msg.contains("no cloud provider configured for slug 'openai'"),
"{msg}"
);
}
#[test]
fn primary_cloud_defaults_to_openhuman_when_no_providers() {
let config = Config::default();
assert!(create_chat_provider("reasoning", &config).is_ok());
}
// ── Summarization alias ───────────────────────────────────────────────────
#[test]
fn summarization_aliases_memory_provider() {
let mut config = Config::default();
config.memory_provider = Some("ollama:llama3.1:8b".to_string());
assert_eq!(provider_for_role("memory", &config), "ollama:llama3.1:8b");
assert_eq!(
provider_for_role("summarization", &config),
"ollama:llama3.1:8b",
"summarization must alias memory_provider"
);
}
#[test]
fn summarization_defaults_to_openhuman_like_memory() {
let config = Config::default();
assert_eq!(provider_for_role("memory", &config), "openhuman");
assert_eq!(provider_for_role("summarization", &config), "openhuman");
}
#[test]
fn unknown_workload_falls_back_to_openhuman() {
let config = Config::default();
assert_eq!(
provider_for_role("nope-not-a-workload", &config),
"openhuman"
);
assert_eq!(provider_for_role("", &config), "openhuman");
}
// ── OpenHuman backend state_dir wiring ────────────────────────────────────
#[test]
fn openhuman_backend_uses_config_path_parent_as_state_dir() {
let mut config = Config::default();
config.config_path = std::path::PathBuf::from("/tmp/oh-test-workspace/config.toml");
let (_provider, model) = create_chat_provider("reasoning", &config)
.expect("openhuman backend must build with no cloud_providers");
assert!(!model.is_empty(), "model must be set")
}
}
#[path = "factory_test.rs"]
mod factory_test;
+346
View File
@@ -0,0 +1,346 @@
use super::*;
use crate::openhuman::config::schema::cloud_providers::{AuthStyle, CloudProviderCreds};
use crate::openhuman::config::Config;
use crate::openhuman::credentials::AuthService;
use tempfile::TempDir;
fn config_with_providers(providers: Vec<CloudProviderCreds>) -> Config {
let mut c = Config::default();
c.cloud_providers = providers;
c
}
fn config_with_providers_in_tempdir(tmp: &TempDir, providers: Vec<CloudProviderCreds>) -> Config {
let mut c = config_with_providers(providers);
c.workspace_dir = tmp.path().join("workspace");
c.config_path = tmp.path().join("config.toml");
c
}
fn oh_entry(id: &str) -> CloudProviderCreds {
CloudProviderCreds {
id: id.to_string(),
slug: "openhuman".to_string(),
label: "OpenHuman".to_string(),
endpoint: "https://api.openhuman.ai/v1".to_string(),
auth_style: AuthStyle::OpenhumanJwt,
..Default::default()
}
}
fn openai_entry(id: &str, slug: &str) -> CloudProviderCreds {
CloudProviderCreds {
id: id.to_string(),
slug: slug.to_string(),
label: "OpenAI".to_string(),
endpoint: "https://api.openai.com/v1".to_string(),
auth_style: AuthStyle::Bearer,
default_model: Some("gpt-4o".to_string()),
..Default::default()
}
}
fn anthropic_entry(id: &str, slug: &str) -> CloudProviderCreds {
CloudProviderCreds {
id: id.to_string(),
slug: slug.to_string(),
label: "Anthropic".to_string(),
endpoint: "https://api.anthropic.com/v1".to_string(),
auth_style: AuthStyle::Anthropic,
default_model: Some("claude-sonnet-4-6".to_string()),
..Default::default()
}
}
#[test]
fn openhuman_literal() {
let config = Config::default();
let (_, model) = create_chat_provider_from_string("reasoning", "openhuman", &config)
.expect("openhuman literal must build");
assert!(!model.is_empty(), "model must not be empty");
}
#[test]
fn cloud_no_providers_falls_back_to_openhuman() {
let config = Config::default();
let result = create_chat_provider_from_string("reasoning", "cloud", &config);
assert!(
result.is_ok(),
"cloud fallback must succeed: {:?}",
result.err()
);
}
#[test]
fn openhuman_slug_routes_to_backend() {
let config = config_with_providers(vec![oh_entry("p_oh")]);
let (_, model) =
create_chat_provider_from_string("reasoning", "openhuman:", &config).expect("build");
assert!(!model.is_empty());
}
#[test]
fn openai_slug_model() {
let config = config_with_providers(vec![openai_entry("p_oai", "openai")]);
let (_, model) = create_chat_provider_from_string("agentic", "openai:gpt-4o-mini", &config)
.expect("openai:<model> must build");
assert_eq!(model, "gpt-4o-mini");
}
#[test]
fn anthropic_slug_model() {
let config = config_with_providers(vec![anthropic_entry("p_ant", "anthropic")]);
let (_, model) =
create_chat_provider_from_string("coding", "anthropic:claude-sonnet-4-6", &config)
.expect("anthropic:<model> must build");
assert_eq!(model, "claude-sonnet-4-6");
}
#[test]
fn openrouter_slug_model() {
let mut config = Config::default();
config.cloud_providers.push(CloudProviderCreds {
id: "p_or".to_string(),
slug: "openrouter".to_string(),
label: "OpenRouter".to_string(),
endpoint: "https://openrouter.ai/api/v1".to_string(),
auth_style: AuthStyle::Bearer,
default_model: Some("openai/gpt-4o".to_string()),
..Default::default()
});
let (_, model) =
create_chat_provider_from_string("agentic", "openrouter:meta-llama/llama-3.1-8b", &config)
.expect("openrouter:<model> must build");
assert_eq!(model, "meta-llama/llama-3.1-8b");
}
#[test]
fn ollama_prefix() {
let config = Config::default();
let (_, model) = create_chat_provider_from_string("heartbeat", "ollama:llama3.1:8b", &config)
.expect("ollama:<model> must build");
assert_eq!(model, "llama3.1:8b");
}
#[tokio::test]
async fn ollama_provider_does_not_require_api_key() {
let mut config = Config::default();
config.local_ai.base_url = Some("http://127.0.0.1:9".to_string());
let (provider, model) =
create_chat_provider_from_string("heartbeat", "ollama:llama3.1:8b", &config)
.expect("ollama:<model> must build");
let err = provider
.chat_with_system(None, "hello", &model, 0.0)
.await
.expect_err("unreachable local Ollama should still attempt a transport call");
let msg = err.to_string();
assert!(
!msg.contains("API key not set"),
"ollama path must not fail on missing key: {msg}"
);
}
#[test]
fn all_workloads_default_to_openhuman() {
let config = Config::default();
for role in &[
"reasoning",
"agentic",
"coding",
"memory",
"embeddings",
"heartbeat",
"learning",
"subconscious",
] {
assert_eq!(
provider_for_role(role, &config),
"openhuman",
"role={role} must default to openhuman"
);
}
}
#[test]
fn workload_override_respected() {
let mut config = Config::default();
config.heartbeat_provider = Some("ollama:llama3.2:3b".to_string());
assert_eq!(
provider_for_role("heartbeat", &config),
"ollama:llama3.2:3b"
);
assert_eq!(provider_for_role("reasoning", &config), "openhuman");
}
#[test]
fn create_chat_provider_uses_role() {
let mut config = Config::default();
config.cloud_providers.push(openai_entry("p_oai", "openai"));
config.reasoning_provider = Some("openai:gpt-4o-mini".to_string());
let (_, model) =
create_chat_provider("reasoning", &config).expect("create_chat_provider must succeed");
assert_eq!(model, "gpt-4o-mini");
}
#[test]
fn unknown_slug_rejected() {
let config = Config::default();
let err = create_chat_provider_from_string("reasoning", "groq:llama3", &config)
.err()
.expect("unknown slug must fail");
assert!(
err.to_string()
.contains("no cloud provider configured for slug"),
"{err}"
);
}
#[test]
fn bare_string_without_colon_rejected() {
let config = Config::default();
let err = create_chat_provider_from_string("reasoning", "openai", &config)
.err()
.expect("bare string must fail");
assert!(
err.to_string().contains("unrecognised provider string"),
"{err}"
);
}
#[test]
fn empty_model_in_ollama_rejected() {
let config = Config::default();
let err = create_chat_provider_from_string("reasoning", "ollama:", &config)
.err()
.expect("empty model must fail");
assert!(err.to_string().contains("empty model"), "{err}");
}
#[test]
fn missing_slug_for_openai_gives_clear_error() {
let config = Config::default();
let err = create_chat_provider_from_string("reasoning", "openai:gpt-4o", &config)
.err()
.expect("missing slug must fail");
let msg = err.to_string();
assert!(
msg.contains("no cloud provider configured for slug 'openai'"),
"{msg}"
);
}
#[tokio::test]
async fn cloud_provider_without_stored_key_fails_with_actionable_error() {
let tmp = TempDir::new().expect("tempdir");
let config = config_with_providers_in_tempdir(&tmp, vec![openai_entry("p_oai", "openai")]);
let (provider, model) = create_chat_provider_from_string("reasoning", "openai:gpt-4o", &config)
.expect("provider should build without eagerly requiring credentials");
let err = provider
.chat_with_system(None, "hello", &model, 0.0)
.await
.expect_err("missing key should fail at call time");
assert!(
err.to_string().contains("cloud API key not set"),
"expected missing-key guidance, got: {err}"
);
}
#[tokio::test]
async fn cloud_provider_with_auth_none_does_not_require_api_key() {
let tmp = TempDir::new().expect("tempdir");
let mut entry = openai_entry("p_proxy", "proxy");
entry.auth_style = AuthStyle::None;
entry.endpoint = "http://127.0.0.1:9".to_string();
let config = config_with_providers_in_tempdir(&tmp, vec![entry]);
let (provider, model) = create_chat_provider_from_string("reasoning", "proxy:gpt-oss", &config)
.expect("auth:none provider must build");
let err = provider
.chat_with_system(None, "hello", &model, 0.0)
.await
.expect_err("unreachable auth:none endpoint should attempt transport");
let msg = err.to_string();
assert!(
!msg.contains("API key not set"),
"auth:none provider must not fail on missing key: {msg}"
);
}
#[tokio::test]
async fn cloud_provider_with_malformed_endpoint_surfaces_url_error() {
let tmp = TempDir::new().expect("tempdir");
let mut entry = openai_entry("p_bad", "openai");
entry.endpoint = "://not a url".to_string();
let config = config_with_providers_in_tempdir(&tmp, vec![entry]);
let auth = AuthService::from_config(&config);
auth.store_provider_token(
"provider:openai",
"default",
"sk-test",
Default::default(),
true,
)
.expect("store provider token");
let (provider, model) = create_chat_provider_from_string("reasoning", "openai:gpt-4o", &config)
.expect("provider should still build");
let err = provider
.chat_with_system(None, "hello", &model, 0.0)
.await
.expect_err("malformed endpoint should fail at request build/send time");
let msg = err.to_string().to_ascii_lowercase();
assert!(
msg.contains("builder error")
|| msg.contains("relative url without a base")
|| msg.contains("empty host")
|| msg.contains("invalid port"),
"expected malformed-url style error, got: {msg}"
);
}
#[test]
fn primary_cloud_defaults_to_openhuman_when_no_providers() {
let config = Config::default();
assert!(create_chat_provider("reasoning", &config).is_ok());
}
#[test]
fn summarization_aliases_memory_provider() {
let mut config = Config::default();
config.memory_provider = Some("ollama:llama3.1:8b".to_string());
assert_eq!(provider_for_role("memory", &config), "ollama:llama3.1:8b");
assert_eq!(
provider_for_role("summarization", &config),
"ollama:llama3.1:8b",
"summarization must alias memory_provider"
);
}
#[test]
fn summarization_defaults_to_openhuman_like_memory() {
let config = Config::default();
assert_eq!(provider_for_role("memory", &config), "openhuman");
assert_eq!(provider_for_role("summarization", &config), "openhuman");
}
#[test]
fn unknown_workload_falls_back_to_openhuman() {
let config = Config::default();
assert_eq!(
provider_for_role("nope-not-a-workload", &config),
"openhuman"
);
assert_eq!(provider_for_role("", &config), "openhuman");
}
#[test]
fn openhuman_backend_uses_config_path_parent_as_state_dir() {
let mut config = Config::default();
config.config_path = std::path::PathBuf::from("/tmp/oh-test-workspace/config.toml");
let (_provider, model) = create_chat_provider("reasoning", &config)
.expect("openhuman backend must build with no cloud_providers");
assert!(!model.is_empty(), "model must be set")
}
+2 -290
View File
@@ -228,293 +228,5 @@ impl Provider for RouterProvider {
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct MockProvider {
calls: Arc<AtomicUsize>,
response: &'static str,
last_model: parking_lot::Mutex<String>,
}
impl MockProvider {
fn new(response: &'static str) -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
response,
last_model: parking_lot::Mutex::new(String::new()),
}
}
fn call_count(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
fn last_model(&self) -> String {
self.last_model.lock().clone()
}
}
#[async_trait]
impl Provider for MockProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
self.calls.fetch_add(1, Ordering::SeqCst);
*self.last_model.lock() = model.to_string();
Ok(self.response.to_string())
}
}
fn make_router(
providers: Vec<(&'static str, &'static str)>,
routes: Vec<(&str, &str, &str)>,
) -> (RouterProvider, Vec<Arc<MockProvider>>) {
let mocks: Vec<Arc<MockProvider>> = providers
.iter()
.map(|(_, response)| Arc::new(MockProvider::new(response)))
.collect();
let provider_list: Vec<(String, Box<dyn Provider>)> = providers
.iter()
.zip(mocks.iter())
.map(|((name, _), mock)| {
(
name.to_string(),
Box::new(Arc::clone(mock)) as Box<dyn Provider>,
)
})
.collect();
let route_list: Vec<(String, Route)> = routes
.iter()
.map(|(hint, provider_name, model)| {
(
hint.to_string(),
Route {
provider_name: provider_name.to_string(),
model: model.to_string(),
},
)
})
.collect();
let router = RouterProvider::new(provider_list, route_list, "default-model".to_string());
(router, mocks)
}
// Arc<MockProvider> should also be a Provider
#[async_trait]
impl Provider for Arc<MockProvider> {
async fn chat_with_system(
&self,
system_prompt: Option<&str>,
message: &str,
model: &str,
temperature: f64,
) -> anyhow::Result<String> {
self.as_ref()
.chat_with_system(system_prompt, message, model, temperature)
.await
}
}
#[tokio::test]
async fn routes_hint_to_correct_provider() {
let (router, mocks) = make_router(
vec![("fast", "fast-response"), ("smart", "smart-response")],
vec![
("fast", "fast", "llama-3-70b"),
("reasoning", "smart", "claude-opus"),
],
);
let result = router
.simple_chat("hello", "hint:reasoning", 0.5)
.await
.unwrap();
assert_eq!(result, "smart-response");
assert_eq!(mocks[1].call_count(), 1);
assert_eq!(mocks[1].last_model(), "claude-opus");
assert_eq!(mocks[0].call_count(), 0);
}
#[tokio::test]
async fn routes_fast_hint() {
let (router, mocks) = make_router(
vec![("fast", "fast-response"), ("smart", "smart-response")],
vec![("fast", "fast", "llama-3-70b")],
);
let result = router.simple_chat("hello", "hint:fast", 0.5).await.unwrap();
assert_eq!(result, "fast-response");
assert_eq!(mocks[0].call_count(), 1);
assert_eq!(mocks[0].last_model(), "llama-3-70b");
}
#[tokio::test]
async fn unknown_hint_falls_back_to_default() {
let (router, mocks) = make_router(
vec![("default", "default-response"), ("other", "other-response")],
vec![],
);
let result = router
.simple_chat("hello", "hint:nonexistent", 0.5)
.await
.unwrap();
assert_eq!(result, "default-response");
assert_eq!(mocks[0].call_count(), 1);
// Falls back to default with the hint as model name
assert_eq!(mocks[0].last_model(), "hint:nonexistent");
}
#[tokio::test]
async fn non_hint_model_uses_default_provider() {
let (router, mocks) = make_router(
vec![
("primary", "primary-response"),
("secondary", "secondary-response"),
],
vec![("code", "secondary", "codellama")],
);
let result = router
.simple_chat("hello", "anthropic/claude-sonnet-4-20250514", 0.5)
.await
.unwrap();
assert_eq!(result, "primary-response");
assert_eq!(mocks[0].call_count(), 1);
assert_eq!(mocks[0].last_model(), "anthropic/claude-sonnet-4-20250514");
}
#[test]
fn resolve_preserves_model_for_non_hints() {
let (router, _) = make_router(vec![("default", "ok")], vec![]);
let (idx, model) = router.resolve("gpt-4o");
assert_eq!(idx, 0);
assert_eq!(model, "gpt-4o");
}
#[test]
fn resolve_strips_hint_prefix() {
let (router, _) = make_router(
vec![("fast", "ok"), ("smart", "ok")],
vec![("reasoning", "smart", "claude-opus")],
);
let (idx, model) = router.resolve("hint:reasoning");
assert_eq!(idx, 1);
assert_eq!(model, "claude-opus");
}
#[test]
fn skips_routes_with_unknown_provider() {
let (router, _) = make_router(
vec![("default", "ok")],
vec![("broken", "nonexistent", "model")],
);
// Route should not exist
assert!(!router.routes.contains_key("broken"));
}
#[tokio::test]
async fn warmup_calls_all_providers() {
let (router, _) = make_router(vec![("a", "ok"), ("b", "ok")], vec![]);
// Warmup should not error
assert!(router.warmup().await.is_ok());
}
#[tokio::test]
async fn chat_with_system_passes_system_prompt() {
let mock = Arc::new(MockProvider::new("response"));
let router = RouterProvider::new(
vec![(
"default".into(),
Box::new(Arc::clone(&mock)) as Box<dyn Provider>,
)],
vec![],
"model".into(),
);
let result = router
.chat_with_system(Some("system"), "hello", "model", 0.5)
.await
.unwrap();
assert_eq!(result, "response");
assert_eq!(mock.call_count(), 1);
}
#[tokio::test]
async fn chat_with_tools_delegates_to_resolved_provider() {
let mock = Arc::new(MockProvider::new("tool-response"));
let router = RouterProvider::new(
vec![(
"default".into(),
Box::new(Arc::clone(&mock)) as Box<dyn Provider>,
)],
vec![],
"model".into(),
);
let messages = vec![ChatMessage {
id: None,
role: "user".to_string(),
content: "use tools".to_string(),
extra_metadata: None,
}];
let tools = vec![serde_json::json!({
"type": "function",
"function": {
"name": "shell",
"description": "Run shell command",
"parameters": {}
}
})];
// chat_with_tools should delegate through the router to the mock.
// MockProvider's default chat_with_tools calls chat_with_history -> chat_with_system.
let result = router
.chat_with_tools(&messages, &tools, "model", 0.7)
.await
.unwrap();
assert_eq!(result.text.as_deref(), Some("tool-response"));
assert_eq!(mock.call_count(), 1);
assert_eq!(mock.last_model(), "model");
}
#[tokio::test]
async fn chat_with_tools_routes_hint_correctly() {
let (router, mocks) = make_router(
vec![("fast", "fast-tool"), ("smart", "smart-tool")],
vec![("reasoning", "smart", "claude-opus")],
);
let messages = vec![ChatMessage {
id: None,
role: "user".to_string(),
content: "reason about this".to_string(),
extra_metadata: None,
}];
let tools = vec![serde_json::json!({"type": "function", "function": {"name": "test"}})];
let result = router
.chat_with_tools(&messages, &tools, "hint:reasoning", 0.5)
.await
.unwrap();
assert_eq!(result.text.as_deref(), Some("smart-tool"));
assert_eq!(mocks[1].call_count(), 1);
assert_eq!(mocks[1].last_model(), "claude-opus");
assert_eq!(mocks[0].call_count(), 0);
}
}
#[path = "router_test.rs"]
mod router_test;
+306
View File
@@ -0,0 +1,306 @@
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct MockProvider {
calls: Arc<AtomicUsize>,
response: &'static str,
last_model: parking_lot::Mutex<String>,
}
impl MockProvider {
fn new(response: &'static str) -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
response,
last_model: parking_lot::Mutex::new(String::new()),
}
}
fn call_count(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
fn last_model(&self) -> String {
self.last_model.lock().clone()
}
}
#[async_trait]
impl Provider for MockProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
self.calls.fetch_add(1, Ordering::SeqCst);
*self.last_model.lock() = model.to_string();
Ok(self.response.to_string())
}
}
fn make_router(
providers: Vec<(&'static str, &'static str)>,
routes: Vec<(&str, &str, &str)>,
) -> (RouterProvider, Vec<Arc<MockProvider>>) {
let mocks: Vec<Arc<MockProvider>> = providers
.iter()
.map(|(_, response)| Arc::new(MockProvider::new(response)))
.collect();
let provider_list: Vec<(String, Box<dyn Provider>)> = providers
.iter()
.zip(mocks.iter())
.map(|((name, _), mock)| {
(
name.to_string(),
Box::new(Arc::clone(mock)) as Box<dyn Provider>,
)
})
.collect();
let route_list: Vec<(String, Route)> = routes
.iter()
.map(|(hint, provider_name, model)| {
(
hint.to_string(),
Route {
provider_name: provider_name.to_string(),
model: model.to_string(),
},
)
})
.collect();
let router = RouterProvider::new(provider_list, route_list, "default-model".to_string());
(router, mocks)
}
#[async_trait]
impl Provider for Arc<MockProvider> {
async fn chat_with_system(
&self,
system_prompt: Option<&str>,
message: &str,
model: &str,
temperature: f64,
) -> anyhow::Result<String> {
self.as_ref()
.chat_with_system(system_prompt, message, model, temperature)
.await
}
}
#[tokio::test]
async fn routes_hint_to_correct_provider() {
let (router, mocks) = make_router(
vec![("fast", "fast-response"), ("smart", "smart-response")],
vec![
("fast", "fast", "llama-3-70b"),
("reasoning", "smart", "claude-opus"),
],
);
let result = router
.simple_chat("hello", "hint:reasoning", 0.5)
.await
.unwrap();
assert_eq!(result, "smart-response");
assert_eq!(mocks[1].call_count(), 1);
assert_eq!(mocks[1].last_model(), "claude-opus");
assert_eq!(mocks[0].call_count(), 0);
}
#[tokio::test]
async fn routes_fast_hint() {
let (router, mocks) = make_router(
vec![("fast", "fast-response"), ("smart", "smart-response")],
vec![("fast", "fast", "llama-3-70b")],
);
let result = router.simple_chat("hello", "hint:fast", 0.5).await.unwrap();
assert_eq!(result, "fast-response");
assert_eq!(mocks[0].call_count(), 1);
assert_eq!(mocks[0].last_model(), "llama-3-70b");
}
#[tokio::test]
async fn unknown_hint_falls_back_to_default() {
let (router, mocks) = make_router(
vec![("default", "default-response"), ("other", "other-response")],
vec![],
);
let result = router
.simple_chat("hello", "hint:nonexistent", 0.5)
.await
.unwrap();
assert_eq!(result, "default-response");
assert_eq!(mocks[0].call_count(), 1);
assert_eq!(mocks[0].last_model(), "hint:nonexistent");
}
#[tokio::test]
async fn non_hint_model_uses_default_provider() {
let (router, mocks) = make_router(
vec![
("primary", "primary-response"),
("secondary", "secondary-response"),
],
vec![("code", "secondary", "codellama")],
);
let result = router
.simple_chat("hello", "anthropic/claude-sonnet-4-20250514", 0.5)
.await
.unwrap();
assert_eq!(result, "primary-response");
assert_eq!(mocks[0].call_count(), 1);
assert_eq!(mocks[0].last_model(), "anthropic/claude-sonnet-4-20250514");
}
#[test]
fn resolve_preserves_model_for_non_hints() {
let (router, _) = make_router(vec![("default", "ok")], vec![]);
let (idx, model) = router.resolve("gpt-4o");
assert_eq!(idx, 0);
assert_eq!(model, "gpt-4o");
}
#[test]
fn resolve_strips_hint_prefix() {
let (router, _) = make_router(
vec![("fast", "ok"), ("smart", "ok")],
vec![("reasoning", "smart", "claude-opus")],
);
let (idx, model) = router.resolve("hint:reasoning");
assert_eq!(idx, 1);
assert_eq!(model, "claude-opus");
}
#[test]
fn resolve_translates_openhuman_tier_aliases_via_route_table() {
let (router, _) = make_router(
vec![("default", "ok"), ("smart", "ok")],
vec![
("reasoning", "smart", "gpt-5.5"),
("chat", "smart", "gpt-5.5-mini"),
("summarization", "smart", "gpt-4.1-nano"),
],
);
let (reasoning_idx, reasoning_model) = router.resolve("reasoning-v1");
assert_eq!(reasoning_idx, 1);
assert_eq!(reasoning_model, "gpt-5.5");
let (chat_idx, chat_model) = router.resolve("reasoning-quick-v1");
assert_eq!(chat_idx, 1);
assert_eq!(chat_model, "gpt-5.5-mini");
let (summary_idx, summary_model) = router.resolve("summarization-v1");
assert_eq!(summary_idx, 1);
assert_eq!(summary_model, "gpt-4.1-nano");
}
#[test]
fn skips_routes_with_unknown_provider() {
let (router, _) = make_router(
vec![("default", "ok")],
vec![("broken", "nonexistent", "model")],
);
assert!(!router.routes.contains_key("broken"));
}
#[tokio::test]
async fn warmup_calls_all_providers() {
let (router, _) = make_router(vec![("a", "ok"), ("b", "ok")], vec![]);
assert!(router.warmup().await.is_ok());
}
#[tokio::test]
async fn chat_with_system_passes_system_prompt() {
let mock = Arc::new(MockProvider::new("response"));
let router = RouterProvider::new(
vec![(
"default".into(),
Box::new(Arc::clone(&mock)) as Box<dyn Provider>,
)],
vec![],
"model".into(),
);
let result = router
.chat_with_system(Some("system"), "hello", "model", 0.5)
.await
.unwrap();
assert_eq!(result, "response");
assert_eq!(mock.call_count(), 1);
}
#[tokio::test]
async fn chat_with_tools_delegates_to_resolved_provider() {
let mock = Arc::new(MockProvider::new("tool-response"));
let router = RouterProvider::new(
vec![(
"default".into(),
Box::new(Arc::clone(&mock)) as Box<dyn Provider>,
)],
vec![],
"model".into(),
);
let messages = vec![ChatMessage {
id: None,
role: "user".to_string(),
content: "use tools".to_string(),
extra_metadata: None,
}];
let tools = vec![serde_json::json!({
"type": "function",
"function": {
"name": "shell",
"description": "Run shell command",
"parameters": {}
}
})];
let result = router
.chat_with_tools(&messages, &tools, "model", 0.7)
.await
.unwrap();
assert_eq!(result.text.as_deref(), Some("tool-response"));
assert_eq!(mock.call_count(), 1);
assert_eq!(mock.last_model(), "model");
}
#[tokio::test]
async fn chat_with_tools_routes_hint_correctly() {
let (router, mocks) = make_router(
vec![("fast", "fast-tool"), ("smart", "smart-tool")],
vec![("reasoning", "smart", "claude-opus")],
);
let messages = vec![ChatMessage {
id: None,
role: "user".to_string(),
content: "reason about this".to_string(),
extra_metadata: None,
}];
let tools = vec![serde_json::json!({"type": "function", "function": {"name": "test"}})];
let result = router
.chat_with_tools(&messages, &tools, "hint:reasoning", 0.5)
.await
.unwrap();
assert_eq!(result.text.as_deref(), Some("smart-tool"));
assert_eq!(mocks[1].call_count(), 1);
assert_eq!(mocks[1].last_model(), "claude-opus");
assert_eq!(mocks[0].call_count(), 0);
}
+515 -5
View File
@@ -8,7 +8,7 @@ use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use axum::http::{header::AUTHORIZATION, HeaderMap, StatusCode};
use axum::http::{header::AUTHORIZATION, HeaderMap, StatusCode, Uri};
use axum::routing::{get, post};
use axum::{Json, Router};
use futures_util::StreamExt;
@@ -61,6 +61,7 @@ impl Drop for EnvVarGuard {
/// inherited `VITE_BACKEND_URL`.
static JSON_RPC_E2E_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
static CHAT_COMPLETION_MODELS: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
static CHAT_COMPLETION_REQUESTS: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
fn json_rpc_e2e_env_lock() -> std::sync::MutexGuard<'static, ()> {
let mutex = JSON_RPC_E2E_ENV_LOCK.get_or_init(|| Mutex::new(()));
@@ -82,6 +83,17 @@ fn with_chat_completion_models<T>(f: impl FnOnce(&mut Vec<String>) -> T) -> T {
}
}
fn with_chat_completion_requests<T>(f: impl FnOnce(&mut Vec<Value>) -> T) -> T {
let mutex = CHAT_COMPLETION_REQUESTS.get_or_init(|| Mutex::new(Vec::new()));
match mutex.lock() {
Ok(mut guard) => f(&mut guard),
Err(poisoned) => {
let mut guard = poisoned.into_inner();
f(&mut guard)
}
}
}
fn mock_upstream_router() -> Router {
const GENERAL_TOKEN: &str = "e2e-test-jwt";
const BILLING_TOKEN: &str = "e2e-billing-jwt";
@@ -175,10 +187,33 @@ fn mock_upstream_router() -> Router {
})))
}
async fn chat_completions(Json(body): Json<Value>) -> Json<Value> {
async fn chat_completions(
uri: Uri,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Json<Value> {
if let Some(model) = body.get("model").and_then(Value::as_str) {
with_chat_completion_models(|models| models.push(model.to_string()));
}
let auth_header = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let x_api_key = headers
.get("x-api-key")
.and_then(|value| value.to_str().ok())
.map(str::to_string);
with_chat_completion_requests(|requests| {
requests.push(json!({
"path": uri.path(),
"model": body.get("model").and_then(Value::as_str),
"stream": body.get("stream").and_then(Value::as_bool),
"thread_id": body.get("thread_id").and_then(Value::as_str),
"authorization": auth_header,
"x_api_key": x_api_key,
"body": body.clone(),
}))
});
let is_triage_turn = body
.get("messages")
.and_then(Value::as_array)
@@ -203,7 +238,47 @@ fn mock_upstream_router() -> Router {
"choices": [{
"message": {
"role": "assistant",
"content": content
"content": format!("{content} via /openai/v1/chat/completions")
}
}]
}))
}
async fn generic_chat_completions(
uri: Uri,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Json<Value> {
if let Some(model) = body.get("model").and_then(Value::as_str) {
with_chat_completion_models(|models| models.push(model.to_string()));
}
let auth_header = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let x_api_key = headers
.get("x-api-key")
.and_then(|value| value.to_str().ok())
.map(str::to_string);
with_chat_completion_requests(|requests| {
requests.push(json!({
"path": uri.path(),
"model": body.get("model").and_then(Value::as_str),
"stream": body.get("stream").and_then(Value::as_bool),
"thread_id": body.get("thread_id").and_then(Value::as_str),
"authorization": auth_header,
"x_api_key": x_api_key,
"body": body.clone(),
}))
});
Json(json!({
"choices": [{
"message": {
"role": "assistant",
"content": format!(
"Hello from custom provider {}",
body.get("model").and_then(Value::as_str).unwrap_or("unknown-model")
)
}
}]
}))
@@ -418,6 +493,8 @@ fn mock_upstream_router() -> Router {
.route("/settings", get(current_user))
.route("/auth/me", get(current_user))
.route("/openai/v1/chat/completions", post(chat_completions))
.route("/v1/chat/completions", post(generic_chat_completions))
.route("/chat/completions", post(generic_chat_completions))
// billing
.route("/payments/stripe/currentPlan", get(stripe_current_plan))
.route("/payments/stripe/purchasePlan", post(stripe_purchase_plan))
@@ -583,6 +660,68 @@ async fn read_sse_event_by_type(events_url: &str, target_event: &str) -> Value {
panic!("SSE stream ended before receiving '{target_event}' event");
}
/// Read SSE events until a terminal web-chat event arrives.
///
/// This prevents tests from timing out blindly when the turn actually
/// completed with `chat_error` rather than `chat_done`.
async fn read_terminal_web_chat_event(events_url: &str) -> Value {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("client");
let resp = client
.get(events_url)
.header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
.send()
.await
.unwrap_or_else(|e| panic!("GET {events_url}: {e}"));
assert!(
resp.status().is_success(),
"SSE HTTP error {} for {}",
resp.status(),
events_url
);
let mut stream = resp.bytes_stream();
let mut buffer = String::new();
while let Some(item) = stream.next().await {
let chunk = item.unwrap_or_else(|e| panic!("sse stream read failed: {e}"));
let text = std::str::from_utf8(&chunk).unwrap_or("");
buffer.push_str(text);
while let Some(idx) = buffer.find("\n\n") {
let block = buffer[..idx].to_string();
buffer = buffer[idx + 2..].to_string();
let mut data_lines = Vec::new();
for line in block.lines() {
if let Some(data) = line.strip_prefix("data:") {
data_lines.push(data.trim_start());
}
}
if !data_lines.is_empty() {
let payload = data_lines.join("\n");
let value: Value = serde_json::from_str(&payload)
.unwrap_or_else(|e| panic!("invalid sse data json: {e}"));
match value.get("event").and_then(Value::as_str) {
Some("chat_done") | Some("chat_error") => return value,
_ => {}
}
}
}
}
panic!("SSE stream ended before receiving terminal web-chat event");
}
async fn wait_for_chat_completion_requests_len(expected_len: usize) -> Vec<Value> {
for _ in 0..100 {
let snapshot = with_chat_completion_requests(|requests| requests.clone());
if snapshot.len() >= expected_len {
return snapshot;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
with_chat_completion_requests(|requests| requests.clone())
}
fn assert_no_jsonrpc_error<'a>(v: &'a Value, context: &str) -> &'a Value {
if let Some(err) = v.get("error") {
panic!("{context}: JSON-RPC error: {err}");
@@ -775,8 +914,7 @@ async fn json_rpc_protocol_auth_and_agent_hello() {
let client_id = "e2e-client-1";
let thread_id = "thread-1";
let events_url = format!("{}/events?client_id={}", rpc_base, client_id);
let sse_task =
tokio::spawn(async move { read_sse_event_by_type(&events_url, "chat_done").await });
let sse_task = tokio::spawn(async move { read_terminal_web_chat_event(&events_url).await });
let web_chat = post_json_rpc(
&rpc_base,
@@ -1639,6 +1777,378 @@ async fn json_rpc_web_chat_routing_cases_use_expected_backend_models() {
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_web_chat_custom_reasoning_provider_uses_stored_key_and_rebuilds_on_route_change()
{
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config_with_local_ai_disabled(&openhuman_home, &mock_origin);
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_min_config_with_local_ai_disabled(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
let store = post_json_rpc(
&rpc_base,
6001,
"openhuman.auth_store_session",
json!({
"token": "e2e-test-jwt",
"user_id": "e2e-user"
}),
)
.await;
assert_no_jsonrpc_error(&store, "store_session");
let update = post_json_rpc(
&rpc_base,
6002,
"openhuman.update_model_settings",
json!({
"cloud_providers": [{
"id": "p_openai_1",
"slug": "openai",
"label": "OpenAI",
"endpoint": mock_origin,
"auth_style": "bearer"
}],
"reasoning_provider": "openai:gpt-4.1-mini"
}),
)
.await;
assert_no_jsonrpc_error(&update, "update_model_settings");
let store_provider = post_json_rpc(
&rpc_base,
6003,
"openhuman.auth_store_provider_credentials",
json!({
"provider": "provider:openai",
"profile": "default",
"token": "sk-custom-openai-key",
"setActive": true
}),
)
.await;
assert_no_jsonrpc_error(&store_provider, "auth_store_provider_credentials");
with_chat_completion_models(|models| models.clear());
with_chat_completion_requests(|requests| requests.clear());
let client_id = "custom-provider-client";
let thread_id = "custom-provider-thread";
let events_url = format!("{}/events?client_id={}", rpc_base, client_id);
let sse_task =
tokio::spawn(async move { read_sse_event_by_type(&events_url, "chat_done").await });
let accepted = post_json_rpc(
&rpc_base,
6004,
"openhuman.channel_web_chat",
json!({
"client_id": client_id,
"thread_id": thread_id,
"message": "Use the custom reasoning provider"
}),
)
.await;
let accepted_result = assert_no_jsonrpc_error(&accepted, "channel_web_chat first");
assert_eq!(
accepted_result
.get("result")
.and_then(|v| v.get("accepted")),
Some(&json!(true))
);
let sse_event = tokio::time::timeout(Duration::from_secs(12), sse_task)
.await
.expect("timed out waiting for first custom-provider chat_done")
.expect("first custom-provider sse join");
assert_eq!(
sse_event.get("event").and_then(Value::as_str),
Some("chat_done"),
"unexpected first custom-provider terminal event: {sse_event}; requests={:?}",
with_chat_completion_requests(|requests| requests.clone())
);
let requests = wait_for_chat_completion_requests_len(1).await;
assert_eq!(requests.len(), 1, "expected one outbound provider call");
assert_eq!(
requests[0].get("path").and_then(Value::as_str),
Some("/chat/completions")
);
assert_eq!(
requests[0].get("model").and_then(Value::as_str),
Some("gpt-4.1-mini")
);
assert_eq!(
requests[0].get("authorization").and_then(Value::as_str),
Some("Bearer sk-custom-openai-key")
);
let update_again = post_json_rpc(
&rpc_base,
6005,
"openhuman.update_model_settings",
json!({
"reasoning_provider": "openai:gpt-4.1-nano"
}),
)
.await;
assert_no_jsonrpc_error(&update_again, "update_model_settings second");
let events_url = format!("{}/events?client_id={}", rpc_base, client_id);
let sse_task = tokio::spawn(async move { read_terminal_web_chat_event(&events_url).await });
let accepted = post_json_rpc(
&rpc_base,
6006,
"openhuman.channel_web_chat",
json!({
"client_id": client_id,
"thread_id": thread_id,
"message": "Route the next turn with the updated model"
}),
)
.await;
let accepted_result = assert_no_jsonrpc_error(&accepted, "channel_web_chat second");
assert_eq!(
accepted_result
.get("result")
.and_then(|v| v.get("accepted")),
Some(&json!(true))
);
let sse_event = tokio::time::timeout(Duration::from_secs(12), sse_task)
.await
.expect("timed out waiting for second custom-provider chat_done")
.expect("second custom-provider sse join");
assert_eq!(
sse_event.get("event").and_then(Value::as_str),
Some("chat_done"),
"unexpected second custom-provider terminal event: {sse_event}; requests={:?}",
with_chat_completion_requests(|requests| requests.clone())
);
let requests = wait_for_chat_completion_requests_len(2).await;
assert_eq!(requests.len(), 2, "expected two outbound provider calls");
assert_eq!(
requests[1].get("model").and_then(Value::as_str),
Some("gpt-4.1-nano"),
"cached web-chat session should rebuild when reasoning_provider changes"
);
assert_eq!(
requests[1].get("authorization").and_then(Value::as_str),
Some("Bearer sk-custom-openai-key")
);
let events_url = format!("{}/events?client_id={}", rpc_base, client_id);
let sse_task = tokio::spawn(async move { read_terminal_web_chat_event(&events_url).await });
let accepted = post_json_rpc(
&rpc_base,
6007,
"openhuman.channel_web_chat",
json!({
"client_id": client_id,
"thread_id": thread_id,
"message": "This turn should stay on the backend agentic route",
"model_override": "hint:agentic"
}),
)
.await;
let accepted_result =
assert_no_jsonrpc_error(&accepted, "channel_web_chat unaffected agentic route");
assert_eq!(
accepted_result
.get("result")
.and_then(|v| v.get("accepted")),
Some(&json!(true))
);
let sse_event = tokio::time::timeout(Duration::from_secs(12), sse_task)
.await
.expect("timed out waiting for unaffected agentic chat_done")
.expect("unaffected agentic sse join");
assert_eq!(
sse_event.get("event").and_then(Value::as_str),
Some("chat_done"),
"unexpected unaffected-agentic terminal event: {sse_event}; requests={:?}",
with_chat_completion_requests(|requests| requests.clone())
);
let requests = wait_for_chat_completion_requests_len(3).await;
assert_eq!(requests.len(), 3, "expected three outbound provider calls");
assert_eq!(
requests[2].get("path").and_then(Value::as_str),
Some("/openai/v1/chat/completions"),
"custom reasoning provider must not hijack unrelated backend routes"
);
assert_eq!(
requests[2].get("model").and_then(Value::as_str),
Some("agentic-v1")
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_web_chat_custom_reasoning_provider_with_auth_none_omits_auth_header() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config_with_local_ai_disabled(&openhuman_home, &mock_origin);
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_min_config_with_local_ai_disabled(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
let store = post_json_rpc(
&rpc_base,
6101,
"openhuman.auth_store_session",
json!({
"token": "e2e-test-jwt",
"user_id": "e2e-user"
}),
)
.await;
assert_no_jsonrpc_error(&store, "store_session");
let update = post_json_rpc(
&rpc_base,
6102,
"openhuman.update_model_settings",
json!({
"cloud_providers": [{
"id": "p_proxy_1",
"slug": "proxy",
"label": "Proxy",
"endpoint": mock_origin,
"auth_style": "none"
}],
"reasoning_provider": "proxy:gpt-oss"
}),
)
.await;
assert_no_jsonrpc_error(&update, "update_model_settings");
let cfg = post_json_rpc(&rpc_base, 6102_1, "openhuman.config_get", json!({})).await;
let cfg_outer = assert_no_jsonrpc_error(&cfg, "config_get auth-none");
let cfg_payload = cfg_outer.get("result").unwrap_or(&cfg_outer);
let config = cfg_payload.get("config").unwrap_or(cfg_payload);
assert_eq!(
config.get("reasoning_provider").and_then(Value::as_str),
Some("proxy:gpt-oss")
);
assert_eq!(
config
.get("cloud_providers")
.and_then(Value::as_array)
.map(Vec::len),
Some(1)
);
let loaded_config = openhuman_core::openhuman::config::load_config_with_timeout()
.await
.expect("load_config after auth-none update");
let (provider, model) =
openhuman_core::openhuman::providers::create_chat_provider("reasoning", &loaded_config)
.expect("custom auth-none provider should build");
let direct = provider
.simple_chat("direct custom-provider smoke test", &model, 0.0)
.await
.expect("direct custom auth-none provider call should succeed");
assert!(
direct.contains("Hello from custom provider"),
"unexpected direct custom-provider response: {direct}"
);
with_chat_completion_models(|models| models.clear());
with_chat_completion_requests(|requests| requests.clear());
let client_id = "auth-none-client";
let thread_id = "auth-none-thread";
let events_url = format!("{}/events?client_id={}", rpc_base, client_id);
let sse_task = tokio::spawn(async move { read_terminal_web_chat_event(&events_url).await });
let accepted = post_json_rpc(
&rpc_base,
6103,
"openhuman.channel_web_chat",
json!({
"client_id": client_id,
"thread_id": thread_id,
"message": "Use the auth-none provider"
}),
)
.await;
let accepted_result = assert_no_jsonrpc_error(&accepted, "channel_web_chat auth-none");
assert_eq!(
accepted_result
.get("result")
.and_then(|v| v.get("accepted")),
Some(&json!(true))
);
let sse_event = tokio::time::timeout(Duration::from_secs(12), sse_task)
.await
.expect("timed out waiting for auth-none chat_done")
.expect("auth-none sse join");
assert_eq!(
sse_event.get("event").and_then(Value::as_str),
Some("chat_done"),
"unexpected auth-none terminal event: {sse_event}; requests={:?}",
with_chat_completion_requests(|requests| requests.clone())
);
let requests = wait_for_chat_completion_requests_len(1).await;
assert_eq!(requests.len(), 1, "expected one auth-none provider call");
assert_eq!(
requests[0].get("path").and_then(Value::as_str),
Some("/chat/completions")
);
assert_eq!(
requests[0].get("model").and_then(Value::as_str),
Some("gpt-oss")
);
assert!(
requests[0].get("authorization").is_none()
|| requests[0].get("authorization").is_some_and(Value::is_null),
"auth_style=none must not emit Authorization: {:?}",
requests[0].get("authorization")
);
assert!(
requests[0].get("x_api_key").is_none()
|| requests[0].get("x_api_key").is_some_and(Value::is_null),
"auth_style=none must not emit x-api-key: {:?}",
requests[0].get("x_api_key")
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_rejects_non_object_params_with_clear_error() {
let _env_lock = json_rpc_e2e_env_lock();