diff --git a/PR.md b/PR.md new file mode 100644 index 00000000..9f5c0198 --- /dev/null +++ b/PR.md @@ -0,0 +1,57 @@ +# Custom Providers (YAML-based provider configuration) + +## Summary + +Add `reloaded-code-provider-config` crate for defining custom LLM providers via YAML files - no Rust code required. Also fixes OpenAI-compatible providers to work without credentials when no env vars are listed (e.g., local Ollama). + +## Changes + +### New crate: `reloaded-code-provider-config` + +- **loader.rs** - `ProviderConfigLoader` collects YAML files and programmatic entries, merges them (later source wins), validates, and produces catalog sources +- **config.rs** - Serde shapes for `ProviderConfig` and `ModelConfig` +- **api_type.rs** - Maps `api_type` strings to `ProviderType` variants +- **error.rs** - Typed errors for validation and I/O failures + +Conventional config paths (opt-in via `with_default_paths()`): +- `~/.config/reloaded-code/providers.yaml` (user-global) +- `.reloaded/providers.yaml` (project-local) + +### Core changes + +- **`Modality::from_label()`** - Parses `"text"`, `"image"`, `"audio"`, `"video"` into `Modality` bitflags +- **Provider bridge fix** - OpenAI-compatible providers without credential env vars now work with empty API key + +### Documentation + +- New guide: `docs/src/guides/custom-providers.md` +- Updated nav, index, models-catalog, and examples pages + +## YAML schema + +```yaml +my-llm: + api_url: https://api.myllm.com/v1 + api_type: openai-compatible # optional, defaults to "openai-compatible" + env: # optional, credential env var names + - MY_LLM_API_KEY + models: + my-model: + max_input: 128000 # required + max_output: 8192 # required + modalities: [text, image] # optional, defaults to [text] + default_temperature: 0.7 # optional + default_top_p: 0.95 # optional +``` + +Supported `api_type`: `openai`, `openai-compatible`, `openai-responses`, `anthropic`, `google`, `groq`, `mistral`, `ollama`, `bedrock`, `azure`, `openrouter`, `huggingface`, `cohere`. + +## Test coverage + +- Config deserialization (full, minimal, multi-provider) +- Loader (single file, empty, override semantics, programmatic entries) +- Validation (missing fields, unrecognized api_type/modality, malformed YAML) +- Catalog conversion (ProviderType mapping, ProviderIdx consistency) +- Provider bridge (keyless endpoints succeed, credential-required still enforced) + + diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 8e07e10f..fe484e1b 100755 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -77,6 +77,8 @@ nav: - vs. OpenCode: - Comparison: comparison.md - Migration: migration.md - - Custom Framework Integration: guides/custom-framework.md + - Guides: + - Custom Framework Integration: guides/custom-framework.md + - Custom Providers: guides/custom-providers.md - Extra: - Extra Sandboxing Notes: extra-sandboxing-notes.md diff --git a/docs/src/examples.md b/docs/src/examples.md index 23fcbf57..01589d6b 100644 --- a/docs/src/examples.md +++ b/docs/src/examples.md @@ -4,8 +4,8 @@ Runnable examples live in the repository under each crate's `examples/` director ## SerdesAI Integration -| Example | Description | Run | -| ------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Example | Description | Run | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | [serdesai-basic] | Minimal agent with file tools, shell execution, web fetch, and streaming output. | `cargo run --example serdesai-basic -p reloaded-code-serdesai` | | [serdesai-agents] | Load markdown agents through `AgentLoader`, build a named agent via `AgentBuildContext` using the models.dev catalog. | `cargo run --example serdesai-agents -p reloaded-code-serdesai` | | [serdesai-task] | Orchestrator delegates a read-only task to a reader sub-agent, with streamed transcript and tool-call logging. | `cargo run --example serdesai-task -p reloaded-code-serdesai` | @@ -20,8 +20,8 @@ Runnable examples live in the repository under each crate's `examples/` director ## Core Library -| Example | Description | Run | -| -------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Example | Description | Run | +| -------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | [system_prompt_preview] | Full system prompt with all tools enabled, prints static token cost breakdown. | `cargo run --example system_prompt_preview -p reloaded-code-core` | | [system_prompt_preview_readonly] | Smaller read-only system prompt - minimal tool set, lower token cost. | `cargo run --example system_prompt_preview_readonly -p reloaded-code-core` | | [system_prompt_preview_compare] | Compares full vs read-only prompt footprints, prints character and token savings. | `cargo run --example system_prompt_preview_compare -p reloaded-code-core` | @@ -29,3 +29,11 @@ Runnable examples live in the repository under each crate's `examples/` director [system_prompt_preview]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-core/examples/system_prompt_preview.rs [system_prompt_preview_readonly]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-core/examples/system_prompt_preview_readonly.rs [system_prompt_preview_compare]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-core/examples/system_prompt_preview_compare.rs + +## Provider Config + +| Example | Description | Run | +| --------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | +| [config-loader] | Load custom provider YAML files and programmatic entries via `ProviderConfigLoader`. | `cargo run --example config-loader -p reloaded-code-provider-config` | + +[config-loader]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-provider-config/examples/config-loader.rs diff --git a/docs/src/guides/custom-providers.md b/docs/src/guides/custom-providers.md new file mode 100644 index 00000000..0c6323f9 --- /dev/null +++ b/docs/src/guides/custom-providers.md @@ -0,0 +1,179 @@ +# Custom Providers + +Define new LLM providers via YAML configuration files - no Rust code required. + +## Conventional config paths + +!!! note "Opt-in defaults" + These paths are conventions, not hard-coded lookup locations. + The library never reads config files unless the application + explicitly asks it to. + + - [`ProviderConfigLoader::with_default_paths()`][with_default_paths] + resolves and loads both paths in order (user-global, then + project-local). + - [`ProviderConfigLoader::add_path()`][add_path] loads a single + custom path. + + Use [`default_config_paths()`][default_config_paths] if you only + need the path list without loading. + +When using the defaults, later files override earlier ones per provider key: + +| Path | Scope | +| ---------------------------------------- | ------------- | +| `~/.config/reloaded-code/providers.yaml` | User-global | +| `.reloaded/providers.yaml` | Project-local | + +When using the defaults, you can have zero, one, or both files present. +If neither exists, only the models.dev catalog is used (no custom providers). + +[`default_config_paths()`][default_config_paths] is also available to resolve the conventional +paths without loading them. + +## YAML format + +```yaml +my-llm: + api_url: https://api.myllm.com/v1 + api_type: openai-compatible + env: + - MY_LLM_API_KEY + models: + MiniMax-M2.7: + max_input: 204800 + max_output: 131072 + modalities: [text] +``` + +Each provider must include at least one model under `models`. + +### Provider fields + +| Field | Type | Default | Notes | +| ------------ | ----------- | ------------------- | ------------------------------------- | +| `api_url` | string | required | Base URL for the API endpoint | +| `api_type` | string | `openai-compatible` | Maps to provider behaviour profile | +| `env` | string list | `[]` | Env var names checked for credentials | +| `models` | map | required | Models offered by this provider | + +### api_type values + +| Value | Provider type | +| ------------------- | -------------------------------------------- | +| `openai` | OpenAI (chat completions) | +| `openai-compatible` | Any OpenAI-API-compatible endpoint (default) | +| `openai-responses` | OpenAI Responses API | +| `anthropic` | Anthropic | +| `google` | Google/Gemini | +| `groq` | Groq | +| `mistral` | Mistral | +| `ollama` | Ollama | +| `bedrock` | AWS Bedrock | +| `azure` | Azure | +| `openrouter` | OpenRouter | +| `huggingface` | Hugging Face | +| `cohere` | Cohere | + +Omit `api_type` to default to `openai-compatible`. + +`openai` and `openai-compatible` both map to OpenAI chat completions. +`openai` signals actual OpenAI; `openai-compatible` signals any other +OpenAI-API-compatible endpoint. + +### Model fields + +| Field | Type | Default | Notes | +| --------------------- | ----------- | -------- | ----------------------------------------------- | +| `max_input` | u32 | required | Context window / input limit | +| `max_output` | u32 | required | Output token limit | +| `modalities` | string list | `[text]` | Supported modalities: text, image, audio, video | +| `default_temperature` | f32 | - | Default sampling temperature | +| `default_top_p` | f32 | - | Default nucleus sampling | + +## Credentials + +Custom providers use the existing `CredentialResolver` - no separate +resolution path needed. + +The `env` field lists environment variable names to check, in order. +At runtime, `CredentialResolver` checks its overrides first, then falls +back to those env vars. + +```yaml +my-llm: + api_url: https://api.myllm.com/v1 + env: + - MY_LLM_API_KEY + - MY_LLM_TOKEN # fallback +``` + +For providers with no `env` entry (e.g., local endpoints like Ollama +behind a compatibility layer), no API key is required. + +## Rust API + +```rust +use reloaded_code_provider_config::{ModelConfig, ProviderConfig, ProviderConfigLoader}; + +fn main() -> Result<(), Box> { + // Option A: Use conventional paths (user-global then project-local). + let mut loader = ProviderConfigLoader::with_default_paths()?; + + // Option B: Full control - choose paths yourself. + // let mut loader = ProviderConfigLoader::new(); + // loader.add_path(".reloaded/providers.yaml")?; + + // Add a programmatic entry (loaded last, overrides any file entry with the same key). + loader.add_provider("my-llm", ProviderConfig { + api_url: Some("https://api.myllm.com/v1".into()), + api_type: Some("openai-compatible".into()), + env: Some(vec!["MY_LLM_API_KEY".into()]), + models: Some({ + let mut m = indexmap::IndexMap::new(); + m.insert("my-model".to_string(), ModelConfig { + max_input: 128000, + max_output: 8192, + modalities: vec!["text".to_string()], + default_temperature: None, + default_top_p: None, + }); + m + }), + }); + + let loaded = loader.load()?; + let (providers, models) = loaded.to_catalog_sources(); + + // Pass to ModelCatalog::build() alongside models.dev sources. + // let catalog = ModelCatalog::build(&providers, &models)?; + Ok(()) +} +``` + +## Merge Behaviour + +When multiple config sources define the same provider key, the **later** +source completely replaces the earlier entry. There is no deep merge of +model maps - the entire provider entry is replaced. + +```yaml +# File 1: ~/.config/reloaded-code/providers.yaml +my-llm: + api_url: https://api.myllm.com/v1 + models: + v1: { max_input: 128000, max_output: 8192 } + +# File 2: .reloaded/providers.yaml (loaded later, wins) +my-llm: + api_url: https://api.myllm.com/v2 + models: + v2: { max_input: 256000, max_output: 16384 } +``` + +Result: only `v2` model exists under `my-llm` - the `v1` model from +file 1 is fully replaced. + +[with_default_paths]: https://docs.rs/reloaded-code-provider-config/latest/reloaded_code_provider_config/struct.ProviderConfigLoader.html#method.with_default_paths +[add_path]: https://docs.rs/reloaded-code-provider-config/latest/reloaded_code_provider_config/struct.ProviderConfigLoader.html#method.add_path +[default_config_paths]: https://docs.rs/reloaded-code-provider-config/latest/reloaded_code_provider_config/fn.default_config_paths.html diff --git a/docs/src/index.md b/docs/src/index.md index 8f49a60d..6f1de5bf 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -195,6 +195,10 @@ dependency setup and an alternate path without agent files.

