diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index 6c147705..01445564 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -210,6 +210,29 @@ def _openai_compat(vendor: str, default_base_url: str, env_key: Optional[str] = return build +def _openai_responses_compat( + vendor: str, default_base_url: str, env_key: Optional[str] = None +): + """Builder factory for vendors that explicitly implement the OpenAI Responses API. + + Credentials stay isolated to the vendor's own profile/environment variable, matching the + Chat Completions compat path above. In particular, an OpenAI key is never sent to Ark. + """ + + def build(profile: dict[str, Any], secrets: Any) -> ProviderClient: + base_url = ((profile or {}).get("base_url") or "").strip() or default_base_url + api_key = ((profile or {}).get("api_key") or "").strip() or ( + os.environ.get(env_key, "").strip() if env_key else "" + ) + if not api_key: + raise RuntimeError( + f"No {vendor} API key configured — add it in Settings ▸ Models." + ) + return OpenAIResponsesProvider(api_key=api_key, base_url=base_url) + + return build + + def _compat( name: str, title: str, @@ -248,6 +271,43 @@ def _compat( ) +def _responses_compat( + name: str, + title: str, + *, + base_url: str, + recommended_model: str, + env_key: str, + endpoint_help: str = "", +) -> ProviderDescriptor: + """Descriptor for a vendor exposing the OpenAI Responses API.""" + return ProviderDescriptor( + name=name, + title=title, + needs_key=True, + fields=[ + ProviderField( + "api_key", + f"{title} API key", + secret=True, + ), + ProviderField( + "base_url", + "Endpoint", + required=False, + default=base_url, + placeholder=base_url, + help=endpoint_help + or f"Prefilled with {title}'s official Responses endpoint.", + ), + ], + build=_openai_responses_compat(title, base_url, env_key), + recommended_model=recommended_model, + env_key=env_key, + blurb=f"Uses {title}'s OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", + ) + + DESCRIPTORS: list[ProviderDescriptor] = [ ProviderDescriptor( name="openai", @@ -462,6 +522,25 @@ DESCRIPTORS: list[ProviderDescriptor] = [ blurb="Runs models inside your own Google Cloud project. Gemini and Claude use " "their native APIs; open-weight models go through the Vertex MaaS endpoint.", ), + # Ark has two intentionally separate provider identities. BytePlus pay-as-you-go and + # Volcengine Agent Plan use different regions, endpoints, credentials, and model catalogs; + # combining them would let one provider profile route a model to the wrong service. + _responses_compat( + "ark", + "BytePlus Ark", + base_url="https://ark.ap-southeast.bytepluses.com/api/v3", + recommended_model="dola-seed-evolving-latest-version", + env_key="ARK_API_KEY", + endpoint_help="BytePlus Ark's Asia Pacific endpoint. This provider is separate from Volcengine Ark Agent Plan.", + ), + _responses_compat( + "ark-agent-plan-cn", + "Volcengine Ark Agent Plan", + base_url="https://ark.cn-beijing.volces.com/api/plan/v3", + recommended_model="doubao-seed-evolving", + env_key="ARK_AGENT_PLAN_CN_API_KEY", + endpoint_help="Volcengine Ark Agent Plan's China (Beijing) endpoint. It requires an Agent Plan API key.", + ), # OpenAI-compatible vendors, listed as first-class providers so users don't need to know the # "point the OpenAI slot at a different endpoint" trick (owner call, 2026-07-04). Each keeps # its own key profile; the endpoint is prefilled and editable (regional variants in `help`). diff --git a/tests/test_providers.py b/tests/test_providers.py index 943c6e4c..229d6dbf 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -338,6 +338,74 @@ def test_compat_builder_never_leaks_the_openai_key(monkeypatch): build_provider_client("kimi", {}, None) +ARK_RESPONSES_VENDORS = { + "ark": { + "base_url": "https://ark.ap-southeast.bytepluses.com/api/v3", + "env_key": "ARK_API_KEY", + "recommended_model": "dola-seed-evolving-latest-version", + }, + "ark-agent-plan-cn": { + "base_url": "https://ark.cn-beijing.volces.com/api/plan/v3", + "env_key": "ARK_AGENT_PLAN_CN_API_KEY", + "recommended_model": "doubao-seed-evolving", + }, +} + + +def test_ark_responses_descriptors_are_separate(): + from coworker.providers.registry import get_descriptor + + for name, expected in ARK_RESPONSES_VENDORS.items(): + d = get_descriptor(name) + assert d is not None and d.needs_key, name + assert d.env_key == expected["env_key"] + assert d.recommended_model == expected["recommended_model"] + assert "Responses API" in d.blurb + base = next(f for f in d.fields if f.key == "base_url") + assert base.default == expected["base_url"] + assert not base.required + + +def test_ark_responses_builders_use_their_own_keys_and_endpoints(monkeypatch): + from coworker.providers.openai_responses import OpenAIResponsesProvider + from coworker.providers.registry import build_provider_client + + monkeypatch.setenv("ARK_AGENT_PLAN_CN_API_KEY", "plan-key") + bp = build_provider_client("ark", {"api_key": "bp-key"}, None) + plan = build_provider_client("ark-agent-plan-cn", {}, None) + + assert isinstance(bp, OpenAIResponsesProvider) + assert (bp._api_key, bp._base_url) == ( + "bp-key", + ARK_RESPONSES_VENDORS["ark"]["base_url"], + ) + assert isinstance(plan, OpenAIResponsesProvider) + assert (plan._api_key, plan._base_url) == ( + "plan-key", + ARK_RESPONSES_VENDORS["ark-agent-plan-cn"]["base_url"], + ) + + +def test_ark_responses_never_leak_the_openai_key(monkeypatch): + import pytest + + from coworker.providers.registry import build_provider_client + + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-real") + monkeypatch.delenv("ARK_API_KEY", raising=False) + with pytest.raises(RuntimeError, match="BytePlus Ark"): + build_provider_client("ark", {}, None) + + +def test_existing_chat_compat_paths_unchanged(): + """Lockdown: adding Responses vendors must not migrate existing compat providers.""" + from coworker.providers.registry import build_provider_client + + provider = build_provider_client("deepseek", {"api_key": "ds-key"}, None) + assert isinstance(provider, OpenAIProvider) + assert provider._base_url == COMPAT_VENDORS["deepseek"] + + def test_compat_models_route_and_get_tool_capabilities(): from coworker.providers.router import ProviderRouter