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
2 changes: 1 addition & 1 deletion .ai/spec/what/sandbox-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,5 @@ Behavioral specification for how workflow steps run inside ephemeral **sandboxes

- [PLANNED: OLS-2957] **Sandbox template management** UX and CRD ergonomics (base/derived lifecycle, versioning) may change operator/template coupling described in rules 2–4.
- [PLANNED: OLS-3038] **TLS verification and network policy** for agent traffic may replace permissive internal TLS client behavior.
- [PLANNED: OLS-3044] **Provider parity**: environment variable contract for non-Claude providers in templates MUST track sandbox image capabilities.
- **Sandbox provider selection**: Derived templates MUST set `LIGHTSPEED_AGENT_PROVIDER` from `LLMProvider.spec.type` (`Anthropic`/`AWSBedrock` → `claude`; `OpenAI`/`AzureOpenAI` → `openai`; `GoogleCloudVertex` → routed by `googleCloudVertex.modelProvider`: `Anthropic` → `claude`, `Google` → `gemini`, `OpenAI` → `openai`). Model env var follows the provider: `ANTHROPIC_MODEL`, `GEMINI_MODEL`, or `OPENAI_MODEL`.
- [PLANNED: OLS-2894] Support **multiple concurrent skills images** in template derivation beyond the first `skills` entry if product requires composite skill bundles.
73 changes: 68 additions & 5 deletions controller/proposal/sandbox_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ var sandboxTemplateGVK = schema.GroupVersionKind{
}

const (
agentModeEnvVar = "LIGHTSPEED_MODE"
agentModeEnvVar = "LIGHTSPEED_MODE"
sandboxAgentProviderEnvVar = "LIGHTSPEED_AGENT_PROVIDER"

vertexCredsMountPath = "/var/secrets/google"
vertexCredsFileName = "credentials.json"
Expand Down Expand Up @@ -245,14 +246,74 @@ func providerURL(llm *agenticv1alpha1.LLMProvider) string {
}
}

func vertexSandboxAgentProvider(mp agenticv1alpha1.GoogleCloudVertexModelProvider) (string, error) {
switch mp {
case agenticv1alpha1.GoogleCloudVertexModelProviderAnthropic:
return "claude", nil
case agenticv1alpha1.GoogleCloudVertexModelProviderGoogle:
return "gemini", nil
case agenticv1alpha1.GoogleCloudVertexModelProviderOpenAI:
return "openai", nil
default:
return "", fmt.Errorf("unsupported googleCloudVertex.modelProvider %q", mp)
}
}

func sandboxAgentProviderValue(llm *agenticv1alpha1.LLMProvider) (string, error) {
switch llm.Spec.Type {
case agenticv1alpha1.LLMProviderAnthropic, agenticv1alpha1.LLMProviderAWSBedrock:
return "claude", nil
case agenticv1alpha1.LLMProviderGoogleCloudVertex:
return vertexSandboxAgentProvider(llm.Spec.GoogleCloudVertex.ModelProvider)
case agenticv1alpha1.LLMProviderOpenAI, agenticv1alpha1.LLMProviderAzureOpenAI:
return "openai", nil
default:
return "", fmt.Errorf("unsupported LLM provider type %q", llm.Spec.Type)
}
}

func vertexModelEnvVar(mp agenticv1alpha1.GoogleCloudVertexModelProvider) string {
switch mp {
case agenticv1alpha1.GoogleCloudVertexModelProviderAnthropic:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we 3 different variable names for different providers just to map it back to single value in sandbox router? https://github.com/openshift/lightspeed-agentic-sandbox/blob/578aa4b5931571bc2eac05580e9faff09630887c/src/lightspeed_agentic/routes/__init__.py#L32

The only reason I've found was setting the default model, which could be simplified bu just setting the default based on the provider. Exposing 3 different vars for the same thing here feels like a leaky abtraction. IMO it would be better for the sandbox just to get MODEL_PROVIDER and MODEL, and doing the rest inside sandbox codebase.

return "ANTHROPIC_MODEL"
case agenticv1alpha1.GoogleCloudVertexModelProviderGoogle:
return "GEMINI_MODEL"
case agenticv1alpha1.GoogleCloudVertexModelProviderOpenAI:
return "OPENAI_MODEL"
default:
return ""
}
}

