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
1 change: 1 addition & 0 deletions .agents/skills/debug-inference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ Check:
- `anthropic` for Anthropic Messages API
- `nvidia` for NVIDIA-hosted OpenAI-compatible endpoints
- `deepinfra` for DeepInfra's OpenAI-compatible endpoint
- `tars` for Tetrate Agent Router Service; serves OpenAI Chat Completions, Responses, Embeddings, and Anthropic Messages from one base URL
- `google-vertex-ai` for Vertex AI; Claude models use Anthropic Messages and other models use OpenAI Chat Completions
- `aws-bedrock` only through a configured Bedrock-compatible bridge today
- Required credential key exists
Expand Down
2 changes: 1 addition & 1 deletion architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ Cluster inference routes store only `provider_name`, `model_id`, and optional
timeout. The gateway resolves endpoint URLs, protocols, credentials, auth
style, and route-shaping metadata from the provider record when supervisors call
`GetInferenceBundle`. Supported provider types for cluster inference are
`openai`, `anthropic`, `nvidia`, `deepinfra`, and `google-vertex-ai`.
`openai`, `anthropic`, `nvidia`, `deepinfra`, `tars`, and `google-vertex-ai`.

The bundle carries enough information for sandbox-local routers to construct
upstream URLs without re-deriving provider-specific routing logic. Each resolved
Expand Down
109 changes: 108 additions & 1 deletion crates/openshell-core/src/inference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ const ANTHROPIC_PROTOCOLS: &[&str] = &["anthropic_messages", "model_discovery"];
/// base-URL-override escape hatch path.
const VERTEX_AI_PROTOCOLS: &[&str] = &["anthropic_messages", "model_discovery"];

/// Protocol set for the TARS (Tetrate Agent Router Service) profile. TARS is an
/// LLM traffic router that serves both OpenAI-compatible endpoints and the
/// Anthropic Messages API on the same host, so a single route advertises both
/// protocol families and `inference.local` serves OpenAI-style and
/// Anthropic-style clients simultaneously.
const TARS_PROTOCOLS: &[&str] = &[
"openai_chat_completions",
"openai_responses",
"openai_embeddings",
"anthropic_messages",
"model_discovery",
];

// `aws_bedrock_invoke_stream` (`/model/{id}/invoke-with-response-stream`) is
// deferred to a follow-up alongside protocol-aware AWS event-stream error
// handling: the shared streaming relay's truncation/timeout path injects
Expand Down Expand Up @@ -177,6 +190,36 @@ static DEEPINFRA_PROFILE: InferenceProviderProfile = InferenceProviderProfile {
passthrough_headers: &["x-model-id"],
};

