From 9bf5a90a44f350cca7a2da52ce79055bacbc29fa Mon Sep 17 00:00:00 2001 From: Claude Code <220537682+claudecodebyia@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:01:44 +0200 Subject: [PATCH] feat: add Scaleway provider Co-Authored-By: ForgeCode --- README.md | 23 +++++ .../transformers/make_scaleway_compat.rs | 84 +++++++++++++++++++ .../src/dto/openai/transformers/mod.rs | 1 + .../src/dto/openai/transformers/pipeline.rs | 41 +++++++++ crates/forge_domain/src/provider.rs | 12 +++ crates/forge_repo/src/provider/openai.rs | 21 +++++ crates/forge_repo/src/provider/provider.json | 25 ++++++ .../forge_repo/src/provider/provider_repo.rs | 36 ++++++++ 8 files changed, 243 insertions(+) create mode 100644 crates/forge_app/src/dto/openai/transformers/make_scaleway_compat.rs 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)