Skip to content
Open
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
13 changes: 13 additions & 0 deletions crates/protocols/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@ pub enum ProviderType {
/// Google Gemini — special logprobs handling.
#[serde(alias = "gemini", alias = "google")]
Gemini,
/// Amazon Bedrock Runtime.
#[serde(alias = "bedrock", alias = "aws")]
Bedrock,
/// Custom provider with string identifier.
#[serde(untagged)]
Custom(String),
Expand All @@ -280,6 +283,7 @@ impl ProviderType {
Self::XAI => "xai",
Self::Anthropic => "anthropic",
Self::Gemini => "gemini",
Self::Bedrock => "bedrock",
Self::Custom(s) => s.as_str(),
}
}
Expand All @@ -297,6 +301,8 @@ impl ProviderType {
Some(Self::Anthropic)
} else if host.ends_with("googleapis.com") {
Some(Self::Gemini)
} else if host.contains("bedrock-runtime.") && host.ends_with(".amazonaws.com") {
Some(Self::Bedrock)
Comment thread
MohanKumar21 marked this conversation as resolved.
} else {
None
}
Expand All @@ -310,6 +316,7 @@ impl ProviderType {
Self::XAI => Some("XAI_ADMIN_KEY"),
Self::Anthropic => Some("ANTHROPIC_ADMIN_KEY"),
Self::Gemini => Some("GEMINI_ADMIN_KEY"),
Self::Bedrock => Some("AWS_BEDROCK_API_KEY"),
Self::Custom(_) => None,
}
}
Expand All @@ -329,6 +336,12 @@ impl ProviderType {
Some(Self::Gemini)
} else if model_lower.starts_with("claude") {
Some(Self::Anthropic)
} else if model_lower.starts_with("anthropic.claude")
|| model_lower.starts_with("amazon.")
|| model_lower.starts_with("meta.")
|| model_lower.starts_with("mistral.")
{
Some(Self::Bedrock)
} else if model_lower.starts_with("gpt")
|| model_lower.starts_with("o1")
|| model_lower.starts_with("o3")
Expand Down
20 changes: 19 additions & 1 deletion docs/getting-started/external-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ title: External Providers

# External Providers

SMG can route requests to external LLM provider APIs (OpenAI, Anthropic, xAI, Google Gemini), acting as a unified gateway. This enables provider-agnostic applications, load balancing across providers, and centralized observability.
SMG can route requests to external LLM provider APIs (OpenAI, Anthropic, xAI, Google Gemini, and AWS Bedrock), acting as a unified gateway. This enables provider-agnostic applications, load balancing across providers, and centralized observability.

<div class="prerequisites" markdown>

Expand All @@ -27,6 +27,24 @@ SMG auto-detects the provider from the model name in each request and applies th
| Anthropic | `claude-*` models | `x-api-key` (plus `anthropic-version`) |
| xAI | `grok-*` models | `Authorization: Bearer` |
| Google Gemini | `gemini-*` models | `x-goog-api-key` |
| AWS Bedrock | `anthropic.claude*`, `amazon.*`, `meta.*`, `mistral.*` | AWS SigV4 (`Authorization`, `X-Amz-Date`, etc.) |

### Bedrock notes

- Bedrock runs as a first-class routing mode (`routing.mode.type: bedrock`) and is HTTP-only.
- SMG signs Bedrock requests with SigV4 using this credential resolution order:
1) environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optional `AWS_SESSION_TOKEN`)
2) shared credentials profile (`AWS_PROFILE` / `~/.aws/credentials`)
3) container/instance metadata (ECS/EC2 IMDS).
- Configure Bedrock region/service in `router_config.bedrock`:

```yaml
bedrock:
region: us-east-1
service: bedrock
model_map:
claude-opus-4-5: us.anthropic.claude-opus-4-5-20251101-v1:0
```
Comment thread
MohanKumar21 marked this conversation as resolved.

---

Expand Down
6 changes: 5 additions & 1 deletion model_gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ openmetrics-parser = "0.4.4"
arc-swap = "1.7.1"
bitflags.workspace = true
once_cell = "1.21.3"
sha2 = "0.10"
sha2 = "0.11"
crc32fast = "1.4"
crc32c = "0.6"
base64 = "0.22"
image = { version = "0.25.4", default-features = false }
tokio-tungstenite = { workspace = true }
Expand All @@ -125,6 +127,8 @@ wasmtime = { workspace = true }
tempfile = "3.8"
multer = { workspace = true }
str0m = { workspace = true }
hmac = "0.13"
hex = "0.4.3"

