Skip to content
Draft
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,29 @@ model: claude-3.7-sonnet

</details>

<details>
<summary><strong><a href="https://www.scaleway.com/en/docs/generative-apis/api-cli/using-generative-apis/">Scaleway Generative APIs</a></strong></summary>

```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=<your_scaleway_secret_key>
SCW_PROJECT_ID=<optional_project_id>
```

</details>

<details>
<summary><strong>Google Vertex AI</strong></summary>

Expand Down
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>();

let expected = fixtures
.into_iter()
.map(|(_, output)| output.to_string())
.collect::<Vec<_>>();
assert_eq!(actual, expected);
}
}
1 change: 1 addition & 0 deletions crates/forge_app/src/dto/openai/transformers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions crates/forge_app/src/dto/openai/transformers/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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
});
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -314,6 +319,20 @@ mod tests {
}
}

fn scaleway(key: &str) -> Provider<Url> {
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<Url> {
Provider {
id: ProviderId::OPEN_ROUTER,
Expand Down Expand Up @@ -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");
Expand Down
12 changes: 12 additions & 0 deletions crates/forge_domain/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -98,6 +99,7 @@ impl ProviderId {
ProviderId::OPENAI,
ProviderId::OPEN_ROUTER,
ProviderId::REQUESTY,
ProviderId::SCALEWAY,
ProviderId::ZAI,
ProviderId::ZAI_CODING,
ProviderId::CEREBRAS,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand All @@ -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));
Expand Down
21 changes: 21 additions & 0 deletions crates/forge_repo/src/provider/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions crates/forge_repo/src/provider/provider.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions crates/forge_repo/src/provider/provider_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<_>>(),
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)
Expand Down
Loading