diff --git a/.env.example b/.env.example index 0782e2a4..94ce0582 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 Anthropic 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=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 1ecc0834..c6a97f9e 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 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: `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`) | @@ -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 29022265..4cc7cc8e 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") @@ -303,8 +304,10 @@ async def run_agent_task( guardrails: Repository guidelines. previous_task_keys: List of previously implemented task keys for handoff context. """ - # 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", "claude-sonnet-4-5@20250929" + ) logger.info(f"Implementing task: {task_summary}") logger.info(f"Model: {model_name}") @@ -312,14 +315,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) @@ -337,7 +344,7 @@ async def run_agent_task( workspace, task_key, task_summary, task_description, guardrails, previous_task_keys ) - # 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) @@ -345,26 +352,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: @@ -374,7 +383,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, @@ -442,9 +451,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: @@ -592,13 +599,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..8c44121b 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-anthropic-api-key # or Vertex AI — see developer guide -LLM_MODEL=claude-opus-4-5@20251101 +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 8259cdb3..efeb4e72 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -30,7 +30,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 --- @@ -69,15 +69,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 Anthropic API +LLM_API_KEY=your-anthropic-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=claude-sonnet-4-5@20250929 ``` --- @@ -506,7 +506,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..40bed257 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-anthropic-api-key # Direct Anthropic API +# VERTEX_PROJECT_ID=my-proj # OR Vertex AI +# VERTEX_REGION=us-east5 -LLM_MODEL=claude-opus-4-5@20251101 +LLM_MODEL=claude-sonnet-4-5@20250929 ``` ## 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 b03712b7..08dfe6c8 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 72f94b5d..0f5ca68f 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 Anthropic API" ```bash - ANTHROPIC_API_KEY=sk-ant-your-key - LLM_MODEL=claude-opus-4-5@20251101 + LLM_API_KEY=your-anthropic-api-key + LLM_MODEL=claude-sonnet-4-5@20250929 ``` === "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 fcd0f109..fd980c68 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -675,13 +675,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..32733ef3 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 Anthropic 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)", + 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 2b69f5a1..a4364030 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 5d81afa1..8a17e3fa 100644 --- a/src/forge/sandbox/runner.py +++ b/src/forge/sandbox/runner.py @@ -118,14 +118,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 144df2b8..669142fb 100644 --- a/src/forge/workflow/nodes/ci_evaluator.py +++ b/src/forge/workflow/nodes/ci_evaluator.py @@ -250,7 +250,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 7081ee3b..df264354 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" @@ -125,7 +125,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 61f2a461..fde0f958 100644 --- a/src/forge/workflow/nodes/prd_generation.py +++ b/src/forge/workflow/nodes/prd_generation.py @@ -167,7 +167,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" @@ -216,7 +216,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 396fe78f..81b91fb6 100644 --- a/src/forge/workflow/nodes/spec_generation.py +++ b/src/forge/workflow/nodes/spec_generation.py @@ -184,7 +184,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 26d6bbda..b9f84707 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 @@ -369,7 +369,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 f7b3fea2..be4b50fe 100644 --- a/src/forge/workspace/git_ops.py +++ b/src/forge/workspace/git_ops.py @@ -240,7 +240,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 13d5c78c..3fc27e48 100644 --- a/tests/unit/test_config_prd.py +++ b/tests/unit/test_config_prd.py @@ -1,11 +1,31 @@ """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("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): - settings = Settings( + settings = make_settings( jira_base_url="https://test.atlassian.net", jira_api_token="test", jira_user_email="test@example.com", @@ -15,7 +35,7 @@ def test_default_proposals_repo_is_empty(self): assert settings.prd_proposals_repo == "" def test_default_proposals_path(self): - settings = Settings( + settings = make_settings( jira_base_url="https://test.atlassian.net", jira_api_token="test", jira_user_email="test@example.com", @@ -25,7 +45,7 @@ def test_default_proposals_path(self): assert settings.prd_proposals_path == "" def test_proposals_repo_can_be_set_as_global_fallback(self): - settings = Settings( + settings = make_settings( jira_base_url="https://test.atlassian.net", jira_api_token="test", jira_user_email="test@example.com", @@ -34,3 +54,56 @@ def test_proposals_repo_can_be_set_as_global_fallback(self): prd_proposals_repo="org/proposals", ) assert settings.prd_proposals_repo == "org/proposals" + + +class TestLlmConfig: + def test_default_model_is_direct_anthropic_compatible(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 == "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( + 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