mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Harden Rust custom provider routing and mock coverage (#1958)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user