diff --git a/.env.example b/.env.example index 0782e2a4..89d9ce04 100644 --- a/.env.example +++ b/.env.example @@ -66,19 +66,26 @@ 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 the Gemini API, Vertex AI, and Anthropic. # ============================================================================= -# Option 1: Direct Anthropic API (Claude models only) -ANTHROPIC_API_KEY= -# Option 2: Google Vertex AI (supports both Claude and Gemini) -ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id -ANTHROPIC_VERTEX_REGION=us-east5 +# Default: Gemini on Vertex AI using Application Default Credentials. +LLM_BACKEND=vertex-ai +GOOGLE_CLOUD_PROJECT=your-gcp-project-id +GOOGLE_CLOUD_LOCATION=global + +# 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) -# 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..c5935eda 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,13 @@ 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_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: `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`) | @@ -136,9 +139,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`. 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 aeb0aae6..80189dbf 100644 --- a/containers/entrypoint.py +++ b/containers/entrypoint.py @@ -31,10 +31,34 @@ ) 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: from langchain_core.globals import set_debug, set_verbose + set_verbose(True) set_debug(True) logger.info("LangChain verbose/debug mode enabled") @@ -226,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, @@ -369,8 +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 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") + 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}") @@ -378,14 +404,27 @@ 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") + 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("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 API credentials found (ANTHROPIC_API_KEY or ANTHROPIC_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) @@ -408,49 +447,67 @@ 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) max_tokens = int(os.environ.get("LLM_MAX_TOKENS", "16384")) - if vertex_project: + if backend_name == "vertex-ai": 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: + 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 - 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, + 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() @@ -513,9 +570,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 +730,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..b3793446 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,10 @@ 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_MODEL=claude-opus-4-5@20251101 +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 5cb296af..c6026604 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 --- @@ -68,17 +68,11 @@ JIRA_API_TOKEN=your-jira-api-token # GitHub GITHUB_TOKEN=github_pat_your_token -# LLM — choose one backend - -# Option A: Anthropic direct -ANTHROPIC_API_KEY=sk-ant-your-key - -# Option B: Google Vertex AI (supports Claude + Gemini) -ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project -ANTHROPIC_VERTEX_REGION=us-east5 - -# Which model to use -LLM_MODEL=claude-opus-4-5@20251101 +# Default LLM backend +LLM_BACKEND=vertex-ai +GOOGLE_CLOUD_PROJECT=your-gcp-project +GOOGLE_CLOUD_LOCATION=global +LLM_MODEL=gemini-3.5-flash ``` --- @@ -507,7 +501,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..c8b32915 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,11 @@ 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_MODEL=claude-opus-4-5@20251101 +# 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/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..47fab038 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -22,23 +22,35 @@ All configuration is via environment variables in `.env`. See `.env.example` in ### LLM -Choose one backend: +Choose one backend. Gemini 3.5 Flash through Vertex AI is the default. -=== "Anthropic Direct" +=== "Vertex AI (default)" ```bash - ANTHROPIC_API_KEY=sk-ant-your-key - LLM_MODEL=claude-opus-4-5@20251101 + 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 - ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project - ANTHROPIC_VERTEX_REGION=us-east5 - LLM_MODEL=claude-opus-4-5@20251101 + LLM_BACKEND=google-genai + GOOGLE_API_KEY=your-google-api-key + LLM_MODEL=gemini-3.5-flash ``` +=== "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 | 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..e8bdde1f 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -702,13 +702,24 @@ async def cmd_health(_args: argparse.Namespace) -> int: else: print("[SKIP] Jira: API token not configured") - # Check Anthropic/Vertex - 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") + # Check model backend + 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 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..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,27 +147,52 @@ 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 + # 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="Deprecated alias for anthropic_api_key", + ) + 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="Anthropic API key for the anthropic backend", ) - # 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)", + default="", + 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="", @@ -178,11 +203,55 @@ 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.""" return self.container_llm_model or self.llm_model + @property + def direct_llm_api_key(self) -> SecretStr: + """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.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.google_cloud_location + or self.vertex_region + or self.anthropic_vertex_region + or "global" + ) + # Backwards compatibility aliases @property def claude_model(self) -> str: @@ -194,12 +263,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 +291,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,10 +466,8 @@ def trace_metadata_fields(self) -> list["TracingField"]: @property def use_vertex_ai(self) -> bool: - """Check if using Vertex AI instead of direct Anthropic API.""" - return bool( - self.anthropic_vertex_project_id and not self.anthropic_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 2497bbda..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 (Claude and Gemini) +# 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,11 +132,15 @@ 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.""" - 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 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.anthropic_api_key.get_secret_value() + api_key = self.settings.direct_llm_api_key.get_secret_value() if api_key: os.environ["ANTHROPIC_API_KEY"] = api_key @@ -140,58 +151,79 @@ 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 - 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" - ) + model = model_name or self.settings.llm_model + 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": - # Gemini models via ChatGoogleGenerativeAI with Vertex AI backend + 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 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 + if not HAS_ANTHROPIC_VERTEX: + raise ImportError( + "langchain-google-vertexai is required for Vertex AI Anthropic models" + ) 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." - ) + 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 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=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. @@ -761,7 +793,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 +921,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..c43da500 100644 --- a/src/forge/sandbox/runner.py +++ b/src/forge/sandbox/runner.py @@ -119,14 +119,24 @@ 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() + 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["ANTHROPIC_VERTEX_PROJECT_ID"] = self.settings.anthropic_vertex_project_id - env["ANTHROPIC_VERTEX_REGION"] = self.settings.anthropic_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 # 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/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 6d18c5a2..edd05386 100644 --- a/tests/unit/test_config_prd.py +++ b/tests/unit/test_config_prd.py @@ -1,11 +1,35 @@ """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) + 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) + 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: + 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 +39,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 +49,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 +58,86 @@ 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_backend_uses_vertex_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_backend == "vertex-ai" + assert settings.llm_model == "gemini-3.5-flash" + assert Settings.detect_model_provider(settings.llm_model) == "google" + + 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", + google_api_key="google-key", + ) + + assert settings.google_api_key.get_secret_value() == "google-key" + assert settings.use_vertex_ai is False + + 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", + llm_backend="anthropic", + llm_api_key="legacy-key", + ) + + assert settings.direct_llm_api_key.get_secret_value() == "legacy-key" + + 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="us-central1", + anthropic_vertex_project_id="legacy-project", + anthropic_vertex_region="us-east5", + ) + + assert settings.resolved_vertex_project_id == "google-project" + assert settings.resolved_vertex_region == "europe-west1" + assert settings.use_vertex_ai is True