diff --git a/README.md b/README.md
index 1e7e66626d..bd19676290 100644
--- a/README.md
+++ b/README.md
@@ -650,6 +650,29 @@ model: claude-3.7-sonnet
+
+Scaleway Generative APIs
+
+```bash
+forge provider login scaleway
+# Enter your SCW_SECRET_KEY.
+# SCW_PROJECT_ID is optional; leave it blank to use the default project.
+```
+
+```yaml
+# forge.yaml
+model: glm-5.2
+```
+
+Legacy environment-variable setup:
+
+```bash
+SCW_SECRET_KEY=
+SCW_PROJECT_ID=
+```
+
+
+
Google Vertex AI
diff --git a/crates/forge_app/src/dto/openai/transformers/make_scaleway_compat.rs b/crates/forge_app/src/dto/openai/transformers/make_scaleway_compat.rs
new file mode 100644
index 0000000000..94e8a4fbe5
--- /dev/null
+++ b/crates/forge_app/src/dto/openai/transformers/make_scaleway_compat.rs
@@ -0,0 +1,84 @@
+use forge_domain::Transformer;
+
+use crate::dto::openai::Request;
+
+const GLM_5_2_MAX_OUTPUT_TOKENS: u32 = 16_384;
+
+/// Adapts OpenAI-compatible requests to Scaleway Generative APIs limits.
+pub(super) struct MakeScalewayCompat;
+
+impl Transformer for MakeScalewayCompat {
+ type Value = Request;
+
+ fn transform(&mut self, mut request: Self::Value) -> Self::Value {
+ request.max_completion_tokens = request
+ .max_completion_tokens
+ .map(|tokens| tokens.min(GLM_5_2_MAX_OUTPUT_TOKENS));
+
+ request.reasoning_effort = request
+ .reasoning_effort
+ .map(|effort| match effort.as_str() {
+ "none" | "high" | "max" => effort,
+ "xhigh" => "max".to_string(),
+ _ => "high".to_string(),
+ });
+
+ request
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use pretty_assertions::assert_eq;
+
+ use super::*;
+
+ #[test]
+ fn test_clamps_max_completion_tokens_to_scaleway_limit() {
+ let fixture = Request::default().max_completion_tokens(32_768);
+
+ let actual = MakeScalewayCompat.transform(fixture);
+
+ let expected = Some(GLM_5_2_MAX_OUTPUT_TOKENS);
+ assert_eq!(actual.max_completion_tokens, expected);
+ }
+
+ #[test]
+ fn test_preserves_max_completion_tokens_below_scaleway_limit() {
+ let fixture = Request::default().max_completion_tokens(8_192);
+
+ let actual = MakeScalewayCompat.transform(fixture);
+
+ let expected = Some(8_192);
+ assert_eq!(actual.max_completion_tokens, expected);
+ }
+
+ #[test]
+ fn test_normalizes_reasoning_effort_to_scaleway_values() {
+ let fixtures = [
+ ("none", "none"),
+ ("minimal", "high"),
+ ("low", "high"),
+ ("medium", "high"),
+ ("high", "high"),
+ ("xhigh", "max"),
+ ("max", "max"),
+ ];
+
+ let actual = fixtures
+ .into_iter()
+ .map(|(input, _)| {
+ MakeScalewayCompat
+ .transform(Request::default().reasoning_effort(input.to_string()))
+ .reasoning_effort
+ .unwrap()
+ })
+ .collect::>();
+
+ let expected = fixtures
+ .into_iter()
+ .map(|(_, output)| output.to_string())
+ .collect::>();
+ assert_eq!(actual, expected);
+ }
+}
diff --git a/crates/forge_app/src/dto/openai/transformers/mod.rs b/crates/forge_app/src/dto/openai/transformers/mod.rs
index 61cf6f0b04..8ea9515c72 100644
--- a/crates/forge_app/src/dto/openai/transformers/mod.rs
+++ b/crates/forge_app/src/dto/openai/transformers/mod.rs
@@ -4,6 +4,7 @@ mod ensure_system_first;
mod github_copilot_reasoning;
mod make_cerebras_compat;
mod make_openai_compat;
+mod make_scaleway_compat;
mod make_xai_compat;
mod minimax;
mod normalize_tool_schema;
diff --git a/crates/forge_app/src/dto/openai/transformers/pipeline.rs b/crates/forge_app/src/dto/openai/transformers/pipeline.rs
index f8009763c5..66fc0f244c 100644
--- a/crates/forge_app/src/dto/openai/transformers/pipeline.rs
+++ b/crates/forge_app/src/dto/openai/transformers/pipeline.rs
@@ -7,6 +7,7 @@ use super::ensure_system_first::MergeSystemMessages;
use super::github_copilot_reasoning::GitHubCopilotReasoning;
use super::make_cerebras_compat::MakeCerebrasCompat;
use super::make_openai_compat::MakeOpenAiCompat;
+use super::make_scaleway_compat::MakeScalewayCompat;
use super::make_xai_compat::MakeXaiCompat;
use super::minimax::SetMinimaxParams;
use super::normalize_tool_schema::{
@@ -65,9 +66,12 @@ impl Transformer for ProviderPipeline<'_> {
let open_ai_compat = MakeOpenAiCompat.when(move |_| !supports_open_router_params(provider));
+ let scaleway_compat = MakeScalewayCompat.when(move |_| provider.id == ProviderId::SCALEWAY);
+
let set_reasoning_effort = SetReasoningEffort.when(move |request: &Request| {
provider.id == ProviderId::REQUESTY
|| provider.id == ProviderId::GITHUB_COPILOT
+ || provider.id == ProviderId::SCALEWAY
|| is_deepseek_compatible(provider, request)
|| provider.id == ProviderId::NVIDIA
});
@@ -116,6 +120,7 @@ impl Transformer for ProviderPipeline<'_> {
.pipe(strip_thought_signature)
.pipe(set_reasoning_effort)
.pipe(open_ai_compat)
+ .pipe(scaleway_compat)
.pipe(github_copilot_reasoning)
.pipe(reasoning_content)
.pipe(default_reasoning_content)
@@ -314,6 +319,20 @@ mod tests {
}
}
+ fn scaleway(key: &str) -> Provider {
+ Provider {
+ id: ProviderId::SCALEWAY,
+ provider_type: Default::default(),
+ response: Some(ProviderResponse::OpenAI),
+ url: Url::parse("https://api.scaleway.ai/v1/chat/completions").unwrap(),
+ auth_methods: vec![forge_domain::AuthMethod::ApiKey],
+ url_params: vec![],
+ credential: make_credential(ProviderId::SCALEWAY, key),
+ custom_headers: None,
+ models: Some(ModelSource::Hardcoded(vec![])),
+ }
+ }
+
fn open_router(key: &str) -> Provider {
Provider {
id: ProviderId::OPEN_ROUTER,
@@ -1122,6 +1141,28 @@ mod tests {
assert_eq!(actual.reasoning, None);
}
+ #[test]
+ fn test_scaleway_provider_applies_reasoning_effort() {
+ let provider = scaleway("scaleway");
+ let fixture =
+ Request::default()
+ .max_tokens(32_768)
+ .reasoning(forge_domain::ReasoningConfig {
+ enabled: Some(true),
+ effort: Some(forge_domain::Effort::High),
+ max_tokens: None,
+ exclude: None,
+ });
+
+ let mut pipeline = ProviderPipeline::new(&provider, false);
+ let actual = pipeline.transform(fixture);
+
+ assert_eq!(actual.reasoning_effort, Some("high".to_string()));
+ assert_eq!(actual.max_tokens, None);
+ assert_eq!(actual.max_completion_tokens, Some(16_384));
+ assert_eq!(actual.reasoning, None);
+ }
+
#[test]
fn test_opencode_go_deepseek_model_converts_reasoning_details_to_reasoning_content() {
let provider = opencode_go("opencode-go");
diff --git a/crates/forge_domain/src/provider.rs b/crates/forge_domain/src/provider.rs
index 72c914589f..00ea3d872d 100644
--- a/crates/forge_domain/src/provider.rs
+++ b/crates/forge_domain/src/provider.rs
@@ -49,6 +49,7 @@ impl ProviderId {
pub const OPENAI: ProviderId = ProviderId(Cow::Borrowed("openai"));
pub const OPEN_ROUTER: ProviderId = ProviderId(Cow::Borrowed("open_router"));
pub const REQUESTY: ProviderId = ProviderId(Cow::Borrowed("requesty"));
+ pub const SCALEWAY: ProviderId = ProviderId(Cow::Borrowed("scaleway"));
pub const ZAI: ProviderId = ProviderId(Cow::Borrowed("zai"));
pub const ZAI_CODING: ProviderId = ProviderId(Cow::Borrowed("zai_coding"));
pub const CEREBRAS: ProviderId = ProviderId(Cow::Borrowed("cerebras"));
@@ -98,6 +99,7 @@ impl ProviderId {
ProviderId::OPENAI,
ProviderId::OPEN_ROUTER,
ProviderId::REQUESTY,
+ ProviderId::SCALEWAY,
ProviderId::ZAI,
ProviderId::ZAI_CODING,
ProviderId::CEREBRAS,
@@ -198,6 +200,7 @@ impl std::str::FromStr for ProviderId {
"openai" => ProviderId::OPENAI,
"open_router" => ProviderId::OPEN_ROUTER,
"requesty" => ProviderId::REQUESTY,
+ "scaleway" => ProviderId::SCALEWAY,
"zai" => ProviderId::ZAI,
"zai_coding" => ProviderId::ZAI_CODING,
"cerebras" => ProviderId::CEREBRAS,
@@ -586,6 +589,7 @@ mod tests {
fn test_provider_id_display_name() {
assert_eq!(ProviderId::OPENAI.to_string(), "OpenAI");
assert_eq!(ProviderId::OPEN_ROUTER.to_string(), "OpenRouter");
+ assert_eq!(ProviderId::SCALEWAY.to_string(), "Scaleway");
assert_eq!(ProviderId::ZAI.to_string(), "ZAI");
assert_eq!(ProviderId::XAI.to_string(), "XAI");
assert_eq!(ProviderId::ANTHROPIC.to_string(), "Anthropic");
@@ -637,6 +641,13 @@ mod tests {
assert_eq!(actual, expected);
}
+ #[test]
+ fn test_scaleway_from_str() {
+ let actual = ProviderId::from_str("scaleway").unwrap();
+ let expected = ProviderId::SCALEWAY;
+ assert_eq!(actual, expected);
+ }
+
#[test]
fn test_opencode_go_from_str() {
let actual = ProviderId::from_str("opencode_go").unwrap();
@@ -649,6 +660,7 @@ mod tests {
let built_in = ProviderId::built_in_providers();
assert!(built_in.contains(&ProviderId::CODEX));
assert!(built_in.contains(&ProviderId::OPENAI_RESPONSES_COMPATIBLE));
+ assert!(built_in.contains(&ProviderId::SCALEWAY));
assert!(built_in.contains(&ProviderId::FIREWORKS_AI));
assert!(built_in.contains(&ProviderId::VIVGRID));
assert!(built_in.contains(&ProviderId::OPENCODE_GO));
diff --git a/crates/forge_repo/src/provider/openai.rs b/crates/forge_repo/src/provider/openai.rs
index 9f262e98ed..392c773d78 100644
--- a/crates/forge_repo/src/provider/openai.rs
+++ b/crates/forge_repo/src/provider/openai.rs
@@ -23,6 +23,16 @@ use crate::provider::utils::{create_headers, format_http_context, join_url};
/// Enhances error messages with provider-specific helpful information
fn enhance_error(error: anyhow::Error, provider_id: &ProviderId) -> anyhow::Error {
+ if *provider_id == ProviderId::SCALEWAY {
+ let error_string = format!("{error:#}");
+
+ if error_string.contains("insufficient permissions") || error_string.contains("FORBIDDEN") {
+ return error.context(
+ "The Scaleway API key cannot access the configured SCW_PROJECT_ID. Leave the project ID blank to use the default project, or grant the key GenerativeApisModelAccess for that project."
+ );
+ }
+ }
+
// GitHub Copilot specific error enhancements
if *provider_id == ProviderId::GITHUB_COPILOT {
let error_string = format!("{:#}", error);
@@ -879,6 +889,17 @@ mod tests {
insta::assert_snapshot!(error_string);
}
+ #[test]
+ fn test_enhance_error_scaleway_project_permissions() {
+ let fixture =
+ anyhow::anyhow!("403 FORBIDDEN: insufficient permissions to access the resource");
+
+ let actual = enhance_error(fixture, &ProviderId::SCALEWAY);
+
+ let expected = "The Scaleway API key cannot access the configured SCW_PROJECT_ID. Leave the project ID blank to use the default project, or grant the key GenerativeApisModelAccess for that project.";
+ assert_eq!(actual.to_string(), expected);
+ }
+
#[test]
fn test_prepare_copilot_auto_request_strips_model_and_tuning() {
let fixture = Request::default()
diff --git a/crates/forge_repo/src/provider/provider.json b/crates/forge_repo/src/provider/provider.json
index 27da81ff9a..10ffcc524a 100644
--- a/crates/forge_repo/src/provider/provider.json
+++ b/crates/forge_repo/src/provider/provider.json
@@ -17,6 +17,31 @@
"models": "https://api.deepseek.com/models",
"auth_methods": ["api_key"]
},
+ {
+ "id": "scaleway",
+ "api_key_vars": "SCW_SECRET_KEY",
+ "url_param_vars": [
+ {
+ "name": "SCW_PROJECT_ID",
+ "optional": true
+ }
+ ],
+ "response_type": "OpenAI",
+ "url": "https://api.scaleway.ai{{#if SCW_PROJECT_ID}}/{{SCW_PROJECT_ID}}{{/if}}/v1/chat/completions",
+ "models": [
+ {
+ "id": "glm-5.2",
+ "name": "GLM-5.2",
+ "description": "Scaleway-hosted GLM-5.2 for long-horizon reasoning and coding tasks",
+ "context_length": 256000,
+ "tools_supported": true,
+ "supports_parallel_tool_calls": true,
+ "supports_reasoning": true,
+ "input_modalities": ["text"]
+ }
+ ],
+ "auth_methods": ["api_key"]
+ },
{
"id": "github_copilot",
"api_key_vars": "GITHUB_COPILOT_API_KEY",
diff --git a/crates/forge_repo/src/provider/provider_repo.rs b/crates/forge_repo/src/provider/provider_repo.rs
index c8bb120b7d..ad413ce247 100644
--- a/crates/forge_repo/src/provider/provider_repo.rs
+++ b/crates/forge_repo/src/provider/provider_repo.rs
@@ -697,6 +697,42 @@ mod tests {
"https://openrouter.ai/api/v1/chat/completions"
);
+ let scaleway_config = configs
+ .iter()
+ .find(|c| c.id == ProviderId::SCALEWAY)
+ .unwrap();
+ assert_eq!(
+ scaleway_config.api_key_vars,
+ Some("SCW_SECRET_KEY".to_string())
+ );
+ assert_eq!(
+ scaleway_config
+ .url_param_vars
+ .iter()
+ .map(|v| (v.param_name(), v.is_optional()))
+ .collect::>(),
+ vec![("SCW_PROJECT_ID", true)]
+ );
+ assert_eq!(
+ scaleway_config.response_type,
+ Some(ProviderResponse::OpenAI)
+ );
+ assert_eq!(
+ scaleway_config.url,
+ "https://api.scaleway.ai{{#if SCW_PROJECT_ID}}/{{SCW_PROJECT_ID}}{{/if}}/v1/chat/completions"
+ );
+ match scaleway_config.models.as_ref().unwrap() {
+ Models::Hardcoded(models) => {
+ assert_eq!(models.len(), 1);
+ assert_eq!(models[0].id.as_str(), "glm-5.2");
+ assert_eq!(models[0].context_length, Some(256000));
+ assert_eq!(models[0].tools_supported, Some(true));
+ assert_eq!(models[0].supports_parallel_tool_calls, Some(true));
+ assert_eq!(models[0].supports_reasoning, Some(true));
+ }
+ Models::Url(_) => panic!("Expected Models::Hardcoded variant"),
+ }
+
let vivgrid_config = configs
.iter()
.find(|c| c.id == ProviderId::VIVGRID)