use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{self, BufReader};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default = "default_auto_pull")]
pub auto_pull: bool,
#[serde(default)]
pub models_home: Option<PathBuf>,
#[serde(default)]
pub model: Option<ModelConfig>,
#[serde(default = "default_n_ctx")]
pub n_ctx: u32,
#[serde(default)]
pub n_gpu_layers: i32,
#[serde(default)]
pub admin_addr: Option<String>,
#[serde(default)]
pub backends: Option<Vec<BackendEntry>>,
#[serde(default)]
pub listen: Option<ListenConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ListenConfig {
#[serde(default)]
pub tcp: Option<String>,
#[serde(default)]
pub tcp_v2: Option<String>,
#[serde(default)]
pub tcp_embed: Option<String>,
#[serde(default)]
pub api_key_env: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum BackendEntry {
Llamacpp(LlamacppEntry),
OpenaiCompat(OpenaiCompatEntry),
BedrockInvoke(BedrockInvokeEntry),
}
impl BackendEntry {
pub fn name(&self) -> &str {
match self {
BackendEntry::Llamacpp(e) => &e.name,
BackendEntry::OpenaiCompat(e) => &e.name,
BackendEntry::BedrockInvoke(e) => &e.name,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlamacppEntry {
pub name: String,
pub model: ModelConfig,
#[serde(default = "default_n_ctx")]
pub n_ctx: u32,
#[serde(default)]
pub n_gpu_layers: i32,
#[serde(default)]
pub embed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embed_pooling: Option<i32>,
#[serde(default = "default_embed_n_ctx")]
pub embed_n_ctx: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenaiCompatEntry {
pub name: String,
pub base_url: String,
pub model: String,
#[serde(default)]
pub api_key_env: Option<String>,
#[serde(default = "default_openai_timeout_secs")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BedrockInvokeEntry {
pub name: String,
pub region: String,
pub model_id: String,
#[serde(default)]
pub bearer_token_env: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default = "default_bedrock_timeout_secs")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelConfig {
pub name: String,
pub sha256: String,
#[serde(default)]
pub size_bytes: Option<u64>,
pub source_url: String,
#[serde(default)]
pub license: Option<String>,
}
fn default_auto_pull() -> bool {
true
}
fn default_n_ctx() -> u32 {
8192
}
fn default_embed_n_ctx() -> u32 {
2048
}
fn default_openai_timeout_secs() -> u64 {
300
}
fn default_bedrock_timeout_secs() -> u64 {
300
}
fn home_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(not(unix))]
{
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
}
pub fn default_config_path() -> PathBuf {
if let Ok(p) = std::env::var("INFERD_CONFIG") {
return PathBuf::from(p);
}
let home = home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".inferd").join("config.json")
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config file not found: {0}")]
NotFound(PathBuf),
#[error("io reading {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("parse {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("invalid config: {0}")]
Invalid(String),
}
impl ConfigFile {
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let file = File::open(path).map_err(|e| {
if e.kind() == io::ErrorKind::NotFound {
ConfigError::NotFound(path.to_path_buf())
} else {
ConfigError::Io {
path: path.to_path_buf(),
source: e,
}
}
})?;
let reader = BufReader::new(file);
let mut cfg: ConfigFile =
serde_json::from_reader(reader).map_err(|e| ConfigError::Parse {
path: path.to_path_buf(),
source: e,
})?;
cfg.expand_paths();
cfg.validate()?;
Ok(cfg)
}
fn expand_paths(&mut self) {
if let Some(p) = self.models_home.as_ref()
&& let Some(stripped) = p
.to_str()
.and_then(|s| s.strip_prefix("~/").or_else(|| s.strip_prefix("~\\")))
&& let Some(home) = home_dir()
{
self.models_home = Some(home.join(stripped));
}
}
fn validate(&self) -> Result<(), ConfigError> {
match (&self.model, &self.backends) {
(Some(_), Some(_)) => {
return Err(ConfigError::Invalid(
"config: `model` and `backends` are mutually exclusive — \
pick one shape, not both"
.into(),
));
}
(None, None) => {
return Err(ConfigError::Invalid(
"config: must specify either `model` (legacy single-backend) \
or `backends` (multi-backend list)"
.into(),
));
}
_ => {}
}
if self.n_ctx == 0 {
return Err(ConfigError::Invalid("n_ctx must be > 0".into()));
}
if let Some(m) = &self.model {
validate_model_config(m)?;
}
if let Some(listen) = &self.listen {
if let Some(addr) = &listen.tcp
&& addr.trim().is_empty()
{
return Err(ConfigError::Invalid(
"listen.tcp must not be empty when set".into(),
));
}
if let Some(addr) = &listen.tcp_v2
&& addr.trim().is_empty()
{
return Err(ConfigError::Invalid(
"listen.tcp_v2 must not be empty when set".into(),
));
}
if let Some(addr) = &listen.tcp_embed
&& addr.trim().is_empty()
{
return Err(ConfigError::Invalid(
"listen.tcp_embed must not be empty when set".into(),
));
}
}
if let Some(list) = &self.backends {
if list.is_empty() {
return Err(ConfigError::Invalid(
"backends list must not be empty".into(),
));
}
let mut seen = std::collections::HashSet::with_capacity(list.len());
for entry in list {
let name = entry.name();
if name.is_empty() {
return Err(ConfigError::Invalid(
"backends[].name must not be empty".into(),
));
}
if !seen.insert(name.to_string()) {
return Err(ConfigError::Invalid(format!(
"duplicate backends[].name {name:?} — names must be unique"
)));
}
match entry {
BackendEntry::Llamacpp(e) => {
validate_model_config(&e.model)?;
if e.n_ctx == 0 {
return Err(ConfigError::Invalid(format!(
"backends[{name:?}].n_ctx must be > 0"
)));
}
}
BackendEntry::OpenaiCompat(e) => {
if e.base_url.trim().is_empty() {
return Err(ConfigError::Invalid(format!(
"backends[{name:?}].base_url must not be empty"
)));
}
if !(e.base_url.starts_with("https://")
|| e.base_url.starts_with("http://"))
{
return Err(ConfigError::Invalid(format!(
"backends[{name:?}].base_url must be http:// or https:// \
(got {:?})",
e.base_url
)));
}
if e.model.trim().is_empty() {
return Err(ConfigError::Invalid(format!(
"backends[{name:?}].model must not be empty"
)));
}
if e.timeout_secs == 0 {
return Err(ConfigError::Invalid(format!(
"backends[{name:?}].timeout_secs must be > 0"
)));
}
}
BackendEntry::BedrockInvoke(e) => {
if e.region.trim().is_empty() {
return Err(ConfigError::Invalid(format!(
"backends[{name:?}].region must not be empty"
)));
}
if e.model_id.trim().is_empty() {
return Err(ConfigError::Invalid(format!(
"backends[{name:?}].model_id must not be empty"
)));
}
if e.timeout_secs == 0 {
return Err(ConfigError::Invalid(format!(
"backends[{name:?}].timeout_secs must be > 0"
)));
}
}
}
}
}
Ok(())
}
pub fn resolved_backends(&self) -> Vec<BackendEntry> {
if let Some(list) = &self.backends {
return list.clone();
}
let m = self
.model
.as_ref()
.expect("validate() guarantees one of model|backends is set")
.clone();
vec![BackendEntry::Llamacpp(LlamacppEntry {
name: m.name.clone(),
model: m,
n_ctx: self.n_ctx,
n_gpu_layers: self.n_gpu_layers,
embed: false,
embed_pooling: None,
embed_n_ctx: default_embed_n_ctx(),
})]
}
}
fn validate_model_config(m: &ModelConfig) -> Result<(), ConfigError> {
if m.name.is_empty() {
return Err(ConfigError::Invalid("model.name must not be empty".into()));
}
if !m.source_url.starts_with("https://") {
return Err(ConfigError::Invalid(format!(
"model.source_url must be https:// (got {:?})",
m.source_url
)));
}
if m.sha256.len() != 64
|| !m
.sha256
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
{
return Err(ConfigError::Invalid(
"model.sha256 must be 64 lowercase hex chars".into(),
));
}
Ok(())
}
impl From<&ModelConfig> for crate::fetch::ModelSpec {
fn from(m: &ModelConfig) -> Self {
crate::fetch::ModelSpec {
name: m.name.clone(),
source_url: m.source_url.clone(),
sha256_hex: m.sha256.clone(),
size_bytes: m.size_bytes,
license: m.license.clone(),
source: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_config(s: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(s.as_bytes()).unwrap();
f.flush().unwrap();
f
}
fn good_json() -> String {
r#"{
"auto_pull": true,
"models_home": "/tmp/inferd-models-home",
"model": {
"name": "gemma-4-e4b",
"sha256": "30d1e7949597a3446726064e80b876fd1b5cba4aa6eec53d27afa420e731fb36",
"size_bytes": 5126304928,
"source_url": "https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-UD-Q4_K_XL.gguf",
"license": "apache-2.0"
},
"n_ctx": 8192,
"n_gpu_layers": 0
}"#
.to_string()
}
#[test]
fn load_well_formed_config() {
let f = write_config(&good_json());
let cfg = ConfigFile::load(f.path()).unwrap();
let m = cfg.model.as_ref().expect("legacy model present");
assert_eq!(m.name, "gemma-4-e4b");
assert_eq!(m.size_bytes, Some(5_126_304_928));
assert_eq!(m.license.as_deref(), Some("apache-2.0"));
assert!(cfg.auto_pull);
assert_eq!(cfg.n_ctx, 8192);
assert_eq!(
cfg.models_home,
Some(PathBuf::from("/tmp/inferd-models-home"))
);
}
#[test]
fn missing_file_returns_not_found() {
let path = std::env::temp_dir().join("inferd-config-does-not-exist.json");
let _ = std::fs::remove_file(&path);
let err = ConfigFile::load(&path).unwrap_err();
assert!(matches!(err, ConfigError::NotFound(_)));
}
#[test]
fn invalid_json_returns_parse_error() {
let f = write_config("{ not valid json");
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn http_url_rejected() {
let bad = good_json().replace("https://", "http://");
let f = write_config(&bad);
let err = ConfigFile::load(f.path()).unwrap_err();
match err {
ConfigError::Invalid(msg) => assert!(msg.contains("https://")),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn uppercase_sha_rejected() {
let bad = good_json().replace(
"30d1e7949597a3446726064e80b876fd1b5cba4aa6eec53d27afa420e731fb36",
"30D1E7949597A3446726064E80B876FD1B5CBA4AA6EEC53D27AFA420E731FB36",
);
let f = write_config(&bad);
let err = ConfigFile::load(f.path()).unwrap_err();
match err {
ConfigError::Invalid(msg) => assert!(msg.contains("lowercase hex")),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn short_sha_rejected() {
let bad = good_json().replace(
"30d1e7949597a3446726064e80b876fd1b5cba4aa6eec53d27afa420e731fb36",
"30d1e7",
);
let f = write_config(&bad);
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, ConfigError::Invalid(_)));
}
#[test]
fn defaults_when_optional_fields_missing() {
let json = r#"{
"model": {
"name": "gemma-4-e4b",
"sha256": "30d1e7949597a3446726064e80b876fd1b5cba4aa6eec53d27afa420e731fb36",
"source_url": "https://example.com/x.gguf"
}
}"#;
let f = write_config(json);
let cfg = ConfigFile::load(f.path()).unwrap();
let m = cfg.model.as_ref().expect("legacy model present");
assert!(cfg.auto_pull);
assert_eq!(cfg.n_ctx, 8192);
assert_eq!(cfg.n_gpu_layers, 0);
assert!(m.size_bytes.is_none());
assert!(cfg.models_home.is_none());
assert!(m.license.is_none());
}
#[test]
fn modelconfig_converts_to_fetch_modelspec() {
let cfg = ModelConfig {
name: "x".into(),
sha256: "abc".into(),
size_bytes: Some(42),
source_url: "https://e/x.gguf".into(),
license: Some("mit".into()),
};
let spec: crate::fetch::ModelSpec = (&cfg).into();
assert_eq!(spec.name, "x");
assert_eq!(spec.size_bytes, Some(42));
assert_eq!(spec.sha256_hex, "abc");
assert_eq!(spec.license.as_deref(), Some("mit"));
}
fn good_multi_backend_json() -> String {
r#"{
"models_home": "/tmp/inferd-models-home",
"backends": [
{
"kind": "llamacpp",
"name": "local-gemma",
"model": {
"name": "gemma-4-e4b",
"sha256": "30d1e7949597a3446726064e80b876fd1b5cba4aa6eec53d27afa420e731fb36",
"source_url": "https://example.com/gemma.gguf"
},
"n_ctx": 8192,
"n_gpu_layers": 35
},
{
"kind": "openai-compat",
"name": "anthropic-fallback",
"base_url": "https://api.anthropic.com",
"model": "claude-opus-4-7",
"api_key_env": "ANTHROPIC_API_KEY"
}
]
}"#
.to_string()
}
#[test]
fn load_multi_backend_config() {
let f = write_config(&good_multi_backend_json());
let cfg = ConfigFile::load(f.path()).unwrap();
assert!(cfg.model.is_none());
let list = cfg.backends.as_ref().expect("backends present");
assert_eq!(list.len(), 2);
match &list[0] {
BackendEntry::Llamacpp(e) => {
assert_eq!(e.name, "local-gemma");
assert_eq!(e.model.name, "gemma-4-e4b");
assert_eq!(e.n_ctx, 8192);
assert_eq!(e.n_gpu_layers, 35);
}
other => panic!("expected llamacpp, got {other:?}"),
}
match &list[1] {
BackendEntry::OpenaiCompat(e) => {
assert_eq!(e.name, "anthropic-fallback");
assert_eq!(e.base_url, "https://api.anthropic.com");
assert_eq!(e.model, "claude-opus-4-7");
assert_eq!(e.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
assert_eq!(e.timeout_secs, 300);
}
other => panic!("expected openai-compat, got {other:?}"),
}
}
#[test]
fn rejects_both_model_and_backends() {
let json = r#"{
"model": {
"name": "gemma-4-e4b",
"sha256": "30d1e7949597a3446726064e80b876fd1b5cba4aa6eec53d27afa420e731fb36",
"source_url": "https://example.com/x.gguf"
},
"backends": [
{
"kind": "openai-compat",
"name": "x",
"base_url": "https://api.openai.com",
"model": "gpt-4o-mini"
}
]
}"#;
let f = write_config(json);
let err = ConfigFile::load(f.path()).unwrap_err();
match err {
ConfigError::Invalid(msg) => assert!(msg.contains("mutually exclusive")),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn rejects_neither_model_nor_backends() {
let json = r#"{ "auto_pull": true }"#;
let f = write_config(json);
let err = ConfigFile::load(f.path()).unwrap_err();
match err {
ConfigError::Invalid(msg) => assert!(msg.contains("must specify either")),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn rejects_empty_backends_list() {
let json = r#"{ "backends": [] }"#;
let f = write_config(json);
let err = ConfigFile::load(f.path()).unwrap_err();
match err {
ConfigError::Invalid(msg) => assert!(msg.contains("must not be empty")),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn rejects_duplicate_backend_names() {
let json = r#"{
"backends": [
{
"kind": "openai-compat",
"name": "dup",
"base_url": "https://api.openai.com",
"model": "gpt-4o-mini"
},
{
"kind": "openai-compat",
"name": "dup",
"base_url": "https://api.anthropic.com",
"model": "claude-opus-4-7"
}
]
}"#;
let f = write_config(json);
let err = ConfigFile::load(f.path()).unwrap_err();
match err {
ConfigError::Invalid(msg) => assert!(msg.contains("duplicate")),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn rejects_openai_compat_without_base_url() {
let json = r#"{
"backends": [
{
"kind": "openai-compat",
"name": "x",
"base_url": "",
"model": "gpt-4o-mini"
}
]
}"#;
let f = write_config(json);
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, ConfigError::Invalid(_)));
}
#[test]
fn rejects_openai_compat_with_bad_scheme() {
let json = r#"{
"backends": [
{
"kind": "openai-compat",
"name": "x",
"base_url": "ftp://api.openai.com",
"model": "gpt-4o-mini"
}
]
}"#;
let f = write_config(json);
let err = ConfigFile::load(f.path()).unwrap_err();
match err {
ConfigError::Invalid(msg) => assert!(msg.contains("http")),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn accepts_openai_compat_with_localhost_http() {
let json = r#"{
"backends": [
{
"kind": "openai-compat",
"name": "ollama",
"base_url": "http://localhost:11434",
"model": "llama3.1:8b"
}
]
}"#;
let f = write_config(json);
let cfg = ConfigFile::load(f.path()).unwrap();
assert_eq!(cfg.resolved_backends().len(), 1);
}
#[test]
fn rejects_unknown_kind() {
let json = r#"{
"backends": [
{
"kind": "future-thing-not-supported",
"name": "x"
}
]
}"#;
let f = write_config(json);
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn loads_bedrock_invoke_entry() {
let json = r#"{
"backends": [
{
"kind": "bedrock-invoke",
"name": "bedrock-claude",
"region": "us-east-1",
"model_id": "anthropic.claude-3-5-sonnet-20241022-v2:0",
"bearer_token_env": "AWS_BEARER_TOKEN_BEDROCK"
}
]
}"#;
let f = write_config(json);
let cfg = ConfigFile::load(f.path()).unwrap();
let list = cfg.backends.as_ref().unwrap();
assert_eq!(list.len(), 1);
match &list[0] {
BackendEntry::BedrockInvoke(e) => {
assert_eq!(e.name, "bedrock-claude");
assert_eq!(e.region, "us-east-1");
assert_eq!(e.model_id, "anthropic.claude-3-5-sonnet-20241022-v2:0");
assert_eq!(
e.bearer_token_env.as_deref(),
Some("AWS_BEARER_TOKEN_BEDROCK")
);
assert!(e.endpoint.is_none());
assert_eq!(e.timeout_secs, 300);
}
other => panic!("expected bedrock-invoke, got {other:?}"),
}
}
#[test]
fn rejects_bedrock_invoke_without_region() {
let json = r#"{
"backends": [
{
"kind": "bedrock-invoke",
"name": "x",
"region": "",
"model_id": "anthropic.claude-3-5-sonnet-20241022-v2:0"
}
]
}"#;
let f = write_config(json);
let err = ConfigFile::load(f.path()).unwrap_err();
match err {
ConfigError::Invalid(msg) => assert!(msg.contains("region")),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn rejects_bedrock_invoke_without_model_id() {
let json = r#"{
"backends": [
{
"kind": "bedrock-invoke",
"name": "x",
"region": "us-east-1",
"model_id": ""
}
]
}"#;
let f = write_config(json);
let err = ConfigFile::load(f.path()).unwrap_err();
match err {
ConfigError::Invalid(msg) => assert!(msg.contains("model_id")),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn legacy_model_promotes_to_one_backend() {
let f = write_config(&good_json());
let cfg = ConfigFile::load(f.path()).unwrap();
let resolved = cfg.resolved_backends();
assert_eq!(resolved.len(), 1);
match &resolved[0] {
BackendEntry::Llamacpp(e) => {
assert_eq!(e.name, "gemma-4-e4b");
assert_eq!(e.n_ctx, 8192);
assert_eq!(e.n_gpu_layers, 0);
}
other => panic!("expected llamacpp, got {other:?}"),
}
}
#[test]
fn multi_backend_resolved_passes_through() {
let f = write_config(&good_multi_backend_json());
let cfg = ConfigFile::load(f.path()).unwrap();
let resolved = cfg.resolved_backends();
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].name(), "local-gemma");
assert_eq!(resolved[1].name(), "anthropic-fallback");
}
#[test]
fn listen_block_absent_by_default() {
let f = write_config(&good_json());
let cfg = ConfigFile::load(f.path()).unwrap();
assert!(cfg.listen.is_none());
}
#[test]
fn listen_block_carries_tcp_and_api_key_env() {
let json = r#"{
"model": {
"name": "gemma-4-e4b",
"sha256": "30d1e7949597a3446726064e80b876fd1b5cba4aa6eec53d27afa420e731fb36",
"source_url": "https://example.com/x.gguf"
},
"listen": {
"tcp": "127.0.0.1:9090",
"tcp_v2": "127.0.0.1:9091",
"api_key_env": "INFERD_TCP_API_KEY"
}
}"#;
let f = write_config(json);
let cfg = ConfigFile::load(f.path()).unwrap();
let listen = cfg.listen.as_ref().expect("listen present");
assert_eq!(listen.tcp.as_deref(), Some("127.0.0.1:9090"));
assert_eq!(listen.tcp_v2.as_deref(), Some("127.0.0.1:9091"));
assert_eq!(listen.api_key_env.as_deref(), Some("INFERD_TCP_API_KEY"));
}
#[test]
fn llamacpp_entry_embed_defaults_off() {
let f = write_config(&good_multi_backend_json());
let cfg = ConfigFile::load(f.path()).unwrap();
let list = cfg.backends.as_ref().unwrap();
match &list[0] {
BackendEntry::Llamacpp(e) => {
assert!(!e.embed);
assert!(e.embed_pooling.is_none());
assert_eq!(e.embed_n_ctx, 2048);
}
other => panic!("expected llamacpp, got {other:?}"),
}
}
#[test]
fn llamacpp_entry_carries_embed_fields() {
let json = r#"{
"backends": [
{
"kind": "llamacpp",
"name": "embeddings",
"embed": true,
"embed_pooling": 1,
"embed_n_ctx": 1024,
"model": {
"name": "embeddinggemma-300m",
"sha256": "30d1e7949597a3446726064e80b876fd1b5cba4aa6eec53d27afa420e731fb36",
"source_url": "https://example.com/embed.gguf"
}
}
]
}"#;
let f = write_config(json);
let cfg = ConfigFile::load(f.path()).unwrap();
let list = cfg.backends.as_ref().unwrap();
match &list[0] {
BackendEntry::Llamacpp(e) => {
assert!(e.embed);
assert_eq!(e.embed_pooling, Some(1));
assert_eq!(e.embed_n_ctx, 1024);
}
other => panic!("expected llamacpp, got {other:?}"),
}
}
#[test]
fn legacy_promotion_keeps_embed_off() {
let f = write_config(&good_json());
let cfg = ConfigFile::load(f.path()).unwrap();
let list = cfg.resolved_backends();
match &list[0] {
BackendEntry::Llamacpp(e) => {
assert!(!e.embed);
assert!(e.embed_pooling.is_none());
assert_eq!(e.embed_n_ctx, 2048);
}
other => panic!("expected llamacpp, got {other:?}"),
}
}
#[test]
fn listen_rejects_empty_tcp() {
let json = r#"{
"model": {
"name": "gemma-4-e4b",
"sha256": "30d1e7949597a3446726064e80b876fd1b5cba4aa6eec53d27afa420e731fb36",
"source_url": "https://example.com/x.gguf"
},
"listen": { "tcp": " " }
}"#;
let f = write_config(json);
let err = ConfigFile::load(f.path()).unwrap_err();
match err {
ConfigError::Invalid(msg) => assert!(msg.contains("listen.tcp")),
other => panic!("expected Invalid, got {other:?}"),
}
}
}