From 06c3ed2d0379fbed11ffa991455ce69c9fd15beb Mon Sep 17 00:00:00 2001 From: solon Date: Tue, 7 Apr 2026 13:58:45 +0800 Subject: [PATCH 1/3] fix(auth): resolve_auth() sends wrong API key when switching providers When a user has an Anthropic API key stored in settings.json and switches to --api-format openai, resolve_auth() blindly returned the flat self.api_key (Anthropic key) before checking provider-specific environment variables. This sent the wrong credential to the OpenAI endpoint, causing 401 errors. Fix: check the auth_source-specific environment variable (e.g. OPENAI_API_KEY) before falling back to the flat api_key field. This ensures the correct credential is used when multiple providers are configured. --- src/openharness/config/settings.py | 27 ++++++++++++++++++--------- tests/test_config/test_settings.py | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/openharness/config/settings.py b/src/openharness/config/settings.py index 4d62d7f..a8166f6 100644 --- a/src/openharness/config/settings.py +++ b/src/openharness/config/settings.py @@ -545,16 +545,14 @@ class Settings(BaseModel): ) storage_provider = auth_source_provider_name(auth_source) - explicit_key = self.api_key - if explicit_key: - return ResolvedAuth( - provider=provider or storage_provider, - auth_kind="api_key", - value=explicit_key, - source="settings_or_env", - state="configured", - ) + # Look up the provider-specific environment variable first. The flat + # ``self.api_key`` field is a legacy single-slot value that usually + # holds an Anthropic key. When the active profile points at a + # *different* provider (e.g. ``openai_api_key``), blindly returning + # ``self.api_key`` sends the wrong credential to the wrong backend. + # Checking the env var first ensures the correct key is used when the + # user has both ANTHROPIC_API_KEY and OPENAI_API_KEY configured. env_var = { "anthropic_api_key": "ANTHROPIC_API_KEY", "openai_api_key": "OPENAI_API_KEY", @@ -571,6 +569,17 @@ class Settings(BaseModel): state="configured", ) + # Fall back to the flat api_key field (settings.json / --api-key). + explicit_key = self.api_key + if explicit_key: + return ResolvedAuth( + provider=provider or storage_provider, + auth_kind="api_key", + value=explicit_key, + source="settings_or_env", + state="configured", + ) + from openharness.auth.storage import load_credential stored = load_credential(storage_provider, "api_key") diff --git a/tests/test_config/test_settings.py b/tests/test_config/test_settings.py index 973ef86..d2e67a6 100644 --- a/tests/test_config/test_settings.py +++ b/tests/test_config/test_settings.py @@ -63,6 +63,28 @@ class TestSettings: assert s.model != updated.model assert s is not updated + def test_resolve_auth_prefers_env_over_flat_api_key_for_openai(self, monkeypatch): + """When api_format=openai, resolve_auth() should use OPENAI_API_KEY + from the environment rather than the flat api_key field which may + contain an Anthropic key from settings.json.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-correct") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + s = Settings(api_key="sk-ant-wrong-provider", api_format="openai") + s = s.sync_active_profile_from_flat_fields() + auth = s.resolve_auth() + assert auth.value == "sk-openai-correct" + assert "OPENAI" in auth.source + + def test_resolve_auth_falls_back_to_flat_api_key(self, monkeypatch): + """When no provider-specific env var is set, resolve_auth() should + still fall back to the flat api_key field.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + s = Settings(api_key="sk-fallback-key") + s = s.sync_active_profile_from_flat_fields() + auth = s.resolve_auth() + assert auth.value == "sk-fallback-key" + class TestLoadSaveSettings: def test_load_missing_file_returns_defaults(self, tmp_path: Path): From 37f66f48fe4631a8d3cc8af9f12b63fb9c799936 Mon Sep 17 00:00:00 2001 From: solon Date: Tue, 7 Apr 2026 14:49:48 +0800 Subject: [PATCH 2/3] fix(auth): support OPENAI_BASE_URL in env overrides and fix test env leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: _apply_env_overrides() only checked ANTHROPIC_BASE_URL and OPENHARNESS_BASE_URL, ignoring OPENAI_BASE_URL. Users with OpenAI-compatible relay services (e.g. relay.nf.video) that set OPENAI_BASE_URL had their base_url left as None in settings, sending requests to api.openai.com instead of their relay — causing 401 errors. Fix: - Add OPENAI_BASE_URL to the env override chain (after ANTHROPIC_BASE_URL, before OPENHARNESS_BASE_URL) so relay base URLs are explicitly captured in settings rather than relying solely on the OpenAI SDK's env var detection. - Fix 4 pre-existing test failures caused by OPENAI_API_KEY / ANTHROPIC_* env vars leaking into test assertions. Tests now use monkeypatch to isolate from the host environment. - Add tests for OPENAI_BASE_URL pickup and ANTHROPIC_BASE_URL precedence. --- src/openharness/config/settings.py | 6 +++- tests/test_config/test_settings.py | 49 ++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/openharness/config/settings.py b/src/openharness/config/settings.py index a8166f6..3f30bf8 100644 --- a/src/openharness/config/settings.py +++ b/src/openharness/config/settings.py @@ -616,7 +616,11 @@ def _apply_env_overrides(settings: Settings) -> Settings: if model: updates["model"] = model - base_url = os.environ.get("ANTHROPIC_BASE_URL") or os.environ.get("OPENHARNESS_BASE_URL") + base_url = ( + os.environ.get("ANTHROPIC_BASE_URL") + or os.environ.get("OPENAI_BASE_URL") + or os.environ.get("OPENHARNESS_BASE_URL") + ) if base_url: updates["base_url"] = base_url diff --git a/tests/test_config/test_settings.py b/tests/test_config/test_settings.py index d2e67a6..5cac776 100644 --- a/tests/test_config/test_settings.py +++ b/tests/test_config/test_settings.py @@ -45,6 +45,7 @@ class TestSettings: def test_resolve_api_key_missing_raises(self, monkeypatch): monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) s = Settings() with pytest.raises(ValueError, match="No API key found"): s.resolve_api_key() @@ -85,14 +86,50 @@ class TestSettings: auth = s.resolve_auth() assert auth.value == "sk-fallback-key" + def test_env_overrides_picks_up_openai_base_url(self, tmp_path: Path, monkeypatch): + """_apply_env_overrides should pick up OPENAI_BASE_URL for relay + providers that use OpenAI-compatible format.""" + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.delenv("OPENHARNESS_BASE_URL", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_BASE_URL", "https://relay.example.com/v1") + monkeypatch.setenv("OPENAI_API_KEY", "sk-relay-key") + path = tmp_path / "settings.json" + path.write_text(json.dumps({})) + s = load_settings(path) + assert s.base_url == "https://relay.example.com/v1" + + def test_anthropic_base_url_takes_precedence_over_openai(self, tmp_path: Path, monkeypatch): + """ANTHROPIC_BASE_URL should take precedence over OPENAI_BASE_URL.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://anthropic-relay.example.com") + monkeypatch.setenv("OPENAI_BASE_URL", "https://openai-relay.example.com/v1") + path = tmp_path / "settings.json" + path.write_text(json.dumps({})) + s = load_settings(path) + assert s.base_url == "https://anthropic-relay.example.com" + class TestLoadSaveSettings: - def test_load_missing_file_returns_defaults(self, tmp_path: Path): + def test_load_missing_file_returns_defaults(self, tmp_path: Path, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENHARNESS_BASE_URL", raising=False) + monkeypatch.delenv("ANTHROPIC_MODEL", raising=False) + monkeypatch.delenv("OPENHARNESS_MODEL", raising=False) path = tmp_path / "nonexistent.json" s = load_settings(path) assert s == Settings().materialize_active_profile() - def test_load_existing_file(self, tmp_path: Path): + def test_load_existing_file(self, tmp_path: Path, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("ANTHROPIC_MODEL", raising=False) + monkeypatch.delenv("OPENHARNESS_MODEL", raising=False) path = tmp_path / "settings.json" path.write_text(json.dumps({"model": "claude-opus-4-20250514", "verbose": True, "fast_mode": True})) s = load_settings(path) @@ -101,7 +138,13 @@ class TestLoadSaveSettings: assert s.fast_mode is True assert s.api_key == "" # default preserved - def test_save_and_load_roundtrip(self, tmp_path: Path): + def test_save_and_load_roundtrip(self, tmp_path: Path, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("ANTHROPIC_MODEL", raising=False) + monkeypatch.delenv("OPENHARNESS_MODEL", raising=False) path = tmp_path / "settings.json" original = Settings(api_key="sk-roundtrip", model="claude-opus-4-20250514", verbose=True) save_settings(original, path) From 07b528b5e9d80f697d06adfa06d8c26845689dc2 Mon Sep 17 00:00:00 2001 From: solon Date: Tue, 7 Apr 2026 15:09:49 +0800 Subject: [PATCH 3/3] fix(config): infer provider=openai when api_format=openai; prefix bare version models with gpt- When --api-format openai is passed, the provider stayed as "anthropic" (from the default claude-api profile), causing two issues: 1. resolve_model_setting used Claude model resolution instead of OpenAI, so bare versions like "5.4" were passed through as-is instead of becoming "gpt-5.4". 2. Provider-dependent logic elsewhere assumed Anthropic semantics. Fix: in sync_active_profile_from_flat_fields(), when api_format switches to "openai" but provider is still "anthropic", infer provider as "openai". Also add a fallback in resolve_model_setting: bare numeric versions (e.g. "5.4") get prefixed with "gpt-" for OpenAI providers. --- src/openharness/config/settings.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/openharness/config/settings.py b/src/openharness/config/settings.py index 3f30bf8..e04f844 100644 --- a/src/openharness/config/settings.py +++ b/src/openharness/config/settings.py @@ -235,8 +235,12 @@ def resolve_model_setting( return _CLAUDE_ALIAS_TARGETS[normalized] return normalize_anthropic_model_name(configured) - if provider in {"openai", "openai_codex", "copilot"} and normalized in {"default", "best"}: - return "gpt-5.4" + if provider in {"openai", "openai_codex", "copilot"}: + if normalized in {"default", "best"}: + return "gpt-5.4" + # Bare version numbers like "5.4" → "gpt-5.4" + if normalized and normalized[0].isdigit(): + return f"gpt-{configured}" return configured @@ -426,6 +430,11 @@ class Settings(BaseModel): profile_name, profile = self.resolve_profile() next_provider = (self.provider or "").strip() or profile.provider next_api_format = (self.api_format or "").strip() or profile.api_format + # When api_format switches to "openai" but provider is still the + # default "anthropic", infer provider as "openai" so model resolution + # and other provider-dependent logic uses the correct path. + if next_api_format == "openai" and next_provider == "anthropic": + next_provider = "openai" next_base_url = self.base_url if self.base_url is not None else profile.base_url flat_model = (self.model or "").strip() resolved_profile_model = resolve_model_setting(