From 30a4a0706f3f8ffed1ede90bfa516ff97839b287 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 16 May 2026 13:18:10 -0700 Subject: [PATCH] Harden Rust custom provider routing and mock coverage (#1958) --- .../mock-api/routes/__tests__/llm.test.mjs | 172 ++++++ scripts/mock-api/routes/llm.mjs | 150 ++++- .../agent/harness/session/builder.rs | 8 +- src/openhuman/channels/providers/web.rs | 41 +- src/openhuman/channels/providers/web_tests.rs | 84 +-- src/openhuman/credentials/ops_tests.rs | 27 + src/openhuman/doctor/core_tests.rs | 28 + src/openhuman/providers/factory.rs | 271 +-------- src/openhuman/providers/factory_test.rs | 346 ++++++++++++ src/openhuman/providers/router.rs | 292 +--------- src/openhuman/providers/router_test.rs | 306 +++++++++++ tests/json_rpc_e2e.rs | 520 +++++++++++++++++- 12 files changed, 1622 insertions(+), 623 deletions(-) create mode 100644 scripts/mock-api/routes/__tests__/llm.test.mjs create mode 100644 src/openhuman/providers/factory_test.rs create mode 100644 src/openhuman/providers/router_test.rs diff --git a/scripts/mock-api/routes/__tests__/llm.test.mjs b/scripts/mock-api/routes/__tests__/llm.test.mjs new file mode 100644 index 000000000..0fe916746 --- /dev/null +++ b/scripts/mock-api/routes/__tests__/llm.test.mjs @@ -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); +}); diff --git a/scripts/mock-api/routes/llm.mjs b/scripts/mock-api/routes/llm.mjs index 8b5208753..489ce9026 100644 --- a/scripts/mock-api/routes/llm.mjs +++ b/scripts/mock-api/routes/llm.mjs @@ -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) { diff --git a/src/openhuman/agent/harness/session/builder.rs b/src/openhuman/agent/harness/session/builder.rs index d32dad14a..6a958cb5d 100644 --- a/src/openhuman/agent/harness/session/builder.rs +++ b/src/openhuman/agent/harness/session/builder.rs @@ -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, 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 diff --git a/src/openhuman/channels/providers/web.rs b/src/openhuman/channels/providers/web.rs index bc0d210ed..39fcc6d82 100644 --- a/src/openhuman/channels/providers/web.rs +++ b/src/openhuman/channels/providers/web.rs @@ -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, + /// 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) -> Option { .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, diff --git a/src/openhuman/channels/providers/web_tests.rs b/src/openhuman/channels/providers/web_tests.rs index b97a85565..b94a205d1 100644 --- a/src/openhuman/channels/providers/web_tests.rs +++ b/src/openhuman/channels/providers/web_tests.rs @@ -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, 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" ); } diff --git a/src/openhuman/credentials/ops_tests.rs b/src/openhuman/credentials/ops_tests.rs index 470d2c3e5..a557528de 100644 --- a/src/openhuman/credentials/ops_tests.rs +++ b/src/openhuman/credentials/ops_tests.rs @@ -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(); diff --git a/src/openhuman/doctor/core_tests.rs b/src/openhuman/doctor/core_tests.rs index 481a9e9a1..5bde24a2a 100644 --- a/src/openhuman/doctor/core_tests.rs +++ b/src/openhuman/doctor/core_tests.rs @@ -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}"); +} diff --git a/src/openhuman/providers/factory.rs b/src/openhuman/providers/factory.rs index e0af66a39..7f10cb094 100644 --- a/src/openhuman/providers/factory.rs +++ b/src/openhuman/providers/factory.rs @@ -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) -> 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: 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: 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: 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: 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; diff --git a/src/openhuman/providers/factory_test.rs b/src/openhuman/providers/factory_test.rs new file mode 100644 index 000000000..4f515ca2a --- /dev/null +++ b/src/openhuman/providers/factory_test.rs @@ -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) -> Config { + let mut c = Config::default(); + c.cloud_providers = providers; + c +} + +fn config_with_providers_in_tempdir(tmp: &TempDir, providers: Vec) -> 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: 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: 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: 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: 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: 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") +} diff --git a/src/openhuman/providers/router.rs b/src/openhuman/providers/router.rs index 9f0abb049..bfeff5934 100644 --- a/src/openhuman/providers/router.rs +++ b/src/openhuman/providers/router.rs @@ -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, - response: &'static str, - last_model: parking_lot::Mutex, - } - - 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 { - 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>) { - let mocks: Vec> = providers - .iter() - .map(|(_, response)| Arc::new(MockProvider::new(response))) - .collect(); - - let provider_list: Vec<(String, Box)> = providers - .iter() - .zip(mocks.iter()) - .map(|((name, _), mock)| { - ( - name.to_string(), - Box::new(Arc::clone(mock)) as Box, - ) - }) - .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 should also be a Provider - #[async_trait] - impl Provider for Arc { - async fn chat_with_system( - &self, - system_prompt: Option<&str>, - message: &str, - model: &str, - temperature: f64, - ) -> anyhow::Result { - 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, - )], - 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, - )], - 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; diff --git a/src/openhuman/providers/router_test.rs b/src/openhuman/providers/router_test.rs new file mode 100644 index 000000000..138b43883 --- /dev/null +++ b/src/openhuman/providers/router_test.rs @@ -0,0 +1,306 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +struct MockProvider { + calls: Arc, + response: &'static str, + last_model: parking_lot::Mutex, +} + +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 { + 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>) { + let mocks: Vec> = providers + .iter() + .map(|(_, response)| Arc::new(MockProvider::new(response))) + .collect(); + + let provider_list: Vec<(String, Box)> = providers + .iter() + .zip(mocks.iter()) + .map(|((name, _), mock)| { + ( + name.to_string(), + Box::new(Arc::clone(mock)) as Box, + ) + }) + .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 { + async fn chat_with_system( + &self, + system_prompt: Option<&str>, + message: &str, + model: &str, + temperature: f64, + ) -> anyhow::Result { + 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, + )], + 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, + )], + 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); +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 3824c7df7..a556f9e30 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -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> = OnceLock::new(); static CHAT_COMPLETION_MODELS: OnceLock>> = OnceLock::new(); +static CHAT_COMPLETION_REQUESTS: OnceLock>> = 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(f: impl FnOnce(&mut Vec) -> T) -> T { } } +fn with_chat_completion_requests(f: impl FnOnce(&mut Vec) -> 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) -> Json { + async fn chat_completions( + uri: Uri, + headers: HeaderMap, + Json(body): Json, + ) -> Json { 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, + ) -> Json { + 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 { + 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();