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 @@ -17,6 +17,16 @@ ANTHROPIC_API_KEY=sk-ant-api03-...
# SWE-AF auto-selects the open_code runtime and defaults to
# openrouter/deepseek/deepseek-v4-flash. Override the model with SWE_DEFAULT_MODEL.

# Infron (OpenAI-compatible gateway, standard <provider>/<model> ids)
# INFRON_API_KEY=sk-...
# Behaves exactly like the gateway key above: with ONLY an Infron key set,
# SWE-AF auto-selects open_code and defaults to
# infron/deepseek/deepseek-v4-flash. The model ids are unchanged, so moving a
# role over is a prefix swap:
# SWE_DEFAULT_MODEL=infron/moonshotai/kimi-k2.6
# The key above keeps precedence when both are set, so adding this one never
# reroutes an existing deployment on its own.

# OpenAI API-platform billing for OpenAI models and Codex api_key mode.
# OPENAI_API_KEY=sk-...

Expand Down
10 changes: 7 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,14 @@ ENV PATH="/root/.opencode/bin:${PATH}"
# Default HARNESS_MODEL inside the image so a fresh container with no
# env override has *some* value to interpolate. Railway / docker-compose
# overrides win because their env injects after the image's ENV.
#
# The provider block itself lives in docker/opencode.json (shared with
# go/Dockerfile) so the two images cannot drift apart. It declares the
# existing provider plus infron; a provider with no key set is simply never
# selected, so shipping both costs nothing at runtime.
ENV HARNESS_MODEL=openrouter/moonshotai/kimi-k2.6
RUN mkdir -p /root/.config/opencode && \
echo '{"$schema":"https://opencode.ai/config.json","model":"{env:HARNESS_MODEL}","small_model":"{env:HARNESS_MODEL}","provider":{"openrouter":{"options":{"apiKey":"{env:OPENROUTER_API_KEY}"}}}}' \
> /root/.config/opencode/opencode.json
RUN mkdir -p /root/.config/opencode
COPY docker/opencode.json /root/.config/opencode/opencode.json

# Git identity — env vars take highest precedence and are inherited by all
# subprocesses including Claude Code agent instances spawned by the SDK
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,14 @@ JSON

For OpenRouter with `open_code`, use model IDs in `openrouter/<provider>/<model>` format (for example `openrouter/minimax/minimax-m2.5`).

For Infron with `open_code`, set `INFRON_API_KEY` and use `infron/<provider>/<model>` (for example `infron/moonshotai/kimi-k2.6`). Infron is an OpenAI-compatible gateway serving the standard `<provider>/<model>` ids, so moving a role across is a prefix swap and nothing else changes:

```bash
SWE_DEFAULT_MODEL=infron/moonshotai/kimi-k2.6
```

With **only** an `INFRON_API_KEY` set (no `ANTHROPIC_API_KEY`, no other gateway key, no `SWE_DEFAULT_RUNTIME`), SWE-AF auto-selects the `open_code` runtime and defaults to `infron/deepseek/deepseek-v4-flash` — the same rule the existing gateway path already follows. A gateway key already configured keeps precedence, so adding an Infron key never reroutes an existing deployment on its own.

For Codex with ChatGPT subscription auth, install the Codex CLI on the host, run `codex login`, leave `OPENAI_API_KEY` unset for this process, and set `SWE_CODEX_AUTH_MODE=chatgpt` or `auto`. For OpenAI API-platform billing, set `SWE_CODEX_AUTH_MODE=api_key` and `OPENAI_API_KEY`.

