Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5a030d0
feat(providers): add Vertex AI provider type
itdove Apr 6, 2026
dc36903
docs: clarify that cluster:build:full also starts the gateway
itdove Apr 6, 2026
a6cc6a4
docs: add Vertex AI provider to inference and provider docs
itdove Apr 6, 2026
17bf434
feat(vertex): implement GCP OAuth authentication for Vertex AI
itdove Apr 6, 2026
5ac42ba
fix(vertex): use separate thread for OAuth token generation
itdove Apr 6, 2026
f606dc3
feat(scripts): improve cleanup script with sandbox deletion and bette…
itdove Apr 6, 2026
d36e58b
feat(sandbox): inject Vertex AI credentials as actual environment var…
itdove Apr 6, 2026
2dd3438
feat(vertex): auto-inject CLAUDE_CODE_USE_VERTEX for claude CLI
itdove Apr 7, 2026
bc3342d
feat(podman): increase default memory to 12 GB for better build perfo…
itdove Apr 7, 2026
b08de19
fix(scripts): update CLI installation command in setup script
itdove Apr 7, 2026
b56828e
fix(router): remove model field from Vertex AI request bodies
itdove Apr 7, 2026
308dc5c
docs: add Vertex AI example with network policy
itdove Apr 7, 2026
83a94b9
fix(build): handle Podman --push flag and array expansion
itdove Apr 7, 2026
b2d6545
feat(build): add Podman multi-arch support to docker-publish-multiarc…
itdove Apr 7, 2026
8a27b2f
fix: apply cargo fmt formatting to vertex provider
itdove Apr 7, 2026
8241dc7
refactor: remove OAuth token storage from Vertex provider
itdove Apr 7, 2026
987b2a0
docs(vertex): improve ADC detection and troubleshooting docs
itdove Apr 7, 2026
c58f3c7
style(vertex): apply cargo fmt formatting
itdove Apr 7, 2026
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
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,23 @@ These are the primary `mise` tasks for day-to-day development:
| `mise run docs` | Build and serve documentation locally |
| `mise run clean` | Clean build artifacts |

## Rebuilding After Code Changes

When developing OpenShell core components (gateway, router, sandbox supervisor), you need to rebuild the cluster to test your changes:

```bash
bash scripts/rebuild-cluster.sh
```

This script stops the cluster, rebuilds the image with your changes, and restarts it.

**After rebuilding:**
- Providers need to be recreated (gateway database was reset)
- Inference routing needs to be reconfigured
- Sandboxes need to be recreated

For a complete cleanup, see the cleanup scripts in the `scripts/` directory.

## Project Structure

| Path | Purpose |
Expand Down
46 changes: 40 additions & 6 deletions cleanup-openshell-podman-macos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,43 @@ set -e
echo "=== OpenShell Podman Cleanup Script ==="
echo ""

# Delete all sandboxes first (before destroying gateway)
echo "Deleting all sandboxes..."
if command -v openshell &>/dev/null; then
# Get list of sandboxes and delete each one
openshell sandbox list --no-header 2>/dev/null | awk '{print $1}' | while read -r sandbox; do
if [ -n "$sandbox" ]; then
echo " Deleting sandbox: $sandbox"
openshell sandbox delete "$sandbox" 2>/dev/null || true
fi
done
fi

# Destroy OpenShell gateway (if it exists)
echo "Destroying OpenShell gateway..."
if command -v openshell &>/dev/null; then
openshell gateway destroy --name openshell 2>/dev/null || true
fi

# Stop and remove any running OpenShell containers
echo "Stopping OpenShell containers..."
podman ps -a | grep openshell | awk '{print $1}' | xargs -r podman rm -f || true
# Stop and remove cluster container
echo "Stopping cluster container..."
podman stop openshell-cluster-openshell 2>/dev/null || true
podman rm openshell-cluster-openshell 2>/dev/null || true

# Stop and remove local registry container
echo "Stopping local registry..."
podman stop openshell-local-registry 2>/dev/null || true
podman rm openshell-local-registry 2>/dev/null || true

# Stop and remove any other OpenShell containers
echo "Cleaning up remaining OpenShell containers..."
podman ps -a | grep openshell | awk '{print $1}' | xargs -r podman rm -f 2>/dev/null || true