// TARS (Tetrate Agent Router Service) — a multi-tenant LLM traffic router.
// The default base URL is the Tetrate-hosted SaaS gateway; enterprise
// deployments override it with `TARS_BASE_URL` (customer-specific hostname).
// TARS accepts `Authorization: Bearer sk-<key>` on both its OpenAI-compatible
// and Anthropic Messages routes, so `Bearer` covers the whole protocol set.
//
// `default_headers` is intentionally empty even though this profile serves
// `anthropic_messages`, where `ANTHROPIC_PROFILE` injects `anthropic-version`.
// Route default headers are applied to every request on the route regardless
// of the request's protocol (there is no per-protocol hook — see
// `route_headers_for_route`), so injecting `anthropic-version` here would also
// stamp it onto `/v1/chat/completions` and `/v1/embeddings`, where a
// translating gateway may read it as a protocol-family hint. Anthropic clients
// send the header themselves and it is on the passthrough allowlist below.
static TARS_PROFILE: InferenceProviderProfile = InferenceProviderProfile {
provider_type: "tars",
default_base_url: "https://api.router.tetrate.ai/v1",
protocols: TARS_PROTOCOLS,
credential_key_names: &["TARS_API_KEY", "AGENTROUTER_API_KEY"],
base_url_config_keys: &["TARS_BASE_URL", "AGENTROUTER_BASE_URL"],
auth: AuthHeader::Bearer,
default_headers: &[],
passthrough_headers: &[
"anthropic-version",
"anthropic-beta",
"openai-organization",
"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,
Expand Down Expand Up @@ -222,6 +265,7 @@ pub fn normalize_inference_provider_type(input: &str) -> Option<&'static str> {
"anthropic" => Some("anthropic"),
"nvidia" => Some("nvidia"),
"deepinfra" => Some("deepinfra"),
"tars" | "agentrouter" | "tetrate-agent-router" => Some("tars"),
"aws-bedrock" => Some("aws-bedrock"),
"google-vertex-ai" | "vertex" | "vertex-ai" | "google-vertex" | "gcp-vertex" => {
Some("google-vertex-ai")
Expand All @@ -240,6 +284,7 @@ pub fn profile_for(provider_type: &str) -> Option<&'static InferenceProviderProf
"anthropic" => Some(&ANTHROPIC_PROFILE),
"nvidia" => Some(&NVIDIA_PROFILE),
"deepinfra" => Some(&DEEPINFRA_PROFILE),
"tars" => Some(&TARS_PROFILE),
"google-vertex-ai" => Some(&VERTEX_AI_PROFILE),
"aws-bedrock" => Some(&AWS_BEDROCK_PROFILE),
_ => None,
Expand Down Expand Up @@ -403,9 +448,71 @@ mod tests {
assert_eq!(profile.auth, AuthHeader::Bearer);
}

#[test]
fn profile_for_tars_types() {
for key in &["tars", "agentrouter", "tetrate-agent-router", "TARS"] {
let profile = profile_for(key).expect("tars profile should be Some");
assert_eq!(profile.provider_type, "tars");
}
}

#[test]
fn tars_profile_advertises_both_protocol_families() {
let profile = profile_for("tars").expect("tars profile should exist");
assert_eq!(profile.default_base_url, "https://api.router.tetrate.ai/v1");
assert_eq!(profile.auth, AuthHeader::Bearer);
// TARS serves OpenAI-compatible and Anthropic Messages routes on the
// same host, so one route covers both client families.
for protocol in [
"openai_chat_completions",
"openai_responses",
"openai_embeddings",
"anthropic_messages",
"model_discovery",
] {
assert!(
profile.protocols.contains(&protocol),
"tars should route {protocol}"
);
}
}

#[test]
fn tars_profile_omits_legacy_completions() {
let profile = profile_for("tars").expect("tars profile should exist");
assert!(!profile.protocols.contains(&"openai_completions"));
}

/// Unlike `ANTHROPIC_PROFILE`, the dual-family TARS profile must not inject
/// `anthropic-version` as a route default: route default headers apply to
/// every request on the route, so it would also land on the OpenAI-family
/// paths TARS serves from the same base URL.
#[test]
fn tars_profile_injects_no_default_headers() {
let profile = profile_for("tars").expect("tars profile should exist");
assert!(profile.default_headers.is_empty());
assert!(
profile.passthrough_headers.contains(&"anthropic-version"),
"anthropic clients must still be able to send anthropic-version"
);
}

#[test]
fn route_headers_for_tars_include_both_families_passthrough() {
let (auth, headers, passthrough_headers) = route_headers_for_provider_type("tars");
assert_eq!(auth, AuthHeader::Bearer);
assert!(headers.is_empty());
for name in ["anthropic-version", "anthropic-beta", "openai-organization"] {
assert!(
passthrough_headers.iter().any(|h| h == name),
"tars should pass through {name}"
);
}
}

#[test]
fn openai_compatible_profiles_include_embeddings() {
for provider_type in ["openai", "nvidia", "deepinfra"] {
for provider_type in ["openai", "nvidia", "deepinfra", "tars"] {
let profile = profile_for(provider_type).expect("provider profile should exist");
assert!(
profile.protocols.contains(&"openai_embeddings"),
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-core/src/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ pub enum ProviderProfile {
Openai,
Opencode,
Outlook,
Tars,
Custom,
}

Expand All @@ -230,6 +231,7 @@ impl ProviderProfile {
Self::Openai => "openai",
Self::Opencode => "opencode",
Self::Outlook => "outlook",
Self::Tars => "tars",
Self::Custom => "custom",
}
}
Expand All @@ -248,6 +250,7 @@ impl ProviderProfile {
"openai" => Self::Openai,
"opencode" => Self::Opencode,
"outlook" => Self::Outlook,
"tars" | "agentrouter" | "tetrate-agent-router" => Self::Tars,
_ => Self::Custom,
}
}
Expand Down
7 changes: 7 additions & 0 deletions crates/openshell-providers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ impl ProviderRegistry {
registry.register(providers::anthropic::SPEC);
registry.register(providers::nvidia::SPEC);
registry.register(providers::deepinfra::SPEC);
registry.register(providers::tars::SPEC);
registry.register(providers::github::SPEC);
registry.register(providers::gitlab::SPEC);
registry.register(providers::google_cloud::GoogleCloudProvider);
Expand Down Expand Up @@ -241,6 +242,12 @@ mod tests {
normalize_provider_type("vertex-ai"),
Some("google-vertex-ai")
);
assert_eq!(normalize_provider_type("tars"), Some("tars"));
assert_eq!(normalize_provider_type("agentrouter"), Some("tars"));
assert_eq!(
normalize_provider_type("tetrate-agent-router"),
Some("tars")
);
assert_eq!(normalize_provider_type("unknown"), None);
}

Expand Down
1 change: 1 addition & 0 deletions crates/openshell-providers/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[
include_str!("../../../providers/google-vertex-ai.yaml"),
include_str!("../../../providers/nvidia.yaml"),
include_str!("../../../providers/pypi.yaml"),
include_str!("../../../providers/tars.yaml"),
];

#[derive(Debug, thiserror::Error)]
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 @@ -43,4 +43,5 @@ pub mod nvidia;
pub mod openai;
pub mod opencode;
pub mod outlook;
pub mod tars;
pub mod vertex;
15 changes: 15 additions & 0 deletions crates/openshell-providers/src/providers/tars.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use crate::ProviderDiscoverySpec;

pub const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec {
id: "tars",
credential_env_vars: &["TARS_API_KEY", "AGENTROUTER_API_KEY"],
};

test_discovers_env_credential!(
discovers_tars_env_credentials,
"TARS_API_KEY",
"sk-tars-test-123"
);
4 changes: 3 additions & 1 deletion crates/openshell-server/src/grpc/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2902,6 +2902,7 @@ fn telemetry_provider_profile(provider_type: &str) -> TelemetryProviderProfile {
Some("openai") => TelemetryProviderProfile::Openai,
Some("opencode") => TelemetryProviderProfile::Opencode,
Some("outlook") => TelemetryProviderProfile::Outlook,
Some("tars") => TelemetryProviderProfile::Tars,
_ => TelemetryProviderProfile::Custom,
}
}
Expand Down Expand Up @@ -3770,7 +3771,8 @@ mod tests {
"google-cloud",
"google-vertex-ai",
"nvidia",
"pypi"
"pypi",
"tars"
]
);

Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-server/src/inference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ fn resolve_provider_route(
let profile = openshell_core::inference::profile_for(&provider_type).ok_or_else(|| {
Status::invalid_argument(format!(
"provider '{name}' has unsupported type '{raw_provider_type}' for cluster inference \
(supported: openai, anthropic, nvidia, deepinfra, google-vertex-ai, aws-bedrock)",
(supported: openai, anthropic, nvidia, deepinfra, tars, google-vertex-ai, aws-bedrock)",
name = provider.object_name()
))
})?;
Expand Down
2 changes: 1 addition & 1 deletion docs/sandboxes/inference-routing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ If code calls an external inference host directly, OpenShell evaluates that traf
| Property | Detail |
|---|---|
| Credentials | No sandbox API keys needed. Credentials come from the configured provider record. The router strips caller-supplied `Authorization` before forwarding the request. |
| Header forwarding | `inference.local` forwards only a per-provider header allowlist. OpenAI routes allow `openai-organization` and `x-model-id`. Anthropic routes allow `anthropic-version` and `anthropic-beta`. Vertex Claude rawPredict routes strip `anthropic-beta` and do not forward `anthropic-version` as a header because the router injects `anthropic_version` into the Vertex request body. NVIDIA routes allow `x-model-id`. AWS Bedrock routes have no passthrough headers today. All other caller headers are stripped. |
| Header forwarding | `inference.local` forwards only a per-provider header allowlist. OpenAI routes allow `openai-organization` and `x-model-id`. Anthropic routes allow `anthropic-version` and `anthropic-beta`. Vertex Claude rawPredict routes strip `anthropic-beta` and do not forward `anthropic-version` as a header because the router injects `anthropic_version` into the Vertex request body. NVIDIA routes allow `x-model-id`. TARS routes serve both families from one base URL, so they allow `anthropic-version`, `anthropic-beta`, `openai-organization`, and `x-model-id`. AWS Bedrock routes have no passthrough headers today. All other caller headers are stripped. |
| Configuration | One provider and one model define sandbox inference for the active gateway. Every sandbox on that gateway sees the same `inference.local` backend. |
| Provider support | NVIDIA, Anthropic, Google Vertex AI, AWS Bedrock (via a translating bridge — direct AWS with SigV4 signing is a separate follow-up), and any OpenAI-compatible provider all work through the same endpoint. Vertex routes Claude models through `/v1/messages` and non-Anthropic models through `/v1/chat/completions`. The gateway resolves the upstream Vertex host from the provider config, including regional, global, and supported multi-region endpoints. |
| Streaming reliability | The router tolerates idle gaps of up to 120 seconds between streamed chunks so long reasoning responses are not cut off mid-stream. |
Expand Down
2 changes: 2 additions & 0 deletions docs/sandboxes/manage-providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ The following provider types are supported.
| `nvidia` | `NVIDIA_API_KEY` | NVIDIA API Catalog |
| `openai` | `OPENAI_API_KEY` | Any OpenAI-compatible endpoint. Set `--config OPENAI_BASE_URL` to point to the provider. Refer to [Inference Routing](/sandboxes/inference-routing). |
| `opencode` | `OPENCODE_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY` | OpenCode |
| `tars` | `TARS_API_KEY`, `AGENTROUTER_API_KEY` | Tetrate Agent Router Service (TARS). Aliases: `agentrouter`, `tetrate-agent-router`. Serves OpenAI-compatible and Anthropic Messages clients through one provider. Set `--config TARS_BASE_URL` for enterprise (self-hosted) gateways. |

<Note>
`ANTHROPIC_API_KEY` is an API key from [console.anthropic.com](https://console.anthropic.com), not a subscription token. Subscription users must generate a separate API key from the Anthropic Console.
Expand Down Expand Up @@ -366,6 +367,7 @@ The following providers have been tested with `inference.local`. Any provider th
| Groq | `groq` | `openai` | `https://api.groq.com/openai/v1` | `OPENAI_API_KEY` |
| Ollama (local) | `ollama` | `openai` | `http://host.openshell.internal:11434/v1` | `OPENAI_API_KEY` |
| LM Studio (local) | `lmstudio` | `openai` | `http://host.openshell.internal:1234/v1` | `OPENAI_API_KEY` |
| Tetrate Agent Router Service | `tars` | `tars` | `https://api.router.tetrate.ai/v1` (SaaS) or enterprise `TARS_BASE_URL` | `TARS_API_KEY` |

Refer to your provider's documentation for the correct base URL, available models, and API key setup. For the Vertex-specific auth flows and config keys, refer to [Google Vertex AI](/providers/google-vertex-ai). To configure inference routing, refer to [Inference Routing](/sandboxes/inference-routing).

Expand Down
1 change: 1 addition & 0 deletions docs/sandboxes/providers-v2.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Built-in Providers v2 profiles currently include:
| `google-vertex-ai` | `inference` | `GOOGLE_SERVICE_ACCOUNT_KEY`, `GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN`, `VERTEX_AI_SERVICE_ACCOUNT_TOKEN`, `GOOGLE_VERTEX_AI_TOKEN`, `VERTEX_AI_TOKEN` |
| `nvidia` | `inference` | `NVIDIA_API_KEY` |
| `pypi` | `data` | None |
| `tars` | `inference` | `TARS_API_KEY`, `AGENTROUTER_API_KEY` |

Export a built-in profile as YAML:

Expand Down
27 changes: 27 additions & 0 deletions providers/tars.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

id: tars
display_name: Tetrate Agent Router Service
description: Tetrate Agent Router Service (TARS) LLM traffic router
category: inference
inference_capable: true
credentials:
- name: api_key
description: TARS inference API key
env_vars: [TARS_API_KEY, AGENTROUTER_API_KEY]
required: true
auth_style: bearer
header_name: authorization
discovery:
credentials: [api_key]
endpoints:
# Tetrate-hosted SaaS gateway. Enterprise deployments use a
# customer-specific hostname: override the provider base URL with
# `TARS_BASE_URL` and add the gateway host to the sandbox policy.
- host: api.router.tetrate.ai
port: 443
protocol: rest
access: read-write
enforcement: enforce
binaries: [/usr/bin/curl, /usr/local/bin/curl]
Loading