Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ tokenEnv = "MEDULLA_TOKEN"
# [[customHarnesses]]
# id = "deepseek"
# name = "DeepSeek via Claude"
# baseHarness = "claude" # claude | codex | opencode
# baseHarness = "claude" # claude | codex | opencode | openhuman
# model = "deepseek/deepseek-chat"
# fastModel = "deepseek/deepseek-chat"
# hostId = "this-device" # must match [host].address
Expand All @@ -125,6 +125,23 @@ tokenEnv = "MEDULLA_TOKEN"
# baseUrl = "https://openrouter.ai/api" # optional; defaults per base harness
# contextWindow = 114000 # optional; Claude auto-compaction window

# baseHarness = "openhuman" runs the turn on the embedded core rather than
# spawning a CLI, so there is nothing to install. The preset's model, endpoint
# and key apply to that turn only: Medulla passes the core a per-call route
# through the same loopback proxy, and the core never persists it, so the
# account's own OpenHuman inference is left where you set it.
#
# A preset that names only a model still works and still needs no OpenRouter
# key. With nothing exported under apiKeyEnv the turn runs on whatever the
# account's OpenHuman configuration already resolves.
# [[customHarnesses]]
# id = "deepseek-oh"
# name = "DeepSeek via OpenHuman"
# baseHarness = "openhuman"
# model = "deepseek/deepseek-chat"
# hostId = "this-device"
# apiKeyEnv = "OPENROUTER_API_KEY"

# ---------------------------------------------------------------------------
# core — the medulla-serve NDJSON orchestration socket (optional)
# ---------------------------------------------------------------------------
Expand Down
11 changes: 10 additions & 1 deletion docs/attribution-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,19 @@ endpoint:
- watched PTY sessions on the local host;
- harnesses an operator opens by hand in the TUI.

All three built-in harnesses are covered. OpenCode is accepted as a
All three spawned harnesses are covered. OpenCode is accepted as a
`customHarnesses` base even though it can reach OpenRouter natively, because that
native path is exactly the one this proxy needs to take over.

The embedded OpenHuman core is covered too, by a different mechanism. It is not a
child process, so there is no environment to inject into and nothing to scrub:
Medulla resolves the preset's key, exchanges it for a loopback token, and hands
the core the mount and the token as a *per-call* route on
`inference_agent_chat`. The core applies that route to the turn's own in-memory
configuration and never persists it, so borrowing an endpoint for one node does
not repoint the account's own inference. As with a spawned harness, the core is
given the token and never the OpenRouter key.

One limitation applies. Medulla injects environment variables at the spawn seam
and never writes a harness's own configuration file. A harness you have
separately configured to reach OpenRouter through its own config still bypasses
Expand Down
51 changes: 41 additions & 10 deletions gitbooks/developers/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,16 +261,47 @@ operator's override of what the configuration says, applied to the machine they
are standing at, with no file to edit. An exported-but-blank value counts as
unset.

Two preset fields mean less for `openhuman` than for a spawned CLI, and it is
worth knowing which. `baseUrl` and `apiKeyEnv` describe an endpoint Medulla
points a *child process* at; an OpenHuman turn has no child, and the embedded
core resolves its own provider bindings and credentials from the account's
configuration. They are therefore inert here — and the corollary is that the core
must already be configured with a provider that serves the model you name. A
model no configured provider serves is not an error: the core's agent loop falls
through to its resolved default and records that it skipped the override. For the
same reason an `openhuman` preset needs no OpenRouter key to be advertised as
capacity, unlike every other base harness.
### Running an OpenHuman turn on OpenRouter

Naming a model is only half the answer: on its own the name is resolved against
whatever providers the core already has, and one no configured provider serves is
not an error — the agent loop falls through to its resolved default and records
that it skipped the override.