# Remove OpenShell images
echo "Removing OpenShell images..."
podman images | grep -E "openshell|cluster" | awk '{print $3}' | xargs -r podman rmi -f || true
podman rmi localhost/openshell/cluster:dev 2>/dev/null || true
podman rmi localhost/openshell/gateway:dev 2>/dev/null || true
podman images | grep -E "openshell|127.0.0.1:5000/openshell" | awk '{print $3}' | xargs -r podman rmi -f 2>/dev/null || true

# Remove CLI binary
echo "Removing CLI binary..."
Expand All @@ -41,8 +65,11 @@ rm -rf ~/.openshell
echo "Removing build artifacts..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
rm -rf target/
rm -rf deploy/docker/.build/
if command -v cargo &>/dev/null; then
echo " Running cargo clean..."
cargo clean 2>/dev/null || true
fi
rm -rf deploy/docker/.build/ 2>/dev/null || true

# Clean Podman cache
echo "Cleaning Podman build cache..."
Expand All @@ -51,6 +78,13 @@ podman system prune -af --volumes
echo ""
echo "=== Cleanup Complete ==="
echo ""
echo "OpenShell containers, images, and configuration have been removed."
echo ""
echo "To reinstall OpenShell:"
echo " 1. source scripts/podman.env"
echo " 2. mise run cluster:build:full"
echo " 3. cargo install --path crates/openshell-cli --root ~/.local"
echo ""
echo "To completely remove the OpenShell Podman machine:"
echo " podman machine stop openshell"
echo " podman machine rm openshell"
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ enum CliProviderType {
Gitlab,
Github,
Outlook,
Vertex,
}

#[derive(Clone, Debug, ValueEnum)]
Expand Down Expand Up @@ -646,6 +647,7 @@ impl CliProviderType {
Self::Gitlab => "gitlab",
Self::Github => "github",
Self::Outlook => "outlook",
Self::Vertex => "vertex",
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions crates/openshell-core/src/inference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,19 @@ static NVIDIA_PROFILE: InferenceProviderProfile = InferenceProviderProfile {
default_headers: &[],
};

static VERTEX_PROFILE: InferenceProviderProfile = InferenceProviderProfile {
provider_type: "vertex",
// Base URL template - actual URL constructed at request time with project/region/model
default_base_url: "https://us-central1-aiplatform.googleapis.com/v1",
protocols: ANTHROPIC_PROTOCOLS,
// Look for OAuth token first, fallback to project ID (for manual config)
credential_key_names: &["VERTEX_OAUTH_TOKEN", "ANTHROPIC_VERTEX_PROJECT_ID"],
base_url_config_keys: &["VERTEX_BASE_URL", "ANTHROPIC_VERTEX_REGION"],
// Vertex uses OAuth Bearer tokens, not x-api-key
auth: AuthHeader::Bearer,
default_headers: &[("anthropic-version", "vertex-2023-10-16")],
};

/// Look up the inference provider profile for a given provider type.
///
/// Returns `None` for provider types that don't support inference routing
Expand All @@ -95,6 +108,7 @@ pub fn profile_for(provider_type: &str) -> Option<&'static InferenceProviderProf
"openai" => Some(&OPENAI_PROFILE),
"anthropic" => Some(&ANTHROPIC_PROFILE),
"nvidia" => Some(&NVIDIA_PROFILE),
"vertex" => Some(&VERTEX_PROFILE),
_ => None,
}
}
Expand Down Expand Up @@ -176,6 +190,7 @@ mod tests {
assert!(profile_for("openai").is_some());
assert!(profile_for("anthropic").is_some());
assert!(profile_for("nvidia").is_some());
assert!(profile_for("vertex").is_some());
assert!(profile_for("OpenAI").is_some()); // case insensitive
}

Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-providers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ impl ProviderRegistry {
registry.register(providers::gitlab::GitlabProvider);
registry.register(providers::github::GithubProvider);
registry.register(providers::outlook::OutlookProvider);
registry.register(providers::vertex::VertexProvider);
registry
}

