diff --git a/frontend/terminal/src/App.tsx b/frontend/terminal/src/App.tsx index cf37c23..268c12e 100644 --- a/frontend/terminal/src/App.tsx +++ b/frontend/terminal/src/App.tsx @@ -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 → 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; } diff --git a/frontend/terminal/src/hooks/useBackendSession.ts b/frontend/terminal/src/hooks/useBackendSession.ts index f6dce9c..d26e6a3 100644 --- a/frontend/terminal/src/hooks/useBackendSession.ts +++ b/frontend/terminal/src/hooks/useBackendSession.ts @@ -27,7 +27,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number const [mcpServers, setMcpServers] = useState([]); const [bridgeSessions, setBridgeSessions] = useState([]); const [modal, setModal] = useState | 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; diff --git a/frontend/terminal/src/types.ts b/frontend/terminal/src/types.ts index 9b9af07..be345a5 100644 --- a/frontend/terminal/src/types.ts +++ b/frontend/terminal/src/types.ts @@ -43,6 +43,7 @@ export type SelectOptionPayload = { value: string; label: string; description?: string; + active?: boolean; }; export type TodoItemSnapshot = { diff --git a/src/openharness/api/__init__.py b/src/openharness/api/__init__.py index ce00dff..572d4e3 100644 --- a/src/openharness/api/__init__.py +++ b/src/openharness/api/__init__.py @@ -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", diff --git a/src/openharness/api/client.py b/src/openharness/api/client.py index 6ac2e97..6096e9d 100644 --- a/src/openharness/api/client.py +++ b/src/openharness/api/client.py @@ -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 diff --git a/src/openharness/api/codex_client.py b/src/openharness/api/codex_client.py new file mode 100644 index 0000000..b27757a --- /dev/null +++ b/src/openharness/api/codex_client.py @@ -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)) diff --git a/src/openharness/api/openai_client.py b/src/openharness/api/openai_client.py index 78ccb53..55a95bc 100644 --- a/src/openharness/api/openai_client.py +++ b/src/openharness/api/openai_client.py @@ -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: diff --git a/src/openharness/api/provider.py b/src/openharness/api/provider.py index bf3b2ef..1c211ab 100644 --- a/src/openharness/api/provider.py +++ b/src/openharness/api/provider.py @@ -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" diff --git a/src/openharness/auth/__init__.py b/src/openharness/auth/__init__.py index f57d2ca..973ce89 100644 --- a/src/openharness/auth/__init__.py +++ b/src/openharness/auth/__init__.py @@ -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", diff --git a/src/openharness/auth/external.py b/src/openharness/auth/external.py new file mode 100644 index 0000000..792e8a3 --- /dev/null +++ b/src/openharness/auth/external.py @@ -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 diff --git a/src/openharness/auth/manager.py b/src/openharness/auth/manager.py index c89b773..7d4d2f8 100644 --- a/src/openharness/auth/manager.py +++ b/src/openharness/auth/manager.py @@ -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) diff --git a/src/openharness/auth/storage.py b/src/openharness/auth/storage.py index 5b70ca2..5f28815 100644 --- a/src/openharness/auth/storage.py +++ b/src/openharness/auth/storage.py @@ -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) # --------------------------------------------------------------------------- diff --git a/src/openharness/cli.py b/src/openharness/cli.py index 41bfaf0..4947895 100644 --- a/src/openharness/cli.py +++ b/src/openharness/cli.py @@ -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-key–based 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 --- diff --git a/src/openharness/commands/registry.py b/src/openharness/commands/registry.py index 2526d86..f4f6070 100644 --- a/src/openharness/commands/registry.py +++ b/src/openharness/commands/registry.py @@ -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)) diff --git a/src/openharness/config/__init__.py b/src/openharness/config/__init__.py index b169b5b..a626b2a 100644 --- a/src/openharness/config/__init__.py +++ b/src/openharness/config/__init__.py @@ -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", ] diff --git a/src/openharness/config/settings.py b/src/openharness/config/settings.py index 4790678..4d62d7f 100644 --- a/src/openharness/config/settings.py +++ b/src/openharness/config/settings.py @@ -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", diff --git a/src/openharness/engine/query.py b/src/openharness/engine/query.py index 09b698a..1ad0842 100644 --- a/src/openharness/engine/query.py +++ b/src/openharness/engine/query.py @@ -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( diff --git a/src/openharness/engine/query_engine.py b/src/openharness/engine/query_engine.py index 306bf0a..3e8b913 100644 --- a/src/openharness/engine/query_engine.py +++ b/src/openharness/engine/query_engine.py @@ -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.""" diff --git a/src/openharness/engine/stream_events.py b/src/openharness/engine/stream_events.py index 4dac22c..bdbee81 100644 --- a/src/openharness/engine/stream_events.py +++ b/src/openharness/engine/stream_events.py @@ -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 ) diff --git a/src/openharness/hooks/executor.py b/src/openharness/hooks/executor.py index 0b742d7..d223be1 100644 --- a/src/openharness/hooks/executor.py +++ b/src/openharness/hooks/executor.py @@ -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] = [] diff --git a/src/openharness/ui/app.py b/src/openharness/ui/app.py index 89faf47..3d5b8d9 100644 --- a/src/openharness/ui/app.py +++ b/src/openharness/ui/app.py @@ -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 diff --git a/src/openharness/ui/backend_host.py b/src/openharness/ui/backend_host.py index a685012..0f570b8 100644 --- a/src/openharness/ui/backend_host.py +++ b/src/openharness/ui/backend_host.py @@ -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() diff --git a/src/openharness/ui/protocol.py b/src/openharness/ui/protocol.py index 17e0f3d..4a3b9d0 100644 --- a/src/openharness/ui/protocol.py +++ b/src/openharness/ui/protocol.py @@ -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 diff --git a/src/openharness/ui/runtime.py b/src/openharness/ui/runtime.py index d402b86..0920c1a 100644 --- a/src/openharness/ui/runtime.py +++ b/src/openharness/ui/runtime.py @@ -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: diff --git a/src/openharness/ui/textual_app.py b/src/openharness/ui/textual_app.py index 8c21107..cd0568a 100644 --- a/src/openharness/ui/textual_app.py +++ b/src/openharness/ui/textual_app.py @@ -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: diff --git a/tests/test_api/test_client.py b/tests/test_api/test_client.py new file mode 100644 index 0000000..216bec3 --- /dev/null +++ b/tests/test_api/test_client.py @@ -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" + ) diff --git a/tests/test_api/test_codex_client.py b/tests/test_api/test_codex_client.py new file mode 100644 index 0000000..19c9d81 --- /dev/null +++ b/tests/test_api/test_codex_client.py @@ -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" diff --git a/tests/test_auth/test_external.py b/tests/test_auth/test_external.py new file mode 100644 index 0000000..3a76565 --- /dev/null +++ b/tests/test_auth/test_external.py @@ -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" diff --git a/tests/test_commands/test_registry.py b/tests/test_commands/test_registry.py index 1c41911..b45de54 100644 --- a/tests/test_commands/test_registry.py +++ b/tests/test_commands/test_registry.py @@ -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 diff --git a/tests/test_config/test_settings.py b/tests/test_config/test_settings.py index 7e3a2a0..973ef86 100644 --- a/tests/test_config/test_settings.py +++ b/tests/test_config/test_settings.py @@ -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) diff --git a/tests/test_engine/test_query_engine.py b/tests/test_engine/test_query_engine.py index 610da72..b3ebcaf 100644 --- a/tests/test_engine/test_query_engine.py +++ b/tests/test_engine/test_query_engine.py @@ -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" diff --git a/tests/test_ui/test_react_backend.py b/tests/test_ui/test_react_backend.py index 2653502..038feec 100644 --- a/tests/test_ui/test_react_backend.py +++ b/tests/test_ui/test_react_backend.py @@ -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"