diff --git a/pkg/api/harness_capabilities.go b/pkg/api/harness_capabilities.go index aacb6bfc9..d0b5b56a2 100644 --- a/pkg/api/harness_capabilities.go +++ b/pkg/api/harness_capabilities.go @@ -54,6 +54,7 @@ type HarnessAuthCapabilities struct { AuthFile CapabilityField `json:"auth_file" yaml:"auth_file"` OAuthToken CapabilityField `json:"oauth_token" yaml:"oauth_token"` VertexAI CapabilityField `json:"vertex_ai" yaml:"vertex_ai"` + LLMGateway CapabilityField `json:"llm_gateway" yaml:"llm_gateway"` } // HarnessMCPCapabilities describes MCP transport support for a harness. diff --git a/pkg/api/types.go b/pkg/api/types.go index ac482a073..beeb44383 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -475,9 +475,11 @@ type AuthConfig struct { OAuthCreds string // Anthropic auth - AnthropicAPIKey string - ClaudeOAuthToken string // CLAUDE_CODE_OAUTH_TOKEN (long-lived, from `claude setup-token`) - ClaudeAuthFile string // ~/.claude/.credentials.json path (rotating refresh-token store) + AnthropicAPIKey string + AnthropicAuthToken string // ANTHROPIC_AUTH_TOKEN (alias used by LLM proxies) + AnthropicBaseURL string // ANTHROPIC_BASE_URL (custom API endpoint, e.g. LiteLLM proxy) + ClaudeOAuthToken string // CLAUDE_CODE_OAUTH_TOKEN (long-lived, from `claude setup-token`) + ClaudeAuthFile string // ~/.claude/.credentials.json path (rotating refresh-token store) // OpenAI/Codex auth OpenAIAPIKey string diff --git a/pkg/config/schemas/settings-v1.schema.json b/pkg/config/schemas/settings-v1.schema.json index 296d1ea93..7db349817 100644 --- a/pkg/config/schemas/settings-v1.schema.json +++ b/pkg/config/schemas/settings-v1.schema.json @@ -263,8 +263,8 @@ }, "auth_selected_type": { "type": "string", - "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai"], - "description": "Authentication mechanism to use (e.g., api-key, oauth-token, vertex-ai, auth-file)." + "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai", "llm-gateway"], + "description": "Authentication mechanism to use (e.g., api-key, oauth-token, vertex-ai, auth-file, llm-gateway)." }, "secrets": { "type": "array", @@ -453,7 +453,8 @@ "api_key": { "$ref": "#/$defs/capabilityField" }, "auth_file": { "$ref": "#/$defs/capabilityField" }, "oauth_token": { "$ref": "#/$defs/capabilityField" }, - "vertex_ai": { "$ref": "#/$defs/capabilityField" } + "vertex_ai": { "$ref": "#/$defs/capabilityField" }, + "llm_gateway": { "$ref": "#/$defs/capabilityField" } }, "additionalProperties": false }, @@ -475,7 +476,7 @@ "properties": { "default_type": { "type": "string", - "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai"] + "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai", "llm-gateway"] }, "types": { "type": "object", @@ -533,14 +534,14 @@ "type": "object", "additionalProperties": { "type": "string", - "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai"] + "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai", "llm-gateway"] } }, "files": { "type": "object", "additionalProperties": { "type": "string", - "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai"] + "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai", "llm-gateway"] } } }, @@ -604,7 +605,7 @@ "resources": { "$ref": "#/$defs/resourceSpec" }, "auth_selected_type": { "type": "string", - "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai"] + "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai", "llm-gateway"] } }, "additionalProperties": false diff --git a/pkg/harness/auth.go b/pkg/harness/auth.go index a5a4b433c..ca0d1c9ef 100644 --- a/pkg/harness/auth.go +++ b/pkg/harness/auth.go @@ -56,12 +56,14 @@ func GatherAuthWithEnv(env map[string]string, localSources bool) api.AuthConfig auth := api.AuthConfig{ // Env-var sourced fields - GeminiAPIKey: lookup("GEMINI_API_KEY"), - GoogleAPIKey: lookup("GOOGLE_API_KEY"), - AnthropicAPIKey: lookup("ANTHROPIC_API_KEY"), - ClaudeOAuthToken: lookup("CLAUDE_CODE_OAUTH_TOKEN"), - OpenAIAPIKey: lookup("OPENAI_API_KEY"), - CodexAPIKey: lookup("CODEX_API_KEY"), + GeminiAPIKey: lookup("GEMINI_API_KEY"), + GoogleAPIKey: lookup("GOOGLE_API_KEY"), + AnthropicAPIKey: lookup("ANTHROPIC_API_KEY"), + AnthropicAuthToken: lookup("ANTHROPIC_AUTH_TOKEN"), + AnthropicBaseURL: lookup("ANTHROPIC_BASE_URL"), + ClaudeOAuthToken: lookup("CLAUDE_CODE_OAUTH_TOKEN"), + OpenAIAPIKey: lookup("OPENAI_API_KEY"), + CodexAPIKey: lookup("CODEX_API_KEY"), GoogleCloudProject: util.FirstNonEmpty( lookup("GOOGLE_CLOUD_PROJECT"), lookup("GCP_PROJECT"), @@ -292,6 +294,9 @@ func DetectAuthTypeFromEnvVars(harnessName string, envKeys map[string]struct{}) switch harnessName { case "claude": + if _, ok := envKeys["ANTHROPIC_BASE_URL"]; ok { + return "llm-gateway" + } if _, ok := envKeys["ANTHROPIC_API_KEY"]; ok { return "" } @@ -355,6 +360,8 @@ func RequiredAuthEnvKeys(harnessName, authSelectedType string) [][]string { return nil case "vertex-ai": return [][]string{{"GOOGLE_CLOUD_PROJECT"}, {"GOOGLE_CLOUD_REGION", "CLOUD_ML_REGION", "GOOGLE_CLOUD_LOCATION"}} + case "llm-gateway": + return [][]string{{"ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"}, {"ANTHROPIC_BASE_URL"}} } case "gemini": switch effectiveType { diff --git a/pkg/harness/claude/embeds/config.yaml b/pkg/harness/claude/embeds/config.yaml index 69afa779a..b1e94098b 100644 --- a/pkg/harness/claude/embeds/config.yaml +++ b/pkg/harness/claude/embeds/config.yaml @@ -48,6 +48,7 @@ capabilities: auth_file: { support: "yes" } oauth_token: { support: "yes" } vertex_ai: { support: "yes" } + llm_gateway: { support: "yes" } auth: default_type: api-key types: @@ -73,8 +74,13 @@ auth: alternative_env_keys: ["GOOGLE_APPLICATION_CREDENTIALS"] skipped_when_gcp_service_account_assigned: true required: true + llm-gateway: + required_env: + - any_of: ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] + - any_of: ["ANTHROPIC_BASE_URL"] autodetect: env: + ANTHROPIC_BASE_URL: llm-gateway CLAUDE_CODE_OAUTH_TOKEN: oauth-token GOOGLE_APPLICATION_CREDENTIALS: vertex-ai GOOGLE_CLOUD_PROJECT: vertex-ai diff --git a/pkg/harness/claude_code.go b/pkg/harness/claude_code.go index e8073df0e..acbbbc99f 100644 --- a/pkg/harness/claude_code.go +++ b/pkg/harness/claude_code.go @@ -57,6 +57,7 @@ func (c *ClaudeCode) AdvancedCapabilities() api.HarnessAdvancedCapabilities { AuthFile: api.CapabilityField{Support: api.SupportYes}, OAuthToken: api.CapabilityField{Support: api.SupportYes}, VertexAI: api.CapabilityField{Support: api.SupportYes}, + LLMGateway: api.CapabilityField{Support: api.SupportYes}, }, Resume: api.CapabilityField{Support: api.SupportYes}, } @@ -139,6 +140,11 @@ func (c *ClaudeCode) Provision(ctx context.Context, agentName, agentDir, agentHo "ANTHROPIC_VERTEX_PROJECT_ID": "${GOOGLE_CLOUD_PROJECT}", "CLOUD_ML_REGION": "${GOOGLE_CLOUD_REGION}", } + case "llm-gateway": + envUpdates = map[string]string{ + "ANTHROPIC_AUTH_TOKEN": "${ANTHROPIC_AUTH_TOKEN}", + "ANTHROPIC_BASE_URL": "${ANTHROPIC_BASE_URL}", + } } if len(envUpdates) > 0 { @@ -242,10 +248,15 @@ func (c *ClaudeCode) provisionClaudeJSON(ctx context.Context, agentHome, agentWo // api-key auth. This pre-approves the API key so Claude Code does not prompt // for confirmation. func (c *ClaudeCode) ApplyAuthSettings(agentHome string, resolved *api.ResolvedAuth) error { - if resolved.Method != "api-key" { + var apiKey string + switch resolved.Method { + case "api-key": + apiKey = resolved.EnvVars["ANTHROPIC_API_KEY"] + case "llm-gateway": + apiKey = resolved.EnvVars["ANTHROPIC_AUTH_TOKEN"] + default: return nil } - apiKey := resolved.EnvVars["ANTHROPIC_API_KEY"] if apiKey == "" { return nil } @@ -376,12 +387,48 @@ func (c *ClaudeCode) ResolveAuth(auth api.AuthConfig) (*api.ResolvedAuth, error) return nil, fmt.Errorf("claude: auth type %q selected but GOOGLE_CLOUD_PROJECT and/or GOOGLE_CLOUD_REGION not set", auth.SelectedType) } return c.resolveVertexAI(auth), nil + case "llm-gateway": + token := auth.AnthropicAuthToken + if token == "" { + token = auth.AnthropicAPIKey + } + if token == "" { + return nil, fmt.Errorf("claude: auth type %q selected but no token found; set ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY", auth.SelectedType) + } + if auth.AnthropicBaseURL == "" { + return nil, fmt.Errorf("claude: auth type %q selected but ANTHROPIC_BASE_URL is not set", auth.SelectedType) + } + return &api.ResolvedAuth{ + Method: "llm-gateway", + EnvVars: map[string]string{ + "ANTHROPIC_AUTH_TOKEN": token, + "ANTHROPIC_BASE_URL": auth.AnthropicBaseURL, + }, + }, nil default: - return nil, fmt.Errorf("claude: unknown auth type %q; valid types are: api-key, oauth-token, auth-file, vertex-ai", auth.SelectedType) + return nil, fmt.Errorf("claude: unknown auth type %q; valid types are: api-key, oauth-token, auth-file, vertex-ai, llm-gateway", auth.SelectedType) } } - // Auto-detect preference order: API key → OAuth token → credentials file → Vertex AI → error + // Auto-detect preference order: LLM Gateway → API key → OAuth token → credentials file → Vertex AI → error + + // 0. LLM proxy (ANTHROPIC_BASE_URL + (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)) + if auth.AnthropicBaseURL != "" { + token := auth.AnthropicAuthToken + if token == "" { + token = auth.AnthropicAPIKey + } + if token == "" { + return nil, fmt.Errorf("claude: ANTHROPIC_BASE_URL is set but no token found; set ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY") + } + return &api.ResolvedAuth{ + Method: "llm-gateway", + EnvVars: map[string]string{ + "ANTHROPIC_AUTH_TOKEN": token, + "ANTHROPIC_BASE_URL": auth.AnthropicBaseURL, + }, + }, nil + } // 1. Anthropic API key (direct) if auth.AnthropicAPIKey != "" { @@ -424,7 +471,7 @@ func (c *ClaudeCode) ResolveAuth(auth api.AuthConfig) (*api.ResolvedAuth, error) return c.resolveVertexAI(auth), nil } - return nil, fmt.Errorf("claude: no valid auth method found; set ANTHROPIC_API_KEY for direct API access, CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or ~/.claude/.credentials.json for subscription auth, or provide ADC (gcloud-adc secret, GCP service account, or ~/.config/gcloud/application_default_credentials.json) + GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_REGION for Vertex AI") + return nil, fmt.Errorf("claude: no valid auth method found; set ANTHROPIC_API_KEY for direct API access, ANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URL for LLM proxy (e.g. LiteLLM), CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or ~/.claude/.credentials.json for subscription auth, or provide ADC (gcloud-adc secret, GCP service account, or ~/.config/gcloud/application_default_credentials.json) + GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_REGION for Vertex AI") } func (c *ClaudeCode) resolveVertexAI(auth api.AuthConfig) *api.ResolvedAuth { diff --git a/pkg/harness/container_script_harness.go b/pkg/harness/container_script_harness.go index 6867c1bc9..db5c254cf 100644 --- a/pkg/harness/container_script_harness.go +++ b/pkg/harness/container_script_harness.go @@ -221,6 +221,8 @@ func (c *ContainerScriptHarness) ResolveAuth(auth api.AuthConfig) (*api.Resolved } } addIfPresent("ANTHROPIC_API_KEY", auth.AnthropicAPIKey) + addIfPresent("ANTHROPIC_AUTH_TOKEN", auth.AnthropicAuthToken) + addIfPresent("ANTHROPIC_BASE_URL", auth.AnthropicBaseURL) addIfPresent("CLAUDE_CODE_OAUTH_TOKEN", auth.ClaudeOAuthToken) addIfPresent("OPENAI_API_KEY", auth.OpenAIAPIKey) addIfPresent("GEMINI_API_KEY", auth.GeminiAPIKey)