Expand Down Expand Up @@ -138,6 +139,7 @@ pub fn normalize_provider_type(input: &str) -> Option<&'static str> {
"gitlab" | "glab" => Some("gitlab"),
"github" | "gh" => Some("github"),
"outlook" => Some("outlook"),
"vertex" => Some("vertex"),
_ => None,
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-providers/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ pub mod nvidia;
pub mod openai;
pub mod opencode;
pub mod outlook;
pub mod vertex;
102 changes: 102 additions & 0 deletions crates/openshell-providers/src/providers/vertex.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use crate::{
DiscoveredProvider, ProviderDiscoverySpec, ProviderError, ProviderPlugin, RealDiscoveryContext,
discover_with_spec,
};

pub struct VertexProvider;

pub const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec {
id: "vertex",
credential_env_vars: &["ANTHROPIC_VERTEX_PROJECT_ID"],
};

// Additional config keys for Vertex AI
const VERTEX_CONFIG_KEYS: &[&str] = &["ANTHROPIC_VERTEX_REGION"];

impl ProviderPlugin for VertexProvider {
fn id(&self) -> &'static str {
SPEC.id
}

fn discover_existing(&self) -> Result<Option<DiscoveredProvider>, ProviderError> {
let mut discovered = discover_with_spec(&SPEC, &RealDiscoveryContext)?;

// Add region config if present
if let Some(ref mut provider) = discovered {
for &key in VERTEX_CONFIG_KEYS {
if let Ok(value) = std::env::var(key) {
provider.config.insert(key.to_string(), value);
}
}

// Set CLAUDE_CODE_USE_VERTEX=1 to enable Vertex AI in claude CLI
// Must be in credentials (not config) to be injected into sandbox environment
provider
.credentials
.insert("CLAUDE_CODE_USE_VERTEX".to_string(), "1".to_string());

// NOTE: We do NOT generate/store VERTEX_OAUTH_TOKEN here.
// OAuth tokens are short-lived (~1 hour) and storing them leads to stale token pollution.
// Instead, sandboxes generate fresh tokens on-demand from the uploaded ADC file
// (requires --upload ~/.config/gcloud/:.config/gcloud/ when creating sandbox).

// Warn if ADC doesn't exist on host
let adc_exists =
if let Ok(custom_path) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") {
std::path::Path::new(&custom_path).exists()
} else {
let default_path = format!(
"{}/.config/gcloud/application_default_credentials.json",
std::env::var("HOME").unwrap_or_default()
);
std::path::Path::new(&default_path).exists()
};

if !adc_exists {
eprintln!();
eprintln!("⚠️ Warning: GCP Application Default Credentials not found");
eprintln!(" Sandboxes will need ADC uploaded to generate OAuth tokens.");
eprintln!();
eprintln!(" Configure ADC with:");
eprintln!(" gcloud auth application-default login");
eprintln!();
eprintln!(" Or use a service account key:");
eprintln!(" export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json");
eprintln!();
eprintln!(" Then upload credentials when creating sandboxes:");
eprintln!(" openshell sandbox create --provider vertex \\");
eprintln!(" --upload ~/.config/gcloud/:.config/gcloud/");
eprintln!();
}
}

Ok(discovered)
}

fn credential_env_vars(&self) -> &'static [&'static str] {
SPEC.credential_env_vars
}
}

