Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,20 @@ MODEL_DEFAULT={"connection":"vertex-prod","model":"gemini-3.5-flash"}
# Provider credential for an Anthropic connection.
# ANTHROPIC_API_KEY=

# OpenAI-compatible endpoints may use distinct keys. Each connection's
# api_key_env names the environment variable holding its secret; omit it for an
# unauthenticated endpoint.
# OPENROUTER_API_KEY=
# MODEL_CONNECTIONS={"openrouter":{"backend":"openai-compatible","base_url":"https://openrouter.ai/api/v1","api_key_env":"OPENROUTER_API_KEY","allowed_models":["your-model"],"capabilities":["tools"]}}
# MODEL_DEFAULT={"connection":"openrouter","model":"your-model"}

# Legacy single-model configuration. Use only when MODEL_CONNECTIONS and
# MODEL_DEFAULT are not set.
# LLM_BACKEND=vertex-ai
# LLM_MODEL=gemini-3.5-flash
# For a legacy single OpenAI-compatible endpoint:
# OPENAI_BASE_URL=http://model-gateway.internal/v1
# OPENAI_API_KEY=

# Model for container tasks (code implementation)
# Can use a different model than orchestrator (e.g., for cost/rate limit reasons)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Forge connects Jira, GitHub, and AI coding agents into one event-driven workflow

Forge is built on [Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) and passes agents a LangChain chat model instance.

The built-in model factory supports direct Anthropic API credentials and Google Vertex AI-backed models. Because the agent layer is model-object based, Forge can be extended to any LangChain-compatible chat model by adding it to the model factory.
The built-in model factory supports direct Anthropic, Google Gemini, Vertex AI, and OpenAI-compatible Chat Completions endpoints. Named compatible connections can use distinct base URLs and environment-backed credentials. Because the agent layer is model-object based, Forge can be extended to other LangChain-compatible chat models by adding them to the model factory.

## Where Forge Is Different

Expand Down
1 change: 1 addition & 0 deletions containers/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ RUN pip install --no-cache-dir \
deepagents \
anthropic \
langchain-anthropic \
langchain-openai \
langchain-google-genai \
langchain-google-vertexai \
langchain-mcp-adapters \
Expand Down
6 changes: 4 additions & 2 deletions containers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,13 @@ Passed automatically by the orchestrator:

| Variable | Description |
|----------|-------------|
| `LLM_BACKEND` | Required: `vertex-ai`, `google-genai`, or `anthropic` |
| `LLM_BACKEND` | Required: `vertex-ai`, `google-genai`, `anthropic`, or `openai-compatible` |
| `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` |
| `OPENAI_BASE_URL` | Required base URL for `openai-compatible` |
| `OPENAI_API_KEY` | API key for `openai-compatible`; Forge supplies a placeholder for unauthenticated endpoints |
| `LLM_MODEL` | Required model name (for example, `gemini-3.5-flash`) |
| `CONTAINER_LLM_MODEL` | Optional container model override; it must be compatible with `LLM_BACKEND` because containers do not support a separate backend |
| `CONTAINER_COMMAND_TIMEOUT` | Maximum execution time in seconds for individual commands: agent tasks, reviewer commands, and fallback tests (default: `600`, must not exceed `CONTAINER_TIMEOUT`) |
Expand Down Expand Up @@ -141,7 +143,7 @@ Example: `forge-AISOS-189-installer-12345`
## Task Execution

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
factory supports the Gemini API, Vertex AI, Anthropic, and OpenAI-compatible endpoints. Because the
agent receives a LangChain chat model instance, additional providers can be
added by extending the model factory.

Expand Down
18 changes: 15 additions & 3 deletions containers/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,9 +442,7 @@ def _create_llm_model(max_tokens_default: int = 16384):
else:
from langchain_google_vertexai.model_garden import ChatAnthropicVertex

logger.info(
f"Using Vertex AI Anthropic 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,
Expand Down Expand Up @@ -483,6 +481,20 @@ def _create_llm_model(max_tokens_default: int = 16384):
max_tokens=max_tokens,
**tuning,
)
elif backend_name == "openai-compatible":
base_url = os.environ.get("OPENAI_BASE_URL")
if not base_url:
raise RuntimeError("OPENAI_BASE_URL is required for the openai-compatible backend")
from langchain_openai import ChatOpenAI

