diff --git a/Cargo.lock b/Cargo.lock index 8a736f982..da6fc42a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,9 +298,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -308,9 +308,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -2080,6 +2080,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "shared-gateway-api", "tempfile", "thiserror 2.0.19", "toml 0.8.2", @@ -2145,6 +2146,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "shared-gateway-api", "thiserror 2.0.19", "tokio", "tracing", @@ -5517,9 +5519,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -5581,9 +5583,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -6045,6 +6047,28 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared-cloud-providers" +version = "0.3.0" +dependencies = [ + "reqwest 0.12.28", + "serde", + "serde_json", + "shared-gateway-api", + "thiserror 2.0.19", + "time", + "tokio", +] + +[[package]] +name = "shared-gateway-api" +version = "0.3.0" +dependencies = [ + "serde", + "serde_json", + "time", +] + [[package]] name = "shared-loopback" version = "0.3.0" @@ -6878,7 +6902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 7a8f0a9cb..640eba0b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ base64 = "0.22" bytes = "1" promptforge-api = { path = "crates/promptforge-api", version = "0.3.0" } shared-promptforge-api = { path = "crates/shared-promptforge-api", version = "0.3.0" } +shared-gateway-api = { path = "crates/shared-gateway-api", version = "0.3.0" } +shared-cloud-providers = { path = "crates/shared-cloud-providers", version = "0.3.0" } gateway = { path = "crates/gateway", version = "0.3.0" } gateway-config = { path = "crates/gateway-config", version = "0.3.0" } gateway-config-ui = { path = "crates/gateway-config-ui", version = "0.3.0" } diff --git a/crates/gateway-config/Cargo.toml b/crates/gateway-config/Cargo.toml index 1bbfa27f7..875289858 100644 --- a/crates/gateway-config/Cargo.toml +++ b/crates/gateway-config/Cargo.toml @@ -16,6 +16,7 @@ documentation = "https://cppalliance.github.io/promptforge/" serde.workspace = true # JSON rendering of the resolved config for `Config::to_json`. serde_json.workspace = true +shared-gateway-api.workspace = true thiserror.workspace = true toml.workspace = true url.workspace = true diff --git a/crates/gateway-config/src/config.rs b/crates/gateway-config/src/config.rs index 5890ec9f4..4523ecb39 100644 --- a/crates/gateway-config/src/config.rs +++ b/crates/gateway-config/src/config.rs @@ -20,6 +20,9 @@ pub use companion::{ }; pub(crate) use imp::reject_profiles_directory; pub(crate) use interpolate::interpolate_value; +// The canonical home of the model-metadata types is `shared-gateway-api`; +// these re-exports keep the old paths compiling unchanged. +pub use shared_gateway_api::{Capabilities, ModelKind, ThinkingMode}; use stt::RawSttPipelineConfig; pub use stt::{ RECOMMENDED_STT_MODELS, RecommendedSttModel, SttModelConfig, SttPipelineConfig, SttRole, @@ -482,25 +485,6 @@ pub struct EndpointConfig { dominion: Option, } -/// How a model exposes chain-of-thought / thinking tokens to callers. -/// -/// Catalogued on each `[[model]]` so hosts can filter bindings before a -/// request is built. `never` and `always` mean the backend ignores a -/// per-call switch; `switchable` means the client may emit -/// `chat_template_kwargs.enable_thinking`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -#[non_exhaustive] -pub enum ThinkingMode { - /// The backend never emits thinking tokens; a per-call switch is ignored. - #[default] - Never, - /// The backend always emits thinking tokens; a per-call switch is ignored. - Always, - /// The client may turn thinking on or off per request. - Switchable, -} - /// The tool-calling dialect a chat model speaks. /// /// `openai` (the default) forwards tool definitions verbatim and expects @@ -529,84 +513,6 @@ impl fmt::Display for ToolDialect { } } -/// The workload a model serves: chat completions, embeddings, -/// classification, or speech synthesis. -/// -/// The kind scopes which configuration fields are meaningful: chat-only -/// fields (for example `thinking`, `default_max_tokens`, -/// `chat_template_file`) are rejected for non-chat kinds at validation, -/// while `context` applies to every kind. The catalog carries the kind so -/// clients can filter before building a request. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -#[non_exhaustive] -pub enum ModelKind { - /// Chat completions (`POST /v1/chat/completions`). The default. - #[default] - Chat, - /// Text embeddings. - Embedding, - /// Classification / reranking. - Classifier, - /// Speech synthesis (`POST /v1/audio/speech`). - Speech, -} - -impl fmt::Display for ModelKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let spelling = match self { - ModelKind::Chat => "chat", - ModelKind::Embedding => "embedding", - ModelKind::Classifier => "classifier", - ModelKind::Speech => "speech", - }; - f.write_str(spelling) - } -} - -/// Capability metadata advertised on the model catalog. -/// -/// These fields describe what a model can do rather than how the gateway -/// reaches it. They are flattened into `[[model]]` and `[[local_model]]`, -/// validated at load, and surfaced verbatim on `GET /v1/models` so clients -/// can shape requests before sending them. The effort knobs are chat-only -/// and require a `thinking` mode other than `never`; the `voices` list is -/// speech-only. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct Capabilities { - /// Max output tokens the model can emit per completion. Must not exceed - /// `context` when set. - #[serde(default, skip_serializing_if = "Option::is_none")] - max_output: Option, - /// Sampling temperature applied when the caller omits one. - #[serde(default, skip_serializing_if = "Option::is_none")] - default_temperature: Option, - /// Whether the model accepts image inputs. Defaults to false; a - /// `[local_model.multimodal_projector]` companion implies true. - #[serde(default)] - images: bool, - /// Whether the model can emit parallel tool calls. Defaults to false. - #[serde(default)] - parallel_tool_calls: bool, - /// The reasoning-effort levels the model accepts. Empty means the model - /// has no effort knob. - #[serde(default)] - effort_levels: Vec, - /// The effort level applied when the caller omits one; requires a - /// non-empty `effort_levels` and must name a listed level. - #[serde(default, skip_serializing_if = "Option::is_none")] - default_effort: Option, - /// Whether the model adaptively chooses how much to think per request; - /// chat kind only. Defaults to false. - #[serde(default)] - adaptive_thinking: bool, - /// The voices the model offers for speech synthesis; speech kind only. - /// Empty means the model exposes no fixed voice list. - #[serde(default)] - voices: Vec, -} - /// One model name and the backend it resolves to. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/gateway-config/src/config/accessors.rs b/crates/gateway-config/src/config/accessors.rs index 9560fb702..8bd74827c 100644 --- a/crates/gateway-config/src/config/accessors.rs +++ b/crates/gateway-config/src/config/accessors.rs @@ -1668,300 +1668,6 @@ impl LocalModelConfig { } } -impl Capabilities { - /// Returns the max output tokens the model can emit per completion, when - /// set. - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [[endpoint]] - /// # id = "e" - /// # protocol = "openai" - /// # base_url = "http://127.0.0.1:9" - /// # api_key = "" - /// # - /// # [[model]] - /// # name = "m" - /// # description = "a model" - /// # context = 8192 - /// # upstream = "u" - /// # endpoints = ["e"] - /// # max_output = 4096 - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// assert_eq!(config.models()[0].capabilities().max_output(), Some(4096)); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn max_output(&self) -> Option { - self.max_output - } - - /// Returns the sampling temperature applied when the caller omits one, - /// when set. - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [[endpoint]] - /// # id = "e" - /// # protocol = "openai" - /// # base_url = "http://127.0.0.1:9" - /// # api_key = "" - /// # - /// # [[model]] - /// # name = "m" - /// # description = "a model" - /// # context = 8192 - /// # upstream = "u" - /// # endpoints = ["e"] - /// # default_temperature = 0.7 - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// assert_eq!( - /// config.models()[0].capabilities().default_temperature(), - /// Some(0.7) - /// ); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn default_temperature(&self) -> Option { - self.default_temperature - } - - /// Returns whether the model accepts image inputs. - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [[endpoint]] - /// # id = "e" - /// # protocol = "openai" - /// # base_url = "http://127.0.0.1:9" - /// # api_key = "" - /// # - /// # [[model]] - /// # name = "m" - /// # description = "a model" - /// # context = 8192 - /// # upstream = "u" - /// # endpoints = ["e"] - /// # images = true - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// assert!(config.models()[0].capabilities().images()); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn images(&self) -> bool { - self.images - } - - /// Returns whether the model can emit parallel tool calls. - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [[endpoint]] - /// # id = "e" - /// # protocol = "openai" - /// # base_url = "http://127.0.0.1:9" - /// # api_key = "" - /// # - /// # [[model]] - /// # name = "m" - /// # description = "a model" - /// # context = 8192 - /// # upstream = "u" - /// # endpoints = ["e"] - /// # parallel_tool_calls = true - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// assert!(config.models()[0].capabilities().parallel_tool_calls()); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn parallel_tool_calls(&self) -> bool { - self.parallel_tool_calls - } - - /// Returns the reasoning-effort levels the model accepts (empty when the - /// model has no effort knob). - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [[endpoint]] - /// # id = "e" - /// # protocol = "openai" - /// # base_url = "http://127.0.0.1:9" - /// # api_key = "" - /// # - /// # [[model]] - /// # name = "m" - /// # description = "a model" - /// # context = 8192 - /// # thinking = "switchable" - /// # upstream = "u" - /// # endpoints = ["e"] - /// # effort_levels = ["low", "high"] - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// assert_eq!( - /// config.models()[0].capabilities().effort_levels(), - /// ["low", "high"] - /// ); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn effort_levels(&self) -> &[String] { - &self.effort_levels - } - - /// Returns the effort level applied when the caller omits one, when set. - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [[endpoint]] - /// # id = "e" - /// # protocol = "openai" - /// # base_url = "http://127.0.0.1:9" - /// # api_key = "" - /// # - /// # [[model]] - /// # name = "m" - /// # description = "a model" - /// # context = 8192 - /// # thinking = "switchable" - /// # upstream = "u" - /// # endpoints = ["e"] - /// # effort_levels = ["low", "high"] - /// # default_effort = "low" - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// assert_eq!( - /// config.models()[0].capabilities().default_effort(), - /// Some("low") - /// ); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn default_effort(&self) -> Option<&str> { - self.default_effort.as_deref() - } - - /// Returns whether the model adaptively chooses how much to think per - /// request. - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [[endpoint]] - /// # id = "e" - /// # protocol = "openai" - /// # base_url = "http://127.0.0.1:9" - /// # api_key = "" - /// # - /// # [[model]] - /// # name = "m" - /// # description = "a model" - /// # context = 8192 - /// # upstream = "u" - /// # endpoints = ["e"] - /// # adaptive_thinking = true - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// assert!(config.models()[0].capabilities().adaptive_thinking()); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn adaptive_thinking(&self) -> bool { - self.adaptive_thinking - } - - /// Returns the voices the model offers for speech synthesis (empty when - /// the model exposes no fixed voice list). - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [[endpoint]] - /// # id = "e" - /// # protocol = "openai" - /// # base_url = "http://127.0.0.1:9" - /// # api_key = "" - /// # - /// # [[model]] - /// # name = "m" - /// # kind = "speech" - /// # description = "a model" - /// # context = 8192 - /// # upstream = "u" - /// # endpoints = ["e"] - /// # voices = ["alloy", "nova"] - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// assert_eq!( - /// config.models()[0].capabilities().voices(), - /// ["alloy", "nova"] - /// ); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn voices(&self) -> &[String] { - &self.voices - } -} impl ToolsConfig { /// Returns the web-search tool configuration, or `None` when no /// `[tools.web_search]` section is present. diff --git a/crates/gateway-config/src/config/tests/serialize.rs b/crates/gateway-config/src/config/tests/serialize.rs index 9eb79c9ca..0dc86e1c1 100644 --- a/crates/gateway-config/src/config/tests/serialize.rs +++ b/crates/gateway-config/src/config/tests/serialize.rs @@ -237,17 +237,19 @@ fn enums_round_trip_with_their_toml_spellings() { #[test] fn capabilities_round_trip_through_json() { - let capabilities = Capabilities { - max_output: Some(4096), - default_temperature: Some(0.5), - images: true, - parallel_tool_calls: true, - effort_levels: vec!["low".to_owned(), "high".to_owned()], - default_effort: Some("low".to_owned()), - adaptive_thinking: true, - voices: vec!["alloy".to_owned()], - }; - let json = serde_json::to_value(&capabilities).expect("serializes"); - let back: Capabilities = serde_json::from_value(json).expect("deserializes"); - assert_eq!(capabilities, back); + // `Capabilities` is `#[non_exhaustive]` in `shared-gateway-api`, so the + // fixture is built from JSON rather than a struct literal. + let json = serde_json::json!({ + "max_output": 4096, + "default_temperature": 0.5, + "images": true, + "parallel_tool_calls": true, + "effort_levels": ["low", "high"], + "default_effort": "low", + "adaptive_thinking": true, + "voices": ["alloy"], + }); + let capabilities: Capabilities = serde_json::from_value(json.clone()).expect("deserializes"); + let back = serde_json::to_value(&capabilities).expect("serializes"); + assert_eq!(json, back); } diff --git a/crates/gateway-protocol/Cargo.toml b/crates/gateway-protocol/Cargo.toml index 48f186c12..312d372a9 100644 --- a/crates/gateway-protocol/Cargo.toml +++ b/crates/gateway-protocol/Cargo.toml @@ -20,6 +20,7 @@ gateway-config.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +shared-gateway-api.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/crates/gateway-protocol/src/wire.rs b/crates/gateway-protocol/src/wire.rs index bc483c2ff..617b448b8 100644 --- a/crates/gateway-protocol/src/wire.rs +++ b/crates/gateway-protocol/src/wire.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use gateway_config::{Capabilities, ModelKind, ThinkingMode}; +pub use shared_gateway_api::ModelInfo; /// An incoming chat completions request. #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] @@ -582,30 +582,10 @@ pub struct ModelsResponse { pub data: Vec, } -/// One catalogued model, with PromptForge extensions beside the OpenAI `id`. -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct ModelInfo { - /// The caller-facing model name (`[[model]].name`). - pub id: String, - /// Always `"model"`. - pub object: &'static str, - /// The workload this model serves (`"chat"`, `"embedding"`, - /// `"classifier"`, `"speech"`). - pub kind: ModelKind, - /// Prose describing the model for catalog consumers and semantic bind. - pub description: String, - /// Context window size in tokens. - pub context: u32, - /// Whether thinking tokens are never, always, or switchably available. - pub thinking: ThinkingMode, - /// Capability metadata (`max_output`, `images`, effort levels, and so - /// on), flattened into the catalog entry. - #[serde(flatten)] - pub capabilities: Capabilities, -} - #[cfg(test)] mod tests { + use shared_gateway_api::{Capabilities, ModelKind, ThinkingMode}; + use super::*; fn request(model: &str, messages: Vec) -> ChatRequest { diff --git a/crates/shared-cloud-providers/Cargo.toml b/crates/shared-cloud-providers/Cargo.toml new file mode 100644 index 000000000..a07886154 --- /dev/null +++ b/crates/shared-cloud-providers/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "shared-cloud-providers" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +description = "PromptForge shared cloud provider descriptors: the tiered provider registry, per-provider fetch and normalization behind an injected reqwest client, and the sheet-building binary" +keywords = ["prompt", "llm", "gateway", "providers"] +categories = ["rust-patterns"] +documentation = "https://cppalliance.github.io/promptforge/" + +[lib] +name = "shared_cloud_providers" +path = "src/lib.rs" + +[[bin]] +name = "shared-cloud-providers" +path = "src/main.rs" + +[dependencies] +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +shared-gateway-api.workspace = true +thiserror.workspace = true +time = { workspace = true, features = ["parsing"] } +tokio.workspace = true + +[lints] +workspace = true diff --git a/crates/shared-cloud-providers/src/lib.rs b/crates/shared-cloud-providers/src/lib.rs new file mode 100644 index 000000000..f49674127 --- /dev/null +++ b/crates/shared-cloud-providers/src/lib.rs @@ -0,0 +1,241 @@ +//! Tiered descriptors for cloud model providers, the provider registry, +//! and the fetch seam behind which each provider's private variance lives. +//! +//! One Rust file per provider; each file defines a public descriptor while +//! auth header shape, pagination, and response mapping stay private to the +//! file. The crate does double duty: a library linked into the Gateway, and +//! a binary the aggregation workflow compiles and runs. + +use shared_gateway_api::{ModelEntry, Tier}; + +pub mod providers; +mod sheet; + +pub use sheet::{build_sheet, fetch_sheet}; + +/// The public descriptor for one provider. Everything else about the +/// provider - auth header shape, pagination, response mapping - is +/// private to its file. +#[derive(Debug, Clone, Copy)] +pub struct Provider { + /// Registry key, e.g. "anthropic". + pub name: &'static str, + /// UI-facing name, e.g. "Anthropic". + pub display_name: &'static str, + /// Curated product opinion, not a vendor fact. + pub tier: Tier, + /// Environment variable the API key arrives under; matches the + /// GitHub secret name. + pub key_env: &'static str, + /// Default base URL for the model-list endpoint. + pub base_url: &'static str, +} + +/// Every known provider. +#[must_use] +pub fn providers() -> &'static [Provider] { + &[ + providers::anthropic::PROVIDER, + providers::deepgram::PROVIDER, + providers::deepseek::PROVIDER, + providers::elevenlabs::PROVIDER, + providers::gemini::PROVIDER, + providers::meta::PROVIDER, + providers::moonshot::PROVIDER, + providers::openai::PROVIDER, + providers::qwen::PROVIDER, + providers::xai::PROVIDER, + ] +} + +/// A failed provider fetch or sheet download. Never fatal to a sheet +/// build: the caller propagates last-known-good data instead. +#[derive(Debug, thiserror::Error)] +pub enum FetchError { + /// The registry has no fetch implementation for this provider. + #[error("no fetch implementation for provider `{name}`")] + UnsupportedProvider { + /// The provider registry key. + name: String, + }, + /// The HTTP request to the provider failed. + #[error("provider request failed: {0}")] + Http(#[from] reqwest::Error), + /// The previous release's sheet URL answered HTTP 404: the release + /// does not exist yet. + #[error("no sheet at `{url}` (HTTP 404)")] + NotFound { + /// The release URL that answered 404. + url: String, + }, + /// The provider's API key is not available in the environment. + #[error("missing API key for provider `{name}`: environment variable `{key_env}` is not set")] + MissingKey { + /// The provider registry key. + name: String, + /// The environment variable that would carry the key. + key_env: &'static str, + }, +} + +/// Fetch and normalize one provider's model list; the per-provider +/// variance lives behind this seam. The client is injected by the +/// caller (the Gateway's bounded client, or the binary's own). +/// +/// # Errors +/// +/// Returns [`FetchError::UnsupportedProvider`] when the registry has no +/// fetch implementation for the provider, and [`FetchError::Http`] when +/// the provider request fails. +pub async fn fetch_models( + client: &reqwest::Client, + provider: &Provider, + key: &str, +) -> Result, FetchError> { + match provider.name { + "anthropic" => providers::anthropic::fetch(client, provider.base_url, key).await, + "deepgram" => providers::deepgram::fetch(client, provider.base_url, key).await, + "deepseek" => providers::deepseek::fetch(client, provider.base_url, key).await, + "elevenlabs" => providers::elevenlabs::fetch(client, provider.base_url, key).await, + "gemini" => providers::gemini::fetch(client, provider.base_url, key).await, + "meta" => providers::meta::fetch(client, provider.base_url, key).await, + "moonshot" => providers::moonshot::fetch(client, provider.base_url, key).await, + "openai" => providers::openai::fetch(client, provider.base_url, key).await, + "qwen" => providers::qwen::fetch(client, provider.base_url, key).await, + "xai" => providers::xai::fetch(client, provider.base_url, key).await, + _ => Err(FetchError::UnsupportedProvider { + name: provider.name.to_owned(), + }), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use shared_gateway_api::Tier; + + use super::{FetchError, Provider, fetch_models, providers}; + + /// The Prime tier settled in the decision record (2026-09-14): + /// `(name, display_name, base_url)`. + const PRIME: &[(&str, &str, &str)] = &[ + ("anthropic", "Anthropic", "https://api.anthropic.com"), + ("openai", "OpenAI", "https://api.openai.com/v1"), + ( + "gemini", + "Google Gemini", + "https://generativelanguage.googleapis.com", + ), + ("xai", "xAI", "https://api.x.ai"), + ("deepseek", "DeepSeek", "https://api.deepseek.com"), + ( + "qwen", + "Alibaba Qwen", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + ), + ("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1"), + ("meta", "Meta", "https://api.meta.ai/v1"), + ("elevenlabs", "ElevenLabs", "https://api.elevenlabs.io"), + ("deepgram", "Deepgram", "https://api.deepgram.com"), + ]; + + #[test] + fn registry_names_are_unique() { + let mut seen = BTreeSet::new(); + for provider in providers() { + assert!( + seen.insert(provider.name), + "duplicate provider name in the registry: {}", + provider.name + ); + } + } + + #[test] + fn registry_key_envs_are_unique() { + let mut seen = BTreeSet::new(); + for provider in providers() { + assert!( + seen.insert(provider.key_env), + "duplicate key env in the registry: {}", + provider.key_env + ); + } + } + + #[test] + fn all_prime_providers_are_registered() { + let registered: BTreeSet<&str> = providers().iter().map(|provider| provider.name).collect(); + for &(name, ..) in PRIME { + assert!( + registered.contains(name), + "prime provider `{name}` from the decision record is not registered" + ); + let provider = providers() + .iter() + .find(|provider| provider.name == name) + .expect("checked above"); + assert_eq!( + provider.tier, + Tier::Prime, + "decision-record provider `{name}` must be Tier::Prime" + ); + } + } + + #[test] + fn prime_descriptors_match_decision_record() { + for provider in providers() { + if provider.tier != Tier::Prime { + continue; + } + let Some(&(_, display_name, base_url)) = + PRIME.iter().find(|(name, ..)| *name == provider.name) + else { + panic!( + "prime provider `{}` is not in the decision record", + provider.name + ); + }; + assert_eq!( + provider.display_name, display_name, + "display name for `{}`", + provider.name + ); + assert_eq!( + provider.base_url, base_url, + "base URL for `{}`", + provider.name + ); + assert!( + !provider.key_env.is_empty(), + "prime provider `{}` must name its key env var", + provider.name + ); + } + } + + #[tokio::test] + async fn fetch_models_rejects_unsupported_provider() { + let provider = Provider { + name: "no-such-provider", + display_name: "No Such Provider", + tier: Tier::Niche, + key_env: "NO_SUCH_PROVIDER_API_KEY", + base_url: "https://example.invalid", + }; + let client = reqwest::Client::new(); + let Err(err) = fetch_models(&client, &provider, "test-key").await else { + panic!("a provider with no fetch implementation must not succeed"); + }; + assert!( + matches!(err, FetchError::UnsupportedProvider { .. }), + "expected UnsupportedProvider, got {err:?}" + ); + assert!( + err.to_string().contains("no-such-provider"), + "the error must name the provider: {err}" + ); + } +} diff --git a/crates/shared-cloud-providers/src/main.rs b/crates/shared-cloud-providers/src/main.rs new file mode 100644 index 000000000..4163b9645 --- /dev/null +++ b/crates/shared-cloud-providers/src/main.rs @@ -0,0 +1,197 @@ +//! Sheet-building binary: a thin `main` over the `shared-cloud-providers` +//! library, compiled and run by the aggregation workflow and runnable +//! locally for testing and sheet building. +//! +//! Reads each provider's API key from the environment variable named by +//! its descriptor, downloads the previous release's `models.json` when +//! `MODELS_SHEET_PREVIOUS_URL` is set (an unset URL or an HTTP 404 means +//! first run: the build proceeds with no previous sheet; any other +//! download failure is fatal, since silently losing history would demote +//! every slice to `unavailable`), and writes the merged sheet as +//! pretty-printed JSON to the output path named by the first argument, +//! defaulting to `./models.json`. + +use std::process::ExitCode; +use std::time::Duration; + +use shared_gateway_api::Sheet; + +/// Environment variable carrying the previous release's sheet URL. +const PREVIOUS_SHEET_URL_ENV: &str = "MODELS_SHEET_PREVIOUS_URL"; + +#[tokio::main] +async fn main() -> ExitCode { + match run().await { + Ok(path) => { + eprintln!("shared-cloud-providers: wrote {path}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("shared-cloud-providers: {err}"); + ExitCode::FAILURE + } + } +} + +/// Build the sheet and write it to the output path, returning the path. +async fn run() -> Result> { + let output = std::env::args() + .nth(1) + .unwrap_or_else(|| "models.json".to_owned()); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build()?; + let url = std::env::var(PREVIOUS_SHEET_URL_ENV).ok(); + let previous = match previous_sheet(&client, url.as_deref()).await? { + PreviousSheet::FirstRun => None, + PreviousSheet::Fetched(sheet) => Some(sheet), + }; + let keys = |provider: &shared_cloud_providers::Provider| std::env::var(provider.key_env).ok(); + let sheet = shared_cloud_providers::build_sheet(&client, previous, &keys).await; + let mut json = serde_json::to_string_pretty(&sheet)?; + json.push('\n'); + std::fs::write(&output, json)?; + Ok(output) +} + +/// The outcome of resolving the previous release's sheet. +enum PreviousSheet { + /// No URL configured, or the release does not exist yet (HTTP 404): + /// build without history. + FirstRun, + /// The previous release's sheet, fetched and parsed. + Fetched(Sheet), +} + +/// Resolve the previous release's sheet. An unset URL and an HTTP 404 +/// both mean first run; any other failure - transport error, non-404 +/// non-success status, unparseable body - is fatal, since silently +/// losing history would demote every slice to `unavailable`. +async fn previous_sheet( + client: &reqwest::Client, + url: Option<&str>, +) -> Result> { + let Some(url) = url.filter(|url| !url.is_empty()) else { + return Ok(PreviousSheet::FirstRun); + }; + match shared_cloud_providers::fetch_sheet(client, url).await { + Ok(sheet) => Ok(PreviousSheet::Fetched(sheet)), + Err(shared_cloud_providers::FetchError::NotFound { .. }) => { + eprintln!( + "shared-cloud-providers: no previous release at {url} (HTTP 404); building without history" + ); + Ok(PreviousSheet::FirstRun) + } + Err(err) => Err(format!("previous sheet at {url}: {err}").into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Serve one HTTP response with `status` carrying `body`, returning + /// the URL to request. + fn serve_once(status: &'static str, body: &'static str) -> String { + use std::io::{Read as _, Write as _}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fixture server"); + let addr = listener.local_addr().expect("fixture server addr"); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept fixture client"); + // Read the request first: replying before the client finishes + // sending is an HTTP protocol error. A short read timeout bounds + // the capture without a sleep; once the client awaits the + // response, the next read simply times out. + let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(200))); + let mut buf = [0_u8; 4096]; + loop { + match stream.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write fixture response"); + }); + format!("http://{addr}/models.json") + } + + #[tokio::test] + async fn previous_sheet_without_url_is_first_run() { + let client = reqwest::Client::new(); + let outcome = previous_sheet(&client, None) + .await + .expect("an unset URL is not an error"); + assert!( + matches!(outcome, PreviousSheet::FirstRun), + "an unset URL must mean first run" + ); + let outcome = previous_sheet(&client, Some("")) + .await + .expect("an empty URL is not an error"); + assert!( + matches!(outcome, PreviousSheet::FirstRun), + "an empty URL must mean first run" + ); + } + + #[tokio::test] + async fn previous_sheet_treats_404_as_first_run() { + let url = serve_once("404 Not Found", "not found"); + let client = reqwest::Client::new(); + let outcome = previous_sheet(&client, Some(&url)) + .await + .expect("a 404 previous release is not an error"); + assert!( + matches!(outcome, PreviousSheet::FirstRun), + "a 404 must mean first run: the release does not exist yet" + ); + } + + #[tokio::test] + async fn previous_sheet_propagates_transport_error() { + let client = reqwest::Client::new(); + let result = previous_sheet(&client, Some("http://127.0.0.1:1/models.json")).await; + let Err(err) = result else { + panic!("an unreachable previous-sheet URL must be fatal"); + }; + assert!( + err.to_string().contains("http://127.0.0.1:1/models.json"), + "the error must name the URL: {err}" + ); + } + + #[tokio::test] + async fn previous_sheet_propagates_500() { + let url = serve_once("500 Internal Server Error", "boom"); + let client = reqwest::Client::new(); + let result = previous_sheet(&client, Some(&url)).await; + let Err(err) = result else { + panic!("a 500 previous-sheet response must be fatal"); + }; + assert!( + err.to_string().contains(url.as_str()), + "the error must name the URL: {err}" + ); + } + + #[tokio::test] + async fn previous_sheet_propagates_unparseable_200() { + let url = serve_once("200 OK", "this is not a sheet"); + let client = reqwest::Client::new(); + let result = previous_sheet(&client, Some(&url)).await; + let Err(err) = result else { + panic!("a 200 with an unparseable body must be fatal"); + }; + assert!( + err.to_string().contains(url.as_str()), + "the error must name the URL: {err}" + ); + } +} diff --git a/crates/shared-cloud-providers/src/providers/anthropic.rs b/crates/shared-cloud-providers/src/providers/anthropic.rs new file mode 100644 index 000000000..3d2caa18f --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/anthropic.rs @@ -0,0 +1,441 @@ +//! Anthropic provider: the public descriptor plus the private variance of +//! `GET /v1/models` - `x-api-key` and required `anthropic-version` +//! headers, cursor pagination on `after_id`, and normalization of the +//! verified 2026-09-14 response shape (`id`, `display_name`, `created_at`, +//! `max_input_tokens`, `max_tokens`, `capabilities`) into `ModelEntry`. +//! +//! Docs: + +use serde::Deserialize; +use shared_gateway_api::{ModelEntry, ModelKind, Thinking, Tier}; +use time::format_description::well_known::Rfc3339; +use time::{Date, OffsetDateTime}; + +use crate::{FetchError, Provider}; + +/// The Anthropic provider descriptor. +pub const PROVIDER: Provider = Provider { + name: "anthropic", + display_name: "Anthropic", + tier: Tier::Prime, + key_env: "ANTHROPIC_API_KEY", + base_url: "https://api.anthropic.com", +}; + +/// The API version the endpoint requires on every request. +const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// Page size for the list request: the endpoint maximum, so the full +/// catalog arrives in one page today and pagination only engages as the +/// lineup grows. +const PAGE_LIMIT: u32 = 1000; + +/// Fetch and normalize Anthropic's model list, following the cursor until +/// the final page. +pub(crate) async fn fetch( + client: &reqwest::Client, + base_url: &str, + key: &str, +) -> Result, FetchError> { + let mut entries = Vec::new(); + let mut cursor: Option = None; + loop { + let mut request = client + .get(format!("{base_url}/v1/models")) + .header("x-api-key", key) + .header("anthropic-version", ANTHROPIC_VERSION) + .query(&[("limit", PAGE_LIMIT)]); + if let Some(after) = &cursor { + request = request.query(&[("after_id", after)]); + } + let page: Page = request.send().await?.error_for_status()?.json().await?; + entries.extend(page.data.iter().map(normalize_model)); + let Some(next) = next_cursor(&page) else { + break; + }; + cursor = Some(next); + } + Ok(entries) +} + +/// One page of the list response. +#[derive(Debug, Deserialize)] +struct Page { + data: Vec, + #[serde(default)] + has_more: bool, + last_id: Option, +} + +/// One model as the wire reports it. +#[derive(Debug, Deserialize)] +struct WireModel { + id: String, + display_name: String, + created_at: String, + max_input_tokens: Option, + max_tokens: Option, + capabilities: Option, +} + +/// The `capabilities` object; every member is optional in the wire schema. +#[derive(Debug, Deserialize)] +struct WireCapabilities { + batch: Option, + citations: Option, + code_execution: Option, + image_input: Option, + pdf_input: Option, + structured_outputs: Option, + thinking: Option, + effort: Option, +} + +/// The `{ "supported": bool }` leaf every capability reports. +#[derive(Debug, Deserialize)] +struct Support { + supported: bool, +} + +/// The thinking capability and its per-type support flags. +#[derive(Debug, Deserialize)] +struct WireThinking { + supported: bool, + types: Option, +} + +/// Support for each thinking type configuration. +#[derive(Debug, Deserialize)] +struct WireThinkingTypes { + adaptive: Option, + enabled: Option, +} + +/// The effort capability: one support flag per level name. +#[derive(Debug, Deserialize)] +struct WireEffort { + low: Option, + medium: Option, + high: Option, + xhigh: Option, + max: Option, +} + +/// The cursor for the next page: `last_id` while the endpoint reports +/// more results. A missing `last_id` stops traversal even when `has_more` +/// is true, so a malformed page can never loop the fetch forever. +fn next_cursor(page: &Page) -> Option { + if page.has_more { + page.last_id.clone() + } else { + None + } +} + +/// Whether an optional capability leaf reports support. +fn supported(capability: Option<&Support>) -> bool { + capability.is_some_and(|c| c.supported) +} + +/// The provider's own supported effort level names in canonical knob +/// order; unsupported and absent levels drop out, and no cross-provider +/// ordinal scale is invented. +fn effort_levels(effort: Option<&WireEffort>) -> Vec { + let Some(effort) = effort else { + return Vec::new(); + }; + [ + ("low", &effort.low), + ("medium", &effort.medium), + ("high", &effort.high), + ("xhigh", &effort.xhigh), + ("max", &effort.max), + ] + .into_iter() + .filter(|(_, support)| supported(support.as_ref())) + .map(|(name, _)| name.to_owned()) + .collect() +} + +/// Normalize one wire model into a sheet entry. +fn normalize_model(model: &WireModel) -> ModelEntry { + let caps = model.capabilities.as_ref(); + let capability = |pick: fn(&WireCapabilities) -> &Option| { + supported(caps.map(pick).and_then(Option::as_ref)) + }; + let thinking = caps.and_then(|c| c.thinking.as_ref()); + let thinking_type = |pick: fn(&WireThinkingTypes) -> &Option| { + supported( + thinking + .and_then(|t| t.types.as_ref()) + .map(pick) + .and_then(Option::as_ref), + ) + }; + ModelEntry { + id: model.id.clone(), + display_name: model.display_name.clone(), + kind: ModelKind::Chat, + released_at: parse_release_date(&model.created_at), + context_window: model.max_input_tokens, + max_output: model.max_tokens, + images: capability(|c| &c.image_input), + pdf_input: capability(|c| &c.pdf_input), + video_input: false, + audio_input: false, + batch: capability(|c| &c.batch), + citations: capability(|c| &c.citations), + code_execution: capability(|c| &c.code_execution), + structured_outputs: capability(|c| &c.structured_outputs), + // Curated: tool use is core to every Anthropic chat model; the + // list endpoint does not report it. + tool_calling: true, + thinking: Thinking { + supported: thinking.is_some_and(|t| t.supported), + enabled: thinking_type(|t| &t.enabled), + adaptive: thinking_type(|t| &t.adaptive), + }, + effort_levels: effort_levels(caps.and_then(|c| c.effort.as_ref())), + // The endpoint reports no default level, no pricing, and no + // deprecation status. + default_effort: None, + pricing: None, + deprecation: None, + } +} + +/// Parse the release date. The endpoint substitutes the epoch when the +/// release date is unknown; that sentinel normalizes to `None`, as does +/// an unparseable value. +fn parse_release_date(created_at: &str) -> Option { + let date = OffsetDateTime::parse(created_at, &Rfc3339).ok()?.date(); + (date != OffsetDateTime::UNIX_EPOCH.date()).then_some(date) +} + +#[cfg(test)] +mod tests { + use time::Month; + + use super::*; + + /// First page of the recorded 2026-09-14 live payload shape: a fully + /// capable model, with `has_more` set so pagination continues. + const PAGE_1: &str = r#"{ + "data": [ + { + "type": "model", + "id": "claude-opus-5", + "display_name": "Claude Opus 5", + "created_at": "2026-07-24T00:00:00Z", + "max_input_tokens": 1000000, + "max_tokens": 128000, + "capabilities": { + "batch": { "supported": true }, + "citations": { "supported": true }, + "code_execution": { "supported": true }, + "context_management": { + "clear_thinking_20251015": { "supported": true }, + "clear_tool_uses_20250919": { "supported": true }, + "compact_20260112": { "supported": true }, + "supported": true + }, + "effort": { + "low": { "supported": true }, + "medium": { "supported": true }, + "high": { "supported": true }, + "xhigh": { "supported": true }, + "max": { "supported": true }, + "supported": true + }, + "image_input": { "supported": true }, + "pdf_input": { "supported": true }, + "structured_outputs": { "supported": true }, + "thinking": { + "supported": true, + "types": { + "adaptive": { "supported": true }, + "enabled": { "supported": true } + } + } + } + } + ], + "first_id": "claude-opus-5", + "has_more": true, + "last_id": "claude-opus-5" +}"#; + + /// Second and final page: a leaner model exercising partial support + /// flags, an absent `xhigh`/`max` effort level, and the epoch + /// release-date sentinel. + const PAGE_2: &str = r#"{ + "data": [ + { + "type": "model", + "id": "claude-haiku-4-5", + "display_name": "Claude Haiku 4.5", + "created_at": "1970-01-01T00:00:00Z", + "max_input_tokens": 200000, + "max_tokens": 64000, + "capabilities": { + "batch": { "supported": true }, + "citations": { "supported": false }, + "code_execution": { "supported": false }, + "effort": { + "low": { "supported": true }, + "medium": { "supported": false }, + "high": { "supported": true }, + "supported": true + }, + "image_input": { "supported": false }, + "pdf_input": { "supported": true }, + "structured_outputs": { "supported": true }, + "thinking": { + "supported": true, + "types": { + "adaptive": { "supported": true }, + "enabled": { "supported": false } + } + } + } + } + ], + "first_id": "claude-haiku-4-5", + "has_more": false, + "last_id": "claude-haiku-4-5" +}"#; + + fn page(json: &str) -> Page { + serde_json::from_str(json).expect("fixture must parse as a page") + } + + fn only_model(json: &str) -> ModelEntry { + let page = page(json); + assert_eq!(page.data.len(), 1, "fixture holds exactly one model"); + normalize_model(&page.data[0]) + } + + #[test] + fn normalizes_full_capability_entry() { + let entry = only_model(PAGE_1); + assert_eq!(entry.id, "claude-opus-5"); + assert_eq!(entry.display_name, "Claude Opus 5"); + assert_eq!(entry.kind, ModelKind::Chat); + assert_eq!( + entry.released_at, + Date::from_calendar_date(2026, Month::July, 24).ok(), + "created_at maps to released_at" + ); + assert_eq!(entry.context_window, Some(1_000_000)); + assert_eq!(entry.max_output, Some(128_000)); + assert!(entry.images); + assert!(entry.pdf_input); + assert!(!entry.video_input); + assert!(!entry.audio_input); + assert!(entry.batch); + assert!(entry.citations); + assert!(entry.code_execution); + assert!(entry.structured_outputs); + assert!( + entry.tool_calling, + "tool use is core to every Anthropic chat model" + ); + assert!( + entry.thinking.supported && entry.thinking.enabled && entry.thinking.adaptive, + "all three thinking flags map from the types object: {:?}", + entry.thinking + ); + assert_eq!( + entry.effort_levels, + ["low", "medium", "high", "xhigh", "max"] + ); + assert_eq!(entry.default_effort, None); + assert!(entry.pricing.is_none()); + assert!(entry.deprecation.is_none()); + } + + #[test] + fn partial_capabilities_map_conservatively() { + let entry = only_model(PAGE_2); + assert!(!entry.images, "image_input.supported false maps to false"); + assert!(entry.pdf_input); + assert!(!entry.citations); + assert!(!entry.code_execution); + assert!( + entry.thinking.supported && !entry.thinking.enabled && entry.thinking.adaptive, + "per-type flags map independently of the top-level flag: {:?}", + entry.thinking + ); + assert_eq!( + entry.effort_levels, + ["low", "high"], + "unsupported and absent levels drop out, canonical order holds" + ); + assert_eq!( + entry.released_at, None, + "the epoch sentinel means the release date is unknown" + ); + assert_eq!(entry.context_window, Some(200_000)); + assert_eq!(entry.max_output, Some(64_000)); + } + + #[test] + fn null_capabilities_yield_a_conservative_entry() { + let entry = only_model( + r#"{ + "data": [ + { + "type": "model", + "id": "claude-legacy", + "display_name": "Claude Legacy", + "created_at": "2024-03-04T00:00:00Z", + "max_input_tokens": null, + "max_tokens": null, + "capabilities": null + } + ], + "first_id": "claude-legacy", + "has_more": false, + "last_id": "claude-legacy" + }"#, + ); + assert_eq!(entry.context_window, None); + assert_eq!(entry.max_output, None); + assert!(!entry.batch); + assert!(!entry.images); + assert!(!entry.thinking.supported); + assert!(!entry.thinking.enabled); + assert!(!entry.thinking.adaptive); + assert!(entry.effort_levels.is_empty()); + assert!( + entry.tool_calling, + "curated: Anthropic chat models take tools even when the endpoint is silent" + ); + } + + #[test] + fn pagination_follows_last_id_while_has_more() { + let first = page(PAGE_1); + let cursor = next_cursor(&first).expect("has_more page must yield a cursor"); + assert_eq!(cursor, "claude-opus-5", "the cursor is the page's last_id"); + let second = page(PAGE_2); + assert_eq!(next_cursor(&second), None, "the final page ends traversal"); + } + + #[test] + fn pagination_stops_when_last_id_is_absent() { + let page = page( + r#"{ + "data": [], + "first_id": null, + "has_more": true, + "last_id": null + }"#, + ); + assert_eq!( + next_cursor(&page), + None, + "has_more without a cursor must not loop forever" + ); + } +} diff --git a/crates/shared-cloud-providers/src/providers/deepgram.rs b/crates/shared-cloud-providers/src/providers/deepgram.rs new file mode 100644 index 000000000..a7db27cbe --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/deepgram.rs @@ -0,0 +1,153 @@ +//! Deepgram provider: the public descriptor plus the private variance of +//! `GET /v1/models` - the `Authorization: Token` prefix over one payload +//! that carries STT models and a TTS array, split here into separate +//! entries with distinct kinds. No pagination. Languages, architectures, +//! and tags have no sheet field and are dropped. +//! +//! Docs: + +use serde::Deserialize; +use shared_gateway_api::{ModelEntry, ModelKind, Tier}; + +use crate::providers::openai_shape::base_entry; +use crate::{FetchError, Provider}; + +/// The Deepgram provider descriptor. +pub const PROVIDER: Provider = Provider { + name: "deepgram", + display_name: "Deepgram", + tier: Tier::Prime, + key_env: "DEEPGRAM_API_KEY", + base_url: "https://api.deepgram.com", +}; + +/// The list path under the base URL. +const MODELS_PATH: &str = "/v1/models"; + +/// Fetch and normalize Deepgram's model list in a single request. +pub(crate) async fn fetch( + client: &reqwest::Client, + base_url: &str, + key: &str, +) -> Result, FetchError> { + let response: ListResponse = client + .get(format!("{base_url}{MODELS_PATH}")) + .header("Authorization", format!("Token {key}")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(normalize_list(&response)) +} + +/// The list envelope: STT models and TTS models in separate arrays. +#[derive(Debug, Deserialize)] +struct ListResponse { + #[serde(default)] + stt: Vec, + #[serde(default)] + tts: Vec, +} + +/// One STT model as the wire reports it. +#[derive(Debug, Deserialize)] +struct WireStt { + name: String, + canonical_name: String, + batch: Option, +} + +/// One TTS model as the wire reports it. +#[derive(Debug, Deserialize)] +struct WireTts { + name: String, + canonical_name: String, +} + +/// Split one payload into STT and TTS entries with distinct kinds. +fn normalize_list(response: &ListResponse) -> Vec { + let stt = response.stt.iter().map(|model| { + let mut entry = base_entry(&model.canonical_name, None); + entry.display_name.clone_from(&model.name); + entry.kind = ModelKind::Transcription; + entry.batch = model.batch.unwrap_or(false); + entry + }); + let tts = response.tts.iter().map(|model| { + let mut entry = base_entry(&model.canonical_name, None); + entry.display_name.clone_from(&model.name); + entry.kind = ModelKind::Speech; + entry + }); + stt.chain(tts).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The documented example response shape: one Nova STT model with + /// batch and streaming flags, and one Aura TTS model with tags. + const LIST: &str = r#"{ + "stt": [ + { + "name": "Nova-3 General", + "canonical_name": "nova-3-general", + "architecture": "nova-3", + "languages": ["en", "en-US", "es"], + "version": "2024-01-01.0", + "uuid": "1b1b1b1b-0000-0000-0000-000000000000", + "batch": true, + "streaming": true, + "formatted_output": true + } + ], + "tts": [ + { + "name": "Aura-2 Thalia English", + "canonical_name": "aura-2-thalia-en", + "architecture": "aura-2", + "languages": ["en"], + "version": "2025-04-01.0", + "uuid": "2c2c2c2c-0000-0000-0000-000000000000", + "tags": ["general", "female"] + } + ] +}"#; + + fn entries(json: &str) -> Vec { + let response: ListResponse = + serde_json::from_str(json).expect("fixture must parse as a list"); + normalize_list(&response) + } + + #[test] + fn one_payload_splits_into_distinct_kinds() { + let entries = entries(LIST); + assert_eq!(entries.len(), 2, "STT and TTS arrays both contribute"); + let stt = &entries[0]; + assert_eq!(stt.id, "nova-3-general"); + assert_eq!(stt.display_name, "Nova-3 General"); + assert_eq!(stt.kind, ModelKind::Transcription); + let tts = &entries[1]; + assert_eq!(tts.id, "aura-2-thalia-en"); + assert_eq!(tts.display_name, "Aura-2 Thalia English"); + assert_eq!(tts.kind, ModelKind::Speech); + } + + #[test] + fn stt_batch_flag_maps_to_batch_capability() { + let entries = entries(LIST); + assert!(entries[0].batch, "the wire batch flag maps to batch"); + assert!(!entries[1].batch, "TTS entries report no batch capability"); + } + + #[test] + fn empty_arrays_yield_no_entries() { + let result = entries(r#"{ "stt": [], "tts": [] }"#); + assert!(result.is_empty()); + let result = entries(r"{}"); + assert!(result.is_empty(), "absent arrays default to empty"); + } +} diff --git a/crates/shared-cloud-providers/src/providers/deepseek.rs b/crates/shared-cloud-providers/src/providers/deepseek.rs new file mode 100644 index 000000000..2b4a8d3be --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/deepseek.rs @@ -0,0 +1,89 @@ +//! DeepSeek provider: the public descriptor plus the private variance of +//! `GET /models` under `https://api.deepseek.com` - Bearer auth and the +//! IDs-only OpenAI response shape: no pagination, no token limits, and no +//! capability reporting, so every entry is the conservative base entry. +//! +//! Docs: + +use serde::Deserialize; +use shared_gateway_api::{ModelEntry, Tier}; + +use crate::providers::openai_shape::{base_entry, fetch_list}; +use crate::{FetchError, Provider}; + +/// The DeepSeek provider descriptor. +pub const PROVIDER: Provider = Provider { + name: "deepseek", + display_name: "DeepSeek", + tier: Tier::Prime, + key_env: "DEEPSEEK_API_KEY", + base_url: "https://api.deepseek.com", +}; + +/// The list path under the base URL: DeepSeek mounts it at `/models`, +/// not `/v1/models`. +const MODELS_PATH: &str = "/models"; + +/// Fetch and normalize DeepSeek's model list in a single request. +pub(crate) async fn fetch( + client: &reqwest::Client, + base_url: &str, + key: &str, +) -> Result, FetchError> { + let models: Vec = + fetch_list(client, &format!("{base_url}{MODELS_PATH}"), key).await?; + Ok(models.iter().map(normalize_model).collect()) +} + +/// One model as the wire reports it. +#[derive(Debug, Deserialize)] +struct WireModel { + id: String, + created: Option, +} + +/// Normalize one wire model: the endpoint is IDs-only, so the entry is +/// the conservative base. +fn normalize_model(model: &WireModel) -> ModelEntry { + base_entry(&model.id, model.created) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::openai_shape::ListResponse; + + /// The documented example response shape: the two flagship models. + const LIST: &str = r#"{ + "object": "list", + "data": [ + { + "id": "deepseek-chat", + "object": "model", + "created": 1782864000, + "owned_by": "deepseek" + }, + { + "id": "deepseek-reasoner", + "object": "model", + "created": 1782864000, + "owned_by": "deepseek" + } + ] +}"#; + + #[test] + fn ids_only_entries_are_conservative() { + let page: ListResponse = + serde_json::from_str(LIST).expect("fixture must parse as a list"); + let entries: Vec = page.data.iter().map(normalize_model).collect(); + assert_eq!(entries.len(), 2, "both listed models normalize"); + let entry = &entries[0]; + assert_eq!(entry.id, "deepseek-chat"); + assert_eq!(entry.display_name, "deepseek-chat"); + assert_eq!(entry.context_window, None, "IDs-only providers omit limits"); + assert_eq!(entry.max_output, None); + assert!(!entry.images && !entry.tool_calling && !entry.thinking.supported); + assert!(entry.pricing.is_none()); + } +} diff --git a/crates/shared-cloud-providers/src/providers/elevenlabs.rs b/crates/shared-cloud-providers/src/providers/elevenlabs.rs new file mode 100644 index 000000000..8f21a8ab9 --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/elevenlabs.rs @@ -0,0 +1,168 @@ +//! ElevenLabs provider: the public descriptor plus the private variance +//! of `GET /v1/models` - the `xi-api-key` header over a rich list +//! response (per-model languages, capability flags, character rates). No +//! pagination. Languages and character-cost rates have no sheet field and +//! are dropped; the capability flags select the entry kind. +//! +//! Docs: + +use serde::Deserialize; +use shared_gateway_api::{ModelEntry, ModelKind, Tier}; + +use crate::providers::openai_shape::base_entry; +use crate::{FetchError, Provider}; + +/// The ElevenLabs provider descriptor. +pub const PROVIDER: Provider = Provider { + name: "elevenlabs", + display_name: "ElevenLabs", + tier: Tier::Prime, + key_env: "ELEVENLABS_API_KEY", + base_url: "https://api.elevenlabs.io", +}; + +/// The list path under the base URL. +const MODELS_PATH: &str = "/v1/models"; + +/// Fetch and normalize ElevenLabs' model list in a single request. +pub(crate) async fn fetch( + client: &reqwest::Client, + base_url: &str, + key: &str, +) -> Result, FetchError> { + let models: Vec = client + .get(format!("{base_url}{MODELS_PATH}")) + .header("xi-api-key", key) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(models.iter().map(normalize_model).collect()) +} + +/// One model as the wire reports it. The response is a bare array, not +/// an envelope; languages, rates, and fine-tuning flags are dropped. +#[derive(Debug, Deserialize)] +struct WireModel { + model_id: String, + name: Option, + can_do_text_to_speech: Option, +} + +/// Normalize one wire model into a sheet entry. +fn normalize_model(model: &WireModel) -> ModelEntry { + let mut entry = base_entry(&model.model_id, None); + if let Some(name) = &model.name { + entry.display_name.clone_from(name); + } + entry.kind = if model.can_do_text_to_speech.unwrap_or(false) { + ModelKind::Speech + } else { + ModelKind::Transcription + }; + entry +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The documented example response shape: one TTS model and the + /// Scribe STT model, with the fields normalization consumes. + const LIST: &str = r#"[ + { + "model_id": "eleven_multilingual_v2", + "name": "Eleven Multilingual v2", + "can_be_finetuned": true, + "can_do_text_to_speech": true, + "can_do_voice_conversion": true, + "can_use_style": true, + "can_use_speaker_boost": true, + "serves_pro_voices": false, + "token_cost_factor": 1.0, + "description": "Cutting-edge multilingual model", + "requires_alpha_access": false, + "max_characters_request_free_user": 2500, + "max_characters_request_subscribed_user": 5000, + "maximum_text_length_per_request": 5000, + "languages": [ + { "language_id": "en", "name": "English" }, + { "language_id": "ja", "name": "Japanese" } + ], + "model_rates": { "character_cost_multiplier": 1.0 }, + "concurrency_group": "standard" + }, + { + "model_id": "scribe_v1", + "name": "Scribe v1", + "can_be_finetuned": false, + "can_do_text_to_speech": false, + "can_do_voice_conversion": false, + "can_use_style": false, + "can_use_speaker_boost": false, + "serves_pro_voices": false, + "token_cost_factor": 1.0, + "description": "Speech-to-text model", + "requires_alpha_access": false, + "max_characters_request_free_user": 0, + "max_characters_request_subscribed_user": 0, + "maximum_text_length_per_request": 0, + "languages": [ + { "language_id": "en", "name": "English" } + ], + "model_rates": { "character_cost_multiplier": 1.0 }, + "concurrency_group": "standard" + } +]"#; + + fn entries(json: &str) -> Vec { + let models: Vec = + serde_json::from_str(json).expect("fixture must parse as a list"); + models.iter().map(normalize_model).collect() + } + + #[test] + fn tts_flag_selects_speech_kind() { + let entries = entries(LIST); + let entry = &entries[0]; + assert_eq!(entry.id, "eleven_multilingual_v2"); + assert_eq!( + entry.display_name, "Eleven Multilingual v2", + "the wire name is the display name" + ); + assert_eq!( + entry.kind, + ModelKind::Speech, + "can_do_text_to_speech maps to the speech kind" + ); + } + + #[test] + fn absent_tts_flag_selects_transcription_kind() { + let entries = entries(LIST); + let entry = &entries[1]; + assert_eq!(entry.id, "scribe_v1"); + assert_eq!( + entry.kind, + ModelKind::Transcription, + "Scribe cannot do TTS, so it is a transcription model" + ); + } + + #[test] + fn missing_flags_and_name_are_conservative() { + let entries = entries(r#"[{ "model_id": "eleven_legacy" }]"#); + let entry = &entries[0]; + assert_eq!( + entry.display_name, "eleven_legacy", + "the id doubles as the display name" + ); + assert_eq!(entry.kind, ModelKind::Transcription); + assert!(!entry.images && !entry.tool_calling); + assert!( + entry.pricing.is_none(), + "character rates are not token pricing" + ); + } +} diff --git a/crates/shared-cloud-providers/src/providers/gemini.rs b/crates/shared-cloud-providers/src/providers/gemini.rs new file mode 100644 index 000000000..e2b6f3335 --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/gemini.rs @@ -0,0 +1,314 @@ +//! Gemini provider: the public descriptor plus the private variance of +//! `GET /v1beta/models` - `x-goog-api-key` header auth, `pageToken` +//! pagination, and normalization of `inputTokenLimit`, `outputTokenLimit`, +//! `supportedGenerationMethods`, and the `thinking` flag into +//! `ModelEntry`. +//! +//! Docs: + +use serde::Deserialize; +use shared_gateway_api::{ModelEntry, ModelKind, Thinking, Tier}; + +use crate::{FetchError, Provider}; + +/// The Gemini provider descriptor. +pub const PROVIDER: Provider = Provider { + name: "gemini", + display_name: "Google Gemini", + tier: Tier::Prime, + key_env: "GEMINI_API_KEY", + base_url: "https://generativelanguage.googleapis.com", +}; + +/// Page size for the list request: generous, so the full catalog arrives +/// in one page today and pagination only engages as the lineup grows. +const PAGE_SIZE: u32 = 1000; + +/// Fetch and normalize Gemini's model list, following `nextPageToken` +/// until the final page. +pub(crate) async fn fetch( + client: &reqwest::Client, + base_url: &str, + key: &str, +) -> Result, FetchError> { + let mut entries = Vec::new(); + let mut token: Option = None; + loop { + let mut request = client + .get(format!("{base_url}/v1beta/models")) + .header("x-goog-api-key", key) + .query(&[("pageSize", PAGE_SIZE)]); + if let Some(page_token) = &token { + request = request.query(&[("pageToken", page_token)]); + } + let page: Page = request.send().await?.error_for_status()?.json().await?; + entries.extend(page.models.iter().map(normalize_model)); + let Some(next) = next_token(&page) else { + break; + }; + token = Some(next); + } + Ok(entries) +} + +/// One page of the list response. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Page { + models: Vec, + next_page_token: Option, +} + +/// One model as the wire reports it. `displayName` and the token limits +/// are optional in the wire schema; the generation-method list and the +/// thinking flag default to empty and false. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireModel { + name: String, + display_name: Option, + input_token_limit: Option, + output_token_limit: Option, + #[serde(default)] + supported_generation_methods: Vec, + #[serde(default)] + thinking: bool, +} + +/// The token for the next page. An absent or empty `nextPageToken` ends +/// traversal, so a malformed page can never loop the fetch forever. +fn next_token(page: &Page) -> Option { + page.next_page_token + .clone() + .filter(|token| !token.is_empty()) +} + +/// The upstream model slug: the wire `name` carries a `models/` prefix +/// that the sheet id drops. +fn model_id(name: &str) -> &str { + name.strip_prefix("models/").unwrap_or(name) +} + +/// The workload: an embedding-only method list means an embedding model; +/// everything else is chat. +fn model_kind(methods: &[String]) -> ModelKind { + if methods.iter().any(|m| m == "embedContent") + && !methods.iter().any(|m| m == "generateContent") + { + ModelKind::Embedding + } else { + ModelKind::Chat + } +} + +/// Normalize one wire model into a sheet entry. +fn normalize_model(model: &WireModel) -> ModelEntry { + let methods = &model.supported_generation_methods; + let generates = methods.iter().any(|m| m == "generateContent"); + ModelEntry { + id: model_id(&model.name).to_owned(), + display_name: model + .display_name + .clone() + .unwrap_or_else(|| model_id(&model.name).to_owned()), + kind: model_kind(methods), + released_at: None, + context_window: model.input_token_limit, + max_output: model.output_token_limit, + // The list endpoint reports no modality, citation, code-execution, + // or structured-output flags. + images: false, + pdf_input: false, + video_input: false, + audio_input: false, + citations: false, + code_execution: false, + structured_outputs: false, + batch: methods.iter().any(|m| m == "batchGenerateContent"), + // Curated: function calling is part of `generateContent`; the + // endpoint reports no separate flag. + tool_calling: generates, + thinking: Thinking { + supported: model.thinking, + // The bare reasoning flag says nothing about budget modes + // (the contract's normalization principle; see moonshot.rs). + enabled: false, + // The endpoint does not report model-chosen thinking depth. + adaptive: false, + }, + // The endpoint reports no effort levels, pricing, release date, + // or deprecation status. + effort_levels: Vec::new(), + default_effort: None, + pricing: None, + deprecation: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// First page of the documented example response shape: a fully + /// capable thinking chat model, with `nextPageToken` set so + /// pagination continues. + const PAGE_1: &str = r#"{ + "models": [ + { + "name": "models/gemini-2.5-pro", + "version": "2.5", + "displayName": "Gemini 2.5 Pro", + "description": "Stable release of Gemini 2.5 Pro.", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + } + ], + "nextPageToken": "page-2" +}"#; + + /// Second and final page: an embedding-only model with no thinking + /// flag, exercising the generation-method to kind mapping. + const PAGE_2: &str = r#"{ + "models": [ + { + "name": "models/gemini-embedding-001", + "version": "001", + "displayName": "Gemini Embedding", + "description": "Text embedding model.", + "inputTokenLimit": 2048, + "outputTokenLimit": 1, + "supportedGenerationMethods": ["embedContent"] + } + ] +}"#; + + fn page(json: &str) -> Page { + serde_json::from_str(json).expect("fixture must parse as a page") + } + + fn only_model(json: &str) -> ModelEntry { + let page = page(json); + assert_eq!(page.models.len(), 1, "fixture holds exactly one model"); + normalize_model(&page.models[0]) + } + + #[test] + fn normalizes_full_chat_entry() { + let entry = only_model(PAGE_1); + assert_eq!(entry.id, "gemini-2.5-pro", "the models/ prefix drops"); + assert_eq!(entry.display_name, "Gemini 2.5 Pro"); + assert_eq!(entry.kind, ModelKind::Chat); + assert_eq!( + entry.context_window, + Some(1_048_576), + "inputTokenLimit maps to context_window" + ); + assert_eq!( + entry.max_output, + Some(65_536), + "outputTokenLimit maps to max_output" + ); + assert!( + entry.batch, + "batchGenerateContent maps to the batch capability" + ); + assert!( + entry.tool_calling, + "curated: function calling is part of generateContent" + ); + assert!( + entry.thinking.supported && !entry.thinking.enabled, + "the thinking flag maps to supported only; the bare flag says \ + nothing about budget modes: {:?}", + entry.thinking + ); + assert!( + !entry.thinking.adaptive, + "the endpoint does not report model-chosen thinking depth" + ); + assert!(!entry.images && !entry.pdf_input); + assert!(!entry.video_input && !entry.audio_input); + assert!(!entry.citations && !entry.code_execution && !entry.structured_outputs); + assert_eq!( + entry.released_at, None, + "the endpoint reports no release date" + ); + assert!(entry.effort_levels.is_empty()); + assert_eq!(entry.default_effort, None); + assert!(entry.pricing.is_none()); + assert!(entry.deprecation.is_none()); + } + + #[test] + fn embedding_only_methods_map_to_embedding_kind() { + let entry = only_model(PAGE_2); + assert_eq!(entry.id, "gemini-embedding-001"); + assert_eq!( + entry.kind, + ModelKind::Embedding, + "embedContent without generateContent is an embedding model" + ); + assert!(!entry.tool_calling, "no generateContent, no tool calling"); + assert!(!entry.batch, "no batchGenerateContent, no batch"); + assert!( + !entry.thinking.supported && !entry.thinking.enabled, + "an absent thinking flag normalizes to false" + ); + assert_eq!(entry.context_window, Some(2048)); + assert_eq!(entry.max_output, Some(1)); + } + + #[test] + fn missing_display_name_and_limits_fall_back_conservatively() { + let entry = only_model( + r#"{ + "models": [ + { + "name": "models/gemini-legacy", + "version": "1.0", + "supportedGenerationMethods": ["generateContent", "countTokens"] + } + ] + }"#, + ); + assert_eq!( + entry.display_name, "gemini-legacy", + "a missing displayName falls back to the stripped id" + ); + assert_eq!(entry.context_window, None); + assert_eq!(entry.max_output, None); + assert!(!entry.thinking.supported); + assert!(!entry.batch); + assert_eq!(entry.kind, ModelKind::Chat); + } + + #[test] + fn pagination_follows_next_page_token() { + let first = page(PAGE_1); + let token = next_token(&first).expect("a page with nextPageToken must yield a token"); + assert_eq!(token, "page-2", "the token passes through verbatim"); + let second = page(PAGE_2); + assert_eq!(next_token(&second), None, "the final page ends traversal"); + } + + #[test] + fn pagination_stops_on_empty_token() { + let page = page(r#"{ "models": [], "nextPageToken": "" }"#); + assert_eq!( + next_token(&page), + None, + "an empty token must not loop the fetch forever" + ); + } +} diff --git a/crates/shared-cloud-providers/src/providers/meta.rs b/crates/shared-cloud-providers/src/providers/meta.rs new file mode 100644 index 000000000..39f8c75ad --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/meta.rs @@ -0,0 +1,89 @@ +//! Meta provider: the public descriptor plus the private variance of +//! `GET /v1/models` under `https://api.meta.ai/v1` - Bearer auth over the +//! OpenAI-compatible list shape. The response schema is not fully +//! enumerated in the official docs, so normalization treats it as the +//! IDs-only shape: every entry is the conservative base entry. +//! +//! Docs: + +use serde::Deserialize; +use shared_gateway_api::{ModelEntry, Tier}; + +use crate::providers::openai_shape::{base_entry, fetch_list}; +use crate::{FetchError, Provider}; + +/// The Meta provider descriptor. +pub const PROVIDER: Provider = Provider { + name: "meta", + display_name: "Meta", + tier: Tier::Prime, + key_env: "META_API_KEY", + base_url: "https://api.meta.ai/v1", +}; + +/// The list path under the base URL. +const MODELS_PATH: &str = "/models"; + +/// Fetch and normalize Meta's model list in a single request. +pub(crate) async fn fetch( + client: &reqwest::Client, + base_url: &str, + key: &str, +) -> Result, FetchError> { + let models: Vec = + fetch_list(client, &format!("{base_url}{MODELS_PATH}"), key).await?; + Ok(models.iter().map(normalize_model).collect()) +} + +/// One model as the wire reports it. +#[derive(Debug, Deserialize)] +struct WireModel { + id: String, + created: Option, +} + +/// Normalize one wire model: the response schema is not fully enumerated, +/// so the entry is the conservative base. +fn normalize_model(model: &WireModel) -> ModelEntry { + base_entry(&model.id, model.created) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::openai_shape::ListResponse; + + /// The documented example response shape: OpenAI-compatible entries. + const LIST: &str = r#"{ + "object": "list", + "data": [ + { + "id": "llama-4-maverick", + "object": "model", + "created": 1782864000, + "owned_by": "meta" + }, + { + "id": "llama-4-scout", + "object": "model", + "created": 1782864000, + "owned_by": "meta" + } + ] +}"#; + + #[test] + fn ids_only_entries_are_conservative() { + let page: ListResponse = + serde_json::from_str(LIST).expect("fixture must parse as a list"); + let entries: Vec = page.data.iter().map(normalize_model).collect(); + assert_eq!(entries.len(), 2, "both listed models normalize"); + let entry = &entries[0]; + assert_eq!(entry.id, "llama-4-maverick"); + assert_eq!(entry.display_name, "llama-4-maverick"); + assert_eq!(entry.context_window, None, "IDs-only providers omit limits"); + assert_eq!(entry.max_output, None); + assert!(!entry.images && !entry.tool_calling && !entry.thinking.supported); + assert!(entry.pricing.is_none()); + } +} diff --git a/crates/shared-cloud-providers/src/providers/mod.rs b/crates/shared-cloud-providers/src/providers/mod.rs new file mode 100644 index 000000000..ec5763cf1 --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/mod.rs @@ -0,0 +1,15 @@ +//! One file per provider: a public `Provider` descriptor plus the +//! private variance of that provider's model-list endpoint - auth header +//! shape, pagination, and response mapping never leave the file. + +pub mod anthropic; +pub mod deepgram; +pub mod deepseek; +pub mod elevenlabs; +pub mod gemini; +pub mod meta; +pub mod moonshot; +pub mod openai; +mod openai_shape; +pub mod qwen; +pub mod xai; diff --git a/crates/shared-cloud-providers/src/providers/moonshot.rs b/crates/shared-cloud-providers/src/providers/moonshot.rs new file mode 100644 index 000000000..0a5078e5b --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/moonshot.rs @@ -0,0 +1,153 @@ +//! Moonshot AI (Kimi) provider: the public descriptor plus the private +//! variance of `GET /v1/models` - Bearer auth over the OpenAI list shape, +//! enriched with `context_length` and image-input, video-input, and +//! reasoning flags. No pagination. The global `.ai` host is used; `.cn` +//! keys are not interchangeable with it. +//! +//! Docs: + +use serde::Deserialize; +use shared_gateway_api::{ModelEntry, Tier}; + +use crate::providers::openai_shape::{base_entry, fetch_list}; +use crate::{FetchError, Provider}; + +/// The Moonshot AI provider descriptor. +pub const PROVIDER: Provider = Provider { + name: "moonshot", + display_name: "Moonshot AI", + tier: Tier::Prime, + key_env: "MOONSHOT_API_KEY", + base_url: "https://api.moonshot.ai/v1", +}; + +/// The list path under the base URL. +const MODELS_PATH: &str = "/models"; + +/// Fetch and normalize Moonshot's model list in a single request. +pub(crate) async fn fetch( + client: &reqwest::Client, + base_url: &str, + key: &str, +) -> Result, FetchError> { + let models: Vec = + fetch_list(client, &format!("{base_url}{MODELS_PATH}"), key).await?; + Ok(models.iter().map(normalize_model).collect()) +} + +/// One model as the wire reports it: the OpenAI shape plus Moonshot's +/// enrichment fields. +#[derive(Debug, Deserialize)] +struct WireModel { + id: String, + created: Option, + context_length: Option, + supports_image_input: Option, + supports_video_input: Option, + supports_reasoning: Option, +} + +/// Normalize one wire model into a sheet entry. +fn normalize_model(model: &WireModel) -> ModelEntry { + let mut entry = base_entry(&model.id, model.created); + entry.context_window = model.context_length; + entry.images = model.supports_image_input.unwrap_or(false); + entry.video_input = model.supports_video_input.unwrap_or(false); + entry.thinking.supported = model.supports_reasoning.unwrap_or(false); + entry +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::openai_shape::ListResponse; + + /// The documented example response shape: one fully enriched entry + /// and one text-only non-reasoning entry. + const LIST: &str = r#"{ + "object": "list", + "data": [ + { + "id": "kimi-k2.5", + "object": "model", + "created": 1782864000, + "owned_by": "moonshot", + "context_length": 262144, + "supports_image_input": true, + "supports_video_input": true, + "supports_reasoning": true + }, + { + "id": "kimi-k2-instruct", + "object": "model", + "created": 1782864000, + "owned_by": "moonshot", + "context_length": 131072, + "supports_image_input": false, + "supports_video_input": false, + "supports_reasoning": false + } + ] +}"#; + + fn entries(json: &str) -> Vec { + let page: ListResponse = + serde_json::from_str(json).expect("fixture must parse as a list"); + page.data.iter().map(normalize_model).collect() + } + + #[test] + fn maps_context_length_and_capability_flags() { + let entries = entries(LIST); + let entry = &entries[0]; + assert_eq!(entry.id, "kimi-k2.5"); + assert_eq!( + entry.context_window, + Some(262_144), + "context_length maps to context_window" + ); + assert!(entry.images, "supports_image_input maps to images"); + assert!( + entry.video_input, + "supports_video_input maps to video_input" + ); + assert!( + entry.thinking.supported, + "supports_reasoning maps to thinking.supported" + ); + assert!( + !entry.thinking.enabled && !entry.thinking.adaptive, + "the reasoning flag says nothing about budget modes" + ); + } + + #[test] + fn text_only_entry_is_conservative() { + let entries = entries(LIST); + let entry = &entries[1]; + assert_eq!(entry.id, "kimi-k2-instruct"); + assert_eq!(entry.context_window, Some(131_072)); + assert!(!entry.images && !entry.video_input); + assert!(!entry.thinking.supported); + } + + #[test] + fn absent_flags_are_conservative() { + let entries = entries( + r#"{ + "object": "list", + "data": [ + { + "id": "kimi-legacy", + "object": "model", + "created": 1782864000, + "owned_by": "moonshot" + } + ] + }"#, + ); + let entry = &entries[0]; + assert_eq!(entry.context_window, None); + assert!(!entry.images && !entry.video_input && !entry.thinking.supported); + } +} diff --git a/crates/shared-cloud-providers/src/providers/openai.rs b/crates/shared-cloud-providers/src/providers/openai.rs new file mode 100644 index 000000000..d3cb05dc9 --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/openai.rs @@ -0,0 +1,110 @@ +//! OpenAI provider: the public descriptor plus the private variance of +//! `GET /v1/models` - Bearer auth and the IDs-only response shape (`id`, +//! `created`, `owned_by`): no pagination, no token limits, and no +//! capability reporting, so every entry is the conservative base entry. +//! +//! Docs: + +use serde::Deserialize; +use shared_gateway_api::{ModelEntry, Tier}; + +use crate::providers::openai_shape::{base_entry, fetch_list}; +use crate::{FetchError, Provider}; + +/// The OpenAI provider descriptor. +pub const PROVIDER: Provider = Provider { + name: "openai", + display_name: "OpenAI", + tier: Tier::Prime, + key_env: "OPENAI_API_KEY", + base_url: "https://api.openai.com/v1", +}; + +/// The list path under the base URL. +const MODELS_PATH: &str = "/models"; + +/// Fetch and normalize OpenAI's model list in a single request. +pub(crate) async fn fetch( + client: &reqwest::Client, + base_url: &str, + key: &str, +) -> Result, FetchError> { + let models: Vec = + fetch_list(client, &format!("{base_url}{MODELS_PATH}"), key).await?; + Ok(models.iter().map(normalize_model).collect()) +} + +/// One model as the wire reports it; `owned_by` is carried on the wire +/// but has no sheet field. +#[derive(Debug, Deserialize)] +struct WireModel { + id: String, + created: Option, +} + +/// Normalize one wire model: the endpoint is IDs-only, so the entry is +/// the conservative base. +fn normalize_model(model: &WireModel) -> ModelEntry { + base_entry(&model.id, model.created) +} + +#[cfg(test)] +mod tests { + use time::{Date, Month}; + + use super::*; + use crate::providers::openai_shape::ListResponse; + + /// The documented example response shape: two IDs-only entries. + const LIST: &str = r#"{ + "object": "list", + "data": [ + { + "id": "gpt-5.2", + "object": "model", + "created": 1782864000, + "owned_by": "openai" + }, + { + "id": "gpt-5-mini", + "object": "model", + "created": 1782864000, + "owned_by": "openai" + } + ] +}"#; + + fn entries(json: &str) -> Vec { + let page: ListResponse = + serde_json::from_str(json).expect("fixture must parse as a list"); + page.data.iter().map(normalize_model).collect() + } + + #[test] + fn ids_only_entries_are_conservative() { + let entries = entries(LIST); + assert_eq!(entries.len(), 2, "both listed models normalize"); + let entry = &entries[0]; + assert_eq!(entry.id, "gpt-5.2"); + assert_eq!( + entry.display_name, "gpt-5.2", + "the id doubles as the display name" + ); + assert_eq!(entry.context_window, None, "IDs-only providers omit limits"); + assert_eq!(entry.max_output, None); + assert!(!entry.images && !entry.tool_calling && !entry.thinking.supported); + assert!(entry.effort_levels.is_empty()); + assert!(entry.pricing.is_none()); + assert!(entry.deprecation.is_none()); + } + + #[test] + fn created_maps_to_released_at() { + let entries = entries(LIST); + assert_eq!( + entries[0].released_at, + Date::from_calendar_date(2026, Month::July, 1).ok(), + "the unix `created` timestamp maps to a calendar date" + ); + } +} diff --git a/crates/shared-cloud-providers/src/providers/openai_shape.rs b/crates/shared-cloud-providers/src/providers/openai_shape.rs new file mode 100644 index 000000000..4b1d21a83 --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/openai_shape.rs @@ -0,0 +1,110 @@ +//! The shared OpenAI list-response shape: a `data` array in a `list` +//! envelope, Bearer auth, no pagination. Each provider speaking this +//! dialect defines its own wire model struct - with whatever extra fields +//! its endpoint reports - and its own normalization; this module carries +//! only the envelope, the single-request fetch, and the conservative base +//! entry every dialect entry starts from. + +use serde::Deserialize; +use serde::de::DeserializeOwned; +use shared_gateway_api::{ModelEntry, ModelKind, Thinking}; +use time::OffsetDateTime; + +use crate::FetchError; + +/// The list envelope every OpenAI-dialect endpoint speaks. +#[derive(Debug, Deserialize)] +pub(crate) struct ListResponse { + /// The listed models. + pub data: Vec, +} + +/// Fetch the whole list in one request; the dialect has no pagination. +pub(crate) async fn fetch_list( + client: &reqwest::Client, + url: &str, + key: &str, +) -> Result, FetchError> { + let response: ListResponse = client + .get(url) + .bearer_auth(key) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(response.data) +} + +/// The conservative base entry for one listed model: chat kind, every +/// capability false, every optional field empty. Provider files overwrite +/// the fields their endpoint actually reports; an IDs-only endpoint +/// yields this entry unchanged. The dialect reports no display name, so +/// the id doubles as the display name. +pub(crate) fn base_entry(id: &str, created: Option) -> ModelEntry { + ModelEntry { + id: id.to_owned(), + display_name: id.to_owned(), + kind: ModelKind::Chat, + released_at: created + .and_then(|unix| OffsetDateTime::from_unix_timestamp(unix).ok()) + .map(OffsetDateTime::date), + context_window: None, + max_output: None, + images: false, + pdf_input: false, + video_input: false, + audio_input: false, + batch: false, + citations: false, + code_execution: false, + structured_outputs: false, + tool_calling: false, + thinking: Thinking::default(), + effort_levels: Vec::new(), + default_effort: None, + pricing: None, + deprecation: None, + } +} + +#[cfg(test)] +mod tests { + use time::{Date, Month}; + + use super::*; + + #[test] + fn base_entry_is_conservative() { + let entry = base_entry("some-model", None); + assert_eq!(entry.id, "some-model"); + assert_eq!( + entry.display_name, "some-model", + "the id doubles as the display name" + ); + assert_eq!(entry.kind, ModelKind::Chat); + assert_eq!(entry.released_at, None, "no created, no release date"); + assert_eq!(entry.context_window, None); + assert_eq!(entry.max_output, None); + assert!(!entry.images && !entry.pdf_input); + assert!(!entry.video_input && !entry.audio_input); + assert!(!entry.batch && !entry.citations && !entry.code_execution); + assert!(!entry.structured_outputs && !entry.tool_calling); + assert!(!entry.thinking.supported); + assert!(!entry.thinking.enabled && !entry.thinking.adaptive); + assert!(entry.effort_levels.is_empty()); + assert_eq!(entry.default_effort, None); + assert!(entry.pricing.is_none()); + assert!(entry.deprecation.is_none()); + } + + #[test] + fn created_unix_seconds_map_to_a_calendar_date() { + let entry = base_entry("some-model", Some(1_782_864_000)); + assert_eq!( + entry.released_at, + Date::from_calendar_date(2026, Month::July, 1).ok(), + "1782864000 is 2026-07-01T00:00:00Z" + ); + } +} diff --git a/crates/shared-cloud-providers/src/providers/qwen.rs b/crates/shared-cloud-providers/src/providers/qwen.rs new file mode 100644 index 000000000..d05b1b9ec --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/qwen.rs @@ -0,0 +1,90 @@ +//! Alibaba Qwen provider: the public descriptor plus the private variance +//! of the DashScope compatible-mode endpoint - `GET /models` under +//! `https://dashscope.aliyuncs.com/compatible-mode/v1`, Bearer auth, and +//! the plain OpenAI response shape. The native `/api/v1/models` endpoint +//! adds pagination, pricing, and context length; the compatible-mode +//! endpoint is IDs-only, so every entry is the conservative base entry. +//! +//! Docs: + +use serde::Deserialize; +use shared_gateway_api::{ModelEntry, Tier}; + +use crate::providers::openai_shape::{base_entry, fetch_list}; +use crate::{FetchError, Provider}; + +/// The Alibaba Qwen provider descriptor. +pub const PROVIDER: Provider = Provider { + name: "qwen", + display_name: "Alibaba Qwen", + tier: Tier::Prime, + key_env: "DASHSCOPE_API_KEY", + base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", +}; + +/// The list path under the base URL. +const MODELS_PATH: &str = "/models"; + +/// Fetch and normalize Qwen's model list in a single request. +pub(crate) async fn fetch( + client: &reqwest::Client, + base_url: &str, + key: &str, +) -> Result, FetchError> { + let models: Vec = + fetch_list(client, &format!("{base_url}{MODELS_PATH}"), key).await?; + Ok(models.iter().map(normalize_model).collect()) +} + +/// One model as the wire reports it. +#[derive(Debug, Deserialize)] +struct WireModel { + id: String, + created: Option, +} + +/// Normalize one wire model: the compatible-mode endpoint is IDs-only, +/// so the entry is the conservative base. +fn normalize_model(model: &WireModel) -> ModelEntry { + base_entry(&model.id, model.created) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::openai_shape::ListResponse; + + /// The documented example response shape from the compatible-mode + /// endpoint. + const LIST: &str = r#"{ + "object": "list", + "data": [ + { + "id": "qwen3-max", + "object": "model", + "created": 1782864000, + "owned_by": "alibaba" + }, + { + "id": "qwen3-coder-plus", + "object": "model", + "created": 1782864000, + "owned_by": "alibaba" + } + ] +}"#; + + #[test] + fn ids_only_entries_are_conservative() { + let page: ListResponse = + serde_json::from_str(LIST).expect("fixture must parse as a list"); + let entries: Vec = page.data.iter().map(normalize_model).collect(); + assert_eq!(entries.len(), 2, "both listed models normalize"); + let entry = &entries[0]; + assert_eq!(entry.id, "qwen3-max"); + assert_eq!(entry.context_window, None, "IDs-only providers omit limits"); + assert_eq!(entry.max_output, None); + assert!(!entry.images && !entry.tool_calling && !entry.thinking.supported); + assert!(entry.pricing.is_none()); + } +} diff --git a/crates/shared-cloud-providers/src/providers/xai.rs b/crates/shared-cloud-providers/src/providers/xai.rs new file mode 100644 index 000000000..155f19cb6 --- /dev/null +++ b/crates/shared-cloud-providers/src/providers/xai.rs @@ -0,0 +1,180 @@ +//! xAI provider: the public descriptor plus the private variance of +//! `GET /v1/models` - Bearer auth over the OpenAI list shape, extended +//! with `aliases` (carried on the wire; the sheet schema has no aliases +//! field, so they are deliberately dropped), `context_length`, and +//! per-token pricing reported as USD cents per 100M tokens. +//! +//! Docs: + +use serde::Deserialize; +use shared_gateway_api::{ModelEntry, Pricing, Tier}; + +use crate::providers::openai_shape::{base_entry, fetch_list}; +use crate::{FetchError, Provider}; + +/// The xAI provider descriptor. +pub const PROVIDER: Provider = Provider { + name: "xai", + display_name: "xAI", + tier: Tier::Prime, + key_env: "XAI_API_KEY", + base_url: "https://api.x.ai", +}; + +/// The list path under the base URL. +const MODELS_PATH: &str = "/v1/models"; + +/// USD cents per 100M tokens to USD per million tokens: 100M to 1M is a +/// factor of 100, cents to dollars another. +const CENTS_PER_100M_TO_USD_PER_MTOK: f64 = 10_000.0; + +/// Fetch and normalize xAI's model list in a single request. +pub(crate) async fn fetch( + client: &reqwest::Client, + base_url: &str, + key: &str, +) -> Result, FetchError> { + let models: Vec = + fetch_list(client, &format!("{base_url}{MODELS_PATH}"), key).await?; + Ok(models.iter().map(normalize_model).collect()) +} + +/// One model as the wire reports it: the OpenAI shape plus xAI's +/// extensions. `aliases` parses but is dropped - the sheet schema has no +/// aliases field. +#[derive(Debug, Deserialize)] +struct WireModel { + id: String, + created: Option, + context_length: Option, + prompt_text_token_price: Option, + completion_text_token_price: Option, +} + +/// Normalize one wire model into a sheet entry. +fn normalize_model(model: &WireModel) -> ModelEntry { + let mut entry = base_entry(&model.id, model.created); + entry.context_window = model.context_length; + entry.pricing = pricing(model); + entry +} + +/// Convert a wire price (USD cents per 100M tokens) to USD per million +/// tokens. +fn usd_per_mtok(cents_per_100m: f64) -> f64 { + cents_per_100m / CENTS_PER_100M_TO_USD_PER_MTOK +} + +/// Pricing is emitted only when the wire reports both directions; a +/// half-known price is worse than an absent one. +fn pricing(model: &WireModel) -> Option { + Some(Pricing { + currency: "USD".to_owned(), + prompt_per_mtok: usd_per_mtok(model.prompt_text_token_price?), + completion_per_mtok: usd_per_mtok(model.completion_text_token_price?), + }) +} + +#[cfg(test)] +mod tests { + use time::{Date, Month}; + + use super::*; + use crate::providers::openai_shape::ListResponse; + + /// The documented example response shape: one fully extended entry + /// and one entry carrying neither pricing nor a context length. + const LIST: &str = r#"{ + "object": "list", + "data": [ + { + "id": "grok-4", + "object": "model", + "created": 1782864000, + "owned_by": "xai", + "aliases": ["grok-4-latest"], + "context_length": 256000, + "prompt_text_token_price": 30000, + "completion_text_token_price": 150000 + }, + { + "id": "grok-3-mini", + "object": "model", + "created": 1782864000, + "owned_by": "xai", + "aliases": [] + } + ] +}"#; + + fn entries(json: &str) -> Vec { + let page: ListResponse = + serde_json::from_str(json).expect("fixture must parse as a list"); + page.data.iter().map(normalize_model).collect() + } + + #[test] + fn normalizes_context_length_and_pricing() { + let entries = entries(LIST); + let entry = &entries[0]; + assert_eq!(entry.id, "grok-4"); + assert_eq!( + entry.context_window, + Some(256_000), + "context_length maps to context_window" + ); + assert_eq!( + entry.released_at, + Date::from_calendar_date(2026, Month::July, 1).ok() + ); + let pricing = entry.pricing.as_ref().expect("pricing must be present"); + assert_eq!(pricing.currency, "USD"); + assert_eq!( + pricing.prompt_per_mtok.to_bits(), + 3.0f64.to_bits(), + "30000 cents per 100M tokens is $3 per million" + ); + assert_eq!( + pricing.completion_per_mtok.to_bits(), + 15.0f64.to_bits(), + "150000 cents per 100M tokens is $15 per million" + ); + } + + #[test] + fn absent_extensions_stay_absent() { + let entries = entries(LIST); + let entry = &entries[1]; + assert_eq!(entry.id, "grok-3-mini"); + assert_eq!(entry.context_window, None); + assert!( + entry.pricing.is_none(), + "pricing is emitted only when both directions are reported" + ); + } + + #[test] + fn aliases_are_tolerated_and_dropped() { + let entries = entries(LIST); + assert_eq!( + entries.len(), + 2, + "the aliases extension must not break parsing" + ); + } + + #[test] + fn pricing_requires_both_directions() { + let half = WireModel { + id: "m".to_owned(), + created: None, + context_length: None, + prompt_text_token_price: Some(30000.0), + completion_text_token_price: None, + }; + assert!( + pricing(&half).is_none(), + "a half-known price is worse than an absent one" + ); + } +} diff --git a/crates/shared-cloud-providers/src/sheet.rs b/crates/shared-cloud-providers/src/sheet.rs new file mode 100644 index 000000000..a12257825 --- /dev/null +++ b/crates/shared-cloud-providers/src/sheet.rs @@ -0,0 +1,501 @@ +//! Sheet assembly and download: `build_sheet` walks the provider +//! registry, fetching each provider and propagating last-known-good +//! slices for failed fetches; `fetch_sheet` downloads the published +//! sheet from the release artifact. + +use std::collections::BTreeMap; +use std::future::Future; +use std::pin::Pin; + +use shared_gateway_api::{ModelEntry, ProviderSlice, Sheet, SliceStatus, Tier}; +use time::OffsetDateTime; + +use crate::{FetchError, Provider}; + +/// The current sheet schema version. +const SCHEMA_VERSION: u32 = 1; + +/// The outcome of one provider fetch: the normalized models, or the +/// failure that triggers last-known-good propagation. +type BoxFetch = Pin, FetchError>> + Send>>; + +/// Build the complete sheet: fetch every provider, propagate +/// last-known-good slices from `previous` for failed fetches, emit +/// static slices for Niche providers, and assemble the envelope. +/// +/// A failed fetch never fails the build: a provider with a previous +/// slice is copied verbatim with `status` rewritten to `stale`, and a +/// provider with no previous slice records `unavailable` with an empty +/// `models` array. Niche providers are never fetched and take nothing +/// from `previous`; their `static` slice is read from a per-provider +/// JSON file compiled into the binary. +pub async fn build_sheet( + client: &reqwest::Client, + previous: Option, + keys: &dyn Fn(&Provider) -> Option, +) -> Sheet { + build_sheet_with( + crate::providers(), + previous, + keys, + &|client: reqwest::Client, provider: Provider, key: Option| { + Box::pin(async move { + match key { + Some(key) => crate::fetch_models(&client, &provider, &key).await, + None => Err(FetchError::MissingKey { + name: provider.name.to_owned(), + key_env: provider.key_env, + }), + } + }) + }, + client, + ) + .await +} + +/// Download and parse the current sheet from the release artifact. +/// +/// # Errors +/// +/// Returns [`FetchError::NotFound`] on HTTP 404 and [`FetchError::Http`] +/// when the download fails, the response is any other non-success, or +/// the body does not parse as a [`Sheet`]. +pub async fn fetch_sheet(client: &reqwest::Client, release_url: &str) -> Result { + let response = client.get(release_url).send().await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(FetchError::NotFound { + url: release_url.to_owned(), + }); + } + Ok(response.error_for_status()?.json().await?) +} + +/// The testable core of [`build_sheet`]: the registry and the fetch seam +/// are parameters so tests can inject providers and canned outcomes. +async fn build_sheet_with( + registry: &[Provider], + previous: Option, + keys: &dyn Fn(&Provider) -> Option, + fetch: &dyn Fn(reqwest::Client, Provider, Option) -> BoxFetch, + client: &reqwest::Client, +) -> Sheet { + let mut previous = previous.map_or_else(BTreeMap::new, |sheet| sheet.providers); + let now = OffsetDateTime::now_utc(); + let mut slices = BTreeMap::new(); + for provider in registry { + let prior = previous.remove(provider.name); + let slice = if provider.tier == Tier::Niche { + static_slice(provider) + } else { + match fetch(client.clone(), *provider, keys(provider)).await { + Ok(models) => ProviderSlice { + display_name: provider.display_name.to_owned(), + tier: provider.tier, + status: SliceStatus::Ok, + fetched_at: Some(now), + models, + }, + Err(_) => stale_or_unavailable(provider, prior), + } + }; + slices.insert(provider.name.to_owned(), slice); + } + Sheet { + schema_version: SCHEMA_VERSION, + generated_at: now, + providers: slices, + } +} + +/// Propagate a failed fetch: the previous slice verbatim with `status` +/// rewritten to `stale`, or `unavailable` with an empty model list when +/// there is nothing to propagate. +fn stale_or_unavailable(provider: &Provider, prior: Option) -> ProviderSlice { + match prior { + Some(mut slice) => { + slice.status = SliceStatus::Stale; + slice + } + None => ProviderSlice { + display_name: provider.display_name.to_owned(), + tier: provider.tier, + status: SliceStatus::Unavailable, + fetched_at: None, + models: Vec::new(), + }, + } +} + +/// The compiled-in model list for a Niche provider: one JSON file per +/// provider in the repo, pulled in with `include_str!` and parsed as +/// `Vec`. v1 ships no Niche providers, so the match has no +/// production arms yet. +fn static_json(name: &str) -> Option<&'static str> { + match name { + #[cfg(test)] + "test-niche" => Some(include_str!("../tests/fixtures/test-niche.json")), + _ => None, + } +} + +/// A Niche provider's slice: never fetched, and nothing taken from the +/// previous sheet. The hand-curated models are compiled into the binary +/// from the provider's JSON file; `fetched_at` is always absent because +/// the slice has never been fresh. +fn static_slice(provider: &Provider) -> ProviderSlice { + let models = static_json(provider.name).map_or_else(Vec::new, |json| { + serde_json::from_str(json).unwrap_or_else(|err| { + panic!( + "compiled-in static model file for `{}` must parse: {err}", + provider.name + ) + }) + }); + ProviderSlice { + display_name: provider.display_name.to_owned(), + tier: provider.tier, + status: SliceStatus::Static, + fetched_at: None, + models, + } +} + +#[cfg(test)] +mod tests { + use shared_gateway_api::{ModelKind, Thinking}; + use time::format_description::well_known::Rfc3339; + + use super::*; + + fn provider(name: &'static str, display_name: &'static str, tier: Tier) -> Provider { + Provider { + name, + display_name, + tier, + key_env: "TEST_PROVIDER_API_KEY", + base_url: "https://example.invalid", + } + } + + fn entry(id: &str) -> ModelEntry { + ModelEntry { + id: id.to_owned(), + display_name: id.to_owned(), + kind: ModelKind::Chat, + released_at: None, + context_window: Some(200_000), + max_output: Some(8_192), + images: false, + pdf_input: false, + video_input: false, + audio_input: false, + batch: false, + citations: false, + code_execution: false, + structured_outputs: true, + tool_calling: true, + thinking: Thinking::default(), + effort_levels: Vec::new(), + default_effort: None, + pricing: None, + deprecation: None, + } + } + + fn pinned(text: &str) -> OffsetDateTime { + OffsetDateTime::parse(text, &Rfc3339).expect("pinned timestamp must parse") + } + + fn prior_sheet(name: &str, slice: ProviderSlice) -> Sheet { + Sheet { + schema_version: 1, + generated_at: pinned("2026-09-01T00:00:00Z"), + providers: BTreeMap::from([(name.to_owned(), slice)]), + } + } + + #[tokio::test] + async fn fresh_fetch_writes_ok_slice() { + let registry = [provider("test-ok", "Test OK", Tier::Prime)]; + let keys = |_provider: &Provider| Some("test-key".to_owned()); + let fetch = |_client: reqwest::Client, + _provider: Provider, + _key: Option| + -> BoxFetch { Box::pin(async { Ok(vec![entry("m1"), entry("m2")]) }) }; + let client = reqwest::Client::new(); + let before = OffsetDateTime::now_utc(); + let sheet = build_sheet_with(®istry, None, &keys, &fetch, &client).await; + let after = OffsetDateTime::now_utc(); + assert_eq!(sheet.schema_version, 1); + assert!( + before <= sheet.generated_at && sheet.generated_at <= after, + "generated_at must be this run's time" + ); + let slice = &sheet.providers["test-ok"]; + assert_eq!(slice.status, SliceStatus::Ok); + assert_eq!(slice.display_name, "Test OK"); + assert_eq!(slice.tier, Tier::Prime); + let fetched_at = slice.fetched_at.expect("an ok slice must carry fetched_at"); + assert!( + before <= fetched_at && fetched_at <= after, + "fetched_at must be this run's time" + ); + assert_eq!(slice.models.len(), 2); + assert_eq!(slice.models[0].id, "m1"); + } + + #[tokio::test] + async fn failed_fetch_propagates_previous_slice_verbatim_as_stale() { + let registry = [provider("test-stale", "Test Stale", Tier::Prime)]; + let fetched_at = pinned("2026-01-01T00:00:00Z"); + let previous = prior_sheet( + "test-stale", + ProviderSlice { + display_name: "Old Name".to_owned(), + tier: Tier::Subprime, + status: SliceStatus::Ok, + fetched_at: Some(fetched_at), + models: vec![entry("old-m1")], + }, + ); + let keys = |_provider: &Provider| Some("test-key".to_owned()); + let fetch = + |_client: reqwest::Client, _provider: Provider, _key: Option| -> BoxFetch { + Box::pin(async { + Err(FetchError::UnsupportedProvider { + name: "boom".to_owned(), + }) + }) + }; + let client = reqwest::Client::new(); + let sheet = build_sheet_with(®istry, Some(previous), &keys, &fetch, &client).await; + let slice = &sheet.providers["test-stale"]; + assert_eq!(slice.status, SliceStatus::Stale); + assert_eq!( + slice.fetched_at, + Some(fetched_at), + "a stale slice preserves its original fetched_at" + ); + assert_eq!( + slice.display_name, "Old Name", + "a stale slice is the previous slice verbatim" + ); + assert_eq!(slice.tier, Tier::Subprime); + assert_eq!(slice.models.len(), 1); + assert_eq!(slice.models[0].id, "old-m1"); + } + + #[tokio::test] + async fn failed_fetch_without_previous_records_unavailable() { + let registry = [provider("test-down", "Test Down", Tier::Prime)]; + let keys = |_provider: &Provider| Some("test-key".to_owned()); + let fetch = + |_client: reqwest::Client, _provider: Provider, _key: Option| -> BoxFetch { + Box::pin(async { + Err(FetchError::UnsupportedProvider { + name: "boom".to_owned(), + }) + }) + }; + let client = reqwest::Client::new(); + let sheet = build_sheet_with(®istry, None, &keys, &fetch, &client).await; + let slice = &sheet.providers["test-down"]; + assert_eq!(slice.status, SliceStatus::Unavailable); + assert!(slice.models.is_empty()); + assert_eq!(slice.fetched_at, None); + assert_eq!( + slice.display_name, "Test Down", + "an unavailable slice still describes the provider" + ); + assert_eq!(slice.tier, Tier::Prime); + } + + #[tokio::test] + async fn niche_provider_emits_static_slice_without_fetching() { + let registry = [ + provider("test-niche", "Test Niche", Tier::Niche), + provider("test-niche-empty", "Test Niche Empty", Tier::Niche), + ]; + let previous = prior_sheet( + "test-niche", + ProviderSlice { + display_name: "Test Niche".to_owned(), + tier: Tier::Niche, + status: SliceStatus::Static, + fetched_at: None, + models: vec![entry("old-m1")], + }, + ); + let keys = |_provider: &Provider| Some("test-key".to_owned()); + let fetch = + |_client: reqwest::Client, provider: Provider, _key: Option| -> BoxFetch { + panic!("niche providers must never be fetched: {}", provider.name); + }; + let client = reqwest::Client::new(); + let sheet = build_sheet_with(®istry, Some(previous), &keys, &fetch, &client).await; + let curated = &sheet.providers["test-niche"]; + assert_eq!(curated.status, SliceStatus::Static); + assert_eq!( + curated.fetched_at, None, + "a static slice is never fresh, so fetched_at is absent" + ); + assert_eq!( + curated.models.len(), + 1, + "a static slice's models come from the compiled-in JSON file" + ); + assert_eq!(curated.models[0].id, "curated-m1"); + assert_eq!(curated.models[0].display_name, "Curated M1"); + assert_eq!(curated.models[0].context_window, Some(64_000)); + assert_eq!( + curated.models[0].released_at, + Some( + time::Date::from_calendar_date(2026, time::Month::January, 15) + .expect("fixture date must be valid") + ), + "the compiled-in JSON parses into full model entries" + ); + let empty = &sheet.providers["test-niche-empty"]; + assert_eq!(empty.status, SliceStatus::Static); + assert!( + empty.models.is_empty(), + "a niche provider with no compiled-in file takes nothing from `previous`" + ); + assert_eq!(empty.fetched_at, None); + } + + #[tokio::test] + async fn failed_fetch_never_fails_the_build_and_never_drops_data() { + let registry = [ + provider("test-ok", "Test OK", Tier::Prime), + provider("test-down", "Test Down", Tier::Prime), + provider("test-nokey", "Test Nokey", Tier::Prime), + ]; + let previous = prior_sheet( + "test-nokey", + ProviderSlice { + display_name: "Test Nokey".to_owned(), + tier: Tier::Prime, + status: SliceStatus::Ok, + fetched_at: Some(pinned("2026-06-01T00:00:00Z")), + models: vec![entry("kept-m1")], + }, + ); + let keys = + |provider: &Provider| (provider.name != "test-nokey").then(|| "test-key".to_owned()); + let fetch = + |_client: reqwest::Client, provider: Provider, key: Option| -> BoxFetch { + Box::pin(async move { + match (provider.name, key) { + ("test-ok", Some(_)) => Ok(vec![entry("m1")]), + ("test-nokey", None) => Err(FetchError::MissingKey { + name: provider.name.to_owned(), + key_env: provider.key_env, + }), + _ => Err(FetchError::UnsupportedProvider { + name: provider.name.to_owned(), + }), + } + }) + }; + let client = reqwest::Client::new(); + let sheet = build_sheet_with(®istry, Some(previous), &keys, &fetch, &client).await; + assert_eq!( + sheet.providers.len(), + 3, + "one provider's failure must not fail the build" + ); + assert_eq!(sheet.providers["test-ok"].status, SliceStatus::Ok); + assert_eq!( + sheet.providers["test-down"].status, + SliceStatus::Unavailable + ); + let nokey = &sheet.providers["test-nokey"]; + assert_eq!( + nokey.status, + SliceStatus::Stale, + "a missing key propagates last-known-good like any failed fetch" + ); + assert_eq!( + nokey.models[0].id, "kept-m1", + "a failed fetch never drops data" + ); + } + + #[tokio::test] + async fn fetch_sheet_reports_http_errors() { + let client = reqwest::Client::new(); + let result = fetch_sheet(&client, "http://127.0.0.1:1/models.json").await; + let Err(err) = result else { + panic!("an unreachable release URL must not parse as a sheet"); + }; + assert!( + matches!(err, FetchError::Http(_)), + "expected a transport error, got {err:?}" + ); + } + + #[tokio::test] + async fn fetch_sheet_parses_a_successful_response() { + use std::io::Write as _; + + let fetched_at = pinned("2026-09-14T13:00:00Z"); + let expected = Sheet { + schema_version: 1, + generated_at: fetched_at, + providers: BTreeMap::from([( + "test-provider".to_owned(), + ProviderSlice { + display_name: "Test Provider".to_owned(), + tier: Tier::Prime, + status: SliceStatus::Ok, + fetched_at: Some(fetched_at), + models: vec![entry("m1")], + }, + )]), + }; + let body = serde_json::to_string(&expected).expect("sheet fixture must serialize"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fixture server"); + let addr = listener.local_addr().expect("fixture server addr"); + let server = std::thread::spawn(move || { + use std::io::Read as _; + + let (mut stream, _) = listener.accept().expect("accept fixture client"); + // Read the request first: replying before the client finishes + // sending is an HTTP protocol error. A short read timeout bounds + // the capture without a sleep; once the client awaits the + // response, the next read simply times out. + let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(200))); + let mut buf = [0_u8; 4096]; + loop { + match stream.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write fixture response"); + }); + let client = reqwest::Client::new(); + let sheet = fetch_sheet(&client, &format!("http://{addr}/models.json")) + .await + .expect("a 2xx response with a valid sheet body must parse"); + server.join().expect("fixture server must finish"); + assert_eq!(sheet.schema_version, 1); + assert_eq!(sheet.generated_at, fetched_at); + let slice = &sheet.providers["test-provider"]; + assert_eq!(slice.display_name, "Test Provider"); + assert_eq!(slice.tier, Tier::Prime); + assert_eq!(slice.status, SliceStatus::Ok); + assert_eq!(slice.fetched_at, Some(fetched_at)); + assert_eq!(slice.models.len(), 1); + assert_eq!(slice.models[0].id, "m1"); + assert_eq!(slice.models[0].context_window, Some(200_000)); + } +} diff --git a/crates/shared-cloud-providers/tests/fixtures/test-niche.json b/crates/shared-cloud-providers/tests/fixtures/test-niche.json new file mode 100644 index 000000000..a6cd1f52b --- /dev/null +++ b/crates/shared-cloud-providers/tests/fixtures/test-niche.json @@ -0,0 +1,24 @@ +[ + { + "id": "curated-m1", + "display_name": "Curated M1", + "kind": "chat", + "released_at": "2026-01-15", + "context_window": 64000, + "max_output": 4096, + "images": false, + "pdf_input": false, + "video_input": false, + "audio_input": false, + "batch": false, + "citations": false, + "code_execution": false, + "structured_outputs": false, + "tool_calling": false, + "thinking": { "supported": false, "enabled": false, "adaptive": false }, + "effort_levels": [], + "default_effort": null, + "pricing": null, + "deprecation": null + } +] diff --git a/crates/shared-cloud-providers/tests/sheet_binary.rs b/crates/shared-cloud-providers/tests/sheet_binary.rs new file mode 100644 index 000000000..f17ad73ea --- /dev/null +++ b/crates/shared-cloud-providers/tests/sheet_binary.rs @@ -0,0 +1,246 @@ +//! Integration tests for the sheet-building binary: the binary runs +//! against a recorded previous-sheet fixture served over loopback HTTP, +//! and the emitted `models.json` must parse as a schema-valid [`Sheet`]. + +use std::io::{Read as _, Write as _}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::process::{Command, Output}; + +use shared_cloud_providers::providers; +use shared_gateway_api::{Sheet, SliceStatus}; + +/// The binary under test, built by Cargo alongside the integration test. +const BIN: &str = env!("CARGO_BIN_EXE_shared-cloud-providers"); + +/// The environment variable carrying the previous release's sheet URL. +const PREVIOUS_SHEET_URL_ENV: &str = "MODELS_SHEET_PREVIOUS_URL"; + +/// A recorded previous release: one fresh Anthropic slice with one model. +const PREVIOUS_SHEET_JSON: &str = r#"{ + "schema_version": 1, + "generated_at": "2026-09-01T00:00:00Z", + "providers": { + "anthropic": { + "display_name": "Anthropic", + "tier": "prime", + "status": "ok", + "fetched_at": "2026-09-01T00:00:00Z", + "models": [ + { + "id": "recorded-m1", + "display_name": "Recorded M1", + "kind": "chat", + "released_at": null, + "context_window": 200000, + "max_output": 8192, + "images": true, + "pdf_input": false, + "video_input": false, + "audio_input": false, + "batch": false, + "citations": false, + "code_execution": false, + "structured_outputs": true, + "tool_calling": true, + "thinking": { "supported": true, "enabled": true, "adaptive": false }, + "effort_levels": ["low", "high"], + "default_effort": "high", + "pricing": null, + "deprecation": null + } + ] + } + } +}"#; + +/// Serve one HTTP response with `status` carrying `body`, returning the +/// URL to request. +fn serve_once(status: &'static str, body: &'static str) -> String { + let Ok(listener) = TcpListener::bind("127.0.0.1:0") else { + panic!("bind fixture server"); + }; + let Ok(addr) = listener.local_addr() else { + panic!("fixture server addr"); + }; + std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + panic!("accept fixture client"); + }; + // Read the request first: replying before the client finishes + // sending is an HTTP protocol error. A short read timeout bounds + // the capture without a sleep; once the client awaits the + // response, the next read simply times out. + let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(200))); + let mut buf = [0_u8; 4096]; + loop { + match stream.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + assert!( + stream.write_all(response.as_bytes()).is_ok(), + "write fixture response" + ); + }); + format!("http://{addr}/models.json") +} + +/// A unique output path in the temp directory for one test run. +fn output_path(test: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "shared-cloud-providers-{test}-{}.json", + std::process::id() + )) +} + +/// Run the binary with every provider key stripped from the environment, +/// so no host credential can turn a fixture run into a live fetch. +fn run_binary(output: &PathBuf, previous_url: Option<&str>) -> Output { + let mut command = Command::new(BIN); + command.arg(output); + for provider in providers() { + command.env_remove(provider.key_env); + } + match previous_url { + Some(url) => command.env(PREVIOUS_SHEET_URL_ENV, url), + None => command.env_remove(PREVIOUS_SHEET_URL_ENV), + }; + let Ok(output) = command.output() else { + panic!("run the sheet-building binary"); + }; + output +} + +/// Read the emitted sheet, failing with the binary's stderr when the +/// run itself failed. +fn read_output(output: &PathBuf, result: &Output) -> Sheet { + assert!( + result.status.success(), + "the binary must exit successfully: {}", + String::from_utf8_lossy(&result.stderr) + ); + let Ok(json) = std::fs::read_to_string(output) else { + panic!("the binary must write the output file"); + }; + let Ok(sheet) = serde_json::from_str(&json) else { + panic!("the output must parse as a schema-valid Sheet"); + }; + sheet +} + +#[test] +fn binary_emits_valid_sheet_and_propagates_stale_slices() { + let url = serve_once("200 OK", PREVIOUS_SHEET_JSON); + let output = output_path("stale"); + let result = run_binary(&output, Some(&url)); + let sheet = read_output(&output, &result); + let _ = std::fs::remove_file(&output); + + assert_eq!(sheet.schema_version, 1); + assert_eq!( + sheet.providers.len(), + providers().len(), + "every registered provider must appear in the sheet" + ); + let anthropic = &sheet.providers["anthropic"]; + assert_eq!( + anthropic.status, + SliceStatus::Stale, + "a failed fetch must propagate the previous slice as stale" + ); + assert_eq!( + anthropic.models.len(), + 1, + "stale propagation keeps the recorded models" + ); + assert_eq!(anthropic.models[0].id, "recorded-m1"); + let openai = &sheet.providers["openai"]; + assert_eq!( + openai.status, + SliceStatus::Unavailable, + "a provider with no key and no previous slice records unavailable" + ); + assert!(openai.models.is_empty()); +} + +#[test] +fn binary_tolerates_first_run_without_previous_sheet() { + let output = output_path("first-run"); + let result = run_binary(&output, None); + let sheet = read_output(&output, &result); + let _ = std::fs::remove_file(&output); + + assert_eq!(sheet.schema_version, 1); + assert_eq!(sheet.providers.len(), providers().len()); + for (name, slice) in &sheet.providers { + assert_eq!( + slice.status, + SliceStatus::Unavailable, + "first run with no keys must record `{name}` as unavailable, not fail" + ); + } +} + +#[test] +fn binary_fails_without_writing_when_previous_sheet_errors() { + let url = serve_once("500 Internal Server Error", "boom"); + let output = output_path("previous-500"); + let _ = std::fs::remove_file(&output); + let result = run_binary(&output, Some(&url)); + + assert!( + !result.status.success(), + "a 500 previous-sheet response must fail the run" + ); + assert!( + !output.exists(), + "a failed run must not write the output file" + ); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!( + stderr.contains(url.as_str()), + "stderr must name the failing URL: {stderr}" + ); +} + +#[test] +fn binary_treats_404_previous_sheet_as_first_run() { + let url = serve_once("404 Not Found", "not found"); + let output = output_path("previous-404"); + let result = run_binary(&output, Some(&url)); + let sheet = read_output(&output, &result); + let _ = std::fs::remove_file(&output); + + assert_eq!(sheet.schema_version, 1); + assert_eq!(sheet.providers.len(), providers().len()); + for (name, slice) in &sheet.providers { + assert_eq!( + slice.status, + SliceStatus::Unavailable, + "a 404 previous sheet means first run: `{name}` must record unavailable" + ); + } +} + +#[test] +fn binary_fails_without_writing_when_previous_sheet_is_unparseable() { + let url = serve_once("200 OK", "this is not a sheet"); + let output = output_path("previous-invalid"); + let _ = std::fs::remove_file(&output); + let result = run_binary(&output, Some(&url)); + + assert!( + !result.status.success(), + "an unparseable 200 previous-sheet body must fail the run" + ); + assert!( + !output.exists(), + "a failed run must not write the output file" + ); +} diff --git a/crates/shared-gateway-api/Cargo.toml b/crates/shared-gateway-api/Cargo.toml new file mode 100644 index 000000000..ce4e2006c --- /dev/null +++ b/crates/shared-gateway-api/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "shared-gateway-api" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +description = "PromptForge shared gateway vocabulary: the provider model sheet schema" +keywords = ["prompt", "llm", "gateway", "models"] +categories = ["rust-patterns"] +documentation = "https://cppalliance.github.io/promptforge/" + +[dependencies] +serde.workspace = true +time = { workspace = true, features = ["parsing", "serde-human-readable"] } + +[dev-dependencies] +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/shared-gateway-api/src/lib.rs b/crates/shared-gateway-api/src/lib.rs new file mode 100644 index 000000000..10e9194e6 --- /dev/null +++ b/crates/shared-gateway-api/src/lib.rs @@ -0,0 +1,309 @@ +//! The provider model sheet schema: one versioned JSON snapshot of every +//! provider's models, published as a release artifact and consumed by the +//! Gateway and the Workshop UI. +//! +//! This crate is pure vocabulary: it depends only on `serde` and `time` and +//! on no other workspace crate, so every product crate may depend on it. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use time::{Date, OffsetDateTime}; + +mod metadata; + +pub use metadata::{Capabilities, ModelInfo, ModelKind, ThinkingMode}; + +/// The sheet envelope: one atomic snapshot of every provider's models. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sheet { + /// Bumped on breaking change. + pub schema_version: u32, + /// RFC 3339; always this run's time. + #[serde(with = "time::serde::rfc3339")] + pub generated_at: OffsetDateTime, + /// Keyed by provider name, e.g. "anthropic". + pub providers: BTreeMap, +} + +/// One provider's slice of the sheet. Self-describing: the descriptor's +/// public fields are copied in at build time so consumers can render a +/// provider dropdown from the sheet alone. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderSlice { + /// UI-facing name, e.g. "Anthropic". + pub display_name: String, + /// Curated product opinion, not a vendor fact. + pub tier: Tier, + /// Freshness of this slice. + pub status: SliceStatus, + /// Last fresh fetch; absent for `static` slices. + #[serde(with = "time::serde::rfc3339::option")] + pub fetched_at: Option, + /// The provider's normalized model entries. + pub models: Vec, +} + +/// Curated product opinion, not a vendor fact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Tier { + /// The frontier providers. + Prime, + /// Credible challengers. + Subprime, + /// Specialized or regional providers. + Niche, + /// Resellers of other providers' models. + Aggregator, +} + +/// Freshness of one provider's slice. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SliceStatus { + /// Fetched fresh this run. + Ok, + /// Copied verbatim from the previous sheet after a failed fetch. + Stale, + /// No previous slice and the fetch failed; `models` is empty. + Unavailable, + /// Curated by hand; never fetched. + Static, +} + +/// One normalized model entry. +// The modality and capability booleans are the sheet schema itself; a +// builder or sub-struct would only obscure the wire shape. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelEntry { + /// The upstream slug. + pub id: String, + /// UI-facing name. + pub display_name: String, + /// The workload: chat, embedding, classifier, speech (TTS), + /// transcription (STT), image, video. + #[serde(default)] + pub kind: ModelKind, + /// Optional: not every provider reports it. + pub released_at: Option, + /// Optional: the IDs-only providers omit it. + pub context_window: Option, + /// Optional maximum completion tokens. + pub max_output: Option, + // Modalities. + /// Accepts image input. + pub images: bool, + /// Accepts PDF input. + pub pdf_input: bool, + /// Accepts video input. + pub video_input: bool, + /// Accepts audio input. + pub audio_input: bool, + // Capabilities. + /// Supports batch submission. + pub batch: bool, + /// Returns grounded citations. + pub citations: bool, + /// Can execute code server-side. + pub code_execution: bool, + /// Honors response schemas. + pub structured_outputs: bool, + /// Emits tool calls. + pub tool_calling: bool, + /// Reasoning capability. + pub thinking: Thinking, + /// The provider's own level names, e.g. `["low", "high", "max"]`; + /// never mapped to a cross-provider scale. + pub effort_levels: Vec, + /// The provider's own default level name. + pub default_effort: Option, + /// Normalized to per-million-token units. + pub pricing: Option, + /// Sunset information, when the provider reports it. + pub deprecation: Option, +} + +/// Reasoning capability. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct Thinking { + /// Any reasoning capability at all. + pub supported: bool, + /// Manual budget mode (Anthropic "enabled"). + pub enabled: bool, + /// Model-chosen thinking depth. + pub adaptive: bool, +} + +/// Token pricing, normalized to per-million-token units. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Pricing { + /// ISO 4217, e.g. "USD", "CNY". + pub currency: String, + /// Prompt price per million tokens. + pub prompt_per_mtok: f64, + /// Completion price per million tokens. + pub completion_per_mtok: f64, +} + +/// Sunset information, when the provider reports it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Deprecation { + /// Provider's own lifecycle label, e.g. "LEGACY". + pub status: String, + /// The sunset date, when known. + pub date: Option, + /// The successor model id, when named. + pub replacement: Option, +} + +#[cfg(test)] +mod tests { + use time::OffsetDateTime; + use time::format_description::well_known::Rfc3339; + + use super::*; + + /// The example sheet from the implementation contract, verbatim. + const CONTRACT_EXAMPLE: &str = r#"{ + "schema_version": 1, + "generated_at": "2026-09-14T13:00:00Z", + "providers": { + "anthropic": { + "display_name": "Anthropic", + "tier": "prime", + "status": "ok", + "fetched_at": "2026-09-14T13:00:00Z", + "models": [ + { + "id": "claude-opus-5", + "display_name": "Claude Opus 5", + "released_at": "2026-07-24", + "context_window": 1000000, + "max_output": 128000, + "images": true, + "pdf_input": true, + "video_input": false, + "audio_input": false, + "batch": true, + "citations": true, + "code_execution": true, + "structured_outputs": true, + "tool_calling": true, + "thinking": { "supported": true, "enabled": false, "adaptive": true }, + "effort_levels": ["low", "medium", "high", "xhigh", "max"], + "default_effort": "high", + "pricing": { "currency": "USD", "prompt_per_mtok": 5.0, "completion_per_mtok": 25.0 }, + "deprecation": null + } + ] + } + } +}"#; + + fn entry(id: &str) -> ModelEntry { + ModelEntry { + id: id.to_owned(), + display_name: id.to_owned(), + kind: ModelKind::Chat, + released_at: None, + context_window: Some(200_000), + max_output: Some(8_192), + images: false, + pdf_input: false, + video_input: false, + audio_input: false, + batch: false, + citations: false, + code_execution: false, + structured_outputs: true, + tool_calling: true, + thinking: Thinking::default(), + effort_levels: vec!["low".to_owned(), "high".to_owned()], + default_effort: None, + pricing: None, + deprecation: None, + } + } + + fn slice(name: &str, models: Vec) -> ProviderSlice { + ProviderSlice { + display_name: name.to_owned(), + tier: Tier::Prime, + status: SliceStatus::Ok, + fetched_at: None, + models, + } + } + + fn sheet_with(names: &[&str]) -> Sheet { + Sheet { + schema_version: 1, + generated_at: OffsetDateTime::parse("2026-09-14T13:00:00Z", &Rfc3339) + .expect("pinned timestamp must parse"), + providers: names + .iter() + .map(|name| ((*name).to_owned(), slice(name, vec![entry("m1")]))) + .collect(), + } + } + + #[test] + fn schema_round_trip() { + let sheet = sheet_with(&["anthropic", "openai"]); + let line = serde_json::to_string(&sheet).expect("sheet must serialize"); + let back: Sheet = serde_json::from_str(&line).expect("its own output must parse"); + let line2 = serde_json::to_string(&back).expect("parsed sheet must re-serialize"); + assert_eq!(line, line2, "round-trip must be lossless"); + assert_eq!(back.schema_version, 1); + assert_eq!(back.providers["anthropic"].models[0].id, "m1"); + } + + #[test] + fn provider_ordering_is_byte_deterministic() { + // Insertion order is not sorted; the emitted bytes must be. + let sheet = sheet_with(&["openai", "anthropic", "gemini"]); + let line = serde_json::to_string(&sheet).expect("sheet must serialize"); + let anthropic = line.find("\"anthropic\"").expect("key must appear"); + let gemini = line.find("\"gemini\"").expect("key must appear"); + let openai = line.find("\"openai\"").expect("key must appear"); + assert!( + anthropic < gemini && gemini < openai, + "provider keys must serialize in sorted order: {line}" + ); + let again = serde_json::to_string(&sheet).expect("sheet must serialize twice"); + assert_eq!(line, again, "repeated serialization must be identical"); + } + + #[test] + fn generated_at_serializes_as_rfc3339_with_z() { + let sheet = sheet_with(&[]); + let line = serde_json::to_string(&sheet).expect("sheet must serialize"); + assert!( + line.contains("\"generated_at\":\"2026-09-14T13:00:00Z\""), + "generated_at must be RFC 3339 with a literal Z: {line}" + ); + } + + #[test] + fn contract_example_parses() { + let sheet: Sheet = + serde_json::from_str(CONTRACT_EXAMPLE).expect("contract example must parse"); + assert_eq!(sheet.schema_version, 1); + let anthropic = &sheet.providers["anthropic"]; + assert_eq!(anthropic.display_name, "Anthropic"); + assert_eq!(anthropic.tier, Tier::Prime); + assert_eq!(anthropic.status, SliceStatus::Ok); + let model = &anthropic.models[0]; + assert_eq!(model.id, "claude-opus-5"); + assert_eq!(model.kind, ModelKind::Chat, "absent kind defaults to chat"); + assert_eq!(model.max_output, Some(128_000)); + assert!(model.thinking.adaptive); + assert!(!model.thinking.enabled); + assert_eq!(model.effort_levels.len(), 5); + assert!(model.deprecation.is_none()); + let pricing = model.pricing.as_ref().expect("pricing must parse"); + assert_eq!(pricing.currency, "USD"); + } +} diff --git a/crates/shared-gateway-api/src/metadata.rs b/crates/shared-gateway-api/src/metadata.rs new file mode 100644 index 000000000..19997209a --- /dev/null +++ b/crates/shared-gateway-api/src/metadata.rs @@ -0,0 +1,289 @@ +//! Model-metadata vocabulary: what a model can do, independent of how the +//! gateway reaches it. +//! +//! These types are the canonical home of the metadata hoisted from +//! `gateway-config` (`Capabilities`, `ModelKind`, `ThinkingMode`) and +//! `gateway-protocol` (`ModelInfo`); both crates re-export them at their +//! old paths so downstream call sites compile unchanged. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// How a model exposes chain-of-thought / thinking tokens to callers. +/// +/// Catalogued on each `[[model]]` so hosts can filter bindings before a +/// request is built. `never` and `always` mean the backend ignores a +/// per-call switch; `switchable` means the client may emit +/// `chat_template_kwargs.enable_thinking`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum ThinkingMode { + /// The backend never emits thinking tokens; a per-call switch is ignored. + #[default] + Never, + /// The backend always emits thinking tokens; a per-call switch is ignored. + Always, + /// The client may turn thinking on or off per request. + Switchable, +} + +/// The workload a model serves: chat completions, embeddings, +/// classification, speech synthesis, transcription, or image or video +/// generation. +/// +/// The kind scopes which configuration fields are meaningful: chat-only +/// fields (for example `thinking`, `default_max_tokens`, +/// `chat_template_file`) are rejected for non-chat kinds at validation, +/// while `context` applies to every kind. The catalog carries the kind so +/// clients can filter before building a request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum ModelKind { + /// Chat completions (`POST /v1/chat/completions`). The default. + #[default] + Chat, + /// Text embeddings. + Embedding, + /// Classification / reranking. + Classifier, + /// Speech synthesis (`POST /v1/audio/speech`). + Speech, + /// Speech-to-text transcription. + Transcription, + /// Image generation. + Image, + /// Video generation. + Video, +} + +impl fmt::Display for ModelKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let spelling = match self { + ModelKind::Chat => "chat", + ModelKind::Embedding => "embedding", + ModelKind::Classifier => "classifier", + ModelKind::Speech => "speech", + ModelKind::Transcription => "transcription", + ModelKind::Image => "image", + ModelKind::Video => "video", + }; + f.write_str(spelling) + } +} + +/// Capability metadata advertised on the model catalog. +/// +/// These fields describe what a model can do rather than how the gateway +/// reaches it. They are flattened into `[[model]]` and `[[local_model]]`, +/// validated at load, and surfaced verbatim on `GET /v1/models` so clients +/// can shape requests before sending them. The effort knobs are chat-only +/// and require a `thinking` mode other than `never`; the `voices` list is +/// speech-only. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Capabilities { + /// Max output tokens the model can emit per completion. Must not exceed + /// `context` when set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_output: Option, + /// Sampling temperature applied when the caller omits one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_temperature: Option, + /// Whether the model accepts image inputs. Defaults to false; a + /// `[local_model.multimodal_projector]` companion implies true. + #[serde(default)] + pub images: bool, + /// Whether the model can emit parallel tool calls. Defaults to false. + #[serde(default)] + pub parallel_tool_calls: bool, + /// The reasoning-effort levels the model accepts. Empty means the model + /// has no effort knob. + #[serde(default)] + pub effort_levels: Vec, + /// The effort level applied when the caller omits one; requires a + /// non-empty `effort_levels` and must name a listed level. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_effort: Option, + /// Whether the model adaptively chooses how much to think per request; + /// chat kind only. Defaults to false. + #[serde(default)] + pub adaptive_thinking: bool, + /// The voices the model offers for speech synthesis; speech kind only. + /// Empty means the model exposes no fixed voice list. + #[serde(default)] + pub voices: Vec, +} + +impl Capabilities { + /// Returns the max output tokens the model can emit per completion, when + /// set. + /// + /// # Examples + /// ``` + /// let mut capabilities = shared_gateway_api::Capabilities::default(); + /// capabilities.max_output = Some(4096); + /// assert_eq!(capabilities.max_output(), Some(4096)); + /// ``` + #[must_use] + pub fn max_output(&self) -> Option { + self.max_output + } + + /// Returns the sampling temperature applied when the caller omits one, + /// when set. + /// + /// # Examples + /// ``` + /// let mut capabilities = shared_gateway_api::Capabilities::default(); + /// capabilities.default_temperature = Some(0.7); + /// assert_eq!(capabilities.default_temperature(), Some(0.7)); + /// ``` + #[must_use] + pub fn default_temperature(&self) -> Option { + self.default_temperature + } + + /// Returns whether the model accepts image inputs. + /// + /// # Examples + /// ``` + /// let mut capabilities = shared_gateway_api::Capabilities::default(); + /// capabilities.images = true; + /// assert!(capabilities.images()); + /// ``` + #[must_use] + pub fn images(&self) -> bool { + self.images + } + + /// Returns whether the model can emit parallel tool calls. + /// + /// # Examples + /// ``` + /// let mut capabilities = shared_gateway_api::Capabilities::default(); + /// capabilities.parallel_tool_calls = true; + /// assert!(capabilities.parallel_tool_calls()); + /// ``` + #[must_use] + pub fn parallel_tool_calls(&self) -> bool { + self.parallel_tool_calls + } + + /// Returns the reasoning-effort levels the model accepts (empty when the + /// model has no effort knob). + /// + /// # Examples + /// ``` + /// let mut capabilities = shared_gateway_api::Capabilities::default(); + /// capabilities.effort_levels = vec!["low".to_owned(), "high".to_owned()]; + /// assert_eq!(capabilities.effort_levels(), ["low", "high"]); + /// ``` + #[must_use] + pub fn effort_levels(&self) -> &[String] { + &self.effort_levels + } + + /// Returns the effort level applied when the caller omits one, when set. + /// + /// # Examples + /// ``` + /// let mut capabilities = shared_gateway_api::Capabilities::default(); + /// capabilities.default_effort = Some("low".to_owned()); + /// assert_eq!(capabilities.default_effort(), Some("low")); + /// ``` + #[must_use] + pub fn default_effort(&self) -> Option<&str> { + self.default_effort.as_deref() + } + + /// Returns whether the model adaptively chooses how much to think per + /// request. + /// + /// # Examples + /// ``` + /// let mut capabilities = shared_gateway_api::Capabilities::default(); + /// capabilities.adaptive_thinking = true; + /// assert!(capabilities.adaptive_thinking()); + /// ``` + #[must_use] + pub fn adaptive_thinking(&self) -> bool { + self.adaptive_thinking + } + + /// Returns the voices the model offers for speech synthesis (empty when + /// the model exposes no fixed voice list). + /// + /// # Examples + /// ``` + /// let mut capabilities = shared_gateway_api::Capabilities::default(); + /// capabilities.voices = vec!["alloy".to_owned(), "nova".to_owned()]; + /// assert_eq!(capabilities.voices(), ["alloy", "nova"]); + /// ``` + #[must_use] + pub fn voices(&self) -> &[String] { + &self.voices + } +} + +/// One catalogued model, with PromptForge extensions beside the OpenAI `id`. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct ModelInfo { + /// The caller-facing model name (`[[model]].name`). + pub id: String, + /// Always `"model"`. + pub object: &'static str, + /// The workload this model serves (`"chat"`, `"embedding"`, + /// `"classifier"`, `"speech"`, `"transcription"`, `"image"`, + /// `"video"`). + pub kind: ModelKind, + /// Prose describing the model for catalog consumers and semantic bind. + pub description: String, + /// Context window size in tokens. + pub context: u32, + /// Whether thinking tokens are never, always, or switchably available. + pub thinking: ThinkingMode, + /// Capability metadata (`max_output`, `images`, effort levels, and so + /// on), flattened into the catalog entry. + #[serde(flatten)] + pub capabilities: Capabilities, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_kind_variants_use_catalog_spelling() { + // Catches a serde rename or Display regression on every variant, + // including the hoisted transcription/image/video extensions. + for (kind, spelling) in [ + (ModelKind::Chat, "chat"), + (ModelKind::Embedding, "embedding"), + (ModelKind::Classifier, "classifier"), + (ModelKind::Speech, "speech"), + (ModelKind::Transcription, "transcription"), + (ModelKind::Image, "image"), + (ModelKind::Video, "video"), + ] { + let json = serde_json::to_value(kind).expect("serialize"); + assert_eq!(json.as_str(), Some(spelling)); + assert_eq!(kind.to_string(), spelling); + } + } + + #[test] + fn thinking_mode_uses_catalog_spelling() { + // Catches a serde rename regression on the hoisted ThinkingMode. + for (mode, spelling) in [ + (ThinkingMode::Never, "never"), + (ThinkingMode::Always, "always"), + (ThinkingMode::Switchable, "switchable"), + ] { + let json = serde_json::to_value(mode).expect("serialize"); + assert_eq!(json.as_str(), Some(spelling)); + } + } +} diff --git a/vibe/2026-09-14-2-provider-model-sheets.md b/vibe/2026-09-14-2-provider-model-sheets.md new file mode 100644 index 000000000..2056613b0 --- /dev/null +++ b/vibe/2026-09-14-2-provider-model-sheets.md @@ -0,0 +1,671 @@ +--- +name: Provider Model Sheets +overview: "Phase 1 infrastructure for provider model sheets: two new shared crates - shared-gateway-api (the normalized sheet schema, plus the hoisted Capabilities/ModelInfo/ModelKind/ThinkingMode at their canonical home) and shared-cloud-providers (tiered per-provider descriptors for chat, image, STT, and TTS providers, fetch/normalize logic, and the sheet-building binary) - plus a scheduled GitHub workflow in a separate aggregation repo that aggregates vendor model-list endpoints into a models.json release artifact with last-known-good propagation. Phase 2 (deferred): the Gateway's sheet-consumption path and config-UI integration." +todos: + - id: settle-open-questions + content: Settle remaining open questions (raw payload embedding, v1 scope confirmation, cadence) + status: pending + - id: shared-gateway-api + content: Create shared-gateway-api with the sheet schema types (Sheet, ProviderSlice, SliceStatus, ModelEntry, Thinking, Pricing, Deprecation, Tier); hoist Capabilities, ModelInfo, ModelKind (extended with transcription/image/video), ThinkingMode from gateway-config/gateway-protocol with re-exports at the old paths + status: pending + - id: shared-cloud-providers + content: Create shared-cloud-providers (lib + bin) with Provider descriptor (name, display_name, tier, key_env, base_url), provider registry, per-provider files, fetch/normalize behind an injected reqwest::Client, build_sheet/fetch_sheet + status: pending + - id: aggregation-workflow + content: Build the GitHub workflow (manual + cron) that compiles the binary, runs it with secrets as env vars, and publishes models.json as a release artifact - DEFERRED 2026-09-14 to a separate aggregation repo, not this plan's scope + status: pending +isProject: false +--- + +# Provider Model Sheets + + + +## Product Requirements + +The Gateway today knows a remote model only through hand-written `[[model]]` entries in `gateway.toml` (`crates/gateway-config/src/config.rs`, `ModelConfig`). Closed-weight providers change their lineups constantly, and each provider's model-list endpoint speaks its own dialect of auth, pagination, and response shape. This plan adds a single aggregation point: a scheduled GitHub workflow builds a machine-readable models sheet from every vendor's model-list endpoint and publishes it as a release artifact, and a provider-descriptor crate linked into the Gateway lets the Gateway understand each provider's offerings and normalize them into model choices for the config UI. + +- Problem and users: model metadata for closed-weight providers is hand-maintained in configuration and goes stale; each provider's model-list API differs in auth, pagination, and response shape. Users are Gateway operators picking models in the config UI, and downstream hosts - Workshop, the PromptForge Agent Harness (not yet written), and the PromptForge CLI (not yet written) - which consume models through the Gateway's normal catalog. +- Goals: + - A types-only crate `shared-gateway-api` holding the normalized sheet schema structs, consumed by the provider crate, the Gateway, and Workshop server (UI elements such as the model dropdown). + - A crate `shared-cloud-providers` with one Rust file per provider (`anthropic.rs`, `openai.rs`, `gemini.rs`, `moonshot.rs`, and so on), each defining a public `Provider` descriptor, plus the fetch/normalize logic and the sheet-building binary. + - A GitHub workflow, triggerable manually and on a schedule, that calls every provider's model-list endpoint with keys held in GitHub secrets and builds the models sheet. + - The sheet published as a release artifact in a separate aggregation repo, so any Gateway downloads it for free with no provider key of its own. + - (Phase 2) The Gateway consumes the sheet and normalizes provider models into choices for the config UI; hosts consume the normalized models through the Gateway as usual. +- Non-goals: the Agent Harness and CLI themselves; local (GGUF) model metadata; changes to the Gateway's routing or `[[model]]` resolution semantics. Phase 1 is infrastructure only: no UI changes and no Gateway sheet-consumption - the user's words: "I don't want anything changed in the UI yet. First I want to get the infrastructure in place and reliable to build the table." The type hoist IS in phase 1 scope: the user's words: "I still want to relocate the gateway types to shared-gateway-api." +- Success criteria: the workflow produces a current, schema-valid sheet on demand and on schedule; a failed provider fetch propagates last-known-good data; adding a new provider is one new Rust file plus one GitHub secret. (Phase 2 criterion, not phase 1: a Gateway with no provider keys boots against the release artifact and presents normalized provider models in the config UI.) +- Constraints: provider API keys live only in GitHub secrets and never ship in the artifact or the crate; the artifact is safe to fetch unauthenticated; the crate follows workspace conventions (edition 2024, workspace lints, no file over 500 lines). +- Settled questions (2026-09-14): + - Raw payload embedding: no. Model entries carry normalized fields only; the artifact stays small, schema-stable, and free of provider-specific shapes leaking into consumers. + - First-iteration scope: the Prime tier only - Anthropic, OpenAI, Google Gemini, xAI, DeepSeek, Alibaba Qwen, Moonshot AI, Meta, ElevenLabs, Deepgram. Subprime providers, Niche static lists, and Aggregators follow once the Prime pipeline is proven. The schema and `build_sheet` keep `static` slice support, but no Niche provider files ship in v1. + - Schedule cadence: weekly, plus manual dispatch. + +### Provider Landscape + +Verified against official documentation on 2026-09-14. Response richness matters because it decides how much of the normalized schema each provider file can fill from the list endpoint alone. Starter-list dedupes: Grok is xAI, Gemini is Google DeepMind, Kimi is Moonshot AI. + +American closed-weight: + +- Anthropic - `https://api.anthropic.com`, `GET /v1/models`, `x-api-key` header + required `anthropic-version` header. Rich: capabilities object, token limits, display name. Cursor pagination. Docs: docs.anthropic.com/en/api/models-list +- OpenAI - `https://api.openai.com/v1`, `GET /v1/models`, Bearer. IDs only (`id`, `created`, `owned_by`); no pagination, no limits or capabilities. Docs: developers.openai.com/api/reference +- Google Gemini - `https://generativelanguage.googleapis.com`, `GET /v1beta/models`, `?key=` query param or `x-goog-api-key` header. Verified 2026-09-14: 403 without a key. Richest: `inputTokenLimit`, `outputTokenLimit`, `supportedGenerationMethods`, thinking flag. `pageToken` pagination. Docs: ai.google.dev/api/models +- xAI - `https://api.x.ai`, `GET /v1/models`, Bearer. OpenAI-shaped but extended: `aliases`, `context_length`, per-token pricing. No pagination. Docs: docs.x.ai +- Amazon Nova (Bedrock) - `https://bedrock.{region}.amazonaws.com`, `GET /foundation-models`, AWS SigV4 (no bearer). Modalities and lifecycle, no context window. Valid `{region}` values (35, control-plane endpoints table, docs.aws.amazon.com/general/latest/gr/bedrock.html): us-east-1, us-east-2, us-west-1, us-west-2, af-south-1, ap-east-2, ap-northeast-1, ap-northeast-2, ap-northeast-3, ap-south-1, ap-south-2, ap-southeast-1 through ap-southeast-7, ca-central-1, ca-west-1, eu-central-1, eu-central-2, eu-north-1, eu-south-1, eu-south-2, eu-west-1, eu-west-2, eu-west-3, il-central-1, me-central-1, me-south-1, mx-central-1, sa-east-1, us-gov-east-1, us-gov-west-1. The Provider descriptor needs a region field (or a pinned default region) for this one. Docs: docs.aws.amazon.com/bedrock +- Microsoft (Azure AI Foundry) - per-resource URL, no global endpoint; `GET {endpoint}/openai/v1/models`, `api-key` header or Bearer. Basic info only. Docs: learn.microsoft.com/rest/api/aifoundry +- Meta - first-party Meta Model API exists: `https://api.meta.ai/v1`, `GET /v1/models`, Bearer, OpenAI-compatible. Response schema not fully enumerated. Docs: ai.developer.meta.com/docs + +Chinese providers: + +- Moonshot AI (Kimi) - both weights. `https://api.moonshot.ai/v1` global, `https://api.moonshot.cn/v1` China (keys not interchangeable). `GET /v1/models`, Bearer. Enriched: `context_length`, image/video input and reasoning flags. No pagination. +- DeepSeek - open-weight. `https://api.deepseek.com`, `GET /models`, Bearer. IDs only. +- Alibaba Qwen (DashScope / Model Studio) - both weights. `https://dashscope.aliyuncs.com/compatible-mode/v1`, `GET .../models`, Bearer. Plain OpenAI shape on the compatible endpoint; the native `/api/v1/models` adds pagination, pricing, and context length. +- Zhipu AI (GLM) - both weights. `https://open.bigmodel.cn/api/paas/v4`, Bearer. Flag: no officially documented model-list endpoint; catalog lives on a docs page. +- MiniMax - both weights. `https://api.minimax.io/v1` global, `https://api.minimaxi.com/v1` China. `GET /v1/models`, Bearer. Plain OpenAI shape. +- ByteDance Doubao (Volcano Ark) - closed. `https://ark.cn-beijing.volces.com/api/v3`, Bearer. Flag: no API-key-callable model list; the catalog endpoint needs control-plane AK/SK signing. +- Baidu (ERNIE / Qianfan) - both weights. `https://qianfan.baidubce.com/v2`, `GET /v2/models`, Bearer. Richest of the set: `context_length`, `max_tokens`, modality, pricing. +- StepFun - both weights. `https://api.stepfun.com/v1` (CN) / `.ai` (intl). `GET /v1/models`, Bearer. Plain OpenAI shape. +- iFlytek Spark - closed. Flag: no documented model-list endpoint; IDs enumerated only on doc pages. +- 01.AI Yi - pivoted away from foundation models in 2025; platform longevity uncertain. Exclude from v1. + +European and other: + +- Mistral AI - France, both weights. `https://api.mistral.ai`, `GET /v1/models`, Bearer (401 without, verified 2026-09-14). Rich: capabilities (chat/fim/function_calling/vision), `max_context_length`, `aliases`, `deprecation`. No pagination. +- Cohere - Canada, both weights. `https://api.cohere.com`, `GET /v1/models`, Bearer (401 without, verified 2026-09-14). `context_length`, `endpoints`, `features`; token pagination. +- AI21 Labs - Israel, both weights. Flag: no documented model-list endpoint; IDs documented statically. +- Perplexity (Sonar) - US, closed. `https://api.perplexity.ai`, `GET /v1/models`. Verified 2026-09-14: listing requires auth (401 without a key); response shape not officially documented. Canonical chat is `POST /v1/sonar`; `/chat/completions` is an alias. +- NVIDIA (build.nvidia.com) - US, both weights. `https://integrate.api.nvidia.com/v1`, `GET /v1/models`. Verified live 2026-09-14: no auth required for listing, full model list returned. IDs only, namespaced (`meta/llama-3.1-8b-instruct`); `created` is a constant placeholder on every entry. Rerank/retrieval use separate base `ai.api.nvidia.com/v1`. + +Media providers (verified 2026-09-14): + +Image generation: + +- Midjourney - excluded: no official public API exists (enterprise API is application-stage only; every "Midjourney API" on the market is a ToS-violating wrapper). +- Kling AI (Kuaishou; the "Kang.ai" the user mentioned) - `https://api.klingai.com`, JWT (HS256) from an AccessKey/SecretKey pair, static model IDs (Kolors family, `kling-v1` through `kling-v3`). +- Real model-list endpoints: Google (`GET /v1beta/models`), OpenAI (`GET /v1/models`), Leonardo (`GET /platformModels`). Partial: Stability (`GET /v1/engines/list`, legacy v1 only); Ideogram and Adobe list custom models only. +- Static IDs only: Black Forest Labs (`x-key` header), Recraft, Kling, ByteDance Seedream (Ark), Alibaba Wan (DashScope), xAI image (unverified), Runway (Bearer + version header), Luma. +- Deprecation landmines: Google Imagen 4 shut down 2026-08-17 (succeeded by Gemini 3.1 Flash Image); OpenAI gpt-image-1.x sunsets 2026-12-01 (use gpt-image-2); DALL-E already gone. + +Speech to text: + +- Real model-list endpoints: Deepgram (`GET /v1/models`, rich: languages, version, batch/streaming flags; `Authorization: Token` prefix), Groq (`GET /openai/v1/models`), Soniox (`GET /v1/models` with per-model languages), Azure Speech (`GET /speechtotext/v3.2/models/base`). +- Static IDs: AssemblyAI, ElevenLabs Scribe, Gladia, Rev.ai, AWS Transcribe, Cartesia. Google STT exposes capability discovery via its Locations API instead; Speechmatics has `GET /v1/discovery/features`. + +Text to speech: + +- PlayHT - excluded: acquired by Meta, API offline since 2025-07, platform sunset 2025-12-31. +- Real model-list endpoints: ElevenLabs (`GET /v1/models`, rich: languages, capabilities, rates; `xi-api-key` header), Deepgram (`GET /v1/models`, TTS array with languages and tags). +- Static IDs: Cartesia (also requires a `Cartesia-Version` date header), Murf, OpenAI, Google, Azure, Amazon Polly, MiniMax, Hume, Resemble. Voice-list endpoints are near-universal even where model lists are absent. + +Aggregators (not providers, but relevant): + +- OpenRouter - `GET https://openrouter.ai/api/v1/models`, no auth for listing. Verified live 2026-09-14: 718 KB payload; each model has `id`, `canonical_slug`, `name`, `created`, `description`, `context_length`, `architecture` (modality, input/output modalities, tokenizer), `pricing` (prompt/completion USD per token, cache read), `top_provider` (`max_completion_tokens`, `is_moderated`), `supported_parameters`, plus server-side filtering and pagination. A viable complement or fallback source. +- SiliconFlow - the major Chinese aggregator; one OpenAI-compatible endpoint across most Chinese providers. + +Design consequences: auth variance is confirmed across a dozen shapes (`x-api-key`, Bearer, `?key=` query param, `api-key` header, SigV4, `Token` prefix, `xi-api-key`, `x-gladia-key`, `Ocp-Apim-Subscription-Key`, JWT-from-AK/SK, OAuth2, Basic), which validates the private-variance encapsulation. Media providers are mostly Niche by the functional definition: outside Deepgram, ElevenLabs, Groq, Soniox, Azure Speech, Google, and OpenAI, media catalogs are static ID lists, and TTS voice discovery is near-universal even where model discovery is absent. (The richness split and field-availability constraints live with the schema in Technical Design.) + +### Provider Signup + +The signup checklist, one table per tier. Endpoint facts are verified from the 2026-09-14 survey; console URLs are from knowledge, not re-verified. Tier assignment is functional: Prime and Subprime have a working key-callable model-list endpoint, Niche do not (their sheet slices are static lists compiled into the binary, so no key is needed for aggregation), Aggregators list many providers' models through one endpoint. + +Prime: + +| Name | URL | Notes | +| --- | --- | --- | +| Anthropic | https://console.anthropic.com/ | Keys under Settings; usage credits need a card, listing is free | +| OpenAI | https://platform.openai.com/api-keys | Billing setup required before keys work | +| Google Gemini | https://aistudio.google.com/apikey | Free tier, no card needed | +| xAI | https://console.x.ai/ | Paid credits | +| DeepSeek | https://platform.deepseek.com/ | Prepaid balance, inexpensive | +| Alibaba Qwen | https://modelstudio.console.alibabacloud.com/ | Use the international console; the China console may require real-name verification | +| Moonshot AI | https://platform.moonshot.ai/ | Global variant; .ai and .cn keys are not interchangeable | +| Meta | https://ai.developer.meta.com/ | Newer first-party program | + +Subprime: + +| Name | URL | Notes | +| --- | --- | --- | +| Mistral AI | https://console.mistral.ai/ | Free experiment tier | +| Cohere | https://dashboard.cohere.com/ | Trial keys free, rate-limited | +| Baidu (Qianfan) | https://qianfan.cloud.baidu.com/ | Chinese console; real-name verification likely | +| MiniMax | https://platform.minimax.io/ | Global variant (.io, not .com) | +| StepFun | https://platform.stepfun.ai/ | International variant | +| Amazon Nova (Bedrock) | https://console.aws.amazon.com/bedrock/ | Heaviest setup: AWS account, IAM credentials, SigV4, pick a region | +| Microsoft Foundry | https://ai.azure.com/ | Azure subscription plus a deployed resource; no global endpoint | +| NVIDIA | https://build.nvidia.com/ | No key needed for model listing (verified); key only for inference | + +Niche (no key needed for the sheet; static lists ship in the binary): + +| Name | URL | Notes | +| --- | --- | --- | +| Zhipu AI (GLM) | https://open.bigmodel.cn/ | No officially documented model-list endpoint | +| ByteDance Doubao | https://www.volcengine.com/ | Model catalog needs control-plane AK/SK signing | +| iFlytek Spark | https://www.xfyun.cn/ | No documented model-list endpoint | +| AI21 Labs | https://studio.ai21.com/ | No documented model-list endpoint | +| Perplexity | https://www.perplexity.ai/settings/api | List endpoint exists but requires auth (401 verified); shape undocumented | + +Aggregator: + +| Name | URL | Notes | +| --- | --- | --- | +| OpenRouter | https://openrouter.ai/keys | No key needed for model listing; key only for inference | +| SiliconFlow | https://cloud.siliconflow.cn/ | Chinese aggregator; one endpoint across most Chinese providers | + +Suggested signup order: + +1. Free and instant: Google Gemini, NVIDIA, Mistral, Cohere (no card, keys in minutes). OpenRouter needs nothing for listing. +2. Card-required majors: Anthropic, OpenAI, xAI, DeepSeek, Meta. +3. Heavy setup: Amazon Bedrock (AWS account, IAM, SigV4, region choice), Microsoft Foundry (Azure subscription plus a deployed resource). +4. Chinese consoles last: Alibaba Model Studio international, Moonshot global, MiniMax global, StepFun international, Baidu Qianfan (real-name verification overhead). +5. Niche providers: skip entirely - their sheet slices are static lists compiled into the binary. + +### Media Signup + +Image, speech-to-text, and text-to-speech providers. Same caveats as above: endpoint facts verified 2026-09-14, console URLs from knowledge. Providers already covered by a chat-provider signup row (OpenAI, Google, Azure, MiniMax, xAI, Alibaba) are omitted - their media models ride the same key. + +Image generation: + +| Name | URL | Notes | +| --- | --- | --- | +| Leonardo | https://leonardo.ai/ | Subprime; real list endpoint (`GET /platformModels`) | +| Kling AI | https://klingai.com/ | Niche; static list; JWT from an AccessKey/SecretKey pair | +| Black Forest Labs | https://bfl.ai/ | Niche; static list; `x-key` header | +| Recraft | https://www.recraft.ai/ | Niche; static list | +| Ideogram | https://ideogram.ai/ | Niche; lists custom models only | +| Adobe Firefly | https://developer.adobe.com/firefly-services/ | Niche; OAuth client credentials; custom models only | +| Runway | https://runwayml.com/ | Niche; Bearer plus a version header | +| Luma | https://lumalabs.ai/ | Niche; static list | +| Stability AI | https://platform.stability.ai/ | Niche; list endpoint is legacy v1 only | + +Speech to text: + +| Name | URL | Notes | +| --- | --- | --- | +| ElevenLabs (Scribe) | https://elevenlabs.io/ | Prime; one key covers TTS and STT | +| Deepgram | https://console.deepgram.com/ | Prime; rich list endpoint; `Authorization: Token` prefix | +| Groq | https://console.groq.com/ | Subprime; OpenAI-compatible list endpoint; doubles as a fast chat provider | +| Soniox | https://console.soniox.com/ | Subprime; list endpoint with per-model languages | +| AssemblyAI | https://www.assemblyai.com/dashboard | Niche; static list | +| Speechmatics | https://www.speechmatics.com/ | Niche; capability-discovery endpoint, no model list | +| Gladia | https://www.gladia.io/ | Niche; static list; `x-gladia-key` header | +| Rev.ai | https://www.rev.ai/ | Niche; static list | +| AWS Transcribe | https://console.aws.amazon.com/ | Niche; covered by the Bedrock/AWS signup | +| Cartesia | https://play.cartesia.ai/ | Niche; static list; one key covers TTS too | + +Text to speech: + +| Name | URL | Notes | +| --- | --- | --- | +| ElevenLabs | (see STT table) | Prime; the TTS category leader; rich list endpoint | +| Deepgram | (see STT table) | Prime; TTS array in the same list endpoint | +| Cartesia | (see STT table) | Niche; also requires a `Cartesia-Version` date header | +| Murf | https://murf.ai/ | Niche; static list; `api-key` header | +| Hume | https://www.hume.ai/ | Niche; static list; `X-Hume-Api-Key` header | +| Resemble | https://app.resemble.ai/ | Niche; model auto-selected from `voice_uuid` | +| Amazon Polly | https://console.aws.amazon.com/ | Niche; covered by the Bedrock/AWS signup | +| Inworld | https://inworld.ai/ | Niche; list endpoint referenced in docs but unverified | + +## Functional Specification + +Two pipelines share one vocabulary. The build pipeline (workflow) turns provider list-endpoint responses into the sheet; the consumption pipeline (Gateway) turns the sheet into catalog choices. `shared-gateway-api` is the shared vocabulary between them. + +- Actors and workflows: + - The workflow (phase 1): on manual dispatch or schedule, compile the crate's binary and run it with provider keys injected from secrets as environment variables; the binary downloads the previous release's sheet (if any), calls each provider's model-list endpoint, normalizes the responses, propagates previous slices for failed fetches, and writes the merged sheet; the workflow publishes it as the new release artifact. + - The Gateway (phase 2, deferred): fetch the sheet from the release artifact, cache it, and re-serve the normalized catalog on its own route, so hosts consume it from the Gateway rather than fetching from GitHub themselves; the config UI's model choices derive from it. + - Hosts (Workshop now; Agent Harness and CLI later): consume models through the Gateway's existing catalog surface (`crates/gateway/src/model_info.rs`, `CatalogModelsResponse`). (Phase 2, deferred: Workshop server additionally links `shared-gateway-api` directly for UI elements such as the model dropdown.) +- Inputs and outputs: provider list-endpoint JSON in; one `models.json` sheet out, wrapped in an envelope with `schema_version`, `generated_at` (RFC 3339), and a `providers` map keyed by provider name. (Phase 2: Gateway config-UI model choices derived from the sheet.) +- States and validation: each provider entry has a `status` of `ok` (fetched fresh this run), `stale` (fetch failed; the previous sheet's slice was propagated verbatim with its original `fetched_at`), `unavailable` (fetch failed and no previous sheet existed; `models` is empty), or `static` (Niche provider with no list endpoint; a hand-maintained model list compiled into the binary, no fetch attempted). +- Errors and recovery: a failed provider fetch never fails the workflow run and never drops data: the previous sheet's slice for that provider is propagated with `status: "stale"`, preserving its original `fetched_at` so consumers can see the age of the data. A first-ever run with a failed fetch records `unavailable` with an empty model list. +- Security and privacy behavior: keys exist only as GitHub secrets injected into the workflow environment; the sheet and the crate contain no secrets. +- Acceptance criteria (phase 1): `cargo run -p shared-cloud-providers` locally with keys in the environment produces a schema-valid `models.json`; a provider with a missing key or failed fetch appears as `stale` (with its previous slice) or `unavailable`, never as a build failure; a Niche provider emits its static list with `status: "static"`; the hoisted types compile at their old paths via re-export with no downstream call-site changes. (The workflow dispatch and release-publication criteria move with the workflow to the separate aggregation repo.) + + + + +## Technical Design + +The central design fact is the separation between the public descriptor and the private variance. Each provider file exposes a uniform `Provider` struct; everything provider-specific stays inside the file. + +```mermaid +flowchart TD + subgraph gh [GitHub] + anth[Anthropic] + oai[OpenAI] + gem[Gemini] + anth & oai & gem --> wf[Workflow] + wf -->|normalize| sheet[models.json] + end + + sheet -->|fetch| gw[Gateway] +``` + +Dependency map: + +```mermaid +flowchart TD + gw[gateway] --> sga[gateway-api] + gw --> scp[cloud-providers] + ws[workshop-server] --> sga + gha[GHA workflow] -->|bin target| scp + scp --> sga + scp --> reqwest[reqwest] + sga --> serde[serde] + sga --> time[time] +``` + +`shared-gateway-api` is pure vocabulary (serde, time; no workspace dependencies, per the `shared-*` substrate rule). `shared-cloud-providers` adds reqwest behind the injected-client seam. The Gateway links both; Workshop server links only the schema crate; the workflow consumes only the `bin` target. + +- Architecture: + - Two new workspace crates. `shared-gateway-api`: types-only - the sheet envelope, per-provider entry, and per-model entry structs; no product-crate dependencies, mirroring the `shared-promptforge-api` precedent. `shared-cloud-providers`: the `Provider` descriptors, the provider registry, and the per-provider fetch and normalization logic, doing double duty as a `lib` and a thin `bin` (read keys from the environment, fetch the previous sheet, run every provider, write `models.json`) that the GitHub workflow compiles and runs - and that anyone can compile and run locally for testing and sheet building. The user's rationale: "it can also be compiled and run locally for testing and building." + - Consumers: `shared-cloud-providers` depends on `shared-gateway-api`; the Gateway links both (schema for sheet parsing, provider registry for provider metadata); Workshop server links `shared-gateway-api` for UI elements such as the model dropdown. The workspace dependency rules force the schema into `shared-*`: `workshop-*` crates may never depend on `gateway-*` crates. + - One Rust file per provider in `shared-cloud-providers`: `anthropic.rs`, `openai.rs`, `gemini.rs`, `moonshot.rs`, etc. + - Each file defines a public `Provider` descriptor: provider name, tier, the environment-variable name of its API key (matching the GitHub secret name), and the default base URL. Tier is a curated product opinion, not a vendor fact: `prime`, `subprime`, `niche`, `aggregator`. Bedrock additionally needs a region (35 valid values, listed in the Provider Landscape) or a pinned default. + - The variances - auth header shape (`x-api-key` vs `Authorization: Bearer` vs query param), pagination, response field names, capability mapping - are private to each provider file. + - HTTP is needed on both ends (provider endpoints in the binary, sheet download in the Gateway), so `shared-cloud-providers` takes an injected `reqwest::Client` rather than owning one. The Gateway has no shared client to hand it - each upstream privately builds three role-specific clients via `gateway-protocol/src/http_util.rs` (`bounded_client`, `streaming_client`, `audio_streaming_client`; see `gateway-protocol/src/upstream.rs` lines 197-243) - so the Gateway constructs one purpose-built bounded client for sheet downloads from the same factory. + - Because the fetch and normalization logic lives in the `lib`, it is unit-testable offline against recorded fixture JSON; live endpoints are exercised only by manual or scheduled runs of the binary. +- Modules and interfaces: `shared-gateway-api` is the canonical home of the hoisted model-metadata types - `Capabilities`, `ModelInfo`, `ModelKind`, `ThinkingMode`, moved out of `gateway-config` and `gateway-protocol`, which re-import them - plus the sheet schema types (envelope, per-provider entry, per-model entry). `shared-cloud-providers` exports the `Provider` descriptor type and the registry of known providers, so the workflow binary, the Gateway, and Workshop server share one definition. The hoisted inventory is the "what a model can do" half of the existing config structs, a split the `Capabilities` doc comment (`crates/gateway-config/src/config.rs` lines 569-575) already states explicitly: `kind`, `description`, `context`, `thinking`, and the `Capabilities` fields (`max_output`, `default_temperature`, `images`, `parallel_tool_calls`, `effort_levels`, `default_effort`, `adaptive_thinking`, `voices`); the "how the gateway reaches it" half (`upstream`, `endpoints`, `source`, `sha256`, `dominion`, and the local-model tuning fields) stays put. +- Sheet schema: a single `models.json`. Field names mirror the Gateway's existing `Capabilities` vocabulary (`crates/gateway-config/src/config.rs`: `max_output`, `images`, `effort_levels`, `default_effort`) wherever concepts overlap, so normalizing a sheet entry into a `ModelConfig` is mechanical. The schema below is the union of what the surveyed list endpoints actually report (see Provider Landscape and the 2026-09-14 response-shape extractions): + +```json +{ + "schema_version": 1, + "generated_at": "2026-09-14T13:00:00Z", + "providers": { + "anthropic": { + "display_name": "Anthropic", + "tier": "prime", + "status": "ok", + "fetched_at": "2026-09-14T13:00:00Z", + "models": [ + { + "id": "claude-opus-5", + "display_name": "Claude Opus 5", + "released_at": "2026-07-24", + "context_window": 1000000, + "max_output": 128000, + "images": true, + "pdf_input": true, + "video_input": false, + "audio_input": false, + "batch": true, + "citations": true, + "code_execution": true, + "structured_outputs": true, + "tool_calling": true, + "thinking": { "supported": true, "enabled": false, "adaptive": true }, + "effort_levels": ["low", "medium", "high", "xhigh", "max"], + "default_effort": "high", + "pricing": { "currency": "USD", "prompt_per_mtok": 5.0, "completion_per_mtok": 25.0 }, + "deprecation": null + } + ] + } + } +} +``` + + - Envelope: `schema_version` (integer, bumped on breaking change), `generated_at` (RFC 3339, always this run's time), `providers` map keyed by provider name. + - Provider entry: `status` (`ok` / `stale` / `unavailable` / `static`), `fetched_at` (RFC 3339; preserved from the original fetch when `stale`; omitted when `static`), `models` array. + - Model entry: `id` (the upstream slug), `display_name`, `released_at` (optional - not every provider reports it), `context_window` (optional - the IDs-only providers omit it), `max_output` (optional), modality booleans (`images`, `pdf_input`, `video_input`, `audio_input`), capability booleans (`batch`, `citations`, `code_execution`, `structured_outputs`, `tool_calling`), a `thinking` object (`supported` = any reasoning, `enabled` = manual budget mode, `adaptive` = model-chosen), `effort_levels`, `default_effort` (optional), `pricing` (optional), `deprecation` (optional or null). +- Normalization principle: normalize the knob, never the settings. `effort_levels` is a list of the provider's own level names as strings - the observed union across all surveyed providers is exactly `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` - and no cross-provider ordinal scale is ever invented. Kimi's `["low","high","max"]` and Anthropic's five levels both fit without mapping. The same principle applies to `thinking`: Anthropic's thinking types collapse into the `enabled`/`adaptive` booleans; every other provider's reasoning flag collapses into `supported`. +- Field availability constraint (verified 2026-09-14 against official docs): only Anthropic and the OpenRouter aggregate expose effort levels in the list response; Kimi's `low`/`high`/`max` live in its chat-request docs, not its list endpoint. Effort data for other providers is statically curated in the provider file or omitted. Pricing appears natively in xAI, DashScope-native, Baidu, Perplexity's router, and OpenRouter responses - normalized to per-million-token units with an explicit `currency` field, because Baidu reports CNY per 1k tokens and xAI reports USD cents per 100M. Deprecation appears only in Bedrock (`modelLifecycle`), Mistral (`deprecation` + replacement), Cohere (`is_deprecated`), and OpenRouter (`expiration_date`). +- Workflow propagation algorithm: download the previous release's `models.json` before building; per provider, a successful fetch writes a fresh slice (`ok`, `fetched_at` = now) and a failed fetch copies the previous slice verbatim with `status` rewritten to `stale`; a provider with no previous slice and a failed fetch records `unavailable` with an empty `models` array. +- Public Rust declarations. `shared-gateway-api` (types-only; the hoisted `Capabilities`, `ModelInfo`, `ModelKind`, and `ThinkingMode` join these at the same canonical home): + +```rust +use std::collections::BTreeMap; +use serde::{Deserialize, Serialize}; +use time::{Date, OffsetDateTime}; + +/// The sheet envelope: one atomic snapshot of every provider's models. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sheet { + pub schema_version: u32, + /// RFC 3339; always this run's time. + pub generated_at: OffsetDateTime, + /// Keyed by provider name, e.g. "anthropic". + pub providers: BTreeMap, +} + +/// One provider's slice of the sheet. Self-describing: the descriptor's +/// public fields are copied in at build time so consumers can render a +/// provider dropdown from the sheet alone. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderSlice { + pub display_name: String, + pub tier: Tier, + pub status: SliceStatus, + /// Last fresh fetch; absent for `static` slices. + pub fetched_at: Option, + pub models: Vec, +} + +/// Curated product opinion, not a vendor fact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Tier { + Prime, + Subprime, + Niche, + Aggregator, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SliceStatus { + Ok, + Stale, + Unavailable, + Static, +} + +/// One normalized model entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelEntry { + pub id: String, + pub display_name: String, + /// The workload: chat, embedding, classifier, speech (TTS), + /// transcription (STT), image, video. + pub kind: ModelKind, + pub released_at: Option, + pub context_window: Option, + pub max_output: Option, + // Modalities. + pub images: bool, + pub pdf_input: bool, + pub video_input: bool, + pub audio_input: bool, + // Capabilities. + pub batch: bool, + pub citations: bool, + pub code_execution: bool, + pub structured_outputs: bool, + pub tool_calling: bool, + pub thinking: Thinking, + /// The provider's own level names, e.g. ["low", "high", "max"]; + /// never mapped to a cross-provider scale. + pub effort_levels: Vec, + pub default_effort: Option, + pub pricing: Option, + pub deprecation: Option, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct Thinking { + /// Any reasoning capability at all. + pub supported: bool, + /// Manual budget mode (Anthropic "enabled"). + pub enabled: bool, + /// Model-chosen thinking depth. + pub adaptive: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Pricing { + /// ISO 4217, e.g. "USD", "CNY". + pub currency: String, + pub prompt_per_mtok: f64, + pub completion_per_mtok: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Deprecation { + pub status: String, + pub date: Option, + pub replacement: Option, +} +``` + + `shared-cloud-providers` (descriptors, registry, fetch seam; the `bin` target is a thin `main` over these): + +```rust +/// The public descriptor for one provider. Everything else about the +/// provider - auth header shape, pagination, response mapping - is +/// private to its file. +pub struct Provider { + /// Registry key, e.g. "anthropic". + pub name: &'static str, + /// UI-facing name, e.g. "Anthropic". + pub display_name: &'static str, + pub tier: shared_gateway_api::Tier, + /// Environment variable the API key arrives under; matches the + /// GitHub secret name. + pub key_env: &'static str, + /// Default base URL for the model-list endpoint. + pub base_url: &'static str, +} + +/// Every known provider. +pub fn providers() -> &'static [Provider]; + +/// Fetch and normalize one provider's model list; the per-provider +/// variance lives behind this seam. The client is injected by the +/// caller (the Gateway's bounded client, or the binary's own). +pub async fn fetch_models( + client: &reqwest::Client, + provider: &Provider, + key: &str, +) -> Result, FetchError>; + +/// Build the complete sheet: fetch every provider, propagate +/// last-known-good slices from `previous` for failed fetches, emit +/// static slices for Niche providers, assemble the envelope. This is +/// the function the binary's `main` calls. +pub async fn build_sheet( + client: &reqwest::Client, + previous: Option, + keys: &dyn Fn(&Provider) -> Option, +) -> shared_gateway_api::Sheet; + +/// Download and parse the current sheet from the release artifact. +/// This is the function the Gateway calls. +pub async fn fetch_sheet( + client: &reqwest::Client, + release_url: &str, +) -> Result; +``` +- File and public API changes (phase 1): two new crates (`shared-gateway-api`, `shared-cloud-providers`); the hoist moves `Capabilities`, `ModelInfo`, `ModelKind`, and `ThinkingMode` from `gateway-config`/`gateway-protocol` into `shared-gateway-api`, with re-exports at the old paths so downstream call sites compile unchanged. Nothing else in the existing crates is modified; the workflow file lives in a separate aggregation repo and is not this plan's execution scope. (Phase 2, deferred: the Gateway's sheet-consumption path and the config-UI integration; `ModelConfig`/`Routing`/catalog-wire questions get settled then.) +- Data, persistence, failure, security, and privacy constraints: the sheet is a versioned JSON artifact on a GitHub release in a separate aggregation repo; `BTreeMap` key ordering makes the emitted file byte-deterministic for clean diffs between runs. (Phase 2, unsettled: the Gateway's fetch-and-cache behavior - startup fetch, TTL, offline fallback to a vendored copy.) + + + + +## Testing Plan + +The fetch and normalization logic lives in the `shared-cloud-providers` lib precisely so it is testable offline; live endpoints are exercised only by manual or scheduled binary runs. + +- Unit: each provider file's normalization is tested against recorded fixture JSON (the three live payloads captured 2026-09-14 - Anthropic, OpenRouter, NVIDIA - seed the fixture set; documented example responses from official docs cover the rest); sheet schema round-trip tests (serialize, parse, compare); propagation tests (failed fetch with a previous sheet yields `stale` with preserved `fetched_at`; failed fetch without one yields `unavailable`; Niche providers yield `static`); the hoist is proven by the workspace compiling with re-exports and no call-site changes. +- Integration and end-to-end: a local run of the binary against recorded fixtures produces a schema-valid `models.json`. (A manual workflow dispatch producing and publishing the real artifact is verified in the separate aggregation repo.) +- Regression, security, and performance: no keys in the artifact or the crate (CI check: the sheet contains no secret material); existing gateway and workshop suites stay green through the hoist. +- Exit criteria: workspace nextest, doctests, clippy `-D warnings`, and `cargo fmt --all --check` green; a locally built `models.json` parses as a valid `Sheet`. (The published-artifact criterion moves with the workflow to the separate repo.) + + + + +## Decision Record + +- Decisions: + - One Rust file per provider: the user's words - "I want each provider in its own rust file. anthropic.rs gemini.rs openai.rs moonshot.rs and so on." + - The provider file defines a public descriptor struct named `Provider` with fields for the API-key environment-variable name and the default URL: the user's words - "the provider file defines the name of the API key, the default URL, basically there is a descriptor lets call it struct Provider." + - The descriptor is public while the variances are private: the user's words - "the descriptor is public, while the variances are private - the variances are the little bullshit things that differ between providers." + - The crate links into the Gateway and exists so the Gateway understands provider offerings and normalizes them into config-UI choices: the user's words - "the rust crate is to link into the gateway so the gateway can understand what each provider offers, and normalize its models into a set of chocies for the config ui." + - Downstream consumers are Workshop, the PromptForge Agent Harness (not yet written), and the PromptForge CLI (not yet written), consuming models through the Gateway normally. + - Aggregation runs in a GitHub workflow with provider keys in GitHub secrets, manually triggerable and scheduled, publishing the sheet as a release artifact: the user's original framing. The workflow and the release artifact live in a separate aggregation repo, not the promptforge repo: the operator's words - "leave the github part of step 10 out, we are going to use a separate repo" (2026-09-14). This supersedes the earlier decision to place both in the promptforge repo. This plan still delivers the binary the workflow compiles and runs, and the `key_env` contract its secrets must match. + - The sheet is a single `models.json` holding everything, wrapped in an envelope with `schema_version`, `generated_at` (RFC 3339), and a `providers` map: the payload is tiny (~8 KB for Anthropic's 11 models; well under 1 MB at full provider coverage), every consumer wants the whole catalog, and one file gives one atomic snapshot with no version skew between provider slices. + - A failed provider fetch propagates the previous sheet's data for that provider rather than degrading to a marker alone: the user's words - "what happens on a failed fetch? it should propagate the previous file's data." The propagated slice is marked `stale` and keeps its original `fetched_at`. + - The sheet schema's field names mirror the Gateway's existing `Capabilities` vocabulary where concepts overlap, so sheet-to-`ModelConfig` normalization is mechanical rather than a second mapping layer. + - The crate does double duty - `lib` linked into the Gateway, `bin` run by the workflow and locally: the user's words - "it should be both what is compiled in to the gateway, and also what is compiled and runs on GHA. Rationale: it can also be compiled and run locally for testing and building." One normalization codebase, no curl/jq divergence, and offline-testable fetch logic. + - The provider crate is named `shared-cloud-providers`: the user's words - "lets call this new crate shared-cloud-providers." + - The normalized model definition structs live in their own types-only crate, consumed by `shared-cloud-providers`, the Gateway, and Workshop server: the user's words - "there should be a shared crate with the normalized model definition structs, shared-cloud-providers should consume that and gateway should consume that. and probably workshop-server would consume it because it corresponds to UI elements such as the model dropdown." The workspace dependency rules force this: `workshop-*` may never depend on `gateway-*`, so any type the Workshop UI needs must live in `shared-*`. + - That crate is named `shared-gateway-api`: the user's words - "it would be shared-gateway-api." The name follows the existing `shared-promptforge-api` precedent. + - HTTP client injection: `shared-cloud-providers` takes an injected `reqwest::Client`; the Gateway constructs a purpose-built bounded client for sheet downloads via the existing `gateway-protocol/src/http_util.rs` factory, because no shared client exists (each upstream builds three role-specific clients privately; the only injection seam today is `#[cfg(test)]`). + - Providers are tiered `prime` / `subprime` / `niche` / `aggregator`, a curated field on the `Provider` descriptor: the user's words - "the providers should be tiered: Prime, Underdog, Niche, Aggregator", with the second tier renamed per "rename underdog to Subprime." The definition is functional: Prime, Subprime, and Aggregator all have a working key-callable model-list endpoint that normalizes cleanly; Niche providers are listed but have no usable list endpoint. + - Niche providers ship hand-maintained static model lists as one `.json` file per provider in the repo, compiled into the binary (operator decision 2026-09-14: "for the hand-maintained list I want a .json file for each niche provider in the repo, and then that gets compiled in"), emitted with `status: "static"` and no fetch attempted. The tier label tells the UI how fresh to expect the data to be. This supersedes both the original compiled-into-Rust-code wording and the step-8 implementation's carry-the-previous-sheet-slice-forward approach, which was reworked to match. + - Tier assignments (user-approved): Prime - Anthropic, OpenAI, Google Gemini, xAI, DeepSeek, Alibaba Qwen, Moonshot AI, Meta, ElevenLabs (TTS+STT), Deepgram (STT+TTS). Subprime - Mistral, Cohere, Baidu, MiniMax, StepFun, Amazon Nova (Bedrock), Microsoft Foundry, NVIDIA, Groq, Soniox, Azure Speech, Leonardo. Niche - Zhipu, ByteDance Doubao, iFlytek, AI21, Perplexity, plus the static-list media providers (Kling, Black Forest Labs, Recraft, Ideogram, Adobe Firefly, Runway, Luma, Stability, AssemblyAI, Speechmatics, Gladia, Rev.ai, AWS Transcribe, Cartesia, Murf, Hume, Resemble, Inworld). Aggregator - OpenRouter, SiliconFlow. The ElevenLabs and Deepgram Prime promotions are the user's call: "maybe 1 or 2 are Prime." + - Normalize the knob, never the settings: `effort_levels` holds the provider's own level names as strings (observed union: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); no cross-provider ordinal scale is invented. This matches the existing `Capabilities.effort_levels: Vec` in `crates/gateway-config/src/config.rs`. + - The sheet includes optional `pricing` and `deprecation` fields, filled where the provider's list endpoint reports them (pricing: xAI, DashScope-native, Baidu, Perplexity router, OpenRouter; deprecation: Bedrock, Mistral, Cohere, OpenRouter). Pricing normalizes to per-million-token units with an explicit currency field. + - Schema design does not require API keys: every provider's list-response shape was extracted from official documentation on 2026-09-14, and three live payloads (Anthropic, OpenRouter, NVIDIA) were verified directly. Keys are for ongoing freshness at workflow time, not for designing the normalizer. + - Phase 1 is infrastructure only - crates, binary, workflow, artifact - with no UI changes and no Gateway consumption: the user's words - "I don't want anything changed in the UI yet. First I want to get the infrastructure in place and reliable to build the table." + - The sheet covers media models, not just chat: image generation, speech-to-text, and text-to-speech providers are in scope per the user's directive. `ModelEntry` gains a `kind` field, and the hoisted `ModelKind` extends beyond its current chat/embedding/classifier/speech set with `transcription`, `image`, and `video` variants (the gateway's wire already knows `transcription` for STT catalog entries). + - Midjourney and PlayHT are excluded: Midjourney has no official public API, and PlayHT is defunct (Meta acquisition, sunset 2025-12-31). + - Hoist, not mirror: `Capabilities`, `ModelInfo`, `ModelKind`, and `ThinkingMode` move into `shared-gateway-api` as their canonical home, with `gateway-config` and `gateway-protocol` re-importing: the user's words - "hoist for sure." A mirrored parallel definition is exactly the parallel-truth debt the repo's debt-collector passes keep cleaning up. + - `Provider` is a new concept, distinct from `EndpointConfig`: a case-insensitive grep for "provider" across `gateway-config`, `gateway-protocol`, and `gateway-routing` returns zero matches; what the TOML reflects is `EndpointConfig` (`crates/gateway-config/src/config.rs` lines 468-483), an operator-configured endpoint instance holding a live `Secret` and an optional dominion binding, covering any OpenAI-compatible backend. `Provider` is a static vendor descriptor in code - no secrets, no operator choices, just name, default base URL, and API-key env-var name. A future `[[endpoint]]` may reference a provider for its defaults, but that is unification potential, not identity. + - `generated_at` is RFC 3339 with a literal `Z`: every consumer stack parses it natively; the workspace's existing `time` 0.3 dependency (`Cargo.toml`) needs only its `parsing` feature enabled. + - Raw payload embedding: rejected (user decision 2026-09-14). Model entries carry normalized fields only; the artifact stays small and schema-stable, and provider-specific response shapes never leak into consumers. + - First-iteration scope is the Prime tier only (user decision 2026-09-14): Anthropic, OpenAI, Google Gemini, xAI, DeepSeek, Alibaba Qwen, Moonshot AI, Meta, ElevenLabs, Deepgram. Subprime providers, Niche static lists, and Aggregators follow once the Prime pipeline is proven. The schema and `build_sheet` keep `static` slice support, but no Niche provider files ship in v1. + - Schedule cadence: weekly cron plus manual dispatch (user decision 2026-09-14). +- Rejected alternatives: + - The crate as CI-only tooling (a build binary run by the workflow): superseded by the user's correction that the crate links into the Gateway. The workflow running the crate's `bin` target was later settled by the double-duty decision. + - Replacing the gateway's runtime types with the sheet types outright: rejected because the nullability regimes differ - the sheet is best-effort (`context_window: Option` because IDs-only providers omit it) while the runtime enforces validated configuration (`ModelConfig.context` is a required `u32`); `ModelInfo` is also a stable wire contract that Workshop's dropdown already parses. `ModelKind` is the exception: it is shared outright, and the sheet's `kind` field uses it. The user approved this reasoning: "this makes sense." + - LLM inference over provider docs pages inside the publish workflow: rejected because the sheet is consumed as authoritative and LLM extraction introduces silent nondeterminism; a hallucinated context window is worse than an absent one. The fields it would fill are covered by static curation in the provider file. + - Removing the `gateway` crate's lib target as extraneous (no downstream crate links it): rejected because the lib is the integration-test seam - the 30-file suite under `crates/gateway/tests/it/` imports the crate through its lib target, and the crate dev-depends on itself with the `test-fixtures` feature for exactly that reason. Revisit never. + - Mirroring the model-metadata types in `shared-gateway-api` while leaving the originals in place: rejected in favor of hoisting; parallel definitions of `Capabilities` would drift. Revisit never. +- Assumptions, risks, and notes: + - GitHub Actions runners have unrestricted outbound HTTPS; vendor endpoints are reachable from workflows with curl or any HTTP client. + - The Anthropic `GET /v1/models` response shape (verified live 2026-09-14) contains `id`, `display_name`, `created_at`, `max_input_tokens`, `max_tokens`, and a `capabilities` object; it has no pricing and no deprecation status. + - Anthropic's docs publish a keyless markdown mirror of the models overview page; other providers may lack an equivalent, which is part of the case for key-backed aggregation. + - UI consumption evidence (2026-09-14 survey): the Workshop model dropdown uses only `id` and `description` (`crates/workshop-server/ui/src/services/protocol.ts` lines 25-34, `ui/src/ui/chrome/model-picker-trigger.ts` lines 69-99); the config UI's models view consumes `kind`, `description`, `context`, `thinking`, and the flattened capability keys (`crates/gateway-config-ui/ui/src/views/models-view.ts`, `ui/src/components/settings-registry.ts` lines 89-196). The hoisted field set covers both consumers. + +### Deferred and Out of Scope + +- Deferred: the aggregation workflow and release publication. They live in a separate aggregation repo, not the promptforge repo: the operator's words - "leave the github part of step 10 out, we are going to use a separate repo" (2026-09-14). This plan delivers everything the workflow needs: the `shared-cloud-providers` binary it compiles and runs, the `key_env` environment-variable contract its secrets must match, and the `models.json` output shape it publishes. Revisit when the separate repo is created. +- Deferred: the Gateway's sheet-consumption path (`fetch_sheet`, cache, config-UI model choices) and all UI integration. The user's words: "I don't want anything changed in the UI yet. First I want to get the infrastructure in place and reliable to build the table." Revisit when the workflow has produced reliable sheets. Phase 2 should name a gateway route that re-serves the normalized catalog, so hosts consume it from the Gateway rather than fetching from GitHub themselves. +- Deferred: an LLM-assisted curation bot that reads provider docs and opens PRs proposing updates to the static lists - LLM leverage with a human gate, keeping the published artifact deterministic. Revisit when the static lists need their first refresh. +- Deferred: Workshop server linking `shared-gateway-api` directly for the model dropdown. The dropdown already works through the Gateway's catalog; the direct link only matters when the UI wants richer per-provider data than the catalog carries. Revisit when the dropdown needs tier or per-provider metadata. +- Deferred: consolidating the gateway's per-upstream trio of role-specific `reqwest::Client`s into a shared client (reqwest's per-request `timeout()` makes it possible; the gain is marginal because connection pooling is per-host, and the SSE timeout behavior carries regression risk). Revisit when upstream client construction is otherwise touched. +- Deferred: a max-staleness eviction policy for `stale` provider slices, which keep advertising a model if a provider retires it while its fetches keep failing. Revisit when a provider retirement collides with a fetch outage. +- Out of scope: changes to the gateway's upstream client construction beyond the one bounded client built for sheet downloads. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build` (builds only the gateway, the default member, on a fresh clone); the desktop app is explicit: `cargo build -p workshop` +- Focused test command pattern: `cargo nextest run -p ` +- Component test command pattern: `cargo nextest run -p ` +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --all-features`, then doctests via `cargo test --workspace --exclude workshop --exclude workshop-server --all-features --doc`; workshop crates separately: `cargo nextest run --locked -p workshop -p workshop-server` +- Linter command: `cargo clippy --workspace --exclude workshop --exclude workshop-server --all-targets --all-features -- -D warnings` (workshop: `cargo clippy -p workshop -p workshop-server --all-targets -- -D warnings`) +- Formatter check command: `cargo fmt --all --check` +- Docs command: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server` with `RUSTDOCFLAGS="-D warnings"`; user guide: `mdbook build guide` +- Test placement and naming conventions: unit tests live in `#[cfg(test)]` modules beside the source; integration tests live in `crates//tests/` (present in gateway, promptforge-api, workshop-server, and about a dozen other crates); JavaScript tools in `tools/` carry sibling `*.test.mjs` files; nextest profiles and a `heavy` test group for tensor/FFI suites are configured in `.config/nextest.toml`; the boundary and structural harness runs as `cargo test -p build-xtask` +- Directory map: `crates/` holds all workspace members (Rust crates plus the excluded TypeScript package `shared-ui`); `guide/` is the mdBook user guide; `prompts/` holds example PromptForge prompt files; `tools/` holds standalone JS tools and docs; `vibe/` holds design and plan documents including `archdoc.md`; `.github/workflows/` holds CI; `.config/` holds nextest config; `.githooks/`, `.cargo/`, `images/`, `local/`, `target/`, and `target-msrv/` are support and build output +- Component boundaries: three products with strict naming and dependency rules: `promptforge-*` (executor, parser, Lua boundary, store, VFS policy, web tools; may not depend on gateway or workshop crates), `gateway-*` (inference gateway: routing, protocol, config, STT, sidecar; may not depend on promptforge or workshop crates), `workshop-*` (Tauri desktop shell and in-process server; may not depend on gateway crates); `shared-*` crates carry the cross-product API surface and depend on no product crates; `build-*` crates build specific outputs; the one-door rule: crates outside the promptforge-* family may depend only on `promptforge-api`, never on internal promptforge-* substrate crates; dependency direction is shell -> features -> services -> vocabulary, enforced by `build-xtask` +- Conventions summary: edition 2024, workspace-inherited lints forbid unsafe code and deny clippy `all`, `unwrap_used`, and `expect_used`; behavior changes ship with tests in the same change; reuse of existing facilities is preferred over new machinery; error messages are written for model consumption (concise, factual, self-contained); no file exceeds 500 lines; every workshop-* crate's lib.rs opens with a `## Invariants` doc marker; SPA CSS lives beside its TypeScript with `--ws-*` design tokens, never raw values; long-running work reports through `shared-progress`; Cargo features gate real constraints, not product shape + + + + +## Execution Instructions + +Decomposition (Path: FULL), two components in dependency order: + +1. `shared-gateway-api` first: `shared-cloud-providers` depends on its schema types, and the hoist must land before `shared-cloud-providers` can reference the canonical `ModelKind`. +2. `shared-cloud-providers` second: it delivers the binary the aggregation workflow compiles and runs. The workflow itself lives in a separate aggregation repo (operator decision 2026-09-14) and is not execution scope here; the original third component (`aggregation-workflow`) was removed from this plan. + +Pieces build sequentially within each component: the schema precedes the hoist so each commit compiles on its own (the schema is purely additive; the hoist touches existing crates); the fetch seam precedes the provider files that plug into it; `build_sheet` follows the provider files it aggregates; the binary follows the lib it wraps. + + + +### Step 1: shared-gateway-api sheet schema [completed] + +- Component: shared-gateway-api +- Create `crates/shared-gateway-api/` (Cargo.toml, `src/lib.rs`), edition 2024, workspace lints, depending only on `serde` and `time` (with its `parsing` feature), mirroring the `shared-promptforge-api` precedent. +- Declare the sheet schema types exactly as specified in the implementation contract: `Sheet`, `ProviderSlice`, `Tier`, `SliceStatus`, `ModelEntry`, `Thinking`, `Pricing`, `Deprecation`. +- Tests: schema round-trip (serialize, parse, compare); `BTreeMap` provider ordering is byte-deterministic; `generated_at` serializes as RFC 3339 with a literal `Z`; the contract's example JSON parses into the schema. + + + + + +### Step 2: hoist model-metadata types into shared-gateway-api [completed] + +- Component: shared-gateway-api +- Move `Capabilities`, `ModelInfo`, `ModelKind`, and `ThinkingMode` from `crates/gateway-config/src/config.rs` and `crates/gateway-protocol` into `shared-gateway-api` as their canonical home; extend `ModelKind` with the `transcription`, `image`, and `video` variants. +- Re-export all four types at their old paths in `gateway-config` and `gateway-protocol` so downstream call sites compile unchanged; add the `shared-gateway-api` dependency to both crates. +- Tests: the workspace compiles with no call-site edits; existing gateway and workshop suites stay green, proving the hoist. + + + + + +### Step 3: shared-cloud-providers scaffold and fetch seam [completed] + +- Component: shared-cloud-providers +- Create `crates/shared-cloud-providers/` (Cargo.toml with `lib` and `bin` targets, `src/lib.rs`), depending on `shared-gateway-api` and `reqwest`. +- Declare the public `Provider` descriptor (`name`, `display_name`, `tier`, `key_env`, `base_url`), the `providers()` registry, `FetchError`, and the `fetch_models(client, provider, key)` signature with the injected `reqwest::Client` seam, exactly as specified in the implementation contract. +- Tests: registry entries have unique names and unique `key_env` values; every Prime-tier descriptor carries the tier, key-env, and base URL settled in the decision record. + + + + + +### Step 4: anthropic provider file [completed] + +- Component: shared-cloud-providers +- Add `src/providers/anthropic.rs`: public `Provider` descriptor plus private variance - `x-api-key` and required `anthropic-version` headers, cursor pagination, and normalization of the verified response shape (`id`, `display_name`, `created_at`, `max_input_tokens`, `max_tokens`, `capabilities`) into `ModelEntry`. +- Register the provider in `providers()`. +- Tests: normalization against the recorded 2026-09-14 live Anthropic payload as a fixture; pagination across a two-page fixture; capability and thinking-flag mapping. + + + + + +### Step 5: OpenAI-dialect provider files [completed] + +- Component: shared-cloud-providers +- Add `openai.rs`, `xai.rs`, `deepseek.rs`, `qwen.rs`, `moonshot.rs`, and `meta.rs`, sharing one private helper for the OpenAI response shape; per-file variance covers xAI's `aliases`/`context_length`/pricing (normalized from USD cents per 100M to per-million-token), Moonshot's `context_length` and image/video/reasoning flags, and DashScope's compatible-mode endpoint. +- Register all six in `providers()`. +- Tests: per-provider normalization against documented example responses as fixtures; pricing unit normalization for xAI; IDs-only providers emit `None` for `context_window` and `max_output`. + + + + + +### Step 6: gemini provider file [completed] + +- Component: shared-cloud-providers +- Add `src/providers/gemini.rs`: public descriptor plus private variance - `?key=` query param or `x-goog-api-key` header, `pageToken` pagination on `GET /v1beta/models`, and normalization of `inputTokenLimit`, `outputTokenLimit`, `supportedGenerationMethods`, and the thinking flag. +- Register the provider in `providers()`. +- Tests: normalization against documented example responses as fixtures; `pageToken` traversal; generation-method to capability-boolean mapping. + + + + + +### Step 7: media provider files (elevenlabs, deepgram) [completed] + +- Component: shared-cloud-providers +- Add `elevenlabs.rs` and `deepgram.rs`: ElevenLabs uses the `xi-api-key` header and its rich list response (languages, capabilities, rates); Deepgram uses the `Authorization: Token` prefix and splits its STT models and TTS array into separate `ModelEntry` values. +- Both files set `ModelEntry.kind` to `transcription` or `speech` (and `image` where applicable), exercising the extended `ModelKind`. +- Register both in `providers()`. +- Tests: per-provider normalization against documented example responses as fixtures; STT and TTS entries from one Deepgram payload carry distinct kinds; a registry completeness test asserting all ten Prime providers from the decision record are registered with `Tier::Prime` (deferred from step 3, where the registry is intentionally empty). + + + + + +### Step 8: build_sheet, fetch_sheet, and propagation [completed] + +- Component: shared-cloud-providers +- Implement `build_sheet(client, previous, keys)`: per provider, a successful fetch writes a fresh `ok` slice with `fetched_at` = now; a failed fetch copies the previous slice verbatim with `status` rewritten to `stale` and its original `fetched_at` preserved; a failed fetch with no previous slice records `unavailable` with an empty `models` array; a Niche provider emits a `static` slice read from its per-provider `.json` file compiled into the binary (one file per Niche provider in the repo; no fetch attempted, no `fetched_at`), though no Niche provider files ship in v1. Assemble the envelope (`schema_version` 1, `generated_at` = now). +- Implement `fetch_sheet(client, release_url)` for downloading and parsing the release artifact. +- Tests: the full propagation matrix (`ok`, `stale` with preserved `fetched_at`, `unavailable`, `static` via a test-only static provider); a failed fetch never fails the build and never drops data. + + + + + +### Step 9: sheet-building binary [completed] + +- Component: shared-cloud-providers +- Add `src/main.rs`, a thin `main` over the lib: read provider keys from environment variables (names from each descriptor's `key_env`), download the previous release's `models.json` when it exists (tolerate its absence on first run), call `build_sheet`, and write the merged `models.json`. +- Tests: an integration test under `crates/shared-cloud-providers/tests/` runs the binary against recorded fixtures and validates the output parses as a schema-valid `Sheet`. + + + +The aggregation workflow (originally Step 10) is removed from this plan's scope: it lives in a separate aggregation repo per the operator's decision of 2026-09-14. This plan's final step is Step 9; the binary it delivers is what the separate repo's workflow compiles and runs. + +Phase 2 (the Gateway's sheet-consumption path and config-UI integration) is deferred and is not execution scope for this plan. + + diff --git a/vibe/2026-09-14-3-fix-previous-sheet-swallow.md b/vibe/2026-09-14-3-fix-previous-sheet-swallow.md new file mode 100644 index 000000000..638298e7f --- /dev/null +++ b/vibe/2026-09-14-3-fix-previous-sheet-swallow.md @@ -0,0 +1,101 @@ +--- +name: Fix previous-sheet swallow +overview: "Remove DEBT-PMS-1: the sheet binary treats a failed previous-sheet download identically to a first run, so a double failure publishes a regressed sheet with a success exit code. Distinguish absent-release (tolerate) from unreachable/corrupt-release (fatal) in the binary." +todos: + - id: debt-pms-1 + content: "DEBT-PMS-1: distinguish previous-sheet error kinds in fetch_sheet/previous_sheet; fatal exit on outage or corruption, tolerate unset URL and 404; unit + 3 integration tests; nextest/clippy/fmt green" + status: pending +isProject: false +--- + + +## Product Requirements + +- Scope and target work: the provider-model-sheets run (`1537ecf2..970f3c29`, 12 commits) in `c:\Users\Vinnie\cursor\promptforge`. One accepted debt: DEBT-PMS-1 (introduced by `8e27fe60`, interacting with `79d1d691`). +- The debt: `crates/shared-cloud-providers/src/main.rs` `previous_sheet` returns `None` on any `fetch_sheet` error (transport, non-success status, parse failure) with only a stderr note. With `previous: None`, `crates/shared-cloud-providers/src/sheet.rs` `stale_or_unavailable` records every failed provider fetch as `unavailable` with an empty model list, and the binary writes the sheet and exits 0. A transient outage or misconfigured `MODELS_SHEET_PREVIOUS_URL` therefore publishes a sheet that replaces last-known-good data with empty slices - defeating the "never drops data" contract in exactly the double-failure case it exists for, with no signal to the workflow to withhold publication. +- Cleanup goals: a configured-but-unreachable or unparseable previous-sheet URL is fatal (nonzero exit, no output written); a genuinely absent release (URL unset, or HTTP 404) is tolerated as first run. +- Non-goals: no schema, wire, or public-API changes; no changes to provider fetch/normalization; no changes to the aggregation workflow repo (not yet created); the 8 rejected candidates (R1-R8) stay as recorded. +- Success criteria: the double-failure case exits nonzero and writes nothing; first-run and 404 cases still succeed; full workspace gates stay green. + +### Debt Inventory + +- DEBT-PMS-1 (introduced, accepted by analysis and upheld by challenge): swallowed previous-sheet download failure. Evidence: `previous_sheet` in `main.rs` collapses all error kinds to `None`; commit `8e27fe60` carries `Design: new swallowed-exception @ ...main.rs::previous_sheet`; consequence is a durable-data path onto the published release artifact. Reversal cost: low - `previous_sheet` is private to the binary with no consumers yet. Target state: error-kind distinction with fatal exit on outage, tolerance on genuine absence. +- Exposed pre-existing debt: none observed. +- Rejected candidates: 5 weak/speculative (R1 dispatch/registry duplication, R5 pub `Capabilities` fields, R6 unbounded body reads, R7 repeating-cursor pagination, R8 non-atomic write), 3 residual-but-acceptable (R2 `ModelEntry` bag-of-state, R3 re-export shims, R4 `static_json` test-only arms). Full evidence in the findings and challenge scratch files of the debt-collector run dated 2026-09-14. + +## Functional Specification + +- Actors and workflows: the sheet-building binary (the `shared-cloud-providers` bin target), run locally or by the aggregation workflow in the separate repo. It reads provider keys and `MODELS_SHEET_PREVIOUS_URL` from the environment, downloads the previous sheet when configured, builds the merged sheet, and writes `models.json`. +- Inputs and outputs: environment variables in; one `models.json` file out at argv[1] or `./models.json`; the process exit code is the workflow's publish/withhold signal. +- States and validation: the previous-sheet download has exactly three tolerated-or-fatal outcomes - URL not configured (first run, proceed), release absent (HTTP 404, proceed as first run), sheet fetched (proceed with history). +- Errors and recovery: on a configured URL, transport failure, a non-404 non-success status, or an unparseable body is fatal - the binary writes nothing, exits nonzero, and stderr names the URL and the failure. Provider fetch failures keep their existing semantics (`stale` propagation when history exists, `unavailable` when not); only the loss of history itself becomes fatal. + + + + +## Technical Design + +- In `crates/shared-cloud-providers/src/main.rs`, change `previous_sheet` to return a three-way result: no URL configured (first run), release absent (HTTP 404), or a fetched `Sheet`. Any other failure - transport error, non-404 non-success status, or unparseable body - propagates as a run error: `run` returns before writing output and `main` exits nonzero with a concise stderr message naming the URL and the failure. +- `fetch_sheet` in `crates/shared-cloud-providers/src/sheet.rs` needs its error to carry the HTTP status (or a dedicated `NotFound` variant on `FetchError`) so the binary can distinguish 404 from other failures. This is an internal enrichment of an in-flight crate, not a wire or persisted change. +- No change to `build_sheet` propagation semantics: `stale`/`unavailable` behavior is correct once history loss is fatal. + + + + +## Testing Plan + +- Unit (DEBT-PMS-1): `previous_sheet` maps unset URL, 404, transport error, 500, and unparseable-200 to the correct three-way outcome; loopback stub server per the existing `fetch_sheet_parses_a_successful_response` idiom. +- Integration (DEBT-PMS-1): in `crates/shared-cloud-providers/tests/sheet_binary.rs` - (a) stub previous-sheet URL returning 500 plus all provider keys stripped: assert nonzero exit and no output file written; (b) stub returning 404: assert success with `unavailable` slices; (c) stub returning 200 with invalid JSON: assert nonzero exit, no write. +- Regression: existing `sheet::` and `binary_` suites stay green. +- Exit checks: `cargo nextest run -p shared-cloud-providers`, clippy `-D warnings`, `cargo fmt --check`. + + + + +## Decision Record + +- Selected remedy (reversible, chosen autonomously): distinguish error kinds - fatal on configured-but-unreachable or corrupt previous sheet, tolerate only unset URL and 404. Consequence: the workflow's failure signal is the exit code it already watches; no schema or consumer change. +- Rejected alternatives: hard-fail on any download problem including 404 (contradicts the first-run tolerance); keep warn-and-continue (that is the debt); stamp the envelope with a propagation-missing flag (adds wire surface to work around a build-time problem; consumers would each need to enforce it); write output only when every failed fetch had a previous slice (blocks publication forever for a provider that never existed). +- User-resolved architecture choices: none required - reversal cost is low and the binary has no consumers yet. +- Assumptions and risks: the aggregation workflow repo will treat nonzero exit as do-not-publish (standard Actions behavior); the 404-as-absent rule assumes the release asset URL 404s before the first publication, which the workflow design should honor. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build` (builds only the gateway, the default member, on a fresh clone); the desktop app is explicit: `cargo build -p workshop` +- Focused test command pattern: `cargo nextest run -p ` +- Component test command pattern: `cargo nextest run -p ` +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --all-features`, then doctests via `cargo test --workspace --exclude workshop --exclude workshop-server --all-features --doc`; workshop crates separately: `cargo nextest run --locked -p workshop -p workshop-server` +- Linter command: `cargo clippy --workspace --exclude workshop --exclude workshop-server --all-targets --all-features -- -D warnings` (workshop: `cargo clippy -p workshop -p workshop-server --all-targets -- -D warnings`) +- Formatter check command: `cargo fmt --all --check` +- Docs command: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server` with `RUSTDOCFLAGS="-D warnings"`; user guide: `mdbook build guide` +- Test placement and naming conventions: unit tests live in `#[cfg(test)]` modules beside the source; integration tests live in `crates//tests/` (present in gateway, promptforge-api, workshop-server, and about a dozen other crates); JavaScript tools in `tools/` carry sibling `*.test.mjs` files; nextest profiles and a `heavy` test group for tensor/FFI suites are configured in `.config/nextest.toml`; the boundary and structural harness runs as `cargo test -p build-xtask` +- Directory map: `crates/` holds all workspace members (Rust crates plus the excluded TypeScript package `shared-ui`); `guide/` is the mdBook user guide; `prompts/` holds example PromptForge prompt files; `tools/` holds standalone JS tools and docs; `vibe/` holds design and plan documents including `archdoc.md`; `.github/workflows/` holds CI; `.config/` holds nextest config; `.githooks/`, `.cargo/`, `images/`, `local/`, `target/`, and `target-msrv/` are support and build output +- Component boundaries: three products with strict naming and dependency rules: `promptforge-*` (executor, parser, Lua boundary, store, VFS policy, web tools; may not depend on gateway or workshop crates), `gateway-*` (inference gateway: routing, protocol, config, STT, sidecar; may not depend on promptforge or workshop crates), `workshop-*` (Tauri desktop shell and in-process server; may not depend on gateway crates); `shared-*` crates carry the cross-product API surface and depend on no product crates; `build-*` crates build specific outputs; the one-door rule: crates outside the promptforge-* family may depend only on `promptforge-api`, never on internal promptforge-* substrate crates; dependency direction is shell -> features -> services -> vocabulary, enforced by `build-xtask` +- Conventions summary: edition 2024, workspace-inherited lints forbid unsafe code and deny clippy `all`, `unwrap_used`, and `expect_used`; behavior changes ship with tests in the same change; reuse of existing facilities is preferred over new machinery; error messages are written for model consumption (concise, factual, self-contained); no file exceeds 500 lines; every workshop-* crate's lib.rs opens with a `## Invariants` doc marker; SPA CSS lives beside its TypeScript with `--ws-*` design tokens, never raw values; long-running work reports through `shared-progress`; Cargo features gate real constraints, not product shape + + + + +## Execution Instructions + + + +### Step 1: distinguish previous-sheet error kinds [completed] + +- Component: none +- Artifacts: `crates/shared-cloud-providers/src/sheet.rs` (`FetchError`, `fetch_sheet`), `crates/shared-cloud-providers/src/main.rs` (`previous_sheet`, `run`, `main`), `crates/shared-cloud-providers/tests/sheet_binary.rs`. +- In `sheet.rs`, add a `NotFound` distinction to `FetchError` (or carry the HTTP status) so `fetch_sheet` lets callers tell 404 from other failures; internal enrichment only, no wire or schema change. +- In `main.rs`, rework `previous_sheet` to a three-way outcome: URL unset (first run), HTTP 404 (release absent, first run), or fetched `Sheet`. Any other failure (transport error, non-404 non-success status, unparseable body) propagates: `run` returns before writing output and `main` exits nonzero with a concise stderr message naming the URL and the failure. Provider fetch failures keep existing `stale`/`unavailable` semantics; only loss of history is fatal. +- Tests in the same commit: unit tests for `previous_sheet` mapping unset URL, 404, transport error, 500, and unparseable-200 to the correct outcome, using the loopback stub idiom of `fetch_sheet_parses_a_successful_response`; integration tests in `tests/sheet_binary.rs`: (a) stub previous-sheet URL returning 500 with provider keys stripped asserts nonzero exit and no output file, (b) stub returning 404 asserts success with `unavailable` slices, (c) stub returning 200 with invalid JSON asserts nonzero exit and no write. +- Regression: existing `sheet::` and `binary_` suites stay green. +- Verify: `cargo nextest run -p shared-cloud-providers`, clippy with `-D warnings`, `cargo fmt --check`. +- One commit containing code and tests (operator directive 2026-09-14: exactly one commit). +- Explicit exclusions: no schema changes, no provider-file changes, no workflow-repo changes, no remediation of rejected candidates R1-R8. + + + +