serdesai

Ready-to-use SerdesAI (LLM serialization framework) integration. 15 LLM provider adapters, multi-agent task delegation with recursion depth limits.

+
+

provider-config

+

YAML-based custom provider definitions. Add providers without writing Rust code, merge multiple config sources, convert to catalog types.

+

bubblewrap

Sandbox shell execution on Linux. Network-isolated, filesystem-filtered profiles for untrusted input. Two presets included.

diff --git a/docs/src/models-catalog.md b/docs/src/models-catalog.md index 77e92243..ee996d6a 100644 --- a/docs/src/models-catalog.md +++ b/docs/src/models-catalog.md @@ -113,6 +113,12 @@ hash-table format (~30 KiB in memory). Exactly one must be enabled. +## Custom providers + +You can define additional providers via YAML configuration files that extend +the models.dev catalog. See [Custom Providers](guides/custom-providers.md) for +the full schema and API reference. + [models.dev]: https://models.dev [tokio]: https://tokio.rs [zstd]: https://facebook.github.io/zstd/ diff --git a/src/Cargo.lock b/src/Cargo.lock index a91bbd57..7cc5f2ca 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -2829,6 +2829,21 @@ dependencies = [ "zstd", ] +[[package]] +name = "reloaded-code-provider-config" +version = "0.1.0" +dependencies = [ + "dirs", + "indexmap", + "indoc", + "reloaded-code-core", + "rstest", + "serde", + "serde_yaml", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "reloaded-code-serdesai" version = "0.2.0" diff --git a/src/Cargo.toml b/src/Cargo.toml index 357d6e6d..28897e55 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -7,6 +7,7 @@ members = [ "reloaded-code-agents", "reloaded-code-models-dev", "reloaded-code-bubblewrap", + "reloaded-code-provider-config", ] [workspace.dependencies] @@ -79,6 +80,7 @@ reloaded-code-core = { version = "0.2.0", path = "reloaded-code-core", default-f reloaded-code-bubblewrap = { version = "0.1.0", path = "reloaded-code-bubblewrap" } reloaded-code-agents = { version = "0.1.0", path = "reloaded-code-agents" } reloaded-code-models-dev = { version = "0.1.0", path = "reloaded-code-models-dev" } +reloaded-code-provider-config = { version = "0.1.0", path = "reloaded-code-provider-config" } # Dev dependencies criterion = "0.8" diff --git a/src/reloaded-code-core/src/models/catalog/public/modality.rs b/src/reloaded-code-core/src/models/catalog/public/modality.rs index b0da265a..f11d2e4d 100644 --- a/src/reloaded-code-core/src/models/catalog/public/modality.rs +++ b/src/reloaded-code-core/src/models/catalog/public/modality.rs @@ -36,9 +36,48 @@ bitflags! { } } +impl Modality { + /// Parse a combined modality label. + /// + /// Recognized: `"text"`, `"image"`, `"audio"`, `"video"`. + /// Returns `None` for unrecognized strings. + /// + /// When adding a new combined flag to the `bitflags!` block above, + /// add the corresponding label here and to the test below. + pub fn from_label(label: &str) -> Option { + match label { + "text" => Some(Self::TEXT), + "image" => Some(Self::IMAGE), + "audio" => Some(Self::AUDIO), + "video" => Some(Self::VIDEO), + _ => None, + } + } +} + impl Default for Modality { #[inline] fn default() -> Self { Self::TEXT } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_label_covers_all_combined_flags() { + // When adding a new combined flag, add its (label, flag) pair here. + const PAIRS: &[(&str, Modality)] = &[ + ("text", Modality::TEXT), + ("image", Modality::IMAGE), + ("audio", Modality::AUDIO), + ("video", Modality::VIDEO), + ]; + for (label, expected) in PAIRS { + assert_eq!(Modality::from_label(label), Some(*expected)); + } + assert_eq!(Modality::from_label("smell"), None); + } +} diff --git a/src/reloaded-code-provider-config/Cargo.toml b/src/reloaded-code-provider-config/Cargo.toml new file mode 100644 index 00000000..bc0cb6bf --- /dev/null +++ b/src/reloaded-code-provider-config/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "reloaded-code-provider-config" +version = "0.1.0" +edition = "2021" +description = "YAML-based custom provider configuration for reloaded-code" +repository = "https://github.com/Reloaded-Project/ReloadedCode" +license = "Apache-2.0" +include = ["src/**/*", "README.md"] +readme = "README.md" + +[dependencies] +reloaded-code-core = { workspace = true, default-features = false, features = ["tokio"] } +serde = { workspace = true } +serde_yaml = { workspace = true } +indexmap = { workspace = true } +thiserror = { workspace = true } +dirs = { workspace = true } + +[[example]] +name = "config-loader" +path = "examples/config-loader.rs" + +[dev-dependencies] +rstest = { workspace = true } +indoc = { workspace = true } +tempfile = { workspace = true } diff --git a/src/reloaded-code-provider-config/examples/config-loader.rs b/src/reloaded-code-provider-config/examples/config-loader.rs new file mode 100644 index 00000000..bcbf55e7 --- /dev/null +++ b/src/reloaded-code-provider-config/examples/config-loader.rs @@ -0,0 +1,60 @@ +//! Demonstrates loading custom provider configuration from files and programmatic entries. +//! +//! ```sh +//! cargo run -p reloaded-code-provider-config --example config-loader +//! ``` + +use reloaded_code_provider_config::{ModelConfig, ProviderConfig, ProviderConfigLoader}; + +fn main() -> Result<(), Box> { + // Pre-load conventional config paths (user-global then project-local). + // Only files that exist on disk are included. + let mut loader = ProviderConfigLoader::with_default_paths()?; + + // Add a programmatic provider entry (loaded last, overrides any file entry with the same key). + loader.add_provider( + "example-provider", + ProviderConfig { + api_url: Some("https://api.example.com/v1".into()), + api_type: Some("openai-compatible".into()), + env: Some(vec!["EXAMPLE_API_KEY".into()]), + models: Some({ + let mut m = indexmap::IndexMap::new(); + m.insert( + "example-model".to_string(), + ModelConfig { + max_input: 128000, + max_output: 8192, + modalities: vec!["text".to_string()], + default_temperature: None, + default_top_p: None, + }, + ); + m + }), + }, + ); + + let loaded = loader.load()?; + let (providers, models) = loaded.to_catalog_sources()?; + + println!( + "Loaded {} providers and {} models", + providers.len(), + models.len() + ); + for ps in &providers { + println!( + " provider: {} ({:?})", + ps.provider_key, ps.provider.api_type + ); + } + for ms in &models { + println!( + " model: {} (max_input={}, max_output={})", + ms.model_key, ms.model.max_input, ms.model.max_output + ); + } + + Ok(()) +} diff --git a/src/reloaded-code-provider-config/examples/providers.yaml b/src/reloaded-code-provider-config/examples/providers.yaml new file mode 100644 index 00000000..a9dd2f52 --- /dev/null +++ b/src/reloaded-code-provider-config/examples/providers.yaml @@ -0,0 +1,38 @@ +# Example custom provider configuration. +# Copy to ~/.config/reloaded-code/providers.yaml (user-global) +# or .reloaded/providers.yaml (project-local). + +# OpenAI-compatible endpoint with API key +my-llm: + api_url: https://api.myllm.com/v1 + api_type: openai-compatible + env: + - MY_LLM_API_KEY + models: + minimax-coding-plan/MiniMax-M2.7: + max_input: 128000 + max_output: 8192 + modalities: [text, image] + default_temperature: 0.7 + default_top_p: 0.95 + +# Local Ollama behind OpenAI-compatible layer (no key required) +local-ollama: + api_url: http://localhost:11434/v1 + api_type: openai-compatible + models: + llama3: + max_input: 8192 + max_output: 4096 + +# Anthropic provider +my-anthropic: + api_url: https://api.anthropic.com/v1 + api_type: anthropic + env: + - ANTHROPIC_API_KEY + models: + claude-sonnet-4-6: + max_input: 200000 + max_output: 8192 + modalities: [text, image] diff --git a/src/reloaded-code-provider-config/src/api_type.rs b/src/reloaded-code-provider-config/src/api_type.rs new file mode 100644 index 00000000..f18ef522 --- /dev/null +++ b/src/reloaded-code-provider-config/src/api_type.rs @@ -0,0 +1,67 @@ +//! Maps YAML `api_type` string values to [`ProviderType`] enum variants. + +use reloaded_code_core::models::ProviderType; + +/// Maps a YAML `api_type` string to a [`ProviderType`]. +/// +/// `openai` and `openai-compatible` both map to [`ProviderType::OpenAiCompletions`]. +/// `openai` signals actual OpenAI; `openai-compatible` signals any other +/// OpenAI-API-compatible endpoint. +/// +/// Returns [`ProviderType::Unknown`] for unrecognized strings. +pub fn api_type_from_str(s: &str) -> ProviderType { + match s { + "openai" | "openai-compatible" => ProviderType::OpenAiCompletions, + "openai-responses" => ProviderType::OpenAiResponses, + "anthropic" => ProviderType::Anthropic, + "google" => ProviderType::Google, + "groq" => ProviderType::Groq, + "mistral" => ProviderType::Mistral, + "ollama" => ProviderType::Ollama, + "bedrock" => ProviderType::Bedrock, + "azure" => ProviderType::Azure, + "openrouter" => ProviderType::OpenRouter, + "huggingface" => ProviderType::HuggingFace, + "cohere" => ProviderType::Cohere, + _ => ProviderType::Unknown, + } +} + +/// Default `api_type` string used when the field is omitted from YAML. +pub const DEFAULT_API_TYPE: &str = "openai-compatible"; + +#[cfg(test)] +mod tests { + use super::*; + use reloaded_code_core::models::ProviderType; + use rstest::rstest; + + #[rstest] + #[case::openai("openai", ProviderType::OpenAiCompletions)] + #[case::openai_compatible("openai-compatible", ProviderType::OpenAiCompletions)] + #[case::openai_responses("openai-responses", ProviderType::OpenAiResponses)] + #[case::anthropic("anthropic", ProviderType::Anthropic)] + #[case::google("google", ProviderType::Google)] + #[case::groq("groq", ProviderType::Groq)] + #[case::mistral("mistral", ProviderType::Mistral)] + #[case::ollama("ollama", ProviderType::Ollama)] + #[case::bedrock("bedrock", ProviderType::Bedrock)] + #[case::azure("azure", ProviderType::Azure)] + #[case::openrouter("openrouter", ProviderType::OpenRouter)] + #[case::huggingface("huggingface", ProviderType::HuggingFace)] + #[case::cohere("cohere", ProviderType::Cohere)] + #[case::unknown("totally-fake-provider", ProviderType::Unknown)] + #[case::empty("", ProviderType::Unknown)] + fn api_type_maps_to_correct_provider_type(#[case] input: &str, #[case] expected: ProviderType) { + assert_eq!(api_type_from_str(input), expected); + } + + #[test] + fn default_api_type_is_openai_compatible() { + assert_eq!(DEFAULT_API_TYPE, "openai-compatible"); + assert_eq!( + api_type_from_str(DEFAULT_API_TYPE), + ProviderType::OpenAiCompletions + ); + } +} diff --git a/src/reloaded-code-provider-config/src/config.rs b/src/reloaded-code-provider-config/src/config.rs new file mode 100644 index 00000000..b8b9a6ee --- /dev/null +++ b/src/reloaded-code-provider-config/src/config.rs @@ -0,0 +1,148 @@ +//! Serde deserialization shapes for YAML provider configuration. +//! +//! The top-level YAML document is an [`IndexMap`] of provider keys to +//! [`ProviderConfig`] values. Each provider maps its models to +//! [`ModelConfig`] values. +//! +//! ```yaml +//! # ── IndexMap ────────────────────────── +//! my-provider: # provider key (map key) +//! # ── ProviderConfig ──────────── +//! api_url: https://api.example.com/v1 +//! api_type: openai-compatible # optional; defaults to "openai-compatible" +//! env: # optional; checked in order by CredentialResolver +//! - MY_PROVIDER_API_KEY +//! models: +//! # ── ModelConfig ──────────── +//! gpt-4o: # model key (map key) +//! max_input: 128000 # required +//! max_output: 8192 # required +//! modalities: [text, image] # optional; defaults to ["text"] +//! default_temperature: 0.7 # optional; maps to ModelInfo::temperature +//! default_top_p: 0.95 # optional; maps to ModelInfo::top_p +//! ``` +//! +//! After deserialization, [`ProviderConfig`] and [`ModelConfig`] are converted +//! to [`reloaded_code_core::models::ProviderInfo`] and +//! [`reloaded_code_core::models::ModelInfo`] respectively, then discarded. + +use indexmap::IndexMap; +use serde::Deserialize; + +/// Per-provider configuration parsed from YAML. +/// +/// This is a serde deserialization shape only. After loading, it is converted +/// to [`reloaded_code_core::models::ProviderInfo`] and discarded. +#[derive(Debug, Clone, Deserialize)] +pub struct ProviderConfig { + /// Base URL for the provider API. Required for openai-compatible providers. + pub api_url: Option, + /// API type string, mapped via [`crate::api_type::api_type_from_str`]. + /// Defaults to `"openai-compatible"` when omitted. + pub api_type: Option, + /// Environment variable names checked by `CredentialResolver`, in order. + pub env: Option>, + /// Models offered by this provider. Key is the model identifier. + pub models: Option>, +} + +/// Per-model configuration parsed from YAML. +/// +/// This is a serde deserialization shape only. After loading, it is converted +/// to [`reloaded_code_core::models::ModelInfo`] and discarded. +#[derive(Debug, Clone, Deserialize)] +pub struct ModelConfig { + /// Maximum input token count. Required. + pub max_input: u32, + /// Maximum output token count. Required. + pub max_output: u32, + /// Content modalities this model supports. Defaults to `["text"]`. + #[serde(default = "default_modalities")] + pub modalities: Vec, + /// Default sampling temperature. Maps to `ModelInfo::temperature`. + pub default_temperature: Option, + /// Default nucleus sampling value. Maps to `ModelInfo::top_p`. + pub default_top_p: Option, +} + +fn default_modalities() -> Vec { + vec!["text".to_string()] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_full_provider_config() { + let yaml = indoc::indoc! {" + api_url: https://api.example.com/v1 + api_type: openai-compatible + env: + - EXAMPLE_API_KEY + models: + my-model: + max_input: 128000 + max_output: 8192 + modalities: + - text + - image + default_temperature: 0.7 + default_top_p: 0.95 + "}; + let config: ProviderConfig = serde_yaml::from_str(yaml).expect("should parse"); + assert_eq!( + config.api_url.as_deref(), + Some("https://api.example.com/v1") + ); + assert_eq!(config.api_type.as_deref(), Some("openai-compatible")); + assert_eq!(config.env.as_ref().unwrap().len(), 1); + let models = config.models.as_ref().expect("should have models"); + assert_eq!(models.len(), 1); + let model = &models["my-model"]; + assert_eq!(model.max_input, 128000); + assert_eq!(model.max_output, 8192); + assert_eq!(model.modalities, vec!["text", "image"]); + assert_eq!(model.default_temperature, Some(0.7)); + assert_eq!(model.default_top_p, Some(0.95)); + } + + #[test] + fn deserialize_minimal_provider_config() { + let yaml = indoc::indoc! {" + api_url: https://api.example.com/v1 + models: + tiny: + max_input: 4096 + max_output: 2048 + "}; + let config: ProviderConfig = serde_yaml::from_str(yaml).expect("should parse"); + assert!(config.api_type.is_none()); + assert!(config.env.is_none()); + let model = &config.models.as_ref().unwrap()["tiny"]; + assert_eq!(model.modalities, vec!["text"]); // default + assert!(model.default_temperature.is_none()); + assert!(model.default_top_p.is_none()); + } + + #[test] + fn deserialize_multiple_providers() { + let yaml = indoc::indoc! {" + provider-a: + api_url: https://a.example.com/v1 + models: + m1: { max_input: 8192, max_output: 4096 } + provider-b: + api_url: https://b.example.com/v1 + api_type: anthropic + env: [ B_API_KEY ] + models: + m2: { max_input: 200000, max_output: 8192 } + "}; + let map: IndexMap = + serde_yaml::from_str(yaml).expect("should parse"); + assert_eq!(map.len(), 2); + assert!(map.contains_key("provider-a")); + assert!(map.contains_key("provider-b")); + } +} diff --git a/src/reloaded-code-provider-config/src/error.rs b/src/reloaded-code-provider-config/src/error.rs new file mode 100644 index 00000000..ec8aa7e0 --- /dev/null +++ b/src/reloaded-code-provider-config/src/error.rs @@ -0,0 +1,69 @@ +//! Error type for provider configuration operations. + +use std::path::PathBuf; +use thiserror::Error; + +/// Errors that can occur when loading or validating provider configuration. +#[derive(Debug, Error)] +pub enum ProviderConfigError { + /// A config file could not be read. + #[error("failed to read config file `{path}`: {source}")] + FileRead { + /// Path of the config file. + path: PathBuf, + /// Underlying I/O error. + source: std::io::Error, + }, + /// A config file contains invalid YAML. + #[error("failed to parse config file `{path}`: {source}")] + YamlParse { + /// Path of the config file. + path: PathBuf, + /// Underlying YAML parse error. + source: serde_yaml::Error, + }, + /// A provider entry is missing required fields. + #[error("provider `{provider_key}` is missing required field: {field}")] + MissingField { + /// Provider key from the YAML map. + provider_key: String, + /// Name of the missing field. + field: &'static str, + }, + /// A model entry is missing required fields. + #[error("provider `{provider_key}` model `{model_key}` is missing required field: {field}")] + ModelMissingField { + /// Provider key from the YAML map. + provider_key: String, + /// Model key from the provider's models map. + model_key: String, + /// Name of the missing field. + field: &'static str, + }, + /// A modality string is not recognized. + #[error("provider `{provider_key}` model `{model_key}` has unrecognized modality `{value}`; expected one of: text, image, audio, video")] + UnrecognizedModality { + /// Provider key from the YAML map. + provider_key: String, + /// Model key from the provider's models map. + model_key: String, + /// The unrecognized modality string. + value: String, + }, + /// An `api_type` string is not recognized. + #[error("provider `{provider_key}` has unrecognized api_type `{value}`")] + UnrecognizedApiType { + /// Provider key from the YAML map. + provider_key: String, + /// The unrecognized api_type string. + value: String, + }, + /// Provider count exceeds the `u16` provider-index address space. + #[error("provider count {count} exceeds supported maximum {max}")] + TooManyProviders { + /// Number of providers supplied. + count: usize, + /// Maximum supported provider count. + max: usize, + }, +} diff --git a/src/reloaded-code-provider-config/src/lib.rs b/src/reloaded-code-provider-config/src/lib.rs new file mode 100644 index 00000000..0b8188d9 --- /dev/null +++ b/src/reloaded-code-provider-config/src/lib.rs @@ -0,0 +1,15 @@ +//! YAML-based custom provider configuration. +//! +//! Parse provider definitions from YAML files, merge multiple sources, +//! and convert them into catalog types for [`ModelCatalog::build()`]. +//! +//! [`ModelCatalog::build()`]: reloaded_code_core::models::ModelCatalog::build + +mod api_type; +mod config; +mod error; +mod loader; + +pub use config::{ModelConfig, ProviderConfig}; +pub use error::ProviderConfigError; +pub use loader::{default_config_paths, LoadedProviderConfig, ProviderConfigLoader}; diff --git a/src/reloaded-code-provider-config/src/loader.rs b/src/reloaded-code-provider-config/src/loader.rs new file mode 100644 index 00000000..a74037f2 --- /dev/null +++ b/src/reloaded-code-provider-config/src/loader.rs @@ -0,0 +1,682 @@ +//! Builder that collects config sources, merges them, and produces catalog inputs. +//! +//! Use [`ProviderConfigLoader`] to assemble config sources (YAML files and +//! programmatic entries), then call [`ProviderConfigLoader::load()`] to merge +//! and validate them into a [`LoadedProviderConfig`]. Finally, call +//! [`LoadedProviderConfig::to_catalog_sources()`] to obtain the +//! `(Vec, Vec)` pair. The model catalog +//! consumes this pair. + +use indexmap::IndexMap; +use std::path::{Path, PathBuf}; + +use crate::api_type::{api_type_from_str, DEFAULT_API_TYPE}; +use crate::config::ProviderConfig; +use crate::error::ProviderConfigError; +use reloaded_code_core::models::{ + Modality, ModelInfo, ProviderIdx, ProviderInfo, ProviderModelSource, ProviderSource, + ProviderType, +}; + +const CONFIG_FILENAME: &str = "providers.yaml"; +const CONFIG_DIR_NAME: &str = "reloaded-code"; +const PROJECT_LOCAL_DIR: &str = ".reloaded"; + +/// Builder that collects an ordered list of config sources and merges them +/// into a [`LoadedProviderConfig`]. +/// +/// # Merge semantics +/// +/// Later sources override earlier ones per-provider-key. When a provider key +/// appears in a later source, the entire provider entry from the earlier source +/// is replaced (no deep merge of models). +/// +/// # Quick start +/// +/// Use [`Self::with_default_paths()`] to pre-load the conventional config +/// file locations, then chain additional sources before calling [`Self::load()`]. +pub struct ProviderConfigLoader { + sources: Vec, +} + +impl ProviderConfigLoader { + /// Creates a loader pre-loaded with conventional config file paths. + /// + /// Calls [`default_config_paths()`] and adds each existing path as a + /// source. Your application must call [`Self::load()`] explicitly. + /// + /// # Returns + /// + /// - `Ok(Self)`: A loader with conventional file sources appended. + /// + /// Equivalent to: + /// + /// ```rust,no_run + /// use reloaded_code_provider_config::{ProviderConfigLoader, default_config_paths}; + /// fn example() -> Result<(), Box> { + /// let mut loader = ProviderConfigLoader::new(); + /// for path in default_config_paths() { + /// loader.add_path(&path)?; + /// } + /// Ok(()) + /// } + /// ``` + pub fn with_default_paths() -> Result { + let mut loader = Self::new(); + for path in default_config_paths() { + loader.add_path(&path)?; + } + Ok(loader) + } + + /// Creates an empty loader with no sources. + pub fn new() -> Self { + Self { + sources: Vec::new(), + } + } + + /// Adds a YAML config file path to the source list. + /// + /// The file is read and parsed during [`Self::load()`]. + /// + /// # Arguments + /// + /// - `path`: Filesystem path to a YAML config file. The loader stores the + /// path without reading the file until [`Self::load()`] is called. + /// + /// # Returns + /// + /// - `Ok(&mut Self)`: The loader, for chaining. + pub fn add_path(&mut self, path: impl AsRef) -> Result<&mut Self, ProviderConfigError> { + self.sources + .push(ConfigSource::File(path.as_ref().to_path_buf())); + Ok(self) + } + + /// Adds a programmatic provider entry. + /// + /// Programmatic entries participate in merge order just like file-based + /// sources. The loader appends each entry to the source list. + /// + /// The `key` is a user-chosen provider identifier (e.g., `"my-llm"`, + /// `"local-ollama"`). It becomes the [`ProviderSource::provider_key`] in + /// the catalog and is used for model lookups as `"{key}/{model}"`. + /// These keys are custom provider identifiers, not the provider names in + /// the pre-bundled catalog (hosted at models.dev). + pub fn add_provider(&mut self, key: &str, config: ProviderConfig) -> &mut Self { + self.sources.push(ConfigSource::Programmatic { + key: key.to_string(), + config, + }); + self + } + + /// Reads all sources, validates them, and merges into a single config. + /// + /// Later sources override earlier ones per-provider-key. + /// + /// # Errors + /// + /// - Returns [`ProviderConfigError::FileRead`] when a config file cannot be read from disk. + /// - Returns [`ProviderConfigError::YamlParse`] when a config file contains invalid YAML. + /// - Returns [`ProviderConfigError::MissingField`] when a provider is missing `api_url` + /// (for non-Ollama providers) or `models`, or when `models` is empty. + /// - Returns [`ProviderConfigError::UnrecognizedModality`] when a model has a modality + /// string that is not one of: `text`, `image`, `audio`, `video`. + /// - Returns [`ProviderConfigError::UnrecognizedApiType`] when a provider has an + /// `api_type` string that does not map to a known [`ProviderType`] variant. + pub fn load(self) -> Result { + let mut merged: IndexMap = IndexMap::new(); + + for source in self.sources { + let providers = match source { + ConfigSource::File(path) => { + let contents = std::fs::read_to_string(&path).map_err(|e| { + ProviderConfigError::FileRead { + path: path.clone(), + source: e, + } + })?; + let parsed: IndexMap = serde_yaml::from_str(&contents) + .map_err(|e| ProviderConfigError::YamlParse { path, source: e })?; + parsed + } + ConfigSource::Programmatic { key, config } => { + let mut map = IndexMap::new(); + map.insert(key, config); + map + } + }; + + for (key, config) in providers { + // Later source wins: full replacement, no deep merge. + merged.insert(key, config); + } + } + + // Validate all merged entries. + for (key, config) in &merged { + // Validate api_type first so UnrecognizedApiType surfaces before + // MissingField(api_url); otherwise an invalid api_type resolves to + // Unknown (≠ Ollama) and the api_url check fires incorrectly. + let provider_type = + api_type_from_str(config.api_type.as_deref().unwrap_or(DEFAULT_API_TYPE)); + if provider_type == ProviderType::Unknown { + return Err(ProviderConfigError::UnrecognizedApiType { + provider_key: key.clone(), + value: config + .api_type + .as_deref() + .unwrap_or(DEFAULT_API_TYPE) + .to_string(), + }); + } + // api_url is required for non-Ollama providers. + if config.api_url.is_none() && provider_type != ProviderType::Ollama { + return Err(ProviderConfigError::MissingField { + provider_key: key.clone(), + field: "api_url", + }); + } + // models is required and must not be empty. + let models = + config + .models + .as_ref() + .ok_or_else(|| ProviderConfigError::MissingField { + provider_key: key.clone(), + field: "models", + })?; + if models.is_empty() { + return Err(ProviderConfigError::MissingField { + provider_key: key.clone(), + field: "models (must have at least one model)", + }); + } + for (model_key, model) in models { + // Validate modalities - check each string individually. + for mod_str in &model.modalities { + if Modality::from_label(mod_str).is_none() { + return Err(ProviderConfigError::UnrecognizedModality { + provider_key: key.clone(), + model_key: model_key.clone(), + value: mod_str.clone(), + }); + } + } + } + } + + Ok(LoadedProviderConfig { providers: merged }) + } +} + +impl Default for ProviderConfigLoader { + fn default() -> Self { + Self::new() + } +} + +/// Returns the conventional config file paths, in merge order. +/// +/// Order (later overrides earlier): +/// 1. `dirs::config_dir()/reloaded-code/providers.yaml` (user-global) +/// 2. `.reloaded/providers.yaml` (project-local, relative to CWD) +/// +/// Only paths that exist on disk are included. If neither file exists, +/// returns an empty vector and the application proceeds using only its +/// pre-bundled model catalog, with no user-supplied config sources. +/// +/// The application decides whether to call this at all - the library does +/// not auto-load config files. +pub fn default_config_paths() -> Vec { + let mut paths = Vec::new(); + + // User-global: platform config directory. + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join(CONFIG_DIR_NAME).join(CONFIG_FILENAME)); + } + + // Project-local: relative to current working directory. + paths.push(PathBuf::from(PROJECT_LOCAL_DIR).join(CONFIG_FILENAME)); + + // Filter to only paths that exist on disk. + paths.into_iter().filter(|p| p.exists()).collect() +} + +/// Merged, validated provider configuration ready for catalog conversion. +/// +/// Call [`Self::to_catalog_sources()`] to obtain `Result<(Vec, Vec), ProviderConfigError>` +/// that can be passed directly to [`ModelCatalog::build()`]. +/// +/// The map keys are user-chosen provider identifiers (e.g., `"my-llm"`, +/// `"local-ollama"`) that become [`ProviderSource::provider_key`] values in +/// the catalog. These are distinct from the provider names in the +/// pre-bundled catalog (hosted at models.dev) - they are custom providers +/// defined by the user. +/// +/// [`ModelCatalog::build()`]: reloaded_code_core::models::ModelCatalog::build +#[derive(Debug)] +pub struct LoadedProviderConfig { + /// Merged provider entries keyed by user-chosen provider identifier. + pub providers: IndexMap, +} + +impl LoadedProviderConfig { + /// Converts the merged config into catalog source types. + /// + /// # Returns + /// + /// - `Ok((Vec, Vec))`: A pair where + /// each model's [`ProviderModelSource::provider_idx`] (a [`ProviderIdx`] numeric + /// index) corresponds to its provider's position in the first vector. The + /// returned [`ProviderModelSource`] borrows `model_key` strings from `self`, so + /// `self` must outlive the sources. + /// + /// # Errors + /// + /// Returns [`ProviderConfigError::TooManyProviders`] when the number of + /// providers exceeds `u16::MAX + 1` (65,536), which is the maximum + /// addressable by [`ProviderIdx`]. + pub fn to_catalog_sources( + &self, + ) -> Result<(Vec, Vec>), ProviderConfigError> { + let provider_count = self.providers.len(); + let max = (u16::MAX as usize) + 1; + if provider_count > max { + return Err(ProviderConfigError::TooManyProviders { + count: provider_count, + max, + }); + } + let mut provider_sources = Vec::with_capacity(self.providers.len()); + let mut model_sources = Vec::new(); + + for (idx, (key, config)) in self.providers.iter().enumerate() { + // Resolve the provider type from the api_type string, defaulting to openai-compatible. + let provider_type = + api_type_from_str(config.api_type.as_deref().unwrap_or(DEFAULT_API_TYPE)); + let provider_info = ProviderInfo { + api_url: config.api_url.clone().unwrap_or_default(), + env_vars: config.env.clone().unwrap_or_default(), + api_type: provider_type, + }; + let provider_source = ProviderSource::new(key, provider_info); + let provider_idx = ProviderIdx::new(idx as u16); + + // Convert each model entry into a ProviderModelSource. + if let Some(models) = &config.models { + for (model_key, model_config) in models { + // Build the modality bitmask by OR'ing individual Modality flags; + // defaults to text-only if the model declares no modalities. + let mut modalities = Modality::empty(); + for s in &model_config.modalities { + if let Some(m) = Modality::from_label(s) { + modalities |= m; + } + } + if modalities.is_empty() { + modalities = Modality::TEXT; + } + let model_info = ModelInfo { + modalities, + max_input: model_config.max_input, + max_output: model_config.max_output, + temperature: model_config.default_temperature, + top_p: model_config.default_top_p, + }; + model_sources.push(ProviderModelSource::new( + provider_idx, + model_key, + model_info, + )); + } + } + + provider_sources.push(provider_source); + } + + Ok((provider_sources, model_sources)) + } +} + +/// Individual source of provider configuration: a YAML file or a programmatic entry. +enum ConfigSource { + /// A YAML file on disk. + File(std::path::PathBuf), + /// A programmatic provider entry added at build time. + Programmatic { key: String, config: ProviderConfig }, +} + +#[cfg(test)] +mod tests { + use super::*; + use reloaded_code_core::models::{Modality, ProviderType}; + use tempfile::NamedTempFile; + + fn write_yaml(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().expect("temp file"); + std::io::Write::write_all(&mut f, content.as_bytes()).expect("write yaml"); + f + } + + #[test] + fn load_single_file() { + let f = write_yaml(indoc::indoc! {" + my-llm: + api_url: https://api.example.com/v1 + env: + - EXAMPLE_API_KEY + models: + m1: + max_input: 128000 + max_output: 8192 + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f.path()).expect("add_path"); + let loaded = loader.load().expect("load"); + assert_eq!(loaded.providers.len(), 1); + assert!(loaded.providers.contains_key("my-llm")); + } + + #[test] + fn load_empty_produces_no_providers() { + let loaded = ProviderConfigLoader::new().load().expect("load"); + assert!(loaded.providers.is_empty()); + let (ps, ms) = loaded.to_catalog_sources().expect("catalog sources"); + assert!(ps.is_empty()); + assert!(ms.is_empty()); + } + + #[test] + fn later_file_overrides_same_provider_key() { + let f1 = write_yaml(indoc::indoc! {" + shared: + api_url: https://old.example.com/v1 + models: + m1: { max_input: 4096, max_output: 2048 } + "}); + let f2 = write_yaml(indoc::indoc! {" + shared: + api_url: https://new.example.com/v1 + models: + m2: { max_input: 128000, max_output: 8192 } + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f1.path()).expect("add_path 1"); + loader.add_path(f2.path()).expect("add_path 2"); + let loaded = loader.load().expect("load"); + let config = &loaded.providers["shared"]; + assert_eq!( + config.api_url.as_deref(), + Some("https://new.example.com/v1") + ); + // Only m2 exists - no deep merge. + let models = config.models.as_ref().expect("models"); + assert!(!models.contains_key("m1")); + assert!(models.contains_key("m2")); + } + + #[test] + fn earlier_keys_preserved_when_not_in_later_source() { + let f1 = write_yaml(indoc::indoc! {" + alpha: + api_url: https://alpha.example.com/v1 + models: + m1: { max_input: 8192, max_output: 4096 } + "}); + let f2 = write_yaml(indoc::indoc! {" + beta: + api_url: https://beta.example.com/v1 + models: + m2: { max_input: 128000, max_output: 8192 } + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f1.path()).expect("add_path 1"); + loader.add_path(f2.path()).expect("add_path 2"); + let loaded = loader.load().expect("load"); + assert_eq!(loaded.providers.len(), 2); + assert!(loaded.providers.contains_key("alpha")); + assert!(loaded.providers.contains_key("beta")); + } + + #[test] + fn programmatic_entry_overrides_file() { + let f = write_yaml(indoc::indoc! {" + my-llm: + api_url: https://file.example.com/v1 + models: + m1: { max_input: 4096, max_output: 2048 } + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f.path()).expect("add_path"); + loader.add_provider( + "my-llm", + ProviderConfig { + api_url: Some("https://programmatic.example.com/v1".into()), + api_type: Some("anthropic".into()), + env: None, + models: Some({ + let mut m = indexmap::IndexMap::new(); + m.insert( + "m2".to_string(), + crate::config::ModelConfig { + max_input: 8192, + max_output: 4096, + modalities: vec!["text".to_string()], + default_temperature: None, + default_top_p: None, + }, + ); + m + }), + }, + ); + let loaded = loader.load().expect("load"); + let config = &loaded.providers["my-llm"]; + assert_eq!( + config.api_url.as_deref(), + Some("https://programmatic.example.com/v1") + ); + assert_eq!(config.api_type.as_deref(), Some("anthropic")); + } + + #[test] + fn to_catalog_sources_produces_correct_types() { + let f = write_yaml(indoc::indoc! {" + my-llm: + api_url: https://api.example.com/v1 + api_type: anthropic + env: + - MY_LLM_API_KEY + models: + claude-3: + max_input: 200000 + max_output: 8192 + modalities: [text, image] + default_temperature: 0.7 + default_top_p: 0.95 + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f.path()).expect("add_path"); + let loaded = loader.load().expect("load"); + let (providers, models) = loaded.to_catalog_sources().expect("catalog sources"); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].provider_key, "my-llm"); + assert_eq!(providers[0].provider.api_type, ProviderType::Anthropic); + assert_eq!(providers[0].provider.api_url, "https://api.example.com/v1"); + assert_eq!(providers[0].provider.env_vars, vec!["MY_LLM_API_KEY"]); + assert_eq!(models.len(), 1); + assert_eq!(models[0].model_key, "claude-3"); + assert_eq!(models[0].model.max_input, 200000); + assert_eq!(models[0].model.max_output, 8192); + assert_eq!(models[0].model.modalities, Modality::TEXT | Modality::IMAGE); + assert_eq!(models[0].model.temperature, Some(0.7)); + assert_eq!(models[0].model.top_p, Some(0.95)); + } + + #[test] + fn to_catalog_sources_provider_idx_is_consistent() { + let f = write_yaml(indoc::indoc! {" + alpha: + api_url: https://a.example.com/v1 + models: + m1: { max_input: 8192, max_output: 4096 } + beta: + api_url: https://b.example.com/v1 + models: + m2: { max_input: 128000, max_output: 8192 } + m3: { max_input: 64000, max_output: 4096 } + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f.path()).expect("add_path"); + let loaded = loader.load().expect("load"); + let (providers, models) = loaded.to_catalog_sources().expect("catalog sources"); + assert_eq!(providers.len(), 2); + assert_eq!(models.len(), 3); + // All models under "alpha" should have provider_idx 0. + assert_eq!(models[0].provider_idx, ProviderIdx::new(0)); + // Models under "beta" should have provider_idx 1. + assert_eq!(models[1].provider_idx, ProviderIdx::new(1)); + assert_eq!(models[2].provider_idx, ProviderIdx::new(1)); + } + + #[test] + fn to_catalog_sources_default_api_type_is_openai_compatible() { + let f = write_yaml(indoc::indoc! {" + local: + api_url: http://localhost:11434/v1 + models: + m1: { max_input: 4096, max_output: 2048 } + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f.path()).expect("add_path"); + let loaded = loader.load().expect("load"); + let (providers, _) = loaded.to_catalog_sources().expect("catalog sources"); + assert_eq!( + providers[0].provider.api_type, + ProviderType::OpenAiCompletions + ); + } + + #[test] + fn load_rejects_unrecognized_api_type() { + let f = write_yaml(indoc::indoc! {" + bad: + api_type: totally-fake + api_url: https://example.com/v1 + models: + m1: { max_input: 4096, max_output: 2048 } + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f.path()).expect("add_path"); + let result = loader.load(); + let err = result.expect_err("should reject unknown api_type"); + assert!( + err.to_string().contains("unrecognized api_type"), + "error was: {err}" + ); + } + + #[test] + fn load_rejects_unrecognized_modality() { + let f = write_yaml(indoc::indoc! {" + bad: + api_url: https://example.com/v1 + models: + m1: { max_input: 4096, max_output: 2048, modalities: [smell] } + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f.path()).expect("add_path"); + let result = loader.load(); + let err = result.expect_err("should reject unknown modality"); + assert!( + err.to_string().contains("unrecognized modality"), + "error was: {err}" + ); + } + + #[test] + fn load_file_not_found_returns_error() { + let mut loader = ProviderConfigLoader::new(); + loader.add_path("/nonexistent/path.yaml").expect("add_path"); + let result = loader.load(); + assert!(result.is_err()); + } + + #[test] + fn load_rejects_malformed_yaml() { + let f = write_yaml("not: valid: yaml: ["); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f.path()).expect("add_path"); + let result = loader.load(); + let err = result.expect_err("should reject malformed YAML"); + assert!(err.to_string().contains("parse"), "error was: {err}"); + } + + #[test] + fn load_rejects_empty_models_map() { + let f = write_yaml(indoc::indoc! {" + bad: + api_url: https://example.com/v1 + models: {} + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f.path()).expect("add_path"); + let result = loader.load(); + let err = result.expect_err("should reject empty models map"); + assert!(err.to_string().contains("models"), "error was: {err}"); + } + + #[test] + fn load_rejects_invalid_api_type_even_when_api_url_missing() { + let f = write_yaml(indoc::indoc! {" + bad: + api_type: totally-fake + models: + m1: { max_input: 4096, max_output: 2048 } + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f.path()).expect("add_path"); + let err = loader.load().expect_err("should reject unknown api_type"); + assert!( + err.to_string().contains("unrecognized api_type"), + "error was: {err}" + ); + } + + #[test] + fn load_rejects_missing_api_url_for_non_ollama() { + let f = write_yaml(indoc::indoc! {" + bad: + api_type: openai-compatible + models: + m1: { max_input: 4096, max_output: 2048 } + "}); + let mut loader = ProviderConfigLoader::new(); + loader.add_path(f.path()).expect("add_path"); + let result = loader.load(); + let err = result.expect_err("should reject missing api_url"); + assert!(err.to_string().contains("api_url"), "error was: {err}"); + } + + #[test] + fn default_config_paths_returns_empty_when_no_files_exist() { + // In a temp directory with no config files, default_config_paths() + // should return an empty vector (project-local path doesn't exist). + // This test is inherently environment-dependent, so we only verify + // the function doesn't panic and returns a Vec. + let _paths = default_config_paths(); + } + + #[test] + fn with_default_paths_creates_loader_without_panic() { + // with_default_paths() should succeed even when no config files exist. + let loader = ProviderConfigLoader::with_default_paths(); + assert!(loader.is_ok(), "should create loader even with no files"); + let _loaded = loader.expect("loader").load().expect("load"); + // May be empty if no config files exist on the test machine. + // The key invariant: it doesn't error on missing files. + } +} diff --git a/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs b/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs index f5013980..4e87f24f 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs @@ -321,14 +321,24 @@ fn build_openai_chat( ) -> Result { #[cfg(feature = "openai")] { - let api_key = require_env_value( - credentials, - provider_key, - OPENAI_COMPATIBLE_PROVIDER, - env_vars, - "a credential", - is_credential_env_var, - )?; + // When the provider lists credential env vars, require at least one to be set. + // When no credential env vars are listed (e.g., local OpenAI-compatible + // endpoints like Ollama behind a compat layer), proceed with an empty key. + let has_credential_vars = env_vars.iter().any(|v| is_credential_env_var(v)); + let api_key = if has_credential_vars { + // Credential env vars listed - require one to be set. + require_env_value( + credentials, + provider_key, + OPENAI_COMPATIBLE_PROVIDER, + env_vars, + "a credential", + is_credential_env_var, + )? + } else { + // No credential env vars - allow keyless endpoint (e.g., local Ollama). + String::new() + }; let mut model = serdes_ai_models::OpenAIChatModel::new(model_name, api_key); if let Some(api_url) = api_url { model = model.with_base_url(api_url); diff --git a/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/tests.rs b/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/tests.rs index 3456e600..52f4793f 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/tests.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/tests.rs @@ -428,3 +428,87 @@ fn build_serdes_model_rejects_unknown_provider_type() { .contains("provider `mystery` has no SerdesAI mapping") ); } + +#[test] +fn build_openai_chat_succeeds_without_credential_when_no_env_vars() { + // A provider with no credential env vars should build successfully + // even without any API key set (e.g., local OpenAI-compatible endpoints). + let catalog = build_catalog( + vec![( + "local-compat", + provider( + "http://localhost:11434/v1", + &[], // No env vars listed - no credential required + ProviderType::OpenAiCompletions, + ), + )], + vec![("local-compat", "llama3", model_info(8_192, 4_096))], + ); + let defaults = AgentDefaults::with_model("local-compat/llama3"); + let agent = config_with_model("planner", None); + let credentials = CredentialResolver::without_env(); + + let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); + let result = build_serdes_model(&catalog, &resolved, &credentials); + assert!(result.is_ok(), "should succeed with no credential env vars"); + let model = result.expect("should build"); + assert_eq!(model.spec.as_ref(), "openai:llama3"); +} + +#[test] +fn build_openai_chat_still_requires_credential_when_env_vars_present() { + // Existing behavior: when env vars list credentials, they must be set. + // This is a regression test to ensure the optional-key change doesn't + // break providers that do require credentials. + let catalog = build_catalog( + vec![( + "remote-compat", + provider( + "https://api.example.com/v1", + &["EXAMPLE_API_KEY"], // Credential env var listed + ProviderType::OpenAiCompletions, + ), + )], + vec![("remote-compat", "my-model", model_info(128_000, 8_192))], + ); + let defaults = AgentDefaults::with_model("remote-compat/my-model"); + let agent = config_with_model("planner", None); + let credentials = CredentialResolver::without_env(); + + let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); + let err = build_serdes_model(&catalog, &resolved, &credentials) + .err() + .expect("should fail without credentials"); + assert!( + err.to_string() + .contains("provider `remote-compat` mapped to serdes `openai` requires a credential"), + "error was: {err}" + ); +} + +#[test] +fn build_openai_chat_succeeds_with_non_credential_env_vars() { + // Env vars that don't match credential patterns (no _API_KEY/_TOKEN/_ACCESS_TOKEN suffix) + // should behave like no-credential - empty key is used. + let catalog = build_catalog( + vec![( + "compat-noncred", + provider( + "http://localhost:11434/v1", + &["MY_BASE_URL"], // Not a credential env var + ProviderType::OpenAiCompletions, + ), + )], + vec![("compat-noncred", "llama3", model_info(8_192, 4_096))], + ); + let defaults = AgentDefaults::with_model("compat-noncred/llama3"); + let agent = config_with_model("planner", None); + let credentials = CredentialResolver::without_env(); + + let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); + let result = build_serdes_model(&catalog, &resolved, &credentials); + assert!( + result.is_ok(), + "should succeed with non-credential env vars" + ); +}