feat(auth): add provider profiles and subscription clients

This commit is contained in:
tjb-tech
2026-04-06 08:47:10 +00:00
parent 0f7862f4f0
commit cd52191f6e
32 changed files with 3917 additions and 185 deletions
+29 -29
View File
@@ -27,11 +27,20 @@ const scriptedSteps = (() => {
}
})();
const PERMISSION_MODES: SelectOption[] = [
{value: 'default', label: 'Default', description: 'Ask before write/execute operations'},
{value: 'full_auto', label: 'Auto', description: 'Allow all tools automatically'},
{value: 'plan', label: 'Plan Mode', description: 'Block all write operations'},
];
const SELECTABLE_COMMANDS = new Set([
'/provider',
'/model',
'/theme',
'/output-style',
'/permissions',
'/resume',
'/effort',
'/passes',
'/turns',
'/fast',
'/vim',
'/voice',
]);
type SelectModalState = {
title: string;
@@ -61,6 +70,13 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
const [selectIndex, setSelectIndex] = useState(0);
const session = useBackendSession(config, () => exit());
useEffect(() => {
const nextTheme = session.status.theme;
if (typeof nextTheme === 'string' && nextTheme) {
setThemeName(nextTheme);
}
}, [session.status.theme, setThemeName]);
// Current tool name for spinner
const currentToolName = useMemo(() => {
for (let i = session.transcript.length - 1; i >= 0; i--) {
@@ -100,12 +116,13 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
session.setSelectRequest(null);
return;
}
setSelectIndex(0);
const initialIndex = req.options.findIndex((option) => option.active);
setSelectIndex(initialIndex >= 0 ? initialIndex : 0);
setSelectModal({
title: req.title,
options: req.options.map((o) => ({value: o.value, label: o.label, description: o.description})),
options: req.options.map((o) => ({value: o.value, label: o.label, description: o.description, active: o.active})),
onSelect: (value) => {
session.sendRequest({type: 'submit_line', line: `${req.submitPrefix}${value}`});
session.sendRequest({type: 'apply_select_command', command: req.command, value});
session.setBusy(true);
setSelectModal(null);
},
@@ -117,31 +134,14 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
const handleCommand = (cmd: string): boolean => {
const trimmed = cmd.trim();
// /theme set <name> → switch theme locally
const themeMatch = /^\/theme\s+set\s+(\S+)$/.exec(trimmed);
if (themeMatch) {
setThemeName(themeMatch[1]);
if (SELECTABLE_COMMANDS.has(trimmed)) {
session.sendRequest({type: 'select_command', command: trimmed.slice(1)});
return true;
}
// /permissions → show mode picker
if (trimmed === '/permissions' || trimmed === '/permissions show') {
const currentMode = String(session.status.permission_mode ?? 'default');
const options = PERMISSION_MODES.map((opt) => ({
...opt,
active: opt.value === currentMode,
}));
const initialIndex = options.findIndex((o) => o.active);
setSelectIndex(initialIndex >= 0 ? initialIndex : 0);
setSelectModal({
title: 'Permission Mode',
options,
onSelect: (value) => {
session.sendRequest({type: 'submit_line', line: `/permissions set ${value}`});
session.setBusy(true);
setSelectModal(null);
},
});
session.sendRequest({type: 'select_command', command: 'permissions'});
return true;
}
@@ -159,7 +159,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
// /resume → request session list from backend (will trigger select_request)
if (trimmed === '/resume') {
session.sendRequest({type: 'list_sessions'});
session.sendRequest({type: 'select_command', command: 'resume'});
return true;
}
@@ -27,7 +27,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
const [mcpServers, setMcpServers] = useState<McpServerSnapshot[]>([]);
const [bridgeSessions, setBridgeSessions] = useState<BridgeSessionSnapshot[]>([]);
const [modal, setModal] = useState<Record<string, unknown> | null>(null);
const [selectRequest, setSelectRequest] = useState<{title: string; submitPrefix: string; options: SelectOptionPayload[]} | null>(null);
const [selectRequest, setSelectRequest] = useState<{title: string; command: string; options: SelectOptionPayload[]} | null>(null);
const [busy, setBusy] = useState(false);
const [ready, setReady] = useState(false);
const [todoMarkdown, setTodoMarkdown] = useState('');
@@ -210,7 +210,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
const m = event.modal ?? {};
setSelectRequest({
title: String(m.title ?? 'Select'),
submitPrefix: String(m.submit_prefix ?? ''),
command: String(m.command ?? ''),
options: event.select_options ?? [],
});
return;
+1
View File
@@ -43,6 +43,7 @@ export type SelectOptionPayload = {
value: string;
label: string;
description?: string;
active?: boolean;
};
export type TodoItemSnapshot = {
+2
View File
@@ -1,6 +1,7 @@
"""API exports."""
from openharness.api.client import AnthropicApiClient
from openharness.api.codex_client import CodexApiClient
from openharness.api.copilot_client import CopilotClient
from openharness.api.errors import OpenHarnessApiError
from openharness.api.openai_client import OpenAICompatibleClient
@@ -9,6 +10,7 @@ from openharness.api.usage import UsageSnapshot
__all__ = [
"AnthropicApiClient",
"CodexApiClient",
"CopilotClient",
"OpenAICompatibleClient",
"OpenHarnessApiError",
+89 -8
View File
@@ -3,9 +3,11 @@
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Protocol
from typing import Any, AsyncIterator, Callable, Protocol
from anthropic import APIError, APIStatusError, AsyncAnthropic
@@ -15,6 +17,12 @@ from openharness.api.errors import (
RateLimitFailure,
RequestFailure,
)
from openharness.auth.external import (
claude_attribution_header,
claude_oauth_betas,
claude_oauth_headers,
get_claude_code_session_id,
)
from openharness.api.usage import UsageSnapshot
from openharness.engine.messages import ConversationMessage, assistant_message_from_api
@@ -25,6 +33,7 @@ MAX_RETRIES = 3
BASE_DELAY = 1.0 # seconds
MAX_DELAY = 30.0
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 529}
OAUTH_BETA_HEADER = "oauth-2025-04-20"
@dataclass(frozen=True)
@@ -54,7 +63,17 @@ class ApiMessageCompleteEvent:
stop_reason: str | None = None
ApiStreamEvent = ApiTextDeltaEvent | ApiMessageCompleteEvent
@dataclass(frozen=True)
class ApiRetryEvent:
"""A recoverable upstream failure that will be retried automatically."""
message: str
attempt: int
max_attempts: int
delay_seconds: float
ApiStreamEvent = ApiTextDeltaEvent | ApiMessageCompleteEvent | ApiRetryEvent
class SupportsStreamingMessages(Protocol):
@@ -98,11 +117,45 @@ def _get_retry_delay(attempt: int, exc: Exception | None = None) -> float:
class AnthropicApiClient:
"""Thin wrapper around the Anthropic async SDK with retry logic."""
def __init__(self, api_key: str, *, base_url: str | None = None) -> None:
kwargs: dict[str, Any] = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
self._client = AsyncAnthropic(**kwargs)
def __init__(
self,
api_key: str | None = None,
*,
auth_token: str | None = None,
base_url: str | None = None,
claude_oauth: bool = False,
auth_token_resolver: Callable[[], str] | None = None,
) -> None:
self._api_key = api_key
self._auth_token = auth_token
self._base_url = base_url
self._claude_oauth = claude_oauth
self._auth_token_resolver = auth_token_resolver
self._session_id = get_claude_code_session_id() if claude_oauth else ""
self._client = self._create_client()
def _create_client(self) -> AsyncAnthropic:
kwargs: dict[str, Any] = {}
if self._api_key:
kwargs["api_key"] = self._api_key
if self._auth_token:
kwargs["auth_token"] = self._auth_token
kwargs["default_headers"] = (
claude_oauth_headers()
if self._claude_oauth
else {"anthropic-beta": OAUTH_BETA_HEADER}
)
if self._base_url:
kwargs["base_url"] = self._base_url
return AsyncAnthropic(**kwargs)
def _refresh_client_auth(self) -> None:
if not self._claude_oauth or self._auth_token_resolver is None:
return
next_token = self._auth_token_resolver()
if next_token and next_token != self._auth_token:
self._auth_token = next_token
self._client = self._create_client()
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
"""Yield text deltas and the final assistant message with retry on transient errors."""
@@ -110,6 +163,7 @@ class AnthropicApiClient:
for attempt in range(MAX_RETRIES + 1):
try:
self._refresh_client_auth()
async for event in self._stream_once(request):
yield event
return # Success
@@ -128,6 +182,12 @@ class AnthropicApiClient:
"API request failed (attempt %d/%d, status=%s), retrying in %.1fs: %s",
attempt + 1, MAX_RETRIES + 1, status, delay, exc,
)
yield ApiRetryEvent(
message=str(exc),
attempt=attempt + 1,
max_attempts=MAX_RETRIES + 1,
delay_seconds=delay,
)
await asyncio.sleep(delay)
if last_error is not None:
@@ -144,11 +204,32 @@ class AnthropicApiClient:
}
if request.system_prompt:
params["system"] = request.system_prompt
if self._claude_oauth:
attribution = claude_attribution_header()
params["system"] = (
f"{attribution}\n{params['system']}"
if params.get("system")
else attribution
)
if request.tools:
params["tools"] = request.tools
if self._claude_oauth:
params["betas"] = claude_oauth_betas()
params["metadata"] = {
"user_id": json.dumps(
{
"device_id": "openharness",
"session_id": self._session_id,
"account_uuid": "",
},
separators=(",", ":"),
)
}
params["extra_headers"] = {"x-client-request-id": str(uuid.uuid4())}
try:
async with self._client.messages.stream(**params) as stream:
stream_api = self._client.beta.messages if self._claude_oauth else self._client.messages
async with stream_api.stream(**params) as stream:
async for event in stream:
if getattr(event, "type", None) != "content_block_delta":
continue
+358
View File
@@ -0,0 +1,358 @@
"""OpenAI Codex subscription client backed by chatgpt.com Codex Responses."""
from __future__ import annotations
import base64
import json
import platform
from typing import Any, AsyncIterator
import httpx
from openharness.api.client import (
ApiMessageCompleteEvent,
ApiMessageRequest,
ApiRetryEvent,
ApiStreamEvent,
ApiTextDeltaEvent,
)
from openharness.api.errors import AuthenticationFailure, OpenHarnessApiError, RateLimitFailure, RequestFailure
from openharness.api.usage import UsageSnapshot
from openharness.engine.messages import ConversationMessage, TextBlock, ToolResultBlock, ToolUseBlock
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"
JWT_CLAIM_PATH = "https://api.openai.com/auth"
MAX_RETRIES = 3
BASE_DELAY_SECONDS = 1.0
MAX_DELAY_SECONDS = 30.0
def _extract_account_id(token: str) -> str:
parts = token.split(".")
if len(parts) != 3:
raise AuthenticationFailure("Codex access token is not a valid JWT.")
try:
payload = json.loads(
base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4)).decode("utf-8")
)
except Exception as exc:
raise AuthenticationFailure("Could not decode Codex access token payload.") from exc
auth_claim = payload.get(JWT_CLAIM_PATH)
if not isinstance(auth_claim, dict):
raise AuthenticationFailure("Codex access token is missing account metadata.")
account_id = auth_claim.get("chatgpt_account_id")
if not isinstance(account_id, str) or not account_id:
raise AuthenticationFailure("Codex access token is missing chatgpt_account_id.")
return account_id
def _resolve_codex_url(base_url: str | None) -> str:
trimmed = (base_url or "").strip()
if trimmed and "chatgpt.com/backend-api" not in trimmed:
trimmed = ""
raw = (trimmed or DEFAULT_CODEX_BASE_URL).rstrip("/")
if raw.endswith("/codex/responses"):
return raw
if raw.endswith("/codex"):
return f"{raw}/responses"
return f"{raw}/codex/responses"
def _build_codex_headers(token: str, *, session_id: str | None = None) -> dict[str, str]:
account_id = _extract_account_id(token)
headers = {
"Authorization": f"Bearer {token}",
"chatgpt-account-id": account_id,
"originator": "openharness",
"User-Agent": f"openharness ({platform.system().lower()} {platform.machine() or 'unknown'})",
"OpenAI-Beta": "responses=experimental",
"accept": "text/event-stream",
"content-type": "application/json",
}
if session_id:
headers["session_id"] = session_id
return headers
def _convert_messages_to_codex(messages: list[ConversationMessage]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for msg in messages:
if msg.role == "user":
text = "".join(block.text for block in msg.content if isinstance(block, TextBlock))
if text.strip():
result.append({
"role": "user",
"content": [{"type": "input_text", "text": text}],
})
for block in msg.content:
if isinstance(block, ToolResultBlock):
result.append({
"type": "function_call_output",
"call_id": block.tool_use_id,
"output": block.content,
})
continue
assistant_text = "".join(block.text for block in msg.content if isinstance(block, TextBlock))
if assistant_text:
result.append({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": assistant_text, "annotations": []}],
})
for block in msg.content:
if isinstance(block, ToolUseBlock):
result.append({
"type": "function_call",
"id": f"fc_{block.id[:58]}",
"call_id": block.id,
"name": block.name,
"arguments": json.dumps(block.input, separators=(",", ":")),
})
return result
def _convert_tools_to_codex(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"type": "function",
"name": tool["name"],
"description": tool.get("description", ""),
"parameters": tool.get("input_schema", {}),
}
for tool in tools
]
def _usage_from_response(response: dict[str, Any]) -> UsageSnapshot:
usage = response.get("usage")
if not isinstance(usage, dict):
return UsageSnapshot()
return UsageSnapshot(
input_tokens=int(usage.get("input_tokens") or 0),
output_tokens=int(usage.get("output_tokens") or 0),
)
def _stop_reason_from_response(response: dict[str, Any], *, has_tool_calls: bool) -> str | None:
status = response.get("status")
if has_tool_calls and status == "completed":
return "tool_use"
if status == "completed":
return "stop"
if status == "incomplete":
return "length"
if status in {"failed", "cancelled"}:
return "error"
return None
def _format_error_message(status_code: int, payload: str) -> str:
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, dict):
error = parsed.get("error")
if isinstance(error, dict):
message = error.get("message")
if isinstance(message, str) and message.strip():
return message
detail = parsed.get("detail")
if isinstance(detail, str) and detail.strip():
return detail
text = payload.strip()
if text:
return text
return f"Codex request failed with status {status_code}"
def _translate_status_error(status_code: int, message: str) -> OpenHarnessApiError:
if status_code in {401, 403}:
return AuthenticationFailure(message)
if status_code == 429:
return RateLimitFailure(message)
return RequestFailure(message)
class CodexApiClient:
"""Client for ChatGPT/Codex subscription-backed Codex Responses."""
def __init__(self, auth_token: str, *, base_url: str | None = None) -> None:
self._auth_token = auth_token
self._base_url = base_url
self._url = _resolve_codex_url(base_url)
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
last_error: Exception | None = None
for attempt in range(MAX_RETRIES + 1):
try:
async for event in self._stream_once(request):
yield event
return
except Exception as exc:
last_error = exc
if attempt >= MAX_RETRIES or not self._is_retryable(exc):
raise self._translate_error(exc) from exc
delay = min(BASE_DELAY_SECONDS * (2 ** attempt), MAX_DELAY_SECONDS)
import asyncio
yield ApiRetryEvent(
message=str(exc),
attempt=attempt + 1,
max_attempts=MAX_RETRIES + 1,
delay_seconds=delay,
)
await asyncio.sleep(delay)
if last_error is not None:
raise self._translate_error(last_error) from last_error
async def _stream_once(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
body: dict[str, Any] = {
"model": request.model,
"store": False,
"stream": True,
"instructions": request.system_prompt or "You are OpenHarness.",
"input": _convert_messages_to_codex(request.messages),
"text": {"verbosity": "medium"},
"include": ["reasoning.encrypted_content"],
"tool_choice": "auto",
"parallel_tool_calls": True,
}
if request.tools:
body["tools"] = _convert_tools_to_codex(request.tools)
content: list[TextBlock | ToolUseBlock] = []
current_text_parts: list[str] = []
completed_response: dict[str, Any] | None = None
headers = _build_codex_headers(self._auth_token)
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
async with client.stream("POST", self._url, headers=headers, json=body) as response:
if response.status_code >= 400:
payload = await response.aread()
message = _format_error_message(response.status_code, payload.decode("utf-8", "replace"))
raise httpx.HTTPStatusError(message, request=response.request, response=response)
async for event in self._iter_sse_events(response):
event_type = event.get("type")
if event_type == "response.output_text.delta":
delta = event.get("delta")
if isinstance(delta, str) and delta:
current_text_parts.append(delta)
yield ApiTextDeltaEvent(text=delta)
elif event_type == "response.output_item.done":
item = event.get("item")
if not isinstance(item, dict):
continue
item_type = item.get("type")
if item_type == "message":
text = ""
raw_content = item.get("content")
if isinstance(raw_content, list):
parts = []
for block in raw_content:
if isinstance(block, dict):
if block.get("type") == "output_text":
parts.append(str(block.get("text", "")))
elif block.get("type") == "refusal":
parts.append(str(block.get("refusal", "")))
text = "".join(parts)
if text:
content.append(TextBlock(text=text))
elif item_type == "function_call":
arguments = item.get("arguments")
parsed_arguments: dict[str, Any]
if isinstance(arguments, str) and arguments:
try:
loaded = json.loads(arguments)
except json.JSONDecodeError:
loaded = {}
else:
loaded = {}
parsed_arguments = loaded if isinstance(loaded, dict) else {}
call_id = item.get("call_id")
name = item.get("name")
if isinstance(call_id, str) and call_id and isinstance(name, str) and name:
content.append(ToolUseBlock(id=call_id, name=name, input=parsed_arguments))
elif event_type == "response.completed":
response_payload = event.get("response")
if isinstance(response_payload, dict):
completed_response = response_payload
elif event_type == "response.failed":
response_payload = event.get("response")
if isinstance(response_payload, dict):
error = response_payload.get("error")
if isinstance(error, dict):
message = str(error.get("message") or error.get("code") or "Codex response failed")
raise RequestFailure(message)
raise RequestFailure("Codex response failed")
elif event_type == "error":
message = str(event.get("message") or event.get("code") or "Codex error")
raise RequestFailure(message)
if current_text_parts and not any(isinstance(block, TextBlock) for block in content):
content.insert(0, TextBlock(text="".join(current_text_parts)))
final_message = ConversationMessage(role="assistant", content=content)
usage = _usage_from_response(completed_response or {})
stop_reason = _stop_reason_from_response(
completed_response or {},
has_tool_calls=bool(final_message.tool_uses),
)
yield ApiMessageCompleteEvent(
message=final_message,
usage=usage,
stop_reason=stop_reason,
)
async def _iter_sse_events(self, response: httpx.Response) -> AsyncIterator[dict[str, Any]]:
data_lines: list[str] = []
async for line in response.aiter_lines():
if line == "":
if data_lines:
payload = "\n".join(data_lines).strip()
data_lines = []
if payload and payload != "[DONE]":
try:
event = json.loads(payload)
except json.JSONDecodeError:
continue
if isinstance(event, dict):
yield event
continue
if line.startswith("data:"):
data_lines.append(line[5:].strip())
if data_lines:
payload = "\n".join(data_lines).strip()
if payload and payload != "[DONE]":
try:
event = json.loads(payload)
except json.JSONDecodeError:
return
if isinstance(event, dict):
yield event
@staticmethod
def _is_retryable(exc: Exception) -> bool:
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in {429, 500, 502, 503, 504}
if isinstance(exc, RateLimitFailure):
return True
if isinstance(exc, RequestFailure):
message = str(exc).lower()
return any(term in message for term in ["timeout", "connect", "network", "rate", "overloaded"])
if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError)):
return True
return False
@staticmethod
def _translate_error(exc: Exception) -> OpenHarnessApiError:
if isinstance(exc, OpenHarnessApiError):
return exc
if isinstance(exc, httpx.HTTPStatusError):
status = exc.response.status_code
return _translate_status_error(status, str(exc))
if isinstance(exc, httpx.HTTPError):
return RequestFailure(str(exc))
return RequestFailure(str(exc))
+7
View File
@@ -12,6 +12,7 @@ from openai import AsyncOpenAI
from openharness.api.client import (
ApiMessageCompleteEvent,
ApiMessageRequest,
ApiRetryEvent,
ApiStreamEvent,
ApiTextDeltaEvent,
)
@@ -201,6 +202,12 @@ class OpenAICompatibleClient:
"OpenAI API request failed (attempt %d/%d), retrying in %.1fs: %s",
attempt + 1, MAX_RETRIES + 1, delay, exc,
)
yield ApiRetryEvent(
message=str(exc),
attempt=attempt + 1,
max_attempts=MAX_RETRIES + 1,
delay_seconds=delay,
)
await asyncio.sleep(delay)
if last_error is not None:
+39 -3
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from dataclasses import dataclass
from openharness.auth.external import describe_external_binding
from openharness.auth.storage import load_external_binding
from openharness.api.registry import detect_provider_from_registry
from openharness.config.settings import Settings
@@ -11,6 +13,8 @@ _AUTH_KIND: dict[str, str] = {
"anthropic": "api_key",
"openai_compat": "api_key",
"copilot": "oauth_device",
"openai_codex": "external_oauth",
"anthropic_claude": "external_oauth",
}
_VOICE_REASON: dict[str, str] = {
@@ -19,6 +23,8 @@ _VOICE_REASON: dict[str, str] = {
),
"openai_compat": "voice mode is not wired for OpenAI-compatible providers in this build",
"copilot": "voice mode is not supported for GitHub Copilot",
"openai_codex": "voice mode is not supported for Codex subscription auth",
"anthropic_claude": "voice mode is not supported for Claude subscription auth",
}
@@ -34,6 +40,20 @@ class ProviderInfo:
def detect_provider(settings: Settings) -> ProviderInfo:
"""Infer the active provider and rough capability set using the registry."""
if settings.provider == "openai_codex":
return ProviderInfo(
name="openai-codex",
auth_kind="external_oauth",
voice_supported=False,
voice_reason=_VOICE_REASON["openai_codex"],
)
if settings.provider == "anthropic_claude":
return ProviderInfo(
name="claude-subscription",
auth_kind="external_oauth",
voice_supported=False,
voice_reason=_VOICE_REASON["anthropic_claude"],
)
if settings.api_format == "copilot":
return ProviderInfo(
name="github_copilot",
@@ -84,6 +104,22 @@ def auth_status(settings: Settings) -> str:
if auth_info.enterprise_url:
return f"configured (enterprise: {auth_info.enterprise_url})"
return "configured"
if settings.api_key:
return "configured"
return "missing"
try:
resolved = settings.resolve_auth()
except ValueError as exc:
if settings.provider == "openai_codex":
return "missing (run 'oh auth codex-login')"
if settings.provider == "anthropic_claude":
binding = load_external_binding("anthropic_claude")
if binding is not None:
external_state = describe_external_binding(binding)
if external_state.state != "missing":
return external_state.state
message = str(exc)
if "third-party" in message:
return "invalid base_url"
return "missing (run 'oh auth claude-login')"
return "missing"
if resolved.source.startswith("external:"):
return f"configured ({resolved.source.removeprefix('external:')})"
return "configured"
+4
View File
@@ -6,7 +6,9 @@ from openharness.auth.storage import (
clear_provider_credentials,
decrypt,
encrypt,
load_external_binding,
load_credential,
store_external_binding,
store_credential,
)
@@ -17,6 +19,8 @@ __all__ = [
"DeviceCodeFlow",
"store_credential",
"load_credential",
"store_external_binding",
"load_external_binding",
"clear_provider_credentials",
"encrypt",
"decrypt",
+432
View File
@@ -0,0 +1,432 @@
"""Integration with external CLI-managed subscription credentials."""
from __future__ import annotations
import base64
import json
import os
import subprocess
import time
import urllib.parse
import urllib.request
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from openharness.auth.storage import ExternalAuthBinding
CODEX_PROVIDER = "openai_codex"
CLAUDE_PROVIDER = "anthropic_claude"
CLAUDE_CODE_VERSION_FALLBACK = "2.1.92"
CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
CLAUDE_OAUTH_TOKEN_ENDPOINTS = (
"https://platform.claude.com/v1/oauth/token",
"https://console.anthropic.com/v1/oauth/token",
)
CLAUDE_COMMON_BETAS = (
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
)
CLAUDE_OAUTH_ONLY_BETAS = (
"claude-code-20250219",
"oauth-2025-04-20",
)
_claude_code_version_cache: str | None = None
_claude_code_session_id: str | None = None
@dataclass(frozen=True)
class ExternalAuthCredential:
"""Normalized external credential used at runtime."""
provider: str
value: str
auth_kind: str
source_path: Path
managed_by: str
profile_label: str = ""
refresh_token: str = ""
expires_at_ms: int | None = None
@dataclass(frozen=True)
class ExternalAuthState:
"""Human-readable state for an external auth source."""
configured: bool
state: str
source: str
detail: str = ""
def default_binding_for_provider(provider: str) -> ExternalAuthBinding:
"""Return the default external auth source for *provider*."""
if provider == CODEX_PROVIDER:
codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
return ExternalAuthBinding(
provider=provider,
source_path=str(codex_home / "auth.json"),
source_kind="codex_auth_json",
managed_by="codex-cli",
profile_label="Codex CLI",
)
if provider == CLAUDE_PROVIDER:
claude_home = Path(os.environ.get("CLAUDE_HOME", "~/.claude")).expanduser()
return ExternalAuthBinding(
provider=provider,
source_path=str(claude_home / ".credentials.json"),
source_kind="claude_credentials_json",
managed_by="claude-cli",
profile_label="Claude CLI",
)
raise ValueError(f"Unsupported external auth provider: {provider}")
def load_external_credential(
binding: ExternalAuthBinding,
*,
refresh_if_needed: bool = False,
) -> ExternalAuthCredential:
"""Read a runtime credential from an external auth binding."""
source_path = Path(binding.source_path).expanduser()
if not source_path.exists():
raise ValueError(f"External auth source not found: {source_path}")
try:
payload = json.loads(source_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in external auth source: {source_path}") from exc
if binding.provider == CODEX_PROVIDER:
return _load_codex_credential(payload, source_path, binding)
if binding.provider == CLAUDE_PROVIDER:
return _load_claude_credential(
payload,
source_path,
binding,
refresh_if_needed=refresh_if_needed,
)
raise ValueError(f"Unsupported external auth provider: {binding.provider}")
def _load_codex_credential(
payload: dict[str, Any],
source_path: Path,
binding: ExternalAuthBinding,
) -> ExternalAuthCredential:
tokens = payload.get("tokens")
access_token = ""
refresh_token = ""
if isinstance(tokens, dict):
access_token = str(tokens.get("access_token", "") or "")
refresh_token = str(tokens.get("refresh_token", "") or "")
if not access_token:
access_token = str(payload.get("OPENAI_API_KEY", "") or "")
if not access_token:
raise ValueError("Codex auth source does not contain an access token.")
email = _decode_json_web_token_claim(access_token, ["https://api.openai.com/profile", "email"])
expires_at_ms = _decode_jwt_expiry(access_token)
return ExternalAuthCredential(
provider=CODEX_PROVIDER,
value=access_token,
auth_kind="api_key",
source_path=source_path,
managed_by=binding.managed_by,
profile_label=email or binding.profile_label,
refresh_token=refresh_token,
expires_at_ms=expires_at_ms,
)
def _load_claude_credential(
payload: dict[str, Any],
source_path: Path,
binding: ExternalAuthBinding,
*,
refresh_if_needed: bool,
) -> ExternalAuthCredential:
claude_oauth = payload.get("claudeAiOauth")
if not isinstance(claude_oauth, dict):
raise ValueError("Claude auth source does not contain claudeAiOauth.")
access_token = str(claude_oauth.get("accessToken", "") or "")
refresh_token = str(claude_oauth.get("refreshToken", "") or "")
expires_at_raw = claude_oauth.get("expiresAt")
if not access_token:
raise ValueError("Claude auth source does not contain an access token.")
expires_at_ms = _coerce_int(expires_at_raw)
credential = ExternalAuthCredential(
provider=CLAUDE_PROVIDER,
value=access_token,
auth_kind="auth_token",
source_path=source_path,
managed_by=binding.managed_by,
profile_label=binding.profile_label,
refresh_token=refresh_token,
expires_at_ms=expires_at_ms,
)
if refresh_if_needed and is_credential_expired(credential):
if not refresh_token:
raise ValueError(
f"Claude credentials at {source_path} are expired and cannot be refreshed."
)
refreshed = refresh_claude_oauth_credential(refresh_token)
write_claude_credentials(
source_path,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
)
credential = ExternalAuthCredential(
provider=CLAUDE_PROVIDER,
value=str(refreshed["access_token"]),
auth_kind="auth_token",
source_path=source_path,
managed_by=binding.managed_by,
profile_label=binding.profile_label,
refresh_token=str(refreshed["refresh_token"]),
expires_at_ms=int(refreshed["expires_at_ms"]),
)
return credential
def describe_external_binding(binding: ExternalAuthBinding) -> ExternalAuthState:
"""Return a human-readable state for an external auth binding."""
source_path = Path(binding.source_path).expanduser()
if not source_path.exists():
return ExternalAuthState(
configured=False,
state="missing",
source="missing",
detail=f"external auth source not found: {source_path}",
)
try:
credential = load_external_credential(binding, refresh_if_needed=False)
except ValueError as exc:
return ExternalAuthState(
configured=False,
state="invalid",
source="external",
detail=str(exc),
)
if binding.provider == CLAUDE_PROVIDER and is_credential_expired(credential):
if credential.refresh_token:
return ExternalAuthState(
configured=True,
state="refreshable",
source="external",
detail=f"expired token can be refreshed from {source_path}",
)
return ExternalAuthState(
configured=False,
state="expired",
source="external",
detail=f"expired token at {source_path}",
)
return ExternalAuthState(
configured=True,
state="configured",
source="external",
detail=str(source_path),
)
def is_credential_expired(credential: ExternalAuthCredential, *, now_ms: int | None = None) -> bool:
"""Return True when the external credential is definitely expired."""
if credential.expires_at_ms is None:
return False
if now_ms is None:
import time
now_ms = int(time.time() * 1000)
return credential.expires_at_ms <= now_ms
def get_claude_code_version() -> str:
"""Return the locally installed Claude Code version or a fallback."""
global _claude_code_version_cache
if _claude_code_version_cache is not None:
return _claude_code_version_cache
for command in ("claude", "claude-code"):
try:
result = subprocess.run(
[command, "--version"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except Exception:
continue
version = (result.stdout or "").strip().split(" ", 1)[0]
if result.returncode == 0 and version and version[0].isdigit():
_claude_code_version_cache = version
return version
_claude_code_version_cache = CLAUDE_CODE_VERSION_FALLBACK
return _claude_code_version_cache
def get_claude_code_session_id() -> str:
"""Return a stable Claude Code-style session identifier for this process."""
global _claude_code_session_id
if _claude_code_session_id is None:
_claude_code_session_id = str(uuid.uuid4())
return _claude_code_session_id
def claude_oauth_betas() -> list[str]:
"""Return Claude OAuth betas as a list for SDK beta endpoints."""
return list(CLAUDE_COMMON_BETAS + CLAUDE_OAUTH_ONLY_BETAS)
def claude_attribution_header() -> str:
"""Return the Claude Code billing attribution prefix used in system prompts."""
version = get_claude_code_version()
return (
"x-anthropic-billing-header: "
f"cc_version={version}; cc_entrypoint=cli;"
)
def claude_oauth_headers() -> dict[str, str]:
"""Return Claude Code-style headers for subscription OAuth traffic."""
all_betas = ",".join(claude_oauth_betas())
return {
"anthropic-beta": all_betas,
"user-agent": f"claude-cli/{get_claude_code_version()} (external, cli)",
"x-app": "cli",
"X-Claude-Code-Session-Id": get_claude_code_session_id(),
}
def refresh_claude_oauth_credential(refresh_token: str) -> dict[str, Any]:
"""Refresh a Claude OAuth token without mutating local files."""
if not refresh_token:
raise ValueError("refresh_token is required")
payload = urllib.parse.urlencode(
{
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLAUDE_OAUTH_CLIENT_ID,
}
).encode("utf-8")
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": f"claude-cli/{get_claude_code_version()} (external, cli)",
}
last_error: Exception | None = None
for endpoint in CLAUDE_OAUTH_TOKEN_ENDPOINTS:
request = urllib.request.Request(endpoint, data=payload, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode("utf-8"))
except Exception as exc:
last_error = exc
continue
access_token = str(result.get("access_token", "") or "")
if not access_token:
raise ValueError("Claude OAuth refresh response missing access_token")
next_refresh = str(result.get("refresh_token", refresh_token) or refresh_token)
expires_in = int(result.get("expires_in", 3600) or 3600)
return {
"access_token": access_token,
"refresh_token": next_refresh,
"expires_at_ms": int(time.time() * 1000) + expires_in * 1000,
"scopes": result.get("scope"),
}
if last_error is not None:
raise ValueError(f"Claude OAuth refresh failed: {last_error}") from last_error
raise ValueError("Claude OAuth refresh failed")
def write_claude_credentials(
source_path: Path,
*,
access_token: str,
refresh_token: str,
expires_at_ms: int,
) -> None:
"""Write refreshed Claude credentials back to the upstream credentials file."""
existing: dict[str, Any] = {}
if source_path.exists():
try:
existing = json.loads(source_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
existing = {}
previous = existing.get("claudeAiOauth")
next_oauth: dict[str, Any] = {
"accessToken": access_token,
"refreshToken": refresh_token,
"expiresAt": expires_at_ms,
}
if isinstance(previous, dict):
for key in ("scopes", "rateLimitTier", "subscriptionType"):
if key in previous:
next_oauth[key] = previous[key]
existing["claudeAiOauth"] = next_oauth
source_path.parent.mkdir(parents=True, exist_ok=True)
source_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
try:
source_path.chmod(0o600)
except OSError:
pass
def is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
"""Return True for non-Anthropic endpoints using Anthropic-compatible APIs."""
if not base_url:
return False
normalized = base_url.rstrip("/").lower()
return "anthropic.com" not in normalized and "claude.com" not in normalized
def _coerce_int(value: Any) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
trimmed = value.strip()
if trimmed.isdigit():
return int(trimmed)
return None
def _decode_jwt_expiry(token: str) -> int | None:
exp = _decode_json_web_token_claim(token, ["exp"])
if exp is None:
return None
if isinstance(exp, int):
return exp * 1000
if isinstance(exp, float):
return int(exp * 1000)
if isinstance(exp, str) and exp.strip().isdigit():
return int(exp.strip()) * 1000
return None
def _decode_json_web_token_claim(token: str, path: list[str]) -> Any | None:
parts = token.split(".")
if len(parts) != 3:
return None
try:
encoded = parts[1]
padded = encoded + "=" * (-len(encoded) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
except Exception:
return None
current: Any = payload
for key in path:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return None
return current
+228 -46
View File
@@ -5,8 +5,16 @@ from __future__ import annotations
import logging
from typing import Any
from openharness.config.settings import (
ProviderProfile,
auth_source_provider_name,
builtin_provider_profile_names,
default_auth_source_for_provider,
display_model_setting,
)
from openharness.auth.storage import (
clear_provider_credentials,
load_external_binding,
load_credential,
store_credential,
)
@@ -16,13 +24,34 @@ log = logging.getLogger(__name__)
# Providers that OpenHarness knows about.
_KNOWN_PROVIDERS = [
"anthropic",
"anthropic_claude",
"openai",
"openai_codex",
"copilot",
"dashscope",
"bedrock",
"vertex",
]
_AUTH_SOURCES = [
"anthropic_api_key",
"openai_api_key",
"codex_subscription",
"claude_subscription",
"copilot_oauth",
"dashscope_api_key",
"bedrock_api_key",
"vertex_api_key",
]
_PROFILE_BY_PROVIDER = {
"anthropic": "claude-api",
"anthropic_claude": "claude-subscription",
"openai": "openai-compatible",
"openai_codex": "codex",
"copilot": "copilot",
}
class AuthManager:
"""Central authority for provider authentication state.
@@ -49,21 +78,9 @@ class AuthManager:
return self._settings
def _provider_from_settings(self) -> str:
"""Return the provider name derived from current settings."""
api_format = getattr(self.settings, "api_format", "anthropic")
if api_format == "copilot":
return "copilot"
if api_format == "openai":
base_url = (getattr(self.settings, "base_url", "") or "").lower()
model = (getattr(self.settings, "model", "") or "").lower()
if "dashscope" in base_url or model.startswith("qwen"):
return "dashscope"
if "bedrock" in base_url:
return "bedrock"
if "vertex" in base_url or "aiplatform" in base_url:
return "vertex"
return "openai"
return "anthropic"
"""Return the provider name derived from the active profile."""
_, profile = self.settings.resolve_profile()
return profile.provider
# ------------------------------------------------------------------
# Public API
@@ -73,6 +90,75 @@ class AuthManager:
"""Return the name of the currently active provider."""
return self._provider_from_settings()
def get_active_profile(self) -> str:
"""Return the active provider profile name."""
return self.settings.resolve_profile()[0]
def list_profiles(self) -> dict[str, ProviderProfile]:
"""Return the configured provider profiles."""
return self.settings.merged_profiles()
def get_auth_source_statuses(self) -> dict[str, Any]:
"""Return auth source configuration status."""
import os
from openharness.auth.external import describe_external_binding
active_profile_name, active_profile = self.settings.resolve_profile()
result: dict[str, Any] = {}
for source in _AUTH_SOURCES:
configured = False
origin = "missing"
state = "missing"
detail = ""
storage_provider = auth_source_provider_name(source)
if source == "anthropic_api_key":
if os.environ.get("ANTHROPIC_API_KEY"):
configured = True
origin = "env"
state = "configured"
elif load_credential(storage_provider, "api_key") or getattr(self.settings, "api_key", ""):
configured = True
origin = "file"
state = "configured"
elif source == "openai_api_key":
if os.environ.get("OPENAI_API_KEY"):
configured = True
origin = "env"
state = "configured"
elif load_credential(storage_provider, "api_key"):
configured = True
origin = "file"
state = "configured"
elif source in {"codex_subscription", "claude_subscription"}:
binding = load_external_binding(storage_provider)
if binding is not None:
external_state = describe_external_binding(binding)
configured = external_state.configured
origin = external_state.source
state = external_state.state
detail = external_state.detail
elif source == "copilot_oauth":
from openharness.api.copilot_auth import load_copilot_auth
if load_copilot_auth():
configured = True
origin = "file"
state = "configured"
elif load_credential(storage_provider, "api_key"):
configured = True
origin = "file"
state = "configured"
result[source] = {
"configured": configured,
"source": origin,
"state": state,
"detail": detail,
"active": source == active_profile.auth_source,
"active_profile": active_profile_name,
}
return result
def get_auth_status(self) -> dict[str, Any]:
"""Return authentication status for all known providers.
@@ -104,6 +190,12 @@ class AuthManager:
configured = True
source = "file"
elif provider == "anthropic_claude":
binding = load_external_binding(provider)
if binding is not None:
configured = True
source = "external"
elif provider == "openai":
if os.environ.get("OPENAI_API_KEY"):
configured = True
@@ -112,6 +204,12 @@ class AuthManager:
configured = True
source = "file"
elif provider == "openai_codex":
binding = load_external_binding(provider)
if binding is not None:
configured = True
source = "external"
elif provider == "copilot":
from openharness.api.copilot_auth import load_copilot_auth
@@ -142,43 +240,129 @@ class AuthManager:
return result
def switch_provider(self, name: str) -> None:
"""Switch the active provider by updating settings.
def get_profile_statuses(self) -> dict[str, Any]:
"""Return the available provider profiles and whether their auth is configured."""
active = self.get_active_profile()
auth_sources = self.get_auth_source_statuses()
return {
name: {
"label": profile.label,
"provider": profile.provider,
"api_format": profile.api_format,
"auth_source": profile.auth_source,
"configured": bool(auth_sources.get(profile.auth_source, {}).get("configured")),
"auth_state": str(auth_sources.get(profile.auth_source, {}).get("state", "missing")),
"active": name == active,
"base_url": profile.base_url,
"model": display_model_setting(profile),
}
for name, profile in self.list_profiles().items()
}
Persists the ``api_format`` (and clears ``base_url`` for standard
providers) so subsequent runs use the new provider.
"""
def save_settings(self) -> None:
"""Persist the in-memory settings."""
from openharness.config import save_settings
if name not in _KNOWN_PROVIDERS:
raise ValueError(f"Unknown provider: {name!r}. Known providers: {_KNOWN_PROVIDERS}")
save_settings(self.settings)
fmt_map = {
"anthropic": "anthropic",
"openai": "openai",
"copilot": "copilot",
"dashscope": "openai",
"bedrock": "openai",
"vertex": "openai",
}
new_format = fmt_map[name]
updated = self.settings.model_copy(update={"api_format": new_format})
save_settings(updated)
def use_profile(self, name: str) -> None:
"""Activate a provider profile."""
profiles = self.settings.merged_profiles()
if name not in profiles:
raise ValueError(f"Unknown provider profile: {name!r}")
updated = self.settings.model_copy(update={"active_profile": name}).materialize_active_profile()
self._settings = updated
log.info("Switched active provider to %s (api_format=%s)", name, new_format)
self.save_settings()
log.info("Switched active profile to %s", name)
def upsert_profile(self, name: str, profile: ProviderProfile) -> None:
"""Create or replace a provider profile."""
profiles = self.settings.merged_profiles()
profiles[name] = profile
updated = self.settings.model_copy(update={"profiles": profiles})
self._settings = updated.materialize_active_profile()
self.save_settings()
def update_profile(
self,
name: str,
*,
label: str | None = None,
provider: str | None = None,
api_format: str | None = None,
base_url: str | None = None,
auth_source: str | None = None,
default_model: str | None = None,
last_model: str | None = None,
) -> None:
"""Update a profile in-place."""
profiles = self.settings.merged_profiles()
if name not in profiles:
raise ValueError(f"Unknown provider profile: {name!r}")
current = profiles[name]
next_provider = provider or current.provider
next_format = api_format or current.api_format
updates = {
"label": label or current.label,
"provider": next_provider,
"api_format": next_format,
"base_url": base_url if base_url is not None else current.base_url,
"auth_source": auth_source or current.auth_source or default_auth_source_for_provider(next_provider, next_format),
"default_model": default_model or current.default_model,
"last_model": last_model if last_model is not None else current.last_model,
}
profiles[name] = current.model_copy(update=updates)
updated = self.settings.model_copy(update={"profiles": profiles})
self._settings = updated.materialize_active_profile()
self.save_settings()
def remove_profile(self, name: str) -> None:
"""Remove a non-built-in provider profile."""
if name == self.get_active_profile():
raise ValueError("Cannot remove the active profile.")
if name in builtin_provider_profile_names():
raise ValueError(f"Cannot remove built-in profile: {name}")
profiles = self.settings.merged_profiles()
if name not in profiles:
raise ValueError(f"Unknown provider profile: {name!r}")
del profiles[name]
updated = self.settings.model_copy(update={"profiles": profiles})
self._settings = updated.materialize_active_profile()
self.save_settings()
def switch_auth_source(self, auth_source: str, *, profile_name: str | None = None) -> None:
"""Switch the auth source for a profile."""
if auth_source not in _AUTH_SOURCES:
raise ValueError(f"Unknown auth source: {auth_source!r}. Known auth sources: {_AUTH_SOURCES}")
target = profile_name or self.get_active_profile()
self.update_profile(target, auth_source=auth_source)
def switch_provider(self, name: str) -> None:
"""Backward-compatible switch entrypoint for profile/provider/auth source names."""
if name in _AUTH_SOURCES:
self.switch_auth_source(name)
return
profiles = self.list_profiles()
if name in profiles:
self.use_profile(name)
return
if name in _KNOWN_PROVIDERS:
self.use_profile(_PROFILE_BY_PROVIDER.get(name, "openai-compatible" if name == "openai" else "claude-api"))
return
raise ValueError(
f"Unknown provider or auth source: {name!r}. "
f"Known providers: {_KNOWN_PROVIDERS}; auth sources: {_AUTH_SOURCES}"
)
def store_credential(self, provider: str, key: str, value: str) -> None:
"""Store a credential for the given provider."""
store_credential(provider, key, value)
# If this is a primary API key for a known provider, also sync to
# settings so existing code that reads settings.api_key still works.
if key == "api_key" and provider == self.get_active_provider():
# Keep the flattened active settings snapshot aligned for compatibility.
if key == "api_key" and provider == auth_source_provider_name(self.settings.resolve_profile()[1].auth_source):
try:
from openharness.config import save_settings
updated = self.settings.model_copy(update={"api_key": value})
save_settings(updated)
self._settings = updated
self._settings = updated.materialize_active_profile()
self.save_settings()
except Exception as exc:
log.warning("Could not sync api_key to settings: %s", exc)
@@ -186,12 +370,10 @@ class AuthManager:
"""Remove all stored credentials for the given provider."""
clear_provider_credentials(provider)
# Also clear api_key in settings if this is the active provider.
if provider == self.get_active_provider():
if provider == auth_source_provider_name(self.settings.resolve_profile()[1].auth_source):
try:
from openharness.config import save_settings
updated = self.settings.model_copy(update={"api_key": ""})
save_settings(updated)
self._settings = updated
self._settings = updated.materialize_active_profile()
self.save_settings()
except Exception as exc:
log.warning("Could not clear api_key from settings: %s", exc)
+42
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
@@ -19,6 +20,17 @@ _CREDS_FILE_NAME = "credentials.json"
_KEYRING_SERVICE = "openharness"
@dataclass(frozen=True)
class ExternalAuthBinding:
"""Pointer to credentials managed by an external CLI."""
provider: str
source_path: str
source_kind: str
managed_by: str
profile_label: str = ""
# ---------------------------------------------------------------------------
# File-based backend (always available)
# ---------------------------------------------------------------------------
@@ -146,6 +158,36 @@ def list_stored_providers() -> list[str]:
return list(_load_creds_file().keys())
def store_external_binding(binding: ExternalAuthBinding) -> None:
"""Persist metadata describing an external auth source for *provider*."""
data = _load_creds_file()
entry = data.setdefault(binding.provider, {})
entry["external_binding"] = asdict(binding)
_save_creds_file(data)
log.debug("Stored external auth binding for provider: %s", binding.provider)
def load_external_binding(provider: str) -> ExternalAuthBinding | None:
"""Load external auth binding metadata for *provider* if present."""
entry = _load_creds_file().get(provider, {})
if not isinstance(entry, dict):
return None
raw = entry.get("external_binding")
if not isinstance(raw, dict):
return None
try:
return ExternalAuthBinding(
provider=str(raw["provider"]),
source_path=str(raw["source_path"]),
source_kind=str(raw["source_kind"]),
managed_by=str(raw["managed_by"]),
profile_label=str(raw.get("profile_label", "") or ""),
)
except KeyError:
log.warning("Ignoring malformed external auth binding for provider: %s", provider)
return None
# ---------------------------------------------------------------------------
# Encrypt/decrypt helpers (lightweight XOR obfuscation, not true encryption)
# ---------------------------------------------------------------------------
+227 -23
View File
@@ -37,11 +37,13 @@ app = typer.Typer(
mcp_app = typer.Typer(name="mcp", help="Manage MCP servers")
plugin_app = typer.Typer(name="plugin", help="Manage plugins")
auth_app = typer.Typer(name="auth", help="Manage authentication")
provider_app = typer.Typer(name="provider", help="Manage provider profiles")
cron_app = typer.Typer(name="cron", help="Manage cron scheduler and jobs")
app.add_typer(mcp_app)
app.add_typer(plugin_app)
app.add_typer(auth_app)
app.add_typer(provider_app)
app.add_typer(cron_app)
@@ -260,13 +262,85 @@ def cron_logs_cmd(
# Mapping from provider name to human-readable label for interactive prompts.
_PROVIDER_LABELS: dict[str, str] = {
"anthropic": "Anthropic (Claude API)",
"anthropic_claude": "Claude subscription (Claude CLI)",
"openai": "OpenAI / compatible",
"openai_codex": "OpenAI Codex subscription (Codex CLI)",
"copilot": "GitHub Copilot",
"dashscope": "Alibaba DashScope",
"bedrock": "AWS Bedrock",
"vertex": "Google Vertex AI",
}
_AUTH_SOURCE_LABELS: dict[str, str] = {
"anthropic_api_key": "Anthropic API key",
"openai_api_key": "OpenAI API key",
"codex_subscription": "Codex subscription",
"claude_subscription": "Claude subscription",
"copilot_oauth": "GitHub Copilot OAuth",
"dashscope_api_key": "DashScope API key",
"bedrock_api_key": "Bedrock credentials",
"vertex_api_key": "Vertex credentials",
}
def _maybe_update_default_model_for_provider(provider: str) -> None:
"""Keep the active model in-family after switching auth providers."""
from openharness.auth.manager import AuthManager
manager = AuthManager()
profile_name = {
"openai_codex": "codex",
"anthropic_claude": "claude-subscription",
}.get(provider)
if profile_name is None:
return
profile = manager.list_profiles()[profile_name]
model = profile.resolved_model.lower()
target_model = None
if provider == "openai_codex" and not model.startswith(("gpt-", "o1", "o3", "o4")):
target_model = "gpt-5.4"
elif provider == "anthropic_claude" and not model.startswith("claude-"):
target_model = "sonnet"
if not target_model:
return
manager.update_profile(profile_name, default_model=target_model, last_model=target_model)
def _bind_external_provider(provider: str) -> None:
"""Bind a provider to credentials managed by an external CLI."""
from openharness.auth.external import default_binding_for_provider, load_external_credential
from openharness.auth.storage import store_external_binding
binding = default_binding_for_provider(provider)
try:
credential = load_external_credential(
binding,
refresh_if_needed=(provider == "anthropic_claude"),
)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr, flush=True)
raise typer.Exit(1)
profile_label = credential.profile_label or binding.profile_label
store_external_binding(
binding.__class__(
provider=binding.provider,
source_path=binding.source_path,
source_kind=binding.source_kind,
managed_by=binding.managed_by,
profile_label=profile_label,
)
)
_maybe_update_default_model_for_provider(provider)
label = _PROVIDER_LABELS.get(provider, provider)
profile_name = {
"openai_codex": "codex",
"anthropic_claude": "claude-subscription",
}[provider]
print(f"{label} bound from {credential.source_path}.", flush=True)
print(f"Use `oh provider use {profile_name}` to activate it.", flush=True)
@auth_app.command("login")
def auth_login(
@@ -275,7 +349,7 @@ def auth_login(
"""Interactively authenticate with a provider.
Run without arguments to choose a provider from a menu.
Supported providers: anthropic, openai, copilot, dashscope, bedrock, vertex.
Supported providers: anthropic, anthropic_claude, openai, openai_codex, copilot, dashscope, bedrock, vertex.
"""
from openharness.auth.flows import ApiKeyFlow
from openharness.auth.manager import AuthManager
@@ -305,6 +379,10 @@ def auth_login(
_run_copilot_login()
return
if provider in ("openai_codex", "anthropic_claude"):
_bind_external_provider(provider)
return
# API-keybased providers
if provider in ("anthropic", "openai", "dashscope", "bedrock", "vertex"):
label = _PROVIDER_LABELS.get(provider, provider)
@@ -315,7 +393,6 @@ def auth_login(
print(f"Error: {exc}", file=sys.stderr)
raise typer.Exit(1)
store_credential(provider, "api_key", key)
# Keep settings.api_key in sync for the active provider.
try:
manager.store_credential(provider, "api_key", key)
except Exception:
@@ -329,25 +406,31 @@ def auth_login(
@auth_app.command("status")
def auth_status_cmd() -> None:
"""Show authentication status for all providers in a table."""
"""Show authentication source and provider profile status."""
from openharness.auth.manager import AuthManager
manager = AuthManager()
statuses = manager.get_auth_status()
auth_sources = manager.get_auth_source_statuses()
profiles = manager.get_profile_statuses()
col_provider = 22
col_status = 12
col_source = 10
header = f"{'Provider':<{col_provider}} {'Status':<{col_status}} {'Source':<{col_source}} Active"
print(header)
print("-" * len(header))
for name, info in statuses.items():
label = _PROVIDER_LABELS.get(name, name)
status_str = "configured" if info["configured"] else "missing"
source_str = info["source"]
print("Auth sources:")
print(f"{'Source':<24} {'State':<14} {'Origin':<10} Active")
print("-" * 60)
for name, info in auth_sources.items():
label = _AUTH_SOURCE_LABELS.get(name, name)
active_str = "<-- active" if info["active"] else ""
print(f"{label:<{col_provider}} {status_str:<{col_status}} {source_str:<{col_source}} {active_str}")
print(f"{label:<24} {info['state']:<14} {info['source']:<10} {active_str}")
if info.get("detail"):
print(f" detail: {info['detail']}")
print()
print("Provider profiles:")
print(f"{'Profile':<20} {'Provider':<18} {'Auth source':<22} {'State':<12} Active")
print("-" * 92)
for name, info in profiles.items():
status_str = "ready" if info["configured"] else info.get("auth_state", "missing auth")
active_str = "<-- active" if info["active"] else ""
print(f"{name:<20} {info['provider']:<18} {info['auth_source']:<22} {status_str:<12} {active_str}")
@auth_app.command("logout")
@@ -358,16 +441,16 @@ def auth_logout(
from openharness.auth.manager import AuthManager
manager = AuthManager()
target = provider or manager.get_active_provider()
target = provider or manager.list_profiles()[manager.get_active_profile()].provider
manager.clear_credential(target)
print(f"Authentication cleared for provider: {target}", flush=True)
@auth_app.command("switch")
def auth_switch(
provider: str = typer.Argument(..., help="Provider to activate"),
provider: str = typer.Argument(..., help="Auth source or profile to activate"),
) -> None:
"""Switch the active provider."""
"""Switch the auth source for the active profile, or use a profile by name."""
from openharness.auth.manager import AuthManager
manager = AuthManager()
@@ -376,7 +459,7 @@ def auth_switch(
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
raise typer.Exit(1)
print(f"Switched active provider to: {provider}", flush=True)
print(f"Switched auth/profile to: {provider}", flush=True)
# ---------------------------------------------------------------------------
@@ -420,8 +503,7 @@ def _run_copilot_login() -> None:
print(f" Enterprise domain: {enterprise_url}", flush=True)
print(flush=True)
print("To use Copilot as the provider, run:", flush=True)
print(" oh auth switch copilot", flush=True)
print(" # or set OPENHARNESS_API_FORMAT=copilot", flush=True)
print(" oh provider use copilot", flush=True)
@auth_app.command("copilot-login")
@@ -430,6 +512,18 @@ def auth_copilot_login() -> None:
_run_copilot_login()
@auth_app.command("codex-login")
def auth_codex_login() -> None:
"""Bind OpenHarness to a local Codex CLI subscription session."""
_bind_external_provider("openai_codex")
@auth_app.command("claude-login")
def auth_claude_login() -> None:
"""Bind OpenHarness to a local Claude CLI subscription session."""
_bind_external_provider("anthropic_claude")
@auth_app.command("copilot-logout")
def auth_copilot_logout() -> None:
"""Remove stored GitHub Copilot authentication."""
@@ -438,6 +532,116 @@ def auth_copilot_logout() -> None:
clear_github_token()
print("Copilot authentication cleared.")
# ---- provider subcommands ----
@provider_app.command("list")
def provider_list() -> None:
"""List configured provider profiles."""
from openharness.auth.manager import AuthManager
statuses = AuthManager().get_profile_statuses()
for name, info in statuses.items():
marker = "*" if info["active"] else " "
configured = "ready" if info["configured"] else "missing auth"
base = info["base_url"] or "(default)"
print(f"{marker} {name}: {info['label']} [{configured}]")
print(f" provider={info['provider']} auth={info['auth_source']} model={info['model']} base_url={base}")
@provider_app.command("use")
def provider_use(
name: str = typer.Argument(..., help="Provider profile name"),
) -> None:
"""Activate a provider profile."""
from openharness.auth.manager import AuthManager
manager = AuthManager()
try:
manager.use_profile(name)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
raise typer.Exit(1)
print(f"Activated provider profile: {name}", flush=True)
@provider_app.command("add")
def provider_add(
name: str = typer.Argument(..., help="Provider profile name"),
label: str = typer.Option(..., "--label", help="Display label"),
provider: str = typer.Option(..., "--provider", help="Runtime provider id"),
api_format: str = typer.Option(..., "--api-format", help="API format"),
auth_source: str = typer.Option(..., "--auth-source", help="Auth source name"),
model: str = typer.Option(..., "--model", help="Default model"),
base_url: str | None = typer.Option(None, "--base-url", help="Optional base URL"),
) -> None:
"""Create a provider profile."""
from openharness.auth.manager import AuthManager
from openharness.config.settings import ProviderProfile
manager = AuthManager()
manager.upsert_profile(
name,
ProviderProfile(
label=label,
provider=provider,
api_format=api_format,
auth_source=auth_source,
default_model=model,
last_model=model,
base_url=base_url,
),
)
print(f"Saved provider profile: {name}", flush=True)
@provider_app.command("edit")
def provider_edit(
name: str = typer.Argument(..., help="Provider profile name"),
label: str | None = typer.Option(None, "--label", help="Display label"),
provider: str | None = typer.Option(None, "--provider", help="Runtime provider id"),
api_format: str | None = typer.Option(None, "--api-format", help="API format"),
auth_source: str | None = typer.Option(None, "--auth-source", help="Auth source name"),
model: str | None = typer.Option(None, "--model", help="Default model"),
base_url: str | None = typer.Option(None, "--base-url", help="Optional base URL"),
) -> None:
"""Edit a provider profile."""
from openharness.auth.manager import AuthManager
manager = AuthManager()
try:
manager.update_profile(
name,
label=label,
provider=provider,
api_format=api_format,
auth_source=auth_source,
default_model=model,
last_model=model,
base_url=base_url,
)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
raise typer.Exit(1)
print(f"Updated provider profile: {name}", flush=True)
@provider_app.command("remove")
def provider_remove(
name: str = typer.Argument(..., help="Provider profile name"),
) -> None:
"""Remove a provider profile."""
from openharness.auth.manager import AuthManager
manager = AuthManager()
try:
manager.remove_profile(name)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
raise typer.Exit(1)
print(f"Removed provider profile: {name}", flush=True)
# ---------------------------------------------------------------------------
# Main command
# ---------------------------------------------------------------------------
@@ -498,7 +702,7 @@ def main(
max_turns: int | None = typer.Option(
None,
"--max-turns",
help="Maximum number of agentic turns (useful with --print)",
help="Maximum number of agentic turns (enforced by default in --print; optional cap for interactive mode)",
rich_help_panel="Model & Effort",
),
# --- Output ---
+126 -29
View File
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Awaitable, Callable, Literal, get_args
import pyperclip
from openharness.auth.manager import AuthManager
from openharness.config.paths import (
get_config_dir,
get_data_dir,
@@ -25,7 +26,7 @@ from openharness.bridge import get_bridge_manager
from openharness.bridge.types import WorkSecret
from openharness.bridge.work_secret import build_sdk_url, decode_work_secret, encode_work_secret
from openharness.api.provider import auth_status, detect_provider
from openharness.config.settings import Settings, load_settings, save_settings
from openharness.config.settings import Settings, display_model_setting, load_settings, save_settings
from openharness.engine.messages import ConversationMessage
from openharness.engine.query_engine import QueryEngine
from openharness.memory import (
@@ -66,6 +67,7 @@ class CommandResult:
replay_messages: list | None = None # ConversationMessage list to replay in TUI
continue_pending: bool = False
continue_turns: int | None = None
refresh_runtime: bool = False
@dataclass
@@ -220,10 +222,12 @@ def create_default_command_registry() -> CommandRegistry:
async def _status_handler(_: str, context: CommandContext) -> CommandResult:
usage = context.engine.total_usage
state = context.app_state.get() if context.app_state is not None else None
manager = AuthManager()
return CommandResult(
message=(
f"Messages: {len(context.engine.messages)}\n"
f"Usage: input={usage.input_tokens} output={usage.output_tokens}\n"
f"Profile: {manager.get_active_profile()}\n"
f"Effort: {state.effort if state is not None else load_settings().effort}\n"
f"Passes: {state.passes if state is not None else load_settings().passes}"
)
@@ -693,6 +697,8 @@ def create_default_command_registry() -> CommandRegistry:
async def _login_handler(args: str, context: CommandContext) -> CommandResult:
del context
settings = load_settings()
manager = AuthManager(settings)
profile_name, profile = settings.resolve_profile()
provider = detect_provider(settings)
api_key = args.strip()
if not api_key:
@@ -704,7 +710,9 @@ def create_default_command_registry() -> CommandRegistry:
return CommandResult(
message=(
f"Auth status:\n"
f"- profile: {profile_name}\n"
f"- provider: {provider.name}\n"
f"- auth_source: {profile.auth_source}\n"
f"- auth_status: {auth_status(settings)}\n"
f"- base_url: {settings.base_url or '(default)'}\n"
f"- model: {settings.model}\n"
@@ -712,15 +720,14 @@ def create_default_command_registry() -> CommandRegistry:
"Usage: /login API_KEY"
)
)
settings.api_key = api_key
save_settings(settings)
manager.store_credential(profile.provider, "api_key", api_key)
return CommandResult(message="Stored API key in ~/.openharness/settings.json")
async def _logout_handler(_: str, context: CommandContext) -> CommandResult:
del context
settings = load_settings()
settings.api_key = ""
save_settings(settings)
provider = settings.resolve_profile()[1].provider
AuthManager(settings).clear_credential(provider)
return CommandResult(message="Cleared stored API key.")
async def _feedback_handler(args: str, context: CommandContext) -> CommandResult:
@@ -799,13 +806,14 @@ def create_default_command_registry() -> CommandRegistry:
async def _turns_handler(args: str, context: CommandContext) -> CommandResult:
settings = load_settings()
engine_turns = "unlimited" if context.engine.max_turns is None else str(context.engine.max_turns)
tokens = args.split()
if not tokens or tokens[0] == "show":
return CommandResult(
message=(
f"Max turns (engine): {context.engine.max_turns}\n"
f"Max turns (engine): {engine_turns}\n"
f"Max turns (config): {settings.max_turns}\n"
"Usage: /turns [show|COUNT]"
"Usage: /turns [show|unlimited|COUNT]"
)
)
if tokens[0] == "set" and len(tokens) == 2:
@@ -813,11 +821,19 @@ def create_default_command_registry() -> CommandRegistry:
elif len(tokens) == 1:
raw = tokens[0]
else:
return CommandResult(message="Usage: /turns [show|COUNT]")
return CommandResult(message="Usage: /turns [show|unlimited|COUNT]")
if raw.lower() == "unlimited":
context.engine.set_max_turns(None)
return CommandResult(
message=(
"Max turns set to unlimited for this session. "
f"Saved config remains {settings.max_turns}."
)
)
try:
turns = int(raw)
except ValueError:
return CommandResult(message="Usage: /turns [show|COUNT]")
return CommandResult(message="Usage: /turns [show|unlimited|COUNT]")
turns = max(1, min(turns, 512))
settings.max_turns = turns
save_settings(settings)
@@ -987,15 +1003,20 @@ def create_default_command_registry() -> CommandRegistry:
f"Denied tools: {permission.denied_tools}"
)
)
target_mode: str | None = None
if tokens[0] == "set" and len(tokens) == 2:
settings.permission.mode = PermissionMode(tokens[1])
target_mode = tokens[1]
elif len(tokens) == 1 and tokens[0] in _MODE_LABELS:
target_mode = tokens[0]
if target_mode is not None:
settings.permission.mode = PermissionMode(target_mode)
save_settings(settings)
context.engine.set_permission_checker(PermissionChecker(settings.permission))
if context.app_state is not None:
context.app_state.set(permission_mode=settings.permission.mode.value)
label = _MODE_LABELS.get(tokens[1], tokens[1])
return CommandResult(message=f"Permission mode set to {label}")
return CommandResult(message="Usage: /permissions [show|set MODE]")
label = _MODE_LABELS.get(target_mode, target_mode)
return CommandResult(message=f"Permission mode set to {label}", refresh_runtime=True)
return CommandResult(message="Usage: /permissions [show|MODE]")
async def _plan_handler(args: str, context: CommandContext) -> CommandResult:
settings = load_settings()
@@ -1006,29 +1027,87 @@ def create_default_command_registry() -> CommandRegistry:
context.engine.set_permission_checker(PermissionChecker(settings.permission))
if context.app_state is not None:
context.app_state.set(permission_mode=settings.permission.mode.value)
return CommandResult(message="Plan mode enabled.")
return CommandResult(message="Plan mode enabled.", refresh_runtime=True)
if mode in {"off", "exit"}:
settings.permission.mode = PermissionMode.DEFAULT
save_settings(settings)
context.engine.set_permission_checker(PermissionChecker(settings.permission))
if context.app_state is not None:
context.app_state.set(permission_mode=settings.permission.mode.value)
return CommandResult(message="Plan mode disabled.")
return CommandResult(message="Plan mode disabled.", refresh_runtime=True)
return CommandResult(message="Usage: /plan [on|off]")
async def _model_handler(args: str, context: CommandContext) -> CommandResult:
settings = load_settings()
manager = AuthManager(settings)
active_profile = manager.get_active_profile()
_, profile = settings.resolve_profile(active_profile)
tokens = args.split(maxsplit=1)
if not tokens or tokens[0] == "show":
return CommandResult(message=f"Model: {settings.model}")
return CommandResult(message=f"Model: {display_model_setting(profile)}\nProfile: {active_profile}")
if tokens[0] == "set" and len(tokens) == 2:
settings.model = tokens[1]
save_settings(settings)
context.engine.set_model(tokens[1])
model_name = tokens[1].strip()
elif args.strip():
model_name = args.strip()
else:
model_name = None
if model_name:
if model_name.lower() == "default":
manager.update_profile(active_profile, last_model="")
message = "Model reset to default."
else:
manager.update_profile(active_profile, last_model=model_name)
message = f"Model set to {model_name}."
updated = load_settings()
context.engine.set_model(updated.model)
if context.app_state is not None:
context.app_state.set(model=tokens[1])
return CommandResult(message=f"Model set to {tokens[1]}. Restart session to use it.")
return CommandResult(message="Usage: /model [show|set MODEL]")
updated_profile = updated.resolve_profile()[1]
context.app_state.set(model=display_model_setting(updated_profile))
return CommandResult(message=message, refresh_runtime=True)
return CommandResult(message="Usage: /model [show|MODEL]")
async def _provider_handler(args: str, context: CommandContext) -> CommandResult:
manager = AuthManager()
profiles = manager.get_profile_statuses()
tokens = args.split()
if not tokens or tokens[0] == "show":
active_name = manager.get_active_profile()
active = profiles[active_name]
lines = [
f"Active profile: {active_name}",
f"Label: {active['label']}",
f"Provider: {active['provider']}",
f"Auth source: {active['auth_source']}",
f"Configured: {'yes' if active['configured'] else 'no'}",
f"Base URL: {active['base_url'] or '(default)'}",
f"Model: {active['model']}",
]
return CommandResult(message="\n".join(lines))
if tokens[0] == "list":
lines = ["Provider profiles:"]
for name, info in profiles.items():
marker = "*" if info["active"] else " "
configured = "configured" if info["configured"] else "missing auth"
lines.append(f"{marker} {name} [{configured}] {info['label']} -> {info['model']}")
return CommandResult(message="\n".join(lines))
target = tokens[1] if tokens[0] == "use" and len(tokens) == 2 else (tokens[0] if len(tokens) == 1 else None)
if target is None:
return CommandResult(message="Usage: /provider [show|list|PROFILE]")
manager.use_profile(target)
updated = load_settings()
profile = updated.resolve_profile()[1]
context.engine.set_model(updated.model)
if context.app_state is not None:
context.app_state.set(
model=display_model_setting(profile),
provider=detect_provider(updated).name,
auth_status=auth_status(updated),
base_url=updated.base_url or "",
)
return CommandResult(
message=f"Switched provider profile to {target} ({profile.label}).",
refresh_runtime=True,
)
async def _theme_handler(args: str, context: CommandContext) -> CommandResult:
from openharness.themes import list_themes, load_theme
@@ -1069,6 +1148,11 @@ def create_default_command_registry() -> CommandRegistry:
if tokens[0] == "set" and len(tokens) == 2:
name = tokens[1]
elif len(tokens) == 1 and tokens[0] not in {"list", "preview"}:
name = tokens[0]
else:
name = None
if name is not None:
try:
load_theme(name)
except KeyError:
@@ -1107,7 +1191,7 @@ def create_default_command_registry() -> CommandRegistry:
]
return CommandResult(message="\n".join(lines))
return CommandResult(message="Usage: /theme [list|show|set NAME|preview NAME]")
return CommandResult(message="Usage: /theme [list|show|NAME|preview NAME]")
async def _output_style_handler(args: str, context: CommandContext) -> CommandResult:
settings = load_settings()
@@ -1126,14 +1210,20 @@ def create_default_command_registry() -> CommandRegistry:
message="\n".join(f"{style.name} [{style.source}]" for style in styles)
)
if tokens[0] == "set" and len(tokens) == 2:
if tokens[1] not in available:
return CommandResult(message=f"Unknown output style: {tokens[1]}")
settings.output_style = tokens[1]
style_name = tokens[1]
elif len(tokens) == 1 and tokens[0] not in {"list"}:
style_name = tokens[0]
else:
style_name = None
if style_name is not None:
if style_name not in available:
return CommandResult(message=f"Unknown output style: {style_name}")
settings.output_style = style_name
save_settings(settings)
if context.app_state is not None:
context.app_state.set(output_style=tokens[1])
return CommandResult(message=f"Output style set to {tokens[1]}")
return CommandResult(message="Usage: /output-style [show|list|set NAME]")
context.app_state.set(output_style=style_name)
return CommandResult(message=f"Output style set to {style_name}")
return CommandResult(message="Usage: /output-style [show|list|NAME]")
async def _keybindings_handler(_: str, context: CommandContext) -> CommandResult:
from openharness.keybindings import get_keybindings_path, load_keybindings
@@ -1204,12 +1294,17 @@ def create_default_command_registry() -> CommandRegistry:
async def _doctor_handler(_: str, context: CommandContext) -> CommandResult:
settings = load_settings()
manager = AuthManager(settings)
active_profile_name, active_profile = settings.resolve_profile()
memory_dir = get_project_memory_dir(context.cwd)
state = context.app_state.get() if context.app_state is not None else None
lines = [
"Doctor summary:",
f"- cwd: {context.cwd}",
f"- active_profile: {active_profile_name}",
f"- model: {settings.model}",
f"- provider_workflow: {active_profile.label}",
f"- auth_source: {active_profile.auth_source}",
f"- permission_mode: {state.permission_mode if state is not None else settings.permission.mode}",
f"- theme: {state.theme if state is not None else settings.theme}",
f"- output_style: {state.output_style if state is not None else settings.output_style}",
@@ -1220,6 +1315,7 @@ def create_default_command_registry() -> CommandRegistry:
f"- memory_dir: {memory_dir}",
f"- plugin_count: {max(len(context.plugin_summary.splitlines()) - 1, 0) if context.plugin_summary else 0}",
f"- mcp_configured: {'yes' if context.mcp_summary and 'No MCP' not in context.mcp_summary else 'no'}",
f"- auth_configured: {'yes' if manager.get_profile_statuses()[active_profile_name]['configured'] else 'no'}",
]
return CommandResult(message="\n".join(lines))
@@ -1418,6 +1514,7 @@ def create_default_command_registry() -> CommandRegistry:
registry.register(SlashCommand("passes", "Show or update reasoning pass count", _passes_handler))
registry.register(SlashCommand("turns", "Show or update maximum agentic turn count", _turns_handler))
registry.register(SlashCommand("continue", "Continue the previous tool loop if it was interrupted", _continue_handler))
registry.register(SlashCommand("provider", "Show or switch provider profiles", _provider_handler))
registry.register(SlashCommand("model", "Show or update the default model", _model_handler))
registry.register(SlashCommand("theme", "List, set, show or preview TUI themes", _theme_handler))
registry.register(SlashCommand("output-style", "Show or update output style", _output_style_handler))
+14 -1
View File
@@ -9,13 +9,26 @@ from openharness.config.paths import (
get_data_dir,
get_logs_dir,
)
from openharness.config.settings import Settings, load_settings
from openharness.config.settings import (
ProviderProfile,
Settings,
auth_source_provider_name,
default_auth_source_for_provider,
default_provider_profiles,
load_settings,
save_settings,
)
__all__ = [
"ProviderProfile",
"Settings",
"auth_source_provider_name",
"default_auth_source_for_provider",
"default_provider_profiles",
"get_config_dir",
"get_config_file_path",
"get_data_dir",
"get_logs_dir",
"load_settings",
"save_settings",
]
+483 -5
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -72,15 +73,283 @@ class SandboxSettings(BaseModel):
filesystem: SandboxFilesystemSettings = Field(default_factory=SandboxFilesystemSettings)
class ProviderProfile(BaseModel):
"""Named provider workflow configuration."""
label: str
provider: str
api_format: str
auth_source: str
default_model: str
base_url: str | None = None
last_model: str | None = None
@property
def resolved_model(self) -> str:
"""Return the active model for this profile."""
return resolve_model_setting(
(self.last_model or "").strip() or self.default_model,
self.provider,
default_model=self.default_model,
)
@dataclass(frozen=True)
class ResolvedAuth:
"""Normalized auth material used to construct API clients."""
provider: str
auth_kind: str
value: str
source: str
state: str = "configured"
CLAUDE_MODEL_ALIAS_OPTIONS: tuple[tuple[str, str, str], ...] = (
("default", "Default", "Recommended model for this profile"),
("best", "Best", "Most capable available model"),
("sonnet", "Sonnet", "Latest Sonnet for everyday coding"),
("opus", "Opus", "Latest Opus for complex reasoning"),
("haiku", "Haiku", "Fastest Claude model"),
("sonnet[1m]", "Sonnet (1M context)", "Latest Sonnet with 1M context"),
("opus[1m]", "Opus (1M context)", "Latest Opus with 1M context"),
("opusplan", "Opus Plan Mode", "Use Opus in plan mode and Sonnet otherwise"),
)
_CLAUDE_ALIAS_TARGETS: dict[str, str] = {
"sonnet": "claude-sonnet-4-6",
"opus": "claude-opus-4-6",
"haiku": "claude-haiku-4-5",
"sonnet[1m]": "claude-sonnet-4-6[1m]",
"opus[1m]": "claude-opus-4-6[1m]",
}
def normalize_anthropic_model_name(model: str) -> str:
"""Normalize an Anthropic model name the same way Hermes does.
- Strips the ``anthropic/`` prefix when present.
- Converts dotted Claude version separators to Anthropic's hyphenated form.
"""
normalized = model.strip()
lower = normalized.lower()
if lower.startswith("anthropic/"):
normalized = normalized[len("anthropic/"):]
lower = normalized.lower()
if lower.startswith("claude-"):
return normalized.replace(".", "-")
return normalized
def default_provider_profiles() -> dict[str, ProviderProfile]:
"""Return the built-in provider workflow catalog."""
return {
"claude-api": ProviderProfile(
label="Claude API",
provider="anthropic",
api_format="anthropic",
auth_source="anthropic_api_key",
default_model="claude-sonnet-4-6",
),
"claude-subscription": ProviderProfile(
label="Claude Subscription",
provider="anthropic_claude",
api_format="anthropic",
auth_source="claude_subscription",
default_model="claude-sonnet-4-6",
),
"openai-compatible": ProviderProfile(
label="OpenAI Compatible",
provider="openai",
api_format="openai",
auth_source="openai_api_key",
default_model="gpt-5.4",
),
"codex": ProviderProfile(
label="Codex Subscription",
provider="openai_codex",
api_format="openai",
auth_source="codex_subscription",
default_model="gpt-5.4",
),
"copilot": ProviderProfile(
label="GitHub Copilot",
provider="copilot",
api_format="copilot",
auth_source="copilot_oauth",
default_model="gpt-5.4",
),
}
def builtin_provider_profile_names() -> set[str]:
"""Return the names of built-in provider profiles."""
return set(default_provider_profiles())
def is_claude_family_provider(provider: str) -> bool:
"""Return True when the provider is a Claude/Anthropic workflow."""
return provider in {"anthropic", "anthropic_claude"}
def display_model_setting(profile: ProviderProfile) -> str:
"""Return the user-facing model setting for a profile."""
configured = (profile.last_model or "").strip()
if not configured and is_claude_family_provider(profile.provider):
return "default"
return configured or profile.default_model
def resolve_model_setting(
model_setting: str,
provider: str,
*,
default_model: str | None = None,
permission_mode: str | None = None,
) -> str:
"""Resolve a user-facing model setting into the concrete runtime model ID."""
configured = model_setting.strip()
normalized = configured.lower()
if not configured or normalized == "default":
fallback = (default_model or "").strip()
if fallback and fallback.lower() != "default":
return resolve_model_setting(
fallback,
provider,
default_model=None,
permission_mode=permission_mode,
)
if is_claude_family_provider(provider):
return _CLAUDE_ALIAS_TARGETS["sonnet"]
return "gpt-5.4"
if is_claude_family_provider(provider):
if normalized == "best":
return _CLAUDE_ALIAS_TARGETS["opus"]
if normalized == "opusplan":
if permission_mode == PermissionMode.PLAN.value:
return _CLAUDE_ALIAS_TARGETS["opus"]
return _CLAUDE_ALIAS_TARGETS["sonnet"]
if normalized in _CLAUDE_ALIAS_TARGETS:
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"
return configured
def auth_source_provider_name(auth_source: str) -> str:
"""Map an auth source to the storage/runtime provider name."""
mapping = {
"anthropic_api_key": "anthropic",
"openai_api_key": "openai",
"codex_subscription": "openai_codex",
"claude_subscription": "anthropic_claude",
"copilot_oauth": "copilot",
"dashscope_api_key": "dashscope",
"bedrock_api_key": "bedrock",
"vertex_api_key": "vertex",
}
return mapping.get(auth_source, auth_source)
def default_auth_source_for_provider(provider: str, api_format: str | None = None) -> str:
"""Infer the default auth source for a provider/backend."""
if provider == "anthropic_claude":
return "claude_subscription"
if provider == "openai_codex":
return "codex_subscription"
if provider == "copilot":
return "copilot_oauth"
if provider == "dashscope":
return "dashscope_api_key"
if provider == "bedrock":
return "bedrock_api_key"
if provider == "vertex":
return "vertex_api_key"
if provider == "openai" or api_format == "openai":
return "openai_api_key"
return "anthropic_api_key"
def _slugify_profile_name(value: str) -> str:
cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in value).strip("-")
while "--" in cleaned:
cleaned = cleaned.replace("--", "-")
return cleaned or "custom"
def _infer_profile_name_from_flat_settings(settings: "Settings") -> str:
provider = (settings.provider or "").strip()
if provider == "openai_codex":
return "codex"
if provider == "anthropic_claude":
return "claude-subscription"
if provider == "copilot" or settings.api_format == "copilot":
return "copilot"
if provider == "openai" and not settings.base_url:
return "openai-compatible"
if provider == "anthropic" and not settings.base_url:
return "claude-api"
if settings.base_url:
return _slugify_profile_name(Path(settings.base_url).name or settings.base_url)
if provider:
return _slugify_profile_name(provider)
return "claude-api"
def _profile_from_flat_settings(settings: "Settings") -> tuple[str, ProviderProfile]:
defaults = default_provider_profiles()
name = _infer_profile_name_from_flat_settings(settings)
existing = defaults.get(name)
if existing is not None and (
existing.provider == settings.provider or not settings.provider
) and (
existing.api_format == settings.api_format
) and (
existing.base_url == settings.base_url
):
profile = existing.model_copy(
update={
"last_model": settings.model or existing.resolved_model,
}
)
return name, profile
provider = settings.provider or ("copilot" if settings.api_format == "copilot" else ("openai" if settings.api_format == "openai" else "anthropic"))
profile = ProviderProfile(
label=f"Imported {provider}",
provider=provider,
api_format=settings.api_format,
auth_source=default_auth_source_for_provider(provider, settings.api_format),
default_model=settings.model or defaults.get("claude-api", ProviderProfile(
label="Claude API",
provider="anthropic",
api_format="anthropic",
auth_source="anthropic_api_key",
default_model="sonnet",
)).default_model,
last_model=settings.model or None,
base_url=settings.base_url,
)
return name, profile
class Settings(BaseModel):
"""Main settings model for OpenHarness."""
# API configuration
api_key: str = ""
model: str = "claude-sonnet-4-20250514"
model: str = "claude-sonnet-4-6"
max_tokens: int = 16384
base_url: str | None = None
api_format: str = "anthropic" # "anthropic", "openai", or "copilot"
provider: str = ""
active_profile: str = "claude-api"
profiles: dict[str, ProviderProfile] = Field(default_factory=default_provider_profiles)
max_turns: int = 200
# Behavior
@@ -102,6 +371,96 @@ class Settings(BaseModel):
passes: int = 1
verbose: bool = False
def merged_profiles(self) -> dict[str, ProviderProfile]:
"""Return the saved profiles merged over the built-in catalog."""
merged = default_provider_profiles()
merged.update(
{
name: (
profile.model_copy(deep=True)
if isinstance(profile, ProviderProfile)
else ProviderProfile.model_validate(profile)
)
for name, profile in self.profiles.items()
}
)
return merged
def resolve_profile(self, name: str | None = None) -> tuple[str, ProviderProfile]:
"""Return the active provider profile."""
profiles = self.merged_profiles()
profile_name = (name or self.active_profile or "").strip() or "claude-api"
if profile_name not in profiles:
fallback_name, fallback = _profile_from_flat_settings(self)
profiles[fallback_name] = fallback
profile_name = fallback_name
return profile_name, profiles[profile_name].model_copy(deep=True)
def materialize_active_profile(self) -> Settings:
"""Project the active profile back onto legacy flat settings fields."""
profile_name, profile = self.resolve_profile()
configured_model = (profile.last_model or "").strip() or profile.default_model
return self.model_copy(
update={
"active_profile": profile_name,
"profiles": self.merged_profiles(),
"provider": profile.provider,
"api_format": profile.api_format,
"base_url": profile.base_url,
"model": resolve_model_setting(
configured_model,
profile.provider,
default_model=profile.default_model,
permission_mode=self.permission.mode.value,
),
}
)
def sync_active_profile_from_flat_fields(self) -> Settings:
"""Fold legacy flat provider fields back into the active profile.
This preserves compatibility for callers that still construct `Settings`
by setting top-level `provider` / `api_format` / `base_url` / `model`
directly before the profile layer is used everywhere.
"""
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
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(
(profile.last_model or "").strip() or profile.default_model,
profile.provider,
default_model=profile.default_model,
permission_mode=self.permission.mode.value,
)
if flat_model and flat_model != resolved_profile_model:
next_model = flat_model
else:
next_model = profile.last_model
current_default_auth = default_auth_source_for_provider(profile.provider, profile.api_format)
next_auth_source = profile.auth_source
if not next_auth_source or next_auth_source == current_default_auth:
next_auth_source = default_auth_source_for_provider(next_provider, next_api_format)
updated_profile = profile.model_copy(
update={
"provider": next_provider,
"api_format": next_api_format,
"base_url": next_base_url,
"auth_source": next_auth_source,
"last_model": next_model,
}
)
profiles = self.merged_profiles()
profiles[profile_name] = updated_profile
return self.model_copy(
update={
"active_profile": profile_name,
"profiles": profiles,
}
)
def resolve_api_key(self) -> str:
"""Resolve API key with precedence: instance value > env var > empty.
@@ -110,8 +469,17 @@ class Settings(BaseModel):
Returns the API key string. Raises ValueError if no key is found.
"""
profile_name, profile = self.resolve_profile()
del profile_name
if profile.provider == "openai_codex":
return self.resolve_auth().value
if profile.provider == "anthropic_claude":
raise ValueError(
"Current provider uses Anthropic auth tokens instead of API keys. "
"Use resolve_auth() for runtime credential resolution."
)
# Copilot format manages its own auth; skip normal key resolution.
if self.api_format == "copilot":
if profile.api_format == "copilot":
return "copilot-managed"
if self.api_key:
@@ -132,10 +500,104 @@ class Settings(BaseModel):
"~/.openharness/settings.json"
)
def resolve_auth(self) -> ResolvedAuth:
"""Resolve auth for the current provider, including subscription bridges."""
_, profile = self.resolve_profile()
provider = profile.provider.strip()
auth_source = profile.auth_source.strip() or default_auth_source_for_provider(provider, profile.api_format)
if auth_source in {"codex_subscription", "claude_subscription"}:
from openharness.auth.external import (
is_third_party_anthropic_endpoint,
load_external_credential,
)
from openharness.auth.storage import load_external_binding
if auth_source == "claude_subscription" and is_third_party_anthropic_endpoint(profile.base_url):
raise ValueError(
"Claude subscription auth only supports direct Anthropic/Claude endpoints. "
"Use an API-key-backed Anthropic-compatible profile for third-party base URLs."
)
binding = load_external_binding(auth_source_provider_name(auth_source))
if binding is None:
raise ValueError(
f"No external auth binding found for {auth_source}. Run 'oh auth "
f"{'codex-login' if auth_source == 'codex_subscription' else 'claude-login'}' first."
)
credential = load_external_credential(
binding,
refresh_if_needed=(auth_source == "claude_subscription"),
)
return ResolvedAuth(
provider=provider,
auth_kind=credential.auth_kind,
value=credential.value,
source=f"external:{credential.source_path}",
state="configured",
)
if auth_source == "copilot_oauth":
return ResolvedAuth(
provider="copilot",
auth_kind="oauth_device",
value="copilot-managed",
source="copilot",
state="configured",
)
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",
)
env_var = {
"anthropic_api_key": "ANTHROPIC_API_KEY",
"openai_api_key": "OPENAI_API_KEY",
"dashscope_api_key": "DASHSCOPE_API_KEY",
}.get(auth_source)
if env_var:
env_value = os.environ.get(env_var, "")
if env_value:
return ResolvedAuth(
provider=provider or storage_provider,
auth_kind="api_key",
value=env_value,
source=f"env:{env_var}",
state="configured",
)
from openharness.auth.storage import load_credential
stored = load_credential(storage_provider, "api_key")
if stored:
return ResolvedAuth(
provider=provider or storage_provider,
auth_kind="api_key",
value=stored,
source=f"file:{storage_provider}",
state="configured",
)
raise ValueError(
f"No credentials found for auth source '{auth_source}'. "
"Configure the matching provider or environment variable first."
)
def merge_cli_overrides(self, **overrides: Any) -> Settings:
"""Return a new Settings with CLI overrides applied (non-None values only)."""
updates = {k: v for k, v in overrides.items() if v is not None}
return self.model_copy(update=updates)
merged = self.model_copy(update=updates)
if not updates:
return merged
profile_keys = {"model", "base_url", "api_format", "provider", "api_key", "active_profile", "profiles"}
if profile_keys.isdisjoint(updates):
return merged
return merged.sync_active_profile_from_flat_fields().materialize_active_profile()
def _apply_env_overrides(settings: Settings) -> Settings:
@@ -165,6 +627,10 @@ def _apply_env_overrides(settings: Settings) -> Settings:
if api_format:
updates["api_format"] = api_format
provider = os.environ.get("OPENHARNESS_PROVIDER")
if provider:
updates["provider"] = provider
sandbox_enabled = os.environ.get("OPENHARNESS_SANDBOX_ENABLED")
sandbox_fail = os.environ.get("OPENHARNESS_SANDBOX_FAIL_IF_UNAVAILABLE")
sandbox_updates: dict[str, Any] = {}
@@ -201,9 +667,20 @@ def load_settings(config_path: Path | None = None) -> Settings:
if config_path.exists():
raw = json.loads(config_path.read_text(encoding="utf-8"))
return _apply_env_overrides(Settings.model_validate(raw))
settings = Settings.model_validate(raw)
if "profiles" not in raw or "active_profile" not in raw:
profile_name, profile = _profile_from_flat_settings(settings)
merged_profiles = settings.merged_profiles()
merged_profiles[profile_name] = profile
settings = settings.model_copy(
update={
"active_profile": profile_name,
"profiles": merged_profiles,
}
)
return _apply_env_overrides(settings.materialize_active_profile())
return _apply_env_overrides(Settings())
return _apply_env_overrides(Settings().materialize_active_profile())
def save_settings(settings: Settings, config_path: Path | None = None) -> None:
@@ -218,6 +695,7 @@ def save_settings(settings: Settings, config_path: Path | None = None) -> None:
config_path = get_config_file_path()
settings = settings.sync_active_profile_from_flat_fields().materialize_active_profile()
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
settings.model_dump_json(indent=2) + "\n",
+17 -3
View File
@@ -10,6 +10,7 @@ from typing import AsyncIterator, Awaitable, Callable
from openharness.api.client import (
ApiMessageCompleteEvent,
ApiMessageRequest,
ApiRetryEvent,
ApiTextDeltaEvent,
SupportsStreamingMessages,
)
@@ -19,6 +20,7 @@ from openharness.engine.stream_events import (
AssistantTextDelta,
AssistantTurnComplete,
ErrorEvent,
StatusEvent,
StreamEvent,
ToolExecutionCompleted,
ToolExecutionStarted,
@@ -54,7 +56,7 @@ class QueryContext:
max_tokens: int
permission_prompt: PermissionPrompt | None = None
ask_user_prompt: AskUserPrompt | None = None
max_turns: int = 200
max_turns: int | None = 200
hook_executor: HookExecutor | None = None
tool_metadata: dict[str, object] | None = None
@@ -78,7 +80,9 @@ async def run_query(
compact_state = AutoCompactState()
for _ in range(context.max_turns):
turn_count = 0
while context.max_turns is None or turn_count < context.max_turns:
turn_count += 1
# --- auto-compact check before calling the model ---------------
messages, was_compacted = await auto_compact_if_needed(
messages,
@@ -105,6 +109,14 @@ async def run_query(
if isinstance(event, ApiTextDeltaEvent):
yield AssistantTextDelta(text=event.text), None
continue
if isinstance(event, ApiRetryEvent):
yield StatusEvent(
message=(
f"Request failed; retrying in {event.delay_seconds:.1f}s "
f"(attempt {event.attempt + 1} of {event.max_attempts}): {event.message}"
)
), None
continue
if isinstance(event, ApiMessageCompleteEvent):
final_message = event.message
@@ -159,7 +171,9 @@ async def run_query(
messages.append(ConversationMessage(role="user", content=tool_results))
raise MaxTurnsExceeded(context.max_turns)
if context.max_turns is not None:
raise MaxTurnsExceeded(context.max_turns)
raise RuntimeError("Query loop exited without a max_turns limit or final response")
async def _execute_tool_call(
+9 -5
View File
@@ -28,7 +28,7 @@ class QueryEngine:
model: str,
system_prompt: str,
max_tokens: int = 4096,
max_turns: int = 8,
max_turns: int | None = 8,
permission_prompt: PermissionPrompt | None = None,
ask_user_prompt: AskUserPrompt | None = None,
hook_executor: HookExecutor | None = None,
@@ -55,8 +55,8 @@ class QueryEngine:
return list(self._messages)
@property
def max_turns(self) -> int:
"""Return the maximum number of agentic turns per user input."""
def max_turns(self) -> int | None:
"""Return the maximum number of agentic turns per user input, if capped."""
return self._max_turns
@property
@@ -77,9 +77,13 @@ class QueryEngine:
"""Update the active model for future turns."""
self._model = model
def set_max_turns(self, max_turns: int) -> None:
def set_api_client(self, api_client: SupportsStreamingMessages) -> None:
"""Update the active API client for future turns."""
self._api_client = api_client
def set_max_turns(self, max_turns: int | None) -> None:
"""Update the maximum number of agentic turns per user input."""
self._max_turns = max(1, int(max_turns))
self._max_turns = None if max_turns is None else max(1, int(max_turns))
def set_permission_checker(self, checker: PermissionChecker) -> None:
"""Update the active permission checker for future turns."""
+8
View File
@@ -49,10 +49,18 @@ class ErrorEvent:
recoverable: bool = True
@dataclass(frozen=True)
class StatusEvent:
"""A transient system status message shown to the user."""
message: str
StreamEvent = (
AssistantTextDelta
| AssistantTurnComplete
| ToolExecutionStarted
| ToolExecutionCompleted
| ErrorEvent
| StatusEvent
)
+12
View File
@@ -49,6 +49,18 @@ class HookExecutor:
"""Replace the active hook registry."""
self._registry = registry
def update_context(
self,
*,
api_client: SupportsStreamingMessages | None = None,
default_model: str | None = None,
) -> None:
"""Update the active hook execution context."""
if api_client is not None:
self._context.api_client = api_client
if default_model is not None:
self._context.default_model = default_model
async def execute(self, event: HookEvent, payload: dict[str, Any]) -> AggregatedHookResult:
"""Execute all matching hooks for an event."""
results: list[HookResult] = []
+18
View File
@@ -38,6 +38,7 @@ async def run_repl(
api_format=api_format,
api_client=api_client,
restore_messages=restore_messages,
enforce_max_turns=max_turns is not None,
)
return
@@ -74,6 +75,8 @@ async def run_print_mode(
from openharness.engine.stream_events import (
AssistantTextDelta,
AssistantTurnComplete,
ErrorEvent,
StatusEvent,
ToolExecutionCompleted,
ToolExecutionStarted,
)
@@ -92,6 +95,7 @@ async def run_print_mode(
system_prompt=system_prompt,
api_key=api_key,
api_format=api_format,
enforce_max_turns=True,
api_client=api_client,
permission_prompt=_noop_permission,
ask_user_prompt=_noop_ask,
@@ -140,6 +144,20 @@ async def run_print_mode(
obj = {"type": "tool_completed", "tool_name": event.tool_name, "output": event.output, "is_error": event.is_error}
print(json.dumps(obj), flush=True)
events_list.append(obj)
elif isinstance(event, ErrorEvent):
if output_format == "text":
print(event.message, file=sys.stderr)
elif output_format == "stream-json":
obj = {"type": "error", "message": event.message, "recoverable": event.recoverable}
print(json.dumps(obj), flush=True)
events_list.append(obj)
elif isinstance(event, StatusEvent):
if output_format == "text":
print(event.message, file=sys.stderr)
elif output_format == "stream-json":
obj = {"type": "status", "message": event.message}
print(json.dumps(obj), flush=True)
events_list.append(obj)
async def _clear_output() -> None:
pass
+348 -3
View File
@@ -12,14 +12,20 @@ from dataclasses import dataclass
from uuid import uuid4
from openharness.api.client import SupportsStreamingMessages
from openharness.auth.manager import AuthManager
from openharness.config.settings import CLAUDE_MODEL_ALIAS_OPTIONS, display_model_setting
from openharness.bridge import get_bridge_manager
from openharness.themes import list_themes
from openharness.engine.stream_events import (
AssistantTextDelta,
AssistantTurnComplete,
ErrorEvent,
StatusEvent,
StreamEvent,
ToolExecutionCompleted,
ToolExecutionStarted,
)
from openharness.output_styles import load_output_styles
from openharness.tasks import get_task_manager
from openharness.ui.protocol import BackendEvent, FrontendRequest, TranscriptItem
from openharness.ui.runtime import build_runtime, close_runtime, handle_line, start_runtime
@@ -41,6 +47,7 @@ class BackendHostConfig:
api_format: str | None = None
api_client: SupportsStreamingMessages | None = None
restore_messages: list[dict] | None = None
enforce_max_turns: bool = True
class ReactBackendHost:
@@ -70,6 +77,7 @@ class ReactBackendHost:
restore_messages=self._config.restore_messages,
permission_prompt=self._ask_permission,
ask_user_prompt=self._ask_question,
enforce_max_turns=self._config.enforce_max_turns,
)
await start_runtime(self._bundle)
await self._emit(
@@ -99,6 +107,25 @@ class ReactBackendHost:
if request.type == "list_sessions":
await self._handle_list_sessions()
continue
if request.type == "select_command":
await self._handle_select_command(request.command or "")
continue
if request.type == "apply_select_command":
if self._busy:
await self._emit(BackendEvent(type="error", message="Session is busy"))
continue
self._busy = True
try:
should_continue = await self._apply_select_command(
request.command or "",
request.value or "",
)
finally:
self._busy = False
if not should_continue:
await self._emit(BackendEvent(type="shutdown"))
break
continue
if request.type != "submit_line":
await self._emit(BackendEvent(type="error", message=f"Unknown request type: {request.type}"))
continue
@@ -140,10 +167,10 @@ class ReactBackendHost:
continue
await self._request_queue.put(request)
async def _process_line(self, line: str) -> bool:
async def _process_line(self, line: str, *, transcript_line: str | None = None) -> bool:
assert self._bundle is not None
await self._emit(
BackendEvent(type="transcript_item", item=TranscriptItem(role="user", text=line))
BackendEvent(type="transcript_item", item=TranscriptItem(role="user", text=transcript_line or line))
)
async def _print_system(message: str) -> None:
@@ -219,6 +246,18 @@ class ReactBackendHost:
assert self._bundle is not None
new_mode = self._bundle.app_state.get().permission_mode
await self._emit(BackendEvent(type="plan_mode_change", plan_mode=new_mode))
return
if isinstance(event, ErrorEvent):
await self._emit(BackendEvent(type="error", message=event.message))
await self._emit(
BackendEvent(type="transcript_item", item=TranscriptItem(role="system", text=event.message))
)
return
if isinstance(event, StatusEvent):
await self._emit(
BackendEvent(type="transcript_item", item=TranscriptItem(role="system", text=event.message))
)
return
async def _clear_output() -> None:
await self._emit(BackendEvent(type="clear_transcript"))
@@ -235,6 +274,43 @@ class ReactBackendHost:
await self._emit(BackendEvent(type="line_complete"))
return should_continue
async def _apply_select_command(self, command_name: str, value: str) -> bool:
command = command_name.strip().lstrip("/").lower()
selected = value.strip()
line = self._build_select_command_line(command, selected)
if line is None:
await self._emit(BackendEvent(type="error", message=f"Unknown select command: {command_name}"))
await self._emit(BackendEvent(type="line_complete"))
return True
return await self._process_line(line, transcript_line=f"/{command}")
def _build_select_command_line(self, command: str, value: str) -> str | None:
if command == "provider":
return f"/provider {value}"
if command == "resume":
return f"/resume {value}" if value else "/resume"
if command == "permissions":
return f"/permissions {value}"
if command == "theme":
return f"/theme {value}"
if command == "output-style":
return f"/output-style {value}"
if command == "effort":
return f"/effort {value}"
if command == "passes":
return f"/passes {value}"
if command == "turns":
return f"/turns {value}"
if command == "fast":
return f"/fast {value}"
if command == "vim":
return f"/vim {value}"
if command == "voice":
return f"/voice {value}"
if command == "model":
return f"/model {value}"
return None
def _status_snapshot(self) -> BackendEvent:
assert self._bundle is not None
return BackendEvent.status_snapshot(
@@ -278,11 +354,278 @@ class ReactBackendHost:
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Resume Session", "submit_prefix": "/resume "},
modal={"kind": "select", "title": "Resume Session", "command": "resume"},
select_options=options,
)
)
async def _handle_select_command(self, command_name: str) -> None:
assert self._bundle is not None
command = command_name.strip().lstrip("/").lower()
if command == "resume":
await self._handle_list_sessions()
return
settings = self._bundle.current_settings()
state = self._bundle.app_state.get()
_, active_profile = settings.resolve_profile()
current_model = display_model_setting(active_profile)
if command == "provider":
statuses = AuthManager(settings).get_profile_statuses()
options = [
{
"value": name,
"label": info["label"],
"description": f"{info['provider']} / {info['auth_source']}" + (" [missing auth]" if not info["configured"] else ""),
"active": info["active"],
}
for name, info in statuses.items()
]
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Provider Profile", "command": "provider"},
select_options=options,
)
)
return
if command == "permissions":
options = [
{
"value": "default",
"label": "Default",
"description": "Ask before write/execute operations",
"active": settings.permission.mode.value == "default",
},
{
"value": "full_auto",
"label": "Auto",
"description": "Allow all tools automatically",
"active": settings.permission.mode.value == "full_auto",
},
{
"value": "plan",
"label": "Plan Mode",
"description": "Block all write operations",
"active": settings.permission.mode.value == "plan",
},
]
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Permission Mode", "command": "permissions"},
select_options=options,
)
)
return
if command == "theme":
options = [
{
"value": name,
"label": name,
"active": name == settings.theme,
}
for name in list_themes()
]
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Theme", "command": "theme"},
select_options=options,
)
)
return
if command == "output-style":
options = [
{
"value": style.name,
"label": style.name,
"description": style.source,
"active": style.name == settings.output_style,
}
for style in load_output_styles()
]
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Output Style", "command": "output-style"},
select_options=options,
)
)
return
if command == "effort":
options = [
{"value": "low", "label": "Low", "description": "Fastest responses", "active": settings.effort == "low"},
{"value": "medium", "label": "Medium", "description": "Balanced reasoning", "active": settings.effort == "medium"},
{"value": "high", "label": "High", "description": "Deepest reasoning", "active": settings.effort == "high"},
]
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Reasoning Effort", "command": "effort"},
select_options=options,
)
)
return
if command == "passes":
current = int(state.passes or settings.passes)
options = [
{"value": str(value), "label": f"{value} pass{'es' if value != 1 else ''}", "active": value == current}
for value in range(1, 9)
]
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Reasoning Passes", "command": "passes"},
select_options=options,
)
)
return
if command == "turns":
current = self._bundle.engine.max_turns
values = {32, 64, 128, 200, 256, 512}
if isinstance(current, int):
values.add(current)
options = [{"value": "unlimited", "label": "Unlimited", "description": "Do not hard-stop this session", "active": current is None}]
options.extend(
{"value": str(value), "label": f"{value} turns", "active": value == current}
for value in sorted(values)
)
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Max Turns", "command": "turns"},
select_options=options,
)
)
return
if command == "fast":
current = bool(state.fast_mode)
options = [
{"value": "on", "label": "On", "description": "Prefer shorter, faster responses", "active": current},
{"value": "off", "label": "Off", "description": "Use normal response mode", "active": not current},
]
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Fast Mode", "command": "fast"},
select_options=options,
)
)
return
if command == "vim":
current = bool(state.vim_enabled)
options = [
{"value": "on", "label": "On", "description": "Enable Vim keybindings", "active": current},
{"value": "off", "label": "Off", "description": "Use standard keybindings", "active": not current},
]
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Vim Mode", "command": "vim"},
select_options=options,
)
)
return
if command == "voice":
current = bool(state.voice_enabled)
options = [
{"value": "on", "label": "On", "description": state.voice_reason or "Enable voice mode", "active": current},
{"value": "off", "label": "Off", "description": "Disable voice mode", "active": not current},
]
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Voice Mode", "command": "voice"},
select_options=options,
)
)
return
if command == "model":
options = self._model_select_options(current_model, active_profile.provider)
await self._emit(
BackendEvent(
type="select_request",
modal={"kind": "select", "title": "Model", "command": "model"},
select_options=options,
)
)
return
await self._emit(BackendEvent(type="error", message=f"No selector available for /{command}"))
def _model_select_options(self, current_model: str, provider: str) -> list[dict[str, object]]:
provider_name = provider.lower()
if provider_name in {"anthropic", "anthropic_claude"}:
return [
{
"value": value,
"label": label,
"description": description,
"active": value == current_model,
}
for value, label, description in CLAUDE_MODEL_ALIAS_OPTIONS
]
families: list[tuple[str, str]] = []
if provider_name in {"openai-codex", "openai", "openai-compatible", "openrouter", "github_copilot"}:
families.extend(
[
("gpt-5.4", "OpenAI flagship"),
("gpt-5", "General GPT-5"),
("gpt-4.1", "Stable GPT-4.1"),
("o4-mini", "Fast reasoning"),
]
)
elif provider_name in {"moonshot", "moonshot-compatible"}:
families.extend(
[
("kimi-k2.5", "Moonshot K2.5"),
("kimi-k2-turbo-preview", "Faster Moonshot"),
]
)
elif provider_name == "dashscope":
families.extend(
[
("qwen3.5-flash", "Fast Qwen"),
("qwen3-max", "Strong Qwen"),
("deepseek-r1", "Reasoning model"),
]
)
elif provider_name == "gemini":
families.extend(
[
("gemini-2.5-pro", "Gemini Pro"),
("gemini-2.5-flash", "Gemini Flash"),
]
)
seen: set[str] = set()
options: list[dict[str, object]] = []
for value, description in [(current_model, "Current model"), *families]:
if not value or value in seen:
continue
seen.add(value)
options.append(
{
"value": value,
"label": value,
"description": description,
"active": value == current_model,
}
)
return options
async def _ask_permission(self, tool_name: str, reason: str) -> bool:
request_id = uuid4().hex
future: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
@@ -348,6 +691,7 @@ async def run_backend_host(
cwd: str | None = None,
api_client: SupportsStreamingMessages | None = None,
restore_messages: list[dict] | None = None,
enforce_max_turns: bool = True,
) -> int:
"""Run the structured React backend host."""
if cwd:
@@ -362,6 +706,7 @@ async def run_backend_host(
api_format=api_format,
api_client=api_client,
restore_messages=restore_messages,
enforce_max_turns=enforce_max_turns,
)
)
return await host.run()
+11 -1
View File
@@ -15,8 +15,18 @@ from openharness.tasks.types import TaskRecord
class FrontendRequest(BaseModel):
"""One request sent from the React frontend to the Python backend."""
type: Literal["submit_line", "permission_response", "question_response", "list_sessions", "shutdown"]
type: Literal[
"submit_line",
"permission_response",
"question_response",
"list_sessions",
"select_command",
"apply_select_command",
"shutdown",
]
line: str | None = None
command: str | None = None
value: str | None = None
request_id: str | None = None
allowed: bool | None = None
answer: str | None = None
+72 -20
View File
@@ -8,12 +8,14 @@ from pathlib import Path
from typing import Any, Awaitable, Callable
from openharness.api.client import AnthropicApiClient, SupportsStreamingMessages
from openharness.api.codex_client import CodexApiClient
from openharness.api.copilot_client import CopilotClient
from openharness.api.openai_client import OpenAICompatibleClient
from openharness.api.provider import auth_status, detect_provider
from openharness.bridge import get_bridge_manager
from openharness.commands import CommandContext, CommandResult, create_default_command_registry
from openharness.config import get_config_file_path, load_settings
from openharness.config.settings import display_model_setting
from openharness.engine import QueryEngine
from openharness.engine.messages import ConversationMessage, ToolResultBlock, ToolUseBlock
from openharness.engine.query import MaxTurnsExceeded
@@ -50,6 +52,7 @@ class RuntimeBundle:
engine: QueryEngine
commands: object
external_api_client: bool
enforce_max_turns: bool = True
session_id: str = ""
settings_overrides: dict[str, Any] = field(default_factory=dict)
@@ -99,6 +102,43 @@ class RuntimeBundle:
return "\n".join(lines)
def _resolve_api_client_from_settings(settings) -> SupportsStreamingMessages:
"""Build the appropriate API client for the resolved settings."""
if settings.api_format == "copilot":
from openharness.api.copilot_client import COPILOT_DEFAULT_MODEL
copilot_model = (
COPILOT_DEFAULT_MODEL
if settings.model in {"claude-sonnet-4-20250514", "claude-sonnet-4-6", "sonnet", "default"}
else settings.model
)
return CopilotClient(model=copilot_model)
if settings.provider == "openai_codex":
auth = settings.resolve_auth()
return CodexApiClient(
auth_token=auth.value,
base_url=settings.base_url,
)
if settings.provider == "anthropic_claude":
return AnthropicApiClient(
auth_token=settings.resolve_auth().value,
base_url=settings.base_url,
claude_oauth=True,
auth_token_resolver=lambda: settings.resolve_auth().value,
)
if settings.api_format == "openai":
auth = settings.resolve_auth()
return OpenAICompatibleClient(
api_key=auth.value,
base_url=settings.base_url,
)
auth = settings.resolve_auth()
return AnthropicApiClient(
api_key=auth.value,
base_url=settings.base_url,
)
async def build_runtime(
*,
prompt: str | None = None,
@@ -112,6 +152,7 @@ async def build_runtime(
permission_prompt: PermissionPrompt | None = None,
ask_user_prompt: AskUserPrompt | None = None,
restore_messages: list[dict] | None = None,
enforce_max_turns: bool = True,
) -> RuntimeBundle:
"""Build the shared runtime for an OpenHarness session."""
settings_overrides: dict[str, Any] = {
@@ -127,28 +168,17 @@ async def build_runtime(
plugins = load_plugins(settings, cwd)
if api_client:
resolved_api_client = api_client
elif settings.api_format == "copilot":
from openharness.api.copilot_client import COPILOT_DEFAULT_MODEL
copilot_model = settings.model if settings.model != "claude-sonnet-4-20250514" else COPILOT_DEFAULT_MODEL
resolved_api_client = CopilotClient(model=copilot_model)
elif settings.api_format == "openai":
resolved_api_client = OpenAICompatibleClient(
api_key=settings.resolve_api_key(),
base_url=settings.base_url,
)
else:
resolved_api_client = AnthropicApiClient(
api_key=settings.resolve_api_key(),
base_url=settings.base_url,
)
resolved_api_client = _resolve_api_client_from_settings(settings)
mcp_manager = McpClientManager(load_mcp_server_configs(settings, plugins))
await mcp_manager.connect_all()
tool_registry = create_default_tool_registry(mcp_manager)
provider = detect_provider(settings)
_, active_profile = settings.resolve_profile()
bridge_manager = get_bridge_manager()
app_state = AppStateStore(
AppState(
model=settings.model,
model=display_model_setting(active_profile),
permission_mode=settings.permission.mode.value,
theme=settings.theme,
cwd=cwd,
@@ -178,6 +208,7 @@ async def build_runtime(
default_model=settings.model,
),
)
engine_max_turns = settings.max_turns if (enforce_max_turns or max_turns is not None) else None
engine = QueryEngine(
api_client=resolved_api_client,
tool_registry=tool_registry,
@@ -186,7 +217,7 @@ async def build_runtime(
model=settings.model,
system_prompt=build_runtime_system_prompt(settings, cwd=cwd, latest_user_prompt=prompt),
max_tokens=settings.max_tokens,
max_turns=settings.max_turns,
max_turns=engine_max_turns,
permission_prompt=permission_prompt,
ask_user_prompt=ask_user_prompt,
hook_executor=hook_executor,
@@ -211,6 +242,7 @@ async def build_runtime(
engine=engine,
commands=create_default_command_registry(),
external_api_client=api_client is not None,
enforce_max_turns=enforce_max_turns or max_turns is not None,
session_id=uuid4().hex[:12],
settings_overrides=settings_overrides,
)
@@ -292,17 +324,19 @@ def _format_pending_tool_results(messages: list[ConversationMessage]) -> str | N
if len(tool_results) > max_results:
lines.append(f"(+{len(tool_results) - max_results} more tool results)")
lines.append("To continue from these results, run: /continue 32 (or any count).")
lines.append("To continue from these results, run: /continue [COUNT].")
return "\n".join(lines)
def sync_app_state(bundle: RuntimeBundle) -> None:
"""Refresh UI state from current settings and dynamic keybindings."""
settings = bundle.current_settings()
bundle.engine.set_max_turns(settings.max_turns)
if bundle.enforce_max_turns:
bundle.engine.set_max_turns(settings.max_turns)
provider = detect_provider(settings)
_, active_profile = settings.resolve_profile()
bundle.app_state.set(
model=settings.model,
model=display_model_setting(active_profile),
permission_mode=settings.permission.mode.value,
theme=settings.theme,
cwd=bundle.cwd,
@@ -324,6 +358,20 @@ def sync_app_state(bundle: RuntimeBundle) -> None:
)
def refresh_runtime_client(bundle: RuntimeBundle) -> None:
"""Refresh the active runtime client after provider/auth/profile changes."""
settings = bundle.current_settings()
if not bundle.external_api_client:
bundle.api_client = _resolve_api_client_from_settings(settings)
bundle.engine.set_api_client(bundle.api_client)
bundle.hook_executor.update_context(
api_client=bundle.api_client,
default_model=settings.model,
)
bundle.engine.set_model(settings.model)
sync_app_state(bundle)
async def handle_line(
bundle: RuntimeBundle,
line: str,
@@ -353,10 +401,13 @@ async def handle_line(
app_state=bundle.app_state,
),
)
if result.refresh_runtime:
refresh_runtime_client(bundle)
await _render_command_result(result, print_system, clear_output, render_event)
if result.continue_pending:
settings = bundle.current_settings()
bundle.engine.set_max_turns(settings.max_turns)
if bundle.enforce_max_turns:
bundle.engine.set_max_turns(settings.max_turns)
system_prompt = build_runtime_system_prompt(
settings,
cwd=bundle.cwd,
@@ -384,7 +435,8 @@ async def handle_line(
return not result.should_exit
settings = bundle.current_settings()
bundle.engine.set_max_turns(settings.max_turns)
if bundle.enforce_max_turns:
bundle.engine.set_max_turns(settings.max_turns)
system_prompt = build_runtime_system_prompt(settings, cwd=bundle.cwd, latest_user_prompt=line)
bundle.engine.set_system_prompt(system_prompt)
try:
+11
View File
@@ -19,6 +19,8 @@ from openharness.config.settings import load_settings, save_settings
from openharness.engine.stream_events import (
AssistantTextDelta,
AssistantTurnComplete,
ErrorEvent,
StatusEvent,
StreamEvent,
ToolExecutionCompleted,
ToolExecutionStarted,
@@ -329,6 +331,15 @@ class OpenHarnessTerminalApp(App[None]):
if isinstance(event, ToolExecutionCompleted):
prefix = "tool-error>" if event.is_error else "tool-result>"
self._append_line(f"{prefix} {event.tool_name}: {event.output}")
return
if isinstance(event, ErrorEvent):
self._append_line(f"error> {event.message}")
self._assistant_buffer = ""
self._set_current_response("Ready.")
return
if isinstance(event, StatusEvent):
self._append_line(f"system> {event.message}")
def action_clear_conversation(self) -> None:
if self._bundle is None:
+163
View File
@@ -0,0 +1,163 @@
from __future__ import annotations
from openharness.api.client import AnthropicApiClient, OAUTH_BETA_HEADER
def test_anthropic_client_adds_oauth_beta_header(monkeypatch):
captured: dict[str, object] = {}
class _FakeAsyncAnthropic:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("openharness.api.client.AsyncAnthropic", _FakeAsyncAnthropic)
AnthropicApiClient(auth_token="oauth-token")
assert captured["auth_token"] == "oauth-token"
assert captured["default_headers"] == {"anthropic-beta": OAUTH_BETA_HEADER}
def test_anthropic_client_uses_api_key_without_oauth_beta(monkeypatch):
captured: dict[str, object] = {}
class _FakeAsyncAnthropic:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("openharness.api.client.AsyncAnthropic", _FakeAsyncAnthropic)
AnthropicApiClient(api_key="api-key")
assert captured["api_key"] == "api-key"
assert "default_headers" not in captured
def test_anthropic_client_adds_claude_oauth_identity_headers(monkeypatch):
captured: dict[str, object] = {}
class _FakeAsyncAnthropic:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("openharness.api.client.AsyncAnthropic", _FakeAsyncAnthropic)
monkeypatch.setattr(
"openharness.auth.external.get_claude_code_version",
lambda: "2.1.92",
)
monkeypatch.setattr(
"openharness.auth.external.get_claude_code_session_id",
lambda: "session-123",
)
monkeypatch.setattr(
"openharness.api.client.get_claude_code_session_id",
lambda: "session-123",
)
monkeypatch.setattr(
"openharness.api.client.claude_attribution_header",
lambda: "x-anthropic-billing-header: cc_version=2.1.92; cc_entrypoint=cli;",
)
AnthropicApiClient(auth_token="oauth-token", claude_oauth=True)
headers = captured["default_headers"]
assert captured["auth_token"] == "oauth-token"
assert headers["x-app"] == "cli"
assert headers["user-agent"] == "claude-cli/2.1.92 (external, cli)"
assert headers["X-Claude-Code-Session-Id"] == "session-123"
assert "oauth-2025-04-20" in headers["anthropic-beta"]
assert "claude-code-20250219" in headers["anthropic-beta"]
def test_anthropic_client_refreshes_claude_token_on_request(monkeypatch):
captured_tokens: list[str] = []
class _FakeStream:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def __aiter__(self):
if False:
yield None
return
async def get_final_message(self):
class _Usage:
input_tokens = 1
output_tokens = 1
class _Message:
usage = _Usage()
stop_reason = "end_turn"
role = "assistant"
content = []
return _Message()
class _FakeMessages:
def __init__(self):
self.last_params = None
def stream(self, **params):
self.last_params = params
return _FakeStream()
class _FakeBeta:
def __init__(self):
self.messages = _FakeMessages()
class _FakeAsyncAnthropic:
def __init__(self, **kwargs):
captured_tokens.append(kwargs["auth_token"])
self.beta = _FakeBeta()
self.messages = _FakeMessages()
monkeypatch.setattr("openharness.api.client.AsyncAnthropic", _FakeAsyncAnthropic)
monkeypatch.setattr(
"openharness.auth.external.get_claude_code_session_id",
lambda: "session-123",
)
monkeypatch.setattr(
"openharness.api.client.get_claude_code_session_id",
lambda: "session-123",
)
current_token = {"value": "initial-token"}
client = AnthropicApiClient(
auth_token="initial-token",
claude_oauth=True,
auth_token_resolver=lambda: current_token["value"],
)
current_token["value"] = "refreshed-token"
from openharness.api.client import ApiMessageRequest
async def _run():
events = []
async for event in client.stream_message(
ApiMessageRequest(
model="claude-sonnet-4-6",
messages=[],
system_prompt="system prompt",
)
):
events.append(event)
return events
import asyncio
events = asyncio.run(_run())
assert captured_tokens == ["initial-token", "refreshed-token"]
assert events
assert client._client.beta.messages.last_params["metadata"] == {
"user_id": '{"device_id":"openharness","session_id":"session-123","account_uuid":""}'
}
assert "oauth-2025-04-20" in client._client.beta.messages.last_params["betas"]
assert client._client.beta.messages.last_params["system"].startswith(
"x-anthropic-billing-header: cc_version=2.1.92; cc_entrypoint=cli;\n"
)
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
import json
from typing import Any
import pytest
from openharness.api.client import ApiMessageRequest, ApiMessageCompleteEvent, ApiTextDeltaEvent
from openharness.api.codex_client import CodexApiClient, _convert_messages_to_codex, _resolve_codex_url
from openharness.engine.messages import ConversationMessage, TextBlock, ToolResultBlock, ToolUseBlock
class _FakeStreamResponse:
def __init__(self, *, status_code: int = 200, lines: list[str] | None = None, body: str = "") -> None:
self.status_code = status_code
self._lines = lines or []
self._body = body.encode("utf-8")
async def __aenter__(self) -> "_FakeStreamResponse":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
async def aread(self) -> bytes:
return self._body
async def aiter_lines(self):
for line in self._lines:
yield line
class _FakeAsyncClient:
def __init__(self, response: _FakeStreamResponse, sink: dict[str, Any]) -> None:
self._response = response
self._sink = sink
async def __aenter__(self) -> "_FakeAsyncClient":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
def stream(self, method: str, url: str, *, headers: dict[str, str], json: dict[str, Any]):
self._sink["method"] = method
self._sink["url"] = url
self._sink["headers"] = headers
self._sink["json"] = json
return self._response
def _b64url(data: dict[str, object]) -> str:
raw = json.dumps(data, separators=(",", ":")).encode("utf-8")
import base64
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _fake_codex_token() -> str:
payload = {"https://api.openai.com/auth": {"chatgpt_account_id": "acct_test"}}
return f"{_b64url({'alg': 'none', 'typ': 'JWT'})}.{_b64url(payload)}.sig"
def test_convert_messages_to_codex():
messages = [
ConversationMessage.from_user_text("Inspect file"),
ConversationMessage(
role="assistant",
content=[
TextBlock(text="I'll inspect it."),
ToolUseBlock(id="call_123", name="read_file", input={"path": "README.md"}),
],
),
ConversationMessage(
role="user",
content=[ToolResultBlock(tool_use_id="call_123", content="hello", is_error=False)],
),
]
converted = _convert_messages_to_codex(messages)
assert converted[0] == {
"role": "user",
"content": [{"type": "input_text", "text": "Inspect file"}],
}
assert converted[1]["type"] == "message"
assert converted[1]["role"] == "assistant"
assert converted[2]["type"] == "function_call"
assert converted[2]["call_id"] == "call_123"
assert json.loads(converted[2]["arguments"]) == {"path": "README.md"}
assert converted[3] == {
"type": "function_call_output",
"call_id": "call_123",
"output": "hello",
}
def test_resolve_codex_url_ignores_unrelated_base_url():
assert _resolve_codex_url("https://api.moonshot.cn/anthropic") == "https://chatgpt.com/backend-api/codex/responses"
@pytest.mark.asyncio
async def test_codex_client_streams_text(monkeypatch):
sink: dict[str, Any] = {}
response = _FakeStreamResponse(
lines=[
'event: response.output_item.added',
'data: {"type":"response.output_item.added","item":{"id":"msg_1","type":"message","content":[],"role":"assistant"}}',
"",
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","delta":"CODE"}',
"",
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","delta":"X_OK"}',
"",
'event: response.output_item.done',
'data: {"type":"response.output_item.done","item":{"id":"msg_1","type":"message","content":[{"type":"output_text","text":"CODEX_OK","annotations":[]}]}}',
"",
'event: response.completed',
'data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":12,"output_tokens":3}}}',
"",
]
)
monkeypatch.setattr(
"openharness.api.codex_client.httpx.AsyncClient",
lambda *args, **kwargs: _FakeAsyncClient(response, sink),
)
client = CodexApiClient(_fake_codex_token())
request = ApiMessageRequest(
model="gpt-5.4",
messages=[ConversationMessage.from_user_text("hi")],
system_prompt="Be helpful.",
)
events = [event async for event in client.stream_message(request)]
assert [event.text for event in events if isinstance(event, ApiTextDeltaEvent)] == ["CODE", "X_OK"]
complete = next(event for event in events if isinstance(event, ApiMessageCompleteEvent))
assert complete.message.text == "CODEX_OK"
assert complete.usage.input_tokens == 12
assert complete.usage.output_tokens == 3
assert sink["url"].endswith("/codex/responses")
assert sink["json"]["instructions"] == "Be helpful."
assert sink["headers"]["OpenAI-Beta"] == "responses=experimental"
@pytest.mark.asyncio
async def test_codex_client_emits_tool_use(monkeypatch):
sink: dict[str, Any] = {}
response = _FakeStreamResponse(
lines=[
'data: {"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","arguments":"","call_id":"call_abc","name":"glob"}}',
"",
'data: {"type":"response.output_item.done","item":{"id":"fc_1","type":"function_call","arguments":"{\\"pattern\\":\\"src/**/*.py\\"}","call_id":"call_abc","name":"glob"}}',
"",
'data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":7,"output_tokens":2}}}',
"",
]
)
monkeypatch.setattr(
"openharness.api.codex_client.httpx.AsyncClient",
lambda *args, **kwargs: _FakeAsyncClient(response, sink),
)
client = CodexApiClient(_fake_codex_token())
request = ApiMessageRequest(
model="gpt-5.4",
messages=[ConversationMessage.from_user_text("glob")],
system_prompt="Use tools.",
tools=[{"name": "glob", "description": "find files", "input_schema": {"type": "object"}}],
)
events = [event async for event in client.stream_message(request)]
complete = next(event for event in events if isinstance(event, ApiMessageCompleteEvent))
assert complete.stop_reason == "tool_use"
assert len(complete.message.tool_uses) == 1
tool_use = complete.message.tool_uses[0]
assert tool_use.id == "call_abc"
assert tool_use.name == "glob"
assert tool_use.input == {"pattern": "src/**/*.py"}
assert sink["json"]["tools"][0]["name"] == "glob"
+435
View File
@@ -0,0 +1,435 @@
from __future__ import annotations
import base64
import json
from pathlib import Path
import pytest
from typer.testing import CliRunner
from openharness.auth.external import (
CLAUDE_PROVIDER,
CODEX_PROVIDER,
ExternalAuthState,
describe_external_binding,
default_binding_for_provider,
get_claude_code_version,
load_external_credential,
refresh_claude_oauth_credential,
)
from openharness.auth.storage import ExternalAuthBinding, load_external_binding, store_external_binding
from openharness.cli import app
from openharness.config.settings import Settings, load_settings
def _b64url(data: dict[str, object]) -> str:
raw = json.dumps(data, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _fake_jwt(payload: dict[str, object]) -> str:
return f"{_b64url({'alg': 'none', 'typ': 'JWT'})}.{_b64url(payload)}.sig"
def test_load_codex_external_credential(monkeypatch, tmp_path: Path):
codex_home = tmp_path / "codex-home"
codex_home.mkdir()
token = _fake_jwt(
{
"exp": 4_102_444_800,
"https://api.openai.com/profile": {"email": "dev@example.com"},
}
)
(codex_home / "auth.json").write_text(
json.dumps(
{
"auth_mode": "chatgpt",
"tokens": {
"access_token": token,
"refresh_token": "refresh-token",
},
}
),
encoding="utf-8",
)
monkeypatch.setenv("CODEX_HOME", str(codex_home))
binding = default_binding_for_provider(CODEX_PROVIDER)
credential = load_external_credential(binding)
assert credential.provider == CODEX_PROVIDER
assert credential.auth_kind == "api_key"
assert credential.value == token
assert credential.refresh_token == "refresh-token"
assert credential.profile_label == "dev@example.com"
assert credential.expires_at_ms == 4_102_444_800_000
def test_load_claude_external_credential(monkeypatch, tmp_path: Path):
claude_home = tmp_path / "claude-home"
claude_home.mkdir()
(claude_home / ".credentials.json").write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "claude-access-token",
"refreshToken": "claude-refresh-token",
"expiresAt": 4_102_444_800_000,
}
}
),
encoding="utf-8",
)
monkeypatch.setenv("CLAUDE_HOME", str(claude_home))
binding = default_binding_for_provider(CLAUDE_PROVIDER)
credential = load_external_credential(binding)
assert credential.provider == CLAUDE_PROVIDER
assert credential.auth_kind == "auth_token"
assert credential.value == "claude-access-token"
assert credential.refresh_token == "claude-refresh-token"
assert credential.expires_at_ms == 4_102_444_800_000
def test_settings_resolve_auth_uses_external_binding(monkeypatch, tmp_path: Path):
config_dir = tmp_path / "config"
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(config_dir))
source = tmp_path / "claude-credentials.json"
source.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "bound-claude-token",
"refreshToken": "bound-claude-refresh",
"expiresAt": 4_102_444_800_000,
}
}
),
encoding="utf-8",
)
store_external_binding(
ExternalAuthBinding(
provider=CLAUDE_PROVIDER,
source_path=str(source),
source_kind="claude_credentials_json",
managed_by="claude-cli",
profile_label="Claude CLI",
)
)
resolved = Settings(active_profile="claude-subscription").resolve_auth()
assert resolved.auth_kind == "auth_token"
assert resolved.value == "bound-claude-token"
assert str(source) in resolved.source
def test_settings_resolve_auth_refreshes_expired_external_binding(monkeypatch, tmp_path: Path):
config_dir = tmp_path / "config"
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(config_dir))
source = tmp_path / "claude-credentials.json"
source.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "expired-token",
"refreshToken": "refresh-token",
"expiresAt": 1,
}
}
),
encoding="utf-8",
)
monkeypatch.setattr(
"openharness.auth.external.refresh_claude_oauth_credential",
lambda refresh_token: {
"access_token": "fresh-token",
"refresh_token": refresh_token,
"expires_at_ms": 4_102_444_800_000,
},
)
store_external_binding(
ExternalAuthBinding(
provider=CLAUDE_PROVIDER,
source_path=str(source),
source_kind="claude_credentials_json",
managed_by="claude-cli",
profile_label="Claude CLI",
)
)
resolved = Settings(active_profile="claude-subscription").resolve_auth()
assert resolved.value == "fresh-token"
persisted = json.loads(source.read_text(encoding="utf-8"))
assert persisted["claudeAiOauth"]["accessToken"] == "fresh-token"
assert persisted["claudeAiOauth"]["refreshToken"] == "refresh-token"
def test_cli_codex_login_binds_without_switching(monkeypatch, tmp_path: Path):
config_dir = tmp_path / "config"
codex_home = tmp_path / "codex-home"
config_dir.mkdir()
codex_home.mkdir()
token = _fake_jwt({"exp": 4_102_444_800})
(codex_home / "auth.json").write_text(
json.dumps(
{
"auth_mode": "chatgpt",
"tokens": {
"access_token": token,
"refresh_token": "refresh-token",
},
}
),
encoding="utf-8",
)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
(config_dir / "settings.json").write_text(
json.dumps(
{
"api_format": "openai",
"provider": "openai",
"model": "kimi-k2.5",
"base_url": "https://api.moonshot.cn/anthropic",
"api_key": "stale-key",
}
),
encoding="utf-8",
)
runner = CliRunner()
result = runner.invoke(app, ["auth", "codex-login"])
assert result.exit_code == 0
settings = load_settings()
assert settings.active_profile != "codex"
assert settings.provider == "openai"
assert settings.base_url == "https://api.moonshot.cn/anthropic"
assert settings.api_key == "stale-key"
assert "Use `oh provider use codex` to activate it." in result.stdout
binding = load_external_binding(CODEX_PROVIDER)
assert binding is not None
assert Path(binding.source_path) == codex_home / "auth.json"
def test_cli_claude_login_binds_without_switching(monkeypatch, tmp_path: Path):
config_dir = tmp_path / "config"
claude_home = tmp_path / "claude-home"
claude_home.mkdir()
(claude_home / ".credentials.json").write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "claude-access-token",
"refreshToken": "claude-refresh-token",
"expiresAt": 4_102_444_800_000,
}
}
),
encoding="utf-8",
)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("CLAUDE_HOME", str(claude_home))
runner = CliRunner()
result = runner.invoke(app, ["auth", "claude-login"])
assert result.exit_code == 0
settings = load_settings()
assert settings.provider == "anthropic"
assert settings.api_format == "anthropic"
assert settings.active_profile == "claude-api"
assert "Use `oh provider use claude-subscription` to activate it." in result.stdout
binding = load_external_binding(CLAUDE_PROVIDER)
assert binding is not None
assert Path(binding.source_path) == claude_home / ".credentials.json"
def test_cli_claude_login_refreshes_expired_credentials(monkeypatch, tmp_path: Path):
config_dir = tmp_path / "config"
claude_home = tmp_path / "claude-home"
claude_home.mkdir()
source = claude_home / ".credentials.json"
source.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "expired-token",
"refreshToken": "claude-refresh-token",
"expiresAt": 1,
"scopes": ["user:inference"],
}
}
),
encoding="utf-8",
)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("CLAUDE_HOME", str(claude_home))
monkeypatch.setattr(
"openharness.auth.external.refresh_claude_oauth_credential",
lambda refresh_token: {
"access_token": "fresh-token",
"refresh_token": refresh_token,
"expires_at_ms": 4_102_444_800_000,
},
)
runner = CliRunner()
result = runner.invoke(app, ["auth", "claude-login"])
assert result.exit_code == 0
persisted = json.loads(source.read_text(encoding="utf-8"))
assert persisted["claudeAiOauth"]["accessToken"] == "fresh-token"
assert persisted["claudeAiOauth"]["scopes"] == ["user:inference"]
def test_cli_provider_use_activates_codex_profile(monkeypatch, tmp_path: Path):
config_dir = tmp_path / "config"
codex_home = tmp_path / "codex-home"
config_dir.mkdir()
codex_home.mkdir()
token = _fake_jwt({"exp": 4_102_444_800})
(codex_home / "auth.json").write_text(
json.dumps(
{
"auth_mode": "chatgpt",
"tokens": {
"access_token": token,
"refresh_token": "refresh-token",
},
}
),
encoding="utf-8",
)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
runner = CliRunner()
assert runner.invoke(app, ["auth", "codex-login"]).exit_code == 0
result = runner.invoke(app, ["provider", "use", "codex"])
assert result.exit_code == 0
settings = load_settings()
assert settings.active_profile == "codex"
assert settings.provider == CODEX_PROVIDER
assert settings.api_format == "openai"
assert settings.base_url is None
assert settings.model == "gpt-5.4"
def test_settings_resolve_auth_rejects_third_party_base_url_for_claude_subscription(
monkeypatch,
tmp_path: Path,
):
config_dir = tmp_path / "config"
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(config_dir))
source = tmp_path / "claude-credentials.json"
source.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "valid-token",
"refreshToken": "refresh-token",
"expiresAt": 4_102_444_800_000,
}
}
),
encoding="utf-8",
)
store_external_binding(
ExternalAuthBinding(
provider=CLAUDE_PROVIDER,
source_path=str(source),
source_kind="claude_credentials_json",
managed_by="claude-cli",
profile_label="Claude CLI",
)
)
settings = Settings(active_profile="claude-subscription").model_copy(
update={"base_url": "https://api.moonshot.cn/anthropic"}
).sync_active_profile_from_flat_fields()
with pytest.raises(ValueError, match="third-party"):
settings.resolve_auth()
def test_describe_external_binding_reports_refreshable_claude_token(tmp_path: Path):
source = tmp_path / "claude-credentials.json"
source.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "expired-token",
"refreshToken": "refresh-token",
"expiresAt": 1,
}
}
),
encoding="utf-8",
)
state = describe_external_binding(
ExternalAuthBinding(
provider=CLAUDE_PROVIDER,
source_path=str(source),
source_kind="claude_credentials_json",
managed_by="claude-cli",
profile_label="Claude CLI",
)
)
assert state == ExternalAuthState(
configured=True,
state="refreshable",
source="external",
detail=f"expired token can be refreshed from {source}",
)
def test_refresh_claude_oauth_credential(monkeypatch):
class _FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return json.dumps(
{
"access_token": "fresh-token",
"refresh_token": "fresh-refresh",
"expires_in": 7200,
}
).encode("utf-8")
monkeypatch.setattr(
"openharness.auth.external.urllib.request.urlopen",
lambda request, timeout=10: _FakeResponse(),
)
monkeypatch.setattr("openharness.auth.external.time.time", lambda: 1000)
refreshed = refresh_claude_oauth_credential("refresh-token")
assert refreshed["access_token"] == "fresh-token"
assert refreshed["refresh_token"] == "fresh-refresh"
assert refreshed["expires_at_ms"] == (1000 * 1000) + (7200 * 1000)
def test_get_claude_code_version_uses_fallback(monkeypatch):
class _Result:
returncode = 1
stdout = ""
monkeypatch.setattr(
"openharness.auth.external.subprocess.run",
lambda *args, **kwargs: _Result(),
)
monkeypatch.setattr("openharness.auth.external._claude_code_version_cache", None)
assert get_claude_code_version() == "2.1.92"
+113 -3
View File
@@ -78,13 +78,123 @@ async def test_permissions_command_persists(tmp_path: Path, monkeypatch):
async def test_model_command_persists(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, args = registry.lookup("/model set claude-opus-test")
command, args = registry.lookup("/model opus")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert "claude-opus-test" in result.message
assert load_settings().model == "claude-opus-test"
assert "opus" in result.message
assert load_settings().resolve_profile()[1].last_model == "opus"
assert load_settings().model == "claude-opus-4-6"
@pytest.mark.asyncio
async def test_model_command_accepts_direct_value(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
command, args = registry.lookup("/model gpt-5.4")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert "gpt-5.4" in result.message
assert load_settings().model == "gpt-5.4"
@pytest.mark.asyncio
async def test_model_command_default_clears_profile_override(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
save_settings(
Settings().model_copy(
update={
"active_profile": "claude-api",
"profiles": {
"claude-api": {
"label": "Claude API",
"provider": "anthropic",
"api_format": "anthropic",
"auth_source": "anthropic_api_key",
"default_model": "sonnet",
"last_model": "opus",
}
},
}
)
)
registry = create_default_command_registry()
command, args = registry.lookup("/model default")
assert command is not None
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
assert "reset to default" in result.message
assert load_settings().resolve_profile()[1].last_model == ""
assert load_settings().model == "claude-sonnet-4-6"
@pytest.mark.asyncio
async def test_turns_show_reports_unlimited_engine_when_session_is_unbounded(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
context.engine.set_max_turns(None)
command, args = registry.lookup("/turns show")
assert command is not None
result = await command.handler(args, context)
assert "Max turns (engine): unlimited" in result.message
@pytest.mark.asyncio
async def test_turns_command_accepts_unlimited(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
registry = create_default_command_registry()
context = _make_context(tmp_path)
command, args = registry.lookup("/turns unlimited")
assert command is not None
result = await command.handler(args, context)
assert "unlimited for this session" in result.message
assert context.engine.max_turns is None
@pytest.mark.asyncio
async def test_provider_command_switches_profile_and_requests_runtime_refresh(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
save_settings(
Settings().model_copy(
update={
"profiles": {
"kimi-anthropic": {
"label": "Kimi Anthropic",
"provider": "anthropic",
"api_format": "anthropic",
"auth_source": "anthropic_api_key",
"default_model": "kimi-k2.5",
"last_model": "kimi-k2.5",
"base_url": "https://api.moonshot.cn/anthropic",
}
}
}
)
)
registry = create_default_command_registry()
context = _make_context(tmp_path)
command, args = registry.lookup("/provider kimi-anthropic")
assert command is not None
result = await command.handler(args, context)
loaded = load_settings()
assert result.refresh_runtime is True
assert loaded.active_profile == "kimi-anthropic"
assert loaded.base_url == "https://api.moonshot.cn/anthropic"
assert loaded.model == "kimi-k2.5"
@pytest.mark.asyncio
+148 -3
View File
@@ -7,14 +7,21 @@ from pathlib import Path
import pytest
from openharness.config.settings import Settings, load_settings, save_settings
from openharness.config.settings import (
ProviderProfile,
Settings,
display_model_setting,
load_settings,
normalize_anthropic_model_name,
save_settings,
)
class TestSettings:
def test_defaults(self):
s = Settings()
assert s.api_key == ""
assert s.model == "claude-sonnet-4-20250514"
assert s.model == "claude-sonnet-4-6"
assert s.max_tokens == 16384
assert s.max_turns == 200
assert s.fast_mode is False
@@ -61,7 +68,7 @@ class TestLoadSaveSettings:
def test_load_missing_file_returns_defaults(self, tmp_path: Path):
path = tmp_path / "nonexistent.json"
s = load_settings(path)
assert s == Settings()
assert s == Settings().materialize_active_profile()
def test_load_existing_file(self, tmp_path: Path):
path = tmp_path / "settings.json"
@@ -81,6 +88,144 @@ class TestLoadSaveSettings:
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_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_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)
+76 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
import pytest
from openharness.api.client import ApiMessageCompleteEvent, ApiTextDeltaEvent
from openharness.api.client import ApiMessageCompleteEvent, ApiRetryEvent, ApiTextDeltaEvent
from openharness.api.usage import UsageSnapshot
from openharness.config.settings import PermissionSettings
from openharness.engine.messages import ConversationMessage, TextBlock, ToolUseBlock
@@ -15,6 +15,7 @@ from openharness.engine.query_engine import QueryEngine
from openharness.engine.stream_events import (
AssistantTextDelta,
AssistantTurnComplete,
StatusEvent,
ToolExecutionCompleted,
ToolExecutionStarted,
)
@@ -65,6 +66,17 @@ class StaticApiClient:
)
class RetryThenSuccessApiClient:
async def stream_message(self, request):
del request
yield ApiRetryEvent(message="rate limited", attempt=1, max_attempts=4, delay_seconds=1.5)
yield ApiMessageCompleteEvent(
message=ConversationMessage(role="assistant", content=[TextBlock(text="after retry")]),
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
stop_reason=None,
)
@pytest.mark.asyncio
async def test_query_engine_plain_text_reply(tmp_path: Path):
engine = QueryEngine(
@@ -145,6 +157,69 @@ async def test_query_engine_executes_tool_calls(tmp_path: Path):
assert len(engine.messages) == 4
@pytest.mark.asyncio
async def test_query_engine_allows_unbounded_turns_when_max_turns_is_none(tmp_path: Path):
sample = tmp_path / "hello.txt"
sample.write_text("alpha\nbeta\n", encoding="utf-8")
engine = QueryEngine(
api_client=FakeApiClient(
[
_FakeResponse(
message=ConversationMessage(
role="assistant",
content=[
TextBlock(text="I will inspect the file."),
ToolUseBlock(
id="toolu_123",
name="read_file",
input={"path": str(sample), "offset": 0, "limit": 2},
),
],
),
usage=UsageSnapshot(input_tokens=4, output_tokens=3),
),
_FakeResponse(
message=ConversationMessage(
role="assistant",
content=[TextBlock(text="The file contains alpha and beta.")],
),
usage=UsageSnapshot(input_tokens=8, output_tokens=6),
),
]
),
tool_registry=create_default_tool_registry(),
permission_checker=PermissionChecker(PermissionSettings()),
cwd=tmp_path,
model="claude-test",
system_prompt="system",
max_turns=None,
)
events = [event async for event in engine.submit_message("read the file")]
assert isinstance(events[-1], AssistantTurnComplete)
assert "alpha and beta" in events[-1].message.text
assert engine.max_turns is None
@pytest.mark.asyncio
async def test_query_engine_surfaces_retry_status_events(tmp_path: Path):
engine = QueryEngine(
api_client=RetryThenSuccessApiClient(),
tool_registry=create_default_tool_registry(),
permission_checker=PermissionChecker(PermissionSettings()),
cwd=tmp_path,
model="claude-test",
system_prompt="system",
)
events = [event async for event in engine.submit_message("hello")]
assert any(isinstance(event, StatusEvent) and "retrying in 1.5s" in event.message for event in events)
assert isinstance(events[-1], AssistantTurnComplete)
@pytest.mark.asyncio
async def test_query_engine_respects_pre_tool_hook_blocks(tmp_path: Path):
sample = tmp_path / "hello.txt"
+212
View File
@@ -30,6 +30,19 @@ class StaticApiClient:
)
class FailingApiClient:
"""Fake client that triggers the query-loop ErrorEvent path."""
def __init__(self, message: str) -> None:
self._message = message
async def stream_message(self, request):
del request
if False:
yield None
raise RuntimeError(self._message)
class FakeBinaryStdout:
"""Capture protocol writes through a binary stdout buffer."""
@@ -106,6 +119,37 @@ async def test_backend_host_processes_model_turn(tmp_path, monkeypatch):
)
@pytest.mark.asyncio
async def test_backend_host_surfaces_query_errors(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
host = ReactBackendHost(BackendHostConfig(api_client=FailingApiClient("rate limit")))
host._bundle = await build_runtime(api_client=FailingApiClient("rate limit"))
events = []
async def _emit(event):
events.append(event)
host._emit = _emit # type: ignore[method-assign]
await start_runtime(host._bundle)
try:
should_continue = await host._process_line("hi")
finally:
await close_runtime(host._bundle)
assert should_continue is True
assert any(event.type == "error" and "rate limit" in event.message for event in events)
assert any(
event.type == "transcript_item"
and event.item
and event.item.role == "system"
and "rate limit" in event.item.text
for event in events
)
@pytest.mark.asyncio
async def test_backend_host_command_does_not_reset_cli_overrides(tmp_path, monkeypatch):
"""Regression: slash commands should not snap model/provider back to persisted defaults.
@@ -147,6 +191,23 @@ async def test_backend_host_command_does_not_reset_cli_overrides(tmp_path, monke
await close_runtime(host._bundle)
@pytest.mark.asyncio
async def test_build_runtime_leaves_interactive_sessions_unbounded_by_default(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
bundle = await build_runtime(
api_client=StaticApiClient("unused"),
enforce_max_turns=False,
)
try:
assert bundle.engine.max_turns is None
assert bundle.enforce_max_turns is False
finally:
await close_runtime(bundle)
@pytest.mark.asyncio
async def test_backend_host_emits_utf8_protocol_bytes(monkeypatch):
host = ReactBackendHost(BackendHostConfig())
@@ -161,3 +222,154 @@ async def test_backend_host_emits_utf8_protocol_bytes(monkeypatch):
payload = json.loads(decoded.removeprefix("OHJSON:"))
assert payload["type"] == "assistant_delta"
assert payload["message"] == "你好😊"
@pytest.mark.asyncio
async def test_backend_host_emits_model_select_request(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused")))
host._bundle = await build_runtime(api_client=StaticApiClient("unused"), model="opus", api_format="anthropic")
events = []
async def _emit(event):
events.append(event)
host._emit = _emit # type: ignore[method-assign]
await start_runtime(host._bundle)
try:
await host._handle_select_command("model")
finally:
await close_runtime(host._bundle)
event = next(item for item in events if item.type == "select_request")
assert event.modal["command"] == "model"
assert any(option["value"] == "opus" and option.get("active") for option in event.select_options)
assert any(option["value"] == "default" for option in event.select_options)
@pytest.mark.asyncio
async def test_backend_host_emits_theme_select_request(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused")))
host._bundle = await build_runtime(api_client=StaticApiClient("unused"))
events = []
async def _emit(event):
events.append(event)
host._emit = _emit # type: ignore[method-assign]
await start_runtime(host._bundle)
try:
await host._handle_select_command("theme")
finally:
await close_runtime(host._bundle)
event = next(item for item in events if item.type == "select_request")
assert event.modal["command"] == "theme"
assert any(option["value"] == "default" for option in event.select_options)
@pytest.mark.asyncio
async def test_backend_host_emits_turns_select_request_with_unlimited_option(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused")))
host._bundle = await build_runtime(api_client=StaticApiClient("unused"), enforce_max_turns=False)
events = []
async def _emit(event):
events.append(event)
host._emit = _emit # type: ignore[method-assign]
await start_runtime(host._bundle)
try:
await host._handle_select_command("turns")
finally:
await close_runtime(host._bundle)
event = next(item for item in events if item.type == "select_request")
assert event.modal["command"] == "turns"
assert any(option["value"] == "unlimited" and option.get("active") for option in event.select_options)
@pytest.mark.asyncio
async def test_backend_host_emits_provider_select_request(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused")))
host._bundle = await build_runtime(api_client=StaticApiClient("unused"))
events = []
async def _emit(event):
events.append(event)
host._emit = _emit # type: ignore[method-assign]
await start_runtime(host._bundle)
try:
await host._handle_select_command("provider")
finally:
await close_runtime(host._bundle)
event = next(item for item in events if item.type == "select_request")
assert event.modal["command"] == "provider"
assert any(option["value"] == "claude-api" and option.get("active") for option in event.select_options)
@pytest.mark.asyncio
async def test_backend_host_apply_select_command_shows_single_segment_transcript(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused")))
host._bundle = await build_runtime(api_client=StaticApiClient("unused"))
events = []
async def _emit(event):
events.append(event)
host._emit = _emit # type: ignore[method-assign]
await start_runtime(host._bundle)
try:
should_continue = await host._apply_select_command("theme", "default")
finally:
await close_runtime(host._bundle)
assert should_continue is True
user_event = next(item for item in events if item.type == "transcript_item" and item.item and item.item.role == "user")
assert user_event.item.text == "/theme"
@pytest.mark.asyncio
async def test_backend_host_apply_provider_select_command_shows_single_segment_transcript(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused")))
host._bundle = await build_runtime(api_client=StaticApiClient("unused"))
events = []
async def _emit(event):
events.append(event)
host._emit = _emit # type: ignore[method-assign]
await start_runtime(host._bundle)
try:
should_continue = await host._apply_select_command("provider", "claude-api")
finally:
await close_runtime(host._bundle)
assert should_continue is True
user_event = next(item for item in events if item.type == "transcript_item" and item.item and item.item.role == "user")
assert user_event.item.text == "/provider"