mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Test subagent cache reuse and chat tier routing (#3389)
This commit is contained in:
@@ -39,8 +39,8 @@ const MODEL_HINTS = [
|
||||
];
|
||||
const MODEL_TIERS = [
|
||||
'reasoning-v1',
|
||||
'reasoning-quick-v1',
|
||||
'chat-v1',
|
||||
'reasoning-quick-v1',
|
||||
'agentic-v1',
|
||||
'coding-v1',
|
||||
'summarization-v1',
|
||||
|
||||
@@ -69,9 +69,16 @@ pub const PRICING_TABLE: &[ModelPricing] = &[
|
||||
cached_input_per_mtok_usd: 1.50,
|
||||
output_per_mtok_usd: 75.00,
|
||||
},
|
||||
// Quick reasoning tier — Kimi K2.6 Turbo on Fireworks (backend PR
|
||||
// #760). Low TTFT, 128k context, `supportsThinking: false`. Rates
|
||||
// track Fireworks' published Kimi turbo pricing at time of writing.
|
||||
// Chat tier — Kimi K2.6 Turbo on Fireworks (backend PR #760).
|
||||
// Low TTFT, 128k context, `supportsThinking: false`. Rates track
|
||||
// Fireworks' published Kimi turbo pricing at time of writing.
|
||||
ModelPricing {
|
||||
model: "chat-v1",
|
||||
input_per_mtok_usd: 0.60,
|
||||
cached_input_per_mtok_usd: 0.06,
|
||||
output_per_mtok_usd: 2.50,
|
||||
},
|
||||
// Legacy chat tier slug retained for older transcripts/configs.
|
||||
ModelPricing {
|
||||
model: "reasoning-quick-v1",
|
||||
input_per_mtok_usd: 0.60,
|
||||
|
||||
@@ -120,7 +120,7 @@ subagents = [
|
||||
|
||||
[model]
|
||||
# Front-line conversational agent: TTFT dominates UX. `hint:chat` resolves
|
||||
# to the fast chat tier (`reasoning-quick-v1` / Kimi K2.6 Turbo on
|
||||
# to the fast chat tier (`chat-v1` / Kimi K2.6 Turbo on
|
||||
# Fireworks via backend PR #760, 128k context, `supportsThinking: false`).
|
||||
# The orchestrator is a planner/router that picks a delegate and
|
||||
# synthesises sub-agent output — workload that doesn't benefit from the
|
||||
|
||||
@@ -150,7 +150,8 @@ impl Default for CostConfig {
|
||||
/// Default pricing for popular models (USD per 1M tokens)
|
||||
fn get_default_pricing() -> HashMap<String, ModelPricing> {
|
||||
use super::types::{
|
||||
MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1,
|
||||
MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1,
|
||||
MODEL_REASONING_V1,
|
||||
};
|
||||
|
||||
let mut prices = HashMap::new();
|
||||
@@ -163,6 +164,13 @@ fn get_default_pricing() -> HashMap<String, ModelPricing> {
|
||||
},
|
||||
);
|
||||
// Kimi K2.6 Turbo on Fireworks — see backend PR #760.
|
||||
prices.insert(
|
||||
MODEL_CHAT_V1.into(),
|
||||
ModelPricing {
|
||||
input: 0.60,
|
||||
output: 2.50,
|
||||
},
|
||||
);
|
||||
prices.insert(
|
||||
MODEL_REASONING_QUICK_V1.into(),
|
||||
ModelPricing {
|
||||
|
||||
@@ -9,32 +9,19 @@ use std::path::PathBuf;
|
||||
/// Standard model identifiers matching the backend model registry.
|
||||
pub const MODEL_AGENTIC_V1: &str = "agentic-v1";
|
||||
pub const MODEL_REASONING_V1: &str = "reasoning-v1";
|
||||
/// Conversational tier (deprecated — retired from the backend strict model
|
||||
/// registry in migration 2→3). Do not use for new sessions; the backend now
|
||||
/// returns 400 for threads that send `chat-v1`. Retained here only for
|
||||
/// migration code that needs to identify and replace the old model identifier.
|
||||
/// Use [`MODEL_REASONING_QUICK_V1`] or [`DEFAULT_MODEL`] instead.
|
||||
/// Low-latency conversational tier.
|
||||
pub const MODEL_CHAT_V1: &str = "chat-v1";
|
||||
/// Low-latency chat tier. Backend maps this to Kimi K2.6 Turbo on
|
||||
/// Fireworks (128k context, `supportsThinking: false`) — tuned for
|
||||
/// time-to-first-token on conversational turns. See backend PR #760.
|
||||
/// The orchestrator (user-facing front-line agent) rides on this tier
|
||||
/// by default (via `hint:chat`) so chat responses feel snappy; reach
|
||||
/// for the slower `reasoning-v1` (DeepSeek V4 Pro) only when deep
|
||||
/// reasoning is needed.
|
||||
/// Legacy low-latency chat tier slug retained for older persisted configs.
|
||||
pub const MODEL_REASONING_QUICK_V1: &str = "reasoning-quick-v1";
|
||||
pub const MODEL_CODING_V1: &str = "coding-v1";
|
||||
pub const MODEL_SUMMARIZATION_V1: &str = "summarization-v1";
|
||||
/// Default model used when no explicit model is configured.
|
||||
///
|
||||
/// Set to `reasoning-quick-v1` (Kimi K2.6 Turbo on Fireworks — low-latency,
|
||||
/// 128k context, tuned for time-to-first-token). `chat-v1` was the previous
|
||||
/// value here but was retired from the backend strict model registry; new
|
||||
/// session threads that sent `chat-v1` received a 400 error. Existing threads
|
||||
/// had it silently remapped to `reasoning-v1` by the backend, but sub-agent
|
||||
/// spawns (new threads) failed. Migration 2 → 3 (`retire_chat_v1_model`)
|
||||
/// upgrades any persisted `config.toml` that still holds `chat-v1`.
|
||||
pub const DEFAULT_MODEL: &str = MODEL_REASONING_QUICK_V1;
|
||||
/// Set to `chat-v1`, the backend's low-latency conversational tier. The
|
||||
/// orchestrator (user-facing front-line agent) rides on this tier by default
|
||||
/// via `hint:chat`; reach for the slower `reasoning-v1` only when deep
|
||||
/// reasoning is needed.
|
||||
pub const DEFAULT_MODEL: &str = MODEL_CHAT_V1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ModelRegistryEntry {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! metadata is not yet available.
|
||||
|
||||
use crate::openhuman::config::{
|
||||
MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1,
|
||||
MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1,
|
||||
};
|
||||
|
||||
/// Conservative default for OpenHuman abstract tier models (tokens).
|
||||
@@ -99,7 +99,7 @@ fn tier_context_window(model: &str) -> Option<u64> {
|
||||
match model {
|
||||
MODEL_REASONING_V1 | MODEL_AGENTIC_V1 | MODEL_CODING_V1 => Some(TIER_LARGE_CONTEXT),
|
||||
"summarization-v1" => Some(TIER_SUMMARIZATION_CONTEXT),
|
||||
MODEL_REASONING_QUICK_V1 | "chat" => Some(TIER_STANDARD_CONTEXT),
|
||||
MODEL_CHAT_V1 | MODEL_REASONING_QUICK_V1 | "chat" => Some(TIER_STANDARD_CONTEXT),
|
||||
m if m.starts_with("gemma") || m.contains(":1b") || m.contains("270m") => {
|
||||
Some(TIER_LOCAL_CONTEXT)
|
||||
}
|
||||
@@ -179,6 +179,7 @@ mod tests {
|
||||
fn tier_aliases_resolve() {
|
||||
assert_eq!(context_window_for_model("reasoning-v1"), Some(200_000));
|
||||
assert_eq!(context_window_for_model("agentic-v1"), Some(200_000));
|
||||
assert_eq!(context_window_for_model("chat-v1"), Some(128_000));
|
||||
assert_eq!(
|
||||
context_window_for_model("reasoning-quick-v1"),
|
||||
Some(128_000)
|
||||
|
||||
@@ -62,7 +62,8 @@ pub const BYOK_INCOMPLETE_SENTINEL: &str = "__byok_incomplete__";
|
||||
|
||||
fn is_abstract_tier_model(model: &str) -> bool {
|
||||
use crate::openhuman::config::{
|
||||
MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1,
|
||||
MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1,
|
||||
MODEL_REASONING_V1,
|
||||
};
|
||||
// No dedicated constant for the summarization tier yet; keep the literal
|
||||
// in sync with the tier name used by the summarizer sub-agent.
|
||||
@@ -70,6 +71,7 @@ fn is_abstract_tier_model(model: &str) -> bool {
|
||||
let trimmed = model.trim();
|
||||
trimmed == MODEL_REASONING_V1
|
||||
|| trimmed == MODEL_REASONING_QUICK_V1
|
||||
|| trimmed == MODEL_CHAT_V1
|
||||
|| trimmed == MODEL_AGENTIC_V1
|
||||
|| trimmed == MODEL_CODING_V1
|
||||
|| trimmed == MODEL_SUMMARIZATION_V1
|
||||
@@ -91,17 +93,17 @@ pub fn auth_key_for_slug(slug: &str) -> String {
|
||||
pub fn resolve_model_for_hint(hint_or_tier: &str, config: &Config) -> String {
|
||||
let hint_to_tier: &[(&str, &str)] = &[
|
||||
("reasoning", crate::openhuman::config::MODEL_REASONING_V1),
|
||||
("chat", crate::openhuman::config::MODEL_REASONING_QUICK_V1),
|
||||
("chat", crate::openhuman::config::MODEL_CHAT_V1),
|
||||
("agentic", crate::openhuman::config::MODEL_AGENTIC_V1),
|
||||
("coding", crate::openhuman::config::MODEL_CODING_V1),
|
||||
("summarization", "summarization-v1"),
|
||||
];
|
||||
let tier_to_role: &[(&str, &str)] = &[
|
||||
(crate::openhuman::config::MODEL_REASONING_V1, "reasoning"),
|
||||
(crate::openhuman::config::MODEL_CHAT_V1, "chat"),
|
||||
(crate::openhuman::config::MODEL_REASONING_QUICK_V1, "chat"),
|
||||
(crate::openhuman::config::MODEL_AGENTIC_V1, "agentic"),
|
||||
(crate::openhuman::config::MODEL_CODING_V1, "coding"),
|
||||
("chat-v1", "chat"),
|
||||
("summarization-v1", "summarization"),
|
||||
];
|
||||
|
||||
@@ -616,7 +618,7 @@ fn make_openhuman_backend(config: &Config) -> anyhow::Result<(Box<dyn Provider>,
|
||||
// "deepseek-v4-pro", "claude-opus-4-7") fall back to the platform default.
|
||||
let model = match model.strip_prefix("hint:") {
|
||||
Some("reasoning") => crate::openhuman::config::MODEL_REASONING_V1.to_string(),
|
||||
Some("chat") => crate::openhuman::config::MODEL_REASONING_QUICK_V1.to_string(),
|
||||
Some("chat") => crate::openhuman::config::MODEL_CHAT_V1.to_string(),
|
||||
Some("agentic") => crate::openhuman::config::MODEL_AGENTIC_V1.to_string(),
|
||||
Some("coding") => crate::openhuman::config::MODEL_CODING_V1.to_string(),
|
||||
Some("summarization") => crate::openhuman::config::MODEL_SUMMARIZATION_V1.to_string(),
|
||||
|
||||
@@ -1772,10 +1772,7 @@ fn resolve_model_for_hint_maps_known_hints_to_tiers() {
|
||||
resolve_model_for_hint("hint:reasoning", &config),
|
||||
"reasoning-v1"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_model_for_hint("hint:chat", &config),
|
||||
"reasoning-quick-v1"
|
||||
);
|
||||
assert_eq!(resolve_model_for_hint("hint:chat", &config), "chat-v1");
|
||||
assert_eq!(
|
||||
resolve_model_for_hint("hint:agentic", &config),
|
||||
"agentic-v1"
|
||||
|
||||
@@ -3,13 +3,14 @@ use super::Provider;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Maps OpenHuman's abstract tier model names (`reasoning-v1`,
|
||||
/// Maps OpenHuman's abstract tier model names (`reasoning-v1`, `chat-v1`,
|
||||
/// `reasoning-quick-v1`, `agentic-v1`, `coding-v1`, `summarization-v1`)
|
||||
/// to the hint slot in `model_routes`. Returns `None` for any model the
|
||||
/// router shouldn't rewrite.
|
||||
fn openhuman_tier_to_hint(model: &str) -> Option<&'static str> {
|
||||
match model {
|
||||
"reasoning-v1" => Some("reasoning"),
|
||||
"chat-v1" => Some("chat"),
|
||||
"reasoning-quick-v1" => Some("chat"),
|
||||
"agentic-v1" => Some("agentic"),
|
||||
"coding-v1" => Some("coding"),
|
||||
@@ -90,8 +91,8 @@ impl RouterProvider {
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `hint:<name>` — direct hint lookup (e.g. `hint:reasoning`).
|
||||
/// 2. OpenHuman abstract tier names — `reasoning-v1`, `agentic-v1`,
|
||||
/// `coding-v1`, `summarization-v1` map onto the corresponding hints
|
||||
/// 2. OpenHuman abstract tier names — `reasoning-v1`, `chat-v1`,
|
||||
/// `agentic-v1`, `coding-v1`, `summarization-v1` map onto the corresponding hints
|
||||
/// so a custom provider gets the user-configured model id instead of
|
||||
/// the literal tier name (which is only meaningful to the OpenHuman
|
||||
/// backend and would 404 on OpenAI/Anthropic/etc.).
|
||||
|
||||
@@ -199,7 +199,7 @@ fn resolve_translates_openhuman_tier_aliases_via_route_table() {
|
||||
assert_eq!(reasoning_idx, 1);
|
||||
assert_eq!(reasoning_model, "gpt-5.5");
|
||||
|
||||
let (chat_idx, chat_model) = router.resolve("reasoning-quick-v1");
|
||||
let (chat_idx, chat_model) = router.resolve("chat-v1");
|
||||
assert_eq!(chat_idx, 1);
|
||||
assert_eq!(chat_model, "gpt-5.5-mini");
|
||||
|
||||
@@ -249,6 +249,7 @@ fn every_tier_alias_falls_back_to_default_model_when_unrouted() {
|
||||
|
||||
for alias in [
|
||||
"reasoning-v1",
|
||||
"chat-v1",
|
||||
"reasoning-quick-v1",
|
||||
"agentic-v1",
|
||||
"coding-v1",
|
||||
|
||||
@@ -19,7 +19,7 @@ Startup data-migration runner gated by `Config::schema_version`. Each migration
|
||||
| `src/openhuman/migrations/mod.rs` | Module docstring + the `run_pending` runner and `CURRENT_SCHEMA_VERSION` constant; declares each migration as a private `mod`. |
|
||||
| `src/openhuman/migrations/phase_out_profile_md.rs` | **0→1.** Deletes `{workspace}/PROFILE.md` and strips `### PROFILE.md` blocks from persisted JSONL session transcripts (`session_raw/**`) and `.md` companions (`sessions/**`). Blocking fs I/O. |
|
||||
| `src/openhuman/migrations/unify_ai_provider_settings.rs` | **1→2.** Consolidates scattered AI-provider settings into per-workload provider strings; seeds `cloud_providers` (always an `Openhuman` entry) and migrates a non-backend `inference_url` into a `Custom` cloud entry. Pure in-memory. |
|
||||
| `src/openhuman/migrations/retire_chat_v1_model.rs` | **2→3.** Remaps a persisted `default_model = "chat-v1"` to `"reasoning-quick-v1"` (backend dropped `chat-v1` from its strict registry; sub-agent spawns 400'd). Pure in-memory. |
|
||||
| `src/openhuman/migrations/retire_chat_v1_model.rs` | **2→3.** Legacy chat-v1 migration hook retained for schema-version progression. No longer remaps `chat-v1`, which is the canonical low-latency chat slug again. Pure in-memory. |
|
||||
| `src/openhuman/migrations/expand_autonomy_defaults.rs` | **3→4.** Additively merges expanded `autonomy.allowed_commands` / `auto_approve` defaults (PR #2500) and bumps `max_actions_per_hour` from the old `20` to `u32::MAX` only when still exactly `20`. Pure in-memory. |
|
||||
| `src/openhuman/migrations/remove_write_auto_approve.rs` | **4→5.** Removes `file_write` / `edit_file` from `autonomy.auto_approve` so Supervised mode resumes its ask-before-edit prompt. Pure in-memory. |
|
||||
| `src/openhuman/migrations/repair_http_request_limits.rs` | **5→6 (a).** Coerces stale-zero `[http_request]` `timeout_secs` / `max_response_size` to schema defaults (30s / 1 MB); a persisted `0` is an instant timeout / empty-body cap that serde defaults don't repair. Pure in-memory. |
|
||||
@@ -55,7 +55,7 @@ Does not own a `store.rs`. Its effects are written through other layers:
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `crate::openhuman::config` (`Config`, `HttpRequestConfig`, `schema::cloud_providers::{CloudProviderCreds, AuthStyle, CloudProviderType, generate_provider_id}`, `schema::{MODEL_CHAT_V1, MODEL_REASONING_QUICK_V1}`) — the migrations read and mutate the config struct; `schema_version` is the gating field.
|
||||
- `crate::openhuman::config` (`Config`, `HttpRequestConfig`, `schema::cloud_providers::{CloudProviderCreds, AuthStyle, CloudProviderType, generate_provider_id}`, `schema::MODEL_CHAT_V1`) — the migrations read and mutate the config struct; `schema_version` is the gating field.
|
||||
- `crate::openhuman::agent::harness::session::transcript` (`transcript`, `SessionTranscript`, `write_transcript`) — `phase_out_profile_md` parses and rewrites persisted session transcripts byte-compatibly.
|
||||
- `crate::openhuman::inference::provider::factory` — `reconcile_orphaned_providers` mirrors the factory's exact, case-sensitive provider-string grammar so "resolvable here" matches "resolvable at inference time"; `unify_ai_provider_settings` references the factory's provider-string format. (`inference::provider::ChatMessage` is imported only by tests.)
|
||||
|
||||
|
||||
@@ -147,10 +147,9 @@ pub async fn run_pending(config: &mut Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2 -> 3: retire `chat-v1` as the default model. The backend removed
|
||||
// `chat-v1` from its strict model registry; sub-agent spawns (new
|
||||
// threads) that sent this literal model ID received a 400. Remap any
|
||||
// persisted `default_model = "chat-v1"` to `"reasoning-quick-v1"`.
|
||||
// 2 -> 3: legacy chat-v1 migration hook retained for schema-version
|
||||
// progression. `chat-v1` is now the canonical low-latency chat slug, so
|
||||
// the migration no longer rewrites default_model.
|
||||
// Guard on `== 2` so a failed 1→2 migration doesn't skip this step.
|
||||
if config.schema_version == 2 {
|
||||
match retire_chat_v1_model::run(config) {
|
||||
|
||||
@@ -1,56 +1,36 @@
|
||||
//! Migration 2 → 3: retire `chat-v1` as the default model.
|
||||
//! Migration 2 → 3: legacy `chat-v1` default-model migration.
|
||||
//!
|
||||
//! The backend removed `chat-v1` from its strict model registry. New inference
|
||||
//! threads (sub-agent spawns) that sent the literal `"chat-v1"` model ID
|
||||
//! received a 400 error ("Model 'chat-v1' is not available"), while existing
|
||||
//! session threads continued to work because the backend silently remapped
|
||||
//! `chat-v1` → `reasoning-v1` for backward compatibility on old threads only.
|
||||
//!
|
||||
//! This migration upgrades any workspace whose `config.default_model` is still
|
||||
//! `"chat-v1"` to `"reasoning-quick-v1"` — the same Kimi K2.6 Turbo backend
|
||||
//! model that `chat-v1` was previously aliased to. The code constant
|
||||
//! `DEFAULT_MODEL` was updated in the same change.
|
||||
//! `chat-v1` is once again the canonical low-latency conversational tier. This
|
||||
//! migration is retained as a no-op so already-upgraded workspaces keep their
|
||||
//! schema-version progression without rewriting the model slug away from
|
||||
//! `chat-v1`.
|
||||
//!
|
||||
//! ## Behaviour
|
||||
//!
|
||||
//! - Pure in-memory mutation of `Config`. The caller (`migrations::run_pending`)
|
||||
//! persists the result via `Config::save()` and bumps `schema_version`.
|
||||
//! - Idempotent: only remaps when `default_model == Some("chat-v1")`.
|
||||
//! - Idempotent: does not remap `default_model`.
|
||||
//! - Does not touch any other config fields, API keys, or session files.
|
||||
|
||||
use crate::openhuman::config::schema::MODEL_CHAT_V1;
|
||||
use crate::openhuman::config::schema::MODEL_REASONING_QUICK_V1;
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
/// Counters returned by [`run`] for diagnostics.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct MigrationStats {
|
||||
/// `true` when `default_model` was remapped from `chat-v1`.
|
||||
/// Always false; retained for backward-compatible migration telemetry.
|
||||
pub default_model_remapped: bool,
|
||||
}
|
||||
|
||||
/// Run the `chat-v1` retirement migration on the given `Config`.
|
||||
/// Run the legacy `chat-v1` migration hook on the given `Config`.
|
||||
///
|
||||
/// Synchronous — pure config mutation, no I/O. Caller persists via
|
||||
/// `Config::save()` once `schema_version` is also bumped.
|
||||
pub fn run(config: &mut Config) -> anyhow::Result<MigrationStats> {
|
||||
let mut stats = MigrationStats::default();
|
||||
|
||||
if config.default_model.as_deref() == Some(MODEL_CHAT_V1) {
|
||||
log::info!(
|
||||
"[migrations][retire-chat-v1] remapping default_model chat-v1 -> {}",
|
||||
MODEL_REASONING_QUICK_V1
|
||||
);
|
||||
config.default_model = Some(MODEL_REASONING_QUICK_V1.to_string());
|
||||
stats.default_model_remapped = true;
|
||||
} else {
|
||||
log::debug!(
|
||||
"[migrations][retire-chat-v1] default_model={:?} — no remap needed",
|
||||
config.default_model
|
||||
);
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
log::debug!(
|
||||
"[migrations][retire-chat-v1] default_model={:?} — no remap needed",
|
||||
config.default_model
|
||||
);
|
||||
Ok(MigrationStats::default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -59,17 +39,17 @@ mod tests {
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
#[test]
|
||||
fn remaps_chat_v1_to_reasoning_quick_v1() {
|
||||
fn leaves_chat_v1_default_model_unchanged() {
|
||||
let mut config = Config::default();
|
||||
config.default_model = Some("chat-v1".to_string());
|
||||
|
||||
let stats = run(&mut config).expect("migration should succeed");
|
||||
|
||||
assert!(stats.default_model_remapped);
|
||||
assert!(!stats.default_model_remapped);
|
||||
assert_eq!(
|
||||
config.default_model.as_deref(),
|
||||
Some("reasoning-quick-v1"),
|
||||
"default_model must be remapped"
|
||||
Some("chat-v1"),
|
||||
"chat-v1 is the canonical chat tier and must not be remapped"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Intelligent model routing — a policy-driven layer that sits between callers (a
|
||||
- Probe and cache local model-server health (`GET {base}/api/tags` for Ollama, `GET {base}/models` for OpenAI-compat backends) with a 30 s TTL and 3 s probe timeout.
|
||||
- On a local primary: dispatch locally, and on error **or** low-quality output retry on remote — unless `privacy_required` forbids leaving the device.
|
||||
- Heuristically score local responses for low quality (length floor, empty-noise utterances, refusal phrases) to drive fallback.
|
||||
- Normalize heavy `hint:*` model strings to backend-valid model IDs (`reasoning` → `MODEL_REASONING_V1`, `chat` → `MODEL_REASONING_QUICK_V1`, `agentic` → `MODEL_AGENTIC_V1`, `coding` → `MODEL_CODING_V1`).
|
||||
- Normalize heavy `hint:*` model strings to backend-valid model IDs (`reasoning` -> `MODEL_REASONING_V1`, `chat` -> `MODEL_CHAT_V1`, `agentic` -> `MODEL_AGENTIC_V1`, `coding` -> `MODEL_CODING_V1`).
|
||||
- Force remote when native tool-calling is required (tools present) and refuse to silently bypass local routing for streaming.
|
||||
- Emit a structured `RoutingRecord` (category, target, resolved model, health, fallback flag, latency, tokens, cost) per completed call.
|
||||
|
||||
@@ -60,7 +60,7 @@ None (no `store.rs`). The only state is the in-memory, TTL'd health cache inside
|
||||
|
||||
- `LocalAiConfig` (`openhuman::config`): `runtime_enabled`, `provider`, `base_url`, `api_key`, `chat_model_id` drive local-provider selection and whether local routing is active at all.
|
||||
- `OPENHUMAN_LOCAL_INFERENCE_URL` (env): full `/v1` base URL of a local OpenAI-compatible server; when set, takes precedence over `config.base_url` and switches the health probe to `GET {base}/models`.
|
||||
- Model-ID constants from `openhuman::config`: `MODEL_REASONING_V1`, `MODEL_REASONING_QUICK_V1`, `MODEL_AGENTIC_V1`, `MODEL_CODING_V1`.
|
||||
- Model-ID constants from `openhuman::config`: `MODEL_REASONING_V1`, `MODEL_CHAT_V1`, `MODEL_REASONING_QUICK_V1` (legacy), `MODEL_AGENTIC_V1`, `MODEL_CODING_V1`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::openhuman::config::{
|
||||
MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1,
|
||||
MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_V1,
|
||||
};
|
||||
use crate::openhuman::inference::provider::traits::{
|
||||
ChatMessage, ChatRequest, ChatResponse, Provider, ProviderCapabilities, StreamChunk,
|
||||
@@ -96,10 +96,8 @@ impl IntelligentRoutingProvider {
|
||||
// Keep remote model naming aligned with backend modelRegistry.
|
||||
match requested_model.strip_prefix("hint:") {
|
||||
Some("reasoning") => MODEL_REASONING_V1.to_string(),
|
||||
// Orchestrator's low-TTFT chat tier — Kimi K2.6 Turbo on the
|
||||
// backend's `reasoning-quick-v1`. Backend support added in
|
||||
// tinyhumansai/backend#760.
|
||||
Some("chat") => MODEL_REASONING_QUICK_V1.to_string(),
|
||||
// Orchestrator's low-TTFT chat tier.
|
||||
Some("chat") => MODEL_CHAT_V1.to_string(),
|
||||
Some("agentic") => MODEL_AGENTIC_V1.to_string(),
|
||||
Some("coding") => MODEL_CODING_V1.to_string(),
|
||||
_ => requested_model.to_string(),
|
||||
|
||||
@@ -388,7 +388,7 @@ async fn regression_reasoning_hint_routes_remote_with_backend_model_name() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_chat_hint_routes_remote_as_reasoning_quick_v1() {
|
||||
async fn regression_chat_hint_routes_remote_as_chat_v1() {
|
||||
let local = MockProvider::new("local", "l");
|
||||
let remote = MockProvider::new("remote", "r");
|
||||
let health = LocalHealthChecker::seeded(true);
|
||||
@@ -403,10 +403,10 @@ async fn regression_chat_hint_routes_remote_as_reasoning_quick_v1() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// hint:chat must be translated to the backend's reasoning-quick-v1 tier
|
||||
// (Kimi K2.6 Turbo). Sending the literal "hint:chat" would 400 on the
|
||||
// backend since modelRegistry has no `hint:*` aliases.
|
||||
assert_eq!(remote.last_model(), "reasoning-quick-v1");
|
||||
// hint:chat must be translated to the backend's chat-v1 tier. Sending the
|
||||
// literal "hint:chat" would 400 on the backend since modelRegistry has no
|
||||
// `hint:*` aliases.
|
||||
assert_eq!(remote.last_model(), "chat-v1");
|
||||
assert_eq!(local.calls(), 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use openhuman_core::openhuman::agent::dispatcher::NativeToolDispatcher;
|
||||
use openhuman_core::openhuman::agent::harness::definition::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::harness::session::Agent;
|
||||
use openhuman_core::openhuman::agent::harness::{
|
||||
run_subagent, with_parent_context, AgentDefinition, ParentExecutionContext, PromptSource,
|
||||
SandboxMode, SubagentRunOptions, ToolScope,
|
||||
};
|
||||
use openhuman_core::openhuman::agent::progress::AgentProgress;
|
||||
use openhuman_core::openhuman::config::AgentConfig;
|
||||
use openhuman_core::openhuman::context::prompt::ToolCallFormat;
|
||||
use openhuman_core::openhuman::inference::provider::traits::ProviderCapabilities;
|
||||
@@ -13,6 +15,7 @@ use openhuman_core::openhuman::inference::provider::{
|
||||
ChatMessage, ChatRequest, ChatResponse, Provider, ToolCall, UsageInfo,
|
||||
};
|
||||
use openhuman_core::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary};
|
||||
use openhuman_core::openhuman::tools::SpawnSubagentTool;
|
||||
use openhuman_core::openhuman::tools::{Tool, ToolResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::json;
|
||||
@@ -166,11 +169,15 @@ impl Tool for EchoTool {
|
||||
}
|
||||
|
||||
fn usage(input_tokens: u64, output_tokens: u64) -> UsageInfo {
|
||||
usage_with_cached(input_tokens, output_tokens, input_tokens / 2)
|
||||
}
|
||||
|
||||
fn usage_with_cached(input_tokens: u64, output_tokens: u64, cached_input_tokens: u64) -> UsageInfo {
|
||||
UsageInfo {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
context_window: 8_192,
|
||||
cached_input_tokens: input_tokens / 2,
|
||||
cached_input_tokens,
|
||||
charged_amount_usd: 0.001,
|
||||
}
|
||||
}
|
||||
@@ -197,6 +204,21 @@ fn response(
|
||||
}
|
||||
}
|
||||
|
||||
fn response_with_cached(
|
||||
text: Option<&str>,
|
||||
tool_calls: Vec<ToolCall>,
|
||||
input: u64,
|
||||
output: u64,
|
||||
cached: u64,
|
||||
) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: text.map(str::to_string),
|
||||
tool_calls,
|
||||
usage: Some(usage_with_cached(input, output, cached)),
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_config() -> AgentConfig {
|
||||
AgentConfig {
|
||||
max_tool_iterations: 4,
|
||||
@@ -209,10 +231,19 @@ fn build_agent(
|
||||
workspace: &Path,
|
||||
provider: Arc<ScriptedProvider>,
|
||||
agent_name: &str,
|
||||
) -> Result<Agent> {
|
||||
build_agent_with_tools(workspace, provider, agent_name, vec![Box::new(EchoTool)])
|
||||
}
|
||||
|
||||
fn build_agent_with_tools(
|
||||
workspace: &Path,
|
||||
provider: Arc<ScriptedProvider>,
|
||||
agent_name: &str,
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
) -> Result<Agent> {
|
||||
let mut agent = Agent::builder()
|
||||
.provider_arc(provider)
|
||||
.tools(vec![Box::new(EchoTool)])
|
||||
.tools(tools)
|
||||
.memory(Arc::new(StubMemory))
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.config(agent_config())
|
||||
@@ -411,3 +442,207 @@ async fn run_subagent_filters_tools_runs_inner_loop_and_writes_child_transcript(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_subagent_spawns_keep_cacheable_prefix_and_record_provider_cache_hit() -> Result<()>
|
||||
{
|
||||
let workspace = tempfile::tempdir()?;
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
response_with_cached(Some("first"), Vec::new(), 100, 5, 0),
|
||||
response_with_cached(Some("second"), Vec::new(), 100, 5, 88),
|
||||
]));
|
||||
let parent = parent_context(workspace.path().to_path_buf(), provider.clone());
|
||||
let definition = coverage_definition();
|
||||
|
||||
let (first, second) = with_parent_context(parent, async {
|
||||
let first = run_subagent(
|
||||
&definition,
|
||||
"Answer the stable cache probe.",
|
||||
SubagentRunOptions {
|
||||
task_id: Some("cache-probe-a".to_string()),
|
||||
..SubagentRunOptions::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let second = run_subagent(
|
||||
&definition,
|
||||
"Answer the stable cache probe.",
|
||||
SubagentRunOptions {
|
||||
task_id: Some("cache-probe-b".to_string()),
|
||||
..SubagentRunOptions::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok::<_, openhuman_core::openhuman::agent::harness::SubagentRunError>((first, second))
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(first.output, "first");
|
||||
assert_eq!(second.output, "second");
|
||||
|
||||
let requests = provider.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let first_system = requests[0]
|
||||
.iter()
|
||||
.find(|message| message.role == "system")
|
||||
.expect("first subagent request should include a system prompt");
|
||||
let second_system = requests[1]
|
||||
.iter()
|
||||
.find(|message| message.role == "system")
|
||||
.expect("second subagent request should include a system prompt");
|
||||
assert_eq!(
|
||||
first_system.content, second_system.content,
|
||||
"repeated subagent spawns of the same definition must preserve the byte-identical \
|
||||
system prefix the backend can cache"
|
||||
);
|
||||
|
||||
let transcripts = transcript_jsonl_files(workspace.path());
|
||||
assert_eq!(
|
||||
transcripts.len(),
|
||||
2,
|
||||
"each subagent run should persist its own raw transcript: {transcripts:?}"
|
||||
);
|
||||
let joined = transcripts
|
||||
.iter()
|
||||
.map(std::fs::read_to_string)
|
||||
.collect::<std::io::Result<Vec<_>>>()?
|
||||
.join("\n");
|
||||
assert!(
|
||||
joined.contains("\"cached_input_tokens\":88"),
|
||||
"provider-reported cached input tokens from the second child run should be preserved \
|
||||
in subagent transcript accounting:\n{joined}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orchestrator_spawn_subagent_round_trip_streams_child_events_and_returns_result(
|
||||
) -> Result<()> {
|
||||
let workspace = tempfile::tempdir()?;
|
||||
let agents_dir = workspace.path().join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
std::fs::write(
|
||||
agents_dir.join("cache_probe_child.toml"),
|
||||
r#"
|
||||
id = "cache_probe_child"
|
||||
display_name = "Cache Probe Child"
|
||||
when_to_use = "Deterministic child agent used by harness cache tests."
|
||||
temperature = 0.0
|
||||
max_iterations = 3
|
||||
omit_identity = true
|
||||
omit_memory_context = true
|
||||
omit_safety_preamble = true
|
||||
omit_skills_catalog = true
|
||||
omit_profile = true
|
||||
omit_memory_md = true
|
||||
|
||||
[system_prompt]
|
||||
inline = "Answer the delegated cache probe directly."
|
||||
"#,
|
||||
)?;
|
||||
let _ = AgentDefinitionRegistry::init_global(workspace.path());
|
||||
|
||||
let child_answer = "child-cache-observation: prefix was reusable";
|
||||
let parent_final = "orchestrator final: child-cache-observation accepted";
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
response(
|
||||
Some("delegating to child"),
|
||||
vec![tool_call(
|
||||
"spawn-child-1",
|
||||
"spawn_subagent",
|
||||
json!({
|
||||
"agent_id": "cache_probe_child",
|
||||
"prompt": "Inspect whether the child turn can answer a cache probe.",
|
||||
"context": "Parent observed request id cache-42.",
|
||||
}),
|
||||
)],
|
||||
140,
|
||||
9,
|
||||
),
|
||||
response_with_cached(Some(child_answer), Vec::new(), 96, 7, 64),
|
||||
response(Some(parent_final), Vec::new(), 120, 10),
|
||||
]));
|
||||
let mut agent = build_agent_with_tools(
|
||||
workspace.path(),
|
||||
provider.clone(),
|
||||
"coverage_orchestrator",
|
||||
vec![Box::new(SpawnSubagentTool::new())],
|
||||
)?;
|
||||
let (progress_tx, mut progress_rx) = tokio::sync::mpsc::channel(1024);
|
||||
agent.set_on_progress(Some(progress_tx));
|
||||
|
||||
let answer = agent
|
||||
.turn("Ask a child agent for the cache observation, then respond.")
|
||||
.await?;
|
||||
assert_eq!(
|
||||
answer, parent_final,
|
||||
"orchestrator should synthesize after receiving the child result"
|
||||
);
|
||||
|
||||
let mut progress = Vec::new();
|
||||
while let Ok(event) = progress_rx.try_recv() {
|
||||
progress.push(event);
|
||||
}
|
||||
assert!(
|
||||
progress.iter().any(|event| matches!(
|
||||
event,
|
||||
AgentProgress::SubagentSpawned { agent_id, task_id, .. }
|
||||
if agent_id == "cache_probe_child" && task_id.starts_with("sub-")
|
||||
)),
|
||||
"parent progress should announce the child spawn: {progress:#?}"
|
||||
);
|
||||
assert!(
|
||||
progress.iter().any(|event| matches!(
|
||||
event,
|
||||
AgentProgress::SubagentCompleted { agent_id, output_chars, .. }
|
||||
if agent_id == "cache_probe_child" && *output_chars == child_answer.chars().count()
|
||||
)),
|
||||
"parent progress should announce child completion: {progress:#?}"
|
||||
);
|
||||
|
||||
let requests = provider.requests();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
3,
|
||||
"expected parent request, child request, then parent synthesis request"
|
||||
);
|
||||
assert!(
|
||||
requests[0]
|
||||
.iter()
|
||||
.any(|message| message.role == "user" && message.content.contains("Ask a child agent")),
|
||||
"first request should be the orchestrator turn: {:#?}",
|
||||
requests[0]
|
||||
);
|
||||
assert!(
|
||||
requests[1].iter().any(|message| message.role == "system"
|
||||
&& message.content.contains("Sub-agent Role Contract"))
|
||||
&& requests[1].iter().any(|message| message.role == "user"
|
||||
&& message
|
||||
.content
|
||||
.contains("Parent observed request id cache-42")),
|
||||
"second request should be the child subagent turn with parent-supplied context: {:#?}",
|
||||
requests[1]
|
||||
);
|
||||
assert!(
|
||||
requests[2]
|
||||
.iter()
|
||||
.any(|message| message.role == "tool" && message.content.contains(child_answer)),
|
||||
"third request should return the child result to the orchestrator as a tool result: {:#?}",
|
||||
requests[2]
|
||||
);
|
||||
let transcripts = transcript_jsonl_files(workspace.path());
|
||||
let joined = transcripts
|
||||
.iter()
|
||||
.map(std::fs::read_to_string)
|
||||
.collect::<std::io::Result<Vec<_>>>()?
|
||||
.join("\n");
|
||||
assert!(
|
||||
joined.contains("\"agent\":\"cache_probe_child\"")
|
||||
&& joined.contains(child_answer)
|
||||
&& joined.contains("\"cached_input_tokens\":64"),
|
||||
"child transcript should be persisted alongside the parent turn:\n{joined}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4386,7 +4386,7 @@ async fn inference_router_provider_covers_hint_tier_and_passthrough_routing() {
|
||||
assert!(routed_hint.contains("model=fast-chat"));
|
||||
|
||||
let routed_tier = router
|
||||
.chat_with_history(&[ChatMessage::user("tier")], "reasoning-quick-v1", 0.3)
|
||||
.chat_with_history(&[ChatMessage::user("tier")], "chat-v1", 0.3)
|
||||
.await
|
||||
.expect("tier route");
|
||||
assert!(routed_tier.contains("model=fast-chat"));
|
||||
|
||||
Reference in New Issue
Block a user