diff --git a/api/v1alpha1/agent_types.go b/api/v1alpha1/agent_types.go
index 7de83ff..396ee58 100644
--- a/api/v1alpha1/agent_types.go
+++ b/api/v1alpha1/agent_types.go
@@ -98,10 +98,13 @@ type AgentSpec struct {
// Otherwise any non-empty value fails the agent run, for every provider: the
// agent-executor's LLM API keys are process-wide, so an endpoint the operator has not
// approved must not receive them. Once accepted, it is passed to gollm as
- // ClientOptions.URL; with the pinned gollm it is honored ONLY by modelProvider
+ // ClientOptions.URL; with the pinned gollm it is honored by modelProvider
// "azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://);
+ // "anthropic" honors it unless ANTHROPIC_BASE_URL is set on the process;
// "openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead;
- // "google", "gemini" and "anthropic" ignore it; "local" uses LLAMACPP_HOST.
+ // "local" uses LLAMACPP_HOST. "google" and "gemini" reject any endpoint, allowlisted or
+ // not, because their client ignores it; set GOOGLE_GEMINI_BASE_URL on the agent-executor
+ // process instead.
// - skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false"
// is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on
// the agent-executor process (keeps TLS verification on); as a last resort, set
diff --git a/charts/ottoflow/crds/ottoflow.nirmata.io_agents.yaml b/charts/ottoflow/crds/ottoflow.nirmata.io_agents.yaml
index 143fc6b..bb6fa1b 100644
--- a/charts/ottoflow/crds/ottoflow.nirmata.io_agents.yaml
+++ b/charts/ottoflow/crds/ottoflow.nirmata.io_agents.yaml
@@ -56,10 +56,13 @@ spec:
Otherwise any non-empty value fails the agent run, for every provider: the
agent-executor's LLM API keys are process-wide, so an endpoint the operator has not
approved must not receive them. Once accepted, it is passed to gollm as
- ClientOptions.URL; with the pinned gollm it is honored ONLY by modelProvider
+ ClientOptions.URL; with the pinned gollm it is honored by modelProvider
"azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://);
+ "anthropic" honors it unless ANTHROPIC_BASE_URL is set on the process;
"openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead;
- "google", "gemini" and "anthropic" ignore it; "local" uses LLAMACPP_HOST.
+ "local" uses LLAMACPP_HOST. "google" and "gemini" reject any endpoint, allowlisted or
+ not, because their client ignores it; set GOOGLE_GEMINI_BASE_URL on the agent-executor
+ process instead.
- skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false"
is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on
the agent-executor process (keeps TLS verification on); as a last resort, set
diff --git a/config/crd/bases/ottoflow.nirmata.io_agents.yaml b/config/crd/bases/ottoflow.nirmata.io_agents.yaml
index 143fc6b..bb6fa1b 100644
--- a/config/crd/bases/ottoflow.nirmata.io_agents.yaml
+++ b/config/crd/bases/ottoflow.nirmata.io_agents.yaml
@@ -56,10 +56,13 @@ spec:
Otherwise any non-empty value fails the agent run, for every provider: the
agent-executor's LLM API keys are process-wide, so an endpoint the operator has not
approved must not receive them. Once accepted, it is passed to gollm as
- ClientOptions.URL; with the pinned gollm it is honored ONLY by modelProvider
+ ClientOptions.URL; with the pinned gollm it is honored by modelProvider
"azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://);
+ "anthropic" honors it unless ANTHROPIC_BASE_URL is set on the process;
"openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead;
- "google", "gemini" and "anthropic" ignore it; "local" uses LLAMACPP_HOST.
+ "local" uses LLAMACPP_HOST. "google" and "gemini" reject any endpoint, allowlisted or
+ not, because their client ignores it; set GOOGLE_GEMINI_BASE_URL on the agent-executor
+ process instead.
- skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false"
is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on
the agent-executor process (keeps TLS verification on); as a last resort, set
diff --git a/docs/user/reference/api/agent.md b/docs/user/reference/api/agent.md
index d145cd9..d7f0df9 100644
--- a/docs/user/reference/api/agent.md
+++ b/docs/user/reference/api/agent.md
@@ -19,7 +19,7 @@
| `modelName` | string | No | Model identifier (e.g. `gpt-4`, `claude-3-opus`). Default depends on provider. |
| `mcpTools` | []string | No | List of MCP tools the agent can use. Format: `"server:tool"` (e.g. `kubernetes-mcp:get-resource`). |
| `outputExtraction` | [OutputExtraction](#outputextraction) | No | How to extract outputs from agent responses (json, regex, text). |
-| `config` | map[string]string | No | Provider client options. Only `endpoint` and `skipVerifySSL` are read. `endpoint` is accepted **only** if the agent-executor operator has allowlisted its origin via `AGENT_LLM_ENDPOINT_ALLOWLIST` (Helm: `agentExecutor.llmEndpointAllowlist`), and only as an origin (`scheme://host[:port]`; a path, query, fragment or userinfo is rejected). With the allowlist empty (the default), any non-empty `endpoint` fails the agent run, for every provider, because the agent-executor's LLM API keys are shared by every Agent. Once accepted, `endpoint` is effective only for `azure-openai` (where it must be `https://`); `openai` uses `OPENAI_ENDPOINT`/`OPENAI_API_BASE` from the agent-executor environment, and `local` uses `LLAMACPP_HOST`. `skipVerifySSL: "true"` is **rejected** for every provider and fails the agent run; to reach an endpoint with a private CA, set `SSL_CERT_FILE` or `SSL_CERT_DIR` on the agent-executor process (as a last resort, `LLM_SKIP_VERIFY_SSL=true` there, a process-wide setting not scoped to one Agent). API keys are never read from `config` — they come from the agent-executor process environment. |
+| `config` | map[string]string | No | Provider client options. Only `endpoint` and `skipVerifySSL` are read. `endpoint` is accepted **only** if the agent-executor operator has allowlisted its origin via `AGENT_LLM_ENDPOINT_ALLOWLIST` (Helm: `agentExecutor.llmEndpointAllowlist`), and only as an origin (`scheme://host[:port]`; a path, query, fragment or userinfo is rejected). With the allowlist empty (the default), any non-empty `endpoint` fails the agent run, for every provider, because the agent-executor's LLM API keys are shared by every Agent. Once accepted, `endpoint` is effective for `azure-openai` (where it must be `https://`); `anthropic` also honors it unless `ANTHROPIC_BASE_URL` is set; `openai` uses `OPENAI_ENDPOINT`/`OPENAI_API_BASE` from the agent-executor environment, and `local` uses `LLAMACPP_HOST`. `google`/`gemini` reject any `endpoint`, allowlisted or not, because their client ignores it; set `GOOGLE_GEMINI_BASE_URL` on the agent-executor instead. `skipVerifySSL: "true"` is **rejected** for every provider and fails the agent run; to reach an endpoint with a private CA, set `SSL_CERT_FILE` or `SSL_CERT_DIR` on the agent-executor process (as a last resort, `LLM_SKIP_VERIFY_SSL=true` there, a process-wide setting not scoped to one Agent). API keys are never read from `config` — they come from the agent-executor process environment. |
| `serviceAccount` | string | No | Kubernetes service account for agent execution (RBAC). |
| `serviceName` | string | No | Name of the AgentExecutor Service. Default: `ottoflow-agent-executor`. |
| `serviceNamespace` | string | No | Namespace of the AgentExecutor Service. Default: `ottoflow`. |
diff --git a/docs/user/reference/api/api-docs.md b/docs/user/reference/api/api-docs.md
index 73e4658..50f9d01 100644
--- a/docs/user/reference/api/api-docs.md
+++ b/docs/user/reference/api/api-docs.md
@@ -78,7 +78,7 @@ _Appears in:_
| `serviceName` _string_ | ServiceName is the name of the AgentExecutor Service
If not specified, uses default: ottoflow-agent-executor | | Optional: \{\}
|
| `serviceNamespace` _string_ | ServiceNamespace is the namespace of the AgentExecutor Service
Defaults to ottoflow | | Optional: \{\}
|
| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#resourcerequirements-v1-core)_ | Resources defines resource requests/limits for agent execution
Currently used for future sandbox mode
All steps using this agent use these resources | | Optional: \{\}
|
-| `config` _object (keys:string, values:string)_ | Config contains provider-specific client options. The executor reads exactly
two keys (internal/agent/default_executor.go):
- endpoint: accepted ONLY if the agent-executor operator has allowlisted its origin
via AGENT_LLM_ENDPOINT_ALLOWLIST (Helm: agentExecutor.llmEndpointAllowlist), and only
as an origin ("scheme://host[:port]"; a path, query, fragment or userinfo is rejected).
Otherwise any non-empty value fails the agent run, for every provider: the
agent-executor's LLM API keys are process-wide, so an endpoint the operator has not
approved must not receive them. Once accepted, it is passed to gollm as
ClientOptions.URL; with the pinned gollm it is honored ONLY by modelProvider
"azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://);
"openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead;
"google", "gemini" and "anthropic" ignore it; "local" uses LLAMACPP_HOST.
- skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false"
is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on
the agent-executor process (keeps TLS verification on); as a last resort, set
LLM_SKIP_VERIFY_SSL=true on that process, a process-wide setting not scoped to one Agent.
API keys are NOT read from here; they come from the agent-executor process
environment (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AZURE_OPENAI_API_KEY). | | Optional: \{\}
|
+| `config` _object (keys:string, values:string)_ | Config contains provider-specific client options. The executor reads exactly
two keys (internal/agent/default_executor.go):
- endpoint: accepted ONLY if the agent-executor operator has allowlisted its origin
via AGENT_LLM_ENDPOINT_ALLOWLIST (Helm: agentExecutor.llmEndpointAllowlist), and only
as an origin ("scheme://host[:port]"; a path, query, fragment or userinfo is rejected).
Otherwise any non-empty value fails the agent run, for every provider: the
agent-executor's LLM API keys are process-wide, so an endpoint the operator has not
approved must not receive them. Once accepted, it is passed to gollm as
ClientOptions.URL; with the pinned gollm it is honored by modelProvider
"azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://);
"anthropic" honors it unless ANTHROPIC_BASE_URL is set on the process;
"openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead;
"local" uses LLAMACPP_HOST. "google" and "gemini" reject any endpoint, allowlisted or
not, because their client ignores it; set GOOGLE_GEMINI_BASE_URL on the agent-executor
process instead.
- skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false"
is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on
the agent-executor process (keeps TLS verification on); as a last resort, set
LLM_SKIP_VERIFY_SSL=true on that process, a process-wide setting not scoped to one Agent.
API keys are NOT read from here; they come from the agent-executor process
environment (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AZURE_OPENAI_API_KEY). | | Optional: \{\}
|
#### AgentStatus
diff --git a/internal/agent/default_executor.go b/internal/agent/default_executor.go
index 15db1bc..750d793 100644
--- a/internal/agent/default_executor.go
+++ b/internal/agent/default_executor.go
@@ -138,6 +138,14 @@ func (e *DefaultAgentExecutor) createLLMClient(ctx context.Context, agentCRD *ot
// verification. Both checks run before any client exists.
var opts []gollm.Option
if endpoint := agentCRD.Spec.Config["endpoint"]; endpoint != "" {
+ // gollm's gemini client never reads ClientOptions.URL.
+ if provider == providerGoogle || provider == providerGemini {
+ return nil, fmt.Errorf("agent endpoint rejected: spec.config.endpoint (%q) is not permitted for "+
+ "provider %s: %s", endpoint, provider,
+ fmt.Sprintf("spec.config.endpoint and %s have no effect for this provider — set "+
+ "GOOGLE_GEMINI_BASE_URL in the agent-executor process environment instead (Helm: "+
+ "agentExecutor.env)", AgentLLMEndpointAllowlistEnv))
+ }
parsed, err := allowedLLMEndpoint(endpoint)
if err != nil {
return nil, fmt.Errorf("agent endpoint rejected: spec.config.endpoint (%q) is not permitted for "+
diff --git a/internal/agent/default_executor_test.go b/internal/agent/default_executor_test.go
index db8789b..1b28ffa 100644
--- a/internal/agent/default_executor_test.go
+++ b/internal/agent/default_executor_test.go
@@ -80,6 +80,8 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() {
Describe("spec.config.endpoint / skipVerifySSL: operator allowlist gate", func() {
allProviders := []string{"anthropic", "azure-openai", "openai", "google", "gemini", "local"}
+ nonGeminiProviders := []string{"anthropic", "azure-openai", "openai", "local"}
+ geminiProviders := []string{"google", "gemini"}
newAgent := func(provider string, config map[string]string) *ottoflowv1alpha1.Agent {
return &ottoflowv1alpha1.Agent{
@@ -122,7 +124,7 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() {
It("default closed: an unset or empty-ish allowlist refuses every provider", func() {
for _, envVal := range []string{"", " ", ",", " , ", ",,", "\t\n"} {
GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, envVal)
- assertRefused(allProviders, map[string]string{"endpoint": "https://unlisted.example.com"},
+ assertRefused(nonGeminiProviders, map[string]string{"endpoint": "https://unlisted.example.com"},
"allowlisted no LLM endpoints")
}
})
@@ -145,7 +147,7 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() {
DescribeTable("refuses an endpoint whose origin differs from the allowlisted one",
func(endpoint string) {
GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, "https://llm.corp.example")
- assertRefused(allProviders, map[string]string{"endpoint": endpoint}, "not in the operator's allowlist")
+ assertRefused(nonGeminiProviders, map[string]string{"endpoint": endpoint}, "not in the operator's allowlist")
},
Entry("host suffix", "https://llm.corp.example.other.example"),
Entry("scheme", "http://llm.corp.example"),
@@ -155,14 +157,14 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() {
It("refuses userinfo on an allowlisted host", func() {
GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, "https://llm.corp.example")
- assertRefused(allProviders, map[string]string{"endpoint": "https://other.example@llm.corp.example"},
+ assertRefused(nonGeminiProviders, map[string]string{"endpoint": "https://other.example@llm.corp.example"},
"userinfo is not permitted")
})
It("refuses a path, query or fragment on an allowlisted origin", func() {
GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, "https://llm.corp.example")
for _, ep := range []string{"https://llm.corp.example/v1", "https://llm.corp.example?x=1", "https://llm.corp.example#f"} {
- assertRefused(allProviders, map[string]string{"endpoint": ep}, "path/query/fragment are not permitted")
+ assertRefused(nonGeminiProviders, map[string]string{"endpoint": ep}, "path/query/fragment are not permitted")
}
})
@@ -177,7 +179,7 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() {
err := ValidateLLMEndpointAllowlist()
Expect(err).To(HaveOccurred(), entry)
Expect(err.Error()).To(ContainSubstring("origin only"), entry)
- assertRefused(allProviders, map[string]string{"endpoint": "https://llm.corp.example"}, "origin only")
+ assertRefused(nonGeminiProviders, map[string]string{"endpoint": "https://llm.corp.example"}, "origin only")
}
})
@@ -195,7 +197,7 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() {
" https://llm.corp.example , , ",
} {
GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, envVal)
- assertRefused(allProviders, map[string]string{"endpoint": "https://definitely-not-listed.invalid"},
+ assertRefused(nonGeminiProviders, map[string]string{"endpoint": "https://definitely-not-listed.invalid"},
"not in the operator's allowlist")
}
})
@@ -215,12 +217,20 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() {
It("refuses skipVerifySSL=true for every provider, with or without an allowlisted endpoint", func() {
GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, "https://llm.corp.example")
assertRefused(allProviders, map[string]string{"skipVerifySSL": "true"}, "no longer honored")
- assertRefused(allProviders, map[string]string{
+ assertRefused(nonGeminiProviders, map[string]string{
"endpoint": "https://llm.corp.example",
"skipVerifySSL": "true",
}, "no longer honored")
})
+ It("refuses a google/gemini endpoint before the allowlist is consulted", func() {
+ for _, allow := range []string{"", "https://llm.corp.example/v1", "https://llm.corp.example"} {
+ GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, allow)
+ assertRefused(geminiProviders, map[string]string{"endpoint": "https://llm.corp.example"},
+ "GOOGLE_GEMINI_BASE_URL")
+ }
+ })
+
It("keeps skipVerifySSL=\"false\" legal", func() {
factory := &mockLLMClientFactory{client: &mockGollmClient{}}
e := NewDefaultAgentExecutorWithLLMFactory(nil, factory)
diff --git a/internal/webhook/agent_mcpserver_validators.go b/internal/webhook/agent_mcpserver_validators.go
index 35c7fe2..3b49801 100644
--- a/internal/webhook/agent_mcpserver_validators.go
+++ b/internal/webhook/agent_mcpserver_validators.go
@@ -65,9 +65,11 @@ func validateAgentConfig(a *ottoflowv1alpha1.Agent) admission.Warnings {
warnings = append(warnings, fmt.Sprintf(
"spec.config.skipVerifySSL must be \"true\" or \"false\"; got %q, which is treated as false", val))
case k == "endpoint" && val != "":
- warnings = append(warnings, "spec.config.endpoint is honored only if it is origin-only "+
- "(scheme://host[:port]) and the agent-executor operator has allowlisted that origin "+
- "(AGENT_LLM_ENDPOINT_ALLOWLIST / Helm agentExecutor.llmEndpointAllowlist); otherwise the agent run will FAIL.")
+ warnings = append(warnings, "spec.config.endpoint is honored only for providers that read it (anthropic, azure-openai), "+
+ "and only if it is origin-only (no path, query or fragment) and the agent-executor operator "+
+ "has allowlisted its origin (AGENT_LLM_ENDPOINT_ALLOWLIST / Helm "+
+ "agentExecutor.llmEndpointAllowlist). Otherwise the agent run will FAIL. "+
+ "google, gemini and nirmata always FAIL when it is set; openai and local ignore it.")
}
}
diff --git a/internal/webhook/agent_validator_test.go b/internal/webhook/agent_validator_test.go
index 429cb88..db321fc 100644
--- a/internal/webhook/agent_validator_test.go
+++ b/internal/webhook/agent_validator_test.go
@@ -51,7 +51,7 @@ func TestAgentValidatorConfigWarnings(t *testing.T) {
name: "endpoint is reported as allowlist-gated",
config: map[string]string{"endpoint": "https://llm.example.com"},
wantCount: 1,
- wantContains: []string{"endpoint", "AGENT_LLM_ENDPOINT_ALLOWLIST", "origin-only", "FAIL"},
+ wantContains: []string{"endpoint", "AGENT_LLM_ENDPOINT_ALLOWLIST", "origin-only", "FAIL", "google, gemini"},
},
{
// The CRD doc-comment advertises apiKey/baseURL, but no executor in this build
diff --git a/samples/workflows/features/agent-custom-endpoint.yaml b/samples/workflows/features/agent-custom-endpoint.yaml
index a89ca94..b02da25 100644
--- a/samples/workflows/features/agent-custom-endpoint.yaml
+++ b/samples/workflows/features/agent-custom-endpoint.yaml
@@ -13,7 +13,7 @@
#
# Listing an origin trusts it with the agent-executor's LLM credentials: any
# Agent may name a listed origin, and every provider client that uses the
-# endpoint (with the pinned gollm, azure-openai) sends its credential there
+# endpoint (with the pinned gollm, azure-openai and anthropic) sends its credential there
# together with the full prompt. Do not allowlist a host you do not control.
#
# Agent.spec.config effectiveness by provider, once an origin IS allowlisted:
@@ -26,7 +26,10 @@
# OPENAI_ENDPOINT or OPENAI_API_BASE process environment variable
# (set via the agent-executor pod's env, e.g. Helm
# `agentExecutor.env`).
-# * gemini / anthropic: IGNORED entirely.
+# * anthropic: honored unless the agent-executor sets ANTHROPIC_BASE_URL.
+# * gemini / google: REJECTED, even when the origin is allowlisted,
+# because the client ignores the field. Set GOOGLE_GEMINI_BASE_URL on
+# the agent-executor process instead (Helm `agentExecutor.env`).
# * local: IGNORED — the local (llama.cpp) provider uses LLAMACPP_HOST
# instead (see agent-local-llamacpp.yaml).
# - skipVerifySSL: