use std::collections::HashSet;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use axum::http::HeaderMap;
use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
use nemo_relay::plugin::dynamic::DynamicPluginManifest;
use nemo_relay::plugin::{PluginError, merge_plugin_config_documents};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use strum::{Display, IntoStaticStr};
use crate::error::CliError;
use crate::plugin_shim::PluginShimCommand;
use crate::plugins::lifecycle::enforce_required_dynamic_plugin_startup;
use crate::plugins::policy::DynamicPluginHostPolicy;
#[derive(Debug, Clone, Parser)]
#[command(name = "nemo-relay")]
#[command(about = "Coding-agent gateway for NeMo Relay observability")]
#[command(version)]
pub(crate) struct Cli {
#[command(flatten)]
pub(crate) server: ServerArgs,
#[command(subcommand)]
pub(crate) command: Option<Command>,
}
#[derive(Debug, Clone, Subcommand)]
pub(crate) enum Command {
#[command(
long_about = "Run Anthropic's `claude` CLI under an ephemeral NeMo Relay gateway. \
Observability (ATIF + OpenInference) is wired in transparently via \
ANTHROPIC_BASE_URL. First-time use launches the setup wizard so the \
`[agents.claude]` block lands in `.nemo-relay/config.toml` and observation \
starts on the next invocation without prompts.",
after_help = "Examples:\n \
nemo-relay claude\n \
nemo-relay claude -- chat \"refactor the launcher\"\n \
nemo-relay claude -- --resume <session-id>"
)]
Claude(EasyPathCommand),
#[command(
long_about = "Run OpenAI's `codex` CLI under an ephemeral NeMo Relay gateway. NeMo Relay \
injects a `nemo-relay-openai` provider override so codex points at the \
gateway; the gateway then forwards to `--openai-base-url` (defaults to \
api.openai.com) with `OPENAI_API_KEY` injected on the codex route (see \
NMF-86 — codex's own auth.json JWT is stripped). Requires codex-cli >= \
0.129.0.",
after_help = "Examples:\n \
nemo-relay codex\n \
nemo-relay codex -- exec \"fix the bug in foo.rs\"\n \
nemo-relay --openai-base-url https://inference-api.nvidia.com codex"
)]
Codex(EasyPathCommand),
#[command(
long_about = "Run NVIDIA's Hermes agent under a NeMo Relay gateway. Hermes reads hooks \
from `.hermes/config.yaml`; first-run setup writes that file alongside \
`.nemo-relay/config.toml` so every subsequent invocation traces \
automatically. Re-run `nemo-relay config hermes` to refresh the hooks.",
after_help = "Examples:\n \
nemo-relay hermes\n \
nemo-relay hermes -- chat --provider custom"
)]
Hermes(EasyPathCommand),
Config(ConfigCommand),
Plugins(PluginsCommand),
Install(InstallCommand),
Uninstall(UninstallCommand),
ModelPricing(PricingCommand),
Doctor(DoctorCommand),
Agents(AgentsCommand),
Completions(CompletionsCommand),
Run(RunCommand),
#[command(hide = true)]
HookForward(HookForwardCommand),
#[command(hide = true)]
PluginShim(PluginShimCommand),
}
#[derive(Debug, Clone, Args)]
pub(crate) struct DoctorCommand {
#[arg(value_enum)]
pub(crate) agent: Option<CodingAgent>,
#[arg(long, value_enum)]
pub(crate) plugin: Option<PluginHost>,
#[arg(long)]
pub(crate) install_dir: Option<PathBuf>,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct InstallCommand {
#[arg(value_enum)]
pub(crate) host: PluginHost,
#[arg(long)]
pub(crate) install_dir: Option<PathBuf>,
#[arg(long)]
pub(crate) force: bool,
#[arg(long)]
pub(crate) dry_run: bool,
#[arg(long)]
pub(crate) skip_doctor: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct UninstallCommand {
#[arg(value_enum)]
pub(crate) host: PluginHost,
#[arg(long)]
pub(crate) install_dir: Option<PathBuf>,
#[arg(long)]
pub(crate) dry_run: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct AgentsCommand {
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct CompletionsCommand {
#[arg(value_enum)]
pub(crate) shell: Option<clap_complete::Shell>,
#[arg(long)]
pub(crate) install: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct ConfigCommand {
#[arg(value_enum)]
pub(crate) agent: Option<CodingAgent>,
#[arg(long)]
pub(crate) reset: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsCommand {
#[command(subcommand)]
pub(crate) command: PluginsSubcommand,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct PluginJsonContext<'a> {
pub(crate) command: &'static str,
pub(crate) target: Option<&'a str>,
}
#[derive(Debug, Clone, Subcommand)]
pub(crate) enum PluginsSubcommand {
Edit(PluginsEditCommand),
Add(PluginsAddCommand),
Validate(PluginsValidateCommand),
List(PluginsListCommand),
Inspect(PluginsInspectCommand),
Enable(PluginsEnableCommand),
Disable(PluginsDisableCommand),
Remove(PluginsRemoveCommand),
}
impl PluginsSubcommand {
pub(crate) fn json_context(&self) -> Option<PluginJsonContext<'_>> {
match self {
Self::Validate(command) if command.json => Some(PluginJsonContext {
command: "plugins validate",
target: Some(command.target.as_str()),
}),
Self::List(command) if command.json => Some(PluginJsonContext {
command: "plugins list",
target: None,
}),
Self::Inspect(command) if command.json => Some(PluginJsonContext {
command: "plugins inspect",
target: Some(command.id.as_str()),
}),
_ => None,
}
}
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PricingCommand {
#[command(subcommand)]
pub(crate) command: PricingSubcommand,
}
#[derive(Debug, Clone, Subcommand)]
pub(crate) enum PricingSubcommand {
Validate(PricingValidateCommand),
Init(PricingInitCommand),
AddSource(PricingAddSourceCommand),
Resolve(PricingResolveCommand),
}
#[derive(Debug, Clone, Default, Args)]
#[command(group(
ArgGroup::new("pricing_scope")
.args(["user", "project", "global"])
.multiple(false)
))]
pub(crate) struct PricingScopeArgs {
#[arg(long)]
pub(crate) user: bool,
#[arg(long)]
pub(crate) project: bool,
#[arg(long)]
pub(crate) global: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PricingValidateCommand {
pub(crate) path: PathBuf,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PricingInitCommand {
#[command(flatten)]
pub(crate) scope: PricingScopeArgs,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PricingAddSourceCommand {
#[command(flatten)]
pub(crate) scope: PricingScopeArgs,
pub(crate) path: PathBuf,
#[arg(long)]
pub(crate) append: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PricingResolveCommand {
pub(crate) model: String,
#[arg(long)]
pub(crate) provider: Option<String>,
#[arg(long)]
pub(crate) prompt_tokens: Option<u64>,
#[arg(long)]
pub(crate) completion_tokens: Option<u64>,
#[arg(long)]
pub(crate) cache_read_tokens: Option<u64>,
#[arg(long)]
pub(crate) cache_write_tokens: Option<u64>,
}
#[derive(Debug, Clone, Default, Args)]
#[command(group(
ArgGroup::new("scope")
.args(["user", "project", "global"])
.multiple(false)
))]
pub(crate) struct PluginsScopeArgs {
#[arg(long)]
pub(crate) user: bool,
#[arg(long)]
pub(crate) project: bool,
#[arg(long)]
pub(crate) global: bool,
}
#[derive(Debug, Clone, Default, Args)]
pub(crate) struct PluginsEditCommand {
#[command(flatten)]
pub(crate) scope: PluginsScopeArgs,
}
#[derive(Debug, Clone, Default, Args)]
pub(crate) struct PluginsAddCommand {
#[command(flatten)]
pub(crate) scope: PluginsScopeArgs,
pub(crate) path: PathBuf,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsValidateCommand {
pub(crate) target: String,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, Clone, Default, Args)]
pub(crate) struct PluginsListCommand {
#[arg(long)]
pub(crate) all: bool,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsInspectCommand {
pub(crate) id: String,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsEnableCommand {
pub(crate) id: String,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsDisableCommand {
pub(crate) id: String,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsRemoveCommand {
pub(crate) id: String,
}
#[derive(Debug, Clone, Default, Args)]
pub(crate) struct ServerArgs {
#[arg(long)]
pub(crate) config: Option<PathBuf>,
#[arg(long, env = "NEMO_RELAY_GATEWAY_BIND")]
pub(crate) bind: Option<SocketAddr>,
#[arg(long, env = "NEMO_RELAY_OPENAI_BASE_URL")]
pub(crate) openai_base_url: Option<String>,
#[arg(long, env = "NEMO_RELAY_ANTHROPIC_BASE_URL")]
pub(crate) anthropic_base_url: Option<String>,
#[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)]
pub(crate) plugin_config_path: Option<PathBuf>,
#[arg(long, env = "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES")]
pub(crate) max_hook_payload_bytes: Option<usize>,
#[arg(long, env = "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES")]
pub(crate) max_passthrough_body_bytes: Option<usize>,
}
impl ServerArgs {
pub(crate) fn requested_daemon_mode(&self) -> bool {
self.bind.is_some()
|| self.openai_base_url.is_some()
|| self.anthropic_base_url.is_some()
|| self.plugin_config_path.is_some()
|| self.max_hook_payload_bytes.is_some()
|| self.max_passthrough_body_bytes.is_some()
|| self.config.is_some()
}
}
pub(crate) const DEFAULT_MAX_HOOK_PAYLOAD_BYTES: usize = 20 * 1024 * 1024;
pub(crate) const DEFAULT_MAX_PASSTHROUGH_BODY_BYTES: usize = 100 * 1024 * 1024;
#[derive(Debug, Clone)]
pub(crate) struct GatewayConfig {
pub(crate) bind: SocketAddr,
pub(crate) openai_base_url: String,
pub(crate) anthropic_base_url: String,
pub(crate) metadata: Option<Value>,
pub(crate) plugin_config: Option<Value>,
pub(crate) max_hook_payload_bytes: usize,
pub(crate) max_passthrough_body_bytes: usize,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct HookForwardCommand {
#[arg(value_enum)]
pub(crate) agent: CodingAgent,
#[arg(long)]
pub(crate) gateway_url: Option<String>,
#[arg(long)]
pub(crate) profile: Option<String>,
#[arg(long)]
pub(crate) session_metadata: Option<String>,
#[arg(long, value_enum)]
pub(crate) gateway_mode: Option<GatewayMode>,
#[arg(long)]
pub(crate) fail_closed: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct EasyPathCommand {
#[arg(last = true)]
pub(crate) command: Vec<String>,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct RunCommand {
#[arg(long, value_enum)]
pub(crate) agent: Option<CodingAgent>,
#[arg(long)]
pub(crate) config: Option<PathBuf>,
#[arg(long)]
pub(crate) openai_base_url: Option<String>,
#[arg(long)]
pub(crate) anthropic_base_url: Option<String>,
#[arg(long)]
pub(crate) session_metadata: Option<String>,
#[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)]
pub(crate) plugin_config_path: Option<PathBuf>,
#[arg(long)]
pub(crate) dry_run: bool,
#[arg(long)]
pub(crate) print: bool,
#[arg(last = true)]
pub(crate) command: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub(crate) enum CodingAgent {
#[value(name = "claude", alias = "claude-code")]
ClaudeCode,
Codex,
Hermes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub(crate) enum PluginHost {
Codex,
#[value(name = "claude-code", alias = "claude")]
ClaudeCode,
All,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub(crate) enum GatewayMode {
HookOnly,
Passthrough,
Required,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SessionConfig {
pub(crate) metadata: Option<Value>,
pub(crate) plugin_config: Option<Value>,
pub(crate) profile: Option<String>,
pub(crate) gateway_mode: Option<String>,
}
impl GatewayConfig {
pub(crate) fn session_config_from_headers(&self, headers: &HeaderMap) -> SessionConfig {
let metadata =
header_json(headers, "x-nemo-relay-session-metadata").or_else(|| self.metadata.clone());
let plugin_config = header_json(headers, "x-nemo-relay-plugin-config")
.or_else(|| self.plugin_config.clone());
let profile = header_string(headers, "x-nemo-relay-config-profile");
let gateway_mode = header_string(headers, "x-nemo-relay-gateway-mode");
SessionConfig {
metadata,
plugin_config,
profile,
gateway_mode,
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ResolvedConfig {
pub(crate) gateway: GatewayConfig,
pub(crate) agents: AgentConfigs,
pub(crate) dynamic_plugins: Vec<ResolvedDynamicPluginConfig>,
pub(crate) dynamic_plugin_policy: DynamicPluginHostPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedDynamicPluginConfig {
pub(crate) plugin_id: String,
pub(crate) manifest_ref: String,
pub(crate) config: Map<String, Value>,
pub(crate) has_explicit_config: bool,
pub(crate) source: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Display, IntoStaticStr)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub(crate) enum DynamicPluginHostConfigStatus {
Absent,
Present,
}
impl ResolvedDynamicPluginConfig {
pub(crate) fn host_config_status(&self) -> DynamicPluginHostConfigStatus {
if self.has_explicit_config {
DynamicPluginHostConfigStatus::Present
} else {
DynamicPluginHostConfigStatus::Absent
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct AgentConfigs {
pub(crate) claude: AgentCommandConfig,
pub(crate) codex: AgentCommandConfig,
pub(crate) hermes: AgentCommandConfig,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct AgentCommandConfig {
pub(crate) command: Option<String>,
pub(crate) hooks_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileConfig {
gateway: Option<FileGatewayConfig>,
upstream: Option<FileUpstreamConfig>,
agents: Option<FileAgentsConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileGatewayConfig {
max_hook_payload_bytes: Option<usize>,
max_passthrough_body_bytes: Option<usize>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileUpstreamConfig {
openai_base_url: Option<String>,
anthropic_base_url: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileAgentsConfig {
claude: Option<FileAgentCommandConfig>,
codex: Option<FileAgentCommandConfig>,
hermes: Option<FileAgentCommandConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileAgentCommandConfig {
command: Option<String>,
hooks_path: Option<PathBuf>,
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
bind: "127.0.0.1:4040"
.parse()
.expect("valid default bind address"),
openai_base_url: "https://api.openai.com/v1".into(),
anthropic_base_url: "https://api.anthropic.com".into(),
metadata: None,
plugin_config: None,
max_hook_payload_bytes: DEFAULT_MAX_HOOK_PAYLOAD_BYTES,
max_passthrough_body_bytes: DEFAULT_MAX_PASSTHROUGH_BODY_BYTES,
}
}
}
pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result<ResolvedConfig, CliError> {
let mut resolved = load_shared_config(args.config.as_ref(), args.plugin_config_path.as_ref())?;
apply_server_overrides(&mut resolved.gateway, args)?;
enforce_required_dynamic_plugin_startup(args.config.as_ref(), &resolved)?;
Ok(resolved)
}
pub(crate) fn resolve_plugins_config(
explicit: Option<&PathBuf>,
) -> Result<ResolvedConfig, CliError> {
load_shared_config(explicit, None)
}
pub(crate) fn resolve_run_config(
command: &RunCommand,
inherited: Option<&ServerArgs>,
) -> Result<ResolvedConfig, CliError> {
let config = command
.config
.as_ref()
.or_else(|| inherited.and_then(|args| args.config.as_ref()));
let plugin_config_path = command
.plugin_config_path
.as_ref()
.or_else(|| inherited.and_then(|args| args.plugin_config_path.as_ref()));
let mut resolved = load_shared_config(config, plugin_config_path)?;
if let Some(args) = inherited {
apply_server_overrides(&mut resolved.gateway, args)?;
}
apply_run_overrides(&mut resolved.gateway, command)?;
resolved.gateway.bind = "127.0.0.1:0"
.parse()
.expect("valid transparent bind address");
if !command.dry_run {
enforce_required_dynamic_plugin_startup(config, &resolved)?;
}
Ok(resolved)
}
fn apply_run_overrides(config: &mut GatewayConfig, command: &RunCommand) -> Result<(), CliError> {
apply_run_url_overrides(config, command);
apply_run_json_overrides(config, command)?;
Ok(())
}
fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunCommand) {
if let Some(value) = &command.openai_base_url {
config.openai_base_url = value.clone();
}
if let Some(value) = &command.anthropic_base_url {
config.anthropic_base_url = value.clone();
}
}
fn apply_run_json_overrides(
config: &mut GatewayConfig,
command: &RunCommand,
) -> Result<(), CliError> {
if let Some(value) = &command.session_metadata {
config.metadata = Some(parse_json_option("session metadata", value)?);
}
Ok(())
}
fn apply_server_overrides(config: &mut GatewayConfig, args: &ServerArgs) -> Result<(), CliError> {
if let Some(value) = args.bind {
config.bind = value;
}
if let Some(value) = &args.openai_base_url {
config.openai_base_url = value.clone();
}
if let Some(value) = &args.anthropic_base_url {
config.anthropic_base_url = value.clone();
}
if let Some(value) = args.max_hook_payload_bytes {
config.max_hook_payload_bytes = validate_body_limit("max hook payload bytes", value)?;
}
if let Some(value) = args.max_passthrough_body_bytes {
config.max_passthrough_body_bytes =
validate_body_limit("max passthrough body bytes", value)?;
}
Ok(())
}
pub(crate) const PLUGINS_TOML: &str = "plugins.toml";
fn load_shared_config(
explicit: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
) -> Result<ResolvedConfig, CliError> {
let mut merged = toml::Value::Table(toml::map::Map::new());
for path in config_paths(explicit) {
let Some(raw) = read_config_file(&path, explicit.is_some(), "configuration")? else {
continue;
};
let parsed = raw
.parse::<toml::Table>()
.map(toml::Value::Table)
.map_err(|error| {
CliError::Config(format!("invalid TOML in {}: {error}", path.display()))
})?;
let legacy_observability = legacy_observability_sections(&parsed);
if !legacy_observability.is_empty() {
return Err(CliError::Config(format!(
"legacy observability config in {} is no longer supported: {}; configure \
observability in plugins.toml with `nemo-relay plugins edit`",
path.display(),
legacy_observability.join(", ")
)));
}
if parsed.get("plugins").is_some() {
return Err(CliError::Config(format!(
"plugin configuration in {} is no longer supported; move it to plugins.toml",
path.display()
)));
}
merge_toml(&mut merged, parsed);
}
let plugin_toml = load_plugin_toml_config(explicit, plugin_config_path)?;
let mut resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
..ResolvedConfig::default()
};
apply_file_config(&mut resolved, merged)?;
apply_plugin_toml_config(&mut resolved, plugin_toml);
apply_env_config(&mut resolved.gateway)?;
Ok(resolved)
}
fn read_config_file(
path: &Path,
required: bool,
description: &str,
) -> Result<Option<String>, CliError> {
match path.try_exists() {
Ok(false) if !required => Ok(None),
Ok(false) => Err(CliError::Config(format!(
"explicit {description} file {} does not exist",
path.display()
))),
Err(error) => Err(CliError::Config(format!(
"failed to inspect {description} file {}: {error}",
path.display()
))),
Ok(true) => std::fs::read_to_string(path).map(Some).map_err(|error| {
CliError::Config(format!(
"failed to read {description} file {}: {error}",
path.display()
))
}),
}
}
pub(crate) fn any_config_file_exists() -> bool {
config_paths(None).iter().any(|path| path.exists())
}
fn config_paths(explicit: Option<&PathBuf>) -> Vec<PathBuf> {
if let Some(path) = explicit {
return vec![path.clone()];
}
let mut paths = vec![PathBuf::from("/etc/nemo-relay/config.toml")];
if let Ok(cwd) = std::env::current_dir()
&& let Some(project) = find_project_config(&cwd)
{
paths.push(project);
}
if let Some(user) = user_config_path() {
paths.push(user);
}
paths
}
fn plugin_config_paths(
explicit: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
) -> Vec<PathBuf> {
if let Some(path) = plugin_config_path {
return vec![path.clone()];
}
if let Some(path) = explicit {
return path
.parent()
.map(|parent| vec![parent.join(PLUGINS_TOML)])
.unwrap_or_default();
}
implicit_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir())
}
pub(crate) fn default_plugin_config_paths() -> Vec<PathBuf> {
plugin_config_paths(None, None)
}
fn implicit_plugin_config_paths(
cwd: Option<&std::path::Path>,
user_config_dir: Option<PathBuf>,
) -> Vec<PathBuf> {
nemo_relay::plugin::default_plugin_config_paths(cwd, user_config_dir)
}
fn find_project_config(start: &std::path::Path) -> Option<PathBuf> {
for ancestor in start.ancestors() {
let path = ancestor.join(".nemo-relay/config.toml");
if path.exists() {
return Some(path);
}
}
None
}
fn find_project_plugin_config(start: &std::path::Path) -> Option<PathBuf> {
nemo_relay::plugin::nearest_project_plugin_config(start)
}
pub(crate) fn user_plugin_config_path() -> Option<PathBuf> {
user_config_dir().map(|dir| dir.join(PLUGINS_TOML))
}
pub(crate) fn project_plugin_config_path(start: &std::path::Path) -> PathBuf {
find_project_plugin_config(start)
.or_else(|| {
find_project_config(start)
.and_then(|path| path.parent().map(|parent| parent.join(PLUGINS_TOML)))
})
.unwrap_or_else(|| start.join(".nemo-relay").join(PLUGINS_TOML))
}
pub(crate) fn global_plugin_config_path() -> PathBuf {
PathBuf::from("/etc/nemo-relay").join(PLUGINS_TOML)
}
fn user_config_path() -> Option<PathBuf> {
user_config_dir().map(|dir| dir.join("config.toml"))
}
pub(crate) fn user_config_dir() -> Option<PathBuf> {
nemo_relay::plugin::user_config_dir()
}
fn apply_file_config(resolved: &mut ResolvedConfig, value: toml::Value) -> Result<(), CliError> {
let config: FileConfig = value.try_into().map_err(|error| {
CliError::Config(format!("invalid gateway configuration shape: {error}"))
})?;
apply_file_gateway_config(&mut resolved.gateway, config.gateway)?;
apply_file_upstream_config(&mut resolved.gateway, config.upstream);
apply_file_agents_config(&mut resolved.agents, config.agents);
Ok(())
}
fn apply_file_gateway_config(
gateway: &mut GatewayConfig,
config: Option<FileGatewayConfig>,
) -> Result<(), CliError> {
let Some(config) = config else {
return Ok(());
};
if let Some(value) = config.max_hook_payload_bytes {
gateway.max_hook_payload_bytes =
validate_body_limit("gateway.max_hook_payload_bytes", value)?;
}
if let Some(value) = config.max_passthrough_body_bytes {
gateway.max_passthrough_body_bytes =
validate_body_limit("gateway.max_passthrough_body_bytes", value)?;
}
Ok(())
}
fn apply_file_upstream_config(gateway: &mut GatewayConfig, upstream: Option<FileUpstreamConfig>) {
let Some(upstream) = upstream else {
return;
};
if let Some(value) = upstream.openai_base_url {
gateway.openai_base_url = value;
}
if let Some(value) = upstream.anthropic_base_url {
gateway.anthropic_base_url = value;
}
}
#[derive(Debug, Clone)]
struct PluginTomlConfig {
value: Option<Value>,
dynamic_plugins: Vec<ResolvedDynamicPluginConfig>,
dynamic_plugin_policy: DynamicPluginHostPolicy,
contributing_sources: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct PluginTomlPluginsSection {
#[serde(default)]
dynamic: Vec<FileDynamicPluginConfig>,
#[serde(default)]
policy: Option<crate::plugins::policy::FileDynamicPluginHostPolicy>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileDynamicPluginConfig {
manifest: String,
#[serde(default)]
config: Option<Map<String, Value>>,
}
fn load_plugin_toml_config(
explicit: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
) -> Result<Option<PluginTomlConfig>, CliError> {
load_plugin_toml_config_from_paths(plugin_config_paths(explicit, plugin_config_path))
}
pub(crate) fn effective_plugin_toml_sources() -> Result<Vec<PathBuf>, CliError> {
let Some(config) = load_plugin_toml_config(None, None)? else {
return Ok(Vec::new());
};
let mut sources = config.contributing_sources;
sources.sort();
sources.dedup();
Ok(sources)
}
fn load_plugin_toml_config_from_paths<I>(paths: I) -> Result<Option<PluginTomlConfig>, CliError>
where
I: IntoIterator<Item = PathBuf>,
{
let paths = paths.into_iter().collect::<Vec<_>>();
let mut dynamic_plugins = Vec::new();
let mut dynamic_plugin_policy = DynamicPluginHostPolicy::default();
let mut seen_plugin_ids = HashSet::new();
let mut contributing_sources = Vec::new();
let mut runtime_documents = Vec::new();
for path in &paths {
let Some(raw) = read_config_file(path, false, "plugin configuration")? else {
continue;
};
let mut parsed = raw
.parse::<toml::Table>()
.map(toml::Value::Table)
.map_err(|error| {
CliError::Config(format!(
"invalid plugin TOML in {}: {error}",
path.display()
))
})?;
let resolved_plugins =
resolve_dynamic_plugin_refs(path, &mut parsed, &mut seen_plugin_ids)?;
if !resolved_plugins.dynamic_plugins.is_empty()
|| resolved_plugins.dynamic_plugin_policy != DynamicPluginHostPolicy::default()
{
contributing_sources.push(path.clone());
}
dynamic_plugins.extend(resolved_plugins.dynamic_plugins);
dynamic_plugin_policy.merge_from(resolved_plugins.dynamic_plugin_policy);
runtime_documents.push((
path.clone(),
serde_json::to_value(remove_dynamic_plugin_sections(parsed))
.expect("toml value serializes to JSON"),
));
}
let resolved = merge_plugin_config_documents(runtime_documents).map_err(|err| match err {
PluginError::InvalidConfig(message) => CliError::Config(message),
other => CliError::Config(other.to_string()),
})?;
match resolved {
Some((value, sources)) => {
contributing_sources.extend(sources.iter().cloned());
contributing_sources.sort();
contributing_sources.dedup();
Ok(Some(PluginTomlConfig {
value: plugin_toml_runtime_value(value),
dynamic_plugins,
dynamic_plugin_policy,
contributing_sources,
}))
}
None => Ok((!dynamic_plugins.is_empty()
|| dynamic_plugin_policy != DynamicPluginHostPolicy::default())
.then_some(PluginTomlConfig {
value: None,
dynamic_plugins,
dynamic_plugin_policy,
contributing_sources,
})),
}
}
fn apply_plugin_toml_config(resolved: &mut ResolvedConfig, plugin_toml: Option<PluginTomlConfig>) {
let Some(plugin_toml) = plugin_toml else {
return;
};
if let Some(value) = plugin_toml.value {
resolved.gateway.plugin_config = Some(value);
}
resolved.dynamic_plugins = plugin_toml.dynamic_plugins;
resolved.dynamic_plugin_policy = plugin_toml.dynamic_plugin_policy;
}
struct ResolvedDynamicPluginRefs {
dynamic_plugins: Vec<ResolvedDynamicPluginConfig>,
dynamic_plugin_policy: DynamicPluginHostPolicy,
}
fn resolve_dynamic_plugin_refs(
source: &Path,
value: &mut toml::Value,
seen_plugin_ids: &mut HashSet<String>,
) -> Result<ResolvedDynamicPluginRefs, CliError> {
let Some(root) = value.as_table_mut() else {
return Ok(ResolvedDynamicPluginRefs {
dynamic_plugins: Vec::new(),
dynamic_plugin_policy: DynamicPluginHostPolicy::default(),
});
};
let plugins_value = root.get("plugins").cloned();
let Some(plugins_value) = plugins_value else {
return Ok(ResolvedDynamicPluginRefs {
dynamic_plugins: Vec::new(),
dynamic_plugin_policy: DynamicPluginHostPolicy::default(),
});
};
let plugins: PluginTomlPluginsSection = plugins_value.try_into().map_err(|error| {
CliError::Config(format!(
"invalid dynamic plugin config in {}: {error}",
source.display()
))
})?;
if let Some(toml::Value::Table(plugins_table)) = root.get_mut("plugins") {
plugins_table.remove("dynamic");
plugins_table.remove("policy");
if plugins_table.is_empty() {
root.remove("plugins");
}
}
let mut resolved = Vec::with_capacity(plugins.dynamic.len());
for dynamic in plugins.dynamic {
let manifest_path = resolve_dynamic_manifest_path(source, &dynamic.manifest);
let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_path)
.map_err(|error| {
CliError::Config(format!(
"invalid dynamic plugin manifest referenced by {}: {error}",
source.display()
))
})?;
let plugin_id = manifest.plugin.id.trim().to_owned();
if !seen_plugin_ids.insert(plugin_id.clone()) {
return Err(CliError::Config(format!(
"duplicate dynamic plugin id '{}' in {} across plugins.toml sources",
plugin_id,
source.display()
)));
}
resolved.push(ResolvedDynamicPluginConfig {
plugin_id,
manifest_ref,
has_explicit_config: dynamic.config.is_some(),
config: dynamic.config.unwrap_or_default(),
source: source.to_path_buf(),
});
}
Ok(ResolvedDynamicPluginRefs {
dynamic_plugins: resolved,
dynamic_plugin_policy: plugins.policy.map(Into::into).unwrap_or_default(),
})
}
fn resolve_dynamic_manifest_path(source: &Path, manifest: &str) -> PathBuf {
let manifest = PathBuf::from(manifest);
if manifest.is_absolute() {
manifest
} else {
source
.parent()
.map(|parent| parent.join(&manifest))
.unwrap_or(manifest)
}
}
fn plugin_toml_runtime_value(value: Value) -> Option<Value> {
match value {
Value::Object(ref object) if object.is_empty() => None,
other => Some(other),
}
}
fn remove_dynamic_plugin_sections(mut value: toml::Value) -> toml::Value {
if let Some(root) = value.as_table_mut()
&& let Some(toml::Value::Table(plugins)) = root.get_mut("plugins")
{
plugins.remove("dynamic");
plugins.remove("policy");
if plugins.is_empty() {
root.remove("plugins");
}
}
value
}
fn apply_file_agents_config(agents: &mut AgentConfigs, file_agents: Option<FileAgentsConfig>) {
let Some(file_agents) = file_agents else {
return;
};
if let Some(value) = file_agents.claude {
agents.claude.command = value.command;
}
if let Some(value) = file_agents.codex {
agents.codex.command = value.command;
}
if let Some(value) = file_agents.hermes {
agents.hermes.command = value.command;
agents.hermes.hooks_path = value.hooks_path;
}
}
fn apply_env_config(config: &mut GatewayConfig) -> Result<(), CliError> {
if let Ok(value) = std::env::var("NEMO_RELAY_GATEWAY_BIND")
&& let Ok(value) = value.parse()
{
config.bind = value;
}
if let Ok(value) = std::env::var("NEMO_RELAY_OPENAI_BASE_URL") {
config.openai_base_url = value;
}
if let Ok(value) = std::env::var("NEMO_RELAY_ANTHROPIC_BASE_URL") {
config.anthropic_base_url = value;
}
if let Ok(value) = std::env::var("NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES") {
config.max_hook_payload_bytes =
parse_env_body_limit("NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", &value)?;
}
if let Ok(value) = std::env::var("NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES") {
config.max_passthrough_body_bytes =
parse_env_body_limit("NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", &value)?;
}
Ok(())
}
fn parse_env_body_limit(name: &str, raw: &str) -> Result<usize, CliError> {
let value = raw.parse::<usize>().map_err(|error| {
CliError::Config(format!("{name} must be a positive byte count: {error}"))
})?;
validate_body_limit(name, value)
}
fn validate_body_limit(name: &str, value: usize) -> Result<usize, CliError> {
if value == 0 {
return Err(CliError::Config(format!("{name} must be greater than 0")));
}
Ok(value)
}
fn merge_toml(left: &mut toml::Value, right: toml::Value) {
match (left, right) {
(toml::Value::Table(left), toml::Value::Table(right)) => {
for (key, value) in right {
match left.get_mut(&key) {
Some(existing) => merge_toml(existing, value),
None => {
left.insert(key, value);
}
}
}
}
(left, right) => *left = right,
}
}
fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> {
let mut sections = Vec::new();
if value.get("exporters").is_some() {
sections.push("[exporters]");
}
if value.get("observability").is_some() {
sections.push("[observability]");
}
if value
.get("export")
.and_then(|export| export.get("openinference"))
.is_some()
{
sections.push("[export.openinference]");
}
sections
}
fn parse_json_option(name: &str, value: &str) -> Result<Value, CliError> {
serde_json::from_str::<Value>(value)
.map_err(|error| CliError::Config(format!("invalid {name}: {error}")))
}
pub(crate) fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn header_json(headers: &HeaderMap, name: &str) -> Option<Value> {
header_string(headers, name).and_then(|raw| serde_json::from_str(&raw).ok())
}
impl CodingAgent {
pub(crate) const fn hook_path(self) -> &'static str {
match self {
Self::ClaudeCode => "/hooks/claude-code",
Self::Codex => "/hooks/codex",
Self::Hermes => "/hooks/hermes",
}
}
pub(crate) const fn as_arg(self) -> &'static str {
match self {
Self::ClaudeCode => "claude",
Self::Codex => "codex",
Self::Hermes => "hermes",
}
}
pub(crate) fn infer(command: &str) -> Option<Self> {
let name = std::path::Path::new(command)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(command);
match name {
"claude" | "claude-code" => Some(Self::ClaudeCode),
"codex" => Some(Self::Codex),
"hermes" | "hermes-agent" => Some(Self::Hermes),
_ => None,
}
}
}
impl GatewayMode {
pub(crate) const fn as_arg(self) -> &'static str {
match self {
Self::HookOnly => "hook-only",
Self::Passthrough => "passthrough",
Self::Required => "required",
}
}
}
#[cfg(test)]
#[path = "../tests/coverage/config_tests.rs"]
mod tests;