> **Codex deployments using the Docker image must set `SWE_DEFAULT_MODEL=gpt-5.3-codex` on the environment** (or pass `models: {"default": "gpt-5.3-codex"}` in every build's `config`). The image bakes `HARNESS_MODEL=openrouter/moonshotai/kimi-k2.6` as an OpenCode fallback, and SWE-AF's model-resolution env cascade reads `HARNESS_MODEL` — so without `SWE_DEFAULT_MODEL` set, the Codex CLI receives an OpenRouter model id it can't handle and the Product Manager reasoner fails in ~13s. Setting `SWE_DEFAULT_MODEL` makes the cascade pin every role to the Codex model.
Expand Down
4 changes: 4 additions & 0 deletions agentfield-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ user_environment:
description: OpenRouter API key (DeepSeek/Qwen/Llama/… — 200+ models)
type: secret
scope: global
- name: INFRON_API_KEY
description: Infron API key (OpenAI-compatible gateway, standard <provider>/<model> ids)
type: secret
scope: global
required:
# SWE-AF's core loop clones a repo, pushes a branch, and opens a pull
# request — all of which need GitHub write access. `gh`/`git` read this
Expand Down
30 changes: 30 additions & 0 deletions docker/opencode.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"$schema": "https://opencode.ai/config.json",
"model": "{env:HARNESS_MODEL}",
"small_model": "{env:HARNESS_MODEL}",
"provider": {
"openrouter": {
"options": {
"apiKey": "{env:OPENROUTER_API_KEY}"
}
},
"infron": {
"npm": "@ai-sdk/openai-compatible",
"name": "Infron",
"options": {
"baseURL": "https://llm.onerouter.pro/v1",
"apiKey": "{env:INFRON_API_KEY}"
},
"models": {
"moonshotai/kimi-k2.6": { "name": "Kimi K2.6" },
"moonshotai/kimi-k2.7-code": { "name": "Kimi K2.7 Code" },
"moonshotai/kimi-k3": { "name": "Kimi K3" },
"deepseek/deepseek-v4-flash": { "name": "DeepSeek V4 Flash" },
"deepseek/deepseek-v4-pro": { "name": "DeepSeek V4 Pro" },
"minimax/minimax-m2.5": { "name": "MiniMax M2.5" },
"minimax/minimax-m3": { "name": "MiniMax M3" },
"z-ai/glm-5.2": { "name": "GLM-5.2" }
}
}
}
}
10 changes: 7 additions & 3 deletions go/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,14 @@ ENV PATH="/root/.opencode/bin:${PATH}"
# so it must honor the same env var the rest of the stack uses. Default the
# value inside the image so a fresh container has *some* value to interpolate;
# Railway / docker-compose overrides win because their env injects afterward.
#
# The provider block itself lives in docker/opencode.json (shared with the
# Python Dockerfile) so the two images cannot drift apart. It declares the
# existing provider plus infron; a provider with no key set is never selected,
# so shipping both costs nothing at runtime.
ENV HARNESS_MODEL=openrouter/moonshotai/kimi-k2.6
RUN mkdir -p /root/.config/opencode && \
echo '{"$schema":"https://opencode.ai/config.json","model":"{env:HARNESS_MODEL}","small_model":"{env:HARNESS_MODEL}","provider":{"openrouter":{"options":{"apiKey":"{env:OPENROUTER_API_KEY}"}}}}' \
> /root/.config/opencode/opencode.json
RUN mkdir -p /root/.config/opencode
COPY docker/opencode.json /root/.config/opencode/opencode.json