`baseUrl` and `apiKeyEnv` supply the other half, and they are live for
`openhuman` presets. They reach the turn by a different road than they do for a
spawned CLI, which has no child to hand an environment to: Medulla resolves the
key named by `apiKeyEnv`, exchanges it at the loopback attribution proxy for a
machine-local token, and passes the core the mount and that token as a
**per-call** route. The core applies the route to that one turn's in-memory
configuration and never writes it to disk, so pointing a workflow step at
OpenRouter does not repoint the account's own OpenHuman inference — the next turn
without a preset runs exactly where it did before.

The route governs the four roles an agent turn runs on (chat, reasoning, agentic,
coding). Background workloads — memory, embeddings, heartbeat, learning — stay
where the account's configuration puts them, because they run tier-specific
models a coding endpoint generally cannot serve.

So a complete OpenHuman preset needs nothing installed and nothing pre-configured
in the core:

```toml
[[customHarnesses]]
id = "deepseek-oh"
name = "DeepSeek via OpenHuman"
baseHarness = "openhuman"
model = "deepseek/deepseek-chat"
hostId = "this-device"
apiKeyEnv = "OPENROUTER_API_KEY"
```

Routing is skipped, with no error, in three cases: no key exported under
`apiKeyEnv`, a `baseUrl` that resolves somewhere other than `openrouter.ai`, and
a turn with no model resolved. Each leaves the turn on the account's own
OpenHuman configuration, which is why an `openhuman` preset that names only a
model still works and is still advertised as capacity without an OpenRouter key —
unlike every other base harness.

`apiKeyEnv` holds a variable name and never a value. The key stays in the process
environment, and neither the config file nor the app's own state ever holds it. A
Expand Down
19 changes: 12 additions & 7 deletions src/sdk/src/config/custom_harnesses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,18 @@
//! [`crate::daemon::providers::openhuman::effective_model`] for where it sits
//! among the other routes).
//!
//! Two fields mean less here than they do for a spawned CLI, and the difference
//! is worth stating rather than discovering. `baseUrl` and `apiKeyEnv` describe
//! an endpoint Medulla points a *child process* at; an OpenHuman turn has no
//! child, and the embedded core resolves its own provider bindings and
//! credentials from the account's own configuration. So the endpoint and the
//! key name are inert for such a preset, and it is the core that must already
//! be configured with a provider able to serve the named model.
//! `baseUrl` and `apiKeyEnv` are live here too, though they arrive by a
//! different road. For a spawned CLI they are layered into the child's
//! environment; an OpenHuman turn has no child, so Medulla resolves the key,
//! exchanges it for a loopback token at the attribution proxy, and passes the
//! mount and the token to the core as a **per-call** route that the core applies
//! to that turn alone and never persists. See
//! [`crate::daemon::providers::openhuman::openrouter_route`].
//!
//! A preset that leaves them at their defaults still works and still needs no
//! OpenRouter key: with no key exported under `apiKeyEnv` the turn runs on the
//! account's own OpenHuman configuration, which is what an operator who
//! configured only a model is asking for.

use std::collections::HashMap;
use std::path::Path;
Expand Down
6 changes: 5 additions & 1 deletion src/sdk/src/daemon/providers/openhuman/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,17 @@
//! through the process it is already inside.

//! *A model of its own.* The turn runs on whatever the operator chose — see
//! [`model`] for every route to that choice and the order they resolve in.
//! [`model`] for every route to that choice and the order they resolve in, and
//! [`router`] for the endpoint and credential that make a chosen model
//! reachable.

mod model;
mod router;
mod run;

#[cfg(test)]
mod tests;

pub use model::effective_model;
pub use router::openrouter_route;
pub use run::{run_openhuman_task, uses_embedded_core};
18 changes: 11 additions & 7 deletions src/sdk/src/daemon/providers/openhuman/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@
//!
//! # What choosing a model here does not do
//!
//! The name is handed to the core as `model_override` and the core resolves it
//! against *its own* provider bindings. A model id no configured provider
//! serves is not an error here: the core's agent loop falls through to its
//! resolved default and emits its own "override skipped" diagnostic. So a
//! preset's `baseUrl` and `apiKeyEnv` are inert for this provider — the
//! embedded core owns its credentials and endpoints, and Medulla injects
//! neither into a turn that spawns no child.
//! The name is handed to the core as `model_override`, and on its own that is
//! all it is: the core resolves the name against *its own* provider bindings,
//! and a model id no configured provider serves is not an error here — the
//! agent loop falls through to its resolved default and emits its own "override
//! skipped" diagnostic.
//!
//! What makes a named model actually reachable is the endpoint and credential
//! beside it, which is [`super::router`]'s job. A preset that carries a
//! `baseUrl` and an `apiKeyEnv` states the whole answer and the turn runs there;
//! a preset that carries only a model still depends on the core already having a
//! provider that serves it.

use std::collections::HashMap;

Expand Down
79 changes: 79 additions & 0 deletions src/sdk/src/daemon/providers/openhuman/router.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//! Where an embedded OpenHuman turn's inference goes.
//!
//! The sibling of [`super::model`]: that module answers *which model*, this one
//! answers *whose endpoint and whose credential*. They are separate questions
//! because a model name alone is inert — the core resolves it against its own
//! provider bindings, and a name no configured provider serves falls back to the
//! account's default with a diagnostic.
//!
//! # What changed, and why this module exists
//!
//! A `[[customHarnesses]]` preset carries `model`, `baseUrl` and `apiKeyEnv`.
//! For a spawned CLI all three are live: the endpoint and key are layered into
//! the child's environment at the spawn seam. For an OpenHuman turn there is no
//! child, so the latter two used to be documented as inert — the preset could
//! name a model but not the provider that serves it, and the operator had to
//! configure the core separately for the preset to do anything.
//!
//! It no longer is. The core accepts a per-call route (`inference_url` +
//! `api_key` on `inference_agent_chat`) that it applies to the turn's own
//! in-memory config and never persists, so a preset can state the whole answer:
//! this model, on OpenRouter, with this key.
//!
//! # Why the core is not given the OpenRouter key
//!
//! For the same reason a child harness is not — see [`crate::inference_proxy`].
//! The endpoint handed over is Medulla's loopback mount and the credential is a
//! machine-local token, so the traffic is re-headed on the way out and credited
//! to Medulla rather than to OpenHuman. The one difference from a spawned run is
//! that there is no environment to scrub: the key is read here, exchanged for a
//! token here, and never written anywhere the turn can reach it.

use std::collections::HashMap;

use crate::config::RouterConfig;
use crate::inference_proxy::EmbeddedRouting;

/// Resolve the loopback route this turn should run on, if any.
///
/// `Ok(None)` — the common case — means the turn runs on whatever the account's
/// own OpenHuman configuration resolves, which is the behaviour every run had
/// before presets could state an endpoint. It covers a run with no router at
/// all, a router pointing somewhere other than OpenRouter, a router whose named
/// key is not exported, and a run with no model to route.
///
/// The model is required because the core's per-call route is expressed as a
/// provider/model pair: with no model there is nothing to pin the route to, and
/// the core would ignore it. Reporting that here as "not routed" keeps the two
/// sides agreeing rather than sending a request the core discards.
///
/// # Errors
///
/// The sentence [`crate::inference_proxy::route_embedded`] produces when the
/// loopback listener cannot bind. Fatal on purpose: the operator asked for a
/// specific endpoint, and quietly running the turn on a different provider —
/// billed to a different account — is worse than failing.
pub fn openrouter_route(
router: Option<&RouterConfig>,
env: &HashMap<String, String>,
model: Option<&str>,
) -> Result<Option<EmbeddedRouting>, String> {
let Some(router) = router else {
return Ok(None);
};
if model.map(str::trim).is_none_or(str::is_empty) {
tracing::debug!(
"[openhuman] not routing this turn: an endpoint was configured but no model was resolved"
);
return Ok(None);
}
let route = crate::inference_proxy::route_embedded(router, env)?;
if route.is_some() {
tracing::debug!("[openhuman] routing this turn through the attribution proxy");
}
Ok(route)
}

#[cfg(test)]
#[path = "router_tests.rs"]
mod tests;
Loading
Loading