Reworks the salvaged OpenCode Free provider to match the tier's real auth contract (verified live 2026-08-21): the Zen relay serves free models ANONYMOUSLY and 401s any unrecognized bearer, so the provider now declares no credentials at all and routes every model through the shared keyless machinery from the Ox Alpha fix (empty Authorization default header overriding the SDK bearer). On top of the salvaged base: - auth.py: no api_key_env_vars; drop the keyed-auth special case - runtime_provider.py: restore the plain fail-closed path (opencode-free never reaches it — the keyless runtime resolves first) - models.py: opencode-free joins the opencode family (prefix stripping, Zen endpoint routing incl. muse->responses); keyless predicate extended with unsuffixed free slugs (big-pickle); free runtime pins EVERY opencode-free model keyless; curated catalog replaces the models.dev cost==0 filter (it lags reality: deepseek-v4-flash-free stayed 'free' there after its promo ended and the relay began 401ing it — delisted) - agent_runtime_helpers.py: replace the httpx transport-sharing auth-strip wrapper with the shared header policy (no proxy-mount loss) - model_setup_flows.py: skip the API-key prompt for opencode-free - plugin profile: keyless headers, no env vars - .env.example + providers.md: keyless docs (no OPENCODE_FREE_API_KEY) - tests rewritten to the keyless contract, incl. catalog-membership invariant (every curated model must satisfy the keyless predicate) E2E: full AIAgent turns with zero keys complete on x-preview-f-free via provider opencode-free and alias 'free', incl. a real terminal tool round-trip; muse routes to /v1/responses; picker lists 8 keyless models.
97 lines
3.4 KiB
Python
97 lines
3.4 KiB
Python
"""Regression guard: opencode-free client keyless header handling.
|
|
|
|
OpenCode's free tier at ``https://opencode.ai/zen/v1`` is served ANONYMOUSLY:
|
|
requests with no recognizable Authorization bearer succeed, while any bearer
|
|
the relay doesn't recognize — placeholders included — is rejected with 401
|
|
"Invalid API key" (verified live 2026-08-21).
|
|
|
|
The client therefore must ship an EMPTY ``Authorization`` default header for
|
|
every opencode-free build, which overrides the OpenAI SDK's always-injected
|
|
``Authorization: Bearer <api_key>`` so no credential-shaped value ever
|
|
reaches the wire.
|
|
"""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from agent.agent_runtime_helpers import create_openai_client
|
|
|
|
ZEN_V1 = "https://opencode.ai/zen/v1"
|
|
|
|
|
|
class _FakeAgent:
|
|
def __init__(self, api_key):
|
|
self.provider = "opencode-free"
|
|
self.base_url = ZEN_V1
|
|
self.api_key = api_key
|
|
self.model = "x-preview-f-free"
|
|
self.api_mode = "chat_completions"
|
|
|
|
def _client_log_context(self):
|
|
return {}
|
|
|
|
def _build_keepalive_http_client(self, base_url, verify=True):
|
|
return None
|
|
|
|
|
|
def _zen_call_headers(mock_openai):
|
|
matching = [
|
|
c for c in mock_openai.call_args_list
|
|
if c.kwargs.get("base_url") == ZEN_V1
|
|
]
|
|
assert matching, "OpenAI was never constructed with the zen base_url"
|
|
return dict(matching[-1].kwargs.get("default_headers") or {})
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_opencode_free_blanks_authorization_header(mock_openai):
|
|
"""Whatever api_key value reaches the client build (placeholder, stale
|
|
key, empty), the Authorization default header must be blanked so the
|
|
SDK's Bearer never hits the wire."""
|
|
mock_openai.return_value = MagicMock()
|
|
for key in ("opencode-zen-free-keyless", "no-key-required", "", "sk-stale"):
|
|
mock_openai.reset_mock()
|
|
create_openai_client(
|
|
_FakeAgent(api_key=key),
|
|
{"api_key": key, "base_url": ZEN_V1},
|
|
reason="test",
|
|
shared=False,
|
|
)
|
|
headers = _zen_call_headers(mock_openai)
|
|
assert headers.get("Authorization") == "", (
|
|
f"opencode-free with api_key={key!r} must blank Authorization; "
|
|
f"got {headers!r}"
|
|
)
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_opencode_free_sends_hermes_attribution(mock_openai):
|
|
"""Keyless requests still identify as Hermes (attribution headers match
|
|
the opencode zen/go profiles)."""
|
|
mock_openai.return_value = MagicMock()
|
|
create_openai_client(
|
|
_FakeAgent(api_key="opencode-zen-free-keyless"),
|
|
{"api_key": "opencode-zen-free-keyless", "base_url": ZEN_V1},
|
|
reason="test",
|
|
shared=False,
|
|
)
|
|
headers = _zen_call_headers(mock_openai)
|
|
assert headers.get("X-Title") == "Hermes Agent"
|
|
assert str(headers.get("User-Agent", "")).startswith("HermesAgent/")
|
|
|
|
|
|
@patch("run_agent.OpenAI")
|
|
def test_other_providers_unaffected(mock_openai):
|
|
"""The opencode-free header policy must not leak to other providers."""
|
|
mock_openai.return_value = MagicMock()
|
|
agent = _FakeAgent(api_key="sk-real")
|
|
agent.provider = "opencode-zen"
|
|
create_openai_client(
|
|
agent,
|
|
{"api_key": "sk-real", "base_url": ZEN_V1},
|
|
reason="test",
|
|
shared=False,
|
|
)
|
|
headers = _zen_call_headers(mock_openai)
|
|
assert "Authorization" not in headers, (
|
|
"opencode-zen (keyed) must not have its Authorization header blanked"
|
|
)
|