[build-dependencies]
chrono = { version = "0.4", features = ["clock"] }
Expand Down
5 changes: 5 additions & 0 deletions model_gateway/src/config/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ impl RouterConfigBuilder {
self
}

pub fn bedrock_mode(mut self, worker_urls: Vec<String>) -> Self {
self.config.mode = RoutingMode::Bedrock { worker_urls };
self
}

pub fn mode(mut self, mode: RoutingMode) -> Self {
self.config.mode = mode;
self
Expand Down
56 changes: 56 additions & 0 deletions model_gateway/src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ pub struct RouterConfig {
pub disable_circuit_breaker: bool,
pub health_check: HealthCheckConfig,
#[serde(default)]
pub bedrock: BedrockConfig,
#[serde(default)]
pub enable_igw: bool,
/// Can be a HuggingFace model ID or local path
pub model_path: Option<String>,
Expand Down Expand Up @@ -215,6 +217,55 @@ pub struct TokenizerCacheConfig {
pub l1_max_memory: usize,
}

/// AWS Bedrock routing configuration
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct BedrockConfig {
/// AWS region used for request signing (e.g. us-east-1).
pub region: Option<String>,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// AWS service identifier for SigV4 (defaults to `bedrock`).
///
/// Note: Bedrock uses `bedrock` as the SigV4 signing service for BOTH the
/// control plane (`bedrock.<region>.amazonaws.com`) and the runtime data
/// plane (`bedrock-runtime.<region>.amazonaws.com`). The hostname differs
/// but the credential scope service does not. See AWS SDK for Go
/// `bedrockruntime/service.go` and AWS SDK for JS v3 `client-bedrock-runtime`,
/// both of which set `SigningName = "bedrock"`.
pub service: String,
/// Optional model-id remapping from incoming model -> Bedrock model ID.
pub model_map: HashMap<String, String>,
}

impl Default for BedrockConfig {
fn default() -> Self {
Self {
region: None,
service: "bedrock".to_string(),
model_map: HashMap::new(),
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

impl BedrockConfig {
/// AWS region for SigV4: non-empty `region`, else `AWS_REGION`, else `AWS_DEFAULT_REGION`.
pub(crate) fn resolved_signing_region(&self) -> Option<String> {
if let Some(r) = &self.region {
let trimmed = r.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
std::env::var("AWS_REGION")
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
std::env::var("AWS_DEFAULT_REGION")
.ok()
.filter(|s| !s.trim().is_empty())
})
}
}

fn default_load_monitor_interval_secs() -> u64 {
10
}
Expand Down Expand Up @@ -284,6 +335,8 @@ pub enum RoutingMode {
Anthropic { worker_urls: Vec<String> },
#[serde(rename = "gemini")]
Gemini { worker_urls: Vec<String> },
#[serde(rename = "bedrock")]
Bedrock { worker_urls: Vec<String> },
}

impl RoutingMode {
Expand All @@ -302,6 +355,7 @@ impl RoutingMode {
RoutingMode::OpenAI { worker_urls } => worker_urls.len(),
RoutingMode::Anthropic { worker_urls } => worker_urls.len(),
RoutingMode::Gemini { worker_urls } => worker_urls.len(),
RoutingMode::Bedrock { worker_urls } => worker_urls.len(),
}
}

Expand Down Expand Up @@ -656,6 +710,7 @@ impl Default for RouterConfig {
disable_retries: false,
disable_circuit_breaker: false,
health_check: HealthCheckConfig::default(),
bedrock: BedrockConfig::default(),
enable_igw: false,
connection_mode: ConnectionMode::Http,
model_path: None,
Expand Down Expand Up @@ -705,6 +760,7 @@ impl RouterConfig {
RoutingMode::OpenAI { .. } => "openai",
RoutingMode::Anthropic { .. } => "anthropic",
RoutingMode::Gemini { .. } => "gemini",
RoutingMode::Bedrock { .. } => "bedrock",
}
}

Expand Down
101 changes: 101 additions & 0 deletions model_gateway/src/config/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ impl ConfigValidator {
}

Self::validate_tokenizer_cache(&config.tokenizer_cache)?;
Self::validate_bedrock(&config.mode, &config.bedrock)?;
Self::validate_skills(config)?;
Self::validate_background(&config.background)?;

Expand Down Expand Up @@ -99,6 +100,31 @@ impl ConfigValidator {
Self::validate_skills_config(skills)
}

fn validate_bedrock(mode: &RoutingMode, cfg: &BedrockConfig) -> ConfigResult<()> {
if cfg.service.trim().is_empty() {
return Err(ConfigError::InvalidValue {
field: "bedrock.service".to_string(),
value: cfg.service.clone(),
reason: "Must not be empty".to_string(),
});
}
if let Some(region) = &cfg.region {
if region.trim().is_empty() {
return Err(ConfigError::InvalidValue {
field: "bedrock.region".to_string(),
value: region.clone(),
reason: "Must not be empty when set".to_string(),
});
}
}
if matches!(mode, RoutingMode::Bedrock { .. }) && cfg.resolved_signing_region().is_none() {
return Err(ConfigError::ValidationFailed {
reason: "Bedrock routing requires a non-empty bedrock.region, or AWS_REGION / AWS_DEFAULT_REGION (used for SigV4 signing)".to_string(),
});
}
Ok(())
}

fn validate_skills_config(skills: &SkillsConfig) -> ConfigResult<()> {
if skills.blob_store.path.trim().is_empty() {
return Err(ConfigError::InvalidValue {
Expand Down Expand Up @@ -349,6 +375,20 @@ impl ConfigValidator {
Self::validate_urls(worker_urls)?;
}
}
RoutingMode::Bedrock { worker_urls } => {
// Allow empty URLs to support dynamic worker addition
if !worker_urls.is_empty() {
Self::validate_urls(worker_urls)?;
if worker_urls.iter().any(|u| u.starts_with("grpc://")) {
return Err(ConfigError::InvalidValue {
field: "worker_url".to_string(),
value: "grpc://...".to_string(),
reason: "Bedrock mode only supports http:// or https:// URLs"
.to_string(),
});
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Ok(())
}
Expand Down Expand Up @@ -593,6 +633,11 @@ impl ConfigValidator {
reason: "Gemini mode does not support service discovery".to_string(),
});
}
RoutingMode::Bedrock { .. } => {
return Err(ConfigError::ValidationFailed {
reason: "Bedrock mode does not support service discovery".to_string(),
});
}
}

Ok(())
Expand Down Expand Up @@ -885,6 +930,8 @@ fn validate_mebibyte_limit(field: &str, value_mb: usize) -> ConfigResult<()> {

#[cfg(test)]
mod tests {
use serial_test::serial;

use super::*;
use crate::worker::ConnectionMode;

Expand Down Expand Up @@ -1217,6 +1264,60 @@ mod tests {

// Should pass validation even with empty URLs
assert!(ConfigValidator::validate(&config).is_ok());

// Test that empty URLs are allowed in Bedrock mode
let mut config = RouterConfig::new(
RoutingMode::Bedrock {
worker_urls: vec![],
},
PolicyConfig::Random,
);
config.bedrock.region = Some("us-east-1".to_string());
assert!(ConfigValidator::validate(&config).is_ok());
}

#[test]
#[serial]
fn bedrock_routing_rejects_missing_signing_region() {
struct EnvRestore {
saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
}
impl EnvRestore {
fn new(keys: &[&'static str]) -> Self {
let saved = keys
.iter()
.map(|k| (*k, std::env::var_os(k)))
.collect::<Vec<_>>();
Self { saved }
}
}
impl Drop for EnvRestore {
fn drop(&mut self) {
for (k, v) in &self.saved {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
}

let _guard = EnvRestore::new(&["AWS_REGION", "AWS_DEFAULT_REGION"]);
std::env::remove_var("AWS_REGION");
std::env::remove_var("AWS_DEFAULT_REGION");

let mut config = RouterConfig::new(
RoutingMode::Bedrock {
worker_urls: vec![],
},
PolicyConfig::Random,
);
config.bedrock.region = None;
let result = ConfigValidator::validate(&config);
assert!(
result.is_err(),
"expected validation error when Bedrock mode has no region and no AWS_* env"
);
}

#[test]
Expand Down
Loading
Loading