diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 290edcdd..de70ab89 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -260,6 +260,7 @@ class ProvidersConfig(Base): github_copilot: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # Github Copilot (OAuth) qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆) nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys) + opencode: ProviderConfig = Field(default_factory=ProviderConfig) # OpenCode Zen (canonical provider id) opencode_zen: ProviderConfig = Field(default_factory=ProviderConfig) # OpenCode Zen (curated coding models) opencode_go: ProviderConfig = Field(default_factory=ProviderConfig) # OpenCode Go (low-cost coding models) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index f932f098..6fbfc2d4 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -179,7 +179,20 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( gateway_reasoning_style="reasoning_effort", ), # OpenCode Zen: OpenAI-compatible chat-completions gateway for coding models. - # OpenCode's own config uses "opencode/"; send the bare model upstream. + # models.dev/OpenCode use provider id "opencode" and model ids like + # "opencode/"; send the bare model upstream. + ProviderSpec( + name="opencode", + keywords=("opencode/", "opencode", "opencode-zen", "opencode_zen"), + env_key="OPENCODE_API_KEY", + display_name="OpenCode Zen", + backend="openai_compat", + is_gateway=True, + detect_by_base_keyword="opencode.ai/zen", + default_api_base="https://opencode.ai/zen/v1", + strip_model_prefixes=("opencode", "opencode_zen", "opencode-zen"), + ), + # Compatibility alias for configs that already used providers.opencodeZen. ProviderSpec( name="opencode_zen", keywords=("opencode/", "opencode_zen", "opencode-zen"), diff --git a/tests/providers/test_opencode_provider.py b/tests/providers/test_opencode_provider.py index d6372761..9f01c356 100644 --- a/tests/providers/test_opencode_provider.py +++ b/tests/providers/test_opencode_provider.py @@ -8,6 +8,7 @@ from nanobot.providers.registry import PROVIDERS, find_by_name def test_opencode_config_fields_exist() -> None: config = ProvidersConfig() + assert hasattr(config, "opencode") assert hasattr(config, "opencode_zen") assert hasattr(config, "opencode_go") @@ -15,7 +16,7 @@ def test_opencode_config_fields_exist() -> None: def test_opencode_specs_use_openai_compatible_gateways() -> None: specs = {spec.name: spec for spec in PROVIDERS} - zen = specs["opencode_zen"] + zen = specs["opencode"] assert zen.backend == "openai_compat" assert zen.env_key == "OPENCODE_API_KEY" assert zen.display_name == "OpenCode Zen" @@ -24,6 +25,10 @@ def test_opencode_specs_use_openai_compatible_gateways() -> None: assert zen.default_api_base == "https://opencode.ai/zen/v1" assert "opencode" in zen.strip_model_prefixes + zen_compat = specs["opencode_zen"] + assert zen_compat.env_key == "OPENCODE_API_KEY" + assert zen_compat.default_api_base == zen.default_api_base + go = specs["opencode_go"] assert go.backend == "openai_compat" assert go.env_key == "OPENCODE_API_KEY" @@ -35,6 +40,10 @@ def test_opencode_specs_use_openai_compatible_gateways() -> None: def test_find_by_name_opencode_providers() -> None: + canonical = find_by_name("opencode") + assert canonical is not None + assert canonical.name == "opencode" + zen = find_by_name("opencode_zen") assert zen is not None assert zen.name == "opencode_zen" @@ -46,15 +55,26 @@ def test_find_by_name_opencode_providers() -> None: def test_opencode_forced_providers_use_default_api_base() -> None: zen_config = Config.model_validate( + { + "providers": {"opencode": {"apiKey": "opencode-key"}}, + "agents": {"defaults": {"provider": "opencode", "model": "opencode/o3"}}, + } + ) + + assert zen_config.get_provider_name() == "opencode" + assert zen_config.get_api_key() == "opencode-key" + assert zen_config.get_api_base() == "https://opencode.ai/zen/v1" + + legacy_zen_config = Config.model_validate( { "providers": {"opencodeZen": {"apiKey": "opencode-key"}}, "agents": {"defaults": {"provider": "opencode_zen", "model": "opencode/o3"}}, } ) - assert zen_config.get_provider_name() == "opencode_zen" - assert zen_config.get_api_key() == "opencode-key" - assert zen_config.get_api_base() == "https://opencode.ai/zen/v1" + assert legacy_zen_config.get_provider_name() == "opencode_zen" + assert legacy_zen_config.get_api_key() == "opencode-key" + assert legacy_zen_config.get_api_base() == "https://opencode.ai/zen/v1" go_config = Config.model_validate( { @@ -72,7 +92,7 @@ def test_opencode_prefixes_are_stripped_before_request() -> None: zen_provider = OpenAICompatProvider( api_key=None, default_model="opencode/o3", - spec=find_by_name("opencode_zen"), + spec=find_by_name("opencode"), ) zen_kwargs = zen_provider._build_kwargs( messages=[{"role": "user", "content": "hi"}],