diff --git a/.env.example b/.env.example index 1a5de27..705da40 100644 --- a/.env.example +++ b/.env.example @@ -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 / 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-... diff --git a/Dockerfile b/Dockerfile index 9687f53..381ff33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index af7d3ae..8b0dc0d 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,14 @@ JSON For OpenRouter with `open_code`, use model IDs in `openrouter//` format (for example `openrouter/minimax/minimax-m2.5`). +For Infron with `open_code`, set `INFRON_API_KEY` and use `infron//` (for example `infron/moonshotai/kimi-k2.6`). Infron is an OpenAI-compatible gateway serving the standard `/` 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. diff --git a/agentfield-package.yaml b/agentfield-package.yaml index 1a393a2..9f1480b 100644 --- a/agentfield-package.yaml +++ b/agentfield-package.yaml @@ -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 / 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 diff --git a/docker/opencode.json b/docker/opencode.json new file mode 100644 index 0000000..b2e052c --- /dev/null +++ b/docker/opencode.json @@ -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" } + } + } + } +} diff --git a/go/Dockerfile b/go/Dockerfile index af62556..adec56f 100644 --- a/go/Dockerfile +++ b/go/Dockerfile @@ -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). diff --git a/go/agentfield-package.yaml b/go/agentfield-package.yaml index 59492e6..36d6cc7 100644 --- a/go/agentfield-package.yaml +++ b/go/agentfield-package.yaml @@ -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 / 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. diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index 82a47b9..b0566ff 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -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", @@ -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"}, @@ -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"}, } diff --git a/go/internal/config/resolve.go b/go/internal/config/resolve.go index aa7b6b1..8cf7568 100644 --- a/go/internal/config/resolve.go +++ b/go/internal/config/resolve.go @@ -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 / + // 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 @@ -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" @@ -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 @@ -271,6 +294,9 @@ func DefaultPlanningModel() string { if openRouterOnlyEnv() { return openRouterAutoDefaultModel } + if infronOnlyEnv() { + return infronAutoDefaultModel + } return "sonnet" } @@ -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)) diff --git a/swe_af/execution/schemas.py b/swe_af/execution/schemas.py index d8ea04b..7dc4438 100644 --- a/swe_af/execution/schemas.py +++ b/swe_af/execution/schemas.py @@ -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 / 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. @@ -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. @@ -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( @@ -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" @@ -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() diff --git a/tests/test_dockerfile.py b/tests/test_dockerfile.py index 9158b2d..6969710 100644 --- a/tests/test_dockerfile.py +++ b/tests/test_dockerfile.py @@ -9,13 +9,17 @@ 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") @@ -23,6 +27,11 @@ 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.""" @@ -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/`.""" + 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: diff --git a/tests/test_model_config.py b/tests/test_model_config.py index 04495fc..2d6dfbc 100644 --- a/tests/test_model_config.py +++ b/tests/test_model_config.py @@ -12,6 +12,7 @@ BuildConfig, ExecutionConfig, ROLE_TO_MODEL_FIELD, + _INFRON_AUTO_DEFAULT_MODEL, _OPENROUTER_AUTO_DEFAULT_MODEL, _default_planning_model, _default_runtime, @@ -23,6 +24,7 @@ _PROVIDER_ENV_KEYS = ( "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", + "INFRON_API_KEY", "SWE_DEFAULT_RUNTIME", "SWE_DEFAULT_MODEL", "AI_MODEL", @@ -128,6 +130,63 @@ def test_open_code_runtime_provider(self) -> None: self.assertEqual(resolved["coder_model"], "openrouter/minimax/minimax-m2.5") +class TestInfronAutoSelection(unittest.TestCase): + """The Infron gateway mirrors the existing gateway auto-selection path: + with only an INFRON_API_KEY present, SWE-AF picks open_code and the Infron + default model. A gateway key already configured keeps precedence.""" + + def test_infron_only_auto_selects_open_code(self) -> None: + with _provider_env(INFRON_API_KEY="sk-inf"): + self.assertEqual(_default_runtime(), "open_code") + + def test_anthropic_key_keeps_claude_code(self) -> None: + with _provider_env(ANTHROPIC_API_KEY="sk-ant", INFRON_API_KEY="sk-inf"): + self.assertEqual(_default_runtime(), "claude_code") + + def test_explicit_runtime_overrides_autoselect(self) -> None: + with _provider_env(INFRON_API_KEY="sk-inf", SWE_DEFAULT_RUNTIME="claude_code"): + self.assertEqual(_default_runtime(), "claude_code") + + def test_auto_infron_defaults_to_deepseek(self) -> None: + with _provider_env(INFRON_API_KEY="sk-inf"): + resolved = resolve_runtime_models(runtime="open_code", models=None) + for field in ALL_MODEL_FIELDS: + self.assertEqual(resolved[field], "infron/deepseek/deepseek-v4-flash") + + def test_explicit_open_code_keeps_minimax(self) -> None: + with _provider_env(INFRON_API_KEY="sk-inf", SWE_DEFAULT_RUNTIME="open_code"): + resolved = resolve_runtime_models(runtime="open_code", models=None) + for field in ALL_MODEL_FIELDS: + self.assertEqual(resolved[field], "openrouter/minimax/minimax-m2.5") + + def test_swe_default_model_overrides_auto(self) -> None: + with _provider_env( + INFRON_API_KEY="sk-inf", SWE_DEFAULT_MODEL="infron/moonshotai/kimi-k2.6" + ): + resolved = resolve_runtime_models(runtime="open_code", models=None) + for field in ALL_MODEL_FIELDS: + self.assertEqual(resolved[field], "infron/moonshotai/kimi-k2.6") + + def test_planning_model_uses_infron_default(self) -> None: + with _provider_env(INFRON_API_KEY="sk-inf"): + self.assertEqual(_default_planning_model(), _INFRON_AUTO_DEFAULT_MODEL) + + def test_build_config_auto_infron_end_to_end(self) -> None: + with _provider_env(INFRON_API_KEY="sk-inf"): + cfg = BuildConfig() + self.assertEqual(cfg.runtime, "open_code") + resolved = cfg.resolved_models() + self.assertEqual(resolved["coder_model"], "infron/deepseek/deepseek-v4-flash") + + def test_model_ids_unchanged_after_prefix_swap(self) -> None: + """The model id itself does not change across gateways, so the defaults + differ only by prefix. Guards against the constants drifting apart.""" + self.assertEqual( + _INFRON_AUTO_DEFAULT_MODEL.split("/", 1)[1], + _OPENROUTER_AUTO_DEFAULT_MODEL.split("/", 1)[1], + ) + + class TestOpenRouterAutoSelection(unittest.TestCase): """When only an OpenRouter key is present (no explicit runtime), SWE-AF auto-selects the open_code runtime and defaults to DeepSeek.""" @@ -181,6 +240,14 @@ def test_build_config_auto_openrouter_end_to_end(self) -> None: resolved = cfg.resolved_models() self.assertEqual(resolved["coder_model"], "openrouter/deepseek/deepseek-v4-flash") + def test_infron_key_does_not_disturb_existing_gateway(self) -> None: + # Adding an Infron key alongside an existing one must not reroute + # anything. + with _provider_env(OPENROUTER_API_KEY="sk-or", INFRON_API_KEY="sk-inf"): + resolved = resolve_runtime_models(runtime="open_code", models=None) + for field in ALL_MODEL_FIELDS: + self.assertEqual(resolved[field], _OPENROUTER_AUTO_DEFAULT_MODEL) + def test_to_execution_config_dict_roundtrips(self) -> None: cfg = BuildConfig(runtime="open_code", models={"coder": "deepseek/deepseek-chat"}) d = cfg.to_execution_config_dict()