logger.info("Using OpenAI-compatible model %s at %s", model_name, base_url)
model = ChatOpenAI(
model=model_name,
base_url=base_url,
api_key=os.environ.get("OPENAI_API_KEY") or "not-required",
max_tokens=max_tokens,
**tuning,
)
else:
raise RuntimeError(f"Unsupported LLM_BACKEND: {backend_name}")

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,4 @@ flowchart TD

**Podman Container**: Ephemeral rootless containers that execute implementation tasks. Each container receives the repo at `/workspace` (read-write), a task file at `/task.json` (read-only), and LLM credentials. Runs Deep Agents with MCP tool access. The orchestrator handles pushing and PR creation after the container exits.

**LLM Backends**: Claude and Gemini models called by both orchestrator nodes (planning, review) and container agents (code generation). Supports Anthropic direct API and Google Vertex AI, selected automatically based on configured credentials.
**LLM Backends**: Claude, Gemini, and OpenAI-compatible models called by both orchestrator nodes (planning, review) and container agents (code generation). Supports Anthropic direct API, Google APIs and Vertex AI, and configured OpenAI-compatible Chat Completions endpoints.
16 changes: 16 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ native credential environment variables.
MODEL_DEFAULT={"connection":"anthropic-prod","model":"claude-sonnet-4-6"}
```

=== "OpenAI-compatible endpoint"

```bash
GATEWAY_API_KEY=your-gateway-key
MODEL_CONNECTIONS={"gateway":{"backend":"openai-compatible","base_url":"https://gateway.example/v1","api_key_env":"GATEWAY_API_KEY","allowed_models":["your-model"],"capabilities":["tools"]}}
MODEL_DEFAULT={"connection":"gateway","model":"your-model"}
```

`api_key_env` contains an environment-variable name, never the credential.
Different connections can reference different keys. Omit `api_key_env` for
endpoints that do not authenticate. Compatible endpoints must implement the
OpenAI Chat Completions API and tool calling for agentic stages.

Forge validates the backend, credentials, model allowlist, capabilities, and
default target at startup.

Expand All @@ -67,6 +80,9 @@ default target at startup.
LLM_MODEL=gemini-3.5-flash
```

For one OpenAI-compatible endpoint, set `LLM_BACKEND=openai-compatible`,
`OPENAI_BASE_URL`, `LLM_MODEL`, and optionally `OPENAI_API_KEY`.

