915 lines
38 KiB
Python
915 lines
38 KiB
Python
"""Tests for openharness.config.settings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from openharness.auth.storage import store_credential
|
|
from openharness.config.settings import (
|
|
ProviderProfile,
|
|
Settings,
|
|
display_model_setting,
|
|
load_settings,
|
|
normalize_anthropic_model_name,
|
|
save_settings,
|
|
strip_ansi_escape_sequences,
|
|
_apply_env_overrides,
|
|
)
|
|
|
|
|
|
class TestSettings:
|
|
def test_defaults(self):
|
|
s = Settings()
|
|
assert s.api_key == ""
|
|
assert s.model == "claude-sonnet-4-6"
|
|
assert s.max_tokens == 16384
|
|
assert s.timeout == 30.0
|
|
assert s.max_turns == 200
|
|
assert s.fast_mode is False
|
|
assert s.permission.mode == "default"
|
|
assert s.sandbox.enabled is False
|
|
assert s.sandbox.filesystem.allow_write == ["."]
|
|
assert s.web.resolution_mode == "auto"
|
|
assert s.web.synthetic_dns_cidrs == []
|
|
|
|
def test_resolve_api_key_from_instance(self):
|
|
s = Settings(api_key="sk-test-123")
|
|
assert s.resolve_api_key() == "sk-test-123"
|
|
|
|
def test_resolve_api_key_from_env(self, monkeypatch):
|
|
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-456")
|
|
s = Settings()
|
|
assert s.resolve_api_key() == "sk-env-456"
|
|
|
|
def test_resolve_api_key_prefers_openharness_env(self, monkeypatch):
|
|
monkeypatch.setenv("OPENHARNESS_ANTHROPIC_API_KEY", "sk-oh-456")
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-456")
|
|
s = Settings()
|
|
assert s.resolve_api_key() == "sk-oh-456"
|
|
|
|
def test_resolve_api_key_instance_takes_precedence(self, monkeypatch):
|
|
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-456")
|
|
s = Settings(api_key="sk-instance-789")
|
|
assert s.resolve_api_key() == "sk-instance-789"
|
|
|
|
def test_resolve_api_key_missing_raises(self, monkeypatch):
|
|
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
|
|
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()
|
|
|
|
def test_merge_cli_overrides(self):
|
|
s = Settings()
|
|
updated = s.merge_cli_overrides(model="claude-opus-4-20250514", verbose=True, api_key=None)
|
|
assert updated.model == "claude-opus-4-20250514"
|
|
assert updated.verbose is True
|
|
# api_key=None should not override the default
|
|
assert updated.api_key == ""
|
|
|
|
def test_merge_cli_overrides_applies_permission_mode(self):
|
|
s = Settings()
|
|
updated = s.merge_cli_overrides(permission_mode="full_auto")
|
|
assert updated.permission.mode == "full_auto"
|
|
assert s.permission.mode == "default"
|
|
|
|
def test_web_settings_env_overrides(self, monkeypatch):
|
|
monkeypatch.setenv("OPENHARNESS_WEB_PROXY", "http://proxy.example.com:7890")
|
|
monkeypatch.setenv("OPENHARNESS_WEB_RESOLUTION_MODE", "synthetic_dns")
|
|
monkeypatch.setenv("OPENHARNESS_WEB_SYNTHETIC_DNS_CIDRS", "100.64.0.0/10,203.0.113.0/24")
|
|
|
|
updated = _apply_env_overrides(Settings())
|
|
|
|
assert updated.web.proxy == "http://proxy.example.com:7890"
|
|
assert updated.web.resolution_mode == "synthetic_dns"
|
|
assert updated.web.synthetic_dns_cidrs == ["100.64.0.0/10", "203.0.113.0/24"]
|
|
|
|
def test_merge_cli_overrides_returns_new_instance(self):
|
|
s = Settings()
|
|
updated = s.merge_cli_overrides(model="claude-opus-4-20250514")
|
|
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.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
|
|
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_prefers_openharness_env_for_openai(self, monkeypatch):
|
|
monkeypatch.setenv("OPENHARNESS_OPENAI_API_KEY", "sk-oh-openai")
|
|
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-oh-openai"
|
|
assert auth.source == "env:OPENHARNESS_OPENAI_API_KEY"
|
|
|
|
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("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
|
|
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"
|
|
|
|
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_load_settings_uses_profile_specific_openharness_env_key(self, tmp_path: Path, monkeypatch):
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-wrong")
|
|
monkeypatch.setenv("OPENHARNESS_OPENAI_API_KEY", "sk-oh-openai")
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
path = tmp_path / "settings.json"
|
|
path.write_text(
|
|
Settings(active_profile="openai-compatible").model_dump_json(),
|
|
encoding="utf-8",
|
|
)
|
|
s = load_settings(path)
|
|
assert s.active_profile == "openai-compatible"
|
|
assert s.api_key == "sk-oh-openai"
|
|
|
|
def test_load_settings_ignores_wrong_provider_native_env_key(self, tmp_path: Path, monkeypatch):
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-wrong")
|
|
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
path = tmp_path / "settings.json"
|
|
path.write_text(
|
|
Settings(active_profile="openai-compatible").model_dump_json(),
|
|
encoding="utf-8",
|
|
)
|
|
s = load_settings(path)
|
|
assert s.active_profile == "openai-compatible"
|
|
assert s.api_key == ""
|
|
|
|
def test_env_overrides_pick_up_compact_threshold_settings(self, tmp_path: Path, monkeypatch):
|
|
monkeypatch.setenv("OPENHARNESS_CONTEXT_WINDOW_TOKENS", "123456")
|
|
monkeypatch.setenv("OPENHARNESS_AUTO_COMPACT_THRESHOLD_TOKENS", "120000")
|
|
path = tmp_path / "settings.json"
|
|
path.write_text(json.dumps({}))
|
|
s = load_settings(path)
|
|
assert s.context_window_tokens == 123456
|
|
assert s.auto_compact_threshold_tokens == 120000
|
|
|
|
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, monkeypatch):
|
|
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
|
|
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, monkeypatch):
|
|
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
|
|
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)
|
|
assert s.model == "claude-opus-4-20250514"
|
|
assert s.verbose is True
|
|
assert s.fast_mode is True
|
|
assert s.api_key == "" # default preserved
|
|
|
|
def test_save_and_load_roundtrip(self, tmp_path: Path, monkeypatch):
|
|
monkeypatch.delenv("OPENHARNESS_ANTHROPIC_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENHARNESS_OPENAI_API_KEY", raising=False)
|
|
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)
|
|
loaded = load_settings(path)
|
|
assert loaded.api_key == original.api_key
|
|
assert loaded.model == original.model
|
|
assert loaded.verbose == original.verbose
|
|
|
|
def test_load_migrates_flat_provider_settings_to_profile(self, tmp_path: Path):
|
|
path = tmp_path / "settings.json"
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"api_format": "anthropic",
|
|
"provider": "anthropic",
|
|
"model": "kimi-k2.5",
|
|
"base_url": "https://api.moonshot.cn/anthropic",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
loaded = load_settings(path)
|
|
profile_name, profile = loaded.resolve_profile()
|
|
|
|
assert profile_name == "anthropic"
|
|
assert profile.base_url == "https://api.moonshot.cn/anthropic"
|
|
assert profile.resolved_model == "kimi-k2.5"
|
|
assert loaded.base_url == "https://api.moonshot.cn/anthropic"
|
|
assert loaded.model == "kimi-k2.5"
|
|
|
|
def test_materialize_active_profile_uses_profile_model(self):
|
|
settings = Settings(
|
|
active_profile="codex",
|
|
profiles={
|
|
"codex": ProviderProfile(
|
|
label="Codex Subscription",
|
|
provider="openai_codex",
|
|
api_format="openai",
|
|
auth_source="codex_subscription",
|
|
default_model="gpt-5.4",
|
|
last_model="gpt-5",
|
|
)
|
|
},
|
|
)
|
|
|
|
materialized = settings.materialize_active_profile()
|
|
|
|
assert materialized.provider == "openai_codex"
|
|
assert materialized.api_format == "openai"
|
|
assert materialized.model == "gpt-5"
|
|
|
|
def test_materialize_active_profile_projects_compact_threshold_settings(self):
|
|
settings = Settings(
|
|
active_profile="openai-compatible",
|
|
profiles={
|
|
"openai-compatible": ProviderProfile(
|
|
label="OpenAI-Compatible API",
|
|
provider="openai",
|
|
api_format="openai",
|
|
auth_source="openai_api_key",
|
|
default_model="gpt-5.4",
|
|
context_window_tokens=100000,
|
|
auto_compact_threshold_tokens=90000,
|
|
)
|
|
},
|
|
)
|
|
|
|
materialized = settings.materialize_active_profile()
|
|
|
|
assert materialized.context_window_tokens == 100000
|
|
assert materialized.auto_compact_threshold_tokens == 90000
|
|
|
|
def test_merge_cli_active_profile_does_not_inherit_flat_provider_fields(self):
|
|
settings = Settings(
|
|
active_profile="moonshot",
|
|
provider="moonshot",
|
|
api_format="openai",
|
|
base_url="https://api.moonshot.cn/v1",
|
|
model="kimi-k2.5",
|
|
profiles={
|
|
"moonshot": ProviderProfile(
|
|
label="Moonshot",
|
|
provider="moonshot",
|
|
api_format="openai",
|
|
auth_source="moonshot_api_key",
|
|
default_model="kimi-k2.5",
|
|
last_model="kimi-k2.5",
|
|
base_url="https://api.moonshot.cn/v1",
|
|
),
|
|
"codex": ProviderProfile(
|
|
label="Codex Subscription",
|
|
provider="openai_codex",
|
|
api_format="openai",
|
|
auth_source="codex_subscription",
|
|
default_model="gpt-5.4",
|
|
last_model="gpt-5.4",
|
|
),
|
|
},
|
|
)
|
|
|
|
updated = settings.merge_cli_overrides(active_profile="codex")
|
|
profile_name, profile = updated.resolve_profile()
|
|
|
|
assert profile_name == "codex"
|
|
assert updated.provider == "openai_codex"
|
|
assert updated.base_url is None
|
|
assert updated.model == "gpt-5.4"
|
|
assert profile.provider == "openai_codex"
|
|
assert profile.auth_source == "codex_subscription"
|
|
|
|
def test_merge_cli_active_profile_with_model_keeps_target_profile_auth(self):
|
|
settings = Settings(
|
|
active_profile="moonshot",
|
|
provider="moonshot",
|
|
api_format="openai",
|
|
base_url="https://api.moonshot.cn/v1",
|
|
model="kimi-k2.5",
|
|
profiles={
|
|
"moonshot": ProviderProfile(
|
|
label="Moonshot",
|
|
provider="moonshot",
|
|
api_format="openai",
|
|
auth_source="moonshot_api_key",
|
|
default_model="kimi-k2.5",
|
|
last_model="kimi-k2.5",
|
|
base_url="https://api.moonshot.cn/v1",
|
|
),
|
|
"codex": ProviderProfile(
|
|
label="Codex Subscription",
|
|
provider="openai_codex",
|
|
api_format="openai",
|
|
auth_source="codex_subscription",
|
|
default_model="gpt-5.4",
|
|
last_model="gpt-5.4",
|
|
),
|
|
},
|
|
)
|
|
|
|
updated = settings.merge_cli_overrides(active_profile="codex", model="gpt-5.5")
|
|
profile_name, profile = updated.resolve_profile()
|
|
|
|
assert profile_name == "codex"
|
|
assert updated.provider == "openai_codex"
|
|
assert updated.api_format == "openai"
|
|
assert updated.base_url is None
|
|
assert updated.model == "gpt-5.5"
|
|
assert profile.provider == "openai_codex"
|
|
assert profile.auth_source == "codex_subscription"
|
|
assert profile.last_model == "gpt-5.5"
|
|
|
|
def test_merge_cli_active_profile_keeps_profile_compact_threshold_settings(self):
|
|
settings = Settings(
|
|
active_profile="moonshot",
|
|
context_window_tokens=64000,
|
|
auto_compact_threshold_tokens=60000,
|
|
profiles={
|
|
"moonshot": ProviderProfile(
|
|
label="Moonshot",
|
|
provider="moonshot",
|
|
api_format="openai",
|
|
auth_source="moonshot_api_key",
|
|
default_model="kimi-k2.5",
|
|
last_model="kimi-k2.5",
|
|
base_url="https://api.moonshot.cn/v1",
|
|
context_window_tokens=64000,
|
|
auto_compact_threshold_tokens=60000,
|
|
),
|
|
"openai-compatible": ProviderProfile(
|
|
label="OpenAI-Compatible API",
|
|
provider="openai",
|
|
api_format="openai",
|
|
auth_source="openai_api_key",
|
|
default_model="gpt-5.4",
|
|
last_model="gpt-5.4",
|
|
base_url="https://relay.example.com/v1",
|
|
context_window_tokens=200000,
|
|
auto_compact_threshold_tokens=180000,
|
|
),
|
|
},
|
|
)
|
|
|
|
updated = settings.merge_cli_overrides(active_profile="openai-compatible")
|
|
|
|
assert updated.base_url == "https://relay.example.com/v1"
|
|
assert updated.context_window_tokens == 200000
|
|
assert updated.auto_compact_threshold_tokens == 180000
|
|
|
|
def test_claude_profile_materializes_alias_to_concrete_model(self):
|
|
settings = Settings(
|
|
active_profile="claude-subscription",
|
|
profiles={
|
|
"claude-subscription": ProviderProfile(
|
|
label="Claude Subscription",
|
|
provider="anthropic_claude",
|
|
api_format="anthropic",
|
|
auth_source="claude_subscription",
|
|
default_model="sonnet",
|
|
last_model="opus",
|
|
)
|
|
},
|
|
)
|
|
|
|
materialized = settings.materialize_active_profile()
|
|
|
|
assert materialized.model == "claude-opus-4-6"
|
|
|
|
def test_claude_profile_normalizes_prefixed_model_name(self):
|
|
settings = Settings(
|
|
active_profile="claude-subscription",
|
|
profiles={
|
|
"claude-subscription": ProviderProfile(
|
|
label="Claude Subscription",
|
|
provider="anthropic_claude",
|
|
api_format="anthropic",
|
|
auth_source="claude_subscription",
|
|
default_model="claude-sonnet-4-6",
|
|
last_model="anthropic/claude-sonnet-4-20250514",
|
|
)
|
|
},
|
|
)
|
|
|
|
materialized = settings.materialize_active_profile()
|
|
|
|
assert materialized.model == "claude-sonnet-4-20250514"
|
|
|
|
def test_claude_profile_normalizes_dotted_model_name(self):
|
|
settings = Settings(
|
|
active_profile="claude-api",
|
|
profiles={
|
|
"claude-api": ProviderProfile(
|
|
label="Claude API",
|
|
provider="anthropic",
|
|
api_format="anthropic",
|
|
auth_source="anthropic_api_key",
|
|
default_model="claude-sonnet-4-6",
|
|
last_model="claude-opus-4.6",
|
|
)
|
|
},
|
|
)
|
|
|
|
materialized = settings.materialize_active_profile()
|
|
|
|
assert materialized.model == "claude-opus-4-6"
|
|
|
|
def test_display_model_setting_uses_default_alias(self):
|
|
profile = ProviderProfile(
|
|
label="Claude API",
|
|
provider="anthropic",
|
|
api_format="anthropic",
|
|
auth_source="anthropic_api_key",
|
|
default_model="claude-sonnet-4-6",
|
|
last_model=None,
|
|
)
|
|
|
|
assert display_model_setting(profile) == "default"
|
|
|
|
def test_opusplan_resolves_by_permission_mode(self):
|
|
settings = Settings(
|
|
permission={"mode": "plan"},
|
|
active_profile="claude-api",
|
|
profiles={
|
|
"claude-api": ProviderProfile(
|
|
label="Claude API",
|
|
provider="anthropic",
|
|
api_format="anthropic",
|
|
auth_source="anthropic_api_key",
|
|
default_model="claude-sonnet-4-6",
|
|
last_model="opusplan",
|
|
)
|
|
},
|
|
)
|
|
|
|
materialized = settings.materialize_active_profile()
|
|
|
|
assert materialized.model == "claude-opus-4-6"
|
|
|
|
def test_resolve_auth_prefers_profile_scoped_credential_for_custom_compatible_profile(self, tmp_path: Path, monkeypatch):
|
|
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-global-env")
|
|
store_credential("profile:kimi-anthropic", "api_key", "sk-profile-specific", use_keyring=False)
|
|
settings = Settings(
|
|
active_profile="kimi-anthropic",
|
|
profiles={
|
|
"kimi-anthropic": ProviderProfile(
|
|
label="Kimi Anthropic",
|
|
provider="anthropic",
|
|
api_format="anthropic",
|
|
auth_source="anthropic_api_key",
|
|
default_model="kimi-k2.5",
|
|
base_url="https://api.moonshot.cn/anthropic",
|
|
credential_slot="kimi-anthropic",
|
|
)
|
|
},
|
|
)
|
|
|
|
resolved = settings.resolve_auth()
|
|
|
|
assert resolved.value == "sk-profile-specific"
|
|
assert resolved.source == "file:profile:kimi-anthropic"
|
|
|
|
|
|
def test_normalize_anthropic_model_name_matches_hermes_behavior():
|
|
assert normalize_anthropic_model_name("anthropic/claude-sonnet-4-20250514") == "claude-sonnet-4-20250514"
|
|
assert normalize_anthropic_model_name("claude-opus-4.6") == "claude-opus-4-6"
|
|
|
|
def test_save_creates_parent_dirs(self, tmp_path: Path):
|
|
path = tmp_path / "deep" / "nested" / "settings.json"
|
|
save_settings(Settings(), path)
|
|
assert path.exists()
|
|
|
|
def test_load_with_permission_settings(self, tmp_path: Path):
|
|
path = tmp_path / "settings.json"
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"permission": {
|
|
"mode": "full_auto",
|
|
"allowed_tools": ["Bash", "Read"],
|
|
}
|
|
}
|
|
)
|
|
)
|
|
s = load_settings(path)
|
|
assert s.permission.mode == "full_auto"
|
|
assert s.permission.allowed_tools == ["Bash", "Read"]
|
|
|
|
def test_load_applies_env_overrides(self, tmp_path: Path, monkeypatch):
|
|
path = tmp_path / "settings.json"
|
|
path.write_text(json.dumps({"model": "from-file", "base_url": "https://file.example"}))
|
|
monkeypatch.setenv("ANTHROPIC_MODEL", "from-env-model")
|
|
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://env.example/anthropic")
|
|
monkeypatch.setenv("OPENHARNESS_TIMEOUT", "42.5")
|
|
monkeypatch.setenv("OPENHARNESS_MAX_TURNS", "42")
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-override")
|
|
monkeypatch.setenv("OPENHARNESS_SANDBOX_ENABLED", "true")
|
|
monkeypatch.setenv("OPENHARNESS_SANDBOX_FAIL_IF_UNAVAILABLE", "1")
|
|
|
|
s = load_settings(path)
|
|
|
|
assert s.model == "from-env-model"
|
|
assert s.base_url == "https://env.example/anthropic"
|
|
assert s.timeout == 42.5
|
|
assert s.max_turns == 42
|
|
assert s.api_key == "sk-env-override"
|
|
assert s.sandbox.enabled is True
|
|
assert s.sandbox.fail_if_unavailable is True
|
|
|
|
def test_load_with_sandbox_settings(self, tmp_path: Path):
|
|
path = tmp_path / "settings.json"
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"sandbox": {
|
|
"enabled": True,
|
|
"enabled_platforms": ["linux", "wsl"],
|
|
"network": {"allowed_domains": ["github.com"]},
|
|
"filesystem": {"allow_write": [".", "/tmp"], "deny_write": [".env"]},
|
|
}
|
|
}
|
|
)
|
|
)
|
|
|
|
s = load_settings(path)
|
|
|
|
assert s.sandbox.enabled is True
|
|
assert s.sandbox.enabled_platforms == ["linux", "wsl"]
|
|
assert s.sandbox.network.allowed_domains == ["github.com"]
|
|
assert s.sandbox.filesystem.allow_write == [".", "/tmp"]
|
|
assert s.sandbox.filesystem.deny_write == [".env"]
|
|
|
|
|
|
class TestAnsiEscapeSequences:
|
|
"""Tests for ANSI escape sequence handling in settings."""
|
|
|
|
def test_strip_ansi_escape_sequences(self):
|
|
"""Test that ANSI escape sequences are properly stripped."""
|
|
# Normal model name should pass through unchanged
|
|
assert strip_ansi_escape_sequences("claude-opus-4-6") == "claude-opus-4-6"
|
|
# Bold formatting should be stripped
|
|
assert strip_ansi_escape_sequences("\x1b[1mclaude-opus-4-6\x1b[0m") == "claude-opus-4-6"
|
|
# Green + bold formatting should be stripped
|
|
assert strip_ansi_escape_sequences("\x1b[32m\x1b[1mclaude-opus-4-6\x1b[0m") == "claude-opus-4-6"
|
|
# Only bold prefix
|
|
assert strip_ansi_escape_sequences("\x1b[1mclaude-opus-4-6") == "claude-opus-4-6"
|
|
# Only reset suffix
|
|
assert strip_ansi_escape_sequences("claude-opus-4-6\x1b[0m") == "claude-opus-4-6"
|
|
# Empty string should return empty string
|
|
assert strip_ansi_escape_sequences("") == ""
|
|
# None should return None
|
|
assert strip_ansi_escape_sequences(None) is None
|
|
|
|
def test_env_override_strips_ansi_from_model(self, monkeypatch):
|
|
"""Test that ANSI escape sequences are stripped from ANTHROPIC_MODEL env var."""
|
|
monkeypatch.setenv("ANTHROPIC_MODEL", "\x1b[1mclaude-opus-4-6\x1b[0m")
|
|
s = Settings()
|
|
updated = _apply_env_overrides(s)
|
|
assert updated.model == "claude-opus-4-6"
|
|
|
|
def test_env_override_strips_ansi_from_openharness_model(self, monkeypatch):
|
|
"""Test that ANSI escape sequences are stripped from OPENHARNESS_MODEL env var."""
|
|
monkeypatch.setenv("OPENHARNESS_MODEL", "\x1b[32mclaude-sonnet-4-6\x1b[0m")
|
|
s = Settings()
|
|
updated = _apply_env_overrides(s)
|
|
assert updated.model == "claude-sonnet-4-6"
|
|
|
|
def test_merge_cli_overrides_strips_ansi_from_model(self):
|
|
"""Test that ANSI escape sequences are stripped from CLI model override."""
|
|
s = Settings()
|
|
updated = s.merge_cli_overrides(model="\x1b[1mclaude-opus-4-6\x1b[0m")
|
|
assert updated.model == "claude-opus-4-6"
|
|
|
|
|
|
class TestMiniMaxProvider:
|
|
"""Tests for MiniMax provider profile and auth integration."""
|
|
|
|
def test_minimax_in_default_provider_profiles(self):
|
|
from openharness.config.settings import default_provider_profiles
|
|
|
|
profiles = default_provider_profiles()
|
|
assert "minimax" in profiles
|
|
profile = profiles["minimax"]
|
|
assert profile.provider == "minimax"
|
|
assert profile.api_format == "openai"
|
|
assert profile.auth_source == "minimax_api_key"
|
|
assert profile.default_model == "MiniMax-M2.7"
|
|
assert profile.base_url == "https://api.minimax.io/v1"
|
|
|
|
def test_auth_source_provider_name_minimax(self):
|
|
from openharness.config.settings import auth_source_provider_name
|
|
|
|
assert auth_source_provider_name("minimax_api_key") == "minimax"
|
|
|
|
def test_default_auth_source_for_minimax_provider(self):
|
|
from openharness.config.settings import default_auth_source_for_provider
|
|
|
|
assert default_auth_source_for_provider("minimax") == "minimax_api_key"
|
|
|
|
def test_resolve_auth_reads_minimax_api_key_env(self, monkeypatch):
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "minimax-test-key")
|
|
settings = Settings(
|
|
active_profile="minimax",
|
|
profiles={
|
|
"minimax": ProviderProfile(
|
|
label="MiniMax",
|
|
provider="minimax",
|
|
api_format="openai",
|
|
auth_source="minimax_api_key",
|
|
default_model="MiniMax-M2.7",
|
|
base_url="https://api.minimax.io/v1",
|
|
)
|
|
},
|
|
)
|
|
resolved = settings.resolve_auth()
|
|
assert resolved.value == "minimax-test-key"
|
|
assert "MINIMAX_API_KEY" in resolved.source
|
|
|
|
def test_minimax_profile_materializes_default_model(self):
|
|
settings = Settings(
|
|
active_profile="minimax",
|
|
profiles={
|
|
"minimax": ProviderProfile(
|
|
label="MiniMax",
|
|
provider="minimax",
|
|
api_format="openai",
|
|
auth_source="minimax_api_key",
|
|
default_model="MiniMax-M2.7",
|
|
base_url="https://api.minimax.io/v1",
|
|
)
|
|
},
|
|
)
|
|
materialized = settings.materialize_active_profile()
|
|
assert materialized.model == "MiniMax-M2.7"
|
|
assert materialized.provider == "minimax"
|
|
assert materialized.api_format == "openai"
|
|
|
|
|
|
class TestNvidiaProvider:
|
|
"""Tests for NVIDIA NIM provider profile and auth integration."""
|
|
|
|
def test_nvidia_in_default_provider_profiles(self):
|
|
from openharness.config.settings import default_provider_profiles
|
|
|
|
profiles = default_provider_profiles()
|
|
assert "nvidia" in profiles
|
|
profile = profiles["nvidia"]
|
|
assert profile.provider == "nvidia"
|
|
assert profile.api_format == "openai"
|
|
assert profile.auth_source == "nvidia_api_key"
|
|
assert profile.default_model == "openai/gpt-oss-120b"
|
|
assert profile.base_url == "https://integrate.api.nvidia.com/v1"
|
|
|
|
def test_auth_source_provider_name_nvidia(self):
|
|
from openharness.config.settings import auth_source_provider_name
|
|
|
|
assert auth_source_provider_name("nvidia_api_key") == "nvidia"
|
|
|
|
def test_default_auth_source_for_nvidia_provider(self):
|
|
from openharness.config.settings import default_auth_source_for_provider
|
|
|
|
assert default_auth_source_for_provider("nvidia") == "nvidia_api_key"
|
|
|
|
def test_resolve_auth_reads_nvidia_api_key_env(self, monkeypatch):
|
|
monkeypatch.setenv("NVIDIA_API_KEY", "nvidia-test-key")
|
|
settings = Settings(
|
|
active_profile="nvidia",
|
|
profiles={
|
|
"nvidia": ProviderProfile(
|
|
label="NVIDIA NIM",
|
|
provider="nvidia",
|
|
api_format="openai",
|
|
auth_source="nvidia_api_key",
|
|
default_model="openai/gpt-oss-120b",
|
|
base_url="https://integrate.api.nvidia.com/v1",
|
|
)
|
|
},
|
|
)
|
|
resolved = settings.resolve_auth()
|
|
assert resolved.value == "nvidia-test-key"
|
|
assert "NVIDIA_API_KEY" in resolved.source
|
|
|
|
def test_nvidia_profile_materializes_default_model(self):
|
|
settings = Settings(
|
|
active_profile="nvidia",
|
|
profiles={
|
|
"nvidia": ProviderProfile(
|
|
label="NVIDIA NIM",
|
|
provider="nvidia",
|
|
api_format="openai",
|
|
auth_source="nvidia_api_key",
|
|
default_model="openai/gpt-oss-120b",
|
|
base_url="https://integrate.api.nvidia.com/v1",
|
|
)
|
|
},
|
|
)
|
|
materialized = settings.materialize_active_profile()
|
|
assert materialized.model == "openai/gpt-oss-120b"
|
|
assert materialized.provider == "nvidia"
|
|
assert materialized.api_format == "openai"
|
|
|
|
|
|
class TestQwenProvider:
|
|
"""Tests for Qwen (DashScope) provider profile and auth integration."""
|
|
|
|
def test_qwen_in_default_provider_profiles(self):
|
|
from openharness.config.settings import default_provider_profiles
|
|
|
|
profiles = default_provider_profiles()
|
|
assert "qwen" in profiles
|
|
profile = profiles["qwen"]
|
|
assert profile.provider == "dashscope"
|
|
assert profile.api_format == "openai"
|
|
assert profile.auth_source == "dashscope_api_key"
|
|
assert profile.default_model == "qwen-plus"
|
|
assert profile.base_url == "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
|
|
def test_auth_source_provider_name_qwen(self):
|
|
from openharness.config.settings import auth_source_provider_name
|
|
|
|
assert auth_source_provider_name("dashscope_api_key") == "dashscope"
|
|
|
|
def test_default_auth_source_for_qwen_provider(self):
|
|
from openharness.config.settings import default_auth_source_for_provider
|
|
|
|
assert default_auth_source_for_provider("dashscope") == "dashscope_api_key"
|
|
|
|
def test_resolve_auth_reads_qwen_api_key_env(self, monkeypatch):
|
|
monkeypatch.setenv("DASHSCOPE_API_KEY", "dashscope-test-key")
|
|
settings = Settings(
|
|
active_profile="qwen",
|
|
profiles={
|
|
"qwen": ProviderProfile(
|
|
label="Qwen (DashScope)",
|
|
provider="dashscope",
|
|
api_format="openai",
|
|
auth_source="dashscope_api_key",
|
|
default_model="qwen-plus",
|
|
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
)
|
|
},
|
|
)
|
|
resolved = settings.resolve_auth()
|
|
assert resolved.value == "dashscope-test-key"
|
|
assert "DASHSCOPE_API_KEY" in resolved.source
|
|
|
|
def test_display_model_setting_for_qwen(self):
|
|
from openharness.config.settings import display_model_setting
|
|
|
|
profile = ProviderProfile(
|
|
label="Qwen (DashScope)",
|
|
provider="dashscope",
|
|
api_format="openai",
|
|
auth_source="dashscope_api_key",
|
|
default_model="qwen-plus",
|
|
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
)
|
|
assert display_model_setting(profile) == "qwen-plus"
|
|
|
|
def test_materialize_active_profile_qwen(self):
|
|
settings = Settings(
|
|
active_profile="qwen",
|
|
profiles={
|
|
"qwen": ProviderProfile(
|
|
label="Qwen (DashScope)",
|
|
provider="dashscope",
|
|
api_format="openai",
|
|
auth_source="dashscope_api_key",
|
|
default_model="qwen-plus",
|
|
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
)
|
|
},
|
|
)
|
|
materialized = settings.materialize_active_profile()
|
|
assert materialized.model == "qwen-plus"
|
|
assert materialized.provider == "dashscope"
|
|
assert materialized.api_format == "openai"
|
|
|
|
|
|
class TestModelScopeProvider:
|
|
"""Tests for ModelScope provider profile and auth integration."""
|
|
|
|
def test_modelscope_in_default_provider_profiles(self):
|
|
from openharness.config.settings import default_provider_profiles
|
|
|
|
profiles = default_provider_profiles()
|
|
assert "modelscope" in profiles
|
|
profile = profiles["modelscope"]
|
|
assert profile.provider == "modelscope"
|
|
assert profile.api_format == "openai"
|
|
assert profile.auth_source == "modelscope_api_key"
|
|
assert profile.default_model == "deepseek-ai/DeepSeek-V4-Flash"
|
|
assert profile.base_url == "https://api-inference.modelscope.cn/v1"
|
|
|
|
def test_auth_source_provider_name_modelscope(self):
|
|
from openharness.config.settings import auth_source_provider_name
|
|
|
|
assert auth_source_provider_name("modelscope_api_key") == "modelscope"
|
|
|
|
def test_default_auth_source_for_modelscope_provider(self):
|
|
from openharness.config.settings import default_auth_source_for_provider
|
|
|
|
assert default_auth_source_for_provider("modelscope") == "modelscope_api_key"
|
|
|
|
def test_resolve_auth_reads_modelscope_api_key_env(self, monkeypatch):
|
|
monkeypatch.setenv("MODELSCOPE_API_KEY", "modelscope-test-key")
|
|
settings = Settings(
|
|
active_profile="modelscope",
|
|
profiles={
|
|
"modelscope": ProviderProfile(
|
|
label="ModelScope",
|
|
provider="modelscope",
|
|
api_format="openai",
|
|
auth_source="modelscope_api_key",
|
|
default_model="deepseek-ai/DeepSeek-V4-Flash",
|
|
base_url="https://api-inference.modelscope.cn/v1",
|
|
)
|
|
},
|
|
)
|
|
resolved = settings.resolve_auth()
|
|
assert resolved.value == "modelscope-test-key"
|
|
assert "MODELSCOPE_API_KEY" in resolved.source
|
|
|
|
def test_modelscope_profile_materializes_default_model(self):
|
|
settings = Settings(
|
|
active_profile="modelscope",
|
|
profiles={
|
|
"modelscope": ProviderProfile(
|
|
label="ModelScope",
|
|
provider="modelscope",
|
|
api_format="openai",
|
|
auth_source="modelscope_api_key",
|
|
default_model="deepseek-ai/DeepSeek-V4-Flash",
|
|
base_url="https://api-inference.modelscope.cn/v1",
|
|
)
|
|
},
|
|
)
|
|
materialized = settings.materialize_active_profile()
|
|
assert materialized.model == "deepseek-ai/DeepSeek-V4-Flash"
|
|
assert materialized.provider == "modelscope"
|
|
assert materialized.api_format == "openai"
|