#[cfg(test)]
mod tests {
use super::SPEC;
use crate::discover_with_spec;
use crate::test_helpers::MockDiscoveryContext;

#[test]
fn discovers_vertex_env_credentials() {
let ctx =
MockDiscoveryContext::new().with_env("ANTHROPIC_VERTEX_PROJECT_ID", "my-gcp-project");
let discovered = discover_with_spec(&SPEC, &ctx)
.expect("discovery")
.expect("provider");
assert_eq!(
discovered.credentials.get("ANTHROPIC_VERTEX_PROJECT_ID"),
Some(&"my-gcp-project".to_string())
);
}
}
60 changes: 49 additions & 11 deletions crates/openshell-router/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ async fn send_backend_request(
headers: Vec<(String, String)>,
body: bytes::Bytes,
) -> Result<reqwest::Response, RouterError> {
let url = build_backend_url(&route.endpoint, path);
let url = build_backend_url(&route.endpoint, path, &route.model);

let reqwest_method: reqwest::Method = method
.parse()
Expand Down Expand Up @@ -137,13 +137,24 @@ async fn send_backend_request(

// Set the "model" field in the JSON body to the route's configured model so the
// backend receives the correct model ID regardless of what the client sent.
//
// Exception: Vertex AI's :streamRawPredict endpoint expects the model in the URL
// path (already handled in build_backend_url), not in the request body.
let is_vertex_ai = route.endpoint.contains("aiplatform.googleapis.com");

let body = match serde_json::from_slice::<serde_json::Value>(&body) {
Ok(mut json) => {
if let Some(obj) = json.as_object_mut() {
obj.insert(
"model".to_string(),
serde_json::Value::String(route.model.clone()),
);
if is_vertex_ai {
// Remove model field for Vertex AI (it's in the URL path)
obj.remove("model");
} else {
// Insert/override model field for standard backends
obj.insert(
"model".to_string(),
serde_json::Value::String(route.model.clone()),
);
}
}
bytes::Bytes::from(serde_json::to_vec(&json).unwrap_or_else(|_| body.to_vec()))
}
Expand Down Expand Up @@ -241,7 +252,7 @@ pub async fn verify_backend_endpoint(

if mock::is_mock_route(route) {
return Ok(ValidatedEndpoint {
url: build_backend_url(&route.endpoint, probe.path),
url: build_backend_url(&route.endpoint, probe.path, &route.model),
protocol: probe.protocol.to_string(),
});
}
Expand Down Expand Up @@ -306,7 +317,7 @@ async fn try_validation_request(
details,
},
})?;
let url = build_backend_url(&route.endpoint, path);
let url = build_backend_url(&route.endpoint, path, &route.model);

if response.status().is_success() {
return Ok(ValidatedEndpoint {
Expand Down Expand Up @@ -418,8 +429,23 @@ pub async fn proxy_to_backend_streaming(
})
}

fn build_backend_url(endpoint: &str, path: &str) -> String {
fn build_backend_url(endpoint: &str, path: &str, model: &str) -> String {
let base = endpoint.trim_end_matches('/');

// Special handling for Vertex AI
if base.contains("aiplatform.googleapis.com") && path.starts_with("/v1/messages") {
// Vertex AI uses a different path structure:
// https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/anthropic/models/{model}:streamRawPredict
// The base already has everything up to /models, so we append /{model}:streamRawPredict
let model_suffix = if model.is_empty() {
String::new()
} else {
format!("/{}", model)
};
return format!("{}{}:streamRawPredict", base, model_suffix);
}

// Deduplicate /v1 prefix for standard endpoints
if base.ends_with("/v1") && (path == "/v1" || path.starts_with("/v1/")) {
return format!("{base}{}", &path[3..]);
}
Expand All @@ -438,23 +464,35 @@ mod tests {
#[test]
fn build_backend_url_dedupes_v1_prefix() {
assert_eq!(
build_backend_url("https://api.openai.com/v1", "/v1/chat/completions"),
build_backend_url("https://api.openai.com/v1", "/v1/chat/completions", "gpt-4"),
"https://api.openai.com/v1/chat/completions"
);
}

#[test]
fn build_backend_url_preserves_non_versioned_base() {
assert_eq!(
build_backend_url("https://api.anthropic.com", "/v1/messages"),
build_backend_url("https://api.anthropic.com", "/v1/messages", "claude-3"),
"https://api.anthropic.com/v1/messages"
);
}

#[test]
fn build_backend_url_handles_vertex_ai() {
assert_eq!(
build_backend_url(
"https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/anthropic/models",
"/v1/messages",
"claude-3-5-sonnet-20241022"
),
"https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet-20241022:streamRawPredict"
);
}

#[test]
fn build_backend_url_handles_exact_v1_path() {
assert_eq!(
build_backend_url("https://api.openai.com/v1", "/v1"),
build_backend_url("https://api.openai.com/v1", "/v1", "gpt-4"),
"https://api.openai.com/v1"
);
}
Expand Down
Loading
Loading