From 930234109daeb06bc9b0e3b5997d073c6f4ae4c1 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 24 Jun 2026 16:37:04 +0300 Subject: [PATCH 1/3] Generalize LLM backend configuration --- .env.example | 20 ++++-- containers/README.md | 20 +++--- containers/entrypoint.py | 60 +++++++++------- docs/dev/setup.md | 6 +- docs/developer-guide.md | 16 ++--- docs/getting-started.md | 12 ++-- docs/guide/pr-commands.md | 2 +- docs/index.md | 2 +- docs/reference/config.md | 19 +++-- src/forge/api/routes/metrics.py | 6 +- src/forge/cli.py | 10 +-- src/forge/config.py | 58 ++++++++++----- src/forge/integrations/agents/agent.py | 45 ++++++------ src/forge/integrations/langfuse/tracing.py | 4 +- src/forge/main.py | 2 +- src/forge/sandbox/runner.py | 16 +++-- src/forge/utils/__init__.py | 2 + src/forge/utils/logging.py | 2 +- src/forge/utils/rate_limiter.py | 9 ++- src/forge/utils/retry.py | 5 +- src/forge/workflow/nodes/ci_evaluator.py | 2 +- .../workflow/nodes/epic_decomposition.py | 4 +- src/forge/workflow/nodes/prd_generation.py | 4 +- src/forge/workflow/nodes/spec_generation.py | 2 +- src/forge/workflow/nodes/task_generation.py | 4 +- src/forge/workspace/git_ops.py | 2 +- tests/unit/test_config_prd.py | 70 ++++++++++++++++++- 27 files changed, 269 insertions(+), 135 deletions(-) diff --git a/.env.example b/.env.example index 0782e2a4..13706eb8 100644 --- a/.env.example +++ b/.env.example @@ -66,19 +66,27 @@ FORGE_REQUIRE_PROJECT_CONFIG=true # ============================================================================= # LLM Configuration -# Supports Claude (via Anthropic API or Vertex AI) and Gemini (via Vertex AI) +# Forge passes LangChain chat model instances into Deep Agents. Built-in +# configuration supports direct Anthropic API credentials and Vertex AI-backed +# model access. Additional LangChain chat models can be wired in by extending +# the model factory. # ============================================================================= -# Option 1: Direct Anthropic API (Claude models only) +# Option 1: Direct model provider API. +LLM_API_KEY= +# Backwards-compatible name for Anthropic direct API deployments. ANTHROPIC_API_KEY= -# Option 2: Google Vertex AI (supports both Claude and Gemini) + +# Option 2: Google Vertex AI. +VERTEX_PROJECT_ID=your-gcp-project-id +VERTEX_REGION=us-east5 +# Backwards-compatible names for existing deployments. ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id ANTHROPIC_VERTEX_REGION=us-east5 # Model for orchestrator (PRD, spec, epic planning) -# Claude models: claude-opus-4-5@20251101, claude-sonnet-4-5@20250929, claude-haiku-4-5@20251001 -# Gemini models: gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro-preview -LLM_MODEL=claude-opus-4-5@20251101 +# Examples: gemini-3.5-flash, gemini-2.5-pro, claude-opus-4-5@20251101 +LLM_MODEL=gemini-3.5-flash # Model for container tasks (code implementation) # Can use a different model than orchestrator (e.g., for cost/rate limit reasons) diff --git a/containers/README.md b/containers/README.md index 1ecc0834..1434cba5 100644 --- a/containers/README.md +++ b/containers/README.md @@ -46,8 +46,8 @@ Based on `mcr.microsoft.com/devcontainers/universal:linux` which provides: Additional packages installed: - `deepagents` - AI agent framework -- `anthropic`, `langchain-anthropic` - Claude API access -- `langchain-google-vertexai` - Vertex AI support (Claude and Gemini) +- `anthropic`, `langchain-anthropic` - direct Anthropic API access +- `langchain-google-vertexai` - Vertex AI model access - `langchain-mcp-adapters` - MCP server integration ## Image Configuration @@ -104,10 +104,11 @@ Passed automatically by the orchestrator: | Variable | Description | |----------|-------------| -| `ANTHROPIC_API_KEY` | Claude API key (direct API) | -| `ANTHROPIC_VERTEX_PROJECT_ID` | GCP project for Vertex AI | -| `ANTHROPIC_VERTEX_REGION` | Vertex AI region | -| `LLM_MODEL` | Model to use (e.g., `claude-opus-4-5@20251101`, `gemini-2.5-pro`) | +| `LLM_API_KEY` | Direct model provider API key | +| `VERTEX_PROJECT_ID` | GCP project for Vertex AI | +| `VERTEX_REGION` | Vertex AI region | +| `ANTHROPIC_*` | Backwards-compatible aliases for existing deployments | +| `LLM_MODEL` | Model to use (default: `gemini-3.5-flash`; examples: `gemini-2.5-pro`, `claude-opus-4-5@20251101`) | | `FORGE_SYSTEM_PROMPT_TEMPLATE` | System prompt template (interpolated by entrypoint) | | `GOOGLE_APPLICATION_CREDENTIALS` | Path to mounted gcloud credentials | | `GIT_USER_NAME` | Git author name for commits (default: `Forge`) | @@ -136,9 +137,10 @@ Example: `forge-AISOS-189-installer-12345` ## Task Execution -The entrypoint runs a Deep Agent with `LocalShellBackend`. Supported models: -- **Claude** (via Anthropic API or Vertex AI): `claude-opus-4-5@20251101`, `claude-sonnet-4-5@20250929` -- **Gemini** (via Vertex AI): `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-3.1-pro-preview` +The entrypoint runs a Deep Agent with `LocalShellBackend`. Built-in model +factory support covers direct Anthropic API credentials and Vertex AI-backed +models. Because the agent receives a LangChain chat model instance, additional +providers can be added by extending the model factory. The agent: 1. Reads and understands the codebase diff --git a/containers/entrypoint.py b/containers/entrypoint.py index aeb0aae6..7ad401ed 100644 --- a/containers/entrypoint.py +++ b/containers/entrypoint.py @@ -35,6 +35,7 @@ if os.environ.get("LANGCHAIN_VERBOSE", "").lower() in ("true", "1", "yes"): try: from langchain_core.globals import set_debug, set_verbose + set_verbose(True) set_debug(True) logger.info("LangChain verbose/debug mode enabled") @@ -369,8 +370,8 @@ async def run_agent_task( previous_task_keys: List of previously implemented task keys for handoff context. trace_context: Workflow fields forwarded to Langfuse only. """ - # Support both new (LLM_MODEL) and legacy (CLAUDE_MODEL) env var names - model_name = os.environ.get("LLM_MODEL") or os.environ.get("CLAUDE_MODEL", "claude-sonnet-4-5@20250929") + # Support both generic (LLM_MODEL) and legacy (CLAUDE_MODEL) env var names. + model_name = os.environ.get("LLM_MODEL") or os.environ.get("CLAUDE_MODEL", "gemini-3.5-flash") logger.info(f"Implementing task: {task_summary}") logger.info(f"Model: {model_name}") @@ -378,14 +379,18 @@ async def run_agent_task( from deepagents import create_deep_agent from deepagents.backends import LocalShellBackend - # Check for API credentials - api_key = os.environ.get("ANTHROPIC_API_KEY") - vertex_project = os.environ.get("ANTHROPIC_VERTEX_PROJECT_ID") + # Check for API credentials. ANTHROPIC_* names are accepted for + # compatibility with existing deployments and LangChain integrations. + api_key = os.environ.get("LLM_API_KEY") or os.environ.get("ANTHROPIC_API_KEY") + vertex_project = os.environ.get("VERTEX_PROJECT_ID") or os.environ.get( + "ANTHROPIC_VERTEX_PROJECT_ID" + ) + vertex_region = os.environ.get("VERTEX_REGION") or os.environ.get( + "ANTHROPIC_VERTEX_REGION", "us-east5" + ) if not api_key and not vertex_project: - logger.error( - "No API credentials found (ANTHROPIC_API_KEY or ANTHROPIC_VERTEX_PROJECT_ID)" - ) + logger.error("No model backend credentials found (LLM_API_KEY or VERTEX_PROJECT_ID)") return False # Create the agent with local shell backend (enables git commands) @@ -408,7 +413,7 @@ async def run_agent_task( "llm_model": model_name, } - # Determine model type (Gemini vs Claude) + # Determine model family for the built-in LangChain model factories. is_gemini = model_name.lower().startswith(("gemini", "models/gemini")) # Get max tokens from env (default 16384) @@ -416,26 +421,28 @@ async def run_agent_task( if vertex_project: if is_gemini: - # Gemini models via ChatGoogleGenerativeAI with Vertex AI backend from langchain_google_genai import ChatGoogleGenerativeAI - logger.info(f"Using Gemini model: {model_name}, max_output_tokens={max_tokens}") + logger.info( + f"Using Vertex AI Gemini model: {model_name}, max_output_tokens={max_tokens}" + ) model = ChatGoogleGenerativeAI( model=model_name, project=vertex_project, - location=os.environ.get("ANTHROPIC_VERTEX_REGION", "us-east5"), + location=vertex_region, vertexai=True, max_output_tokens=max_tokens, ) else: - # Claude models via ChatAnthropicVertex from langchain_google_vertexai.model_garden import ChatAnthropicVertex - logger.info(f"Using Claude model: {model_name}, max_tokens={max_tokens}") + logger.info( + f"Using Vertex AI Anthropic model: {model_name}, max_tokens={max_tokens}" + ) model = ChatAnthropicVertex( model_name=model_name, project=vertex_project, - location=os.environ.get("ANTHROPIC_VERTEX_REGION", "us-east5"), + location=vertex_region, max_tokens=max_tokens, ) else: @@ -445,7 +452,7 @@ async def run_agent_task( from langchain_anthropic import ChatAnthropic - logger.info(f"Using Claude model: {model_name}, max_tokens={max_tokens}") + logger.info(f"Using direct Anthropic model: {model_name}, max_tokens={max_tokens}") model = ChatAnthropic( model=model_name, api_key=api_key, @@ -513,9 +520,7 @@ async def run_agent_task( # Run the agent (with Langfuse session context if enabled) initial_message = { - "messages": [ - {"role": "user", "content": f"Implement this task:\n\n{task_description}"} - ] + "messages": [{"role": "user", "content": f"Implement this task:\n\n{task_description}"}] } if langfuse_enabled: @@ -675,13 +680,18 @@ def main(): # Ensure changes are committed (agent should have done this, but as fallback). # Skip if workspace is not a git repo — analysis tasks (RCA, reflection) write # artifacts to .forge/ without needing a commit. - is_git_repo = subprocess.run( - ["git", "rev-parse", "--is-inside-work-tree"], - cwd=workspace, - capture_output=True, - ).returncode == 0 + is_git_repo = ( + subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=workspace, + capture_output=True, + ).returncode + == 0 + ) if is_git_repo: - fallback_message = f"[{task_key}] {task_summary}\n\nAuto-committed by Forge container fallback." + fallback_message = ( + f"[{task_key}] {task_summary}\n\nAuto-committed by Forge container fallback." + ) if not git_commit(workspace, fallback_message): logger.error("Failed to commit changes") sys.exit(EXIT_TASK_FAILED) diff --git a/docs/dev/setup.md b/docs/dev/setup.md index 6cf4d39c..63969a5d 100644 --- a/docs/dev/setup.md +++ b/docs/dev/setup.md @@ -11,7 +11,7 @@ This page is a condensed reference. For the full walkthrough including payload-b | Podman | `brew install podman` / `dnf install podman` | | Docker Compose | Included with Docker Desktop or `brew install docker-compose` | -External accounts needed: Jira Cloud, GitHub, and Anthropic API key (or Vertex AI). +External accounts needed: Jira Cloud, GitHub, and LLM backend access through a direct model provider API or Vertex AI. ## Installation @@ -33,9 +33,9 @@ JIRA_API_TOKEN=your-jira-api-token GITHUB_TOKEN=github_pat_your_token -ANTHROPIC_API_KEY=sk-ant-your-key # or Vertex AI — see developer guide +LLM_API_KEY=your-provider-api-key # or Vertex AI — see developer guide -LLM_MODEL=claude-opus-4-5@20251101 +LLM_MODEL=gemini-3.5-flash REDIS_URL=redis://localhost:6380/0 ``` diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 5cb296af..6765d2c8 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -31,7 +31,7 @@ Everything you need to run Forge locally, test it, observe what it's doing, and - **Docker Compose** — for Redis and API gateway (`dnf install docker-compose` / included with Docker Desktop) - **Jira Cloud** account with API access - **GitHub** account with a Personal Access Token (scopes: `repo`, `read:org`) -- **Claude API key** (Anthropic direct) OR Google Cloud project with Vertex AI enabled +- **LLM backend access** through a direct model provider API OR Google Cloud project with Vertex AI enabled --- @@ -70,15 +70,15 @@ GITHUB_TOKEN=github_pat_your_token # LLM — choose one backend -# Option A: Anthropic direct -ANTHROPIC_API_KEY=sk-ant-your-key +# Option A: Direct model provider API +LLM_API_KEY=your-provider-api-key -# Option B: Google Vertex AI (supports Claude + Gemini) -ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project -ANTHROPIC_VERTEX_REGION=us-east5 +# Option B: Google Vertex AI +VERTEX_PROJECT_ID=your-gcp-project +VERTEX_REGION=us-east5 # Which model to use -LLM_MODEL=claude-opus-4-5@20251101 +LLM_MODEL=gemini-3.5-flash ``` --- @@ -507,7 +507,7 @@ forge_webhooks_failed_total # External API latency forge_external_api_latency_seconds{service="jira"} forge_external_api_latency_seconds{service="github"} -forge_external_api_latency_seconds{service="claude"} +forge_external_api_latency_seconds{service="llm"} ``` ### Adding worker metrics to Prometheus diff --git a/docs/getting-started.md b/docs/getting-started.md index 08694afe..499cfc67 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,7 +9,7 @@ Get Forge running locally in about 10 minutes. - **Docker Compose** — for Redis (`brew install docker-compose` / included with Docker Desktop) - **Jira Cloud** account with API access - **GitHub** Personal Access Token (scopes: `repo`, `read:org`) -- **Claude API key** (Anthropic direct) or Google Cloud project with Vertex AI enabled +- **LLM backend access** through a direct model provider API or Google Cloud project with Vertex AI enabled ## 1. Install @@ -36,12 +36,12 @@ JIRA_API_TOKEN=your-jira-api-token # GitHub GITHUB_TOKEN=github_pat_your_token -# LLM — choose one -ANTHROPIC_API_KEY=sk-ant-your-key # Anthropic direct -# ANTHROPIC_VERTEX_PROJECT_ID=my-proj # OR Vertex AI -# ANTHROPIC_VERTEX_REGION=us-east5 +# LLM backend — choose one +LLM_API_KEY=your-provider-api-key # Direct model provider API +# VERTEX_PROJECT_ID=my-proj # OR Vertex AI +# VERTEX_REGION=us-east5 -LLM_MODEL=claude-opus-4-5@20251101 +LLM_MODEL=gemini-3.5-flash ``` ## 3. Build the Container Image diff --git a/docs/guide/pr-commands.md b/docs/guide/pr-commands.md index 691cfb44..7403e035 100644 --- a/docs/guide/pr-commands.md +++ b/docs/guide/pr-commands.md @@ -59,7 +59,7 @@ Merge `main` into the PR branch and resolve any merge conflicts using AI. Use th 3. Clones the repository and checks out the PR branch from the fork 4. Attempts `git merge origin/main` 5. If no conflicts: pushes the merge commit to the fork branch -6. If conflicts: spawns a container with Claude to resolve them using the PR description and changed files as context +6. If conflicts: spawns a container agent to resolve them using the PR description and changed files as context 7. Verifies no conflict markers remain, commits, and force-pushes to the fork 8. Returns the workflow to the node it was at before the rebase diff --git a/docs/index.md b/docs/index.md index 80480903..6bad94b8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # Forge -Forge automates the software development lifecycle from feature ideation through code delivery using AI-powered planning and execution. It connects Jira, GitHub, and Claude to transform tickets into shipped code with human approval gates at each stage. +Forge automates the software development lifecycle from feature ideation through code delivery using AI-powered planning and execution. It connects Jira, GitHub, and Deep Agents-backed LangChain models to transform tickets into shipped code with human approval gates at each stage. ## How It Works diff --git a/docs/reference/config.md b/docs/reference/config.md index 1f8bbecb..67f5bcef 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -22,23 +22,28 @@ All configuration is via environment variables in `.env`. See `.env.example` in ### LLM -Choose one backend: +Choose one backend. Forge passes LangChain chat model instances into Deep Agents; the +built-in factory supports direct Anthropic API credentials and Vertex AI-backed +models, and can be extended for other LangChain chat model providers. -=== "Anthropic Direct" +=== "Direct Provider API" ```bash - ANTHROPIC_API_KEY=sk-ant-your-key - LLM_MODEL=claude-opus-4-5@20251101 + LLM_API_KEY=your-provider-api-key + LLM_MODEL=your-provider-model ``` === "Google Vertex AI" ```bash - ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project - ANTHROPIC_VERTEX_REGION=us-east5 - LLM_MODEL=claude-opus-4-5@20251101 + VERTEX_PROJECT_ID=your-gcp-project + VERTEX_REGION=us-east5 + LLM_MODEL=gemini-3.5-flash ``` +`ANTHROPIC_API_KEY`, `ANTHROPIC_VERTEX_PROJECT_ID`, and +`ANTHROPIC_VERTEX_REGION` remain supported as backwards-compatible aliases. + ### Redis | Variable | Default | Description | diff --git a/src/forge/api/routes/metrics.py b/src/forge/api/routes/metrics.py index 42c49656..852ec034 100644 --- a/src/forge/api/routes/metrics.py +++ b/src/forge/api/routes/metrics.py @@ -112,7 +112,7 @@ [ "service", "operation", - ], # service: jira, github, claude; operation: get_issue, create_pr, etc. + ], # service: jira, github, llm; operation: get_issue, create_pr, etc. buckets=[0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0], ) @@ -216,7 +216,7 @@ def observe_external_api_latency(service: str, operation: str, duration: float) """Record latency of an external API call. Args: - service: External service name (jira, github, claude). + service: External service name (jira, github, llm). operation: Operation name (get_issue, create_pr, generate, etc.). duration: Duration in seconds. """ @@ -227,7 +227,7 @@ def record_external_api_error(service: str, operation: str, error_type: str) -> """Record an external API call error. Args: - service: External service name (jira, github, claude). + service: External service name (jira, github, llm). operation: Operation name. error_type: Type of error (timeout, rate_limit, auth, etc.). """ diff --git a/src/forge/cli.py b/src/forge/cli.py index 36016aac..4691045a 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -702,13 +702,13 @@ async def cmd_health(_args: argparse.Namespace) -> int: else: print("[SKIP] Jira: API token not configured") - # Check Anthropic/Vertex + # Check model backend if settings.use_vertex_ai: - print(f"[OK] Using Vertex AI: {settings.anthropic_vertex_project_id}") - elif settings.anthropic_api_key.get_secret_value(): - print("[OK] Using direct Anthropic API") + print(f"[OK] Using Vertex AI: {settings.resolved_vertex_project_id}") + elif settings.direct_llm_api_key.get_secret_value(): + print("[OK] Using direct model provider API") else: - print("[WARN] No Claude API configured") + print("[WARN] No LLM backend configured") print("\nHealth check complete!") return 0 diff --git a/src/forge/config.py b/src/forge/config.py index e1bc7db9..9de8b55b 100644 --- a/src/forge/config.py +++ b/src/forge/config.py @@ -147,27 +147,36 @@ def known_repos(self) -> list[str]: return [] return [r.strip() for r in self.github_known_repos.split(",") if r.strip()] - # Anthropic Configuration - # Option 1: Direct Anthropic API + # LLM provider configuration. + # Generic names are preferred for new deployments. ANTHROPIC_* names remain + # supported for compatibility with existing environments. + llm_api_key: SecretStr = Field( + default=SecretStr(""), + description="Direct model provider API key, when not using Vertex AI", + ) + vertex_project_id: str = Field( + default="", + description="Google Cloud project ID for Vertex AI", + ) + vertex_region: str = Field( + default="", + description="Google Cloud region for Vertex AI", + ) anthropic_api_key: SecretStr = Field( default=SecretStr(""), - description="Anthropic API key for Claude (leave empty for Vertex AI)", + description="Deprecated alias for llm_api_key", ) - # Option 2: Google Vertex AI (supports Claude and Gemini) anthropic_vertex_project_id: str = Field( default="", - description="Google Cloud project ID for Vertex AI", + description="Deprecated alias for vertex_project_id", ) anthropic_vertex_region: str = Field( default="us-east5", - description="Google Cloud region for Vertex AI (e.g., us-east5)", + description="Deprecated alias for vertex_region", ) - # Model configuration (supports Claude and Gemini on Vertex AI) - # Claude models: claude-opus-4-5@20251101, claude-sonnet-4-5@20250929, etc. - # Gemini models: gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro-preview, etc. llm_model: str = Field( - default="claude-sonnet-4-5@20250929", - description="Model for orchestrator (Claude or Gemini on Vertex AI)", + default="gemini-3.5-flash", + description="Model for orchestrator agents", ) container_llm_model: str = Field( default="", @@ -183,6 +192,23 @@ def container_model(self) -> str: """Get model for container execution, falling back to default model.""" return self.container_llm_model or self.llm_model + @property + def direct_llm_api_key(self) -> SecretStr: + """Get the configured direct model provider API key.""" + if self.llm_api_key.get_secret_value(): + return self.llm_api_key + return self.anthropic_api_key + + @property + def resolved_vertex_project_id(self) -> str: + """Get the configured Vertex AI project ID.""" + return self.vertex_project_id or self.anthropic_vertex_project_id + + @property + def resolved_vertex_region(self) -> str: + """Get the configured Vertex AI region.""" + return self.vertex_region or self.anthropic_vertex_region + # Backwards compatibility aliases @property def claude_model(self) -> str: @@ -194,12 +220,12 @@ def detect_model_provider(model_name: str) -> str: """Detect model provider from model name. Returns: - 'anthropic' for Claude models, 'google' for Gemini models. + 'anthropic' for Anthropic models, 'google' for Gemini models. """ model_lower = model_name.lower() if model_lower.startswith(("gemini", "models/gemini")): return "google" - # Default to anthropic for claude-* or unknown models + # Default to anthropic for claude-* or unknown direct-provider models. return "anthropic" # Langfuse Configuration @@ -222,7 +248,7 @@ def detect_model_provider(model_name: str) -> str: description="Comma-separated list of TracingField names to include as Langfuse trace metadata", ) - # Claude Agent SDK Configuration + # Agent Configuration agent_enable_tools: bool = Field( default=True, description="Enable agent tools (Read, Glob, Grep, WebSearch)", @@ -397,9 +423,9 @@ def trace_metadata_fields(self) -> list["TracingField"]: @property def use_vertex_ai(self) -> bool: - """Check if using Vertex AI instead of direct Anthropic API.""" + """Check if using Vertex AI instead of a direct model provider API.""" return bool( - self.anthropic_vertex_project_id and not self.anthropic_api_key.get_secret_value() + self.resolved_vertex_project_id and not self.direct_llm_api_key.get_secret_value() ) diff --git a/src/forge/integrations/agents/agent.py b/src/forge/integrations/agents/agent.py index 2497bbda..9c783f3e 100644 --- a/src/forge/integrations/agents/agent.py +++ b/src/forge/integrations/agents/agent.py @@ -36,7 +36,7 @@ from forge.prompts import load_prompt, set_default_version from forge.skills.resolver import resolve_skill_paths -# Optional Vertex AI support (Claude and Gemini) +# Optional Vertex AI support try: from langchain_google_genai import ChatGoogleGenerativeAI from langchain_google_vertexai.model_garden import ChatAnthropicVertex @@ -125,12 +125,13 @@ def __init__(self, settings: Settings | None = None): set_default_version(self.settings.prompt_version) def _ensure_api_key(self) -> None: - """Ensure Anthropic API key is available.""" + """Ensure direct provider API key aliases are available.""" if self.settings.use_vertex_ai: logger.info("Using Vertex AI backend") - elif not os.environ.get("ANTHROPIC_API_KEY"): - api_key = self.settings.anthropic_api_key.get_secret_value() + elif not os.environ.get("LLM_API_KEY") and not os.environ.get("ANTHROPIC_API_KEY"): + api_key = self.settings.direct_llm_api_key.get_secret_value() if api_key: + os.environ["LLM_API_KEY"] = api_key os.environ["ANTHROPIC_API_KEY"] = api_key def _create_model(self, model_name: str | None = None) -> Any: @@ -140,9 +141,9 @@ def _create_model(self, model_name: str | None = None) -> Any: model_name: Optional model name override. Uses settings if not provided. Returns: - A LangChain ChatModel instance (ChatAnthropic, ChatAnthropicVertex, or ChatVertexAI). + A LangChain chat model instance for Deep Agents. """ - model = model_name or self.settings.claude_model + model = model_name or self.settings.llm_model provider = Settings.detect_model_provider(model) if self.settings.use_vertex_ai: @@ -153,42 +154,43 @@ def _create_model(self, model_name: str | None = None) -> Any: ) if provider == "google": - # Gemini models via ChatGoogleGenerativeAI with Vertex AI backend logger.info( - f"Creating ChatGoogleGenerativeAI (Gemini) model: {model} " - f"in {self.settings.anthropic_vertex_region}, max_output_tokens={self.settings.llm_max_tokens}" + f"Creating Vertex AI Gemini model: {model} " + f"in {self.settings.resolved_vertex_region}, " + f"max_output_tokens={self.settings.llm_max_tokens}" ) return ChatGoogleGenerativeAI( model=model, - project=self.settings.anthropic_vertex_project_id, - location=self.settings.anthropic_vertex_region, + project=self.settings.resolved_vertex_project_id, + location=self.settings.resolved_vertex_region, vertexai=True, max_output_tokens=self.settings.llm_max_tokens, ) else: - # Claude models via ChatAnthropicVertex logger.info( - f"Creating ChatAnthropicVertex model: {model} " - f"in {self.settings.anthropic_vertex_region}, max_tokens={self.settings.llm_max_tokens}" + f"Creating Vertex AI Anthropic model: {model} " + f"in {self.settings.resolved_vertex_region}, " + f"max_tokens={self.settings.llm_max_tokens}" ) return ChatAnthropicVertex( model_name=model, - project=self.settings.anthropic_vertex_project_id, - location=self.settings.anthropic_vertex_region, + project=self.settings.resolved_vertex_project_id, + location=self.settings.resolved_vertex_region, max_tokens=self.settings.llm_max_tokens, ) else: if provider == "google": raise ValueError( f"Gemini model '{model}' requires Vertex AI. " - "Set ANTHROPIC_VERTEX_PROJECT_ID or use a Claude model." + "Set VERTEX_PROJECT_ID or ANTHROPIC_VERTEX_PROJECT_ID." ) logger.info( - f"Creating ChatAnthropic model: {model}, max_tokens={self.settings.llm_max_tokens}" + f"Creating direct Anthropic model: {model}, " + f"max_tokens={self.settings.llm_max_tokens}" ) return ChatAnthropic( model=model, - api_key=self.settings.anthropic_api_key.get_secret_value(), + api_key=self.settings.direct_llm_api_key.get_secret_value(), max_tokens=self.settings.llm_max_tokens, ) @@ -761,7 +763,7 @@ async def run_task( **(context or {}), **(trace_context or {}), "system_prompt_length": len(system_prompt), - "llm_model": self.settings.claude_model, + "llm_model": self.settings.llm_model, } trace_tags, trace_metadata = resolve_trace_fields(trace_state) @@ -889,7 +891,8 @@ def _get_setting_value(self, var_name: str) -> str: "JIRA_DOMAIN": lambda: self.settings.jira_domain_resolved, "ATLASSIAN_AUTH_BASE64": lambda: self.settings.atlassian_auth_base64, "AGENT_WORKING_DIRECTORY": lambda: self.settings.agent_working_directory or os.getcwd(), - "ANTHROPIC_API_KEY": lambda: self.settings.anthropic_api_key.get_secret_value(), + "LLM_API_KEY": lambda: self.settings.direct_llm_api_key.get_secret_value(), + "ANTHROPIC_API_KEY": lambda: self.settings.direct_llm_api_key.get_secret_value(), } if var_name in var_mapping: diff --git a/src/forge/integrations/langfuse/tracing.py b/src/forge/integrations/langfuse/tracing.py index 9b3086e5..048ac2a2 100644 --- a/src/forge/integrations/langfuse/tracing.py +++ b/src/forge/integrations/langfuse/tracing.py @@ -227,8 +227,8 @@ def trace_llm_call( Note: For LangChain/Deep Agents, use get_langfuse_config() instead and pass the callbacks via the config parameter. - This context manager is kept for backwards compatibility with - direct Anthropic API calls. + This context manager is kept for backwards compatibility with direct + provider API calls. Args: name: Name of the LLM operation. diff --git a/src/forge/main.py b/src/forge/main.py index 0826a7fa..5920cf7c 100644 --- a/src/forge/main.py +++ b/src/forge/main.py @@ -64,7 +64,7 @@ def create_app() -> FastAPI: - **Webhook Gateway**: Receives events from Jira and GitHub - **Workflow Orchestration**: LangGraph-based state machine -- **AI Integration**: Claude-powered planning and code generation +- **AI Integration**: Deep Agents-backed planning and code generation - **Multi-repo Support**: Concurrent execution across repositories ### Workflow diff --git a/src/forge/sandbox/runner.py b/src/forge/sandbox/runner.py index a422ef91..e3a2f327 100644 --- a/src/forge/sandbox/runner.py +++ b/src/forge/sandbox/runner.py @@ -119,14 +119,20 @@ def _build_env_vars( """ env = {} - # Pass Anthropic credentials - if self.settings.anthropic_api_key.get_secret_value(): - env["ANTHROPIC_API_KEY"] = self.settings.anthropic_api_key.get_secret_value() + # Pass direct model provider credentials. Keep ANTHROPIC_API_KEY for + # LangChain's Anthropic integration and older container images. + direct_api_key = self.settings.direct_llm_api_key.get_secret_value() + if direct_api_key: + env["LLM_API_KEY"] = direct_api_key + env["ANTHROPIC_API_KEY"] = direct_api_key # Pass Vertex AI credentials if self.settings.use_vertex_ai: - env["ANTHROPIC_VERTEX_PROJECT_ID"] = self.settings.anthropic_vertex_project_id - env["ANTHROPIC_VERTEX_REGION"] = self.settings.anthropic_vertex_region + env["VERTEX_PROJECT_ID"] = self.settings.resolved_vertex_project_id + env["VERTEX_REGION"] = self.settings.resolved_vertex_region + # Backwards-compatible names consumed by older container entrypoints. + env["ANTHROPIC_VERTEX_PROJECT_ID"] = self.settings.resolved_vertex_project_id + env["ANTHROPIC_VERTEX_REGION"] = self.settings.resolved_vertex_region # GOOGLE_APPLICATION_CREDENTIALS will be set if we mount gcloud creds env["GOOGLE_APPLICATION_CREDENTIALS"] = ( "/root/.config/gcloud/application_default_credentials.json" diff --git a/src/forge/utils/__init__.py b/src/forge/utils/__init__.py index ae87a6b3..d34703b1 100644 --- a/src/forge/utils/__init__.py +++ b/src/forge/utils/__init__.py @@ -19,6 +19,7 @@ ANTHROPIC_RETRY_CONFIG, GITHUB_RETRY_CONFIG, JIRA_RETRY_CONFIG, + LLM_RETRY_CONFIG, RetryableError, RetryConfig, calculate_delay, @@ -41,6 +42,7 @@ "ANTHROPIC_RETRY_CONFIG", "GITHUB_RETRY_CONFIG", "JIRA_RETRY_CONFIG", + "LLM_RETRY_CONFIG", "RetryConfig", "RetryableError", "calculate_delay", diff --git a/src/forge/utils/logging.py b/src/forge/utils/logging.py index 43ddc55e..97d45ccb 100644 --- a/src/forge/utils/logging.py +++ b/src/forge/utils/logging.py @@ -221,7 +221,7 @@ def log_api_call( Args: logger: Logger to use. - service: Service name (jira, github, anthropic). + service: Service name (jira, github, llm). method: HTTP method. endpoint: API endpoint. status_code: Response status code. diff --git a/src/forge/utils/rate_limiter.py b/src/forge/utils/rate_limiter.py index 52ee4805..7e6b7d60 100644 --- a/src/forge/utils/rate_limiter.py +++ b/src/forge/utils/rate_limiter.py @@ -97,7 +97,12 @@ def __init__(self) -> None: requests_per_second=1.0, burst_limit=20, ), - # Anthropic: varies by tier, conservative default + # LLM providers vary by tier; keep a conservative default. + "llm": RateLimitConfig( + requests_per_second=0.5, + burst_limit=5, + ), + # Backwards-compatible service key for existing callers. "anthropic": RateLimitConfig( requests_per_second=0.5, burst_limit=5, @@ -130,7 +135,7 @@ async def acquire(self, service: str, tokens: int = 1) -> None: Blocks until tokens are available. Args: - service: Service name (jira, github, anthropic). + service: Service name (jira, github, llm). tokens: Number of tokens to acquire. """ bucket = self._get_bucket(service) diff --git a/src/forge/utils/retry.py b/src/forge/utils/retry.py index 42d12b3a..f8949b0c 100644 --- a/src/forge/utils/retry.py +++ b/src/forge/utils/retry.py @@ -186,7 +186,7 @@ def __init__(self, message: str, original: Exception | None = None): ), ) -ANTHROPIC_RETRY_CONFIG = RetryConfig( +LLM_RETRY_CONFIG = RetryConfig( max_attempts=5, initial_delay=5.0, max_delay=120.0, @@ -197,3 +197,6 @@ def __init__(self, message: str, original: Exception | None = None): RetryableError, ), ) + +# Backwards-compatible alias for existing imports. +ANTHROPIC_RETRY_CONFIG = LLM_RETRY_CONFIG diff --git a/src/forge/workflow/nodes/ci_evaluator.py b/src/forge/workflow/nodes/ci_evaluator.py index 84eb5812..96df1672 100644 --- a/src/forge/workflow/nodes/ci_evaluator.py +++ b/src/forge/workflow/nodes/ci_evaluator.py @@ -236,7 +236,7 @@ async def attempt_ci_fix(state: WorkflowState) -> WorkflowState: This node: 1. Extracts error information from failed checks - 2. Invokes Claude to generate a fix + 2. Invokes the configured LLM backend to generate a fix 3. Applies the fix and pushes 4. Routes back to CI evaluation diff --git a/src/forge/workflow/nodes/epic_decomposition.py b/src/forge/workflow/nodes/epic_decomposition.py index 728f86cd..ecafd9b4 100644 --- a/src/forge/workflow/nodes/epic_decomposition.py +++ b/src/forge/workflow/nodes/epic_decomposition.py @@ -35,7 +35,7 @@ async def decompose_epics(state: WorkflowState) -> WorkflowState: This node: 1. Reads the approved specification from state - 2. Generates 2-5 cohesive Epics using Claude + 2. Generates 2-5 cohesive Epics using the configured LLM backend 3. Creates Epic tickets in Jira linked to parent Feature 4. Transitions Feature to "Pending Plan Approval" @@ -127,7 +127,7 @@ async def decompose_epics(state: WorkflowState) -> WorkflowState: "feedback": state.get("feedback_comment", ""), } - # Generate Epic breakdown using Claude - primary operation + # Generate Epic breakdown using the configured LLM backend - primary operation epics_data = await agent.generate_epics(spec_content, context) if not epics_data: diff --git a/src/forge/workflow/nodes/prd_generation.py b/src/forge/workflow/nodes/prd_generation.py index 9a7bde46..5ab98491 100644 --- a/src/forge/workflow/nodes/prd_generation.py +++ b/src/forge/workflow/nodes/prd_generation.py @@ -172,7 +172,7 @@ async def generate_prd(state: WorkflowState) -> WorkflowState: This node: 1. Reads the current Jira issue description - 2. Generates a structured PRD using Claude + 2. Generates a structured PRD using the configured LLM backend 3. Updates the Jira description with the PRD 4. Transitions the ticket to "Pending PRD Approval" @@ -221,7 +221,7 @@ async def generate_prd(state: WorkflowState) -> WorkflowState: "project_key": issue.project_key, } - # Generate PRD using Claude - primary operation + # Generate PRD using the configured LLM backend - primary operation prd_content = await agent.generate_prd(raw_requirements, context) # Publish PRD - either as GitHub PR or Jira update diff --git a/src/forge/workflow/nodes/spec_generation.py b/src/forge/workflow/nodes/spec_generation.py index 51c88e49..0e8c4630 100644 --- a/src/forge/workflow/nodes/spec_generation.py +++ b/src/forge/workflow/nodes/spec_generation.py @@ -190,7 +190,7 @@ async def generate_spec(state: WorkflowState) -> WorkflowState: "retry_count": state.get("retry_count", 0), } - # Generate specification using Claude - primary operation + # Generate specification using the configured LLM backend - primary operation spec_content = await agent.generate_spec(prd_content, context) # Publish spec — either as GitHub PR or Jira update diff --git a/src/forge/workflow/nodes/task_generation.py b/src/forge/workflow/nodes/task_generation.py index 6e1aa4c7..7c0d128c 100644 --- a/src/forge/workflow/nodes/task_generation.py +++ b/src/forge/workflow/nodes/task_generation.py @@ -22,7 +22,7 @@ async def generate_tasks(state: WorkflowState) -> WorkflowState: This node: 1. Iterates through all Epics in epic_keys - 2. Generates Tasks for each Epic using Claude + 2. Generates Tasks for each Epic using the configured LLM backend 3. Creates Task tickets in Jira with repository labels 4. Updates state with task tracking @@ -375,7 +375,7 @@ def _parse_tasks_response(response: str) -> list[dict[str, str]]: """Parse Task generation response into structured data. Args: - response: Raw response from Claude. + response: Raw response from the configured LLM backend. Returns: List of Task dicts. diff --git a/src/forge/workspace/git_ops.py b/src/forge/workspace/git_ops.py index c78a1a78..ba3be164 100644 --- a/src/forge/workspace/git_ops.py +++ b/src/forge/workspace/git_ops.py @@ -241,7 +241,7 @@ def commit(self, message: str, author_name: str = "Forge") -> bool: "-m", message, "--author", - f"{author_name} ", + f"{author_name} ", ) logger.info(f"Committed: {message[:50]}...") return True diff --git a/tests/unit/test_config_prd.py b/tests/unit/test_config_prd.py index 6d18c5a2..a3cb09f3 100644 --- a/tests/unit/test_config_prd.py +++ b/tests/unit/test_config_prd.py @@ -1,11 +1,23 @@ """Tests for PRD approval configuration settings.""" +import pytest + from forge.config import Settings +@pytest.fixture(autouse=True) +def clear_prd_proposal_env(monkeypatch): + monkeypatch.delenv("PRD_PROPOSALS_REPO", raising=False) + monkeypatch.delenv("PRD_PROPOSALS_PATH", raising=False) + + +def make_settings(**kwargs) -> Settings: + return Settings(_env_file=None, **kwargs) + + class TestPrdApprovalConfig: def test_default_proposals_repo_is_empty(self) -> None: - settings = Settings( + settings = make_settings( jira_base_url="https://test.atlassian.net", jira_api_token="test", jira_user_email="test@example.com", @@ -15,7 +27,7 @@ def test_default_proposals_repo_is_empty(self) -> None: assert settings.prd_proposals_repo == "" def test_default_proposals_path(self) -> None: - settings = Settings( + settings = make_settings( jira_base_url="https://test.atlassian.net", jira_api_token="test", jira_user_email="test@example.com", @@ -25,7 +37,7 @@ def test_default_proposals_path(self) -> None: assert settings.prd_proposals_path == "" def test_proposals_repo_can_be_set_as_global_fallback(self) -> None: - settings = Settings( + settings = make_settings( jira_base_url="https://test.atlassian.net", jira_api_token="test", jira_user_email="test@example.com", @@ -34,3 +46,55 @@ def test_proposals_repo_can_be_set_as_global_fallback(self) -> None: prd_proposals_repo="org/proposals", ) assert settings.prd_proposals_repo == "org/proposals" + + +class TestLlmConfig: + def test_default_model_is_gemini_flash(self): + settings = make_settings( + jira_base_url="https://test.atlassian.net", + jira_api_token="test", + jira_user_email="test@example.com", + github_token="test", + ) + + assert settings.llm_model == "gemini-3.5-flash" + + def test_generic_direct_api_key_takes_precedence(self): + settings = make_settings( + jira_base_url="https://test.atlassian.net", + jira_api_token="test", + jira_user_email="test@example.com", + github_token="test", + llm_api_key="generic-key", + anthropic_api_key="legacy-key", + ) + + assert settings.direct_llm_api_key.get_secret_value() == "generic-key" + assert settings.use_vertex_ai is False + + def test_legacy_direct_api_key_still_works(self): + settings = make_settings( + jira_base_url="https://test.atlassian.net", + jira_api_token="test", + jira_user_email="test@example.com", + github_token="test", + anthropic_api_key="legacy-key", + ) + + assert settings.direct_llm_api_key.get_secret_value() == "legacy-key" + + def test_generic_vertex_settings_take_precedence(self): + settings = make_settings( + jira_base_url="https://test.atlassian.net", + jira_api_token="test", + jira_user_email="test@example.com", + github_token="test", + vertex_project_id="generic-project", + vertex_region="europe-west1", + anthropic_vertex_project_id="legacy-project", + anthropic_vertex_region="us-east5", + ) + + assert settings.resolved_vertex_project_id == "generic-project" + assert settings.resolved_vertex_region == "europe-west1" + assert settings.use_vertex_ai is True From 11cb174e315c510763600371ff4272956b6eb533 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Sun, 28 Jun 2026 14:28:42 +0300 Subject: [PATCH 2/3] Use direct Anthropic-compatible default model --- .env.example | 4 ++-- containers/README.md | 4 ++-- containers/entrypoint.py | 4 +++- docs/dev/setup.md | 4 ++-- docs/developer-guide.md | 6 +++--- docs/getting-started.md | 4 ++-- docs/reference/config.md | 6 +++--- src/forge/config.py | 4 ++-- tests/unit/test_config_prd.py | 13 +++++++++++-- 9 files changed, 30 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 13706eb8..94ce0582 100644 --- a/.env.example +++ b/.env.example @@ -72,7 +72,7 @@ FORGE_REQUIRE_PROJECT_CONFIG=true # the model factory. # ============================================================================= -# Option 1: Direct model provider API. +# Option 1: Direct Anthropic API. LLM_API_KEY= # Backwards-compatible name for Anthropic direct API deployments. ANTHROPIC_API_KEY= @@ -86,7 +86,7 @@ ANTHROPIC_VERTEX_REGION=us-east5 # Model for orchestrator (PRD, spec, epic planning) # Examples: gemini-3.5-flash, gemini-2.5-pro, claude-opus-4-5@20251101 -LLM_MODEL=gemini-3.5-flash +LLM_MODEL=claude-sonnet-4-5@20250929 # Model for container tasks (code implementation) # Can use a different model than orchestrator (e.g., for cost/rate limit reasons) diff --git a/containers/README.md b/containers/README.md index 1434cba5..c6a97f9e 100644 --- a/containers/README.md +++ b/containers/README.md @@ -104,11 +104,11 @@ Passed automatically by the orchestrator: | Variable | Description | |----------|-------------| -| `LLM_API_KEY` | Direct model provider API key | +| `LLM_API_KEY` | Direct Anthropic API key | | `VERTEX_PROJECT_ID` | GCP project for Vertex AI | | `VERTEX_REGION` | Vertex AI region | | `ANTHROPIC_*` | Backwards-compatible aliases for existing deployments | -| `LLM_MODEL` | Model to use (default: `gemini-3.5-flash`; examples: `gemini-2.5-pro`, `claude-opus-4-5@20251101`) | +| `LLM_MODEL` | Model to use (default: `claude-sonnet-4-5@20250929`; examples: `gemini-3.5-flash`, `gemini-2.5-pro`, `claude-opus-4-5@20251101`) | | `FORGE_SYSTEM_PROMPT_TEMPLATE` | System prompt template (interpolated by entrypoint) | | `GOOGLE_APPLICATION_CREDENTIALS` | Path to mounted gcloud credentials | | `GIT_USER_NAME` | Git author name for commits (default: `Forge`) | diff --git a/containers/entrypoint.py b/containers/entrypoint.py index 7ad401ed..7e1180d6 100644 --- a/containers/entrypoint.py +++ b/containers/entrypoint.py @@ -371,7 +371,9 @@ async def run_agent_task( trace_context: Workflow fields forwarded to Langfuse only. """ # Support both generic (LLM_MODEL) and legacy (CLAUDE_MODEL) env var names. - model_name = os.environ.get("LLM_MODEL") or os.environ.get("CLAUDE_MODEL", "gemini-3.5-flash") + model_name = os.environ.get("LLM_MODEL") or os.environ.get( + "CLAUDE_MODEL", "claude-sonnet-4-5@20250929" + ) logger.info(f"Implementing task: {task_summary}") logger.info(f"Model: {model_name}") diff --git a/docs/dev/setup.md b/docs/dev/setup.md index 63969a5d..8c44121b 100644 --- a/docs/dev/setup.md +++ b/docs/dev/setup.md @@ -33,9 +33,9 @@ JIRA_API_TOKEN=your-jira-api-token GITHUB_TOKEN=github_pat_your_token -LLM_API_KEY=your-provider-api-key # or Vertex AI — see developer guide +LLM_API_KEY=your-anthropic-api-key # or Vertex AI — see developer guide -LLM_MODEL=gemini-3.5-flash +LLM_MODEL=claude-sonnet-4-5@20250929 REDIS_URL=redis://localhost:6380/0 ``` diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 6765d2c8..800fe802 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -70,15 +70,15 @@ GITHUB_TOKEN=github_pat_your_token # LLM — choose one backend -# Option A: Direct model provider API -LLM_API_KEY=your-provider-api-key +# Option A: Direct Anthropic API +LLM_API_KEY=your-anthropic-api-key # Option B: Google Vertex AI VERTEX_PROJECT_ID=your-gcp-project VERTEX_REGION=us-east5 # Which model to use -LLM_MODEL=gemini-3.5-flash +LLM_MODEL=claude-sonnet-4-5@20250929 ``` --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 499cfc67..40bed257 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,11 +37,11 @@ JIRA_API_TOKEN=your-jira-api-token GITHUB_TOKEN=github_pat_your_token # LLM backend — choose one -LLM_API_KEY=your-provider-api-key # Direct model provider API +LLM_API_KEY=your-anthropic-api-key # Direct Anthropic API # VERTEX_PROJECT_ID=my-proj # OR Vertex AI # VERTEX_REGION=us-east5 -LLM_MODEL=gemini-3.5-flash +LLM_MODEL=claude-sonnet-4-5@20250929 ``` ## 3. Build the Container Image diff --git a/docs/reference/config.md b/docs/reference/config.md index 67f5bcef..8305c364 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -26,11 +26,11 @@ Choose one backend. Forge passes LangChain chat model instances into Deep Agents built-in factory supports direct Anthropic API credentials and Vertex AI-backed models, and can be extended for other LangChain chat model providers. -=== "Direct Provider API" +=== "Direct Anthropic API" ```bash - LLM_API_KEY=your-provider-api-key - LLM_MODEL=your-provider-model + LLM_API_KEY=your-anthropic-api-key + LLM_MODEL=claude-sonnet-4-5@20250929 ``` === "Google Vertex AI" diff --git a/src/forge/config.py b/src/forge/config.py index 9de8b55b..32733ef3 100644 --- a/src/forge/config.py +++ b/src/forge/config.py @@ -152,7 +152,7 @@ def known_repos(self) -> list[str]: # supported for compatibility with existing environments. llm_api_key: SecretStr = Field( default=SecretStr(""), - description="Direct model provider API key, when not using Vertex AI", + description="Direct Anthropic API key, when not using Vertex AI", ) vertex_project_id: str = Field( default="", @@ -175,7 +175,7 @@ def known_repos(self) -> list[str]: description="Deprecated alias for vertex_region", ) llm_model: str = Field( - default="gemini-3.5-flash", + default="claude-sonnet-4-5@20250929", description="Model for orchestrator agents", ) container_llm_model: str = Field( diff --git a/tests/unit/test_config_prd.py b/tests/unit/test_config_prd.py index a3cb09f3..690cf900 100644 --- a/tests/unit/test_config_prd.py +++ b/tests/unit/test_config_prd.py @@ -9,6 +9,14 @@ def clear_prd_proposal_env(monkeypatch): monkeypatch.delenv("PRD_PROPOSALS_REPO", raising=False) monkeypatch.delenv("PRD_PROPOSALS_PATH", raising=False) + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("VERTEX_PROJECT_ID", raising=False) + monkeypatch.delenv("VERTEX_REGION", raising=False) + monkeypatch.delenv("ANTHROPIC_VERTEX_PROJECT_ID", raising=False) + monkeypatch.delenv("ANTHROPIC_VERTEX_REGION", raising=False) + monkeypatch.delenv("LLM_MODEL", raising=False) + monkeypatch.delenv("CONTAINER_LLM_MODEL", raising=False) def make_settings(**kwargs) -> Settings: @@ -49,7 +57,7 @@ def test_proposals_repo_can_be_set_as_global_fallback(self) -> None: class TestLlmConfig: - def test_default_model_is_gemini_flash(self): + def test_default_model_is_direct_anthropic_compatible(self): settings = make_settings( jira_base_url="https://test.atlassian.net", jira_api_token="test", @@ -57,7 +65,8 @@ def test_default_model_is_gemini_flash(self): github_token="test", ) - assert settings.llm_model == "gemini-3.5-flash" + assert settings.llm_model == "claude-sonnet-4-5@20250929" + assert Settings.detect_model_provider(settings.llm_model) == "anthropic" def test_generic_direct_api_key_takes_precedence(self): settings = make_settings( From a8428bbeb3ce411535bbefa17c9c022191583b0c Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Mon, 13 Jul 2026 11:09:00 +0300 Subject: [PATCH 3/3] feat: make Gemini the default model backend --- .env.example | 27 +++--- containers/README.md | 18 ++-- containers/entrypoint.py | 86 +++++++++++++++---- docs/dev/setup.md | 7 +- docs/developer-guide.md | 16 ++-- docs/getting-started.md | 11 ++- docs/reference/config.md | 29 ++++--- src/forge/cli.py | 19 +++- src/forge/config.py | 77 +++++++++++++---- src/forge/integrations/agents/agent.py | 78 +++++++++++------ src/forge/sandbox/runner.py | 22 +++-- .../containers/test_entrypoint_llm_config.py | 59 +++++++++++++ tests/unit/integrations/agents/test_agent.py | 62 +++++++++++++ tests/unit/sandbox/test_runner_llm_backend.py | 52 +++++++++++ tests/unit/test_config_prd.py | 58 ++++++++++--- 15 files changed, 482 insertions(+), 139 deletions(-) create mode 100644 tests/unit/containers/test_entrypoint_llm_config.py create mode 100644 tests/unit/sandbox/test_runner_llm_backend.py diff --git a/.env.example b/.env.example index 94ce0582..89d9ce04 100644 --- a/.env.example +++ b/.env.example @@ -67,26 +67,25 @@ FORGE_REQUIRE_PROJECT_CONFIG=true # ============================================================================= # LLM Configuration # Forge passes LangChain chat model instances into Deep Agents. Built-in -# configuration supports direct Anthropic API credentials and Vertex AI-backed -# model access. Additional LangChain chat models can be wired in by extending -# the model factory. +# configuration supports the Gemini API, Vertex AI, and Anthropic. # ============================================================================= -# Option 1: Direct Anthropic API. -LLM_API_KEY= -# Backwards-compatible name for Anthropic direct API deployments. -ANTHROPIC_API_KEY= +# Default: Gemini on Vertex AI using Application Default Credentials. +LLM_BACKEND=vertex-ai +GOOGLE_CLOUD_PROJECT=your-gcp-project-id +GOOGLE_CLOUD_LOCATION=global -# Option 2: Google Vertex AI. -VERTEX_PROJECT_ID=your-gcp-project-id -VERTEX_REGION=us-east5 -# Backwards-compatible names for existing deployments. -ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id -ANTHROPIC_VERTEX_REGION=us-east5 +# Alternative: Gemini API. +# LLM_BACKEND=google-genai +# GOOGLE_API_KEY= + +# Alternative: Anthropic API. +# LLM_BACKEND=anthropic +# ANTHROPIC_API_KEY= # Model for orchestrator (PRD, spec, epic planning) # Examples: gemini-3.5-flash, gemini-2.5-pro, claude-opus-4-5@20251101 -LLM_MODEL=claude-sonnet-4-5@20250929 +LLM_MODEL=gemini-3.5-flash # Model for container tasks (code implementation) # Can use a different model than orchestrator (e.g., for cost/rate limit reasons) diff --git a/containers/README.md b/containers/README.md index c6a97f9e..c5935eda 100644 --- a/containers/README.md +++ b/containers/README.md @@ -104,11 +104,13 @@ Passed automatically by the orchestrator: | Variable | Description | |----------|-------------| -| `LLM_API_KEY` | Direct Anthropic API key | -| `VERTEX_PROJECT_ID` | GCP project for Vertex AI | -| `VERTEX_REGION` | Vertex AI region | +| `LLM_BACKEND` | `vertex-ai` (default), `google-genai`, or `anthropic` | +| `GOOGLE_API_KEY` | Gemini API key for `google-genai` | +| `GOOGLE_CLOUD_PROJECT` | GCP project for `vertex-ai` | +| `GOOGLE_CLOUD_LOCATION` | Vertex AI location | +| `ANTHROPIC_API_KEY` | API key for `anthropic` | | `ANTHROPIC_*` | Backwards-compatible aliases for existing deployments | -| `LLM_MODEL` | Model to use (default: `claude-sonnet-4-5@20250929`; examples: `gemini-3.5-flash`, `gemini-2.5-pro`, `claude-opus-4-5@20251101`) | +| `LLM_MODEL` | Model to use (default: `gemini-3.5-flash`) | | `FORGE_SYSTEM_PROMPT_TEMPLATE` | System prompt template (interpolated by entrypoint) | | `GOOGLE_APPLICATION_CREDENTIALS` | Path to mounted gcloud credentials | | `GIT_USER_NAME` | Git author name for commits (default: `Forge`) | @@ -137,10 +139,10 @@ Example: `forge-AISOS-189-installer-12345` ## Task Execution -The entrypoint runs a Deep Agent with `LocalShellBackend`. Built-in model -factory support covers direct Anthropic API credentials and Vertex AI-backed -models. Because the agent receives a LangChain chat model instance, additional -providers can be added by extending the model factory. +The entrypoint runs a Deep Agent with `LocalShellBackend`. The built-in model +factory supports the Gemini API, Vertex AI, and the Anthropic API. Because the +agent receives a LangChain chat model instance, additional providers can be +added by extending the model factory. The agent: 1. Reads and understands the codebase diff --git a/containers/entrypoint.py b/containers/entrypoint.py index 7e1180d6..80189dbf 100644 --- a/containers/entrypoint.py +++ b/containers/entrypoint.py @@ -31,6 +31,29 @@ ) logger = logging.getLogger(__name__) + +def resolve_llm_backend() -> str: + """Resolve explicit or legacy standalone-container backend configuration.""" + explicit_backend = os.environ.get("LLM_BACKEND") + if explicit_backend: + return explicit_backend + if os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("LLM_API_KEY"): + return "anthropic" + if os.environ.get("GOOGLE_API_KEY"): + return "google-genai" + return "vertex-ai" + + +def resolve_llm_model(backend_name: str) -> str: + """Resolve model configuration with a backend-compatible default.""" + configured_model = os.environ.get("LLM_MODEL") or os.environ.get("CLAUDE_MODEL") + if configured_model: + return configured_model + if backend_name == "anthropic": + return "claude-sonnet-4-5-20250929" + return "gemini-3.5-flash" + + # Enable LangChain debug/verbose mode if requested if os.environ.get("LANGCHAIN_VERBOSE", "").lower() in ("true", "1", "yes"): try: @@ -227,12 +250,14 @@ def git_commit(workspace: Path, message: str) -> bool: return False new_files = [ - f for f in ls_result.stdout.split(b"\0") + f + for f in ls_result.stdout.split(b"\0") if f and not f.startswith(b".forge/") and f != b".forge" ] if new_files: result = subprocess.run( - ["git", "add", "--"] + [f.decode("utf-8", errors="surrogateescape") for f in new_files], + ["git", "add", "--"] + + [f.decode("utf-8", errors="surrogateescape") for f in new_files], cwd=workspace, capture_output=True, text=True, @@ -370,10 +395,8 @@ async def run_agent_task( previous_task_keys: List of previously implemented task keys for handoff context. trace_context: Workflow fields forwarded to Langfuse only. """ - # Support both generic (LLM_MODEL) and legacy (CLAUDE_MODEL) env var names. - model_name = os.environ.get("LLM_MODEL") or os.environ.get( - "CLAUDE_MODEL", "claude-sonnet-4-5@20250929" - ) + backend_name = resolve_llm_backend() + model_name = resolve_llm_model(backend_name) logger.info(f"Implementing task: {task_summary}") logger.info(f"Model: {model_name}") @@ -381,18 +404,27 @@ async def run_agent_task( from deepagents import create_deep_agent from deepagents.backends import LocalShellBackend - # Check for API credentials. ANTHROPIC_* names are accepted for - # compatibility with existing deployments and LangChain integrations. - api_key = os.environ.get("LLM_API_KEY") or os.environ.get("ANTHROPIC_API_KEY") - vertex_project = os.environ.get("VERTEX_PROJECT_ID") or os.environ.get( - "ANTHROPIC_VERTEX_PROJECT_ID" + google_api_key = os.environ.get("GOOGLE_API_KEY") + anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("LLM_API_KEY") + vertex_project = ( + os.environ.get("GOOGLE_CLOUD_PROJECT") + or os.environ.get("VERTEX_PROJECT_ID") + or os.environ.get("ANTHROPIC_VERTEX_PROJECT_ID") ) - vertex_region = os.environ.get("VERTEX_REGION") or os.environ.get( - "ANTHROPIC_VERTEX_REGION", "us-east5" + vertex_region = ( + os.environ.get("GOOGLE_CLOUD_LOCATION") + or os.environ.get("VERTEX_REGION") + or os.environ.get("ANTHROPIC_VERTEX_REGION", "global") ) - if not api_key and not vertex_project: - logger.error("No model backend credentials found (LLM_API_KEY or VERTEX_PROJECT_ID)") + if backend_name == "google-genai" and not google_api_key: + logger.error("GOOGLE_API_KEY is required for the google-genai backend") + return False + if backend_name == "vertex-ai" and not vertex_project: + logger.error("GOOGLE_CLOUD_PROJECT is required for the vertex-ai backend") + return False + if backend_name == "anthropic" and not anthropic_api_key: + logger.error("ANTHROPIC_API_KEY is required for the anthropic backend") return False # Create the agent with local shell backend (enables git commands) @@ -421,7 +453,7 @@ async def run_agent_task( # Get max tokens from env (default 16384) max_tokens = int(os.environ.get("LLM_MAX_TOKENS", "16384")) - if vertex_project: + if backend_name == "vertex-ai": if is_gemini: from langchain_google_genai import ChatGoogleGenerativeAI @@ -447,9 +479,22 @@ async def run_agent_task( location=vertex_region, max_tokens=max_tokens, ) - else: + elif backend_name == "google-genai": + if not is_gemini: + logger.error(f"Model '{model_name}' is not supported by google-genai") + return False + + from langchain_google_genai import ChatGoogleGenerativeAI + + logger.info(f"Using Gemini API model: {model_name}, max_output_tokens={max_tokens}") + model = ChatGoogleGenerativeAI( + model=model_name, + api_key=google_api_key, + max_output_tokens=max_tokens, + ) + elif backend_name == "anthropic": if is_gemini: - logger.error(f"Gemini model '{model_name}' requires Vertex AI credentials") + logger.error(f"Model '{model_name}' is not supported by anthropic") return False from langchain_anthropic import ChatAnthropic @@ -457,9 +502,12 @@ async def run_agent_task( logger.info(f"Using direct Anthropic model: {model_name}, max_tokens={max_tokens}") model = ChatAnthropic( model=model_name, - api_key=api_key, + api_key=anthropic_api_key, max_tokens=max_tokens, ) + else: + logger.error(f"Unsupported LLM_BACKEND: {backend_name}") + return False # Load Context7 MCP tools for library documentation mcp_tools = await load_context7_tools() diff --git a/docs/dev/setup.md b/docs/dev/setup.md index 8c44121b..b3793446 100644 --- a/docs/dev/setup.md +++ b/docs/dev/setup.md @@ -33,9 +33,10 @@ JIRA_API_TOKEN=your-jira-api-token GITHUB_TOKEN=github_pat_your_token -LLM_API_KEY=your-anthropic-api-key # or Vertex AI — see developer guide - -LLM_MODEL=claude-sonnet-4-5@20250929 +LLM_BACKEND=vertex-ai +GOOGLE_CLOUD_PROJECT=your-gcp-project +GOOGLE_CLOUD_LOCATION=global +LLM_MODEL=gemini-3.5-flash REDIS_URL=redis://localhost:6380/0 ``` diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 800fe802..c6026604 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -68,17 +68,11 @@ JIRA_API_TOKEN=your-jira-api-token # GitHub GITHUB_TOKEN=github_pat_your_token -# LLM — choose one backend - -# Option A: Direct Anthropic API -LLM_API_KEY=your-anthropic-api-key - -# Option B: Google Vertex AI -VERTEX_PROJECT_ID=your-gcp-project -VERTEX_REGION=us-east5 - -# Which model to use -LLM_MODEL=claude-sonnet-4-5@20250929 +# Default LLM backend +LLM_BACKEND=vertex-ai +GOOGLE_CLOUD_PROJECT=your-gcp-project +GOOGLE_CLOUD_LOCATION=global +LLM_MODEL=gemini-3.5-flash ``` --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 40bed257..c8b32915 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -36,12 +36,11 @@ JIRA_API_TOKEN=your-jira-api-token # GitHub GITHUB_TOKEN=github_pat_your_token -# LLM backend — choose one -LLM_API_KEY=your-anthropic-api-key # Direct Anthropic API -# VERTEX_PROJECT_ID=my-proj # OR Vertex AI -# VERTEX_REGION=us-east5 - -LLM_MODEL=claude-sonnet-4-5@20250929 +# Default LLM backend +LLM_BACKEND=vertex-ai +GOOGLE_CLOUD_PROJECT=your-gcp-project +GOOGLE_CLOUD_LOCATION=global +LLM_MODEL=gemini-3.5-flash ``` ## 3. Build the Container Image diff --git a/docs/reference/config.md b/docs/reference/config.md index 8305c364..47fab038 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -22,27 +22,34 @@ All configuration is via environment variables in `.env`. See `.env.example` in ### LLM -Choose one backend. Forge passes LangChain chat model instances into Deep Agents; the -built-in factory supports direct Anthropic API credentials and Vertex AI-backed -models, and can be extended for other LangChain chat model providers. +Choose one backend. Gemini 3.5 Flash through Vertex AI is the default. -=== "Direct Anthropic API" +=== "Vertex AI (default)" ```bash - LLM_API_KEY=your-anthropic-api-key - LLM_MODEL=claude-sonnet-4-5@20250929 + LLM_BACKEND=vertex-ai + GOOGLE_CLOUD_PROJECT=your-gcp-project + GOOGLE_CLOUD_LOCATION=global + LLM_MODEL=gemini-3.5-flash ``` -=== "Google Vertex AI" +=== "Gemini API" ```bash - VERTEX_PROJECT_ID=your-gcp-project - VERTEX_REGION=us-east5 + LLM_BACKEND=google-genai + GOOGLE_API_KEY=your-google-api-key LLM_MODEL=gemini-3.5-flash ``` -`ANTHROPIC_API_KEY`, `ANTHROPIC_VERTEX_PROJECT_ID`, and -`ANTHROPIC_VERTEX_REGION` remain supported as backwards-compatible aliases. +=== "Anthropic API" + + ```bash + LLM_BACKEND=anthropic + ANTHROPIC_API_KEY=your-anthropic-api-key + LLM_MODEL=claude-sonnet-4-6 + ``` + +Legacy `LLM_API_KEY`, `VERTEX_PROJECT_ID`, and `ANTHROPIC_VERTEX_*` aliases remain supported. ### Redis diff --git a/src/forge/cli.py b/src/forge/cli.py index 4691045a..e8bdde1f 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -703,10 +703,21 @@ async def cmd_health(_args: argparse.Namespace) -> int: print("[SKIP] Jira: API token not configured") # Check model backend - if settings.use_vertex_ai: - print(f"[OK] Using Vertex AI: {settings.resolved_vertex_project_id}") - elif settings.direct_llm_api_key.get_secret_value(): - print("[OK] Using direct model provider API") + if settings.llm_backend == "vertex-ai": + if settings.resolved_vertex_project_id: + print(f"[OK] Using Vertex AI: {settings.resolved_vertex_project_id}") + else: + print("[WARN] Vertex AI selected but GOOGLE_CLOUD_PROJECT is not configured") + elif settings.llm_backend == "google-genai": + if settings.google_api_key.get_secret_value(): + print("[OK] Using Gemini API") + else: + print("[WARN] Gemini API selected but GOOGLE_API_KEY is not configured") + elif settings.llm_backend == "anthropic": + if settings.direct_llm_api_key.get_secret_value(): + print("[OK] Using Anthropic API") + else: + print("[WARN] Anthropic selected but ANTHROPIC_API_KEY is not configured") else: print("[WARN] No LLM backend configured") diff --git a/src/forge/config.py b/src/forge/config.py index 32733ef3..22ca7f37 100644 --- a/src/forge/config.py +++ b/src/forge/config.py @@ -4,7 +4,7 @@ from functools import cached_property, lru_cache from typing import TYPE_CHECKING, Literal -from pydantic import Field, SecretStr +from pydantic import Field, SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict if TYPE_CHECKING: @@ -147,12 +147,28 @@ def known_repos(self) -> list[str]: return [] return [r.strip() for r in self.github_known_repos.split(",") if r.strip()] - # LLM provider configuration. - # Generic names are preferred for new deployments. ANTHROPIC_* names remain - # supported for compatibility with existing environments. + # Model backend configuration. Provider-specific credentials stay at the + # adapter boundary; the rest of Forge consumes the resolved backend/model. + llm_backend: Literal["google-genai", "vertex-ai", "anthropic"] | None = Field( + default=None, + description="Model backend: vertex-ai (default), google-genai, or anthropic", + ) + google_api_key: SecretStr = Field( + default=SecretStr(""), + description="Google Gemini API key for the google-genai backend", + ) + google_cloud_project: str = Field( + default="", + description="Google Cloud project for the vertex-ai backend", + ) + google_cloud_location: str = Field( + default="", + description="Google Cloud location for the vertex-ai backend", + ) + # Compatibility aliases retained for existing deployments. llm_api_key: SecretStr = Field( default=SecretStr(""), - description="Direct Anthropic API key, when not using Vertex AI", + description="Deprecated alias for anthropic_api_key", ) vertex_project_id: str = Field( default="", @@ -164,18 +180,18 @@ def known_repos(self) -> list[str]: ) anthropic_api_key: SecretStr = Field( default=SecretStr(""), - description="Deprecated alias for llm_api_key", + description="Anthropic API key for the anthropic backend", ) anthropic_vertex_project_id: str = Field( default="", description="Deprecated alias for vertex_project_id", ) anthropic_vertex_region: str = Field( - default="us-east5", + default="", description="Deprecated alias for vertex_region", ) llm_model: str = Field( - default="claude-sonnet-4-5@20250929", + default="gemini-3.5-flash", description="Model for orchestrator agents", ) container_llm_model: str = Field( @@ -187,6 +203,26 @@ def known_repos(self) -> list[str]: description="Maximum output tokens for LLM responses (default 16384)", ) + @model_validator(mode="after") + def resolve_legacy_llm_defaults(self) -> "Settings": + """Preserve existing deployments while defaulting new ones to Gemini.""" + if self.llm_backend is None: + # Preserve the historically direct-Anthropic behavior first, then + # prefer the Gemini API over Vertex when multiple legacy credential + # types are present. Explicit LLM_BACKEND always overrides inference. + if self.direct_llm_api_key.get_secret_value(): + self.llm_backend = "anthropic" + elif self.google_api_key.get_secret_value(): + self.llm_backend = "google-genai" + elif self.resolved_vertex_project_id: + self.llm_backend = "vertex-ai" + else: + self.llm_backend = "vertex-ai" + + if self.llm_backend == "anthropic" and "llm_model" not in self.model_fields_set: + self.llm_model = "claude-sonnet-4-5-20250929" + return self + @property def container_model(self) -> str: """Get model for container execution, falling back to default model.""" @@ -194,20 +230,27 @@ def container_model(self) -> str: @property def direct_llm_api_key(self) -> SecretStr: - """Get the configured direct model provider API key.""" - if self.llm_api_key.get_secret_value(): - return self.llm_api_key - return self.anthropic_api_key + """Get the Anthropic key, including the deprecated generic alias.""" + if self.anthropic_api_key.get_secret_value(): + return self.anthropic_api_key + return self.llm_api_key @property def resolved_vertex_project_id(self) -> str: """Get the configured Vertex AI project ID.""" - return self.vertex_project_id or self.anthropic_vertex_project_id + return ( + self.google_cloud_project or self.vertex_project_id or self.anthropic_vertex_project_id + ) @property def resolved_vertex_region(self) -> str: """Get the configured Vertex AI region.""" - return self.vertex_region or self.anthropic_vertex_region + return ( + self.google_cloud_location + or self.vertex_region + or self.anthropic_vertex_region + or "global" + ) # Backwards compatibility aliases @property @@ -423,10 +466,8 @@ def trace_metadata_fields(self) -> list["TracingField"]: @property def use_vertex_ai(self) -> bool: - """Check if using Vertex AI instead of a direct model provider API.""" - return bool( - self.resolved_vertex_project_id and not self.direct_llm_api_key.get_secret_value() - ) + """Compatibility helper for code that needs Vertex-specific setup.""" + return self.llm_backend == "vertex-ai" @lru_cache diff --git a/src/forge/integrations/agents/agent.py b/src/forge/integrations/agents/agent.py index 9c783f3e..f1899475 100644 --- a/src/forge/integrations/agents/agent.py +++ b/src/forge/integrations/agents/agent.py @@ -36,15 +36,22 @@ from forge.prompts import load_prompt, set_default_version from forge.skills.resolver import resolve_skill_paths -# Optional Vertex AI support +# Optional Google model support try: from langchain_google_genai import ChatGoogleGenerativeAI - from langchain_google_vertexai.model_garden import ChatAnthropicVertex - HAS_VERTEX = True + HAS_GOOGLE_GENAI = True except ImportError: ChatGoogleGenerativeAI = None # type: ignore[misc, assignment] - HAS_VERTEX = False + HAS_GOOGLE_GENAI = False + +try: + from langchain_google_vertexai.model_garden import ChatAnthropicVertex + + HAS_ANTHROPIC_VERTEX = True +except ImportError: + ChatAnthropicVertex = None # type: ignore[misc, assignment] + HAS_ANTHROPIC_VERTEX = False # Project root directory PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent @@ -125,13 +132,16 @@ def __init__(self, settings: Settings | None = None): set_default_version(self.settings.prompt_version) def _ensure_api_key(self) -> None: - """Ensure direct provider API key aliases are available.""" - if self.settings.use_vertex_ai: + """Expose provider-native credentials expected by LangChain.""" + if self.settings.llm_backend == "vertex-ai": logger.info("Using Vertex AI backend") - elif not os.environ.get("LLM_API_KEY") and not os.environ.get("ANTHROPIC_API_KEY"): + elif self.settings.llm_backend == "google-genai": + api_key = self.settings.google_api_key.get_secret_value() + if api_key and not os.environ.get("GOOGLE_API_KEY"): + os.environ["GOOGLE_API_KEY"] = api_key + elif not os.environ.get("ANTHROPIC_API_KEY"): api_key = self.settings.direct_llm_api_key.get_secret_value() if api_key: - os.environ["LLM_API_KEY"] = api_key os.environ["ANTHROPIC_API_KEY"] = api_key def _create_model(self, model_name: str | None = None) -> Any: @@ -144,16 +154,30 @@ def _create_model(self, model_name: str | None = None) -> Any: A LangChain chat model instance for Deep Agents. """ model = model_name or self.settings.llm_model - provider = Settings.detect_model_provider(model) - - if self.settings.use_vertex_ai: - if not HAS_VERTEX: - raise ImportError( - "langchain-google-vertexai is required for Vertex AI. " - "Install with: pip install langchain-google-vertexai" - ) + is_gemini = Settings.detect_model_provider(model) == "google" + backend = self.settings.llm_backend + + if backend == "google-genai": + if not is_gemini: + raise ValueError(f"Model '{model}' is not supported by google-genai") + if not HAS_GOOGLE_GENAI: + raise ImportError("langchain-google-genai is required for google-genai") + api_key = self.settings.google_api_key.get_secret_value() + if not api_key: + raise ValueError("GOOGLE_API_KEY is required for google-genai") + return ChatGoogleGenerativeAI( + model=model, + api_key=api_key, + max_output_tokens=self.settings.llm_max_tokens, + ) - if provider == "google": + if backend == "vertex-ai": + project = self.settings.resolved_vertex_project_id + if not project: + raise ValueError("GOOGLE_CLOUD_PROJECT is required for vertex-ai") + if is_gemini: + if not HAS_GOOGLE_GENAI: + raise ImportError("langchain-google-genai is required for Vertex AI Gemini") logger.info( f"Creating Vertex AI Gemini model: {model} " f"in {self.settings.resolved_vertex_region}, " @@ -167,6 +191,10 @@ def _create_model(self, model_name: str | None = None) -> Any: max_output_tokens=self.settings.llm_max_tokens, ) else: + if not HAS_ANTHROPIC_VERTEX: + raise ImportError( + "langchain-google-vertexai is required for Vertex AI Anthropic models" + ) logger.info( f"Creating Vertex AI Anthropic model: {model} " f"in {self.settings.resolved_vertex_region}, " @@ -178,22 +206,24 @@ def _create_model(self, model_name: str | None = None) -> Any: location=self.settings.resolved_vertex_region, max_tokens=self.settings.llm_max_tokens, ) - else: - if provider == "google": - raise ValueError( - f"Gemini model '{model}' requires Vertex AI. " - "Set VERTEX_PROJECT_ID or ANTHROPIC_VERTEX_PROJECT_ID." - ) + if backend == "anthropic": + if is_gemini: + raise ValueError(f"Model '{model}' is not supported by anthropic") + api_key = self.settings.direct_llm_api_key.get_secret_value() + if not api_key: + raise ValueError("ANTHROPIC_API_KEY is required for anthropic") logger.info( f"Creating direct Anthropic model: {model}, " f"max_tokens={self.settings.llm_max_tokens}" ) return ChatAnthropic( model=model, - api_key=self.settings.direct_llm_api_key.get_secret_value(), + api_key=api_key, max_tokens=self.settings.llm_max_tokens, ) + raise ValueError(f"Unsupported LLM backend: {backend}") + def _get_skill_paths(self, ticket_key: str | None = None) -> list[str]: """Return ordered skill source paths for Deep Agents. diff --git a/src/forge/sandbox/runner.py b/src/forge/sandbox/runner.py index e3a2f327..c43da500 100644 --- a/src/forge/sandbox/runner.py +++ b/src/forge/sandbox/runner.py @@ -119,17 +119,21 @@ def _build_env_vars( """ env = {} - # Pass direct model provider credentials. Keep ANTHROPIC_API_KEY for - # LangChain's Anthropic integration and older container images. - direct_api_key = self.settings.direct_llm_api_key.get_secret_value() - if direct_api_key: - env["LLM_API_KEY"] = direct_api_key - env["ANTHROPIC_API_KEY"] = direct_api_key + env["LLM_BACKEND"] = self.settings.llm_backend + + if self.settings.llm_backend == "google-genai": + google_api_key = self.settings.google_api_key.get_secret_value() + if google_api_key: + env["GOOGLE_API_KEY"] = google_api_key + elif self.settings.llm_backend == "anthropic": + anthropic_api_key = self.settings.direct_llm_api_key.get_secret_value() + if anthropic_api_key: + env["ANTHROPIC_API_KEY"] = anthropic_api_key # Pass Vertex AI credentials - if self.settings.use_vertex_ai: - env["VERTEX_PROJECT_ID"] = self.settings.resolved_vertex_project_id - env["VERTEX_REGION"] = self.settings.resolved_vertex_region + if self.settings.llm_backend == "vertex-ai": + env["GOOGLE_CLOUD_PROJECT"] = self.settings.resolved_vertex_project_id + env["GOOGLE_CLOUD_LOCATION"] = self.settings.resolved_vertex_region # Backwards-compatible names consumed by older container entrypoints. env["ANTHROPIC_VERTEX_PROJECT_ID"] = self.settings.resolved_vertex_project_id env["ANTHROPIC_VERTEX_REGION"] = self.settings.resolved_vertex_region diff --git a/tests/unit/containers/test_entrypoint_llm_config.py b/tests/unit/containers/test_entrypoint_llm_config.py new file mode 100644 index 00000000..11b0ef45 --- /dev/null +++ b/tests/unit/containers/test_entrypoint_llm_config.py @@ -0,0 +1,59 @@ +"""Tests for standalone container model configuration compatibility.""" + +import importlib.util +from pathlib import Path + +import pytest + + +@pytest.fixture(scope="module") +def entrypoint_module(): + path = Path(__file__).parents[3] / "containers" / "entrypoint.py" + spec = importlib.util.spec_from_file_location("forge_container_entrypoint", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(autouse=True) +def clear_model_env(monkeypatch): + for name in ( + "LLM_BACKEND", + "LLM_MODEL", + "CLAUDE_MODEL", + "GOOGLE_API_KEY", + "ANTHROPIC_API_KEY", + "LLM_API_KEY", + ): + monkeypatch.delenv(name, raising=False) + + +def test_standalone_container_defaults_to_vertex_gemini(entrypoint_module): + backend = entrypoint_module.resolve_llm_backend() + + assert backend == "vertex-ai" + assert entrypoint_module.resolve_llm_model(backend) == "gemini-3.5-flash" + + +def test_legacy_anthropic_credentials_select_compatible_defaults(entrypoint_module, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "key") + backend = entrypoint_module.resolve_llm_backend() + + assert backend == "anthropic" + assert entrypoint_module.resolve_llm_model(backend) == "claude-sonnet-4-5-20250929" + + +def test_google_api_key_selects_gemini_api(entrypoint_module, monkeypatch): + monkeypatch.setenv("GOOGLE_API_KEY", "key") + + assert entrypoint_module.resolve_llm_backend() == "google-genai" + + +def test_explicit_backend_and_model_take_precedence(entrypoint_module, monkeypatch): + monkeypatch.setenv("LLM_BACKEND", "vertex-ai") + monkeypatch.setenv("LLM_MODEL", "gemini-custom") + monkeypatch.setenv("ANTHROPIC_API_KEY", "legacy-key") + + assert entrypoint_module.resolve_llm_backend() == "vertex-ai" + assert entrypoint_module.resolve_llm_model("vertex-ai") == "gemini-custom" diff --git a/tests/unit/integrations/agents/test_agent.py b/tests/unit/integrations/agents/test_agent.py index f179eb91..15234821 100644 --- a/tests/unit/integrations/agents/test_agent.py +++ b/tests/unit/integrations/agents/test_agent.py @@ -7,6 +7,68 @@ from forge.integrations.agents.agent import ForgeAgent +def _model_agent(backend: str, model: str) -> ForgeAgent: + agent = ForgeAgent.__new__(ForgeAgent) + agent.settings = MagicMock( + llm_backend=backend, + llm_model=model, + llm_max_tokens=16384, + resolved_vertex_project_id="project", + resolved_vertex_region="global", + ) + agent.settings.google_api_key.get_secret_value.return_value = "google-key" + agent.settings.direct_llm_api_key.get_secret_value.return_value = "anthropic-key" + return agent + + +def test_create_model_uses_google_genai_backend(): + agent = _model_agent("google-genai", "gemini-3.5-flash") + + with patch("forge.integrations.agents.agent.ChatGoogleGenerativeAI") as model_class: + agent._create_model() + + model_class.assert_called_once_with( + model="gemini-3.5-flash", + api_key="google-key", + max_output_tokens=16384, + ) + + +def test_create_model_uses_vertex_backend_for_gemini(): + agent = _model_agent("vertex-ai", "gemini-3.5-flash") + + with patch("forge.integrations.agents.agent.ChatGoogleGenerativeAI") as model_class: + agent._create_model() + + model_class.assert_called_once_with( + model="gemini-3.5-flash", + project="project", + location="global", + vertexai=True, + max_output_tokens=16384, + ) + + +def test_create_model_uses_anthropic_backend(): + agent = _model_agent("anthropic", "claude-sonnet-4-6") + + with patch("forge.integrations.agents.agent.ChatAnthropic") as model_class: + agent._create_model() + + model_class.assert_called_once_with( + model="claude-sonnet-4-6", + api_key="anthropic-key", + max_tokens=16384, + ) + + +def test_create_model_rejects_backend_model_mismatch(): + agent = _model_agent("anthropic", "gemini-3.5-flash") + + with pytest.raises(ValueError, match="not supported by anthropic"): + agent._create_model() + + @pytest.mark.asyncio async def test_answer_question(): """ForgeAgent can answer questions about artifacts.""" diff --git a/tests/unit/sandbox/test_runner_llm_backend.py b/tests/unit/sandbox/test_runner_llm_backend.py new file mode 100644 index 00000000..a84d0b77 --- /dev/null +++ b/tests/unit/sandbox/test_runner_llm_backend.py @@ -0,0 +1,52 @@ +"""Tests for passing resolved model backend configuration to containers.""" + +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import SecretStr + +from forge.sandbox.runner import ContainerConfig, ContainerRunner + + +def _settings(backend: str) -> MagicMock: + settings = MagicMock( + llm_backend=backend, + container_model="gemini-3.5-flash", + llm_max_tokens=16384, + git_user_name="Forge", + git_user_email="forge@example.com", + langfuse_enabled=False, + container_langchain_verbose=False, + log_level="INFO", + resolved_vertex_project_id="project", + resolved_vertex_region="global", + ) + settings.google_api_key = SecretStr("google-key") + settings.direct_llm_api_key = SecretStr("anthropic-key") + return settings + + +@pytest.mark.parametrize( + ("backend", "expected", "absent"), + [ + ("google-genai", {"GOOGLE_API_KEY": "google-key"}, "ANTHROPIC_API_KEY"), + ("anthropic", {"ANTHROPIC_API_KEY": "anthropic-key"}, "GOOGLE_API_KEY"), + ( + "vertex-ai", + {"GOOGLE_CLOUD_PROJECT": "project", "GOOGLE_CLOUD_LOCATION": "global"}, + "GOOGLE_API_KEY", + ), + ], +) +def test_build_env_vars_passes_only_selected_backend_credentials( + backend: str, expected: dict[str, str], absent: str +): + runner = ContainerRunner.__new__(ContainerRunner) + runner.settings = _settings(backend) + + with patch("forge.sandbox.runner.load_prompt", return_value="prompt"): + env = runner._build_env_vars(ContainerConfig()) + + assert env["LLM_BACKEND"] == backend + assert env.items() >= expected.items() + assert absent not in env diff --git a/tests/unit/test_config_prd.py b/tests/unit/test_config_prd.py index 690cf900..edd05386 100644 --- a/tests/unit/test_config_prd.py +++ b/tests/unit/test_config_prd.py @@ -10,6 +10,10 @@ def clear_prd_proposal_env(monkeypatch): monkeypatch.delenv("PRD_PROPOSALS_REPO", raising=False) monkeypatch.delenv("PRD_PROPOSALS_PATH", raising=False) monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.delenv("LLM_BACKEND", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False) + monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("VERTEX_PROJECT_ID", raising=False) monkeypatch.delenv("VERTEX_REGION", raising=False) @@ -57,7 +61,7 @@ def test_proposals_repo_can_be_set_as_global_fallback(self) -> None: class TestLlmConfig: - def test_default_model_is_direct_anthropic_compatible(self): + def test_default_backend_uses_vertex_gemini_flash(self): settings = make_settings( jira_base_url="https://test.atlassian.net", jira_api_token="test", @@ -65,45 +69,75 @@ def test_default_model_is_direct_anthropic_compatible(self): github_token="test", ) - assert settings.llm_model == "claude-sonnet-4-5@20250929" - assert Settings.detect_model_provider(settings.llm_model) == "anthropic" + assert settings.llm_backend == "vertex-ai" + assert settings.llm_model == "gemini-3.5-flash" + assert Settings.detect_model_provider(settings.llm_model) == "google" - def test_generic_direct_api_key_takes_precedence(self): + def test_google_api_key_is_provider_specific(self): settings = make_settings( jira_base_url="https://test.atlassian.net", jira_api_token="test", jira_user_email="test@example.com", github_token="test", - llm_api_key="generic-key", - anthropic_api_key="legacy-key", + google_api_key="google-key", ) - assert settings.direct_llm_api_key.get_secret_value() == "generic-key" + assert settings.google_api_key.get_secret_value() == "google-key" assert settings.use_vertex_ai is False - def test_legacy_direct_api_key_still_works(self): + def test_legacy_anthropic_key_alias_still_works(self): settings = make_settings( jira_base_url="https://test.atlassian.net", jira_api_token="test", jira_user_email="test@example.com", github_token="test", - anthropic_api_key="legacy-key", + llm_backend="anthropic", + llm_api_key="legacy-key", ) assert settings.direct_llm_api_key.get_secret_value() == "legacy-key" - def test_generic_vertex_settings_take_precedence(self): + def test_existing_anthropic_configuration_infers_legacy_backend(self): + settings = make_settings( + jira_base_url="https://test.atlassian.net", + jira_api_token="test", + jira_user_email="test@example.com", + github_token="test", + anthropic_api_key="legacy-key", + ) + + assert settings.llm_backend == "anthropic" + assert settings.llm_model == "claude-sonnet-4-5-20250929" + + def test_anthropic_key_wins_when_multiple_legacy_credentials_are_present(self): + settings = make_settings( + jira_base_url="https://test.atlassian.net", + jira_api_token="test", + jira_user_email="test@example.com", + github_token="test", + anthropic_api_key="anthropic-key", + google_api_key="google-key", + google_cloud_project="vertex-project", + ) + + assert settings.llm_backend == "anthropic" + assert settings.llm_model == "claude-sonnet-4-5-20250929" + + def test_google_cloud_settings_take_precedence_over_vertex_aliases(self): settings = make_settings( jira_base_url="https://test.atlassian.net", jira_api_token="test", jira_user_email="test@example.com", github_token="test", + llm_backend="vertex-ai", + google_cloud_project="google-project", + google_cloud_location="europe-west1", vertex_project_id="generic-project", - vertex_region="europe-west1", + vertex_region="us-central1", anthropic_vertex_project_id="legacy-project", anthropic_vertex_region="us-east5", ) - assert settings.resolved_vertex_project_id == "generic-project" + assert settings.resolved_vertex_project_id == "google-project" assert settings.resolved_vertex_region == "europe-west1" assert settings.use_vertex_ai is True