diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs
index 58b5feb2a..705aae300 100644
--- a/crates/openshell-server/src/inference.rs
+++ b/crates/openshell-server/src/inference.rs
@@ -10,7 +10,7 @@ use openshell_core::inference::{
use openshell_core::proto::{
ClusterInferenceConfig, GetClusterInferenceRequest, GetClusterInferenceResponse,
GetInferenceBundleRequest, GetInferenceBundleResponse, InferenceRoute, Provider, ResolvedRoute,
- SetClusterInferenceRequest, SetClusterInferenceResponse, ValidatedEndpoint,
+ Sandbox, SetClusterInferenceRequest, SetClusterInferenceResponse, ValidatedEndpoint,
inference_server::Inference,
};
use openshell_providers::normalize_provider_type;
@@ -54,6 +54,98 @@ fn effective_route_name(name: &str) -> Result<&str, Status> {
}
}
+fn admin_role_name(state: &ServerState) -> &str {
+ state
+ .config
+ .oidc
+ .as_ref()
+ .map_or("", |oidc| &oidc.admin_role)
+}
+
+/// Produce the store-level route name, scoping by owner for non-admin callers.
+/// Admin callers get the global (unscoped) name; non-admin callers get
+/// `"/"` for per-user isolation.
+fn scoped_route_name(
+ base_name: &str,
+ principal: Option<&crate::auth::principal::Principal>,
+ admin_role: &str,
+) -> Result {
+ let Some(principal) = principal else {
+ return Ok(base_name.to_string());
+ };
+ match principal {
+ crate::auth::principal::Principal::User(user) => {
+ if !admin_role.is_empty() && user.identity.roles.iter().any(|r| r == admin_role) {
+ return Ok(base_name.to_string());
+ }
+ let owner = crate::auth::ownership::sanitize_subject(&user.identity.subject)?;
+ Ok(format!("{base_name}/{owner}"))
+ }
+ crate::auth::principal::Principal::Anonymous => Ok(base_name.to_string()),
+ crate::auth::principal::Principal::Sandbox(_) => Err(Status::unauthenticated(
+ "authenticated user identity required for inference operations",
+ )),
+ }
+}
+
+/// Look up the caller's scoped route; fall back to the global route.
+async fn resolve_get_route(
+ store: &Store,
+ base_name: &str,
+ principal: Option<&crate::auth::principal::Principal>,
+ admin_role: &str,
+) -> Result {
+ let scoped = scoped_route_name(base_name, principal, admin_role)?;
+
+ if scoped == base_name {
+ return store
+ .get_message_by_name::(base_name)
+ .await
+ .map_err(|e| Status::internal(format!("fetch route failed: {e}")))?
+ .ok_or_else(|| not_configured_error(base_name));
+ }
+
+ if let Some(route) = store
+ .get_message_by_name::(&scoped)
+ .await
+ .map_err(|e| Status::internal(format!("fetch route failed: {e}")))?
+ {
+ return Ok(route);
+ }
+
+ store
+ .get_message_by_name::(base_name)
+ .await
+ .map_err(|e| Status::internal(format!("fetch route failed: {e}")))?
+ .ok_or_else(|| not_configured_error(base_name))
+}
+
+fn not_configured_error(route_name: &str) -> Status {
+ Status::not_found(format!(
+ "inference route '{route_name}' is not configured; run \
+ 'openshell inference set --provider --model '"
+ ))
+}
+
+/// Resolve a route with owner-scoping for sandbox bundle: try per-user first,
+/// fall back to global. The returned route name is always the BASE name so the
+/// sandbox proxy is unaffected.
+async fn resolve_route_for_owner(
+ store: &Store,
+ base_name: &str,
+ owner: Option<&str>,
+) -> Result