`LLM_BACKEND` and `LLM_MODEL` are required together when
`MODEL_CONNECTIONS` and `MODEL_DEFAULT` are not configured. Provider
credentials must use the provider-native variables shown above; legacy
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ dependencies = [
"anthropic[vertex]>=0.40.0",
"deepagents>=0.1.0",
"langchain-anthropic>=0.3.0",
"langchain-openai>=0.3.0",
"langchain-google-genai>=4.0.0",
"langchain-google-vertexai>=2.0.0",
"gitpython>=3.1.0",
"httpx>=0.27.0",
"pydantic>=2.9.0",
"pydantic-settings>=2.6.0",
"python-dotenv>=1.0.0",
"langfuse>=2.50.0",
"langchain-mcp-adapters>=0.2.2",
"prometheus-client>=0.21.0",
Expand Down
2 changes: 2 additions & 0 deletions src/forge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,8 @@ async def cmd_health(_args: argparse.Namespace) -> int:
print("[OK] Using Anthropic API")
else:
print("[WARN] Anthropic selected but ANTHROPIC_API_KEY is not configured")
elif settings.llm_backend == "openai-compatible":
print(f"[OK] Using OpenAI-compatible endpoint: {settings.openai_base_url}")
else:
print("[WARN] No LLM backend configured")

Expand Down
40 changes: 38 additions & 2 deletions src/forge/config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Configuration management using Pydantic settings."""

import logging
import os
from functools import cached_property, lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal

from dotenv import dotenv_values
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

Expand Down Expand Up @@ -154,8 +156,8 @@ def known_repos(self) -> list[str]:

# 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"] = Field(
description="Model backend: vertex-ai, google-genai, or anthropic",
llm_backend: Literal["google-genai", "vertex-ai", "anthropic", "openai-compatible"] = Field(
description="Model backend: vertex-ai, google-genai, anthropic, or openai-compatible",
)
google_api_key: SecretStr = Field(
default=SecretStr(""),
Expand All @@ -173,6 +175,14 @@ def known_repos(self) -> list[str]:
default=SecretStr(""),
description="Anthropic API key for the anthropic backend",
)
openai_api_key: SecretStr = Field(
default=SecretStr(""),
description="API key for a legacy single OpenAI-compatible connection",
)
openai_base_url: str = Field(
default="",
description="Base URL for a legacy single OpenAI-compatible connection",
)
llm_model: str = Field(
description="Model for orchestrator agents",
)
Expand Down Expand Up @@ -234,6 +244,8 @@ def derive_legacy_model_fields(cls, values: Any) -> Any:
if backend == "vertex-ai":
derived["google_cloud_project"] = connection.get("project") or ""
derived["google_cloud_location"] = connection.get("location") or "global"
elif backend == "openai-compatible":
derived["openai_base_url"] = connection.get("base_url") or ""
return derived

@property
Expand Down Expand Up @@ -305,6 +317,10 @@ def validate_llm_configuration(self) -> "Settings":
self._validate_model_policy(ModelPolicyResolver)
return self

if self.llm_backend == "openai-compatible":
self._validate_model_policy(ModelPolicyResolver)
return self

if not self.anthropic_api_key.get_secret_value():
raise ValueError("ANTHROPIC_API_KEY is required for anthropic")
incompatible = [m for m in models if self.detect_model_provider(m) != "anthropic"]
Expand All @@ -325,6 +341,24 @@ def _validate_model_policy(self, resolver_type: type) -> None:
raise ValueError("GOOGLE_API_KEY is required by a google-genai model connection")
if connection.backend == "anthropic" and not self.anthropic_api_key.get_secret_value():
raise ValueError("ANTHROPIC_API_KEY is required by an anthropic model connection")
if connection.backend == "openai-compatible" and connection.api_key_env:
self.resolve_openai_api_key(connection.api_key_env)

def resolve_openai_api_key(self, api_key_env: str | None) -> str:
"""Resolve a compatible endpoint credential without serializing it into policy."""
if not api_key_env:
return self.openai_api_key.get_secret_value()
value = os.environ.get(api_key_env)
if value is None:
env_file = self.model_config.get("env_file")
if isinstance(env_file, str):
dotenv_value = dotenv_values(env_file).get(api_key_env)
value = str(dotenv_value) if dotenv_value is not None else None
if value is None:
raise ValueError(
f"Environment variable '{api_key_env}' referenced by api_key_env is not set"
)
return value

@property
def effective_model_connections(self) -> dict[str, Any]:
Expand All @@ -342,6 +376,8 @@ def effective_model_connections(self) -> dict[str, Any]:
project=self.google_cloud_project,
location=self.google_cloud_location,
)
elif self.llm_backend == "openai-compatible":
connection.update(base_url=self.openai_base_url)
return {"default": connection}

@property
Expand Down
21 changes: 20 additions & 1 deletion src/forge/integrations/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from deepagents import create_deep_agent
from deepagents.backends.filesystem import FilesystemBackend
from langchain_anthropic import ChatAnthropic
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver

# Optional MCP support
Expand Down Expand Up @@ -156,7 +157,7 @@ def _ensure_api_key(self) -> None:
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"):
elif self.settings.llm_backend == "anthropic" and not os.environ.get("ANTHROPIC_API_KEY"):
api_key = self.settings.anthropic_api_key.get_secret_value()
if api_key:
os.environ["ANTHROPIC_API_KEY"] = api_key
Expand Down Expand Up @@ -261,6 +262,22 @@ def _create_model(
**({"temperature": temperature} if temperature is not None else {}),
)

if backend == "openai-compatible":
base_url = model_target.base_url if model_target else self.settings.openai_base_url
if not base_url:
raise ValueError("OPENAI_BASE_URL is required for openai-compatible")
api_key = self.settings.resolve_openai_api_key(
model_target.api_key_env if model_target else None
)
logger.info("Creating OpenAI-compatible model %s at %s", model, base_url)
return ChatOpenAI(
model=model,
base_url=base_url,
api_key=api_key or "not-required",
max_tokens=max_tokens,
**({"temperature": temperature} if temperature is not None else {}),
)

raise ValueError(f"Unsupported LLM backend: {backend}")

def _get_skill_paths(self, ticket_key: str | None = None) -> list[str]:
Expand Down Expand Up @@ -999,6 +1016,8 @@ def _get_setting_value(self, var_name: str) -> str:
"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(),
"OPENAI_API_KEY": lambda: self.settings.openai_api_key.get_secret_value(),
"OPENAI_BASE_URL": lambda: self.settings.openai_base_url,
}

if var_name in var_mapping:
Expand Down
20 changes: 19 additions & 1 deletion src/forge/models/model_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
credentials remain in :mod:`forge.config` and are resolved at the adapter boundary.
"""

import re
from typing import Any, Literal
from urllib.parse import urlparse

from pydantic import BaseModel, ConfigDict, Field, model_validator

Backend = Literal["google-genai", "vertex-ai", "anthropic"]
Backend = Literal["google-genai", "vertex-ai", "anthropic", "openai-compatible"]
MAX_MODEL_OUTPUT_TOKENS = 131_072


Expand All @@ -20,6 +22,8 @@ class ModelConnection(BaseModel):
backend: Backend
project: str | None = None
location: str | None = None
base_url: str | None = None
api_key_env: str | None = None
allowed_models: list[str] = Field(default_factory=lambda: ["*"])
capabilities: set[str] = Field(default_factory=set)
allow_project_override: bool = True
Expand All @@ -28,6 +32,14 @@ class ModelConnection(BaseModel):
def validate_connection(self) -> "ModelConnection":
if self.backend == "vertex-ai" and not self.project:
raise ValueError("vertex-ai connections require project")
if self.backend == "openai-compatible":
parsed = urlparse(self.base_url or "")
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(
"openai-compatible connections require an absolute HTTP(S) base_url"
)
if self.api_key_env and not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", self.api_key_env):
raise ValueError("api_key_env must be a valid environment variable name")
return self


Expand All @@ -51,6 +63,8 @@ class ResolvedModelTarget(ModelTarget):
policy_source: Literal["project", "project_default", "global", "default"]
project: str | None = None
location: str | None = None
base_url: str | None = None
api_key_env: str | None = None

def trace_metadata(self) -> dict[str, Any]:
return {
Expand Down Expand Up @@ -193,6 +207,8 @@ def _model_matches_backend(model: str, backend: Backend) -> bool:
# Anthropic models. Direct-provider backends accept only their family.
if backend == "vertex-ai":
return True
if backend == "openai-compatible":
return True
return is_gemini == (backend == "google-genai")

def _validate_target(
Expand Down Expand Up @@ -269,6 +285,8 @@ def resolve(
backend=connection.backend,
project=connection.project,
location=connection.location,
base_url=connection.base_url,
api_key_env=connection.api_key_env,
policy_key=key,
policy_source=source,
)
Expand Down
10 changes: 9 additions & 1 deletion src/forge/sandbox/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ def _build_env_vars(
Returns:
Dict of environment variables.
"""
env = {}
env: dict[str, str] = {}

selected_backend = model_target.backend if model_target else self.settings.llm_backend
if not selected_backend:
Expand All @@ -255,6 +255,14 @@ def _build_env_vars(
anthropic_api_key = self.settings.anthropic_api_key.get_secret_value()
if anthropic_api_key:
env["ANTHROPIC_API_KEY"] = anthropic_api_key
elif selected_backend == "openai-compatible":
env["OPENAI_BASE_URL"] = (
model_target.base_url if model_target else self.settings.openai_base_url
) or ""
api_key = self.settings.resolve_openai_api_key(
model_target.api_key_env if model_target else None
)
env["OPENAI_API_KEY"] = api_key or "not-required"

# Pass Vertex AI credentials
if selected_backend == "vertex-ai":
Expand Down
Loading
Loading