func modelEnvVarName(llm *agenticv1alpha1.LLMProvider) string {
switch llm.Spec.Type {
case agenticv1alpha1.LLMProviderOpenAI, agenticv1alpha1.LLMProviderAzureOpenAI:
return "OPENAI_MODEL"
case agenticv1alpha1.LLMProviderGoogleCloudVertex:
return vertexModelEnvVar(llm.Spec.GoogleCloudVertex.ModelProvider)
default:
return "ANTHROPIC_MODEL"
}
}

func patchLLMCredentials(tmpl *unstructured.Unstructured, llm *agenticv1alpha1.LLMProvider, model string) error {
secretName := credentialsSecretName(llm)

if err := addEnvFromSecret(tmpl, secretName); err != nil {
return fmt.Errorf("add credentials envFrom: %w", err)
}
if err := setEnvVar(tmpl, "ANTHROPIC_MODEL", model); err != nil {
return fmt.Errorf("set ANTHROPIC_MODEL: %w", err)

providerValue, err := sandboxAgentProviderValue(llm)
if err != nil {
return err
}
if err := setEnvVar(tmpl, sandboxAgentProviderEnvVar, providerValue); err != nil {
return fmt.Errorf("set %s: %w", sandboxAgentProviderEnvVar, err)
}

modelEnv := modelEnvVarName(llm)
if err := setEnvVar(tmpl, modelEnv, model); err != nil {
return fmt.Errorf("set %s: %w", modelEnv, err)
}

if u := providerURL(llm); u != "" {
Expand All @@ -264,8 +325,10 @@ func patchLLMCredentials(tmpl *unstructured.Unstructured, llm *agenticv1alpha1.L
switch llm.Spec.Type {
case agenticv1alpha1.LLMProviderGoogleCloudVertex:
cfg := llm.Spec.GoogleCloudVertex
if err := setEnvVar(tmpl, "CLAUDE_CODE_USE_VERTEX", "1"); err != nil {
return fmt.Errorf("set CLAUDE_CODE_USE_VERTEX: %w", err)
if cfg.ModelProvider == agenticv1alpha1.GoogleCloudVertexModelProviderAnthropic {
if err := setEnvVar(tmpl, "CLAUDE_CODE_USE_VERTEX", "1"); err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the operator really need to know about CLAUDE_CODE_USE_VERTEX. This feels like sandbox-specifc thing. Doing it here creates high coupling beween the specific sandbox implementation and the operator (that IMO should not care how exactly the sandbox is setup)

return fmt.Errorf("set CLAUDE_CODE_USE_VERTEX: %w", err)
}
}
if err := setEnvVar(tmpl, "GCP_PROJECT", cfg.ProjectID); err != nil {
return fmt.Errorf("set GCP_PROJECT: %w", err)
Expand Down
214 changes: 187 additions & 27 deletions controller/proposal/sandbox_templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func testLLMProvider(providerType agenticv1alpha1.LLMProviderType) *agenticv1alp
case agenticv1alpha1.LLMProviderAnthropic:
spec.Anthropic = agenticv1alpha1.AnthropicConfig{CredentialsSecret: creds}
case agenticv1alpha1.LLMProviderGoogleCloudVertex:
spec.GoogleCloudVertex = agenticv1alpha1.GoogleCloudVertexConfig{CredentialsSecret: creds, ProjectID: "test-project", Region: "us-central1"}
spec.GoogleCloudVertex = agenticv1alpha1.GoogleCloudVertexConfig{CredentialsSecret: creds, ProjectID: "test-project", Region: "us-central1", ModelProvider: agenticv1alpha1.GoogleCloudVertexModelProviderAnthropic}
case agenticv1alpha1.LLMProviderOpenAI:
spec.OpenAI = agenticv1alpha1.OpenAIConfig{CredentialsSecret: creds}
case agenticv1alpha1.LLMProviderAzureOpenAI:
Expand Down Expand Up @@ -43,6 +43,21 @@ func testLLMProviderWithURL(providerType agenticv1alpha1.LLMProviderType, u stri
return p
}

func testVertexLLMProvider(mp agenticv1alpha1.GoogleCloudVertexModelProvider) *agenticv1alpha1.LLMProvider {
creds := agenticv1alpha1.SecretReference{Name: "my-llm-secret"}
return &agenticv1alpha1.LLMProvider{
Spec: agenticv1alpha1.LLMProviderSpec{
Type: agenticv1alpha1.LLMProviderGoogleCloudVertex,
GoogleCloudVertex: agenticv1alpha1.GoogleCloudVertexConfig{
CredentialsSecret: creds,
ProjectID: "test-project",
Region: "us-central1",
ModelProvider: mp,
},
},
}
}

func emptyTemplate() *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]any{
Expand Down Expand Up @@ -246,6 +261,12 @@ func TestPatchLLMCredentials_Anthropic(t *testing.T) {
}

envs := getEnvVars(tmpl)
if e, ok := findEnv(envs, "LIGHTSPEED_AGENT_PROVIDER"); !ok {
t.Error("missing LIGHTSPEED_AGENT_PROVIDER")
} else if e["value"] != "claude" {
t.Errorf("LIGHTSPEED_AGENT_PROVIDER = %q, want claude", e["value"])
}

if e, ok := findEnv(envs, "ANTHROPIC_MODEL"); !ok {
t.Error("missing ANTHROPIC_MODEL")
} else if e["value"] != "claude-opus-4-6" {
Expand All @@ -259,56 +280,195 @@ func TestPatchLLMCredentials_Anthropic(t *testing.T) {
}
}

// Vertex env contract per modelProvider:
//
// modelProvider LIGHTSPEED_AGENT_PROVIDER Model env var CLAUDE_CODE_USE_VERTEX
// ───────────── ───────────────────────── ──────────────── ──────────────────────
// Anthropic claude ANTHROPIC_MODEL "1"
// Google gemini GEMINI_MODEL (absent)
// OpenAI openai OPENAI_MODEL (absent)
//
// All three always set: GCP_PROJECT, GCP_REGION, GOOGLE_APPLICATION_CREDENTIALS,
// credentials volume mount at /etc/llm-credentials.
func TestPatchLLMCredentials_Vertex(t *testing.T) {
tests := []struct {
name string
modelProvider agenticv1alpha1.GoogleCloudVertexModelProvider
model string
wantProvider string
wantModelEnv string
wantVertex bool // CLAUDE_CODE_USE_VERTEX="1"
}{
{
name: "Anthropic",
modelProvider: agenticv1alpha1.GoogleCloudVertexModelProviderAnthropic,
model: "claude-opus-4-6",
wantProvider: "claude",
wantModelEnv: "ANTHROPIC_MODEL",
wantVertex: true,
},
{
name: "Google",
modelProvider: agenticv1alpha1.GoogleCloudVertexModelProviderGoogle,
model: "gemini-2.5-flash",
wantProvider: "gemini",
wantModelEnv: "GEMINI_MODEL",
wantVertex: false,
},
{
name: "OpenAI",
modelProvider: agenticv1alpha1.GoogleCloudVertexModelProviderOpenAI,
model: "gpt-4.1",
wantProvider: "openai",
wantModelEnv: "OPENAI_MODEL",
wantVertex: false,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpl := emptyTemplate()
llm := testVertexLLMProvider(tc.modelProvider)

if err := patchLLMCredentials(tmpl, llm, tc.model); err != nil {
t.Fatalf("patchLLMCredentials: %v", err)
}

if !hasSecretEnvFrom(tmpl, "my-llm-secret") {
t.Error("missing envFrom secretRef for my-llm-secret")
}

envs := getEnvVars(tmpl)

if e, ok := findEnv(envs, "LIGHTSPEED_AGENT_PROVIDER"); !ok {
t.Error("missing LIGHTSPEED_AGENT_PROVIDER")
} else if e["value"] != tc.wantProvider {
t.Errorf("LIGHTSPEED_AGENT_PROVIDER = %q, want %q", e["value"], tc.wantProvider)
}

if e, ok := findEnv(envs, tc.wantModelEnv); !ok {
t.Errorf("missing %s", tc.wantModelEnv)
} else if e["value"] != tc.model {
t.Errorf("%s = %q, want %q", tc.wantModelEnv, e["value"], tc.model)
}

if _, ok := findEnv(envs, "CLAUDE_CODE_USE_VERTEX"); ok != tc.wantVertex {
if tc.wantVertex {
t.Error("missing CLAUDE_CODE_USE_VERTEX")
} else {
t.Error("CLAUDE_CODE_USE_VERTEX should not be set")
}
}

if e, ok := findEnv(envs, "GCP_PROJECT"); !ok {
t.Error("missing GCP_PROJECT")
} else if e["value"] != "test-project" {
t.Errorf("GCP_PROJECT = %q", e["value"])
}

if e, ok := findEnv(envs, "GCP_REGION"); !ok {
t.Error("missing GCP_REGION")
} else if e["value"] != "us-central1" {
t.Errorf("GCP_REGION = %q", e["value"])
}

if e, ok := findEnv(envs, "GOOGLE_APPLICATION_CREDENTIALS"); !ok {
t.Error("missing GOOGLE_APPLICATION_CREDENTIALS")
} else if e["value"] != vertexCredsMountPath+"/"+vertexCredsFileName {
t.Errorf("GOOGLE_APPLICATION_CREDENTIALS = %q", e["value"])
}

containers, _, _ := unstructured.NestedSlice(tmpl.Object, "spec", "podTemplate", "spec", "containers")
container := containers[0].(map[string]any)
mounts, _, _ := unstructured.NestedSlice(container, "volumeMounts")
if len(mounts) != 1 {
t.Fatalf("expected 1 volume mount, got %d", len(mounts))
}
mount := mounts[0].(map[string]any)
if mount["name"] != llmCredsVolumeName || mount["mountPath"] != vertexCredsMountPath {
t.Errorf("mount = %v", mount)
}
})
}
}

func TestPatchLLMCredentials_Bedrock(t *testing.T) {
tmpl := emptyTemplate()
llm := testLLMProvider(agenticv1alpha1.LLMProviderGoogleCloudVertex)
llm := testLLMProvider(agenticv1alpha1.LLMProviderAWSBedrock)

if err := patchLLMCredentials(tmpl, llm, "claude-opus-4-6"); err != nil {
t.Fatalf("patchLLMCredentials: %v", err)
}

if !hasSecretEnvFrom(tmpl, "my-llm-secret") {
t.Error("missing envFrom secretRef for my-llm-secret")
envs := getEnvVars(tmpl)
if e, ok := findEnv(envs, "LIGHTSPEED_AGENT_PROVIDER"); !ok {
t.Error("missing LIGHTSPEED_AGENT_PROVIDER")
} else if e["value"] != "claude" {
t.Errorf("LIGHTSPEED_AGENT_PROVIDER = %q, want claude", e["value"])
}

envs := getEnvVars(tmpl)
if e, ok := findEnv(envs, "CLAUDE_CODE_USE_VERTEX"); !ok {
t.Error("missing CLAUDE_CODE_USE_VERTEX")
if e, ok := findEnv(envs, "CLAUDE_CODE_USE_BEDROCK"); !ok {
t.Error("missing CLAUDE_CODE_USE_BEDROCK")
} else if e["value"] != "1" {
t.Errorf("CLAUDE_CODE_USE_VERTEX = %q", e["value"])
t.Errorf("CLAUDE_CODE_USE_BEDROCK = %q", e["value"])
}
}

if e, ok := findEnv(envs, "GOOGLE_APPLICATION_CREDENTIALS"); !ok {
t.Error("missing GOOGLE_APPLICATION_CREDENTIALS")
} else if e["value"] != vertexCredsMountPath+"/"+vertexCredsFileName {
t.Errorf("GOOGLE_APPLICATION_CREDENTIALS = %q", e["value"])
func TestPatchLLMCredentials_OpenAI(t *testing.T) {
tmpl := emptyTemplate()
llm := testLLMProviderWithURL(agenticv1alpha1.LLMProviderOpenAI, "https://api.example.com/v1")

if err := patchLLMCredentials(tmpl, llm, "gpt-4.1"); err != nil {
t.Fatalf("patchLLMCredentials: %v", err)
}

containers, _, _ := unstructured.NestedSlice(tmpl.Object, "spec", "podTemplate", "spec", "containers")
container := containers[0].(map[string]any)
mounts, _, _ := unstructured.NestedSlice(container, "volumeMounts")
if len(mounts) != 1 {
t.Fatalf("expected 1 volume mount, got %d", len(mounts))
envs := getEnvVars(tmpl)
if e, ok := findEnv(envs, "LIGHTSPEED_AGENT_PROVIDER"); !ok {
t.Error("missing LIGHTSPEED_AGENT_PROVIDER")
} else if e["value"] != "openai" {
t.Errorf("LIGHTSPEED_AGENT_PROVIDER = %q, want openai", e["value"])
}
mount := mounts[0].(map[string]any)
if mount["name"] != llmCredsVolumeName || mount["mountPath"] != vertexCredsMountPath {
t.Errorf("mount = %v", mount)

if e, ok := findEnv(envs, "OPENAI_MODEL"); !ok {
t.Error("missing OPENAI_MODEL")
} else if e["value"] != "gpt-4.1" {
t.Errorf("OPENAI_MODEL = %q", e["value"])
}

if _, ok := findEnv(envs, "ANTHROPIC_MODEL"); ok {
t.Error("ANTHROPIC_MODEL should not be set for OpenAI provider")
}

if e, ok := findEnv(envs, "OPENAI_BASE_URL"); !ok {
t.Error("missing OPENAI_BASE_URL")
} else if e["value"] != "https://api.example.com/v1" {
t.Errorf("OPENAI_BASE_URL = %q", e["value"])
}
}

func TestPatchLLMCredentials_Bedrock(t *testing.T) {
func TestPatchLLMCredentials_AzureOpenAI(t *testing.T) {
tmpl := emptyTemplate()
llm := testLLMProvider(agenticv1alpha1.LLMProviderAWSBedrock)
llm := testLLMProvider(agenticv1alpha1.LLMProviderAzureOpenAI)

if err := patchLLMCredentials(tmpl, llm, "claude-opus-4-6"); err != nil {
if err := patchLLMCredentials(tmpl, llm, "gpt-4.1"); err != nil {
t.Fatalf("patchLLMCredentials: %v", err)
}

envs := getEnvVars(tmpl)
if e, ok := findEnv(envs, "CLAUDE_CODE_USE_BEDROCK"); !ok {
t.Error("missing CLAUDE_CODE_USE_BEDROCK")
} else if e["value"] != "1" {
t.Errorf("CLAUDE_CODE_USE_BEDROCK = %q", e["value"])
if e, ok := findEnv(envs, "LIGHTSPEED_AGENT_PROVIDER"); !ok {
t.Error("missing LIGHTSPEED_AGENT_PROVIDER")
} else if e["value"] != "openai" {
t.Errorf("LIGHTSPEED_AGENT_PROVIDER = %q, want openai", e["value"])
}

if e, ok := findEnv(envs, "OPENAI_MODEL"); !ok {
t.Error("missing OPENAI_MODEL")
} else if e["value"] != "gpt-4.1" {
t.Errorf("OPENAI_MODEL = %q", e["value"])
}

if _, ok := findEnv(envs, "ANTHROPIC_MODEL"); ok {
t.Error("ANTHROPIC_MODEL should not be set for AzureOpenAI provider")
}
}

Expand Down