Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
25 changes: 25 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Missing concentrateai alias on Concentrate config table serde attributes

ProviderKind::Concentrate, the provider aliases() list, and secrets::env_for all accept concentrateai as an alias, but the ProvidersToml.concentrate field in crates/config/src/lib.rs and the ProvidersConfig.concentrate field in crates/tui/src/config.rs only declare concentrate-ai and concentrate_ai. A user selecting provider = "concentrateai" and placing credentials under [providers.concentrateai] will have that table silently ignored (serde default ignores unknown fields), leaving the provider unconfigured. Add the missing alias to both serde attributes and add a regression test that parses [providers.concentrateai].

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f09427: concentrateai serde alias is on both ProvidersToml.concentrate and ProvidersConfig.concentrate. [providers.concentrateai] now deserializes onto the concentrate table — pinned in concentrate_resolves_named_responses_gateway_and_environment_overrides (1 passed; 0 failed).

alias = "concentrate_ai",
alias = "concentrateai"
)]
pub concentrate: ProviderConfigToml,
/// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible endpoint).
#[serde(
default,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -6915,6 +6930,8 @@ struct EnvRuntimeOverrides {
telecomjs_model: Option<String>,
edenai_base_url: Option<String>,
edenai_model: Option<String>,
concentrate_base_url: Option<String>,
concentrate_model: Option<String>,
modelstudio_token_plan_base_url: Option<String>,
modelstudio_token_plan_model: Option<String>,
modelstudio_coding_plan_base_url: Option<String>,
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down
77 changes: 68 additions & 9 deletions crates/config/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
/// <https://concentrate.ai/docs/api-reference/introduction>. 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
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -1753,6 +1811,7 @@ static PROVIDER_REGISTRY: [&dyn Provider; 47] = [
&MISTRAL,
&TELECOMJS,
&EDENAI,
&CONCENTRATE,
&MODELSTUDIO_TOKEN_PLAN,
&MODELSTUDIO_TOKEN_PLAN_ANTHROPIC,
&MODELSTUDIO_CODING_PLAN,
Expand Down
7 changes: 7 additions & 0 deletions crates/config/src/provider_defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
18 changes: 17 additions & 1 deletion crates/config/src/provider_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>]
Expand All @@ -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,
Expand Down Expand Up @@ -274,6 +289,7 @@ impl ProviderKind {
Self::Google,
Self::Antigravity,
Self::Edenai,
Self::Concentrate,
Self::Custom,
];

Expand Down
1 change: 1 addition & 0 deletions crates/config/src/route/golden_route_ids.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ anthropic
antigravity
arcee
atlascloud
concentrate
custom
deepinfra
deepseek
Expand Down
18 changes: 18 additions & 0 deletions crates/config/src/route/providers-export.golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions crates/config/src/route/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` is
// `<id>`. 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)
};
Expand Down
40 changes: 39 additions & 1 deletion crates/config/src/route/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading