fix(config): resolve compact thresholds and base URL regressions

This commit is contained in:
tjb-tech
2026-04-12 15:33:43 +00:00
parent a6c63fdab9
commit 39ffc199a8
12 changed files with 262 additions and 8 deletions
+12
View File
@@ -323,6 +323,8 @@ class AuthManager:
last_model: str | None = None,
credential_slot: str | None = None,
allowed_models: list[str] | None = None,
context_window_tokens: int | None = None,
auto_compact_threshold_tokens: int | None = None,
) -> None:
"""Update a profile in-place."""
profiles = self.settings.merged_profiles()
@@ -341,6 +343,16 @@ class AuthManager:
"last_model": last_model if last_model is not None else current.last_model,
"credential_slot": credential_slot if credential_slot is not None else current.credential_slot,
"allowed_models": allowed_models if allowed_models is not None else current.allowed_models,
"context_window_tokens": (
context_window_tokens
if context_window_tokens is not None
else current.context_window_tokens
),
"auto_compact_threshold_tokens": (
auto_compact_threshold_tokens
if auto_compact_threshold_tokens is not None
else current.auto_compact_threshold_tokens
),
}
profiles[name] = current.model_copy(update=updates)
updated = self.settings.model_copy(update={"profiles": profiles})
+8
View File
@@ -1007,6 +1007,8 @@ def provider_add(
base_url: str | None = typer.Option(None, "--base-url", help="Optional base URL"),
credential_slot: str | None = typer.Option(None, "--credential-slot", help="Optional profile-specific credential slot"),
allowed_models: list[str] | None = typer.Option(None, "--allowed-model", help="Allowed model values for this profile"),
context_window_tokens: int | None = typer.Option(None, "--context-window-tokens", help="Optional context window override for auto-compact"),
auto_compact_threshold_tokens: int | None = typer.Option(None, "--auto-compact-threshold-tokens", help="Optional explicit auto-compact threshold override"),
) -> None:
"""Create a provider profile."""
from openharness.auth.manager import AuthManager
@@ -1025,6 +1027,8 @@ def provider_add(
base_url=base_url,
credential_slot=credential_slot or _default_credential_slot_for_profile(name, auth_source),
allowed_models=allowed_models or ([model] if credential_slot or _default_credential_slot_for_profile(name, auth_source) else []),
context_window_tokens=context_window_tokens,
auto_compact_threshold_tokens=auto_compact_threshold_tokens,
),
)
print(f"Saved provider profile: {name}", flush=True)
@@ -1041,6 +1045,8 @@ def provider_edit(
base_url: str | None = typer.Option(None, "--base-url", help="Optional base URL"),
credential_slot: str | None = typer.Option(None, "--credential-slot", help="Optional profile-specific credential slot"),
allowed_models: list[str] | None = typer.Option(None, "--allowed-model", help="Allowed model values for this profile"),
context_window_tokens: int | None = typer.Option(None, "--context-window-tokens", help="Optional context window override for auto-compact"),
auto_compact_threshold_tokens: int | None = typer.Option(None, "--auto-compact-threshold-tokens", help="Optional explicit auto-compact threshold override"),
) -> None:
"""Edit a provider profile."""
from openharness.auth.manager import AuthManager
@@ -1058,6 +1064,8 @@ def provider_edit(
base_url=base_url,
credential_slot=credential_slot,
allowed_models=allowed_models,
context_window_tokens=context_window_tokens,
auto_compact_threshold_tokens=auto_compact_threshold_tokens,
)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
+39 -1
View File
@@ -61,6 +61,8 @@ class MemorySettings(BaseModel):
enabled: bool = True
max_files: int = 5
max_entrypoint_lines: int = 200
context_window_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None
class SandboxNetworkSettings(BaseModel):
@@ -114,6 +116,8 @@ class ProviderProfile(BaseModel):
last_model: str | None = None
credential_slot: str | None = None
allowed_models: list[str] = Field(default_factory=list)
context_window_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None
@property
def resolved_model(self) -> str:
@@ -429,6 +433,8 @@ class Settings(BaseModel):
max_tokens: int = 16384
base_url: str | None = None
timeout: float = 30.0
context_window_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None
api_format: str = "anthropic" # "anthropic", "openai", or "copilot"
provider: str = ""
active_profile: str = "claude-api"
@@ -490,6 +496,8 @@ class Settings(BaseModel):
"provider": profile.provider,
"api_format": profile.api_format,
"base_url": profile.base_url,
"context_window_tokens": profile.context_window_tokens,
"auto_compact_threshold_tokens": profile.auto_compact_threshold_tokens,
"model": resolve_model_setting(
configured_model,
profile.provider,
@@ -510,6 +518,16 @@ class Settings(BaseModel):
next_provider = (self.provider or "").strip() or profile.provider
next_api_format = (self.api_format or "").strip() or profile.api_format
next_base_url = self.base_url if self.base_url is not None else profile.base_url
next_context_window_tokens = (
self.context_window_tokens
if self.context_window_tokens is not None
else profile.context_window_tokens
)
next_auto_compact_threshold_tokens = (
self.auto_compact_threshold_tokens
if self.auto_compact_threshold_tokens is not None
else profile.auto_compact_threshold_tokens
)
flat_model = (self.model or "").strip()
resolved_profile_model = resolve_model_setting(
(profile.last_model or "").strip() or profile.default_model,
@@ -533,6 +551,8 @@ class Settings(BaseModel):
"base_url": next_base_url,
"auth_source": next_auth_source,
"last_model": next_model,
"context_window_tokens": next_context_window_tokens,
"auto_compact_threshold_tokens": next_auto_compact_threshold_tokens,
}
)
profiles = self.merged_profiles()
@@ -698,7 +718,17 @@ class Settings(BaseModel):
merged = self.model_copy(update=updates)
if not updates:
return merged
profile_keys = {"model", "base_url", "api_format", "provider", "api_key", "active_profile", "profiles"}
profile_keys = {
"model",
"base_url",
"api_format",
"provider",
"api_key",
"active_profile",
"profiles",
"context_window_tokens",
"auto_compact_threshold_tokens",
}
profile_updates = profile_keys.intersection(updates)
if not profile_updates:
return merged
@@ -736,6 +766,14 @@ def _apply_env_overrides(settings: Settings) -> Settings:
if max_turns:
updates["max_turns"] = int(max_turns)
context_window_tokens = os.environ.get("OPENHARNESS_CONTEXT_WINDOW_TOKENS")
if context_window_tokens:
updates["context_window_tokens"] = int(context_window_tokens)
auto_compact_threshold_tokens = os.environ.get("OPENHARNESS_AUTO_COMPACT_THRESHOLD_TOKENS")
if auto_compact_threshold_tokens:
updates["auto_compact_threshold_tokens"] = int(auto_compact_threshold_tokens)
api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY")
if api_key:
updates["api_key"] = api_key
+4
View File
@@ -86,6 +86,8 @@ class QueryContext:
model: str
system_prompt: str
max_tokens: int
context_window_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None
permission_prompt: PermissionPrompt | None = None
ask_user_prompt: AskUserPrompt | None = None
max_turns: int | None = 200
@@ -435,6 +437,8 @@ async def run_query(
trigger=trigger,
hook_executor=context.hook_executor,
carryover_metadata=context.tool_metadata,
context_window_tokens=context.context_window_tokens,
auto_compact_threshold_tokens=context.auto_compact_threshold_tokens,
)
)
while True:
+8
View File
@@ -29,6 +29,8 @@ class QueryEngine:
model: str,
system_prompt: str,
max_tokens: int = 4096,
context_window_tokens: int | None = None,
auto_compact_threshold_tokens: int | None = None,
max_turns: int | None = 8,
permission_prompt: PermissionPrompt | None = None,
ask_user_prompt: AskUserPrompt | None = None,
@@ -42,6 +44,8 @@ class QueryEngine:
self._model = model
self._system_prompt = system_prompt
self._max_tokens = max_tokens
self._context_window_tokens = context_window_tokens
self._auto_compact_threshold_tokens = auto_compact_threshold_tokens
self._max_turns = max_turns
self._permission_prompt = permission_prompt
self._ask_user_prompt = ask_user_prompt
@@ -158,6 +162,8 @@ class QueryEngine:
model=self._model,
system_prompt=self._system_prompt,
max_tokens=self._max_tokens,
context_window_tokens=self._context_window_tokens,
auto_compact_threshold_tokens=self._auto_compact_threshold_tokens,
max_turns=self._max_turns,
permission_prompt=self._permission_prompt,
ask_user_prompt=self._ask_user_prompt,
@@ -185,6 +191,8 @@ class QueryEngine:
model=self._model,
system_prompt=self._system_prompt,
max_tokens=self._max_tokens,
context_window_tokens=self._context_window_tokens,
auto_compact_threshold_tokens=self._auto_compact_threshold_tokens,
max_turns=max_turns if max_turns is not None else self._max_turns,
permission_prompt=self._permission_prompt,
ask_user_prompt=self._ask_user_prompt,
+43 -7
View File
@@ -918,8 +918,10 @@ class AutoCompactState:
# Context window helpers
# ---------------------------------------------------------------------------
def get_context_window(model: str) -> int:
def get_context_window(model: str, *, context_window_tokens: int | None = None) -> int:
"""Return the context window size for a model (conservative defaults)."""
if context_window_tokens is not None and context_window_tokens > 0:
return int(context_window_tokens)
m = model.lower()
if "opus" in m:
return 200_000
@@ -931,9 +933,16 @@ def get_context_window(model: str) -> int:
return _DEFAULT_CONTEXT_WINDOW
def get_autocompact_threshold(model: str) -> int:
def get_autocompact_threshold(
model: str,
*,
context_window_tokens: int | None = None,
auto_compact_threshold_tokens: int | None = None,
) -> int:
"""Calculate the token count at which auto-compact fires."""
context_window = get_context_window(model)
if auto_compact_threshold_tokens is not None and auto_compact_threshold_tokens > 0:
return int(auto_compact_threshold_tokens)
context_window = get_context_window(model, context_window_tokens=context_window_tokens)
reserved = min(MAX_OUTPUT_TOKENS_FOR_SUMMARY, 20_000)
effective = context_window - reserved
return effective - AUTOCOMPACT_BUFFER_TOKENS
@@ -943,12 +952,19 @@ def should_autocompact(
messages: list[ConversationMessage],
model: str,
state: AutoCompactState,
*,
context_window_tokens: int | None = None,
auto_compact_threshold_tokens: int | None = None,
) -> bool:
"""Return True when the conversation should be auto-compacted."""
if state.consecutive_failures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES:
return False
token_count = estimate_message_tokens(messages)
threshold = get_autocompact_threshold(model)
threshold = get_autocompact_threshold(
model,
context_window_tokens=context_window_tokens,
auto_compact_threshold_tokens=auto_compact_threshold_tokens,
)
return token_count >= threshold
@@ -1330,6 +1346,8 @@ async def auto_compact_if_needed(
trigger: CompactTrigger = "auto",
hook_executor: HookExecutor | None = None,
carryover_metadata: dict[str, Any] | None = None,
context_window_tokens: int | None = None,
auto_compact_threshold_tokens: int | None = None,
) -> tuple[list[ConversationMessage], bool]:
"""Check if auto-compact should fire, and if so, compact.
@@ -1338,7 +1356,13 @@ async def auto_compact_if_needed(
Returns:
(messages, was_compacted) — if compacted, messages is the new list.
"""
if not force and not should_autocompact(messages, model, state):
if not force and not should_autocompact(
messages,
model,
state,
context_window_tokens=context_window_tokens,
auto_compact_threshold_tokens=auto_compact_threshold_tokens,
):
return messages, False
log.info("Auto-compact triggered (failures=%d)", state.consecutive_failures)
@@ -1361,7 +1385,13 @@ async def auto_compact_if_needed(
token_count=estimate_message_tokens(messages),
details={"tokens_freed": tokens_freed},
)
if tokens_freed > 0 and not should_autocompact(messages, model, state):
if tokens_freed > 0 and not should_autocompact(
messages,
model,
state,
context_window_tokens=context_window_tokens,
auto_compact_threshold_tokens=auto_compact_threshold_tokens,
):
log.info("Microcompact freed ~%d tokens, auto-compact no longer needed", tokens_freed)
return messages, True
@@ -1396,7 +1426,13 @@ async def auto_compact_if_needed(
token_count=estimate_message_tokens(messages),
),
)
if not force and not should_autocompact(messages, model, state):
if not force and not should_autocompact(
messages,
model,
state,
context_window_tokens=context_window_tokens,
auto_compact_threshold_tokens=auto_compact_threshold_tokens,
):
return messages, True
session_memory = try_session_memory_compaction(
View File
+5
View File
@@ -281,6 +281,11 @@ async def build_runtime(
model=settings.model,
system_prompt=system_prompt_text,
max_tokens=settings.max_tokens,
context_window_tokens=settings.context_window_tokens or settings.memory.context_window_tokens,
auto_compact_threshold_tokens=(
settings.auto_compact_threshold_tokens
or settings.memory.auto_compact_threshold_tokens
),
max_turns=engine_max_turns,
permission_prompt=permission_prompt,
ask_user_prompt=ask_user_prompt,
+39
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import json
import httpx
import pytest
from openharness.api.client import ApiMessageRequest
@@ -248,6 +250,43 @@ class _FakeOpenAIClient:
self.chat = _FakeChat()
@pytest.mark.asyncio
async def test_openai_client_uses_full_base_url_path_for_requests():
seen_urls: list[str] = []
def _handler(request: httpx.Request) -> httpx.Response:
seen_urls.append(str(request.url))
return httpx.Response(
200,
json={
"id": "x",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o-mini",
"choices": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
transport = httpx.MockTransport(_handler)
http_client = httpx.AsyncClient(transport=transport)
client = OpenAICompatibleClient(
api_key="test-key",
base_url="https://jarodfund.xyz/openai/v1",
)
client._client._client = http_client
request = ApiMessageRequest(
model="gpt-4o-mini",
messages=[ConversationMessage.from_user_text("Explain the codebase")],
)
events = [event async for event in client.stream_message(request)]
assert events
assert seen_urls == ["https://jarodfund.xyz/openai/v1/chat/completions"]
await http_client.aclose()
def test_openai_client_init_normalizes_base_url(monkeypatch):
captured: dict[str, object] = {}
+67
View File
@@ -103,6 +103,15 @@ class TestSettings:
s = load_settings(path)
assert s.base_url == "https://relay.example.com/v1"
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)
@@ -201,6 +210,27 @@ class TestLoadSaveSettings:
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",
@@ -239,6 +269,43 @@ class TestLoadSaveSettings:
assert profile.provider == "openai_codex"
assert profile.auth_source == "codex_subscription"
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",
@@ -0,0 +1,16 @@
from __future__ import annotations
import importlib
import sys
from pathlib import Path
def test_openharness_package_layout_does_not_shadow_stdlib_types(monkeypatch):
shadow_path = str((Path(__file__).resolve().parents[2] / "src" / "openharness"))
monkeypatch.syspath_prepend(shadow_path)
sys.modules.pop("types", None)
module = importlib.import_module("types")
assert getattr(module, "__file__", "").endswith("types.py")
assert "site-packages" not in getattr(module, "__file__", "")
+21
View File
@@ -22,6 +22,8 @@ from openharness.services import (
from openharness.services.compact import (
AutoCompactState,
auto_compact_if_needed,
get_autocompact_threshold,
should_autocompact,
try_context_collapse,
try_session_memory_compaction,
)
@@ -326,3 +328,22 @@ async def test_auto_compact_if_needed_returns_original_messages_after_timeout(mo
assert was_compacted is False
assert result == messages
def test_get_autocompact_threshold_respects_manual_override():
assert get_autocompact_threshold(
"claude-sonnet-4-6",
auto_compact_threshold_tokens=12345,
) == 12345
def test_should_autocompact_uses_custom_context_window():
messages = [
ConversationMessage(role="user", content=[TextBlock(text="alpha " * 6000)]),
]
assert should_autocompact(
messages,
"claude-sonnet-4-6",
AutoCompactState(),
context_window_tokens=4000,
) is True