diff --git a/crates/openshell-core/src/inference.rs b/crates/openshell-core/src/inference.rs index 0360cae5c..37fde7290 100644 --- a/crates/openshell-core/src/inference.rs +++ b/crates/openshell-core/src/inference.rs @@ -18,6 +18,15 @@ pub enum AuthHeader { Bearer, /// Custom header name (e.g. `x-api-key` for Anthropic). Custom(&'static str), + /// Do not inject any auth header on outgoing requests. The upstream + /// is expected to authenticate itself — used when the configured + /// `default_base_url` (or operator-supplied base-URL override) points + /// at a translating bridge / proxy that holds operator-side + /// credentials in its own pod and ignores caller-supplied auth. + /// Currently used by the `aws-bedrock` profile, where `SigV4` signing + /// is deferred to a follow-up PR; today the only supported shape is + /// a bridge-fronted upstream. + None, } // --------------------------------------------------------------------------- @@ -61,6 +70,8 @@ const OPENAI_PROTOCOLS: &[&str] = &[ const ANTHROPIC_PROTOCOLS: &[&str] = &["anthropic_messages", "model_discovery"]; +const AWS_BEDROCK_PROTOCOLS: &[&str] = &["aws_bedrock_invoke", "aws_bedrock_invoke_stream"]; + static OPENAI_PROFILE: InferenceProviderProfile = InferenceProviderProfile { provider_type: "openai", default_base_url: "https://api.openai.com/v1", @@ -94,6 +105,30 @@ static NVIDIA_PROFILE: InferenceProviderProfile = InferenceProviderProfile { passthrough_headers: &["x-model-id"], }; +// AWS Bedrock — registered as bridge-fronted (no router-side auth +// injection). Real AWS Bedrock requires `SigV4` signing of every request, +// which is deferred to a follow-up PR (see #1704 thread). Until then, +// operators point `BEDROCK_BASE_URL` at a translating bridge or +// Bedrock-compatible proxy that handles auth in its own pod. The router +// passes Bedrock InvokeModel requests through opaquely; the L7 patterns +// `/model/{modelId}/invoke` and `/model/{modelId}/invoke-with-response-stream` +// are wired up in `crates/openshell-sandbox/src/l7/inference.rs`. +static AWS_BEDROCK_PROFILE: InferenceProviderProfile = InferenceProviderProfile { + provider_type: "aws-bedrock", + default_base_url: "https://bedrock-runtime.us-east-1.amazonaws.com", + protocols: AWS_BEDROCK_PROTOCOLS, + // No single API key for Bedrock — `SigV4` takes four credentials + // (access key id, secret, session token, region) and signs requests + // rather than injecting a header. Until the `SigV4` follow-up lands + // the router-side auth shape is `None` and no credential lookup is + // required at route time. + credential_key_names: &[], + base_url_config_keys: &["BEDROCK_BASE_URL"], + auth: AuthHeader::None, + default_headers: &[], + passthrough_headers: &[], +}; + /// Look up the inference provider profile for a given provider type. /// /// Returns `None` for provider types that don't support inference routing @@ -103,6 +138,7 @@ pub fn profile_for(provider_type: &str) -> Option<&'static InferenceProviderProf "openai" => Some(&OPENAI_PROFILE), "anthropic" => Some(&ANTHROPIC_PROFILE), "nvidia" => Some(&NVIDIA_PROFILE), + "aws-bedrock" => Some(&AWS_BEDROCK_PROFILE), _ => None, } } @@ -200,7 +236,31 @@ mod tests { assert!(profile_for("openai").is_some()); assert!(profile_for("anthropic").is_some()); assert!(profile_for("nvidia").is_some()); + assert!(profile_for("aws-bedrock").is_some()); assert!(profile_for("OpenAI").is_some()); // case insensitive + assert!(profile_for("AWS-Bedrock").is_some()); // case insensitive + } + + #[test] + fn aws_bedrock_uses_no_auth_header() { + let (auth, headers) = auth_for_provider_type("aws-bedrock"); + assert_eq!(auth, AuthHeader::None); + assert!(headers.is_empty()); + } + + #[test] + fn aws_bedrock_profile_has_no_credential_keys() { + let profile = profile_for("aws-bedrock").expect("profile registered"); + // No router-side credential lookup until the `SigV4` follow-up. + assert!(profile.credential_key_names.is_empty()); + assert_eq!(profile.base_url_config_keys, &["BEDROCK_BASE_URL"]); + } + + #[test] + fn aws_bedrock_protocols_are_bedrock_specific() { + let profile = profile_for("aws-bedrock").expect("profile registered"); + assert!(profile.protocols.contains(&"aws_bedrock_invoke")); + assert!(profile.protocols.contains(&"aws_bedrock_invoke_stream")); } #[test] diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 68cc06260..8c51c4796 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -17,6 +17,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ + include_str!("../../../providers/aws-bedrock.yaml"), include_str!("../../../providers/claude-code.yaml"), include_str!("../../../providers/github.yaml"), include_str!("../../../providers/nvidia.yaml"), diff --git a/crates/openshell-router/src/backend.rs b/crates/openshell-router/src/backend.rs index 88a6e213a..66e41cd6e 100644 --- a/crates/openshell-router/src/backend.rs +++ b/crates/openshell-router/src/backend.rs @@ -174,6 +174,13 @@ fn prepare_backend_request( AuthHeader::Custom(header_name) => { builder = builder.header(*header_name, &route.api_key); } + AuthHeader::None => { + // Bridge-fronted upstream: no router-side auth injection. + // The configured `endpoint` is expected to be a translating + // bridge / proxy whose own pod holds operator-side + // credentials. Used today by the `aws-bedrock` profile + // (SigV4 signing is a separate follow-up). + } } for (name, value) in &headers { builder = builder.header(name.as_str(), value.as_str()); diff --git a/crates/openshell-sandbox/src/l7/inference.rs b/crates/openshell-sandbox/src/l7/inference.rs index acda0bb36..8dee88919 100644 --- a/crates/openshell-sandbox/src/l7/inference.rs +++ b/crates/openshell-sandbox/src/l7/inference.rs @@ -16,7 +16,7 @@ pub struct InferenceApiPattern { pub kind: String, } -/// Default patterns for known inference APIs (`OpenAI`, Anthropic). +/// Default patterns for known inference APIs (`OpenAI`, Anthropic, AWS Bedrock). pub fn default_patterns() -> Vec { vec![ InferenceApiPattern { @@ -55,10 +55,31 @@ pub fn default_patterns() -> Vec { protocol: "model_discovery".to_string(), kind: "models_get".to_string(), }, + // AWS Bedrock InvokeModel + InvokeModelWithResponseStream. The `*` + // segment is the Bedrock model id (e.g. `anthropic.claude-opus-4-7`). + InferenceApiPattern { + method: "POST".to_string(), + path_glob: "/model/*/invoke".to_string(), + protocol: "aws_bedrock_invoke".to_string(), + kind: "messages".to_string(), + }, + InferenceApiPattern { + method: "POST".to_string(), + path_glob: "/model/*/invoke-with-response-stream".to_string(), + protocol: "aws_bedrock_invoke_stream".to_string(), + kind: "messages".to_string(), + }, ] } /// Check if an HTTP request matches a known inference API pattern. +/// +/// Path globs support two wildcard shapes (one per pattern, not both): +/// - **Trailing `/*`**: `/v1/models/*` matches `/v1/models` and any +/// `/v1/models/` (one or many path segments). +/// - **Middle `/*/`**: `/model/*/invoke` matches `/model//invoke` +/// for a single non-empty segment that contains no `/`. Used for +/// AWS Bedrock's `/model/{modelId}/invoke[-with-response-stream]`. pub fn detect_inference_pattern<'a>( method: &str, path: &str, @@ -78,6 +99,21 @@ pub fn detect_inference_pattern<'a>( .is_some_and(|suffix| suffix.starts_with('/')); } + if let Some((before, after)) = p.path_glob.split_once("/*/") { + let Some(rest) = path_only.strip_prefix(before) else { + return false; + }; + let Some(rest) = rest.strip_prefix('/') else { + return false; + }; + // rest must look like `/` where is non-empty + // and contains no `/` (single path segment). + let Some(slash_at) = rest.find('/') else { + return false; + }; + return slash_at > 0 && rest[slash_at + 1..] == *after; + } + path_only == p.path_glob }) } @@ -445,6 +481,66 @@ mod tests { assert!(result.is_none()); } + #[test] + fn detect_aws_bedrock_invoke() { + let patterns = default_patterns(); + let result = + detect_inference_pattern("POST", "/model/anthropic.claude-opus-4-7/invoke", &patterns); + assert!(result.is_some()); + assert_eq!(result.unwrap().protocol, "aws_bedrock_invoke"); + assert_eq!(result.unwrap().kind, "messages"); + } + + #[test] + fn detect_aws_bedrock_invoke_stream() { + let patterns = default_patterns(); + let result = detect_inference_pattern( + "POST", + "/model/anthropic.claude-opus-4-7/invoke-with-response-stream", + &patterns, + ); + assert!(result.is_some()); + assert_eq!(result.unwrap().protocol, "aws_bedrock_invoke_stream"); + } + + #[test] + fn aws_bedrock_invoke_with_query_string() { + let patterns = default_patterns(); + let result = detect_inference_pattern("POST", "/model/foo.bar/invoke?trace=1", &patterns); + assert!(result.is_some()); + assert_eq!(result.unwrap().protocol, "aws_bedrock_invoke"); + } + + #[test] + fn aws_bedrock_rejects_empty_model_id() { + let patterns = default_patterns(); + // `/model//invoke` — empty wildcard segment is not a valid Bedrock id. + assert!(detect_inference_pattern("POST", "/model//invoke", &patterns).is_none()); + } + + #[test] + fn aws_bedrock_rejects_multi_segment_model_id() { + let patterns = default_patterns(); + // The `*` matches a single path segment only; multi-segment ids must + // not match (would be a path-traversal liability otherwise). + assert!(detect_inference_pattern("POST", "/model/foo/bar/invoke", &patterns).is_none()); + } + + #[test] + fn aws_bedrock_rejects_get() { + let patterns = default_patterns(); + assert!( + detect_inference_pattern("GET", "/model/anthropic.claude-opus-4-7/invoke", &patterns) + .is_none() + ); + } + + #[test] + fn aws_bedrock_rejects_unknown_action() { + let patterns = default_patterns(); + assert!(detect_inference_pattern("POST", "/model/foo/converse", &patterns).is_none()); + } + #[test] fn parse_simple_post_request() { let body = b"{\"hello\":true}"; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index c58c0c9b7..30901ac7f 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1617,7 +1617,7 @@ mod tests { .iter() .map(|profile| profile.id.as_str()) .collect::>(); - assert_eq!(ids, vec!["claude-code", "github", "nvidia"]); + assert_eq!(ids, vec!["aws-bedrock", "claude-code", "github", "nvidia"]); let github = response .profiles diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index f393caab3..2e426a1a6 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -265,18 +265,24 @@ fn resolve_provider_route(provider: &Provider) -> Result + + + +| Pattern | Method | Path | +|---|---|---| +| InvokeModel | `POST` | `/model/{modelId}/invoke` | +| InvokeModelWithResponseStream | `POST` | `/model/{modelId}/invoke-with-response-stream` | + +The `{modelId}` segment is constrained to a single non-empty path segment to avoid path-traversal liabilities. `/model//invoke` and `/model/a/b/invoke` both no-match. + + +Today the `aws-bedrock` provider type is bridge-fronted only. The router does not inject any auth header on outbound requests; the configured `BEDROCK_BASE_URL` is expected to point at a translating bridge or Bedrock-compatible proxy whose own pod holds operator-side credentials. SigV4 signing for direct AWS Bedrock is deferred to a follow-up release. + + @@ -130,6 +145,21 @@ openshell provider create --name anthropic-prod --type anthropic --from-existing This reads `ANTHROPIC_API_KEY` from your environment. + + + + +```shell +openshell provider create \ + --name bedrock-bridge \ + --type aws-bedrock \ + --config BEDROCK_BASE_URL=http://your-bedrock-bridge.your-ns.svc.cluster.local:8080 +``` + +The `aws-bedrock` provider type is bridge-fronted: the router does not inject any auth header on outbound requests. Point `BEDROCK_BASE_URL` at a translating bridge or Bedrock-compatible proxy that handles authentication in its own pod. The bridge is expected to accept Bedrock InvokeModel requests on the patterns listed above and forward to the operator's real upstream. + +For direct AWS Bedrock with SigV4 signing, refer to a future release that adds the SigV4 router-side signer. Today the discovery scan picks up `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, and `AWS_REGION` from your environment via `--from-existing`, but the router does not yet sign requests with them. + diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index a923e9077..aaff26f5a 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -245,6 +245,7 @@ The following provider types are supported. | Type | Environment Variables Injected | Typical Use | |---|---|---| | `anthropic` | `ANTHROPIC_API_KEY` | Anthropic API | +| `aws-bedrock` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION` | AWS Bedrock InvokeModel via a translating bridge. Today the router does not inject any auth header; the configured `BEDROCK_BASE_URL` upstream is expected to handle auth itself. Refer to [Inference Routing](/sandboxes/inference-routing). | | `claude` | `ANTHROPIC_API_KEY`, `CLAUDE_API_KEY` | Claude Code, Anthropic API | | `codex` | `OPENAI_API_KEY` | OpenAI Codex | | `copilot` | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | GitHub Copilot CLI | @@ -271,6 +272,7 @@ The following providers have been tested with `inference.local`. Any provider th | Provider | Name | Type | Base URL | API Key Variable | |---|---|---|---|---| +| AWS Bedrock (via bridge) | `bedrock-bridge` | `aws-bedrock` | Operator-supplied `BEDROCK_BASE_URL` | None at router level (bridge holds creds) | | NVIDIA API Catalog | `nvidia-prod` | `nvidia` | `https://integrate.api.nvidia.com/v1` | `NVIDIA_API_KEY` | | Anthropic | `anthropic-prod` | `anthropic` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | | Baseten | `baseten` | `openai` | `https://inference.baseten.co/v1` | `OPENAI_API_KEY` | diff --git a/providers/aws-bedrock.yaml b/providers/aws-bedrock.yaml new file mode 100644 index 000000000..01a51b9e8 --- /dev/null +++ b/providers/aws-bedrock.yaml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: aws-bedrock +display_name: AWS Bedrock +description: Anthropic + Mistral + Llama models served via the AWS Bedrock InvokeModel API +category: inference +inference_capable: true +credentials: + - name: aws_access_key_id + description: AWS access key id used for SigV4 signing of outbound Bedrock requests + env_vars: [AWS_ACCESS_KEY_ID] + required: true + - name: aws_secret_access_key + description: AWS secret access key paired with aws_access_key_id + env_vars: [AWS_SECRET_ACCESS_KEY] + required: true + - name: aws_session_token + description: Optional session token for temporary credentials (STS, IAM Roles for Service Accounts) + env_vars: [AWS_SESSION_TOKEN] + required: false + - name: aws_region + description: AWS region the Bedrock endpoint resolves into (e.g. us-east-1) + env_vars: [AWS_REGION, AWS_DEFAULT_REGION] + required: true +discovery: + credentials: [aws_access_key_id, aws_secret_access_key, aws_session_token, aws_region] +endpoints: + # Default endpoint targets us-east-1 since the YAML loader does not yet + # substitute the `{region}` placeholder. Operators in other regions + # override via the `BEDROCK_BASE_URL` config-key the same way the + # `anthropic` provider accepts `ANTHROPIC_BASE_URL`. + - host: bedrock-runtime.us-east-1.amazonaws.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/claude, /usr/local/bin/claude]