diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index dda3ed48c7..5f407dc946 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -9257,8 +9257,8 @@ verbosity = "project-imported" .map(|provider| provider.kind()) .collect(); // Full registry keeps legacy dialect/plan kinds; ALL is the catalog surface. - assert_eq!(registry_kinds.len(), 47); - assert_eq!(ProviderKind::ALL.len(), 42); + assert_eq!(registry_kinds.len(), 48); + assert_eq!(ProviderKind::ALL.len(), 43); for kind in ProviderKind::ALL { assert!( registry_kinds.contains(&kind), diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 850e07c222..0e7ee4ee6f 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -468,6 +468,15 @@ pub struct ProvidersToml { alias = "eden_ai" )] pub edenai: ProviderConfigToml, + /// Concentrate — OpenAI Responses-compatible AI gateway (aggregator). + #[serde( + default, + skip_serializing_if = "ProviderConfigToml::is_empty", + alias = "concentrate-ai", + alias = "concentrate_ai", + alias = "concentrateai" + )] + pub concentrate: ProviderConfigToml, /// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible endpoint). #[serde( default, @@ -692,6 +701,7 @@ impl ProvidersToml { ProviderKind::Antigravity => &self.antigravity, ProviderKind::Telecomjs => &self.telecomjs, ProviderKind::Edenai => &self.edenai, + ProviderKind::Concentrate => &self.concentrate, ProviderKind::ModelstudioTokenPlan => &self.modelstudio_token_plan, ProviderKind::ModelstudioTokenPlanAnthropic => &self.modelstudio_token_plan_anthropic, ProviderKind::ModelstudioCodingPlan => &self.modelstudio_coding_plan, @@ -744,6 +754,7 @@ impl ProvidersToml { ProviderKind::Antigravity => &mut self.antigravity, ProviderKind::Telecomjs => &mut self.telecomjs, ProviderKind::Edenai => &mut self.edenai, + ProviderKind::Concentrate => &mut self.concentrate, ProviderKind::ModelstudioTokenPlan => &mut self.modelstudio_token_plan, ProviderKind::ModelstudioTokenPlanAnthropic => { &mut self.modelstudio_token_plan_anthropic @@ -3926,6 +3937,7 @@ fn provider_passes_model_through(provider: ProviderKind) -> bool { | ProviderKind::Xai | ProviderKind::Telecomjs | ProviderKind::Edenai + | ProviderKind::Concentrate | ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic | ProviderKind::ModelstudioCodingPlan @@ -4516,6 +4528,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_MODEL, ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL, ProviderKind::Edenai => DEFAULT_EDENAI_MODEL, + ProviderKind::Concentrate => DEFAULT_CONCENTRATE_MODEL, ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic | ProviderKind::ModelstudioCodingPlan @@ -4569,6 +4582,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL, ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL, ProviderKind::Edenai => DEFAULT_EDENAI_BASE_URL, + ProviderKind::Concentrate => DEFAULT_CONCENTRATE_BASE_URL, ProviderKind::ModelstudioTokenPlan => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL, ProviderKind::ModelstudioTokenPlanAnthropic => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL, ProviderKind::ModelstudioCodingPlan => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL, @@ -4867,6 +4881,7 @@ pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> normalized.as_str(), "https://api.edenai.run/v3" | "https://api.eu.edenai.run/v3" ), + ProviderKind::Concentrate => normalized == DEFAULT_CONCENTRATE_BASE_URL, // Custom routes have no Codewhale-owned official endpoint. The // descriptor URL is a schema placeholder, never a credential scope. ProviderKind::Custom => false, @@ -6915,6 +6930,8 @@ struct EnvRuntimeOverrides { telecomjs_model: Option, edenai_base_url: Option, edenai_model: Option, + concentrate_base_url: Option, + concentrate_model: Option, modelstudio_token_plan_base_url: Option, modelstudio_token_plan_model: Option, modelstudio_coding_plan_base_url: Option, @@ -7269,6 +7286,12 @@ impl EnvRuntimeOverrides { edenai_model: std::env::var("EDENAI_MODEL") .ok() .filter(|v| !v.trim().is_empty()), + concentrate_base_url: std::env::var("CONCENTRATE_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + concentrate_model: std::env::var("CONCENTRATE_MODEL") + .ok() + .filter(|v| !v.trim().is_empty()), modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), @@ -7351,6 +7374,7 @@ impl EnvRuntimeOverrides { ProviderKind::Antigravity => self.antigravity_base_url.clone(), ProviderKind::Telecomjs => self.telecomjs_base_url.clone(), ProviderKind::Edenai => self.edenai_base_url.clone(), + ProviderKind::Concentrate => self.concentrate_base_url.clone(), ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => { self.modelstudio_token_plan_base_url.clone() } @@ -7398,6 +7422,7 @@ impl EnvRuntimeOverrides { ProviderKind::Antigravity => self.antigravity_model.clone(), ProviderKind::Telecomjs => self.telecomjs_model.clone(), ProviderKind::Edenai => self.edenai_model.clone(), + ProviderKind::Concentrate => self.concentrate_model.clone(), ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => { self.modelstudio_token_plan_model.clone() } diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 2be0dac49e..ef307565f3 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -8,14 +8,15 @@ use super::{ DEFAULT_ANTIGRAVITY_BASE_URL, DEFAULT_ANTIGRAVITY_MODEL, DEFAULT_ARCEE_BASE_URL, DEFAULT_ARCEE_MODEL, DEFAULT_ATLASCLOUD_BASE_URL, DEFAULT_ATLASCLOUD_MODEL, - DEFAULT_DEEPINFRA_BASE_URL, DEFAULT_DEEPINFRA_MODEL, DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, - DEFAULT_DEEPSEEK_ANTHROPIC_MODEL, DEFAULT_DEEPSEEK_BASE_URL, DEFAULT_DEEPSEEK_MODEL, - DEFAULT_EDENAI_BASE_URL, DEFAULT_EDENAI_MODEL, DEFAULT_FIREWORKS_BASE_URL, - DEFAULT_FIREWORKS_MODEL, DEFAULT_GOOGLE_BASE_URL, DEFAULT_GOOGLE_MODEL, - DEFAULT_HUGGINGFACE_BASE_URL, DEFAULT_HUGGINGFACE_MODEL, DEFAULT_LONGCAT_BASE_URL, - DEFAULT_LONGCAT_MODEL, DEFAULT_META_BASE_URL, DEFAULT_META_MODEL, - DEFAULT_MINIMAX_ANTHROPIC_BASE_URL, DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_MODEL, - DEFAULT_MISTRAL_BASE_URL, DEFAULT_MISTRAL_MODEL, DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL, + DEFAULT_CONCENTRATE_BASE_URL, DEFAULT_CONCENTRATE_MODEL, DEFAULT_DEEPINFRA_BASE_URL, + DEFAULT_DEEPINFRA_MODEL, DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, DEFAULT_DEEPSEEK_ANTHROPIC_MODEL, + DEFAULT_DEEPSEEK_BASE_URL, DEFAULT_DEEPSEEK_MODEL, DEFAULT_EDENAI_BASE_URL, + DEFAULT_EDENAI_MODEL, DEFAULT_FIREWORKS_BASE_URL, DEFAULT_FIREWORKS_MODEL, + DEFAULT_GOOGLE_BASE_URL, DEFAULT_GOOGLE_MODEL, DEFAULT_HUGGINGFACE_BASE_URL, + DEFAULT_HUGGINGFACE_MODEL, DEFAULT_LONGCAT_BASE_URL, DEFAULT_LONGCAT_MODEL, + DEFAULT_META_BASE_URL, DEFAULT_META_MODEL, DEFAULT_MINIMAX_ANTHROPIC_BASE_URL, + DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_MODEL, DEFAULT_MISTRAL_BASE_URL, + DEFAULT_MISTRAL_MODEL, DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL, DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL, DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL, DEFAULT_MOONSHOT_BASE_URL, DEFAULT_MOONSHOT_MODEL, DEFAULT_NOVITA_BASE_URL, DEFAULT_NOVITA_MODEL, DEFAULT_NVIDIA_NIM_BASE_URL, DEFAULT_NVIDIA_NIM_MODEL, @@ -438,6 +439,12 @@ pub const fn credential_help(kind: ProviderKind) -> CredentialHelp { docs_url: Some("https://www.edenai.co/docs"), guidance: "Create an Eden AI API key from the Eden AI dashboard, then select models by their provider/model namespaced id.", }, + ProviderKind::Concentrate => CredentialHelp { + acquisition: ApiKey, + credential_url: Some("https://concentrate.ai/"), + docs_url: Some("https://concentrate.ai/docs/api-reference/introduction"), + guidance: "Create a Universal API key in the Concentrate dashboard (API Keys → Create API Key). Codewhale sends it only to the Concentrate base URL and never stores or forwards it elsewhere.", + }, ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic | ProviderKind::ModelstudioCodingPlan @@ -1412,6 +1419,56 @@ provider!( aliases: ["eden-ai", "eden_ai"] ); +/// Concentrate — OpenAI Responses-compatible AI gateway (aggregator). +/// +/// `provider!()` fixes every macro provider on Chat Completions. Concentrate +/// documents the Responses API as its production surface ("For production +/// use, we recommend using the Responses API"), so it carries a fixed +/// Responses wire policy here instead. Contract: +/// . Commercial +/// boundary: its Terms of Service forbid resale, white-label, and +/// service-bureau use without written consent, so this route is BYOK only — +/// the user's own key, their own bill, no Codewhale fee or managed default. +pub struct Concentrate; + +impl Provider for Concentrate { + fn id(&self) -> &'static str { + "concentrate" + } + + fn kind(&self) -> ProviderKind { + ProviderKind::Concentrate + } + + fn display_name(&self) -> &'static str { + "Concentrate" + } + + fn default_base_url(&self) -> &'static str { + DEFAULT_CONCENTRATE_BASE_URL + } + + fn default_model(&self) -> &'static str { + DEFAULT_CONCENTRATE_MODEL + } + + fn env_vars(&self) -> &'static [&'static str] { + &["CONCENTRATE_API_KEY"] + } + + fn provider_config_key(&self) -> &'static str { + "concentrate" + } + + fn aliases(&self) -> &'static [&'static str] { + &["concentrate-ai", "concentrate_ai", "concentrateai"] + } + + fn wire_policy(&self) -> WirePolicy { + WirePolicy::Fixed(WireFormat::Responses) + } +} + /// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible Chat Completions). /// /// Token Plan Personal and Team share the same regional endpoint. The default @@ -1704,6 +1761,7 @@ static MISTRAL: Mistral = Mistral; static ANTIGRAVITY: Antigravity = Antigravity; static TELECOMJS: Telecomjs = Telecomjs; static EDENAI: Edenai = Edenai; +static CONCENTRATE: Concentrate = Concentrate; static MODELSTUDIO_TOKEN_PLAN: ModelstudioTokenPlan = ModelstudioTokenPlan; static MODELSTUDIO_TOKEN_PLAN_ANTHROPIC: ModelstudioTokenPlanAnthropic = ModelstudioTokenPlanAnthropic; @@ -1712,7 +1770,7 @@ static MODELSTUDIO_CODING_PLAN_ANTHROPIC: ModelstudioCodingPlanAnthropic = ModelstudioCodingPlanAnthropic; static CUSTOM: Custom = Custom; -static PROVIDER_REGISTRY: [&dyn Provider; 47] = [ +static PROVIDER_REGISTRY: [&dyn Provider; 48] = [ &DEEPSEEK, &DEEPSEEK_ANTHROPIC, &NVIDIA_NIM, @@ -1753,6 +1811,7 @@ static PROVIDER_REGISTRY: [&dyn Provider; 47] = [ &MISTRAL, &TELECOMJS, &EDENAI, + &CONCENTRATE, &MODELSTUDIO_TOKEN_PLAN, &MODELSTUDIO_TOKEN_PLAN_ANTHROPIC, &MODELSTUDIO_CODING_PLAN, diff --git a/crates/config/src/provider_defaults.rs b/crates/config/src/provider_defaults.rs index 2164b0a147..996b34d16d 100644 --- a/crates/config/src/provider_defaults.rs +++ b/crates/config/src/provider_defaults.rs @@ -196,6 +196,13 @@ pub(crate) const DEFAULT_TELECOMJS_BASE_URL: &str = "https://aigw.telecomjs.com/ // Eden AI (OpenAI-compatible AI gateway) defaults pub(crate) const DEFAULT_EDENAI_MODEL: &str = "deepseek/deepseek-v4-pro"; pub(crate) const DEFAULT_EDENAI_BASE_URL: &str = "https://api.edenai.run/v3"; +// Concentrate (OpenAI Responses-compatible AI gateway) defaults. Contract: +// https://concentrate.ai/docs/api-reference/introduction — base URL, bearer +// Universal API key, `POST /v1/responses`, unauthenticated `GET /v1/models`. +// The default is a plain catalog id (the gateway picks the upstream provider); +// `provider/model` pins a provider and `concentrate/auto` is the gateway router. +pub(crate) const DEFAULT_CONCENTRATE_MODEL: &str = "deepseek-v4-pro"; +pub(crate) const DEFAULT_CONCENTRATE_BASE_URL: &str = "https://api.concentrate.ai/v1"; // Alibaba Cloud Model Studio (DashScope) defaults // Token Plan (Personal / Team): shared endpoint, OpenAI + Anthropic dialects pub(crate) const DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL: &str = "qwen3.8-max"; diff --git a/crates/config/src/provider_kind.rs b/crates/config/src/provider_kind.rs index 123cbd47eb..ee74c96f42 100644 --- a/crates/config/src/provider_kind.rs +++ b/crates/config/src/provider_kind.rs @@ -215,6 +215,21 @@ pub enum ProviderKind { /// namespaced wire ids over the OpenAI Chat Completions protocol. #[serde(alias = "eden-ai", alias = "eden_ai", alias = "edenai")] Edenai, + /// Concentrate — OpenAI Responses-compatible AI gateway (aggregator). + /// + /// Serves a broad catalog of upstream models over the OpenAI Responses + /// protocol at `/v1/responses` with a bearer Universal API key. Model ids + /// pass through verbatim: a plain catalog id (`gpt-5.6-sol`) lets the + /// gateway choose the upstream provider, `provider/model` pins one, and + /// `concentrate/auto` reaches the gateway's own `auto` router. Opt-in and + /// BYOK only: the key lives in the local secret store and Codewhale adds + /// no fee, no managed default, and no resale lane (see docs/PROVIDERS.md). + #[serde( + alias = "concentrate-ai", + alias = "concentrate_ai", + alias = "concentrateai" + )] + Concentrate, /// User-defined OpenAI-compatible endpoint (#1519). /// /// A single dynamic identity for arbitrary `[providers.] @@ -232,7 +247,7 @@ impl ProviderKind { /// stay on the enum for serde and `provider_for_kind`, but they are not /// first-class catalog rows. Plan is `mode` / base_url; dialect is /// `wire = openai|anthropic` on the primary provider config. - pub const ALL: [Self; 42] = [ + pub const ALL: [Self; 43] = [ Self::Deepseek, Self::NvidiaNim, Self::Openai, @@ -274,6 +289,7 @@ impl ProviderKind { Self::Google, Self::Antigravity, Self::Edenai, + Self::Concentrate, Self::Custom, ]; diff --git a/crates/config/src/route/golden_route_ids.txt b/crates/config/src/route/golden_route_ids.txt index 563569af26..5e8d809788 100644 --- a/crates/config/src/route/golden_route_ids.txt +++ b/crates/config/src/route/golden_route_ids.txt @@ -5,6 +5,7 @@ anthropic antigravity arcee atlascloud +concentrate custom deepinfra deepseek diff --git a/crates/config/src/route/providers-export.golden.json b/crates/config/src/route/providers-export.golden.json index 3769fb7950..ecad82b837 100644 --- a/crates/config/src/route/providers-export.golden.json +++ b/crates/config/src/route/providers-export.golden.json @@ -104,6 +104,24 @@ ], "transport": "chat-completions" }, + { + "id": "concentrate", + "family": "concentrate", + "label": "Concentrate", + "endpoint": "https://api.concentrate.ai/v1", + "wire": "responses", + "defaultModel": "deepseek-v4-pro", + "envVars": [ + "CONCENTRATE_API_KEY" + ], + "auth": [ + { + "kind": "api-key", + "label": "API key" + } + ], + "transport": "open-ai-responses" + }, { "id": "custom", "family": "custom", diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index 8446d79964..545b955f25 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -357,6 +357,17 @@ impl RouteResolver { .strip_prefix("opencode/") .or_else(|| logical_model.raw().strip_prefix("opencode-zen/")) .unwrap_or_else(|| logical_model.raw()) + } else if provider_kind == ProviderKind::Concentrate { + // Concentrate's own namespace is not part of its wire ids. + // `concentrate/auto` is the explicit spelling for the gateway's + // `auto` router — Codewhale's bare `auto` is the resolver sentinel + // above, never a literal model id — and `concentrate/` is + // ``. Upstream prefixes (`openai/gpt-5.6-sol`) stay verbatim: + // they pin the upstream provider inside the gateway. + logical_model + .raw() + .strip_prefix("concentrate/") + .unwrap_or_else(|| logical_model.raw()) } else { provider_scoped_wire_alias(provider_kind, logical_model.raw(), class) }; diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index e251ee7bdc..0bb2703a9d 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -60,6 +60,42 @@ fn models_dev_route_resolver() -> RouteResolver { RouteResolver::from_offerings(offerings) } +/// Concentrate ids pass through verbatim (plain catalog id, upstream +/// `provider/model` pin) and only the gateway's own `concentrate/` namespace +/// is stripped, so `concentrate/auto` reaches the gateway's `auto` router +/// while Codewhale's bare `auto` stays the resolver sentinel (provider +/// default). Every selector rides the Responses protocol. +#[test] +fn concentrate_passes_ids_through_and_strips_only_its_own_namespace() { + let r = models_dev_route_resolver(); + for (raw, wire) in [ + ("gpt-5.6-sol", "gpt-5.6-sol"), + ("openai/gpt-5.6-sol", "openai/gpt-5.6-sol"), + ("anthropic/claude-fable-5", "anthropic/claude-fable-5"), + ("concentrate/auto", "auto"), + ("concentrate/deepseek-v4-pro", "deepseek-v4-pro"), + ] { + let out = r + .resolve(&req(Some(ProviderKind::Concentrate), Some(raw))) + .unwrap_or_else(|e| panic!("{raw} should resolve on concentrate: {e}")); + assert_eq!(out.provider_kind(), ProviderKind::Concentrate, "{raw}"); + assert_eq!(out.wire_model_id().as_str(), wire, "{raw} wire id"); + assert_eq!( + out.protocol(), + RequestProtocol::Responses, + "{raw} must ride the Responses wire" + ); + } + + // Codewhale's own `auto` sentinel resolves to the provider default, never + // to a literal "auto" wire id. + let out = r + .resolve(&req(Some(ProviderKind::Concentrate), Some("auto"))) + .expect("auto resolves"); + assert_eq!(out.wire_model_id().as_str(), "deepseek-v4-pro"); + assert_eq!(out.protocol(), RequestProtocol::Responses); +} + #[test] fn provider_id_from_kind_uses_canonical_id() { assert_eq!( @@ -308,7 +344,9 @@ fn descriptor_protocol_matches_provider_wire() { "{kind:?} protocol must equal the provider wire policy" ); let expected = match kind { - ProviderKind::OpenaiCodex => Some(RequestProtocol::Responses), + ProviderKind::OpenaiCodex | ProviderKind::Concentrate => { + Some(RequestProtocol::Responses) + } ProviderKind::DeepseekAnthropic | ProviderKind::Anthropic | ProviderKind::MinimaxAnthropic diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 234e02da5f..94bf88613d 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -1271,6 +1271,9 @@ struct EnvGuard { edenai_api_key: Option, edenai_base_url: Option, edenai_model: Option, + concentrate_api_key: Option, + concentrate_base_url: Option, + concentrate_model: Option, opencode_go_api_key: Option, opencode_go_base_url: Option, opencode_go_model: Option, @@ -1313,6 +1316,9 @@ impl EnvGuard { edenai_api_key: env::var_os("EDENAI_API_KEY"), edenai_base_url: env::var_os("EDENAI_BASE_URL"), edenai_model: env::var_os("EDENAI_MODEL"), + concentrate_api_key: env::var_os("CONCENTRATE_API_KEY"), + concentrate_base_url: env::var_os("CONCENTRATE_BASE_URL"), + concentrate_model: env::var_os("CONCENTRATE_MODEL"), opencode_go_api_key: env::var_os("OPENCODE_GO_API_KEY"), opencode_go_base_url: env::var_os("OPENCODE_GO_BASE_URL"), opencode_go_model: env::var_os("OPENCODE_GO_MODEL"), @@ -1612,6 +1618,9 @@ impl Drop for EnvGuard { Self::restore_var("EDENAI_API_KEY", self.edenai_api_key.take()); Self::restore_var("EDENAI_BASE_URL", self.edenai_base_url.take()); Self::restore_var("EDENAI_MODEL", self.edenai_model.take()); + Self::restore_var("CONCENTRATE_API_KEY", self.concentrate_api_key.take()); + Self::restore_var("CONCENTRATE_BASE_URL", self.concentrate_base_url.take()); + Self::restore_var("CONCENTRATE_MODEL", self.concentrate_model.take()); Self::restore_var("OPENCODE_GO_API_KEY", self.opencode_go_api_key.take()); Self::restore_var("OPENCODE_GO_BASE_URL", self.opencode_go_base_url.take()); Self::restore_var("OPENCODE_GO_MODEL", self.opencode_go_model.take()); @@ -4928,6 +4937,145 @@ model = "anthropic/claude-sonnet-4-5" assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); } +/// Concentrate is an opt-in, BYOK, Responses-wire gateway: aliases collapse +/// onto one catalog identity, the metadata names the official base URL and a +/// distinct `CONCENTRATE_API_KEY` slot, the wire policy is fixed on the +/// Responses API, and env/config overrides resolve exactly like every other +/// provider table. +#[test] +fn concentrate_resolves_named_responses_gateway_and_environment_overrides() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + for alias in [ + "concentrate", + "concentrate-ai", + "concentrate_ai", + "concentrateai", + ] { + assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Concentrate)); + let parsed: ConfigToml = + toml::from_str(&format!("provider = \"{alias}\"")).expect("Concentrate alias"); + assert_eq!(parsed.provider, ProviderKind::Concentrate); + } + + let metadata = provider::resolve_provider("concentrate-ai").expect("Concentrate metadata"); + assert_eq!(metadata.id(), "concentrate"); + assert_eq!(metadata.display_name(), "Concentrate"); + assert_eq!(metadata.provider_config_key(), "concentrate"); + assert_eq!(metadata.default_base_url(), "https://api.concentrate.ai/v1"); + assert_eq!(metadata.default_base_url(), DEFAULT_CONCENTRATE_BASE_URL); + assert_eq!(metadata.default_model(), DEFAULT_CONCENTRATE_MODEL); + assert_eq!(metadata.env_vars(), &["CONCENTRATE_API_KEY"]); + assert_eq!( + metadata.wire_policy(), + provider::WirePolicy::Fixed(provider::WireFormat::Responses) + ); + assert_eq!( + ProviderKind::Concentrate.secret_store_slot(), + "concentrate", + "the gateway key never shares a slot with another provider" + ); + let help = metadata.credential_help(); + assert_eq!(help.acquisition, provider::CredentialAcquisition::ApiKey); + assert_eq!( + help.docs_url, + Some("https://concentrate.ai/docs/api-reference/introduction") + ); + + let config: ConfigToml = toml::from_str( + r#" +provider = "concentrate" + +[providers.concentrate] +api_key = "concentrate-config-key" +model = "openai/gpt-5.6-sol" +"#, + ) + .expect("Concentrate provider table"); + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.provider, ProviderKind::Concentrate); + assert_eq!(resolved.base_url, DEFAULT_CONCENTRATE_BASE_URL); + let alias_table: ConfigToml = toml::from_str( + r#" +provider = "concentrate" + +[providers.concentrateai] +api_key = "concentrate-alias-table-key" +model = "openai/gpt-5.6-sol" +"#, + ) + .expect("[providers.concentrateai] must deserialize onto the concentrate table"); + let alias_resolved = alias_table.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(alias_resolved.provider, ProviderKind::Concentrate); + assert_eq!( + alias_resolved.api_key.as_deref(), + Some("concentrate-alias-table-key"), + "[providers.concentrateai] must not be silently ignored" + ); + assert_eq!( + resolved.model, "openai/gpt-5.6-sol", + "provider/model ids pass through verbatim" + ); + assert_eq!(resolved.api_key.as_deref(), Some("concentrate-config-key")); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); + + unsafe { + std::env::set_var("CONCENTRATE_API_KEY", "concentrate-env-key"); + std::env::set_var("CONCENTRATE_MODEL", "claude-fable-5"); + } + let env_config = ConfigToml { + provider: ProviderKind::Concentrate, + ..ConfigToml::default() + }; + let resolved = env_config.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.base_url, DEFAULT_CONCENTRATE_BASE_URL); + assert_eq!(resolved.model, "claude-fable-5"); + assert_eq!(resolved.api_key.as_deref(), Some("concentrate-env-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); + + // Credential scope: a saved or environment Concentrate key is bound to the + // official gateway. Pointing the same identity at any other base URL is + // an arbitrary endpoint, and the key must NOT follow it (a local stub or + // proxy gets the key only when the user writes both into the config). + unsafe { + std::env::set_var("CONCENTRATE_BASE_URL", "http://127.0.0.1:8790/v1"); + } + let resolved = env_config.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.base_url, "http://127.0.0.1:8790/v1"); + assert_eq!( + resolved.api_key, None, + "an env key must never be sent to a non-official Concentrate base URL" + ); + unsafe { + std::env::remove_var("CONCENTRATE_BASE_URL"); + std::env::remove_var("CONCENTRATE_MODEL"); + std::env::remove_var("CONCENTRATE_API_KEY"); + } + let custom: ConfigToml = toml::from_str( + r#" +provider = "concentrate" + +[providers.concentrate] +base_url = "http://127.0.0.1:8790/v1" +api_key = "stub-key" +model = "concentrate/auto" +"#, + ) + .expect("Concentrate custom endpoint table"); + let resolved = custom.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.base_url, "http://127.0.0.1:8790/v1"); + assert_eq!(resolved.api_key.as_deref(), Some("stub-key")); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); + assert_eq!(resolved.model, "concentrate/auto"); +} + #[test] fn opencode_zen_configures_model_aware_provider_with_catalog_proof() { let _lock = env_lock(); @@ -5029,9 +5177,9 @@ fn meta_model_api_scopes_both_documented_key_names_to_official_endpoint() { fn provider_metadata_registry_covers_every_provider_kind_once() { let providers = provider::all_providers(); // Full registry keeps legacy dialect/plan kinds for provider_for_kind. - assert_eq!(providers.len(), 47); + assert_eq!(providers.len(), 48); // Catalog surface is one identity per vendor (no dual-wire / plan rows). - assert_eq!(ProviderKind::ALL.len(), 42); + assert_eq!(ProviderKind::ALL.len(), 43); assert!(ProviderKind::ALL.len() < providers.len()); let mut ids = std::collections::BTreeSet::new(); @@ -5138,7 +5286,9 @@ fn provider_metadata_defaults_match_runtime_helpers() { // other built-in provider is OpenAI-compatible Chat Completions. let expected_wire = match kind { ProviderKind::Deepseek | ProviderKind::OpencodeZen => None, - ProviderKind::OpenaiCodex => Some(provider::WireFormat::Responses), + ProviderKind::OpenaiCodex | ProviderKind::Concentrate => { + Some(provider::WireFormat::Responses) + } ProviderKind::Anthropic | ProviderKind::DeepseekAnthropic | ProviderKind::MinimaxAnthropic diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 0060226160..5edfd9c8e9 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -1173,6 +1173,7 @@ impl Secrets { /// | `xai` / `grok` | `XAI_API_KEY` | /// | `telecomjs` / `tokenhub` | `TELECOMJS_API_KEY` | /// | `edenai` / `eden-ai` | `EDENAI_API_KEY` | +/// | `concentrate` / `concentrate-ai` | `CONCENTRATE_API_KEY` | /// /// Returns `None` if the provider is not recognised or none of its /// candidate environment variables are set to a non-empty value. @@ -1225,6 +1226,9 @@ pub fn env_for(name: &str) -> Option { &["TELECOMJS_API_KEY"] } "edenai" | "eden-ai" | "eden_ai" => &["EDENAI_API_KEY"], + "concentrate" | "concentrate-ai" | "concentrate_ai" | "concentrateai" => { + &["CONCENTRATE_API_KEY"] + } "daytona" => &[DAYTONA_API_KEY_ENV, CWC_DAYTONA_TOKEN_ENV], // One Alibaba Cloud Model Studio account authenticates every plan / // dialect variant; all four names share one env convention. @@ -1328,6 +1332,7 @@ mod tests { "XAI_API_KEY", "TELECOMJS_API_KEY", "EDENAI_API_KEY", + "CONCENTRATE_API_KEY", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY", DAYTONA_API_KEY_ENV, @@ -1935,6 +1940,32 @@ mod tests { clear_known_envs(); } + #[test] + fn concentrate_env_aliases_resolve() { + let _guard = env_lock(); + clear_known_envs(); + unsafe { std::env::set_var("CONCENTRATE_API_KEY", "concentrate-key") }; + + for alias in [ + "concentrate", + "concentrate-ai", + "concentrate_ai", + "concentrateai", + ] { + assert_eq!( + env_for(alias).as_deref(), + Some("concentrate-key"), + "{alias}" + ); + } + // The gateway key is its own slot: no other provider's env name feeds it + // and it feeds no other provider. + assert_eq!(env_for("edenai"), None); + assert_eq!(env_for("openrouter"), None); + + clear_known_envs(); + } + #[test] fn opencode_go_env_aliases_resolve() { let _guard = env_lock(); diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 75843efd8e..e66f7235eb 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1804,7 +1804,14 @@ fn wire_config_prefers_responses(wire: Option<&str>) -> bool { } fn api_provider_skips_models_probe(api_provider: ApiProvider) -> bool { - matches!(api_provider, ApiProvider::DeepseekAnthropic) + // Concentrate's `GET /v1/models` is explicitly unauthenticated + // (docs/PROVIDERS.md): a 2xx proves nothing about the key, so guided + // setup must not count it as verification — the key is observed on the + // first authenticated call instead. + matches!( + api_provider, + ApiProvider::DeepseekAnthropic | ApiProvider::Concentrate + ) } #[must_use] @@ -2530,6 +2537,17 @@ impl DeepSeekClient { &fingerprint, fetched_at, )? + } else if provider == "concentrate" { + // Concentrate's unauthenticated `GET /v1/models` is the same + // OpenAI list shape (`{"object":"list","data":[{"id":..}]}`); + // rows stay unclaimed unless a same-provider bundled row exists. + named_gateway_catalog_offerings_from_body( + &body, + codewhale_config::ProviderKind::Concentrate, + &provider, + &fingerprint, + fetched_at, + )? } else { let models = apply_provider_model_cutline( self.api_provider, @@ -2614,7 +2632,10 @@ impl DeepSeekClient { let provider = config.api_provider(); // Only refresh for providers that serve their own model list and are // not already covered by the Models.dev catalog. - if !matches!(provider, ApiProvider::Telecomjs | ApiProvider::Edenai) { + if !matches!( + provider, + ApiProvider::Telecomjs | ApiProvider::Edenai | ApiProvider::Concentrate + ) { return; } @@ -3600,6 +3621,9 @@ pub(super) fn apply_reasoning_effort( // This gateway can route unrelated model families, so the generic // provider must not inject a model-specific reasoning dialect. ApiProvider::Edenai => {} + // Concentrate rides the Responses wire (`reasoning.effort`), never + // these Chat Completions controls. + ApiProvider::Concentrate => {} // Model Studio (DashScope): its top-level controls are route- AND // model-specific, so the provider enum alone cannot decide them — // a custom `base_url` on the same identity is an arbitrary @@ -3691,6 +3715,9 @@ pub(super) fn apply_reasoning_effort( // Chat Completions API does not support reasoning_effort or thinking. ApiProvider::Telecomjs => {} ApiProvider::Edenai => {} + // Concentrate rides the Responses wire (`reasoning.effort`), never + // these Chat Completions controls. + ApiProvider::Concentrate => {} // Model Studio: see the "off" branch — the route- and model-aware // shaper in client::chat is the sole writer of these fields. ApiProvider::ModelstudioTokenPlan @@ -3808,6 +3835,9 @@ pub(super) fn apply_reasoning_effort( // Chat Completions API does not support reasoning_effort or thinking. ApiProvider::Telecomjs => {} ApiProvider::Edenai => {} + // Concentrate rides the Responses wire (`reasoning.effort`), never + // these Chat Completions controls. + ApiProvider::Concentrate => {} // Model Studio: see the "off" branch — the route- and model-aware // shaper in client::chat is the sole writer of these fields. ApiProvider::ModelstudioTokenPlan @@ -4201,8 +4231,8 @@ mod tests { }; use crate::client::responses::build_responses_body; use crate::config::{ - DEFAULT_EDENAI_MODEL, DEFAULT_TELECOMJS_MODEL, OPENROUTER_QWEN_3_6_FLASH_MODEL, - ProviderConfig, ProvidersConfig, + DEFAULT_CONCENTRATE_BASE_URL, DEFAULT_CONCENTRATE_MODEL, DEFAULT_EDENAI_MODEL, + DEFAULT_TELECOMJS_MODEL, OPENROUTER_QWEN_3_6_FLASH_MODEL, ProviderConfig, ProvidersConfig, }; use crate::models::{ ContentBlock, ContentBlockStart, Delta, Message, MessageRequest, MessageResponse, @@ -6315,6 +6345,276 @@ mod tests { assert_kimi_code_captures_exact_general_child_catalog().await; } + fn concentrate_client(server: &MockServer, model: &str) -> DeepSeekClient { + let _ = rustls::crypto::ring::default_provider().install_default(); + let config = Config { + provider: Some("concentrate".to_string()), + providers: Some(ProvidersConfig { + concentrate: ProviderConfig { + api_key: Some("concentrate-test-key".to_string()), + base_url: Some(format!("{}/v1", server.uri())), + model: Some(model.to_string()), + ..ProviderConfig::default() + }, + ..ProvidersConfig::default() + }), + ..Config::default() + }; + DeepSeekClient::new(&config).expect("Concentrate client should resolve its route") + } + + /// The documented Concentrate stream: `event:`-typed `response.*` frames + /// with sequence numbers and NO `data: [DONE]` sentinel. + /// https://concentrate.ai/docs/api-reference/endpoint/streaming + fn concentrate_sse_fixture(model: &str) -> String { + let completed = json!({ + "id": "resp_1", + "object": "response", + "status": "completed", + "model": model, + "output": [{ + "type": "message", "id": "msg_1", "status": "completed", "role": "assistant", + "content": [{"type": "output_text", "text": "ok from stub", "annotations": []}] + }], + "usage": {"input_tokens": 12, "output_tokens": 5, "total_tokens": 17, + "input_tokens_details": {"cached_tokens": 0}} + }); + let frame = |event: &str, payload: Value| format!("event: {event}\ndata: {}\n\n", payload); + [ + frame("response.created", json!({"type": "response.created", "sequence_number": 0, "response": {"id": "resp_1", "status": "in_progress"}})), + frame("response.output_item.added", json!({"type": "response.output_item.added", "sequence_number": 1, "output_index": 0, "item": {"type": "message", "id": "msg_1", "status": "in_progress", "role": "assistant", "content": []}})), + frame("response.content_part.added", json!({"type": "response.content_part.added", "sequence_number": 2, "item_id": "msg_1", "output_index": 0, "content_index": 0, "part": {"type": "output_text", "text": ""}})), + frame("response.output_text.delta", json!({"type": "response.output_text.delta", "sequence_number": 3, "item_id": "msg_1", "output_index": 0, "content_index": 0, "delta": "ok "})), + frame("response.output_text.delta", json!({"type": "response.output_text.delta", "sequence_number": 4, "item_id": "msg_1", "output_index": 0, "content_index": 0, "delta": "from stub"})), + frame("response.output_text.done", json!({"type": "response.output_text.done", "sequence_number": 5, "item_id": "msg_1", "output_index": 0, "content_index": 0, "text": "ok from stub"})), + frame("response.output_item.done", json!({"type": "response.output_item.done", "sequence_number": 6, "output_index": 0, "item": completed["output"][0].clone()})), + frame("response.completed", json!({"type": "response.completed", "sequence_number": 7, "response": completed})), + ] + .concat() + } + + /// Request URL, bearer header, verbatim model, documented-fields body, and + /// the typed SSE stream (no `[DONE]`) — the whole Concentrate contract on + /// a loopback wiremock, never the live gateway. + #[tokio::test] + async fn concentrate_responses_request_matches_the_documented_contract() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .and(header("authorization", "Bearer concentrate-test-key")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Content-Type", "text/event-stream") + .set_body_string(concentrate_sse_fixture("openai/gpt-5.6-sol")), + ) + .expect(1) + .mount(&server) + .await; + + let client = concentrate_client(&server, "openai/gpt-5.6-sol"); + assert_eq!(client.wire_format, WireFormat::Responses); + assert_eq!(client.api_provider, ApiProvider::Concentrate); + assert_eq!( + responses_api_url(DEFAULT_CONCENTRATE_BASE_URL, ApiProvider::Concentrate), + "https://api.concentrate.ai/v1/responses", + "the official base URL maps to the documented Responses endpoint" + ); + + let mut stream = client + .create_message_stream(minimal_zen_request("openai/gpt-5.6-sol")) + .await + .expect("Concentrate Responses request should start"); + let mut text = String::new(); + let mut usage = None; + let mut stopped = false; + while let Some(event) = stream.next().await { + match event.expect("Concentrate stream event") { + StreamEvent::ContentBlockDelta { + delta: Delta::TextDelta { text: piece }, + .. + } => text.push_str(&piece), + StreamEvent::MessageDelta { usage: Some(u), .. } => usage = Some(u), + StreamEvent::MessageStop => stopped = true, + _ => {} + } + } + assert_eq!(text, "ok from stub", "typed deltas assemble the reply"); + let usage = usage.expect("response.completed carries usage"); + assert_eq!((usage.input_tokens, usage.output_tokens), (12, 5)); + assert!( + stopped, + "the stream ends on response.completed without a [DONE] sentinel" + ); + + let requests = server.received_requests().await.expect("recorded request"); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_eq!(request.url.path(), "/v1/responses"); + for forbidden in [ + "openai-beta", + "originator", + "chatgpt-account-id", + "x-api-key", + ] { + assert!( + request.headers.get(forbidden).is_none(), + "Concentrate request must not include {forbidden}" + ); + } + let body: Value = serde_json::from_slice(&request.body).expect("Responses JSON body"); + assert_eq!( + body["model"], "openai/gpt-5.6-sol", + "model id verbatim: {body}" + ); + assert_eq!(body["stream"], true); + assert!( + body.get("messages").is_none(), + "Responses body, not Chat: {body}" + ); + for undocumented in [ + "store", + "include", + "instructions", + "metadata", + "previous_response_id", + ] { + assert!( + body.get(undocumented).is_none(), + "undocumented field {undocumented} on the wire: {body}" + ); + } + assert_eq!( + body["input"][0]["role"], "system", + "system prompt rides as a system input item: {body}" + ); + } + + /// The documented error body surfaces verbatim and classifies by message: + /// 401 → authentication, 402 → quota (insufficient credits). + /// https://concentrate.ai/docs/api-reference/endpoint/errors + #[tokio::test] + async fn concentrate_error_bodies_surface_verbatim_and_classify() { + for (status, body, expected, needle) in [ + ( + 401, + r#"{"error":"Unauthorized","message":"Invalid API key"}"#, + crate::error_taxonomy::ErrorCategory::Authentication, + "Invalid API key", + ), + ( + 402, + r#"{"error":"Insufficient funds","message":"Your account has insufficient credits. Please add credits to continue."}"#, + crate::error_taxonomy::ErrorCategory::RateLimit, + "insufficient credits", + ), + ( + 400, + r#"{"error":"Bad Request","message":"Invalid model name: 'invalid-model-xyz'"}"#, + crate::error_taxonomy::ErrorCategory::InvalidInput, + "Invalid model name", + ), + ] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(ResponseTemplate::new(status).set_body_string(body)) + .mount(&server) + .await; + let client = concentrate_client(&server, "gpt-5.6-sol"); + let error = match client + .create_message_stream(minimal_zen_request("gpt-5.6-sol")) + .await + { + Ok(mut stream) => { + let mut failure = None; + while let Some(event) = stream.next().await { + if let Err(err) = event { + failure = Some(err); + break; + } + } + failure.expect("HTTP {status} must fail the stream") + } + Err(err) => err, + }; + let message = format!("{error:#}"); + assert!( + message.contains(needle), + "HTTP {status}: message must carry the documented text, got: {message}" + ); + assert_eq!( + crate::error_taxonomy::classify_error_message(&message), + expected, + "HTTP {status}: {message}" + ); + } + } + + /// Concentrate's `GET /v1/models` is unauthenticated, so a 2xx must not + /// count as key verification. Guided setup treats the probe as unobserved; + /// health_check must not issue the request either. + #[tokio::test] + async fn concentrate_health_check_does_not_treat_unauthenticated_models_as_key_proof() { + let server = MockServer::start().await; + let client = concentrate_client(&server, DEFAULT_CONCENTRATE_MODEL); + + assert!(client.health_check().await.expect("health check")); + assert!(!provider_api_key_verification_is_observed( + ApiProvider::Concentrate + )); + let requests = server.received_requests().await.expect("recorded requests"); + assert!( + requests.is_empty(), + "Concentrate must not treat unauthenticated GET /v1/models as key verification" + ); + } + + /// `GET /v1/models` needs no key and answers the OpenAI list shape; rows + /// are provider-scoped, the default is marked, and unknowns stay unclaimed. + /// https://concentrate.ai/docs/api-reference/endpoint/list-models + #[tokio::test] + async fn concentrate_live_catalog_is_provider_scoped_and_marks_the_default() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "object": "list", + "data": [ + {"id": "claude-fable-5", "object": "model", "owned_by": "anthropic"}, + {"id": DEFAULT_CONCENTRATE_MODEL, "object": "model", "owned_by": "deepseek"}, + {"id": "gpt-5.6-sol", "object": "model", "owned_by": "openai"} + ] + }))) + .expect(1) + .mount(&server) + .await; + + let delta = concentrate_client(&server, DEFAULT_CONCENTRATE_MODEL) + .fetch_catalog_delta() + .await + .expect("Concentrate catalog delta"); + assert_eq!(delta.provider, "concentrate"); + assert_eq!(delta.offerings.len(), 3); + let default = delta + .offerings + .iter() + .find(|offering| offering.wire_model_id == DEFAULT_CONCENTRATE_MODEL) + .expect("default row"); + assert!(default.default_for_provider); + let unknown = delta + .offerings + .iter() + .find(|offering| offering.wire_model_id == "claude-fable-5") + .expect("unclaimed row"); + assert!(!unknown.default_for_provider); + assert_eq!(unknown.canonical_model, None); + assert_eq!( + unknown.cost, None, + "no pricing claim from a gateway catalog" + ); + assert!(matches!(unknown.source, CatalogSource::Live { .. })); + } + fn opencode_zen_client(server: &MockServer, model: &str) -> DeepSeekClient { let config = Config { provider: Some("opencode-zen".to_string()), diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 2b50488943..e3a1047151 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -46,12 +46,20 @@ pub(super) fn build_responses_body_for_provider( provider: ApiProvider, ) -> Value { let is_deepseek = matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN); + // Concentrate documents `model`, `input`, `stream`, `max_output_tokens`, + // `tools` / `tool_choice` / `parallel_tool_calls`, and `reasoning.effort`; + // `store`, `include`, `instructions`, and `reasoning.summary` are absent + // from its parameter reference, so this route sends only documented + // fields and carries the system prompt as a leading `system` message item + // (a documented input role) instead of `instructions`. + // https://concentrate.ai/docs/api-reference/endpoint/request-parameters + let is_concentrate = provider == ApiProvider::Concentrate; let model = &request.model; let mut body = json!({ "model": model, "stream": true, }); - if !is_deepseek { + if !is_deepseek && !is_concentrate { body["store"] = json!(false); } // Every Responses route receives the same resolved request envelope as @@ -80,10 +88,21 @@ pub(super) fn build_responses_body_for_provider( let instructions = system_to_instructions(request.system.clone()) .filter(|text| !text.trim().is_empty()) .unwrap_or_else(|| "You are a helpful assistant.".to_string()); - body["instructions"] = json!(instructions); // Convert messages to Responses input items. - let input = convert_messages_to_responses_input(request, provider); + let mut input = convert_messages_to_responses_input(request, provider); + if is_concentrate { + input.insert( + 0, + json!({ + "type": "message", + "role": "system", + "content": [{ "type": "input_text", "text": instructions }], + }), + ); + } else { + body["instructions"] = json!(instructions); + } body["input"] = json!(input); // Convert tools to Responses function tools. @@ -106,7 +125,7 @@ pub(super) fn build_responses_body_for_provider( if let Some(raw) = request.reasoning_effort.as_deref() && let Some(effort) = responses_reasoning_effort(raw, is_deepseek) { - body["reasoning"] = if is_deepseek { + body["reasoning"] = if is_deepseek || is_concentrate { json!({ "effort": effort }) } else { json!({ @@ -118,7 +137,7 @@ pub(super) fn build_responses_body_for_provider( // OpenAI Codex can replay encrypted reasoning. DeepSeek exposes plain // `reasoning_text` and does not support `include`. - if !is_deepseek { + if !is_deepseek && !is_concentrate { body["include"] = json!(["reasoning.encrypted_content"]); } diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index a6f2e374dc..652619abcb 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -8,6 +8,7 @@ use futures_util::StreamExt; use crate::config::{Config, ProviderConfig, ProvidersConfig, RetryConfig}; use crate::models::Message; use crate::models::Role; +use crate::models::SystemPrompt; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; @@ -537,6 +538,93 @@ fn codex_reasoning_effort_uses_responses_labels() { assert_eq!(codex_responses_reasoning_effort("off"), Some("low")); } +/// Concentrate's parameter reference documents `model`, `input`, `stream`, +/// `max_output_tokens`, `tools`/`tool_choice`/`parallel_tool_calls`, and +/// `reasoning.effort`; `store`, `include`, `instructions`, and +/// `reasoning.summary` are absent. The body sends only documented fields and +/// carries the system prompt as a leading `system` input item. +/// https://concentrate.ai/docs/api-reference/endpoint/request-parameters +#[test] +fn concentrate_responses_body_sends_only_documented_fields() { + let mut request = minimal_responses_request(); + request.model = "openai/gpt-5.6-sol".to_string(); + request.system = Some(SystemPrompt::Text( + "You are the Codewhale test system prompt.".to_string(), + )); + request.reasoning_effort = Some("high".to_string()); + request.tools = Some(vec![Tool { + tool_type: None, + name: "read".to_string(), + description: "Read a file".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]); + + let body = build_responses_body_for_provider(&request, ApiProvider::Concentrate); + let documented = [ + "model", + "input", + "max_output_tokens", + "temperature", + "top_p", + "stream", + "text", + "reasoning", + "tools", + "tool_choice", + "parallel_tool_calls", + "routing", + "cache_control", + "prompt_cache_options", + ]; + for key in body.as_object().expect("object body").keys() { + assert!( + documented.contains(&key.as_str()), + "undocumented top-level field `{key}` reached the Concentrate wire: {body}" + ); + } + assert_eq!( + body["model"], "openai/gpt-5.6-sol", + "provider/model ids pass through verbatim" + ); + assert_eq!(body["stream"], true); + assert!(body.get("store").is_none(), "{body}"); + assert!(body.get("include").is_none(), "{body}"); + assert!(body.get("instructions").is_none(), "{body}"); + let input = body["input"].as_array().expect("input array"); + assert_eq!(input[0]["type"], "message"); + assert_eq!(input[0]["role"], "system"); + assert_eq!(input[0]["content"][0]["type"], "input_text"); + assert_eq!( + input[0]["content"][0]["text"], + "You are the Codewhale test system prompt." + ); + assert_eq!(input[1]["role"], "user"); + assert_eq!(body["reasoning"], serde_json::json!({ "effort": "high" })); + assert_eq!(body["tools"][0]["type"], "function"); + assert_eq!(body["tools"][0]["name"], "read"); + assert_eq!(body["tools"][0]["strict"], false); + assert_eq!(body["tool_choice"], "auto"); + assert_eq!(body["parallel_tool_calls"], true); + + // The same request on the generic Responses path still carries the + // OpenAI-only fields, so the Concentrate branch is a deliberate subset. + let generic = build_responses_body_for_provider(&request, ApiProvider::Openai); + assert!( + generic.get("store").is_some() + && generic.get("include").is_some() + && generic.get("instructions").is_some() + ); +} + #[test] fn deepseek_flash_responses_body_uses_stateless_0731_contract() { let mut request = minimal_responses_request(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index c31e55a159..03783249ef 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -110,6 +110,8 @@ pub enum ApiProvider { Telecomjs, /// Eden AI — OpenAI-compatible AI gateway (aggregator). Edenai, + /// Concentrate — OpenAI Responses-compatible AI gateway (aggregator; BYOK only). + Concentrate, /// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible Chat Completions). ModelstudioTokenPlan, /// Alibaba Cloud Model Studio — Token Plan Anthropic-compatible endpoint. @@ -342,7 +344,7 @@ impl ApiProvider { /// `ApiProvider` discriminant → `ProviderKind` lookup. /// Index 1 is `None` for the legacy `DeepseekCN` variant. - const KIND_LOOKUP: [Option; 48] = [ + const KIND_LOOKUP: [Option; 49] = [ Some(codewhale_config::ProviderKind::Deepseek), None, // DeepseekCN Some(codewhale_config::ProviderKind::DeepseekAnthropic), @@ -386,6 +388,7 @@ impl ApiProvider { Some(codewhale_config::ProviderKind::Antigravity), Some(codewhale_config::ProviderKind::Telecomjs), Some(codewhale_config::ProviderKind::Edenai), + Some(codewhale_config::ProviderKind::Concentrate), Some(codewhale_config::ProviderKind::ModelstudioTokenPlan), Some(codewhale_config::ProviderKind::ModelstudioTokenPlanAnthropic), Some(codewhale_config::ProviderKind::ModelstudioCodingPlan), @@ -394,7 +397,7 @@ impl ApiProvider { ]; /// `ProviderKind` discriminant → `ApiProvider` lookup. - const FROM_KIND_LOOKUP: [Self; 47] = [ + const FROM_KIND_LOOKUP: [Self; 48] = [ Self::Deepseek, Self::DeepseekAnthropic, Self::NvidiaNim, @@ -441,6 +444,7 @@ impl ApiProvider { Self::Antigravity, Self::Google, Self::Edenai, + Self::Concentrate, Self::Custom, ]; @@ -509,6 +513,10 @@ fn subagent_provider_key_matches(key: &str, provider: ApiProvider) -> bool { ApiProvider::Openrouter => matches!(normalized.as_str(), "openrouter" | "open_router"), ApiProvider::Orcarouter => matches!(normalized.as_str(), "orcarouter" | "orca_router"), ApiProvider::Edenai => matches!(normalized.as_str(), "edenai" | "eden_ai"), + ApiProvider::Concentrate => matches!( + normalized.as_str(), + "concentrate" | "concentrate_ai" | "concentrateai" + ), ApiProvider::OpenaiCodex => matches!( normalized.as_str(), "openai_codex" | "codex" | "chatgpt" | "openai_chatgpt" @@ -1668,6 +1676,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati // advertised for the credential-import-only route. ApiProvider::Antigravity => Vec::new(), ApiProvider::Edenai => vec![DEFAULT_EDENAI_MODEL], + ApiProvider::Concentrate => vec![DEFAULT_CONCENTRATE_MODEL], // Custom endpoints expose no built-in completion names; the user // supplies their own model id (#1519). ApiProvider::Custom => Vec::new(), @@ -3955,6 +3964,14 @@ pub struct ProvidersConfig { /// Eden AI — OpenAI-compatible AI gateway (aggregator). #[serde(default, alias = "eden-ai", alias = "eden_ai")] pub edenai: ProviderConfig, + /// Concentrate — OpenAI Responses-compatible AI gateway (aggregator). + #[serde( + default, + alias = "concentrate-ai", + alias = "concentrate_ai", + alias = "concentrateai" + )] + pub concentrate: ProviderConfig, /// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible Chat Completions). #[serde(default, alias = "modelstudio-token-plan")] pub modelstudio_token_plan: ProviderConfig, @@ -5586,6 +5603,7 @@ impl Config { ApiProvider::Antigravity => &providers.antigravity, ApiProvider::Telecomjs => &providers.telecomjs, ApiProvider::Edenai => &providers.edenai, + ApiProvider::Concentrate => &providers.concentrate, ApiProvider::ModelstudioTokenPlan => &providers.modelstudio_token_plan, ApiProvider::ModelstudioTokenPlanAnthropic => { &providers.modelstudio_token_plan_anthropic @@ -5669,6 +5687,7 @@ impl Config { ApiProvider::Antigravity => &mut providers.antigravity, ApiProvider::Telecomjs => &mut providers.telecomjs, ApiProvider::Edenai => &mut providers.edenai, + ApiProvider::Concentrate => &mut providers.concentrate, ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan, ApiProvider::ModelstudioTokenPlanAnthropic => { &mut providers.modelstudio_token_plan_anthropic @@ -6035,6 +6054,7 @@ impl Config { ApiProvider::Antigravity => DEFAULT_ANTIGRAVITY_MODEL, ApiProvider::Telecomjs => DEFAULT_TELECOMJS_MODEL, ApiProvider::Edenai => DEFAULT_EDENAI_MODEL, + ApiProvider::Concentrate => DEFAULT_CONCENTRATE_MODEL, ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioTokenPlanAnthropic | ApiProvider::ModelstudioCodingPlan @@ -6158,6 +6178,7 @@ impl Config { | ApiProvider::Antigravity | ApiProvider::Telecomjs | ApiProvider::Edenai + | ApiProvider::Concentrate | ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioTokenPlanAnthropic | ApiProvider::ModelstudioCodingPlan @@ -6268,6 +6289,7 @@ impl Config { ApiProvider::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL, ApiProvider::Telecomjs => DEFAULT_TELECOMJS_BASE_URL, ApiProvider::Edenai => DEFAULT_EDENAI_BASE_URL, + ApiProvider::Concentrate => DEFAULT_CONCENTRATE_BASE_URL, ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioTokenPlanAnthropic | ApiProvider::ModelstudioCodingPlan @@ -7979,6 +8001,7 @@ fn provider_env_base_url_override(provider: ApiProvider) -> Option { ApiProvider::Antigravity => &["ANTIGRAVITY_BASE_URL"], ApiProvider::Telecomjs => &["TELECOMJS_BASE_URL"], ApiProvider::Edenai => &["EDENAI_BASE_URL"], + ApiProvider::Concentrate => &["CONCENTRATE_BASE_URL"], ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioTokenPlanAnthropic => { &["MODELSTUDIO_TOKEN_PLAN_BASE_URL"] } @@ -8359,6 +8382,13 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo .edenai .base_url = Some(value); } + ApiProvider::Concentrate => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .concentrate + .base_url = Some(value); + } ApiProvider::ModelstudioTokenPlan => { config .providers @@ -8616,6 +8646,9 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo .edenai .base_url = Some(value); } + // Concentrate has no inline block here on purpose: CONCENTRATE_BASE_URL + // is already served by `provider_env_base_url_override`, which the route + // resolver consults — a second inline assignment was a duplicate. if matches!( config.api_provider(), ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioTokenPlanAnthropic @@ -8728,6 +8761,7 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo ApiProvider::Antigravity => &mut providers.antigravity, ApiProvider::Telecomjs => &mut providers.telecomjs, ApiProvider::Edenai => &mut providers.edenai, + ApiProvider::Concentrate => &mut providers.concentrate, ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan, ApiProvider::ModelstudioTokenPlanAnthropic => { &mut providers.modelstudio_token_plan_anthropic @@ -8958,6 +8992,16 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo .telecomjs .model = Some(value); } + if matches!(config.api_provider(), ApiProvider::Concentrate) + && let Ok(value) = std::env::var("CONCENTRATE_MODEL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .concentrate + .model = Some(value); + } if matches!(config.api_provider(), ApiProvider::Edenai) && let Ok(value) = std::env::var("EDENAI_MODEL") && !value.trim().is_empty() @@ -9105,6 +9149,7 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo ApiProvider::Antigravity => &mut providers.antigravity, ApiProvider::Telecomjs => &mut providers.telecomjs, ApiProvider::Edenai => &mut providers.edenai, + ApiProvider::Concentrate => &mut providers.concentrate, ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan, ApiProvider::ModelstudioTokenPlanAnthropic => { &mut providers.modelstudio_token_plan_anthropic @@ -9433,6 +9478,9 @@ pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool { | ApiProvider::Xai | ApiProvider::Telecomjs | ApiProvider::Edenai + // Concentrate ids are gateway-owned (plain, `provider/model`, or the + // gateway's own `auto`); the resolver strips only `concentrate/`. + | ApiProvider::Concentrate | ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioTokenPlanAnthropic | ApiProvider::ModelstudioCodingPlan @@ -10633,6 +10681,7 @@ fn merge_providers( antigravity: merge_provider_config(base.antigravity, override_cfg.antigravity), telecomjs: merge_provider_config(base.telecomjs, override_cfg.telecomjs), edenai: merge_provider_config(base.edenai, override_cfg.edenai), + concentrate: merge_provider_config(base.concentrate, override_cfg.concentrate), modelstudio_token_plan: merge_provider_config( base.modelstudio_token_plan, override_cfg.modelstudio_token_plan, diff --git a/crates/tui/src/config/models.rs b/crates/tui/src/config/models.rs index d583573694..9ebc57b09d 100644 --- a/crates/tui/src/config/models.rs +++ b/crates/tui/src/config/models.rs @@ -255,6 +255,10 @@ pub const DEFAULT_TELECOMJS_MODEL: &str = "deepseek-v4-pro"; pub const DEFAULT_TELECOMJS_BASE_URL: &str = "https://aigw.telecomjs.com/v1"; pub const DEFAULT_EDENAI_MODEL: &str = "deepseek/deepseek-v4-pro"; pub const DEFAULT_EDENAI_BASE_URL: &str = "https://api.edenai.run/v3"; +// Concentrate (OpenAI Responses-compatible AI gateway) defaults; mirrors +// crates/config/src/provider_defaults.rs for the docs drift check. +pub const DEFAULT_CONCENTRATE_MODEL: &str = "deepseek-v4-pro"; +pub const DEFAULT_CONCENTRATE_BASE_URL: &str = "https://api.concentrate.ai/v1"; // Alibaba Cloud Model Studio (DashScope) defaults pub const DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL: &str = "qwen3.8-max"; pub const DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL: &str = diff --git a/crates/tui/src/config_persistence.rs b/crates/tui/src/config_persistence.rs index 9f959f5cdf..b651fbe080 100644 --- a/crates/tui/src/config_persistence.rs +++ b/crates/tui/src/config_persistence.rs @@ -329,6 +329,7 @@ fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static ApiProvider::Antigravity => Ok("antigravity"), ApiProvider::Telecomjs => Ok("telecomjs"), ApiProvider::Edenai => Ok("edenai"), + ApiProvider::Concentrate => Ok("concentrate"), ApiProvider::ModelstudioTokenPlan => Ok("modelstudio_token_plan"), ApiProvider::ModelstudioTokenPlanAnthropic => Ok("modelstudio_token_plan_anthropic"), ApiProvider::ModelstudioCodingPlan => Ok("modelstudio_coding_plan"), diff --git a/crates/tui/src/error_taxonomy.rs b/crates/tui/src/error_taxonomy.rs index bc9d4b4f59..9eb7100d7d 100644 --- a/crates/tui/src/error_taxonomy.rs +++ b/crates/tui/src/error_taxonomy.rs @@ -367,6 +367,13 @@ pub fn classify_error_message(message: &str) -> ErrorCategory { || lower.contains("429") || lower.contains("quota") || lower.contains("usage limit") + // Prepaid gateways answer an exhausted balance with HTTP 402; that is + // a quota condition the operator resolves by topping up, not an input + // or authentication fault (Concentrate: "Insufficient funds"). + || lower.contains("insufficient credits") + || lower.contains("insufficient funds") + || lower.contains("payment required") + || lower.contains("http 402") { return ErrorCategory::RateLimit; } @@ -590,6 +597,29 @@ mod tests { } } + /// A prepaid gateway's exhausted balance (HTTP 402) is a quota condition: + /// the request was well-formed and the key was valid. + #[test] + fn insufficient_credits_classifies_as_rate_limit_not_auth() { + for msg in [ + "Responses API error (HTTP 402 Payment Required): {\"error\":\"Insufficient funds\",\"message\":\"Your account has insufficient credits. Please add credits to continue.\"}", + "insufficient credits", + ] { + assert_eq!( + classify(msg), + ErrorCategory::RateLimit, + "expected RateLimit for `{msg}`" + ); + } + // The documented 401 body still classifies as authentication. + assert_eq!( + classify( + "Responses API error (HTTP 401 Unauthorized): {\"error\":\"Unauthorized\",\"message\":\"Invalid API key\"}" + ), + ErrorCategory::Authentication + ); + } + #[test] fn timeout_catches_both_spellings() { assert_eq!(classify("connection timeout"), ErrorCategory::Timeout); diff --git a/crates/tui/src/llm_client/mod.rs b/crates/tui/src/llm_client/mod.rs index a86adcdc18..1beb7f71da 100644 --- a/crates/tui/src/llm_client/mod.rs +++ b/crates/tui/src/llm_client/mod.rs @@ -790,6 +790,19 @@ fn has_explicit_quota_phrase(body: &str) -> bool { fn extract_json_error_message(body: &str) -> Option { let value: Value = serde_json::from_str(body).ok()?; + // Flat gateway bodies (`{"error":"Bad Request","message":"Invalid model + // name: 'x'"}` — Concentrate, among others) keep the class in `error` and + // the detail in `message`. Surfacing only the class hid the one line the + // person needed, so carry both when both are present and differ. + if let (Some(class), Some(detail)) = ( + value.pointer("/error").and_then(Value::as_str), + value.pointer("/message").and_then(Value::as_str), + ) && !class.trim().is_empty() + && !detail.trim().is_empty() + && !class.trim().eq_ignore_ascii_case(detail.trim()) + { + return Some(format!("{}: {}", class.trim(), detail.trim())); + } for pointer in [ "/error/message", "/error", @@ -1534,6 +1547,35 @@ mod tests { assert_eq!(auth_user_message(err), "Invalid API Key"); } + /// A flat `{"error","message"}` body keeps its detail: the class alone + /// ("Bad Request") told the person nothing about the rejected model. + #[test] + fn flat_error_and_message_body_surfaces_both_halves() { + let body = r#"{"error":"Bad Request","message":"Invalid model name: 'invalid-model-xyz'"}"#; + assert_eq!( + sanitize_http_error_body(Some("Concentrate"), 400, body), + "Bad Request: Invalid model name: 'invalid-model-xyz'" + ); + // Identical halves are not doubled, and the nested OpenAI shape is + // untouched. + assert_eq!( + sanitize_http_error_body( + Some("Concentrate"), + 401, + r#"{"error":"Unauthorized","message":"Unauthorized"}"# + ), + "Unauthorized" + ); + assert_eq!( + sanitize_http_error_body( + Some("fixture"), + 400, + r#"{"error":{"message":"nested detail"}}"# + ), + "nested detail" + ); + } + #[test] fn cloudflare_html_error_is_summarized_without_raw_markup() { let body = r#"Access Denied