From c58fe10b523165a8add1a72c5e91cd09e114a55d Mon Sep 17 00:00:00 2001
From: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Date: Sun, 12 Jul 2026 21:25:07 -0700
Subject: [PATCH] =?UTF-8?q?feat(inference):=20Phase=203=20P3-B=20=E2=80=94?=
=?UTF-8?q?=20crate-native=20turn=20path=20+=20delete=20compatible*.rs=20[?=
=?UTF-8?q?WIP]=20(#4784)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Cargo.lock | 4 +-
app/src-tauri/Cargo.lock | 4 +-
src/bin/inference_probe.rs | 76 +-
src/openhuman/agent/harness/graph.rs | 2 +-
.../agent/harness/session/builder/factory.rs | 46 +-
.../agent/harness/session/builder/setters.rs | 17 +
.../agent/harness/session/turn/core.rs | 2 +-
.../agent/harness/session/turn/session_io.rs | 157 +-
.../harness/subagent_runner/extract_tool.rs | 4 +-
.../agent/harness/subagent_runner/mod.rs | 2 +-
.../harness/subagent_runner/ops/graph.rs | 4 +-
.../agent/harness/subagent_runner/ops/mod.rs | 2 +
.../harness/subagent_runner/ops/provider.rs | 80 +
.../harness/subagent_runner/ops/runner.rs | 174 +-
src/openhuman/agent/tools/delegate.rs | 41 +-
src/openhuman/agent/triage/evaluator.rs | 4 +-
src/openhuman/agent/triage/evaluator_tests.rs | 10 +-
src/openhuman/agent/triage/routing.rs | 69 +-
src/openhuman/channels/context.rs | 8 +-
src/openhuman/channels/routes.rs | 66 +-
src/openhuman/channels/routes_tests.rs | 3 +-
.../channels/runtime/dispatch/processor.rs | 73 +-
src/openhuman/channels/runtime/startup.rs | 72 +-
.../channels/runtime/test_support.rs | 3 +-
src/openhuman/channels/tests/context.rs | 21 +-
.../channels/tests/discord_integration.rs | 3 +-
src/openhuman/channels/tests/memory.rs | 6 +-
.../channels/tests/runtime_dispatch.rs | 22 +-
.../channels/tests/runtime_tool_calls.rs | 22 +-
.../channels/tests/telegram_integration.rs | 3 +-
src/openhuman/inference/http/server.rs | 124 +-
src/openhuman/inference/local/lm_studio.rs | 21 +-
src/openhuman/inference/local/mod.rs | 4 +-
src/openhuman/inference/local/ollama.rs | 4 -
src/openhuman/inference/local/ops.rs | 42 +-
src/openhuman/inference/ops.rs | 77 +-
src/openhuman/inference/provider/auth.rs | 10 +
.../inference/provider/compatible.rs | 422 --
.../inference/provider/compatible_dump.rs | 128 -
.../inference/provider/compatible_helpers.rs | 843 ---
.../inference/provider/compatible_parse.rs | 423 --
.../provider/compatible_provider_impl.rs | 1551 ------
.../inference/provider/compatible_repeat.rs | 76 -
.../inference/provider/compatible_request.rs | 374 --
.../inference/provider/compatible_stream.rs | 91 -
.../provider/compatible_stream_native.rs | 823 ---
.../inference/provider/compatible_tests.rs | 4851 -----------------
.../inference/provider/compatible_timeout.rs | 184 -
.../inference/provider/compatible_types.rs | 675 ---
.../inference/provider/crate_openai.rs | 2 +-
.../inference/provider/crate_provider.rs | 327 ++
src/openhuman/inference/provider/factory.rs | 512 +-
.../inference/provider/factory_tests.rs | 73 +-
.../inference/provider/legacy_provider.rs | 343 ++
src/openhuman/inference/provider/mod.rs | 17 +-
.../inference/provider/openhuman_backend.rs | 43 +-
.../provider/openhuman_backend_model.rs | 191 +-
.../provider/ops/provider_factory.rs | 26 +-
src/openhuman/inference/provider/ops_tests.rs | 2 +-
src/openhuman/learning/linkedin_enrichment.rs | 43 +-
src/openhuman/memory_tree/tree_runtime/ops.rs | 60 +-
src/openhuman/rhai_workflows/bridge.rs | 15 +-
src/openhuman/rhai_workflows/ops.rs | 3 +-
src/openhuman/routing/factory.rs | 22 +-
src/openhuman/threads/ops.rs | 38 +-
src/openhuman/tinyagents/convert.rs | 11 +-
src/openhuman/tinyagents/embeddings.rs | 10 +
src/openhuman/tinyagents/middleware.rs | 2 +
src/openhuman/tinyagents/mod.rs | 322 +-
src/openhuman/tinyagents/model.rs | 91 +-
.../tinyagents/payload_summarizer.rs | 12 +-
src/openhuman/tinyagents/tests.rs | 31 +
src/openhuman/tinyagents/tools.rs | 1 +
src/openhuman/tinyflows/caps.rs | 104 +-
tests/agent_harness_e2e.rs | 72 +-
tests/inference_provider_e2e.rs | 3 +-
.../inference_agent_raw_coverage_e2e.rs | 80 +-
...atible_admin_leftovers_raw_coverage_e2e.rs | 27 +-
...mpatible_admin_round25_raw_coverage_e2e.rs | 13 +-
...ence_compatible_matrix_raw_coverage_e2e.rs | 57 +-
.../inference_local_admin_raw_coverage_e2e.rs | 22 +-
...provider_admin_round22_raw_coverage_e2e.rs | 26 +-
.../inference_provider_raw_coverage_e2e.rs | 8 +-
.../inference_round26_raw_coverage_e2e.rs | 24 +-
.../memory_threads_raw_coverage_e2e.rs | 6 +-
.../owned_domain_raw_coverage_e2e.rs | 33 +-
vendor/tinyagents | 2 +-
87 files changed, 2777 insertions(+), 11620 deletions(-)
create mode 100644 src/openhuman/inference/provider/auth.rs
delete mode 100644 src/openhuman/inference/provider/compatible.rs
delete mode 100644 src/openhuman/inference/provider/compatible_dump.rs
delete mode 100644 src/openhuman/inference/provider/compatible_helpers.rs
delete mode 100644 src/openhuman/inference/provider/compatible_parse.rs
delete mode 100644 src/openhuman/inference/provider/compatible_provider_impl.rs
delete mode 100644 src/openhuman/inference/provider/compatible_repeat.rs
delete mode 100644 src/openhuman/inference/provider/compatible_request.rs
delete mode 100644 src/openhuman/inference/provider/compatible_stream.rs
delete mode 100644 src/openhuman/inference/provider/compatible_stream_native.rs
delete mode 100644 src/openhuman/inference/provider/compatible_tests.rs
delete mode 100644 src/openhuman/inference/provider/compatible_timeout.rs
delete mode 100644 src/openhuman/inference/provider/compatible_types.rs
create mode 100644 src/openhuman/inference/provider/crate_provider.rs
create mode 100644 src/openhuman/inference/provider/legacy_provider.rs
diff --git a/Cargo.lock b/Cargo.lock
index d85aef283..1de8bc5cd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6806,10 +6806,11 @@ dependencies = [
[[package]]
name = "tinyagents"
-version = "1.8.0"
+version = "1.9.0"
dependencies = [
"async-trait",
"bytes",
+ "chrono",
"futures",
"reqwest 0.12.28",
"rhai",
@@ -6819,6 +6820,7 @@ dependencies = [
"sha2 0.11.0",
"thiserror 2.0.18",
"tokio",
+ "tracing",
]
[[package]]
diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock
index 60561c02c..5126fc413 100644
--- a/app/src-tauri/Cargo.lock
+++ b/app/src-tauri/Cargo.lock
@@ -9160,10 +9160,11 @@ dependencies = [
[[package]]
name = "tinyagents"
-version = "1.8.0"
+version = "1.9.0"
dependencies = [
"async-trait",
"bytes",
+ "chrono",
"futures",
"reqwest 0.12.28",
"rhai",
@@ -9173,6 +9174,7 @@ dependencies = [
"sha2 0.11.0",
"thiserror 2.0.18",
"tokio",
+ "tracing",
]
[[package]]
diff --git a/src/bin/inference_probe.rs b/src/bin/inference_probe.rs
index 2f8bb9c42..7cb1bf947 100644
--- a/src/bin/inference_probe.rs
+++ b/src/bin/inference_probe.rs
@@ -32,10 +32,11 @@ use anyhow::{Context, Result};
use clap::Parser;
use openhuman_core::openhuman::agent::Agent;
use openhuman_core::openhuman::config::Config;
-use openhuman_core::openhuman::inference::provider::create_chat_provider;
-use openhuman_core::openhuman::inference::provider::traits::{ChatMessage, ChatRequest};
-use openhuman_core::openhuman::tools::traits::ToolSpec;
+use openhuman_core::openhuman::inference::provider::create_chat_model_with_model_id;
use serde_json::json;
+use tinyagents::harness::message::Message;
+use tinyagents::harness::model::ModelRequest;
+use tinyagents::harness::tool::ToolSchema;
#[derive(Parser, Debug)]
#[command(name = "inference-probe")]
@@ -117,14 +118,16 @@ async fn run_harness(config: &Config, prompt: &str) -> Result<()> {
}
async fn run_raw(config: &Config, role: &str, raw_mode: &str, prompt: &str) -> Result<()> {
- let (provider, model_name) =
- create_chat_provider(role, config).context("create_chat_provider failed")?;
+ let (chat_model, model_name) = create_chat_model_with_model_id(role, config, 0.4)
+ .context("create_chat_model_with_model_id failed")?;
eprintln!("[probe] mode = raw");
eprintln!("[probe] role = {role}");
eprintln!("[probe] resolved model = {model_name}");
eprintln!(
- "[probe] provider.supports_native_tools() = {}",
- provider.supports_native_tools()
+ "[probe] model.profile.tool_calling = {}",
+ chat_model
+ .profile()
+ .is_some_and(|profile| profile.tool_calling)
);
eprintln!("[probe] raw_mode = {raw_mode}");
@@ -149,14 +152,14 @@ Emit tool calls as `name[arg1|arg2]` blocks.
"You are a helpful assistant. Use the provided tools when the user asks something \
a tool can answer.";
- let (system, tools_for_request): (&str, Option>) = match raw_mode {
+ let (system, tools_for_request): (&str, Option>) = match raw_mode {
"native" => (
native_system,
Some(vec![
- ToolSpec {
- name: "get_weather".into(),
- description: "Get the current weather for a city.".into(),
- parameters: json!({
+ ToolSchema::new(
+ "get_weather",
+ "Get the current weather for a city.",
+ json!({
"type": "object",
"properties": {
"city": {"type": "string"},
@@ -164,67 +167,62 @@ Emit tool calls as `name[arg1|arg2]` blocks.
},
"required": ["city"]
}),
- },
- ToolSpec {
- name: "list_files".into(),
- description: "List files in a directory.".into(),
- parameters: json!({
+ ),
+ ToolSchema::new(
+ "list_files",
+ "List files in a directory.",
+ json!({
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
}),
- },
+ ),
]),
),
"pformat" => (pformat_system, None),
other => anyhow::bail!("unknown --raw-mode {other:?}"),
};
- let messages = vec![
- ChatMessage::system(system),
- ChatMessage::user(prompt.to_string()),
- ];
+ let mut request = ModelRequest::new(vec![Message::system(system), Message::user(prompt)])
+ .with_model(model_name.clone())
+ .with_temperature(0.4);
+ if let Some(tools) = tools_for_request {
+ request = request.with_tools(tools);
+ }
- let request = ChatRequest {
- messages: &messages,
- tools: tools_for_request.as_deref(),
- stream: None,
- max_tokens: None,
- };
-
- eprintln!("[probe] >>> raw provider.chat()...");
+ eprintln!("[probe] >>> raw ChatModel::invoke()...");
let started = std::time::Instant::now();
- let response = provider
- .chat(request, &model_name, 0.4)
+ let response = chat_model
+ .invoke(&(), request)
.await
- .context("provider.chat() failed")?;
+ .context("ChatModel::invoke() failed")?;
let elapsed = started.elapsed();
eprintln!("[probe] <<< {elapsed:?}");
println!();
println!("=== RAW RESPONSE TEXT ===");
- println!("{}", response.text_or_empty());
+ println!("{}", response.text());
println!("=== /RAW RESPONSE TEXT ===");
- let text = response.text_or_empty();
+ let text = response.text();
let saw_pformat = text.contains("") || text.contains("");
- let saw_native = !response.tool_calls.is_empty();
+ let saw_native = !response.tool_calls().is_empty();
eprintln!(
"[probe] response.tool_calls.len() = {}",
- response.tool_calls.len()
+ response.tool_calls().len()
);
eprintln!(
"[probe] response contains tag = {}",
saw_pformat
);
if saw_native {
- for tc in &response.tool_calls {
+ for tc in response.tool_calls() {
// Print tool name + arg byte-count only — raw arguments may
// contain user content / secrets and must not land in logs.
eprintln!(
"[probe] native tool_call name={} arg_bytes={}",
tc.name,
- tc.arguments.len()
+ tc.arguments.to_string().len()
);
}
}
diff --git a/src/openhuman/agent/harness/graph.rs b/src/openhuman/agent/harness/graph.rs
index 6bfa4331a..30f678e38 100644
--- a/src/openhuman/agent/harness/graph.rs
+++ b/src/openhuman/agent/harness/graph.rs
@@ -77,7 +77,7 @@ pub(crate) async fn run_channel_turn_via_graph(
// Phase 3 / Motion A): the harness graph names crate model types only, and
// reads native-tool / vision capability + telemetry id off the built bundle.
let context_window = source.effective_context_window(model).await;
- let turn_models = source.build(model, temperature, context_window);
+ let turn_models = source.build(model, temperature, context_window)?;
// Native-tool support drives the durable history-suffix dispatcher (native
// envelope vs prompt-guided text) at the end of this turn; capture it before
diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs
index 2e66062e1..176b12222 100644
--- a/src/openhuman/agent/harness/session/builder/factory.rs
+++ b/src/openhuman/agent/harness/session/builder/factory.rs
@@ -14,7 +14,7 @@ use crate::openhuman::agent::host_runtime;
use crate::openhuman::agent_memory::memory_loader::DefaultMemoryLoader;
use crate::openhuman::config::Config;
use crate::openhuman::context::prompt::SystemPromptBuilder;
-use crate::openhuman::inference::provider::{self, Provider};
+use crate::openhuman::inference::provider;
use crate::openhuman::memory::Memory;
use crate::openhuman::memory_store;
use crate::openhuman::memory_tools::ToolMemoryCaptureHook;
@@ -476,14 +476,21 @@ impl Agent {
// double-retry every transient error. Cross-route fallback is likewise
// the crate registry `FallbackPolicy`, so `config.reliability.*` no longer
// layers on the turn path (it still governs the non-seam provider paths).
- let (provider, mut model_name): (Box, String) =
- crate::openhuman::inference::provider::create_chat_provider(provider_role, config)?;
+ let (resolved_chat_model, mut model_name) =
+ crate::openhuman::inference::provider::create_chat_model_with_model_id(
+ provider_role,
+ config,
+ config.default_temperature,
+ )?;
+ let supports_native = resolved_chat_model
+ .profile()
+ .map_or(true, |profile| profile.tool_calling);
log::info!(
"[session-builder] agent_id={} provider_role={} resolved_model={} supports_native_tools={}",
agent_id,
provider_role,
model_name,
- provider.supports_native_tools()
+ supports_native
);
let target_agent_id = target_def
.map(|def| def.id.as_str())
@@ -530,7 +537,6 @@ impl Agent {
// the choice string now so the provider borrow doesn't conflict
// with the later `provider` move into the builder.
let dispatcher_choice = config.agent.tool_dispatcher.clone();
- let supports_native = provider.supports_native_tools();
// Build prompt builder — either the default "orchestrator /
// main agent" layout that bootstraps from workspace identity
@@ -693,23 +699,12 @@ impl Agent {
> = if config.learning.reflection_source
== crate::openhuman::config::ReflectionSource::Cloud
{
- // Reflection always calls with the `hint:reasoning` route +
- // 0.3 temperature (formerly `simple_chat(prompt,
- // "hint:reasoning", 0.3)`), so bake both into the wrapped
- // model. The routed provider still resolves the hint per call.
- let routed = provider::create_routed_provider(
- config.inference_url.as_deref(),
- config.api_url.as_deref(),
- config.api_key.as_deref(),
- &config.reliability,
- &config.model_routes,
- &model_name,
- )?;
- Some(provider::chat_model_from_provider(
- routed,
- "hint:reasoning".to_string(),
- 0.3,
- ))
+ let (model, resolved_model) =
+ provider::create_chat_model_with_model_id("reasoning", config, 0.3)?;
+ log::debug!(
+ "[learning] built crate-native reflection model resolved_model={resolved_model}"
+ );
+ Some(model)
} else {
None
};
@@ -1140,8 +1135,13 @@ impl Agent {
None
};
+ // Crate-native turn models (Phase 3 P3-B): the production main-turn agent
+ // builds crate `ChatModel`s from `(provider_role, config)` without retaining
+ // a host provider. The
+ // `agent_harness_e2e` mock now serves SSE for streaming, so the crate-native
+ // streaming path is exercised end-to-end.
let mut builder = Agent::builder()
- .provider(provider)
+ .crate_native_provider(provider_role, std::sync::Arc::new(config.clone()))
.tools(tools)
.visible_tool_names(visible)
.memory(memory)
diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs
index ca438f998..81bd46b31 100644
--- a/src/openhuman/agent/harness/session/builder/setters.rs
+++ b/src/openhuman/agent/harness/session/builder/setters.rs
@@ -78,6 +78,23 @@ impl AgentBuilder {
self
}
+ /// Sets the AI provider as a **crate-native** turn-model source (Phase 3 P3-B):
+ /// `build`/`build_summarizer` construct crate `ChatModel`s from `(role, config)`
+ /// via `create_turn_chat_model` (managed → `OpenHumanBackendModel`, local/cloud →
+ /// crate `OpenAiModel`) instead of wrapping `provider` in `ProviderModel`s.
+ /// Used by the production session factory; the plain
+ /// [`provider`](Self::provider) setter (Provider path) stays for tests that
+ /// inject a mock they observe.
+ pub fn crate_native_provider(
+ mut self,
+ role: impl Into,
+ config: Arc,
+ ) -> Self {
+ self.turn_model_source =
+ Some(crate::openhuman::tinyagents::TurnModelSource::new_crate_native(role, config));
+ self
+ }
+
/// Sets the available tools for the agent.
pub fn tools(mut self, tools: Vec>) -> Self {
self.tools = Some(tools);
diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs
index e3e19e2cb..f83ab8e68 100644
--- a/src/openhuman/agent/harness/session/turn/core.rs
+++ b/src/openhuman/agent/harness/session/turn/core.rs
@@ -879,7 +879,7 @@ impl Agent {
.await;
let turn_models =
self.turn_model_source
- .build(effective_model, temperature, context_window);
+ .build(effective_model, temperature, context_window)?;
// Honor custom/BYOK vision models too: they can set `model_vision` even
// when the provider capability bit is false, and must still rehydrate
diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs
index e09226b96..cce7b0150 100644
--- a/src/openhuman/agent/harness/session/turn/session_io.rs
+++ b/src/openhuman/agent/harness/session/turn/session_io.rs
@@ -5,9 +5,9 @@ use super::super::types::Agent;
use crate::openhuman::agent::harness;
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::context::ARCHIVIST_EXTRACTION_PROMPT;
-use crate::openhuman::inference::provider::{
- ChatMessage, ChatRequest, ProviderDelta, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS,
-};
+use crate::openhuman::inference::provider::{ChatMessage, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS};
+use futures::StreamExt;
+use tinyagents::harness::model::{ModelRequest, ModelStreamItem};
impl Agent {
// ─────────────────────────────────────────────────────────────────
@@ -92,89 +92,82 @@ impl Agent {
let mut messages = base_messages.to_vec();
messages.push(ChatMessage::user(instruction));
- // Mirror the main loop's streaming sink so the checkpoint renders
- // incrementally. Only text deltas are relevant here (tools are
- // disabled for this call).
- let (delta_tx_opt, delta_forwarder) = if self.on_progress.is_some() {
- let (tx, mut rx) = tokio::sync::mpsc::channel::(128);
- let progress_tx = self.on_progress.clone();
- let forwarder = tokio::spawn(async move {
- while let Some(event) = rx.recv().await {
- let Some(ref sink) = progress_tx else {
- continue;
- };
- if let ProviderDelta::TextDelta { delta } = event {
- if sink
- .send(AgentProgress::TextDelta {
- delta,
- iteration: iteration_for_stream,
- })
- .await
- .is_err()
- {
- break;
- }
- }
- }
- });
- (Some(tx), Some(forwarder))
- } else {
- (None, None)
+ let chat_model = match self
+ .turn_model_source
+ .build_summarizer(effective_model, self.temperature)
+ {
+ Ok(model) => model,
+ Err(error) => {
+ tracing::error!(
+ error = %error,
+ model = effective_model,
+ "[agent::session] failed to build wrap-up model"
+ );
+ return (String::new(), None);
+ }
+ };
+ let request = ModelRequest::new(
+ messages
+ .iter()
+ .map(crate::openhuman::tinyagents::chat_message_to_message)
+ .collect(),
+ )
+ .with_model(effective_model)
+ .with_temperature(self.temperature)
+ .with_max_tokens(AGENT_TURN_MAX_OUTPUT_TOKENS);
+ let mut stream = match chat_model.stream(&(), request).await {
+ Ok(stream) => stream,
+ Err(error) => {
+ tracing::warn!(
+ error = %error,
+ model = effective_model,
+ "[agent::session] wrap-up stream failed to start"
+ );
+ return (String::new(), None);
+ }
};
- // The cap-hit checkpoint summary streams text deltas to the session
- // progress sink (draft updates), which the crate `ChatModel::invoke` path
- // doesn't expose — so this one call resolves the raw provider off the
- // source inline (issue #4249, Phase 3 / Motion A). The `Agent` struct
- // itself holds no `Provider`; this stays a `.provider()` escape hatch until
- // the streaming checkpoint moves onto the crate stream API (Motion B).
- let result = self
- .turn_model_source
- .provider()
- .chat(
- ChatRequest {
- messages: &messages,
- tools: None,
- stream: delta_tx_opt.as_ref(),
- // Reservation-pricing pre-flight budget cap (TAURI-RUST-C62).
- max_tokens: Some(AGENT_TURN_MAX_OUTPUT_TOKENS),
- },
- effective_model,
- self.temperature,
- )
- .await;
- drop(delta_tx_opt);
- if let Some(handle) = delta_forwarder {
- let _ = handle.await;
- }
-
- match result {
- Ok(resp) => {
- let usage = resp.usage.clone();
- // Strip any stray tool-call XML a text-mode model may have
- // emitted; keep only the prose.
- let (text, calls) = self.tool_dispatcher.parse_response(&resp);
- let checkpoint = if !text.trim().is_empty() {
- text
- } else if calls.is_empty() {
- // No tool-call markup was present, so the raw text (if
- // any) is genuine prose — safe to use.
- resp.text.unwrap_or_default()
- } else {
- // `parse_response` stripped tool-call markup and left no
- // prose. Do NOT re-emit `resp.text` here: it would persist
- // the raw `…` markup verbatim as the checkpoint.
- // Return empty so the caller uses the deterministic
- // fallback instead (bug-report-2026-05-26 A1).
- String::new()
- };
- (checkpoint, usage)
- }
- Err(e) => {
- log::warn!("[agent_loop] checkpoint summary call failed: {e:#}");
- (String::new(), None)
+ let mut streamed_text = String::new();
+ let mut completed = None;
+ while let Some(item) = stream.next().await {
+ match item {
+ ModelStreamItem::MessageDelta(delta) if !delta.text.is_empty() => {
+ streamed_text.push_str(&delta.text);
+ if let Some(sink) = &self.on_progress {
+ let _ = sink
+ .send(AgentProgress::TextDelta {
+ delta: delta.text,
+ iteration: iteration_for_stream,
+ })
+ .await;
+ }
+ }
+ ModelStreamItem::Completed(response) => completed = Some(response),
+ ModelStreamItem::Failed(error) => {
+ tracing::warn!(%error, "[agent::session] wrap-up stream failed");
+ return (String::new(), None);
+ }
+ ModelStreamItem::ProviderFailed(error) => {
+ tracing::warn!(error = %error.message, "[agent::session] wrap-up provider failed");
+ return (String::new(), None);
+ }
+ _ => {}
}
}
+ let Some(response) = completed else {
+ tracing::warn!("[agent::session] wrap-up stream ended without completion");
+ return (String::new(), None);
+ };
+ let usage = crate::openhuman::tinyagents::model::usage_info_from_response(&response);
+ let text = response.text();
+ let checkpoint = if !text.trim().is_empty() {
+ text
+ } else if response.tool_calls().is_empty() {
+ streamed_text
+ } else {
+ String::new()
+ };
+ (checkpoint, usage)
}
/// Persist the exact provider messages as a session transcript.
diff --git a/src/openhuman/agent/harness/subagent_runner/extract_tool.rs b/src/openhuman/agent/harness/subagent_runner/extract_tool.rs
index 6abe993fb..7a8f9d992 100644
--- a/src/openhuman/agent/harness/subagent_runner/extract_tool.rs
+++ b/src/openhuman/agent/harness/subagent_runner/extract_tool.rs
@@ -285,7 +285,7 @@ impl Tool for ExtractFromResultTool {
// carries the messages.
let chat = self
.source
- .build_summarizer(&self.model, EXTRACT_TEMPERATURE);
+ .build_summarizer(&self.model, EXTRACT_TEMPERATURE)?;
// Model id for the per-chunk transcript metadata (the chat call itself
// bakes it into `chat`).
let model = self.model.clone();
@@ -415,7 +415,7 @@ impl ExtractFromResultTool {
let call_seq = self.next_call_seq();
let provider_result = self
.source
- .build_summarizer(&self.model, EXTRACT_TEMPERATURE)
+ .build_summarizer(&self.model, EXTRACT_TEMPERATURE)?
.invoke(
&(),
ModelRequest::new(vec![
diff --git a/src/openhuman/agent/harness/subagent_runner/mod.rs b/src/openhuman/agent/harness/subagent_runner/mod.rs
index 50be61cfb..d1b0c8003 100644
--- a/src/openhuman/agent/harness/subagent_runner/mod.rs
+++ b/src/openhuman/agent/harness/subagent_runner/mod.rs
@@ -64,7 +64,7 @@ pub(crate) use tool_prep::build_text_mode_tool_instructions;
// `ResultHandoffCache` with the `extract_from_result` tool.
pub(crate) use handoff::{apply_handoff, ResultHandoffCache};
pub(crate) use ops::run_agent_turn_request_via_default_graph;
-pub(crate) use ops::{append_subagent_role_contract, resolve_subagent_provider};
+pub(crate) use ops::{append_subagent_role_contract, resolve_subagent_source};
// `user_is_signed_in_to_composio` is the mode-aware "can the user call
// composio at all?" probe added in Wave 2 (#1710). Re-exported here so
diff --git a/src/openhuman/agent/harness/subagent_runner/ops/graph.rs b/src/openhuman/agent/harness/subagent_runner/ops/graph.rs
index a357d373d..56c2daecf 100644
--- a/src/openhuman/agent/harness/subagent_runner/ops/graph.rs
+++ b/src/openhuman/agent/harness/subagent_runner/ops/graph.rs
@@ -215,7 +215,7 @@ pub(super) async fn run_subagent_via_graph(
// turn's own model set is consumed by the run). Built off the same source, so
// the checkpoint invokes a crate `ChatModel` without naming `Provider`
// (issue #4249, Phase 3 / Motion A).
- let summary_model = source.build_summarizer(model, temperature);
+ let summary_model = source.build_summarizer(model, temperature)?;
// Resolve the sub-agent model's effective context window so the harness runs
// the context-window summarization step (issue #4249) on sub-agent turns too.
@@ -227,7 +227,7 @@ pub(super) async fn run_subagent_via_graph(
// Build the child turn's crate `ChatModel` set from the source; capability
// reads (vision/native-tools) + telemetry id now come off the built bundle,
// so the sub-agent path names crate model types only.
- let turn_models = source.build(model, temperature, context_window);
+ let turn_models = source.build(model, temperature, context_window)?;
// Vision forwarding (parity with the legacy `run_inner_loop`): rehydrate
// `[IMAGE:…]` placeholders in the sub-agent's history when either the model
diff --git a/src/openhuman/agent/harness/subagent_runner/ops/mod.rs b/src/openhuman/agent/harness/subagent_runner/ops/mod.rs
index 45d37bb92..c8ba6c102 100644
--- a/src/openhuman/agent/harness/subagent_runner/ops/mod.rs
+++ b/src/openhuman/agent/harness/subagent_runner/ops/mod.rs
@@ -39,7 +39,9 @@ pub(crate) use provider::user_is_signed_in_to_composio;
// `super::resolve_subagent_provider`. Keep it accessible at the ops
// module boundary.
pub(crate) use prompt::append_subagent_role_contract;
+#[cfg(test)]
pub(crate) use provider::resolve_subagent_provider;
+pub(crate) use provider::resolve_subagent_source;
// Re-exports for test companion modules that use `use super::*`.
// These provide the same flat namespace the original ops.rs had.
diff --git a/src/openhuman/agent/harness/subagent_runner/ops/provider.rs b/src/openhuman/agent/harness/subagent_runner/ops/provider.rs
index 72e28ecab..acdf56f1e 100644
--- a/src/openhuman/agent/harness/subagent_runner/ops/provider.rs
+++ b/src/openhuman/agent/harness/subagent_runner/ops/provider.rs
@@ -7,6 +7,86 @@ use std::sync::Arc;
use crate::openhuman::inference::provider::Provider;
+pub(crate) fn resolve_subagent_source(
+ spec: &crate::openhuman::agent::harness::definition::ModelSpec,
+ agent_id: &str,
+ config: Option<&crate::openhuman::config::Config>,
+ parent_source: crate::openhuman::tinyagents::TurnModelSource,
+ parent_model: String,
+ is_team_lead: bool,
+ model_override: Option<&str>,
+ temperature: f64,
+) -> (crate::openhuman::tinyagents::TurnModelSource, String) {
+ use crate::openhuman::agent::harness::definition::ModelSpec;
+ if let Some(model) = model_override
+ .map(str::trim)
+ .filter(|model| !model.is_empty())
+ {
+ tracing::debug!(
+ agent_id,
+ model,
+ "[subagent_runner] using inline model override"
+ );
+ return (parent_source, model.to_string());
+ }
+ if let Some(model) = config.and_then(|cfg| cfg.configured_agent_model(agent_id, is_team_lead)) {
+ tracing::debug!(
+ agent_id,
+ model,
+ "[subagent_runner] using config-level model pin"
+ );
+ return (parent_source, model.to_string());
+ }
+ match spec {
+ ModelSpec::Hint(workload) => match config {
+ Some(config) => {
+ match crate::openhuman::inference::provider::create_chat_model_with_model_id(
+ workload,
+ config,
+ temperature,
+ ) {
+ Ok((_model, model_id)) => {
+ tracing::info!(
+ agent_id,
+ role = workload,
+ model = %model_id,
+ "[subagent_runner] resolved crate-native workload source"
+ );
+ (
+ crate::openhuman::tinyagents::TurnModelSource::new_crate_native(
+ workload.clone(),
+ Arc::new(config.clone()),
+ ),
+ model_id,
+ )
+ }
+ Err(error) => {
+ tracing::warn!(
+ agent_id,
+ role = workload,
+ %error,
+ parent_model,
+ "[subagent_runner] workload model build failed; inheriting parent source"
+ );
+ (parent_source, parent_model)
+ }
+ }
+ }
+ None => {
+ tracing::warn!(
+ agent_id,
+ role = workload,
+ parent_model,
+ "[subagent_runner] config unavailable; inheriting parent source"
+ );
+ (parent_source, parent_model)
+ }
+ },
+ ModelSpec::Inherit => (parent_source, parent_model),
+ ModelSpec::Exact(model) => (parent_source, model.clone()),
+ }
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Provider / model resolution
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs
index a0cf82672..61ebd8b23 100644
--- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs
+++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs
@@ -42,7 +42,7 @@ use tinyagents::harness::workspace::WorkspaceDescriptor;
use super::prompt::{append_subagent_role_contract, dedup_tool_specs_by_name};
use super::provider::{
- resolve_subagent_provider, user_is_signed_in_to_composio, LazyToolkitResolver,
+ resolve_subagent_source, user_is_signed_in_to_composio, LazyToolkitResolver,
};
/// Runtime spawn-hierarchy gate decision for one delegation hop.
@@ -328,19 +328,20 @@ async fn run_typed_mode(
}
}
- // Resolve provider + model. See `resolve_subagent_provider` for the
+ // Resolve model source + model. See `resolve_subagent_source` for the
// semantics of each ModelSpec variant. `Config::load_or_init()` is
// async so the load is hoisted out of the helper — the helper itself
// is sync and unit-tested.
let config_loaded = crate::openhuman::config::Config::load_or_init().await;
- let (subagent_provider, model) = resolve_subagent_provider(
+ let (mut subagent_source, model) = resolve_subagent_source(
&definition.model,
&definition.id,
config_loaded.as_ref().ok(),
- parent.turn_model_source.provider(),
+ parent.turn_model_source.clone(),
parent.model_name.clone(),
!definition.subagents.is_empty(),
options.model_override.as_deref(),
+ definition.temperature,
);
let temperature = definition.temperature;
let max_output_tokens = definition
@@ -626,36 +627,44 @@ async fn run_typed_mode(
// id rather than dead-ending extraction.
let summarization_tier =
crate::openhuman::inference::provider::factory::summarization_tier_model().to_string();
- let (extract_provider, extract_model): (
- Arc,
- String,
- ) = match crate::openhuman::config::Config::load_or_init().await {
+ // The extract summarizer stays on the resolved `Provider` (the extract's own
+ // summarization resolution, incl. test-injected mocks + the managed-vs-local
+ // decision). It is NOT flipped to a role-resolved crate-native source: that
+ // would re-resolve "summarization" from config and bypass the resolved
+ // provider — production stays managed either way, but a test mock injected on
+ // the parent/extract provider would no longer be observed (issue #4249 P3-B:
+ // the extract flip is deferred; the turn-path flip goes through the primary
+ // producers instead).
+ let (extract_source, extract_model) = match crate::openhuman::config::Config::load_or_init()
+ .await
+ {
Ok(cfg) => {
let route =
crate::openhuman::inference::provider::provider_for_role("summarization", &cfg);
let r = route.trim();
let route_is_managed = r.is_empty() || r == "cloud" || r == "openhuman";
if route_is_managed && !parent.turn_model_source.is_local_provider() {
- (
- parent.turn_model_source.provider(),
- summarization_tier.clone(),
- )
+ (parent.turn_model_source.clone(), summarization_tier.clone())
} else {
- match crate::openhuman::inference::provider::create_chat_provider(
+ match crate::openhuman::inference::provider::create_chat_model_with_model_id(
"summarization",
&cfg,
+ parent.temperature,
) {
- Ok((p, m)) => (Arc::from(p), m),
+ Ok((_model, resolved_model)) => (
+ crate::openhuman::tinyagents::TurnModelSource::new_crate_native(
+ "summarization",
+ Arc::new(cfg.clone()),
+ ),
+ resolved_model,
+ ),
Err(e) => {
tracing::warn!(
agent_id = %definition.id,
error = %e,
"[subagent_runner:typed] extract summarization provider build failed; falling back to parent provider"
);
- (
- parent.turn_model_source.provider(),
- summarization_tier.clone(),
- )
+ (parent.turn_model_source.clone(), summarization_tier.clone())
}
}
}
@@ -666,15 +675,12 @@ async fn run_typed_mode(
error = %e,
"[subagent_runner:typed] config load failed for extract provider; falling back to parent provider + summarization-v1"
);
- (
- parent.turn_model_source.provider(),
- summarization_tier.clone(),
- )
+ (parent.turn_model_source.clone(), summarization_tier.clone())
}
};
dynamic_tools.push(Box::new(ExtractFromResultTool::new(
cache.clone(),
- crate::openhuman::tinyagents::TurnModelSource::new(extract_provider),
+ extract_source,
extract_model,
parent.workspace_dir.clone(),
parent_chain,
@@ -867,22 +873,19 @@ async fn run_typed_mode(
// dropped it, so integrations turns advertised native schemas the backend then
// rejected). Wrapping the provider clears `native_tool_calling`, which makes
// the model adapter skip native advertisement and fall back to XML parsing.
- let subagent_provider: Arc =
- if is_integrations_agent_with_toolkit {
- if let Some(sys) = history.iter_mut().find(|m| m.role == "system") {
- sys.content.push_str("\n\n");
- sys.content.push_str(&build_text_mode_tool_instructions());
- }
- tracing::info!(
- agent_id = %definition.id,
- task_id = %task_id,
- tool_count = filtered_specs.len(),
- "[subagent_runner:text-mode] omitting native tool schemas; injected XML tool protocol into system prompt"
- );
- Arc::new(TextModeProvider::new(subagent_provider))
- } else {
- subagent_provider
- };
+ if is_integrations_agent_with_toolkit {
+ if let Some(sys) = history.iter_mut().find(|m| m.role == "system") {
+ sys.content.push_str("\n\n");
+ sys.content.push_str(&build_text_mode_tool_instructions());
+ }
+ tracing::info!(
+ agent_id = %definition.id,
+ task_id = %task_id,
+ tool_count = filtered_specs.len(),
+ "[subagent_runner:text-mode] omitting native tool schemas; injected XML tool protocol into system prompt"
+ );
+ subagent_source = subagent_source.with_text_mode();
+ }
// ── Run the inner tool-call loop ───────────────────────────────────
// Resolve the sub-agent model's user-configured vision flag; defaults to
@@ -972,7 +975,7 @@ async fn run_typed_mode(
match &definition.graph {
AgentGraph::Default => {
super::graph::run_subagent_via_graph(
- crate::openhuman::tinyagents::TurnModelSource::new(subagent_provider.clone()),
+ subagent_source.clone(),
&model,
temperature,
&mut history,
@@ -1008,9 +1011,7 @@ async fn run_typed_mode(
}
AgentGraph::Custom(run) => {
let req = AgentTurnRequest {
- turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(
- subagent_provider.clone(),
- ),
+ turn_model_source: subagent_source.clone(),
model: model.clone(),
temperature,
history: std::mem::take(&mut history),
@@ -1184,90 +1185,3 @@ async fn run_typed_mode(
usage,
})
}
-
-/// A [`Provider`] decorator that reports **no native tool calling**, forcing the
-/// tinyagents model adapter to omit native tool schemas and fall back to
-/// prompt-guided (`` XML) parsing. Everything else delegates to the
-/// inner provider. Used to run `integrations_agent` in text mode (its large
-/// toolkit would otherwise blow the provider's native tool-grammar ceiling).
-struct TextModeProvider {
- inner: Arc,
-}
-
-impl TextModeProvider {
- fn new(inner: Arc) -> Self {
- Self { inner }
- }
-}
-
-#[async_trait::async_trait]
-impl crate::openhuman::inference::provider::Provider for TextModeProvider {
- fn capabilities(&self) -> crate::openhuman::inference::provider::traits::ProviderCapabilities {
- let mut caps = self.inner.capabilities();
- // The whole point: hide native tool calling so the adapter advertises none.
- caps.native_tool_calling = false;
- caps
- }
-
- async fn chat_with_system(
- &self,
- system_prompt: Option<&str>,
- message: &str,
- model: &str,
- temperature: f64,
- ) -> anyhow::Result {
- self.inner
- .chat_with_system(system_prompt, message, model, temperature)
- .await
- }
-
- async fn chat(
- &self,
- request: crate::openhuman::inference::provider::ChatRequest<'_>,
- model: &str,
- temperature: f64,
- ) -> anyhow::Result {
- self.inner.chat(request, model, temperature).await
- }
-
- fn supports_vision(&self) -> bool {
- self.inner.supports_vision()
- }
-
- fn supports_streaming(&self) -> bool {
- self.inner.supports_streaming()
- }
-
- async fn effective_context_window(&self, model: &str) -> Option {
- self.inner.effective_context_window(model).await
- }
-
- // #4469 item 2: forward the local-provider identity + cache passthroughs. This
- // decorator only masks native tool calling (above); everything about *where*
- // and *how* the inner provider runs must pass through unchanged. Without these
- // the default trait impls report the inner as a remote, non-caching provider,
- // so a local runtime behind text mode loses its `n_keep >= n_ctx` un-evictable
- // prefix guard (`is_local_provider*` / `loaded_context_window`, #3550) and its
- // KV-cache pricing/strategy (`prompt_cache_capabilities`, #3939).
- fn is_local_provider(&self) -> bool {
- self.inner.is_local_provider()
- }
-
- fn is_local_provider_for_model(&self, model: &str) -> bool {
- self.inner.is_local_provider_for_model(model)
- }
-
- async fn loaded_context_window(&self, model: &str) -> Option {
- self.inner.loaded_context_window(model).await
- }
-
- fn prompt_cache_capabilities(
- &self,
- ) -> crate::openhuman::inference::provider::traits::PromptCacheCapabilities {
- self.inner.prompt_cache_capabilities()
- }
-
- async fn warmup(&self) -> anyhow::Result<()> {
- self.inner.warmup().await
- }
-}
diff --git a/src/openhuman/agent/tools/delegate.rs b/src/openhuman/agent/tools/delegate.rs
index dd43b6479..a56e0dc15 100644
--- a/src/openhuman/agent/tools/delegate.rs
+++ b/src/openhuman/agent/tools/delegate.rs
@@ -1,6 +1,6 @@
use crate::openhuman::config::DelegateAgentConfig;
use crate::openhuman::inference::provider::{
- create_backend_inference_provider, Provider, ProviderRuntimeOptions, INFERENCE_BACKEND_ID,
+ OpenHumanBackendModel, OpenHumanBackendProvider, ProviderRuntimeOptions, INFERENCE_BACKEND_ID,
};
use crate::openhuman::security::policy::ToolOperation;
use crate::openhuman::security::SecurityPolicy;
@@ -11,6 +11,8 @@ use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
+use tinyagents::harness::message::Message;
+use tinyagents::harness::model::{ChatModel, ModelRequest};
/// Tool that delegates a subtask to a named agent with a different
/// provider/model configuration. Enables multi-agent workflows where
@@ -177,19 +179,10 @@ impl Tool for DelegateTool {
return Ok(ToolResult::error(error));
}
- let provider: Box = match create_backend_inference_provider(
- None,
- None,
- None,
- &self.provider_runtime_options,
- ) {
- Ok(p) => p,
- Err(e) => {
- return Ok(ToolResult::error(format!(
- "Failed to create inference client for delegate agent '{agent_name}': {e}"
- )));
- }
- };
+ let model = OpenHumanBackendModel::new(
+ OpenHumanBackendProvider::new(None, &self.provider_runtime_options),
+ agent_config.model.clone(),
+ );
// Build the message
let full_prompt = if context.is_empty() {
@@ -204,11 +197,19 @@ impl Tool for DelegateTool {
// Wrap the provider call in a timeout to prevent indefinite blocking
let result = tokio::time::timeout(
Duration::from_secs(delegate_timeout_secs),
- provider.chat_with_system(
- agent_config.system_prompt.as_deref(),
- &full_prompt,
- &agent_config.model,
- temperature,
+ model.invoke(
+ &(),
+ ModelRequest::new(
+ agent_config
+ .system_prompt
+ .as_deref()
+ .map(Message::system)
+ .into_iter()
+ .chain(std::iter::once(Message::user(full_prompt)))
+ .collect(),
+ )
+ .with_model(agent_config.model.clone())
+ .with_temperature(temperature),
),
)
.await;
@@ -224,7 +225,7 @@ impl Tool for DelegateTool {
match result {
Ok(response) => {
- let mut rendered = response;
+ let mut rendered = response.text();
if rendered.trim().is_empty() {
rendered = "[Empty response]".to_string();
}
diff --git a/src/openhuman/agent/triage/evaluator.rs b/src/openhuman/agent/triage/evaluator.rs
index 096d22a32..351e95e89 100644
--- a/src/openhuman/agent/triage/evaluator.rs
+++ b/src/openhuman/agent/triage/evaluator.rs
@@ -464,9 +464,7 @@ async fn try_arm(
];
let request = AgentTurnRequest {
- turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(Arc::clone(
- &resolved.provider,
- )),
+ turn_model_source: resolved.turn_model_source.clone(),
history,
tools_registry: Arc::new(Vec::new()),
provider_name: resolved.provider_name.clone(),
diff --git a/src/openhuman/agent/triage/evaluator_tests.rs b/src/openhuman/agent/triage/evaluator_tests.rs
index a42289424..bfd9ea58e 100644
--- a/src/openhuman/agent/triage/evaluator_tests.rs
+++ b/src/openhuman/agent/triage/evaluator_tests.rs
@@ -252,7 +252,10 @@ impl Provider for NoopProvider {
fn cloud_arm() -> ResolvedProvider {
ResolvedProvider {
- provider: StdArc::new(NoopProvider) as StdArc,
+ turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(StdArc::new(
+ NoopProvider,
+ )
+ as StdArc),
provider_name: "stub-cloud".to_string(),
model: "stub-cloud-model".to_string(),
used_local: false,
@@ -261,7 +264,10 @@ fn cloud_arm() -> ResolvedProvider {
fn local_arm() -> ResolvedProvider {
ResolvedProvider {
- provider: StdArc::new(NoopProvider) as StdArc,
+ turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(StdArc::new(
+ NoopProvider,
+ )
+ as StdArc),
provider_name: "stub-local".to_string(),
model: "stub-local-model".to_string(),
used_local: true,
diff --git a/src/openhuman/agent/triage/routing.rs b/src/openhuman/agent/triage/routing.rs
index 9ba17a03c..db6ed9466 100644
--- a/src/openhuman/agent/triage/routing.rs
+++ b/src/openhuman/agent/triage/routing.rs
@@ -14,13 +14,13 @@ use std::sync::Arc;
use anyhow::Context;
use crate::openhuman::config::Config;
-use crate::openhuman::inference::provider::{self, Provider, INFERENCE_BACKEND_ID};
+use crate::openhuman::inference::provider::{self, INFERENCE_BACKEND_ID};
/// The concrete provider + metadata that [`crate::openhuman::agent::triage::evaluator::run_triage`]
/// should use for this particular triage turn.
pub struct ResolvedProvider {
- /// Ready-to-use provider, already constructed.
- pub provider: Arc,
+ /// Model source for this arm. Production sources are crate-native.
+ pub turn_model_source: crate::openhuman::tinyagents::TurnModelSource,
/// Provider name token — always `"openhuman"` (remote backend).
/// Kept for telemetry / observability compat with the previous two-path design.
pub provider_name: String,
@@ -59,15 +59,10 @@ pub async fn resolve_provider_with_config(config: &Config) -> anyhow::Result Option {
- use crate::openhuman::inference::provider::compatible::{AuthStyle, OpenAiCompatibleProvider};
-
let local_cfg = &config.local_ai;
if !local_cfg.runtime_enabled {
tracing::debug!("[triage::routing] local arm disabled (runtime_enabled=false)");
@@ -89,44 +84,28 @@ pub fn build_local_provider_with_config(config: &Config) -> Option = Arc::new(OpenAiCompatibleProvider::new(
- label,
- &base,
- local_api_key,
- auth_style,
- ));
tracing::debug!(
provider = %label,
model = %local_cfg.chat_model_id,
"[triage::routing] resolved local fallback provider"
);
Some(ResolvedProvider {
- provider,
+ turn_model_source:
+ crate::openhuman::tinyagents::TurnModelSource::new_crate_native_from_string(
+ "subconscious",
+ provider_string,
+ Arc::new(config.clone()),
+ ),
provider_name: label.to_string(),
model: local_cfg.chat_model_id.clone(),
used_local: true,
@@ -169,7 +148,7 @@ fn is_local_cli_route(provider_string: &str) -> bool {
fn build_remote_provider(config: &Config) -> anyhow::Result {
use crate::openhuman::inference::local::profile::is_local_provider_string;
use crate::openhuman::inference::provider::factory::{
- create_chat_provider_from_string, PROVIDER_OPENHUMAN,
+ create_chat_model_from_string_with_model_id, PROVIDER_OPENHUMAN,
};
let resolved = provider::provider_for_role("subconscious", config);
@@ -198,9 +177,12 @@ fn build_remote_provider(config: &Config) -> anyhow::Result {
// id via `make_openhuman_backend` → `managed_tier_for_role`, BYOK cloud routes
// via the slug's configured model.
let build = |provider_string: &str| -> anyhow::Result {
- let (provider_box, model) =
- create_chat_provider_from_string("subconscious", provider_string, config)?;
- let provider: Arc = Arc::from(provider_box);
+ let (_chat_model, model) = create_chat_model_from_string_with_model_id(
+ "subconscious",
+ provider_string,
+ config,
+ config.default_temperature,
+ )?;
let provider_name = if provider_string == PROVIDER_OPENHUMAN {
INFERENCE_BACKEND_ID.to_string()
} else {
@@ -211,7 +193,12 @@ fn build_remote_provider(config: &Config) -> anyhow::Result {
.to_string()
};
Ok(ResolvedProvider {
- provider,
+ turn_model_source:
+ crate::openhuman::tinyagents::TurnModelSource::new_crate_native_from_string(
+ "subconscious",
+ provider_string,
+ Arc::new(config.clone()),
+ ),
provider_name,
model,
used_local: false,
diff --git a/src/openhuman/channels/context.rs b/src/openhuman/channels/context.rs
index 3fbc2d41d..fef693361 100644
--- a/src/openhuman/channels/context.rs
+++ b/src/openhuman/channels/context.rs
@@ -29,7 +29,7 @@ pub(crate) type RouteSelectionMap = Arc>>,
- pub(crate) provider: Arc,
+ pub(crate) provider: Option>,
pub(crate) default_provider: Arc,
pub(crate) memory: Arc,
pub(crate) tools_registry: Arc>>,
@@ -51,6 +51,9 @@ pub(crate) struct ChannelRuntimeContext {
pub(crate) message_timeout_secs: u64,
pub(crate) multimodal: crate::openhuman::config::MultimodalConfig,
pub(crate) multimodal_files: crate::openhuman::config::MultimodalFileConfig,
+ /// Full config for building crate-native turn models (Phase 3 P3-B). `Some` in
+ /// production; `None` in tests keeps the channel turn on the `Provider` path.
+ pub(crate) config: Option>,
}
pub(crate) fn conversation_memory_key(msg: &super::traits::ChannelMessage) -> String {
@@ -281,7 +284,7 @@ mod tests {
fn runtime_context() -> ChannelRuntimeContext {
ChannelRuntimeContext {
channels_by_name: Arc::new(HashMap::new()),
- provider: Arc::new(DummyProvider),
+ provider: Some(Arc::new(DummyProvider)),
default_provider: Arc::new("default".into()),
memory: Arc::new(MockMemory {
entries: Vec::new(),
@@ -305,6 +308,7 @@ mod tests {
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
}
}
diff --git a/src/openhuman/channels/routes.rs b/src/openhuman/channels/routes.rs
index 8e26a06aa..62e10ff89 100644
--- a/src/openhuman/channels/routes.rs
+++ b/src/openhuman/channels/routes.rs
@@ -171,7 +171,11 @@ pub(crate) async fn get_or_create_provider(
provider_name: &str,
) -> anyhow::Result> {
if provider_name == ctx.default_provider.as_str() {
- return Ok(Arc::clone(&ctx.provider));
+ return ctx
+ .provider
+ .as_ref()
+ .map(Arc::clone)
+ .ok_or_else(|| anyhow::anyhow!("no injected channel provider for '{provider_name}'"));
}
if let Some(existing) = ctx
@@ -184,30 +188,9 @@ pub(crate) async fn get_or_create_provider(
return Ok(existing);
}
- let (inference_url, backend_url) = if provider_name == ctx.default_provider.as_str() {
- (ctx.inference_url.as_deref(), ctx.api_url.as_deref())
- } else {
- (None, None)
- };
-
- let provider = provider::create_resilient_provider_with_options(
- inference_url,
- backend_url,
- None,
- &ctx.reliability,
- &ctx.provider_runtime_options,
- )?;
- let provider: Arc = Arc::from(provider);
-
- if let Err(err) = provider.warmup().await {
- tracing::warn!(provider = provider_name, "Provider warmup failed: {err}");
- }
-
- let mut cache = ctx.provider_cache.lock().unwrap_or_else(|e| e.into_inner());
- let cached = cache
- .entry(provider_name.to_string())
- .or_insert_with(|| Arc::clone(&provider));
- Ok(Arc::clone(cached))
+ anyhow::bail!(
+ "no injected channel provider for '{provider_name}'; production routes use crate-native model sources"
+ )
}
fn build_models_help_response(current: &ChannelRouteSelection, workspace_dir: &Path) -> String {
@@ -291,26 +274,21 @@ pub(crate) async fn handle_runtime_command_if_needed(
ChannelRuntimeCommand::ShowProviders => build_providers_help_response(¤t),
ChannelRuntimeCommand::SetProvider(raw_provider) => {
match resolve_provider_alias(&raw_provider) {
- Some(provider_name) => match get_or_create_provider(ctx, &provider_name).await {
- Ok(_) => {
- if provider_name != current.provider {
- current.provider = provider_name.clone();
- set_route_selection(ctx, &sender_key, current.clone());
- clear_sender_history(ctx, &sender_key);
- }
-
- format!(
- "Provider switched to `{provider_name}` for this sender session. Current model is `{}`.\nUse `/model ` to set a provider-compatible model.",
- current.model
- )
+ Some(provider_name) => {
+ tracing::debug!(
+ provider = %provider_name,
+ "[channels] validated crate-native provider route from catalog"
+ );
+ if provider_name != current.provider {
+ current.provider = provider_name.clone();
+ set_route_selection(ctx, &sender_key, current.clone());
+ clear_sender_history(ctx, &sender_key);
}
- Err(err) => {
- let safe_err = provider::sanitize_api_error(&err.to_string());
- format!(
- "Failed to initialize provider `{provider_name}`. Route unchanged.\nDetails: {safe_err}"
- )
- }
- },
+ format!(
+ "Provider switched to `{provider_name}` for this sender session. Current model is `{}`.\nUse `/model ` to set a provider-compatible model.",
+ current.model
+ )
+ }
None => format!(
"Unknown provider `{raw_provider}`. Use `/models` to list valid providers."
),
diff --git a/src/openhuman/channels/routes_tests.rs b/src/openhuman/channels/routes_tests.rs
index 3d1c651a1..76065c518 100644
--- a/src/openhuman/channels/routes_tests.rs
+++ b/src/openhuman/channels/routes_tests.rs
@@ -133,7 +133,7 @@ impl Channel for RecordingChannel {
fn runtime_context(workspace_dir: PathBuf) -> ChannelRuntimeContext {
ChannelRuntimeContext {
channels_by_name: Arc::new(HashMap::new()),
- provider: Arc::new(DummyProvider),
+ provider: Some(Arc::new(DummyProvider)),
default_provider: Arc::new("openai".into()),
memory: Arc::new(DummyMemory),
tools_registry: Arc::new(vec![Box::new(DummyTool) as Box]),
@@ -155,6 +155,7 @@ fn runtime_context(workspace_dir: PathBuf) -> ChannelRuntimeContext {
message_timeout_secs: 60,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
}
}
diff --git a/src/openhuman/channels/runtime/dispatch/processor.rs b/src/openhuman/channels/runtime/dispatch/processor.rs
index 109a49d66..d393109ed 100644
--- a/src/openhuman/channels/runtime/dispatch/processor.rs
+++ b/src/openhuman/channels/runtime/dispatch/processor.rs
@@ -229,33 +229,37 @@ pub(crate) async fn process_channel_runtime_message(
let history_key = conversation_history_key(&msg);
let route = get_route_selection(ctx.as_ref(), &history_key);
- let active_provider = match get_or_create_provider(ctx.as_ref(), &route.provider).await {
- Ok(provider) => provider,
- Err(err) => {
- crate::core::observability::report_error(
- &err,
- "channels",
- "provider_init",
- &[
- ("channel", msg.channel.as_str()),
- ("provider", route.provider.as_str()),
- ],
- );
- let safe_err = provider::sanitize_api_error(&err.to_string());
- let message = format!(
+ let active_provider = if ctx.config.is_none() {
+ match get_or_create_provider(ctx.as_ref(), &route.provider).await {
+ Ok(provider) => Some(provider),
+ Err(err) => {
+ crate::core::observability::report_error(
+ &err,
+ "channels",
+ "provider_init",
+ &[
+ ("channel", msg.channel.as_str()),
+ ("provider", route.provider.as_str()),
+ ],
+ );
+ let safe_err = provider::sanitize_api_error(&err.to_string());
+ let message = format!(
"⚠️ Failed to initialize provider `{}`. Please run `/models` to choose another provider.\nDetails: {safe_err}",
route.provider
);
- if let Some(channel) = target_channel.as_ref() {
- let _ = channel
- .send_with_outbound_intent(
- &SendMessage::new(message, &msg.reply_target)
- .in_thread(msg.thread_ts.clone()),
- )
- .await;
+ if let Some(channel) = target_channel.as_ref() {
+ let _ = channel
+ .send_with_outbound_intent(
+ &SendMessage::new(message, &msg.reply_target)
+ .in_thread(msg.thread_ts.clone()),
+ )
+ .await;
+ }
+ return;
}
- return;
}
+ } else {
+ None
};
let memory_context =
@@ -459,13 +463,24 @@ pub(crate) async fn process_channel_runtime_message(
};
let turn_request = AgentTurnRequest {
- // Wrap the channel's cached provider into the seam turn-model source at
- // the bus boundary (issue #4249, Phase 3 / Motion A) so the harness holds
- // crate model types only. The channel provider cache stays provider-typed
- // (a producer concern) until Motion B swaps in crate-native clients.
- turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(Arc::clone(
- &active_provider,
- )),
+ // Crate-native channel turn models (Phase 3 P3-B): when the runtime carries
+ // the full config, build crate `ChatModel`s from `("chat", route.provider,
+ // config)` — `route.provider` is the effective provider string. Tests (no
+ // `config`) stay on the injected `Provider` path.
+ turn_model_source: match &ctx.config {
+ Some(cfg) => {
+ crate::openhuman::tinyagents::TurnModelSource::new_crate_native_from_string(
+ "chat",
+ route.provider.clone(),
+ cfg.clone(),
+ )
+ }
+ None => crate::openhuman::tinyagents::TurnModelSource::new(Arc::clone(
+ active_provider
+ .as_ref()
+ .expect("test channel context must inject a provider"),
+ )),
+ },
history: std::mem::take(&mut history),
tools_registry: Arc::clone(&ctx.tools_registry),
provider_name: route.provider.clone(),
diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs
index d30d79143..751493b81 100644
--- a/src/openhuman/channels/runtime/startup.rs
+++ b/src/openhuman/channels/runtime/startup.rs
@@ -30,7 +30,7 @@ use crate::openhuman::channels::yuanbao::YuanbaoChannel;
use crate::openhuman::channels::Channel;
use crate::openhuman::config::Config;
use crate::openhuman::context::channels_prompt::build_system_prompt;
-use crate::openhuman::inference::provider::{self, Provider};
+use crate::openhuman::inference::provider;
use crate::openhuman::memory::Memory;
use crate::openhuman::memory_store;
use crate::openhuman::security::SecurityPolicy;
@@ -289,42 +289,32 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
};
- let (provider, model, provider_name): (Arc, String, String) =
- match resolve_chat_workload(&config) {
- ChatWorkloadResolution::Cloud => {
- let p: Arc =
- Arc::from(provider::create_intelligent_routing_provider(
- config.inference_url.as_deref(),
- config.api_url.as_deref(),
- config.api_key.as_deref(),
- &config,
- &provider_runtime_options,
- )?);
- let m = config
- .default_model
- .clone()
- .unwrap_or_else(|| crate::openhuman::config::DEFAULT_MODEL.into());
- (p, m, provider::INFERENCE_BACKEND_ID.to_string())
- }
- ChatWorkloadResolution::Workload {
- provider_string,
- slug,
- } => {
- tracing::info!(
- chat_provider = %provider_string,
- slug = %slug,
- "[channels][startup] chat workload routed to per-workload provider — building dedicated channel provider"
- );
- let (boxed, model_id) = provider::create_chat_provider("chat", &config)?;
- (Arc::from(boxed), model_id, slug)
- }
- };
-
- // Warm up the provider connection pool (TLS handshake, DNS, HTTP/2 setup)
- // so the first real message doesn't hit a cold-start timeout.
- if let Err(e) = provider.warmup().await {
- tracing::warn!("Provider warmup failed (non-fatal): {e}");
- }
+ let (model, provider_name) = match resolve_chat_workload(&config) {
+ ChatWorkloadResolution::Cloud => {
+ let (_chat, model) = provider::create_chat_model_with_model_id(
+ "chat",
+ &config,
+ config.default_temperature,
+ )?;
+ (model, provider::INFERENCE_BACKEND_ID.to_string())
+ }
+ ChatWorkloadResolution::Workload {
+ provider_string,
+ slug,
+ } => {
+ tracing::info!(
+ chat_provider = %provider_string,
+ slug = %slug,
+ "[channels][startup] chat workload routed to per-workload provider — building dedicated channel provider"
+ );
+ let (_chat, model_id) = provider::create_chat_model_with_model_id(
+ "chat",
+ &config,
+ config.default_temperature,
+ )?;
+ (model_id, slug)
+ }
+ };
let runtime: Arc = Arc::from(host_runtime::create_runtime(
&config.runtime,
@@ -905,14 +895,12 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
println!(" 🚦 In-flight message limit: {max_in_flight_messages}");
- let mut provider_cache_seed: HashMap> = HashMap::new();
- provider_cache_seed.insert(provider_name.clone(), Arc::clone(&provider));
let message_timeout_secs =
effective_channel_message_timeout_secs(config.channels_config.message_timeout_secs);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name,
- provider: Arc::clone(&provider),
+ provider: None,
default_provider: Arc::new(provider_name),
memory: Arc::clone(&mem),
tools_registry: Arc::clone(&tools_registry),
@@ -923,7 +911,7 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
max_tool_iterations: config.agent.max_tool_iterations,
min_relevance_score: config.memory.min_relevance_score,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
- provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
+ provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_url: config.api_url.clone(),
inference_url: config.inference_url.clone(),
@@ -933,6 +921,8 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
message_timeout_secs,
multimodal: config.multimodal.clone(),
multimodal_files: config.multimodal_files.clone(),
+ // Crate-native turn models for the channel turn (Phase 3 P3-B).
+ config: Some(std::sync::Arc::new(config.clone())),
});
run_message_dispatch_loop(rx, runtime_ctx, max_in_flight_messages).await;
diff --git a/src/openhuman/channels/runtime/test_support.rs b/src/openhuman/channels/runtime/test_support.rs
index d14975d49..1da2c4a9a 100644
--- a/src/openhuman/channels/runtime/test_support.rs
+++ b/src/openhuman/channels/runtime/test_support.rs
@@ -420,7 +420,7 @@ pub async fn run_dispatch_harness(options: DispatchHarnessOptions) -> DispatchHa
let ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider,
+ provider: Some(provider),
default_provider: Arc::new("harness-provider".to_string()),
memory: Arc::new(HarnessMemory {
entries: options
@@ -447,6 +447,7 @@ pub async fn run_dispatch_harness(options: DispatchHarnessOptions) -> DispatchHa
message_timeout_secs: options.timeout_secs,
multimodal: MultimodalConfig::default(),
multimodal_files: MultimodalFileConfig::default(),
+ config: None,
});
let expected_event_channel = options.channel_name.clone();
diff --git a/src/openhuman/channels/tests/context.rs b/src/openhuman/channels/tests/context.rs
index 67ab5517c..3543c4e42 100644
--- a/src/openhuman/channels/tests/context.rs
+++ b/src/openhuman/channels/tests/context.rs
@@ -1,4 +1,3 @@
-use super::common::DummyProvider;
use super::super::context::{
compact_sender_history, conversation_history_key, effective_channel_message_timeout_secs,
is_context_window_overflow_error, should_skip_memory_context_entry, ChannelRuntimeContext,
@@ -6,6 +5,7 @@ use super::super::context::{
CHANNEL_MESSAGE_TIMEOUT_SECS, MIN_CHANNEL_MESSAGE_TIMEOUT_SECS,
};
use super::super::traits;
+use super::common::DummyProvider;
use crate::openhuman::inference::provider::ChatMessage;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
@@ -30,8 +30,7 @@ fn context_window_overflow_error_detector_matches_known_messages() {
);
assert!(is_context_window_overflow_error(&overflow_err));
- let other_err =
- anyhow::anyhow!("OpenAI Codex API error (502 Bad Gateway): error code: 502");
+ let other_err = anyhow::anyhow!("OpenAI Codex API error (502 Bad Gateway): error code: 502");
assert!(!is_context_window_overflow_error(&other_err));
}
@@ -64,7 +63,7 @@ fn compact_sender_history_keeps_recent_truncated_messages() {
let ctx = ChannelRuntimeContext {
channels_by_name: Arc::new(HashMap::new()),
- provider: Arc::new(DummyProvider),
+ provider: Some(Arc::new(DummyProvider)),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(super::common::NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -82,7 +81,9 @@ fn compact_sender_history_keeps_recent_truncated_messages() {
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
- provider_runtime_options: crate::openhuman::inference::provider::ProviderRuntimeOptions::default(),
+ config: None,
+ provider_runtime_options:
+ crate::openhuman::inference::provider::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
};
@@ -129,8 +130,14 @@ fn telegram_history_key_is_thread_ts_agnostic() {
let key_a = conversation_history_key(&with_thread);
let key_b = conversation_history_key(&other_thread);
- assert_eq!(key_base, key_a, "telegram: thread_ts must not change history key");
- assert_eq!(key_a, key_b, "telegram: different thread_ts must share history key");
+ assert_eq!(
+ key_base, key_a,
+ "telegram: thread_ts must not change history key"
+ );
+ assert_eq!(
+ key_a, key_b,
+ "telegram: different thread_ts must share history key"
+ );
}
/// For every other channel (e.g. Slack, Discord), thread_ts splits conversation
diff --git a/src/openhuman/channels/tests/discord_integration.rs b/src/openhuman/channels/tests/discord_integration.rs
index f8ce7050d..98d2e2e54 100644
--- a/src/openhuman/channels/tests/discord_integration.rs
+++ b/src/openhuman/channels/tests/discord_integration.rs
@@ -118,7 +118,7 @@ fn make_discord_ctx(
Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels),
- provider,
+ provider: Some(provider),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -140,6 +140,7 @@ fn make_discord_ctx(
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
})
}
diff --git a/src/openhuman/channels/tests/memory.rs b/src/openhuman/channels/tests/memory.rs
index f255c6d9e..7f0b14426 100644
--- a/src/openhuman/channels/tests/memory.rs
+++ b/src/openhuman/channels/tests/memory.rs
@@ -138,7 +138,7 @@ async fn process_channel_message_restores_per_sender_history_on_follow_ups() {
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: provider_impl.clone(),
+ provider: Some(provider_impl.clone()),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -159,6 +159,7 @@ async fn process_channel_message_restores_per_sender_history_on_follow_ups() {
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
process_channel_message(
@@ -222,7 +223,7 @@ async fn process_channel_message_uses_autosaved_memory_after_history_is_cleared(
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: provider_impl.clone(),
+ provider: Some(provider_impl.clone()),
default_provider: Arc::new("test-provider".to_string()),
memory,
tools_registry: Arc::new(vec![]),
@@ -243,6 +244,7 @@ async fn process_channel_message_uses_autosaved_memory_after_history_is_cleared(
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
let first = traits::ChannelMessage {
diff --git a/src/openhuman/channels/tests/runtime_dispatch.rs b/src/openhuman/channels/tests/runtime_dispatch.rs
index 25051f203..d2811923d 100644
--- a/src/openhuman/channels/tests/runtime_dispatch.rs
+++ b/src/openhuman/channels/tests/runtime_dispatch.rs
@@ -116,9 +116,9 @@ async fn message_dispatch_processes_messages_in_parallel() {
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: Arc::new(SlowProvider {
+ provider: Some(Arc::new(SlowProvider {
delay: Duration::from_millis(5),
- }),
+ })),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -139,6 +139,7 @@ async fn message_dispatch_processes_messages_in_parallel() {
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
(channel_impl, runtime_ctx)
@@ -188,9 +189,9 @@ async fn process_channel_message_cancels_scoped_typing_task() {
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: Arc::new(SlowProvider {
+ provider: Some(Arc::new(SlowProvider {
delay: Duration::from_millis(20),
- }),
+ })),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -211,6 +212,7 @@ async fn process_channel_message_cancels_scoped_typing_task() {
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
process_channel_message(
@@ -277,7 +279,7 @@ async fn dispatch_routes_through_agent_run_turn_bus_handler() {
channels_by_name: Arc::new(channels_by_name),
// Still need a Provider for the Arc field, but the stubbed bus
// handler never invokes it — so a minimal no-op is fine.
- provider: Arc::new(super::common::DummyProvider),
+ provider: Some(Arc::new(super::common::DummyProvider)),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -298,6 +300,7 @@ async fn dispatch_routes_through_agent_run_turn_bus_handler() {
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
process_channel_message(
@@ -359,7 +362,7 @@ async fn channel_processed_event_records_resolved_agent_route() {
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: Arc::new(super::common::DummyProvider),
+ provider: Some(Arc::new(super::common::DummyProvider)),
default_provider: Arc::new("requested-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -380,6 +383,7 @@ async fn channel_processed_event_records_resolved_agent_route() {
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
process_channel_message(
@@ -470,7 +474,7 @@ async fn process_channel_message_hardens_multimodal_files_against_smuggled_marke
};
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: Arc::new(super::common::DummyProvider),
+ provider: Some(Arc::new(super::common::DummyProvider)),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -491,6 +495,7 @@ async fn process_channel_message_hardens_multimodal_files_against_smuggled_marke
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: permissive_operator_default,
+ config: None,
});
// Attacker-shaped message: an absolute-path FILE marker dropped
@@ -552,7 +557,7 @@ async fn process_channel_message_hardens_against_relative_path_markers() {
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: Arc::new(super::common::DummyProvider),
+ provider: Some(Arc::new(super::common::DummyProvider)),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -573,6 +578,7 @@ async fn process_channel_message_hardens_against_relative_path_markers() {
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
process_channel_message(
diff --git a/src/openhuman/channels/tests/runtime_tool_calls.rs b/src/openhuman/channels/tests/runtime_tool_calls.rs
index 257eda678..eb380cc1c 100644
--- a/src/openhuman/channels/tests/runtime_tool_calls.rs
+++ b/src/openhuman/channels/tests/runtime_tool_calls.rs
@@ -23,7 +23,7 @@ async fn process_channel_message_executes_tool_calls_instead_of_sending_raw_json
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: Arc::new(ToolCallingProvider),
+ provider: Some(Arc::new(ToolCallingProvider)),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
@@ -44,6 +44,7 @@ async fn process_channel_message_executes_tool_calls_instead_of_sending_raw_json
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
process_channel_message(
@@ -79,7 +80,7 @@ async fn process_channel_message_executes_tool_calls_with_alias_tags() {
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: Arc::new(ToolCallingAliasProvider),
+ provider: Some(Arc::new(ToolCallingAliasProvider)),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
@@ -100,6 +101,7 @@ async fn process_channel_message_executes_tool_calls_with_alias_tags() {
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
process_channel_message(
@@ -144,7 +146,7 @@ async fn process_channel_message_handles_models_command_without_llm_call() {
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: Arc::clone(&default_provider),
+ provider: Some(Arc::clone(&default_provider)),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -165,6 +167,7 @@ async fn process_channel_message_handles_models_command_without_llm_call() {
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
let cmd_msg = traits::ChannelMessage {
@@ -236,7 +239,7 @@ async fn process_channel_message_uses_route_override_provider_and_model() {
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: Arc::clone(&default_provider),
+ provider: Some(Arc::clone(&default_provider)),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -257,6 +260,7 @@ async fn process_channel_message_uses_route_override_provider_and_model() {
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
process_channel_message(runtime_ctx, routed_msg).await;
@@ -284,9 +288,9 @@ async fn process_channel_message_respects_configured_max_tool_iterations_above_d
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: Arc::new(IterativeToolProvider {
+ provider: Some(Arc::new(IterativeToolProvider {
required_tool_iterations: 11,
- }),
+ })),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
@@ -307,6 +311,7 @@ async fn process_channel_message_respects_configured_max_tool_iterations_above_d
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
process_channel_message(
@@ -341,9 +346,9 @@ async fn process_channel_message_reports_configured_max_tool_iterations_limit()
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
- provider: Arc::new(IterativeToolProvider {
+ provider: Some(Arc::new(IterativeToolProvider {
required_tool_iterations: 20,
- }),
+ })),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
@@ -364,6 +369,7 @@ async fn process_channel_message_reports_configured_max_tool_iterations_limit()
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
});
process_channel_message(
diff --git a/src/openhuman/channels/tests/telegram_integration.rs b/src/openhuman/channels/tests/telegram_integration.rs
index 11811cffb..c78bf2fd0 100644
--- a/src/openhuman/channels/tests/telegram_integration.rs
+++ b/src/openhuman/channels/tests/telegram_integration.rs
@@ -94,7 +94,7 @@ fn make_test_context(
Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels),
- provider,
+ provider: Some(provider),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
@@ -116,6 +116,7 @@ fn make_test_context(
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
+ config: None,
})
}
diff --git a/src/openhuman/inference/http/server.rs b/src/openhuman/inference/http/server.rs
index 592384492..c9bbac49b 100644
--- a/src/openhuman/inference/http/server.rs
+++ b/src/openhuman/inference/http/server.rs
@@ -31,11 +31,13 @@ use axum::{extract::State, Json, Router};
use futures_util::stream::{self, StreamExt};
use serde_json::json;
use std::collections::HashSet;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use tinyagents::harness::model::{ModelRequest, ModelStreamItem};
use tracing::{debug, error};
use crate::core::types::AppState;
use crate::openhuman::config::Config;
-use crate::openhuman::inference::provider;
use crate::openhuman::inference::provider::traits::ChatMessage;
use super::types::{
@@ -93,21 +95,23 @@ async fn chat_completions_handler(
format!("ollama:{}", req.model)
};
- let (provider_box, model_id) = match provider::factory::create_chat_provider_from_string(
- "agentic",
- &provider_string,
- &config,
- ) {
- Ok(pair) => pair,
- Err(e) => {
- error!("{LOG_PREFIX} chat_completions: provider build failed: {e}");
- return (
+ let (chat_model, model_id) =
+ match crate::openhuman::inference::provider::create_chat_model_from_string_with_model_id(
+ "agentic",
+ &provider_string,
+ &config,
+ req.temperature.unwrap_or(config.default_temperature),
+ ) {
+ Ok(pair) => pair,
+ Err(e) => {
+ error!("{LOG_PREFIX} chat_completions: provider build failed: {e}");
+ return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "message": format!("provider error: {e}"), "type": "invalid_request_error" }})),
)
.into_response();
- }
- };
+ }
+ };
// Map request messages to provider ChatMessage type.
let messages: Vec = req
@@ -142,28 +146,59 @@ async fn chat_completions_handler(
let completion_id = format!("chatcmpl-{}", uuid::Uuid::new_v4());
let created = chrono::Utc::now().timestamp();
let model_name = req.model.clone();
+ let model_request = ModelRequest::new(
+ messages
+ .iter()
+ .map(crate::openhuman::tinyagents::chat_message_to_message)
+ .collect(),
+ )
+ .with_model(model_id.clone())
+ .with_temperature(temperature);
if req.stream {
- // Streaming response via SSE
- let options = provider::traits::StreamOptions::new(true);
- let stream =
- provider_box.stream_chat_with_history(&messages, &model_id, temperature, options);
+ let model_stream = match chat_model.stream(&(), model_request).await {
+ Ok(stream) => stream,
+ Err(e) => {
+ error!(error = %e, model = %model_id, "{LOG_PREFIX} chat_completions: stream start failed");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": { "message": format!("inference error: {e}"), "type": "internal_error" }})),
+ )
+ .into_response();
+ }
+ };
let cid = completion_id.clone();
let model_clone = model_name.clone();
- let event_stream = stream
- .enumerate()
- .map(move |(i, chunk_result)| {
+ let first_delta = Arc::new(AtomicBool::new(true));
+ let event_stream = model_stream
+ .filter_map(move |item| {
let cid = cid.clone();
let model_clone = model_clone.clone();
- match chunk_result {
- Ok(chunk) => {
- let finish_reason = if chunk.is_final { Some("stop") } else { None };
- let content = if chunk.delta.is_empty() && chunk.is_final {
- None
- } else {
- Some(chunk.delta)
- };
+ let first_delta = Arc::clone(&first_delta);
+ async move {
+ let (content, finish_reason) = match item {
+ ModelStreamItem::MessageDelta(delta) if !delta.text.is_empty() => {
+ (Some(delta.text), None)
+ }
+ ModelStreamItem::Completed(_) => (None, Some("stop")),
+ ModelStreamItem::Failed(message) => {
+ let body = json!({"error": {"message": message, "type": "stream_error"}});
+ return Some(Ok::(
+ Event::default().data(body.to_string()),
+ ));
+ }
+ ModelStreamItem::ProviderFailed(error) => {
+ let body = json!({"error": {"message": error.message, "type": "stream_error"}});
+ return Some(Ok::(
+ Event::default().data(body.to_string()),
+ ));
+ }
+ _ => return None,
+ };
+ let role = first_delta
+ .swap(false, Ordering::Relaxed)
+ .then(|| "assistant".to_string());
let sse_chunk = ChatCompletionChunk {
id: cid,
object: "chat.completion.chunk",
@@ -172,11 +207,7 @@ async fn chat_completions_handler(
choices: vec![ChatCompletionChunkChoice {
index: 0,
delta: ChatCompletionDelta {
- role: if i == 0 {
- Some("assistant".to_string())
- } else {
- None
- },
+ role,
content,
},
finish_reason,
@@ -184,15 +215,9 @@ async fn chat_completions_handler(
};
let data =
serde_json::to_string(&sse_chunk).unwrap_or_else(|_| "{}".to_string());
- Ok::(Event::default().data(data))
- }
- Err(e) => {
- let err_event = json!({
- "error": { "message": e.to_string(), "type": "stream_error" }
- });
- Ok(Event::default()
- .data(serde_json::to_string(&err_event).unwrap_or_default()))
- }
+ Some(Ok::(
+ Event::default().data(data),
+ ))
}
})
.chain(stream::once(async {
@@ -205,13 +230,10 @@ async fn chat_completions_handler(
.into_response();
}
- // Non-streaming: call chat_with_history
- match provider_box
- .chat_with_history(&messages, &model_id, temperature)
- .await
- {
- Ok(content) => {
+ match chat_model.invoke(&(), model_request).await {
+ Ok(model_response) => {
debug!("{LOG_PREFIX} chat_completions: non-streaming ok");
+ let usage = model_response.usage.unwrap_or_default();
let response = ChatCompletionResponse {
id: completion_id,
object: "chat.completion",
@@ -221,14 +243,14 @@ async fn chat_completions_handler(
index: 0,
message: ChatCompletionMessage {
role: "assistant".to_string(),
- content,
+ content: model_response.text(),
},
finish_reason: "stop",
}],
usage: ChatCompletionUsage {
- prompt_tokens: 0,
- completion_tokens: 0,
- total_tokens: 0,
+ prompt_tokens: usage.input_tokens,
+ completion_tokens: usage.output_tokens,
+ total_tokens: usage.total_tokens,
},
};
(StatusCode::OK, Json(response)).into_response()
diff --git a/src/openhuman/inference/local/lm_studio.rs b/src/openhuman/inference/local/lm_studio.rs
index 511616d1f..cf5317402 100644
--- a/src/openhuman/inference/local/lm_studio.rs
+++ b/src/openhuman/inference/local/lm_studio.rs
@@ -7,6 +7,23 @@
use crate::openhuman::config::{Config, LocalAiConfig};
use serde::{Deserialize, Serialize};
+fn strip_think_tags(input: &str) -> String {
+ let mut result = String::with_capacity(input.len());
+ let mut rest = input;
+ loop {
+ let Some(start) = rest.find("") else {
+ result.push_str(rest);
+ break;
+ };
+ result.push_str(&rest[..start]);
+ let Some(end) = rest[start..].find("") else {
+ break;
+ };
+ rest = &rest[start + end + "".len()..];
+ }
+ result.trim().to_string()
+}
+
pub(crate) const DEFAULT_LM_STUDIO_BASE_URL: &str = "http://localhost:1234/v1";
pub(crate) fn lm_studio_base_url(config: &Config) -> String {
@@ -198,7 +215,7 @@ impl LmStudioChatResponseMessage {
let content = self
.content
.as_deref()
- .map(crate::openhuman::inference::provider::compatible_parse::strip_think_tags)
+ .map(strip_think_tags)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_default();
@@ -214,7 +231,7 @@ impl LmStudioChatResponseMessage {
let reasoning = self
.reasoning_content
.as_deref()
- .map(crate::openhuman::inference::provider::compatible_parse::strip_think_tags)
+ .map(strip_think_tags)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_default();
diff --git a/src/openhuman/inference/local/mod.rs b/src/openhuman/inference/local/mod.rs
index 2943c2fc4..a64370069 100644
--- a/src/openhuman/inference/local/mod.rs
+++ b/src/openhuman/inference/local/mod.rs
@@ -41,9 +41,7 @@ mod ollama;
pub(crate) mod process_util;
pub mod profile;
pub(crate) mod provider;
-pub(crate) use ollama::{
- ollama_base_url, ollama_base_url_from_config, validate_ollama_url, OLLAMA_BASE_URL,
-};
+pub(crate) use ollama::{ollama_base_url, ollama_base_url_from_config, validate_ollama_url};
pub mod service;
pub(crate) mod voice_install_common;
diff --git a/src/openhuman/inference/local/ollama.rs b/src/openhuman/inference/local/ollama.rs
index 799c6645e..e2d8462ab 100644
--- a/src/openhuman/inference/local/ollama.rs
+++ b/src/openhuman/inference/local/ollama.rs
@@ -218,10 +218,6 @@ pub(crate) fn redact_ollama_base_url(raw: &str) -> String {
.unwrap_or_else(|_| "".to_string())
}
-/// Back-compat constant kept at its original value for callers that
-/// reference it directly. New callers should use [`ollama_base_url`].
-pub(crate) const OLLAMA_BASE_URL: &str = DEFAULT_OLLAMA_BASE_URL;
-
#[derive(Debug, Serialize)]
pub(crate) struct OllamaPullRequest {
pub name: String,
diff --git a/src/openhuman/inference/local/ops.rs b/src/openhuman/inference/local/ops.rs
index 8a5f62f40..8315fc3be 100644
--- a/src/openhuman/inference/local/ops.rs
+++ b/src/openhuman/inference/local/ops.rs
@@ -10,7 +10,6 @@ use crate::openhuman::agent::Agent;
use crate::openhuman::config::Config;
use crate::openhuman::inference::local as local_ai;
use crate::openhuman::inference::provider as providers;
-use crate::openhuman::inference::provider::ops::ProviderRuntimeOptions;
use crate::openhuman::inference::{
LocalAiAssetsStatus, LocalAiDownloadsProgress, LocalAiEmbeddingResult, LocalAiSpeechResult,
LocalAiStatus, LocalAiTtsResult,
@@ -148,29 +147,25 @@ pub async fn agent_chat_simple(
.clone()
.unwrap_or_else(|| crate::openhuman::config::DEFAULT_MODEL.to_string());
- let options = ProviderRuntimeOptions {
- auth_profile_override: None,
- openhuman_dir: effective.config_path.parent().map(std::path::PathBuf::from),
- secrets_encrypt: effective.secrets.encrypt,
- reasoning_enabled: effective.runtime.reasoning_enabled,
- };
-
- let provider = providers::create_routed_provider_with_options(
- config.inference_url.as_deref(),
- config.api_url.as_deref(),
- config.api_key.as_deref(),
- &effective.reliability,
- &effective.model_routes,
- default_model.as_str(),
- &options,
+ let (model, resolved_model) = providers::create_chat_model_with_model_id(
+ "chat",
+ &effective,
+ effective.default_temperature,
)
.map_err(|e| e.to_string())?;
-
- let run = provider.chat_with_system(
- None,
- message,
- default_model.as_str(),
- effective.default_temperature,
+ tracing::debug!(
+ requested_model = %default_model,
+ resolved_model = %resolved_model,
+ temperature = effective.default_temperature,
+ "[inference] agent_chat_simple invoking crate-native chat model"
+ );
+ let run = model.invoke(
+ &(),
+ tinyagents::harness::model::ModelRequest::new(vec![
+ tinyagents::harness::message::Message::user(message.to_string()),
+ ])
+ .with_model(default_model.clone())
+ .with_temperature(effective.default_temperature),
);
let response = match thread_id.as_deref() {
Some(id) if !id.trim().is_empty() => {
@@ -182,7 +177,8 @@ pub async fn agent_chat_simple(
run.await
}
}
- .map_err(|e| e.to_string())?;
+ .map_err(|e| e.to_string())?
+ .text();
Ok(RpcOutcome::single_log(
response,
diff --git a/src/openhuman/inference/ops.rs b/src/openhuman/inference/ops.rs
index 708801cc8..852a9d342 100644
--- a/src/openhuman/inference/ops.rs
+++ b/src/openhuman/inference/ops.rs
@@ -9,6 +9,8 @@ use crate::openhuman::inference::{device, presets, sentiment, SentimentResult};
use crate::openhuman::inference::{LocalAiEmbeddingResult, LocalAiStatus};
use crate::rpc::RpcOutcome;
use serde_json::{json, Value};
+use tinyagents::harness::message::Message;
+use tinyagents::harness::model::ModelRequest;
use tracing::{debug, error, warn};
const LOG_PREFIX: &str = "[inference::ops]";
@@ -144,42 +146,47 @@ pub async fn inference_test_provider_model(
prompt_len = prompt.len(),
"{LOG_PREFIX} test_provider_model:start"
);
- let result =
- if provider.trim().starts_with("lmstudio:") || provider.trim().starts_with("ollama:") {
- log::debug!("{LOG_PREFIX} test_provider_model: routing to local provider={provider}");
- let (chat_provider, model) =
- crate::openhuman::inference::provider::factory::create_local_chat_provider_from_string(
- provider, config,
+ let local = provider.trim().starts_with("lmstudio:")
+ || provider.trim().starts_with("ollama:")
+ || provider.trim().starts_with("mlx:")
+ || provider.trim().starts_with("omlx:")
+ || provider.trim().starts_with("local-openai:");
+ let (chat_model, model) = if local {
+ debug!(
+ provider,
+ "{LOG_PREFIX} test_provider_model:build_local_model"
+ );
+ crate::openhuman::inference::provider::factory::create_local_chat_model_from_string(
+ provider, config,
+ )
+ } else {
+ debug!(provider, "{LOG_PREFIX} test_provider_model:build_model");
+ crate::openhuman::inference::provider::create_chat_model_from_string_with_model_id(
+ workload,
+ provider,
+ config,
+ config.default_temperature,
+ )
+ }
+ .map_err(|e| e.to_string())?;
+ debug!(%model, local, "{LOG_PREFIX} test_provider_model:invoke");
+ let result = chat_model
+ .invoke(
+ &(),
+ ModelRequest::new(vec![Message::user(prompt)])
+ .with_model(model)
+ .with_temperature(config.default_temperature),
+ )
+ .await
+ .map_err(|e| e.to_string())
+ .map(|response| {
+ RpcOutcome::single_log(
+ InferenceTestProviderModelResult {
+ reply: response.text(),
+ },
+ "provider model test completed",
)
- .map_err(|e| e.to_string())?;
- log::debug!("{LOG_PREFIX} test_provider_model: invoking local model={model}");
- chat_provider
- .simple_chat(prompt, &model, config.default_temperature)
- .await
- .map_err(|e| e.to_string())
- .map(|reply| {
- RpcOutcome::single_log(
- InferenceTestProviderModelResult { reply },
- "provider model test completed",
- )
- })
- } else {
- let (chat_provider, model) =
- crate::openhuman::inference::provider::factory::create_chat_provider_from_string(
- workload, provider, config,
- )
- .map_err(|e| e.to_string())?;
- chat_provider
- .simple_chat(prompt, &model, config.default_temperature)
- .await
- .map_err(|e| e.to_string())
- .map(|reply| {
- RpcOutcome::single_log(
- InferenceTestProviderModelResult { reply },
- "provider model test completed",
- )
- })
- };
+ });
match &result {
Ok(outcome) => debug!(
output_len = outcome.value.reply.len(),
diff --git a/src/openhuman/inference/provider/auth.rs b/src/openhuman/inference/provider/auth.rs
new file mode 100644
index 000000000..5f3ff49cf
--- /dev/null
+++ b/src/openhuman/inference/provider/auth.rs
@@ -0,0 +1,10 @@
+//! Wire authentication styles shared by crate-native provider builders.
+
+#[derive(Debug, Clone)]
+pub enum AuthStyle {
+ None,
+ Bearer,
+ XApiKey,
+ Anthropic,
+ Custom(String),
+}
diff --git a/src/openhuman/inference/provider/compatible.rs b/src/openhuman/inference/provider/compatible.rs
deleted file mode 100644
index 1ba0a1a15..000000000
--- a/src/openhuman/inference/provider/compatible.rs
+++ /dev/null
@@ -1,422 +0,0 @@
-//! Generic OpenAI-compatible provider.
-//! Most LLM APIs follow the same `/v1/chat/completions` format.
-//! This module provides a single implementation that works for all of them.
-
-#[path = "compatible_dump.rs"]
-mod compatible_dump;
-#[path = "compatible_helpers.rs"]
-mod compatible_helpers;
-#[path = "compatible_parse.rs"]
-mod compatible_parse;
-#[path = "compatible_provider_impl.rs"]
-mod compatible_provider_impl;
-#[path = "compatible_repeat.rs"]
-mod compatible_repeat;
-#[path = "compatible_request.rs"]
-mod compatible_request;
-#[path = "compatible_stream.rs"]
-mod compatible_stream;
-#[path = "compatible_stream_native.rs"]
-mod compatible_stream_native;
-#[path = "compatible_timeout.rs"]
-mod compatible_timeout;
-#[path = "compatible_types.rs"]
-mod compatible_types;
-
-#[cfg(test)]
-pub(crate) use super::traits::{ChatMessage, ConversationMessage, Provider};
-#[cfg(test)]
-pub(crate) use compatible_parse::normalize_function_arguments;
-#[cfg(test)]
-pub(crate) use compatible_parse::{
- build_responses_prompt, extract_responses_text, parse_chat_response_body,
- parse_provider_tool_call_from_value, parse_responses_response_body, parse_sse_line,
- strip_think_tags,
-};
-#[cfg(test)]
-pub(crate) use compatible_repeat::{StreamRepeatDetector, STREAM_REPEAT_THRESHOLD};
-#[cfg(test)]
-pub(crate) use compatible_types::StreamChunkResponse;
-#[cfg(test)]
-pub(crate) use compatible_types::{
- ApiChatRequest, ApiChatResponse, Choice, Function, Message, MessageContent, NativeChatRequest,
- NativeMessage, ResponseMessage, ResponsesResponse, ToolCall,
-};
-
-/// A provider that speaks the OpenAI-compatible chat completions API.
-/// Used by: Venice, Vercel AI Gateway, Cloudflare AI Gateway, Moonshot,
-/// Synthetic, `OpenCode` Zen, `Z.AI`, `GLM`, `MiniMax`, Bedrock, Qianfan, Groq, Mistral, `xAI`, etc.
-pub struct OpenAiCompatibleProvider {
- pub(crate) name: String,
- pub(crate) base_url: String,
- pub(crate) credential: Option,
- pub(crate) auth_header: AuthStyle,
- /// When false, do not fall back to /v1/responses on chat completions 404.
- /// GLM/Zhipu does not support the responses API.
- supports_responses_fallback: bool,
- /// When true, call the Responses API directly instead of first trying
- /// chat completions. Required for ChatGPT-account Codex OAuth.
- responses_api_primary: bool,
- user_agent: Option,
- extra_headers: Vec<(String, String)>,
- extra_query_params: Vec<(String, String)>,
- /// When true, collect all `system` messages and prepend their content
- /// to the first `user` message, then drop the system messages.
- /// Required for providers that reject `role: system` (e.g. MiniMax).
- merge_system_into_user: bool,
- /// When true, forward the OpenHuman backend extension `thread_id`
- /// (read from `thread_context::current_thread_id`) on outbound
- /// chat completions bodies. Off by default — only the
- /// `OpenHumanBackendProvider` opts in, so third-party
- /// OpenAI-compatible endpoints (Venice, Moonshot, Groq, GLM, …)
- /// never see an unrecognized field that could trip strict input
- /// validation.
- emit_openhuman_thread_id: bool,
- /// Shell-style glob patterns (`*` only) for model IDs that MUST NOT
- /// receive a `temperature` field. Matches are done by
- /// `temperature::glob_match`. Defaults to empty (all models support
- /// temperature); populated by the factory when the config has entries.
- pub(crate) temperature_unsupported_models: Vec,
- /// Per-workload temperature override. When `Some`, replaces the
- /// caller-supplied `temperature` for every chat call on this provider
- /// instance — set by the factory when the workload's provider string
- /// carries an `@` suffix (e.g. `"openai:gpt-4o@0.2"`). The
- /// `temperature_unsupported_models` glob filter still applies after.
- pub(crate) temperature_override: Option,
- /// Value reported by `capabilities().native_tool_calling`. Defaults to
- /// `true` because most OpenAI-compatible providers implement the
- /// `tools` parameter correctly. The factory flips this to `false` for
- /// Ollama, whose OpenAI-compat endpoint returns HTTP 400 on `tools`
- /// for many models.
- native_tool_calling: bool,
- /// Value reported by `capabilities().vision`. Defaults to `true` because
- /// OpenAI-compatible cloud endpoints accept the `image_url` message
- /// content shape. Local compatibility shims opt out unless they can prove
- /// the configured model supports vision.
- vision: bool,
- /// Ollama-specific `options.num_ctx` override. When set, every request
- /// to this provider includes `"options": {"num_ctx": }` in the
- /// body so Ollama allocates the requested KV-cache size.
- pub(crate) ollama_num_ctx: Option,
- /// The local provider kind, if this is a local provider.
- /// Used for profile-aware context window resolution and diagnostics.
- pub(crate) local_provider_kind:
- Option,
- /// Per-chunk stream inactivity window for the native streaming path
- /// (`stream_native_chat`, #4269). Defaults to
- /// [`compatible_timeout::stream_idle_timeout`]; tests inject a small value
- /// via [`OpenAiCompatibleProvider::with_stream_idle_timeout`].
- stream_idle_timeout: std::time::Duration,
-}
-
-/// How the provider expects the API key to be sent.
-#[derive(Debug, Clone)]
-pub enum AuthStyle {
- /// No authentication header.
- None,
- /// `Authorization: Bearer `
- Bearer,
- /// `x-api-key: ` (used by some Chinese providers)
- XApiKey,
- /// Anthropic-specific: `x-api-key: ` + `anthropic-version: 2023-06-01`
- Anthropic,
- /// Custom header name
- Custom(String),
-}
-
-impl OpenAiCompatibleProvider {
- pub fn new(
- name: &str,
- base_url: &str,
- credential: Option<&str>,
- auth_style: AuthStyle,
- ) -> Self {
- Self::new_with_options(name, base_url, credential, auth_style, true, None, false)
- }
-
- /// Same as `new` but skips the /v1/responses fallback on 404.
- /// Use for providers (e.g. GLM) that only support chat completions.
- pub fn new_no_responses_fallback(
- name: &str,
- base_url: &str,
- credential: Option<&str>,
- auth_style: AuthStyle,
- ) -> Self {
- Self::new_with_options(name, base_url, credential, auth_style, false, None, false)
- }
-
- /// Create a provider with a custom User-Agent header.
- ///
- /// Some providers (for example Kimi Code) require a specific User-Agent
- /// for request routing and policy enforcement.
- pub fn new_with_user_agent(
- name: &str,
- base_url: &str,
- credential: Option<&str>,
- auth_style: AuthStyle,
- user_agent: &str,
- ) -> Self {
- Self::new_with_options(
- name,
- base_url,
- credential,
- auth_style,
- true,
- Some(user_agent),
- false,
- )
- }
-
- /// For providers that do not support `role: system` (e.g. MiniMax).
- /// System prompt content is prepended to the first user message instead.
- pub fn new_merge_system_into_user(
- name: &str,
- base_url: &str,
- credential: Option<&str>,
- auth_style: AuthStyle,
- ) -> Self {
- Self::new_with_options(name, base_url, credential, auth_style, false, None, true)
- }
-
- /// Opt this provider into emitting the OpenHuman backend extension
- /// `thread_id` on outbound chat completions bodies. Only the
- /// `OpenHumanBackendProvider` should call this — third-party
- /// OpenAI-compatible providers must leave it off so they don't
- /// receive an unknown field.
- pub fn with_openhuman_thread_id(mut self) -> Self {
- self.emit_openhuman_thread_id = true;
- self
- }
-
- fn new_with_options(
- name: &str,
- base_url: &str,
- credential: Option<&str>,
- auth_style: AuthStyle,
- supports_responses_fallback: bool,
- user_agent: Option<&str>,
- merge_system_into_user: bool,
- ) -> Self {
- Self {
- name: name.to_string(),
- base_url: base_url.trim_end_matches('/').to_string(),
- credential: credential.map(ToString::to_string),
- auth_header: auth_style,
- supports_responses_fallback,
- responses_api_primary: false,
- user_agent: user_agent.map(ToString::to_string),
- extra_headers: Vec::new(),
- extra_query_params: Vec::new(),
- merge_system_into_user,
- emit_openhuman_thread_id: false,
- temperature_unsupported_models: Vec::new(),
- temperature_override: None,
- native_tool_calling: true,
- vision: true,
- ollama_num_ctx: None,
- local_provider_kind: None,
- stream_idle_timeout: compatible_timeout::stream_idle_timeout(),
- }
- }
-
- /// Toggle whether this provider advertises native (OpenAI-style) tool
- /// calling to the agent harness. The default is `true`; set to `false`
- /// for providers whose `/v1/chat/completions` endpoint rejects the
- /// `tools` parameter.
- pub fn with_native_tool_calling(mut self, enabled: bool) -> Self {
- self.native_tool_calling = enabled;
- self
- }
-
- /// Test-only: shrink the stream inactivity watchdog window (#4269) so
- /// stalled-stream and wedged-consumer behaviour can be exercised without
- /// waiting the production default (90s).
- #[cfg(test)]
- pub(crate) fn with_stream_idle_timeout(mut self, window: std::time::Duration) -> Self {
- self.stream_idle_timeout = window;
- self
- }
-
- /// Toggle whether this provider advertises OpenAI-compatible vision input.
- /// Cloud providers default to enabled; local OpenAI-compatible shims use
- /// this to stay fail-closed for text-only local models.
- pub fn with_vision(mut self, enabled: bool) -> Self {
- self.vision = enabled;
- self
- }
-
- /// Set the list of model glob patterns for which temperature must be
- /// omitted from request bodies.
- pub fn with_temperature_unsupported_models(mut self, patterns: Vec) -> Self {
- self.temperature_unsupported_models = patterns;
- self
- }
-
- /// Pin a per-workload temperature, overriding whatever the caller passes.
- pub fn with_temperature_override(mut self, temperature: Option) -> Self {
- self.temperature_override = temperature;
- self
- }
-
- /// Set the Ollama `options.num_ctx` override.
- pub fn with_ollama_num_ctx(mut self, num_ctx: Option) -> Self {
- self.ollama_num_ctx = num_ctx;
- self
- }
-
- /// Tag this provider with its local provider kind for profile-aware
- /// context window resolution and diagnostics.
- pub fn with_local_provider_kind(
- mut self,
- kind: crate::openhuman::inference::local::profile::LocalProviderKind,
- ) -> Self {
- self.local_provider_kind = Some(kind);
- self
- }
-
- pub fn with_extra_header(mut self, name: impl Into, value: impl Into) -> Self {
- let name = name.into();
- let value = value.into();
- if !name.trim().is_empty() && !value.trim().is_empty() {
- self.extra_headers
- .push((name.trim().to_string(), value.trim().to_string()));
- }
- self
- }
-
- pub fn with_user_agent(mut self, value: impl Into) -> Self {
- let value = value.into();
- if !value.trim().is_empty() {
- self.user_agent = Some(value.trim().to_string());
- }
- self
- }
-
- pub fn with_responses_api_primary(mut self) -> Self {
- self.responses_api_primary = true;
- self
- }
-
- /// Whether the chat-completions-404 → `/v1/responses` fallback should fire
- /// for this provider on the current call.
- ///
- /// `supports_responses_fallback` is the static (factory-decided) capability,
- /// but a custom / unknown slug whose endpoint does not actually implement
- /// the Responses API only reveals that at runtime — the `/responses` route
- /// returns its own 404. Once we have seen that, [`responses_api_known_unsupported`]
- /// reports the endpoint as Responses-incapable and we stop re-issuing the
- /// guaranteed-failing second request, routing to chat-completions only. This
- /// is the runtime complement to the builtin-slug gate in `factory.rs`
- /// (`builtin_cloud_supports_responses_api`) for custom slugs like
- /// `nous-portal` that the factory can't classify (TAURI-RUST-FJZ).
- pub(super) fn responses_fallback_active(&self) -> bool {
- self.supports_responses_fallback && !responses_api_known_unsupported(&self.base_url)
- }
-
- pub fn with_extra_query_param(
- mut self,
- name: impl Into,
- value: impl Into,
- ) -> Self {
- let name = name.into();
- let value = value.into();
- if !name.trim().is_empty() && !value.trim().is_empty() {
- self.extra_query_params
- .push((name.trim().to_string(), value.trim().to_string()));
- }
- self
- }
-}
-
-/// Endpoints (`base_url`) whose `/v1/responses` route has returned 404 — i.e.
-/// they do not implement the OpenAI Responses API. Once an endpoint is recorded
-/// here, the chat-completions-404 → `/responses` fallback is disabled for it for
-/// the rest of the process lifetime (see [`OpenAiCompatibleProvider::responses_fallback_active`]),
-/// so a permanent client 404 stops triggering a second guaranteed 404 on every
-/// retry (TAURI-RUST-FJZ — `nous-portal`, a chat-completions-only custom slug).
-///
-/// Keyed on `base_url` because that, not the user-facing slug, determines
-/// Responses support: two slugs pointed at the same endpoint share its
-/// capability, and a slug rename must not reset what we learned.
-fn responses_unsupported_endpoints() -> &'static std::sync::Mutex>
-{
- static CACHE: std::sync::OnceLock>> =
- std::sync::OnceLock::new();
- CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
-}
-
-/// Record that `base_url` returned 404 from its `/v1/responses` route and is
-/// therefore not Responses-capable. Idempotent.
-pub(super) fn mark_responses_api_unsupported(base_url: &str) {
- if let Ok(mut set) = responses_unsupported_endpoints().lock() {
- if set.insert(base_url.to_string()) {
- log::debug!(
- "[provider] /responses route 404'd — disabling responses fallback for \
- endpoint {} (chat-completions only henceforth)",
- super::factory::redact_endpoint(base_url),
- );
- }
- }
-}
-
-/// Whether `base_url` has been recorded as not implementing the Responses API.
-pub(super) fn responses_api_known_unsupported(base_url: &str) -> bool {
- responses_unsupported_endpoints()
- .lock()
- .map(|set| set.contains(base_url))
- .unwrap_or(false)
-}
-
-/// Prompt-cache behaviour for an OpenAI-compatible provider, keyed on its
-/// configured slug (#3939).
-///
-/// Conservative by default: only providers we have verified to both (a) cache
-/// identical request prefixes server-side and (b) report cached input tokens
-/// via the OpenAI `prompt_tokens_details.cached_tokens` shape that
-/// [`OpenAiCompatibleProvider`]'s usage extractor already normalises are opted
-/// in. Unknown / custom slugs (including local LM Studio endpoints, which do no
-/// server-side billing-grade caching) get the all-`false` default, so we never
-/// advertise caching a custom endpoint may not do. `explicit_cache_control`
-/// stays `false` for every OpenAI-compatible provider — the chat-completions
-/// API has no cache-control field — and `cache_key_grouping` is reserved for
-/// the OpenHuman backend's `thread_id` extension, declared on
-/// `OpenHumanBackendProvider` directly.
-pub(crate) fn prompt_cache_for_compatible_slug(
- slug: &str,
-) -> super::traits::PromptCacheCapabilities {
- // Compare on the leading slug segment so a user-renamed `openai-eu` or a
- // `openai:gpt-5.1` style slug still resolves to the `openai` family.
- let normalized = slug.trim().to_ascii_lowercase();
- let family = normalized
- .split([':', '/', '-'])
- .next()
- .unwrap_or("")
- .trim();
-
- // Verified OpenAI-style implicit prefix cache + cached-token usage reporting.
- let openai_style_cache = matches!(family, "openai" | "openrouter" | "gmi");
-
- let caps = super::traits::PromptCacheCapabilities {
- automatic_prefix_cache: openai_style_cache,
- explicit_cache_control: false,
- usage_reports_cached_input: openai_style_cache,
- cache_key_grouping: false,
- };
-
- tracing::debug!(
- domain = "llm_provider",
- operation = "prompt_cache_capability",
- provider = %family,
- automatic_prefix_cache = caps.automatic_prefix_cache,
- explicit_cache_control = caps.explicit_cache_control,
- usage_reports_cached_input = caps.usage_reports_cached_input,
- cache_key_grouping = caps.cache_key_grouping,
- "[llm_provider] prompt-cache capability selected for compatible provider"
- );
-
- caps
-}
-
-#[cfg(test)]
-#[path = "compatible_tests.rs"]
-mod tests;
diff --git a/src/openhuman/inference/provider/compatible_dump.rs b/src/openhuman/inference/provider/compatible_dump.rs
deleted file mode 100644
index 0680b4990..000000000
--- a/src/openhuman/inference/provider/compatible_dump.rs
+++ /dev/null
@@ -1,128 +0,0 @@
-//! Prompt and response dump utilities for KV-cache debugging.
-//!
-//! When `OPENHUMAN_PROMPT_DUMP_DIR` is set, both the outbound request payload
-//! and the inbound response body are written to timestamped files under that
-//! directory. Best-effort: failures are logged and swallowed so a dump outage
-//! never breaks inference.
-
-use serde::Serialize;
-use std::sync::atomic::{AtomicU64, Ordering};
-
-/// Monotonic sequence so multiple requests in the same millisecond sort
-/// deterministically in the dump directory.
-pub(crate) static PROMPT_DUMP_SEQ: AtomicU64 = AtomicU64::new(0);
-
-/// Atomically reserve the next dump sequence number. This is the single
-/// source of truth for seq allocation — both the prompt dump and its
-/// paired response dump must use the value returned here. A non-atomic
-/// peek-then-increment split would race under concurrent requests (two
-/// callers could reserve the same seq or correlate a request/response
-/// pair across different turns).
-pub(crate) fn reserve_dump_seq() -> u64 {
- PROMPT_DUMP_SEQ.fetch_add(1, Ordering::Relaxed)
-}
-
-/// When `OPENHUMAN_PROMPT_DUMP_DIR` is set, write `body` (the exact JSON
-/// payload we're about to POST to the provider) to a timestamped file
-/// under that directory. Best-effort: failures are logged and swallowed
-/// so a dump outage never breaks inference.
-///
-/// Intended for KV-cache debugging — diff consecutive turns to see which
-/// bytes of the prefix drifted and broke the cache hit.
-pub(crate) fn dump_prompt_if_enabled(
- provider: &str,
- model: &str,
- seq: u64,
- body: &T,
-) {
- let Ok(dir) = std::env::var("OPENHUMAN_PROMPT_DUMP_DIR") else {
- return;
- };
- let dir = std::path::PathBuf::from(dir);
- if let Err(err) = std::fs::create_dir_all(&dir) {
- log::warn!(
- "[prompt_dump] failed to create dir {}: {err}",
- dir.display()
- );
- return;
- }
- let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
- let safe_model: String = model
- .chars()
- .map(|c| {
- if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
- c
- } else {
- '_'
- }
- })
- .collect();
- let filename = format!("{ts}_{seq:06}_{provider}_{safe_model}.json");
- let path = dir.join(filename);
- match serde_json::to_vec_pretty(body) {
- Ok(bytes) => {
- if let Err(err) = std::fs::write(&path, &bytes) {
- log::warn!("[prompt_dump] write failed {}: {err}", path.display());
- } else {
- log::debug!(
- "[prompt_dump] wrote {} bytes -> {}",
- bytes.len(),
- path.display()
- );
- }
- }
- Err(err) => {
- log::warn!("[prompt_dump] serialize failed: {err}");
- }
- }
-}
-
-/// Write raw response bytes to the dump dir paired with the most-recent
-/// prompt file (same `seq` prefix, `.response.json` suffix). `seq` must
-/// be the value reserved via `reserve_dump_seq` and passed to
-/// `dump_prompt_if_enabled` so request/response files sort next to
-/// each other.
-pub(crate) fn dump_response_if_enabled(provider: &str, model: &str, seq: u64, bytes: &[u8]) {
- let Ok(dir) = std::env::var("OPENHUMAN_PROMPT_DUMP_DIR") else {
- return;
- };
- let dir = std::path::PathBuf::from(dir);
- if let Err(err) = std::fs::create_dir_all(&dir) {
- log::warn!(
- "[prompt_dump] failed to create dir {}: {err}",
- dir.display()
- );
- return;
- }
- let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
- let safe_model: String = model
- .chars()
- .map(|c| {
- if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
- c
- } else {
- '_'
- }
- })
- .collect();
- let filename = format!("{ts}_{seq:06}_{provider}_{safe_model}.response.json");
- let path = dir.join(filename);
- // Re-pretty-print if it parses as JSON so diffs are stable; otherwise
- // write raw bytes (SSE fragments, error HTML, etc).
- let payload: Vec = match serde_json::from_slice::(bytes) {
- Ok(v) => serde_json::to_vec_pretty(&v).unwrap_or_else(|_| bytes.to_vec()),
- Err(_) => bytes.to_vec(),
- };
- if let Err(err) = std::fs::write(&path, &payload) {
- log::warn!(
- "[prompt_dump] response write failed {}: {err}",
- path.display()
- );
- } else {
- log::debug!(
- "[prompt_dump] wrote response {} bytes -> {}",
- payload.len(),
- path.display()
- );
- }
-}
diff --git a/src/openhuman/inference/provider/compatible_helpers.rs b/src/openhuman/inference/provider/compatible_helpers.rs
deleted file mode 100644
index fe7281b4d..000000000
--- a/src/openhuman/inference/provider/compatible_helpers.rs
+++ /dev/null
@@ -1,843 +0,0 @@
-use crate::openhuman::inference::provider::traits::{
- ChatMessage, ChatResponse as ProviderChatResponse, ToolCall as ProviderToolCall,
- UsageInfo as ProviderUsageInfo,
-};
-
-use super::compatible_parse::{
- aggregate_responses_sse_body, build_responses_prompt, extract_responses_text,
- normalize_function_arguments, parse_responses_response_body,
- parse_tool_calls_from_content_json,
-};
-use super::compatible_types::{
- ApiChatResponse, MessageContent, NativeMessage, ResponsesRequest, ToolCall,
-};
-use super::OpenAiCompatibleProvider;
-
-impl OpenAiCompatibleProvider {
- pub(super) async fn chat_via_responses(
- &self,
- credential: Option<&str>,
- messages: &[ChatMessage],
- model: &str,
- max_output_tokens: Option,
- ) -> anyhow::Result {
- let (instructions, input) = build_responses_prompt(messages);
- if input.is_empty() {
- anyhow::bail!(
- "{} Responses API fallback requires at least one non-system message",
- self.name
- );
- }
-
- log::debug!(
- "[provider] {} responses-path model={model} max_output_tokens={:?} input_msgs={}",
- self.name,
- max_output_tokens,
- input.len(),
- );
-
- // #3201: the Codex/ChatGPT OAuth Responses endpoint rejects `stream: false`
- // outright. This branch lifts the constraint for that endpoint specifically
- // and parses the resulting SSE body so the existing non-streaming call
- // signature is preserved. Other providers keep the single-envelope path.
- let is_codex_oauth_responses = reqwest::Url::parse(&self.base_url)
- .ok()
- .and_then(|url| {
- let segments: Vec<&str> = url.path_segments()?.collect();
- Some(
- segments
- .windows(2)
- .any(|window| window == ["backend-api", "codex"]),
- )
- })
- .unwrap_or(false);
-
- // TAURI-RUST-EWD: the Codex/ChatGPT OAuth Responses endpoint
- // (`chatgpt.com/backend-api/codex/responses`) rejects `max_output_tokens`
- // outright (400 `Unsupported parameter: max_output_tokens`), the same way
- // it rejects `stream: false` (#3201). Omit the cap proactively for that
- // endpoint; every other Responses backend keeps it (the cap still flows
- // through for `responses_api_primary` on a real `/v1/responses`).
- if is_codex_oauth_responses && max_output_tokens.is_some() {
- log::debug!(
- "[provider] {} omitting max_output_tokens={:?} for Codex OAuth responses endpoint (unsupported param)",
- self.name,
- max_output_tokens,
- );
- }
-
- // TAURI-RUST-AHX: the ChatGPT-OAuth Codex Responses endpoint rejects the
- // `auto` model sentinel with a 400 (`The 'auto' model is not supported
- // when using Codex with a ChatGPT account.`). `auto` is a Codex-CLI alias
- // valid only for API-key Codex; here it would otherwise leak unchanged to
- // the provider. Remap it to a concrete Codex-class model proactively for
- // this endpoint only, mirroring the #3201 (drop `stream:false`) and EWD
- // (drop `max_output_tokens`) adjustments above. Every other endpoint keeps
- // `model` untouched.
- let effective_model = if is_codex_oauth_responses && model.eq_ignore_ascii_case("auto") {
- let remapped = super::super::openai_codex::OPENAI_CODEX_MODEL_HINTS
- .first()
- .copied()
- .unwrap_or("gpt-5.5");
- log::info!(
- "[provider] {} remapping model=auto -> {remapped} for Codex OAuth responses endpoint (auto unsupported on ChatGPT account)",
- self.name,
- );
- remapped.to_string()
- } else {
- model.to_string()
- };
-
- let mut request = ResponsesRequest {
- model: effective_model,
- input,
- instructions,
- stream: Some(is_codex_oauth_responses),
- store: Some(false),
- max_output_tokens: if is_codex_oauth_responses {
- None
- } else {
- max_output_tokens
- },
- };
-
- let url = self.responses_url();
- let mut retried_without_cap = false;
-
- let (status, error) = loop {
- let response = self
- .apply_auth_header(self.http_client().post(&url).json(&request), credential)
- .send()
- .await?;
-
- if response.status().is_success() {
- let body = response.text().await?;
- if is_codex_oauth_responses {
- return aggregate_responses_sse_body(&self.name, &body);
- }
- let responses = parse_responses_response_body(&self.name, &body)?;
- return extract_responses_text(responses).ok_or_else(|| {
- anyhow::anyhow!("No response from {} Responses API", self.name)
- });
- }
-
- let status = response.status();
- let error = response.text().await?;
-
- // Reactive defense-in-depth: a strict Responses backend may still
- // reject `max_output_tokens` with a 400 (e.g. a future Codex endpoint
- // variant we don't match above). Strip the field and retry once,
- // mirroring the no-tools / frequency_penalty retries. Bounded to a
- // single retry so a genuinely different 400 still surfaces.
- if !retried_without_cap
- && request.max_output_tokens.is_some()
- && status == reqwest::StatusCode::BAD_REQUEST
- && Self::err_indicates_max_output_tokens_unsupported(&error)
- {
- log::info!(
- "[provider] {} rejected max_output_tokens — retrying responses request without it",
- self.name,
- );
- request.max_output_tokens = None;
- retried_without_cap = true;
- continue;
- }
-
- break (status, error);
- };
-
- let status_str = status.as_u16().to_string();
- let sanitized = super::super::sanitize_api_error(&error);
- // Emit the status in the structured `()` position the retry
- // classifier understands (`reliable::structured_http_4xx`). The bare
- // `"… Responses API error: 404 Not Found"` form left the `404`
- // unanchored, so a terminal 404 was misclassified as retryable and
- // looped indefinitely (TAURI-RUST-FJZ, ~15k events).
- let message = format!(
- "{} Responses API error ({status_str}): {sanitized}",
- self.name
- );
- // A 404 from the `/responses` route can mean this endpoint has no
- // Responses API at all — disable the chat-completions-404 →
- // `/responses` fallback for it so we stop issuing a guaranteed second
- // 404. Guard against poisoning the process-global cache on a
- // model/deployment-specific 404 (the route exists, the model
- // doesn't), which would wrongly drop the fallback for every other
- // model on a Responses-capable endpoint. Skip when Responses is the
- // primary path (Codex OAuth): the fallback flag is never consulted
- // and a 404 there is not evidence the route is missing.
- let responses_route_missing = status == reqwest::StatusCode::NOT_FOUND
- && !self.responses_api_primary
- && Self::responses_404_indicates_missing_route(&error);
- if responses_route_missing {
- super::mark_responses_api_unsupported(&self.base_url);
- }
- if responses_route_missing {
- // The `/responses` route 404'd: this endpoint is chat-completions
- // only. We've just cached it unsupported so the fallback won't fire
- // again this process, but the very first probe per process still
- // lands here — and reporting it floods Sentry with one
- // `" Responses API error (404): …"` event per fresh
- // session (TAURI-RUST-5A1, ~900 status=404 events). It is an
- // expected capability-probe miss (the chat-completions path serves
- // the request), not a Sentry-actionable defect, so demote to info.
- log::info!(
- "[provider] {} /responses route 404 — endpoint is chat-completions only; \
- demoting and caching unsupported (fallback disabled henceforth): {}",
- self.name,
- super::super::factory::redact_endpoint(&self.base_url),
- );
- } else if super::super::is_budget_exhausted_http_400(status, &error) {
- super::super::log_budget_exhausted_http_400(
- "responses_api",
- self.name.as_str(),
- Some(model),
- status,
- );
- } else if super::super::is_custom_openai_upstream_bad_request_http_400(
- self.name.as_str(),
- status,
- &error,
- ) {
- super::super::log_custom_openai_upstream_bad_request_http_400(
- "responses_api",
- self.name.as_str(),
- Some(model),
- status,
- );
- } else if super::super::is_provider_access_policy_denied_http_403(status, &error) {
- super::super::log_provider_access_policy_denied_http_403(
- "responses_api",
- self.name.as_str(),
- Some(model),
- status,
- );
- } else if super::super::is_provider_config_rejection_http(
- status,
- self.name.as_str(),
- &error,
- ) {
- super::super::log_provider_config_rejection(
- "responses_api",
- self.name.as_str(),
- Some(model),
- status,
- );
- } else if super::super::is_byo_provider_auth_failure_http(
- self.name.as_str(),
- status,
- &error,
- ) {
- super::super::log_byo_provider_auth_failure(
- "responses_api",
- self.name.as_str(),
- Some(model),
- status,
- );
- } else if super::super::is_openai_oauth_session_expired_http(
- self.name.as_str(),
- status,
- &error,
- ) {
- super::super::log_openai_oauth_session_expired(
- "responses_api",
- self.name.as_str(),
- Some(model),
- status,
- );
- } else if super::super::is_provider_insufficient_credits_402(status, &error) {
- // Insufficient-credits 402: the user's own BYO provider account
- // is out of balance — a flat billing fact, not a reservation-
- // window error, so there is NO local max_tokens lever to apply.
- // Demote to info instead of paging on every retry; this is the
- // complete classification for a genuinely-unpreventable
- // BYO-balance condition
- // (TAURI-RUST-4QF — DeepSeek "Insufficient Balance").
- super::super::log_provider_insufficient_credits_402(
- "responses_api",
- self.name.as_str(),
- Some(model),
- status,
- );
- } else if super::super::is_provider_quota_exhausted(&error) {
- // Codex/ChatGPT OAuth `/responses` plan cap hit
- // (`usage_limit_reached` / "The usage limit has been reached"): a
- // third-party plan limit with no local lever. The subconscious loop
- // retries until `resets_at`, so a single capped Plus user emits
- // hundreds of identical events — demote to info instead of paging on
- // every retry (TAURI-RUST-AFE, extends the #4076/C9A quota machinery
- // to the Responses path).
- super::super::log_provider_quota_exhausted(
- "responses_api",
- self.name.as_str(),
- Some(model),
- status,
- );
- } else if super::super::should_report_provider_http_failure(status) {
- crate::core::observability::report_error(
- message.as_str(),
- "llm_provider",
- "responses_api",
- &[
- ("provider", self.name.as_str()),
- ("model", model),
- ("status", status_str.as_str()),
- ("failure", "non_2xx"),
- ],
- );
- }
- anyhow::bail!(message);
- }
-
- pub(super) fn convert_tool_specs(
- tools: Option<&[crate::openhuman::tools::ToolSpec]>,
- ) -> Option> {
- tools.map(|items| {
- let mut seen: std::collections::HashSet<&str> =
- std::collections::HashSet::with_capacity(items.len());
- let mut dropped: Vec<&str> = Vec::new();
- let mut out: Vec = Vec::with_capacity(items.len());
- for tool in items {
- if !seen.insert(tool.name.as_str()) {
- dropped.push(tool.name.as_str());
- continue;
- }
- out.push(serde_json::json!({
- "type": "function",
- "function": {
- "name": tool.name,
- "description": tool.description,
- "parameters": tool.parameters,
- }
- }));
- }
- if !dropped.is_empty() {
- log::warn!(
- "[providers][compatible] dropped {} duplicate tool spec(s) at wire \
- boundary (TAURI-RUST-2E): {:?}",
- dropped.len(),
- dropped
- );
- }
- out
- })
- }
-
- pub(super) fn convert_messages_for_native(messages: &[ChatMessage]) -> Vec {
- let converted: Vec =
- messages
- .iter()
- .map(|message| {
- let reasoning_content = if message.role == "assistant" {
- message
- .extra_metadata
- .as_ref()
- .and_then(|m| m.get("reasoning_content"))
- .and_then(serde_json::Value::as_str)
- .map(ToString::to_string)
- } else {
- None
- };
-
- if message.role == "assistant" {
- if let Ok(value) =
- serde_json::from_str::(&message.content)
- {
- if let Some(tool_calls_value) = value.get("tool_calls") {
- if let Ok(parsed_calls) =
- serde_json::from_value::>(
- tool_calls_value.clone(),
- )
- {
- let tool_calls = parsed_calls
- .into_iter()
- .map(|tc| ToolCall {
- id: Some(tc.id),
- kind: Some("function".to_string()),
- function: Some(super::compatible_types::Function {
- name: Some(tc.name),
- arguments: Some(serde_json::Value::String(
- tc.arguments,
- )),
- }),
- // Echo Gemini's thought_signature back on
- // the next turn (TAURI-RUST-4PK).
- extra_content: tc.extra_content,
- })
- .collect::>();
-
- let content = Some(MessageContent::Text(
- value
- .get("content")
- .and_then(serde_json::Value::as_str)
- .unwrap_or("")
- .to_string(),
- ));
-
- let reasoning_content = value
- .get("reasoning_content")
- .and_then(serde_json::Value::as_str)
- .filter(|s| !s.trim().is_empty())
- .map(ToString::to_string)
- .or_else(|| reasoning_content.clone());
-
- return NativeMessage {
- role: "assistant".to_string(),
- content,
- tool_call_id: None,
- tool_calls: Some(tool_calls),
- reasoning_content,
- };
- }
- }
- }
- }
-
- if message.role == "tool" {
- if let Ok(value) =
- serde_json::from_str::(&message.content)
- {
- let tool_call_id = value
- .get("tool_call_id")
- .and_then(serde_json::Value::as_str)
- .map(ToString::to_string);
- let content = value
- .get("content")
- .and_then(serde_json::Value::as_str)
- .map(ToString::to_string)
- .or_else(|| Some(message.content.clone()))
- .map(MessageContent::Text);
-
- return NativeMessage {
- role: "tool".to_string(),
- content,
- tool_call_id,
- tool_calls: None,
- reasoning_content: None,
- };
- }
- }
-
- NativeMessage {
- role: message.role.clone(),
- content: Some(MessageContent::from_chat_text(&message.content)),
- tool_call_id: None,
- tool_calls: None,
- reasoning_content,
- }
- })
- .collect();
-
- Self::enforce_tool_message_invariants(converted)
- }
-
- /// Enforce the OpenAI-compatible tool-message ordering invariants on the
- /// fully-serialized wire array, immediately before it goes on the wire.
- ///
- /// Several upstream defects can leave the array malformed and trip a 400
- /// (`messages with role 'tool' must be a response to a preceding message
- /// with 'tool_calls'`). That 400 streams back as an empty completion, which
- /// the agent loop collapses to "The model returned an empty response" and
- /// the chat surface shows as a generic "Something went wrong":
- ///
- /// * **(A)** History tail-trimming cuts *between* an `assistant(tool_calls)`
- /// and its `tool` result, dropping the assistant and orphaning the result.
- /// * **(B)** A persisted assistant tool-call message whose `content` no
- /// longer deserializes as `tool_calls` (format drift) falls through and
- /// is emitted as plain text with its `tool_calls` stripped.
- /// * **(C)** An `assistant(tool_calls)` whose results never arrived leaves
- /// dangling tool-call ids with no matching `tool` response.
- pub(super) fn enforce_tool_message_invariants(
- messages: Vec,
- ) -> Vec {
- use std::collections::HashSet;
-
- let mut out: Vec = Vec::with_capacity(messages.len());
- let mut dropped_orphans = 0usize;
- let mut pruned_calls = 0usize;
-
- let mut iter = messages.into_iter().peekable();
- while let Some(mut msg) = iter.next() {
- if msg.role == "assistant" && msg.tool_calls.is_some() {
- let mut run: Vec = Vec::new();
- while iter.peek().is_some_and(|m| m.role == "tool") {
- run.push(iter.next().expect("peeked tool message"));
- }
- let responded: HashSet =
- run.iter().filter_map(|t| t.tool_call_id.clone()).collect();
-
- let calls = msg.tool_calls.take().unwrap_or_default();
- let before = calls.len();
- let kept: Vec = calls
- .into_iter()
- .filter(|c| c.id.as_deref().is_some_and(|id| responded.contains(id)))
- .collect();
- pruned_calls += before - kept.len();
- let kept_ids: HashSet = kept.iter().filter_map(|c| c.id.clone()).collect();
- msg.tool_calls = if kept.is_empty() { None } else { Some(kept) };
- if msg.tool_calls.is_none() {
- msg.reasoning_content = None;
- }
- out.push(msg);
-
- for tool_msg in run {
- let kept = tool_msg
- .tool_call_id
- .as_deref()
- .is_some_and(|id| kept_ids.contains(id));
- if kept {
- out.push(tool_msg);
- } else {
- dropped_orphans += 1;
- }
- }
- } else if msg.role == "tool" {
- dropped_orphans += 1;
- } else {
- out.push(msg);
- }
- }
-
- if dropped_orphans > 0 || pruned_calls > 0 {
- log::warn!(
- "[provider] sanitized malformed tool-message ordering before send: \
- dropped {dropped_orphans} orphaned tool result(s), pruned {pruned_calls} \
- unanswered tool_call(s)"
- );
- }
-
- out
- }
-
- pub(super) fn with_prompt_guided_tool_instructions(
- messages: &[ChatMessage],
- tools: Option<&[crate::openhuman::tools::ToolSpec]>,
- ) -> Vec {
- let Some(tools) = tools else {
- return messages.to_vec();
- };
-
- if tools.is_empty() {
- return messages.to_vec();
- }
-
- let instructions =
- crate::openhuman::inference::provider::traits::build_tool_instructions_text(tools);
- let mut modified_messages = messages.to_vec();
-
- if let Some(system_message) = modified_messages.iter_mut().find(|m| m.role == "system") {
- if !system_message.content.is_empty() {
- system_message.content.push_str("\n\n");
- }
- system_message.content.push_str(&instructions);
- } else {
- modified_messages.insert(0, ChatMessage::system(instructions));
- }
-
- modified_messages
- }
-
- pub(super) fn parse_native_response(
- api_response: ApiChatResponse,
- provider_name: &str,
- ) -> anyhow::Result {
- let usage = Self::extract_usage(&api_response);
-
- let message = api_response
- .choices
- .into_iter()
- .next()
- .map(|c| c.message)
- .ok_or_else(|| anyhow::anyhow!("No choices in response from {}", provider_name))?;
-
- let mut text = message.effective_content_optional();
- let reasoning_content = message
- .reasoning_content
- .as_deref()
- .map(str::trim)
- .filter(|s| !s.is_empty())
- .map(str::to_owned);
- let mut tool_calls = message
- .tool_calls
- .unwrap_or_default()
- .into_iter()
- .filter_map(|tc| {
- let function = tc.function?;
- let name = function.name?;
- let arguments = normalize_function_arguments(function.arguments);
- Some(ProviderToolCall {
- id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
- name,
- arguments,
- // Preserve Gemini's thought_signature (TAURI-RUST-4PK) so it
- // can be echoed on the next turn; None for providers that
- // don't send extra_content.
- extra_content: tc.extra_content,
- })
- })
- .collect::>();
-
- if tool_calls.is_empty() {
- if let Some(function) = message.function_call.as_ref() {
- if let Some(name) = function
- .name
- .as_ref()
- .filter(|name| !name.trim().is_empty())
- {
- tool_calls.push(ProviderToolCall {
- id: uuid::Uuid::new_v4().to_string(),
- name: name.clone(),
- arguments: normalize_function_arguments(function.arguments.clone()),
- // Legacy `function_call` shape carries no extra_content.
- extra_content: None,
- });
- }
- }
- }
-
- if let Some(content) = message.content.as_deref() {
- if let Some((json_text, json_tool_calls)) = parse_tool_calls_from_content_json(content)
- {
- if !json_tool_calls.is_empty() {
- tool_calls = json_tool_calls;
- text = json_text.or(text);
- }
- }
- }
-
- tracing::debug!(
- has_reasoning_content = reasoning_content.is_some(),
- reasoning_content_chars = reasoning_content.as_ref().map_or(0, |r| r.chars().count()),
- "[provider:parse_native_response] reasoning_content capture"
- );
-
- Ok(ProviderChatResponse {
- text,
- tool_calls,
- usage,
- reasoning_content,
- })
- }
-
- /// Extract usage info from API response, preferring the OpenHuman
- /// metadata block (which includes cache stats and billing) over the
- /// standard OpenAI usage block.
- pub(super) fn extract_usage(resp: &ApiChatResponse) -> Option {
- let oh = resp.openhuman.as_ref();
- let std_usage = resp.usage.as_ref();
-
- if oh.is_none() && std_usage.is_none() {
- return None;
- }
-
- let oh_usage = oh.and_then(|o| o.usage.as_ref());
- let oh_billing = oh.and_then(|o| o.billing.as_ref());
-
- let input_tokens = oh_usage
- .and_then(|u| u.input_tokens)
- .or(std_usage.map(|u| u.prompt_tokens))
- .unwrap_or(0);
- let output_tokens = oh_usage
- .and_then(|u| u.output_tokens)
- .or(std_usage.map(|u| u.completion_tokens))
- .unwrap_or(0);
- let cached_input_tokens = oh_usage
- .and_then(|u| u.cached_input_tokens)
- .or(std_usage
- .and_then(|u| u.prompt_tokens_details.as_ref())
- .map(|d| d.cached_tokens))
- .unwrap_or(0);
- let charged_amount_usd = oh_billing.map(|b| b.charged_amount_usd).unwrap_or(0.0);
-
- let from_openhuman = oh_usage.is_some();
- let from_standard = std_usage.is_some() && !from_openhuman;
- let has_billing = oh_billing.is_some();
- tracing::debug!(
- from_openhuman,
- from_standard,
- has_billing,
- input_tokens,
- output_tokens,
- cached_input_tokens,
- charged_amount_usd,
- "[provider:usage] extract_usage resolved token counts"
- );
-
- Some(ProviderUsageInfo {
- input_tokens,
- output_tokens,
- context_window: 0,
- cached_input_tokens,
- cache_creation_tokens: 0,
- reasoning_tokens: 0,
- charged_amount_usd,
- })
- }
-
- pub(super) fn is_native_tool_schema_unsupported(
- status: reqwest::StatusCode,
- error: &str,
- ) -> bool {
- if !matches!(
- status,
- reqwest::StatusCode::BAD_REQUEST | reqwest::StatusCode::UNPROCESSABLE_ENTITY
- ) {
- return false;
- }
-
- let lower = error.to_lowercase();
- [
- "unknown parameter: tools",
- "unsupported parameter: tools",
- "unrecognized field `tools`",
- "does not support tools",
- "function calling is not supported",
- "tool_choice",
- ]
- .iter()
- .any(|hint| lower.contains(hint))
- }
-
- pub(super) fn err_supports_no_tools_retry(error: &str) -> bool {
- Self::is_native_tool_schema_unsupported(reqwest::StatusCode::BAD_REQUEST, error)
- }
-
- /// Detect a provider rejecting the `frequency_penalty` sampling field. Some
- /// strict OpenAI-compatible backends 400 on unknown params; when this fires
- /// the caller retries once with the field omitted (mirrors the no-tools
- /// retry). String-based because the streamed transport error surfaces the
- /// API error body.
- pub(super) fn err_indicates_frequency_penalty_unsupported(error: &str) -> bool {
- let lower = error.to_lowercase();
- lower.contains("frequency_penalty")
- && (lower.contains("unsupported")
- || lower.contains("unknown")
- || lower.contains("unrecognized")
- || lower.contains("not supported")
- || lower.contains("does not support")
- || lower.contains("invalid")
- || lower.contains("unexpected"))
- }
-
- /// Detect a Responses backend rejecting the `max_output_tokens` field as an
- /// *unrecognized parameter*. The Codex/ChatGPT OAuth endpoint 400s with
- /// `Unsupported parameter: max_output_tokens` (TAURI-RUST-EWD); when this
- /// fires the responses path retries once with the field omitted (mirrors the
- /// no-tools and frequency_penalty retries). String-based because the
- /// rejection surfaces as the API error body.
- ///
- /// Deliberately matches only *parameter-not-accepted* wording, **not**
- /// invalid-value wording (e.g. "value above the allowed range"). Stripping
- /// the cap turns a bounded request into an uncapped generation, so on a
- /// value-range error we must surface the config error rather than silently
- /// drop the credit-preflight / response-size protection `max_tokens` gives.
- pub(super) fn err_indicates_max_output_tokens_unsupported(error: &str) -> bool {
- let lower = error.to_lowercase();
- lower.contains("max_output_tokens")
- && (lower.contains("unsupported")
- || lower.contains("unknown")
- || lower.contains("unrecognized")
- || lower.contains("not supported")
- || lower.contains("does not support")
- || lower.contains("unexpected parameter"))
- }
-
- /// Disambiguate a 404 from the `/responses` route: `true` when it signals the
- /// *route itself* is absent (this endpoint has no Responses API), `false` when
- /// it looks model/deployment-specific (the route exists, that model doesn't).
- ///
- /// Only a missing-route 404 should disable the fallback for the whole endpoint
- /// (TAURI-RUST-FJZ). A bad-model 404 must NOT poison the process-global cache,
- /// or a single bad model would drop the `/responses` fallback for every other
- /// model on a Responses-capable endpoint. Conservative: any mention of a
- /// model/deployment keeps the fallback enabled.
- pub(super) fn responses_404_indicates_missing_route(error: &str) -> bool {
- let lower = error.to_lowercase();
- !(lower.contains("model") || lower.contains("deployment"))
- }
-
- /// Detect a 404 whose body says the model is completion-only. See issue #3193.
- pub(super) fn is_completion_only_model_404(status: reqwest::StatusCode, error: &str) -> bool {
- if status != reqwest::StatusCode::NOT_FOUND {
- return false;
- }
- let lower = error.to_lowercase();
- lower.contains("not a chat model")
- || (lower.contains("v1/chat/completions") && lower.contains("v1/completions"))
- }
-
- /// Detect a model rejected because it has no chat capability. See Sentry TAURI-RUST-4P6.
- pub(super) fn is_not_chat_capable_model(status: reqwest::StatusCode, error: &str) -> bool {
- if !matches!(
- status,
- reqwest::StatusCode::BAD_REQUEST | reqwest::StatusCode::UNPROCESSABLE_ENTITY
- ) {
- return false;
- }
- error.to_lowercase().contains("does not support chat")
- }
-
- pub(super) fn completion_only_model_message(&self, model: &str, sanitized: &str) -> String {
- format!(
- "{name} API error (404): model '{model}' does not support the \
- chat-completions API that OpenHuman uses — it appears to be a \
- completion-only / base model. Assign a chat-capable model to this \
- provider (e.g. in Connections → API keys → LLM), or pick a different model. \
- Provider detail: {sanitized}",
- name = self.name,
- )
- }
-
- /// Guard shared by every chat-completions 404 handler. See issue #3193.
- pub(super) fn completion_only_404_guard(
- &self,
- status: reqwest::StatusCode,
- sanitized: &str,
- model: &str,
- ) -> Option {
- if Self::is_completion_only_model_404(status, sanitized) {
- Some(anyhow::anyhow!(
- self.completion_only_model_message(model, sanitized)
- ))
- } else {
- None
- }
- }
-
- pub(super) fn not_chat_capable_model_message(&self, model: &str, sanitized: &str) -> String {
- format!(
- "{name} API error: model '{model}' does not support chat — it \
- appears to be an embedding or non-chat model. Assign a \
- chat-capable model to this provider (e.g. in Connections → API keys → LLM), or \
- pick a different model. Provider detail: {sanitized}",
- name = self.name,
- )
- }
-
- /// Guard shared by every chat-completions error handler. See Sentry TAURI-RUST-4P6.
- pub(super) fn not_chat_capable_guard(
- &self,
- status: reqwest::StatusCode,
- sanitized: &str,
- model: &str,
- ) -> Option {
- if Self::is_not_chat_capable_model(status, sanitized) {
- Some(anyhow::anyhow!(
- self.not_chat_capable_model_message(model, sanitized)
- ))
- } else {
- None
- }
- }
-
- pub(super) fn enrich_404_message(&self, base: String, status: reqwest::StatusCode) -> String {
- if status == reqwest::StatusCode::NOT_FOUND && !self.supports_responses_fallback {
- format!(
- "{base}; check that your endpoint URL is correct \
- and the model name exists on your provider"
- )
- } else {
- base
- }
- }
-}
diff --git a/src/openhuman/inference/provider/compatible_parse.rs b/src/openhuman/inference/provider/compatible_parse.rs
deleted file mode 100644
index 6c38c307f..000000000
--- a/src/openhuman/inference/provider/compatible_parse.rs
+++ /dev/null
@@ -1,423 +0,0 @@
-//! Parsing and response-extraction free functions for the OpenAI-compatible provider.
-//!
-//! All functions here are stateless transforms — no I/O, no HTTP. They take
-//! raw strings or deserialized values and return structured results.
-
-use crate::openhuman::inference::provider::traits::{
- ChatMessage, StreamError, StreamResult, ToolCall as ProviderToolCall,
-};
-
-use super::compatible_types::{
- ApiChatResponse, ResponsesContentPart, ResponsesInput, ResponsesResponse, StreamChunkResponse,
-};
-
-// ── Think-tag stripping ───────────────────────────────────────────────────────
-
-/// Remove `...` blocks from model output.
-/// Some reasoning models (e.g. MiniMax) embed their chain-of-thought inline
-/// in the `content` field rather than a separate `reasoning_content` field.
-/// The resulting `` tags must be stripped before returning to the user.
-pub(crate) fn strip_think_tags(s: &str) -> String {
- let mut result = String::with_capacity(s.len());
- let mut rest = s;
- loop {
- if let Some(start) = rest.find("") {
- result.push_str(&rest[..start]);
- if let Some(end) = rest[start..].find("") {
- rest = &rest[start + end + "".len()..];
- } else {
- // Unclosed tag: drop the rest to avoid leaking partial reasoning.
- break;
- }
- } else {
- result.push_str(rest);
- break;
- }
- }
- result.trim().to_string()
-}
-
-// ── SSE line parser ───────────────────────────────────────────────────────────
-
-/// Parse a single SSE (Server-Sent Events) line from OpenAI-compatible providers.
-/// Handles the `data: {...}` format and `[DONE]` sentinel.
-pub(crate) fn parse_sse_line(line: &str) -> StreamResult