# Git identity — env vars take highest precedence and are inherited by all
# subprocesses (including the CLIs the harness spawns).
Expand Down
4 changes: 4 additions & 0 deletions go/agentfield-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ user_environment:
description: OpenRouter API key (DeepSeek/Qwen/Llama/… — 200+ models)
type: secret
scope: global
- name: INFRON_API_KEY
description: Infron API key (OpenAI-compatible gateway, standard <provider>/<model> ids)
type: secret
scope: global
required:
# The core loop clones a repo, pushes a branch, and opens a pull request —
# all of which need GitHub write access.
Expand Down
5 changes: 5 additions & 0 deletions go/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
var providerEnvKeys = []string{
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
"INFRON_API_KEY",
"SWE_DEFAULT_RUNTIME",
"SWE_DEFAULT_MODEL",
"AI_MODEL",
Expand Down Expand Up @@ -43,6 +44,8 @@ func TestDefaultRuntime(t *testing.T) {
{"openrouter only -> open_code", map[string]string{"OPENROUTER_API_KEY": "sk-or"}, "open_code"},
{"both keys -> claude_code", map[string]string{"ANTHROPIC_API_KEY": "sk-ant", "OPENROUTER_API_KEY": "sk-or"}, "claude_code"},
{"explicit runtime beats autoselect", map[string]string{"OPENROUTER_API_KEY": "sk-or", "SWE_DEFAULT_RUNTIME": "claude_code"}, "claude_code"},
{"infron only -> open_code", map[string]string{"INFRON_API_KEY": "sk-inf"}, "open_code"},
{"infron + anthropic -> claude_code", map[string]string{"ANTHROPIC_API_KEY": "sk-ant", "INFRON_API_KEY": "sk-inf"}, "claude_code"},
{"env open_code", map[string]string{"SWE_DEFAULT_RUNTIME": "open_code"}, "open_code"},
{"env codex", map[string]string{"SWE_DEFAULT_RUNTIME": "codex"}, "codex"},
{"invalid env -> claude_code", map[string]string{"SWE_DEFAULT_RUNTIME": "bogus_runtime"}, "claude_code"},
Expand Down Expand Up @@ -74,6 +77,8 @@ func TestDefaultPlanningModel(t *testing.T) {
{"claude env -> sonnet", map[string]string{"ANTHROPIC_API_KEY": "sk-ant"}, "sonnet"},
{"no provider env -> sonnet", nil, "sonnet"},
{"openrouter only -> deepseek", map[string]string{"OPENROUTER_API_KEY": "sk-or"}, openRouterAutoDefaultModel},
{"infron only -> infron deepseek", map[string]string{"INFRON_API_KEY": "sk-inf"}, infronAutoDefaultModel},
{"existing gateway key wins over infron", map[string]string{"OPENROUTER_API_KEY": "sk-or", "INFRON_API_KEY": "sk-inf"}, openRouterAutoDefaultModel},
{"swe_default_model wins", map[string]string{"OPENROUTER_API_KEY": "sk-or", "SWE_DEFAULT_MODEL": "openrouter/qwen/qwen3-max"}, "openrouter/qwen/qwen3-max"},
{"ai_model cascade", map[string]string{"ANTHROPIC_API_KEY": "sk-ant", "AI_MODEL": "opus"}, "opus"},
}
Expand Down
38 changes: 34 additions & 4 deletions go/internal/config/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ const (

// Default model for the auto-selected OpenRouter path (see openRouterOnlyEnv).
openRouterAutoDefaultModel = "openrouter/deepseek/deepseek-v4-flash"

// Default model for the auto-selected Infron path (see infronOnlyEnv).
// Infron is OpenAI-compatible and serves the standard <provider>/<model>
// ids, so this is the existing gateway default with the prefix swapped.
infronAutoDefaultModel = "infron/deepseek/deepseek-v4-flash"
)

// runtimeBaseModels ports _RUNTIME_BASE_MODELS[runtime] as a fresh copy for the
Expand Down Expand Up @@ -182,13 +187,31 @@ func openRouterOnlyEnv() bool {
return envStripped("OPENROUTER_API_KEY") != ""
}

// infronOnlyEnv ports _infron_only_env: whether the deployer implicitly chose
// the Infron gateway (no explicit SWE_DEFAULT_RUNTIME, no Anthropic key, no
// other gateway key, but an Infron key present). A gateway key that was already
// honored before Infron existed keeps precedence, so adding an Infron key never
// silently reroutes an existing deployment.
func infronOnlyEnv() bool {
if envStripped("SWE_DEFAULT_RUNTIME") != "" {
return false
}
if envStripped("ANTHROPIC_API_KEY") != "" {
return false
}
if envStripped("OPENROUTER_API_KEY") != "" {
return false
}
return envStripped("INFRON_API_KEY") != ""
}

// DefaultRuntime ports _default_runtime, honoring SWE_DEFAULT_RUNTIME.
// When unset, auto-selects open_code if only an OpenRouter key is present,
// When unset, auto-selects open_code if only a gateway key is present,
// otherwise claude_code. An invalid env value falls back to claude_code.
func DefaultRuntime() string {
value := envStripped("SWE_DEFAULT_RUNTIME")
if value == "" {
if openRouterOnlyEnv() {
if openRouterOnlyEnv() || infronOnlyEnv() {
return "open_code"
}
return "claude_code"
Expand Down Expand Up @@ -259,8 +282,8 @@ func tierModelsFromEnv() map[string]string {
// DefaultPlanningModel ports _default_planning_model: SWE_MODEL_HIGH first
// (the planning reasoners are high-tier roles, see RoleToTier — the same
// relative precedence tier env vars have in ResolveRuntimeModels), then the
// env cascade, then the OpenRouter default when only an OpenRouter key is
// present, else "sonnet".
// env cascade, then the gateway default when only a gateway key is present,
// else "sonnet".
func DefaultPlanningModel() string {
if highModel := tierModelsFromEnv()["high"]; highModel != "" {
return highModel
Expand All @@ -271,6 +294,9 @@ func DefaultPlanningModel() string {
if openRouterOnlyEnv() {
return openRouterAutoDefaultModel
}
if infronOnlyEnv() {
return infronAutoDefaultModel
}
return "sonnet"
}

Expand Down Expand Up @@ -365,6 +391,10 @@ func ResolveRuntimeModels(runtime string, models map[string]string, fieldNames [
for field := range base {
base[field] = openRouterAutoDefaultModel
}
} else if runtime == "open_code" && infronOnlyEnv() {
for field := range base {
base[field] = infronAutoDefaultModel
}
}

resolved := make(map[string]string, len(fieldNames))
Expand Down
33 changes: 32 additions & 1 deletion swe_af/execution/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,11 @@ def _runtime_to_provider(runtime: str) -> Literal["claude", "opencode", "codex"]
# Default model for the auto-selected OpenRouter path (see _openrouter_only_env).
_OPENROUTER_AUTO_DEFAULT_MODEL = "openrouter/deepseek/deepseek-v4-flash"

# Default model for the auto-selected Infron path (see _infron_only_env).
# Infron is OpenAI-compatible and serves the standard <provider>/<model> ids,
# so this is the existing gateway default with the prefix swapped.
_INFRON_AUTO_DEFAULT_MODEL = "infron/deepseek/deepseek-v4-flash"


def _openrouter_only_env() -> bool:
"""Whether the deployer implicitly chose the OpenRouter runtime.
Expand All @@ -672,6 +677,25 @@ def _openrouter_only_env() -> bool:
return bool(os.getenv("OPENROUTER_API_KEY", "").strip())


def _infron_only_env() -> bool:
"""Whether the deployer implicitly chose the Infron runtime.

Mirrors ``_openrouter_only_env`` for the ``INFRON_API_KEY`` gateway: no
explicit ``SWE_DEFAULT_RUNTIME``, no Anthropic key, no other gateway key,
but an ``INFRON_API_KEY``. A gateway key that was already honored before
Infron existed deliberately keeps precedence, so adding an Infron key never
silently reroutes an existing deployment — to move traffic over, drop the
other key or set ``SWE_DEFAULT_MODEL=infron/...`` explicitly.
"""
if os.getenv("SWE_DEFAULT_RUNTIME", "").strip():
return False
if os.getenv("ANTHROPIC_API_KEY", "").strip():
return False
if os.getenv("OPENROUTER_API_KEY", "").strip():
return False
return bool(os.getenv("INFRON_API_KEY", "").strip())


def _default_runtime() -> Literal["claude_code", "open_code", "codex"]:
"""Default runtime, honoring the ``SWE_DEFAULT_RUNTIME`` env var.

Expand All @@ -683,7 +707,9 @@ def _default_runtime() -> Literal["claude_code", "open_code", "codex"]:
"""
value = os.getenv("SWE_DEFAULT_RUNTIME", "").strip()
if not value:
return "open_code" if _openrouter_only_env() else "claude_code"
if _openrouter_only_env() or _infron_only_env():
return "open_code"
return "claude_code"
if value in RUNTIME_VALUES:
return value # type: ignore[return-value]
logging.getLogger(__name__).warning(
Expand Down Expand Up @@ -763,6 +789,8 @@ def _default_planning_model() -> str:
return env_model
if _openrouter_only_env():
return _OPENROUTER_AUTO_DEFAULT_MODEL
if _infron_only_env():
return _INFRON_AUTO_DEFAULT_MODEL
return "sonnet"


Expand Down Expand Up @@ -864,6 +892,9 @@ def resolve_runtime_models(
# default to DeepSeek. Explicit open_code deployers keep their own base
# default; SWE_DEFAULT_MODEL / models overrides still win over this.
base = {field: _OPENROUTER_AUTO_DEFAULT_MODEL for field in base}
elif runtime == "open_code" and _infron_only_env():
# Same rule for the Infron gateway (see _infron_only_env).
base = {field: _INFRON_AUTO_DEFAULT_MODEL for field in base}
resolved: dict[str, str] = {field: base[field] for field in field_names}

env_default = _default_model_from_env()
Expand Down
60 changes: 57 additions & 3 deletions tests/test_dockerfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,29 @@

from __future__ import annotations

import json
import re
from pathlib import Path

import pytest

DOCKERFILE = Path(__file__).resolve().parent.parent / "Dockerfile"
REQUIREMENTS_DOCKER = Path(__file__).resolve().parent.parent / "requirements-docker.txt"
REPO_ROOT = Path(__file__).resolve().parent.parent
DOCKERFILE = REPO_ROOT / "Dockerfile"
GO_DOCKERFILE = REPO_ROOT / "go" / "Dockerfile"
OPENCODE_CONFIG = REPO_ROOT / "docker" / "opencode.json"
REQUIREMENTS_DOCKER = REPO_ROOT / "requirements-docker.txt"


@pytest.fixture(scope="module")
def dockerfile_content() -> str:
return DOCKERFILE.read_text()


@pytest.fixture(scope="module")
def opencode_config() -> dict:
return json.loads(OPENCODE_CONFIG.read_text())


class TestWorkspacesDirectory:
"""Issue #46: /workspaces must be pre-created with write permissions."""

Expand Down Expand Up @@ -65,7 +74,52 @@ def test_dockerfile_installs_codex_cli(dockerfile_content: str) -> None:

def test_dockerfile_preserves_opencode_install(dockerfile_content: str) -> None:
assert "https://opencode.ai/install" in dockerfile_content
assert "OPENROUTER_API_KEY" in dockerfile_content
# The provider block moved out of the Dockerfile into docker/opencode.json
# (shared by both images); the Dockerfile now only has to copy it in.
assert "docker/opencode.json" in dockerfile_content


def test_both_dockerfiles_share_one_opencode_config() -> None:
"""Python and Go images must copy the same config, or they drift apart."""
for path in (DOCKERFILE, GO_DOCKERFILE):
assert "COPY docker/opencode.json /root/.config/opencode/opencode.json" in (
path.read_text()
), f"{path} must copy the shared opencode config"


class TestOpenCodeProviders:
"""docker/opencode.json wires the harness providers for both images."""

def test_model_follows_harness_model_env(self, opencode_config: dict) -> None:
"""Both model and small_model must honor HARNESS_MODEL.

small_model is the one that falls through to config; if it does not
interpolate the same env var, OpenCode silently auto-selects a model
from whatever provider keys it finds.
"""
assert opencode_config["model"] == "{env:HARNESS_MODEL}"
assert opencode_config["small_model"] == "{env:HARNESS_MODEL}"

def test_existing_provider_preserved(self, opencode_config: dict) -> None:
existing = opencode_config["provider"]["openrouter"]
assert existing["options"]["apiKey"] == "{env:OPENROUTER_API_KEY}"

def test_infron_provider_is_openai_compatible(self, opencode_config: dict) -> None:
infron = opencode_config["provider"]["infron"]
assert infron["npm"] == "@ai-sdk/openai-compatible"
assert infron["options"]["baseURL"] == "https://llm.onerouter.pro/v1"
assert infron["options"]["apiKey"] == "{env:INFRON_API_KEY}"

def test_infron_declares_models(self, opencode_config: dict) -> None:
"""openai-compatible providers are not in models.dev, so models must
be listed explicitly or OpenCode cannot resolve `-m infron/<id>`."""
models = opencode_config["provider"]["infron"]["models"]
assert models, "infron provider must declare at least one model"
# The ids are the vendors' own, unchanged across gateways, which is
# what makes this a base-URL change rather than a model-mapping
# exercise.
assert "moonshotai/kimi-k2.6" in models
assert "deepseek/deepseek-v4-flash" in models


def test_docker_requirements_pin_cryptography_below_sigill_version() -> None:
Expand Down
Loading