feat(auth): add provider profiles and subscription clients
This commit is contained in:
@@ -27,11 +27,20 @@ const scriptedSteps = (() => {
|
||||
}
|
||||
})();
|
||||
|
||||
const PERMISSION_MODES: SelectOption[] = [
|
||||
{value: 'default', label: 'Default', description: 'Ask before write/execute operations'},
|
||||
{value: 'full_auto', label: 'Auto', description: 'Allow all tools automatically'},
|
||||
{value: 'plan', label: 'Plan Mode', description: 'Block all write operations'},
|
||||
];
|
||||
const SELECTABLE_COMMANDS = new Set([
|
||||
'/provider',
|
||||
'/model',
|
||||
'/theme',
|
||||
'/output-style',
|
||||
'/permissions',
|
||||
'/resume',
|
||||
'/effort',
|
||||
'/passes',
|
||||
'/turns',
|
||||
'/fast',
|
||||
'/vim',
|
||||
'/voice',
|
||||
]);
|
||||
|
||||
type SelectModalState = {
|
||||
title: string;
|
||||
@@ -61,6 +70,13 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
const [selectIndex, setSelectIndex] = useState(0);
|
||||
const session = useBackendSession(config, () => exit());
|
||||
|
||||
useEffect(() => {
|
||||
const nextTheme = session.status.theme;
|
||||
if (typeof nextTheme === 'string' && nextTheme) {
|
||||
setThemeName(nextTheme);
|
||||
}
|
||||
}, [session.status.theme, setThemeName]);
|
||||
|
||||
// Current tool name for spinner
|
||||
const currentToolName = useMemo(() => {
|
||||
for (let i = session.transcript.length - 1; i >= 0; i--) {
|
||||
@@ -100,12 +116,13 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
session.setSelectRequest(null);
|
||||
return;
|
||||
}
|
||||
setSelectIndex(0);
|
||||
const initialIndex = req.options.findIndex((option) => option.active);
|
||||
setSelectIndex(initialIndex >= 0 ? initialIndex : 0);
|
||||
setSelectModal({
|
||||
title: req.title,
|
||||
options: req.options.map((o) => ({value: o.value, label: o.label, description: o.description})),
|
||||
options: req.options.map((o) => ({value: o.value, label: o.label, description: o.description, active: o.active})),
|
||||
onSelect: (value) => {
|
||||
session.sendRequest({type: 'submit_line', line: `${req.submitPrefix}${value}`});
|
||||
session.sendRequest({type: 'apply_select_command', command: req.command, value});
|
||||
session.setBusy(true);
|
||||
setSelectModal(null);
|
||||
},
|
||||
@@ -117,31 +134,14 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
const handleCommand = (cmd: string): boolean => {
|
||||
const trimmed = cmd.trim();
|
||||
|
||||
// /theme set <name> → switch theme locally
|
||||
const themeMatch = /^\/theme\s+set\s+(\S+)$/.exec(trimmed);
|
||||
if (themeMatch) {
|
||||
setThemeName(themeMatch[1]);
|
||||
if (SELECTABLE_COMMANDS.has(trimmed)) {
|
||||
session.sendRequest({type: 'select_command', command: trimmed.slice(1)});
|
||||
return true;
|
||||
}
|
||||
|
||||
// /permissions → show mode picker
|
||||
if (trimmed === '/permissions' || trimmed === '/permissions show') {
|
||||
const currentMode = String(session.status.permission_mode ?? 'default');
|
||||
const options = PERMISSION_MODES.map((opt) => ({
|
||||
...opt,
|
||||
active: opt.value === currentMode,
|
||||
}));
|
||||
const initialIndex = options.findIndex((o) => o.active);
|
||||
setSelectIndex(initialIndex >= 0 ? initialIndex : 0);
|
||||
setSelectModal({
|
||||
title: 'Permission Mode',
|
||||
options,
|
||||
onSelect: (value) => {
|
||||
session.sendRequest({type: 'submit_line', line: `/permissions set ${value}`});
|
||||
session.setBusy(true);
|
||||
setSelectModal(null);
|
||||
},
|
||||
});
|
||||
session.sendRequest({type: 'select_command', command: 'permissions'});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
|
||||
// /resume → request session list from backend (will trigger select_request)
|
||||
if (trimmed === '/resume') {
|
||||
session.sendRequest({type: 'list_sessions'});
|
||||
session.sendRequest({type: 'select_command', command: 'resume'});
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
const [mcpServers, setMcpServers] = useState<McpServerSnapshot[]>([]);
|
||||
const [bridgeSessions, setBridgeSessions] = useState<BridgeSessionSnapshot[]>([]);
|
||||
const [modal, setModal] = useState<Record<string, unknown> | null>(null);
|
||||
const [selectRequest, setSelectRequest] = useState<{title: string; submitPrefix: string; options: SelectOptionPayload[]} | null>(null);
|
||||
const [selectRequest, setSelectRequest] = useState<{title: string; command: string; options: SelectOptionPayload[]} | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [todoMarkdown, setTodoMarkdown] = useState('');
|
||||
@@ -210,7 +210,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
const m = event.modal ?? {};
|
||||
setSelectRequest({
|
||||
title: String(m.title ?? 'Select'),
|
||||
submitPrefix: String(m.submit_prefix ?? ''),
|
||||
command: String(m.command ?? ''),
|
||||
options: event.select_options ?? [],
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -43,6 +43,7 @@ export type SelectOptionPayload = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export type TodoItemSnapshot = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Integration with external CLI-managed subscription credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openharness.auth.storage import ExternalAuthBinding
|
||||
|
||||
CODEX_PROVIDER = "openai_codex"
|
||||
CLAUDE_PROVIDER = "anthropic_claude"
|
||||
CLAUDE_CODE_VERSION_FALLBACK = "2.1.92"
|
||||
CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
CLAUDE_OAUTH_TOKEN_ENDPOINTS = (
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
)
|
||||
CLAUDE_COMMON_BETAS = (
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
)
|
||||
CLAUDE_OAUTH_ONLY_BETAS = (
|
||||
"claude-code-20250219",
|
||||
"oauth-2025-04-20",
|
||||
)
|
||||
|
||||
_claude_code_version_cache: str | None = None
|
||||
_claude_code_session_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalAuthCredential:
|
||||
"""Normalized external credential used at runtime."""
|
||||
|
||||
provider: str
|
||||
value: str
|
||||
auth_kind: str
|
||||
source_path: Path
|
||||
managed_by: str
|
||||
profile_label: str = ""
|
||||
refresh_token: str = ""
|
||||
expires_at_ms: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalAuthState:
|
||||
"""Human-readable state for an external auth source."""
|
||||
|
||||
configured: bool
|
||||
state: str
|
||||
source: str
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def default_binding_for_provider(provider: str) -> ExternalAuthBinding:
|
||||
"""Return the default external auth source for *provider*."""
|
||||
if provider == CODEX_PROVIDER:
|
||||
codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
|
||||
return ExternalAuthBinding(
|
||||
provider=provider,
|
||||
source_path=str(codex_home / "auth.json"),
|
||||
source_kind="codex_auth_json",
|
||||
managed_by="codex-cli",
|
||||
profile_label="Codex CLI",
|
||||
)
|
||||
if provider == CLAUDE_PROVIDER:
|
||||
claude_home = Path(os.environ.get("CLAUDE_HOME", "~/.claude")).expanduser()
|
||||
return ExternalAuthBinding(
|
||||
provider=provider,
|
||||
source_path=str(claude_home / ".credentials.json"),
|
||||
source_kind="claude_credentials_json",
|
||||
managed_by="claude-cli",
|
||||
profile_label="Claude CLI",
|
||||
)
|
||||
raise ValueError(f"Unsupported external auth provider: {provider}")
|
||||
|
||||
|
||||
def load_external_credential(
|
||||
binding: ExternalAuthBinding,
|
||||
*,
|
||||
refresh_if_needed: bool = False,
|
||||
) -> ExternalAuthCredential:
|
||||
"""Read a runtime credential from an external auth binding."""
|
||||
source_path = Path(binding.source_path).expanduser()
|
||||
if not source_path.exists():
|
||||
raise ValueError(f"External auth source not found: {source_path}")
|
||||
|
||||
try:
|
||||
payload = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON in external auth source: {source_path}") from exc
|
||||
|
||||
if binding.provider == CODEX_PROVIDER:
|
||||
return _load_codex_credential(payload, source_path, binding)
|
||||
if binding.provider == CLAUDE_PROVIDER:
|
||||
return _load_claude_credential(
|
||||
payload,
|
||||
source_path,
|
||||
binding,
|
||||
refresh_if_needed=refresh_if_needed,
|
||||
)
|
||||
raise ValueError(f"Unsupported external auth provider: {binding.provider}")
|
||||
|
||||
|
||||
def _load_codex_credential(
|
||||
payload: dict[str, Any],
|
||||
source_path: Path,
|
||||
binding: ExternalAuthBinding,
|
||||
) -> ExternalAuthCredential:
|
||||
tokens = payload.get("tokens")
|
||||
access_token = ""
|
||||
refresh_token = ""
|
||||
if isinstance(tokens, dict):
|
||||
access_token = str(tokens.get("access_token", "") or "")
|
||||
refresh_token = str(tokens.get("refresh_token", "") or "")
|
||||
if not access_token:
|
||||
access_token = str(payload.get("OPENAI_API_KEY", "") or "")
|
||||
if not access_token:
|
||||
raise ValueError("Codex auth source does not contain an access token.")
|
||||
|
||||
email = _decode_json_web_token_claim(access_token, ["https://api.openai.com/profile", "email"])
|
||||
expires_at_ms = _decode_jwt_expiry(access_token)
|
||||
return ExternalAuthCredential(
|
||||
provider=CODEX_PROVIDER,
|
||||
value=access_token,
|
||||
auth_kind="api_key",
|
||||
source_path=source_path,
|
||||
managed_by=binding.managed_by,
|
||||
profile_label=email or binding.profile_label,
|
||||
refresh_token=refresh_token,
|
||||
expires_at_ms=expires_at_ms,
|
||||
)
|
||||
|
||||
|
||||
def _load_claude_credential(
|
||||
payload: dict[str, Any],
|
||||
source_path: Path,
|
||||
binding: ExternalAuthBinding,
|
||||
*,
|
||||
refresh_if_needed: bool,
|
||||
) -> ExternalAuthCredential:
|
||||
claude_oauth = payload.get("claudeAiOauth")
|
||||
if not isinstance(claude_oauth, dict):
|
||||
raise ValueError("Claude auth source does not contain claudeAiOauth.")
|
||||
|
||||
access_token = str(claude_oauth.get("accessToken", "") or "")
|
||||
refresh_token = str(claude_oauth.get("refreshToken", "") or "")
|
||||
expires_at_raw = claude_oauth.get("expiresAt")
|
||||
if not access_token:
|
||||
raise ValueError("Claude auth source does not contain an access token.")
|
||||
|
||||
expires_at_ms = _coerce_int(expires_at_raw)
|
||||
credential = ExternalAuthCredential(
|
||||
provider=CLAUDE_PROVIDER,
|
||||
value=access_token,
|
||||
auth_kind="auth_token",
|
||||
source_path=source_path,
|
||||
managed_by=binding.managed_by,
|
||||
profile_label=binding.profile_label,
|
||||
refresh_token=refresh_token,
|
||||
expires_at_ms=expires_at_ms,
|
||||
)
|
||||
if refresh_if_needed and is_credential_expired(credential):
|
||||
if not refresh_token:
|
||||
raise ValueError(
|
||||
f"Claude credentials at {source_path} are expired and cannot be refreshed."
|
||||
)
|
||||
refreshed = refresh_claude_oauth_credential(refresh_token)
|
||||
write_claude_credentials(
|
||||
source_path,
|
||||
access_token=refreshed["access_token"],
|
||||
refresh_token=refreshed["refresh_token"],
|
||||
expires_at_ms=refreshed["expires_at_ms"],
|
||||
)
|
||||
credential = ExternalAuthCredential(
|
||||
provider=CLAUDE_PROVIDER,
|
||||
value=str(refreshed["access_token"]),
|
||||
auth_kind="auth_token",
|
||||
source_path=source_path,
|
||||
managed_by=binding.managed_by,
|
||||
profile_label=binding.profile_label,
|
||||
refresh_token=str(refreshed["refresh_token"]),
|
||||
expires_at_ms=int(refreshed["expires_at_ms"]),
|
||||
)
|
||||
return credential
|
||||
|
||||
|
||||
def describe_external_binding(binding: ExternalAuthBinding) -> ExternalAuthState:
|
||||
"""Return a human-readable state for an external auth binding."""
|
||||
source_path = Path(binding.source_path).expanduser()
|
||||
if not source_path.exists():
|
||||
return ExternalAuthState(
|
||||
configured=False,
|
||||
state="missing",
|
||||
source="missing",
|
||||
detail=f"external auth source not found: {source_path}",
|
||||
)
|
||||
try:
|
||||
credential = load_external_credential(binding, refresh_if_needed=False)
|
||||
except ValueError as exc:
|
||||
return ExternalAuthState(
|
||||
configured=False,
|
||||
state="invalid",
|
||||
source="external",
|
||||
detail=str(exc),
|
||||
)
|
||||
if binding.provider == CLAUDE_PROVIDER and is_credential_expired(credential):
|
||||
if credential.refresh_token:
|
||||
return ExternalAuthState(
|
||||
configured=True,
|
||||
state="refreshable",
|
||||
source="external",
|
||||
detail=f"expired token can be refreshed from {source_path}",
|
||||
)
|
||||
return ExternalAuthState(
|
||||
configured=False,
|
||||
state="expired",
|
||||
source="external",
|
||||
detail=f"expired token at {source_path}",
|
||||
)
|
||||
return ExternalAuthState(
|
||||
configured=True,
|
||||
state="configured",
|
||||
source="external",
|
||||
detail=str(source_path),
|
||||
)
|
||||
|
||||
|
||||
def is_credential_expired(credential: ExternalAuthCredential, *, now_ms: int | None = None) -> bool:
|
||||
"""Return True when the external credential is definitely expired."""
|
||||
if credential.expires_at_ms is None:
|
||||
return False
|
||||
if now_ms is None:
|
||||
import time
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
return credential.expires_at_ms <= now_ms
|
||||
|
||||
|
||||
def get_claude_code_version() -> str:
|
||||
"""Return the locally installed Claude Code version or a fallback."""
|
||||
global _claude_code_version_cache
|
||||
if _claude_code_version_cache is not None:
|
||||
return _claude_code_version_cache
|
||||
for command in ("claude", "claude-code"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[command, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
version = (result.stdout or "").strip().split(" ", 1)[0]
|
||||
if result.returncode == 0 and version and version[0].isdigit():
|
||||
_claude_code_version_cache = version
|
||||
return version
|
||||
_claude_code_version_cache = CLAUDE_CODE_VERSION_FALLBACK
|
||||
return _claude_code_version_cache
|
||||
|
||||
|
||||
def get_claude_code_session_id() -> str:
|
||||
"""Return a stable Claude Code-style session identifier for this process."""
|
||||
global _claude_code_session_id
|
||||
if _claude_code_session_id is None:
|
||||
_claude_code_session_id = str(uuid.uuid4())
|
||||
return _claude_code_session_id
|
||||
|
||||
|
||||
def claude_oauth_betas() -> list[str]:
|
||||
"""Return Claude OAuth betas as a list for SDK beta endpoints."""
|
||||
return list(CLAUDE_COMMON_BETAS + CLAUDE_OAUTH_ONLY_BETAS)
|
||||
|
||||
|
||||
def claude_attribution_header() -> str:
|
||||
"""Return the Claude Code billing attribution prefix used in system prompts."""
|
||||
version = get_claude_code_version()
|
||||
return (
|
||||
"x-anthropic-billing-header: "
|
||||
f"cc_version={version}; cc_entrypoint=cli;"
|
||||
)
|
||||
|
||||
|
||||
def claude_oauth_headers() -> dict[str, str]:
|
||||
"""Return Claude Code-style headers for subscription OAuth traffic."""
|
||||
all_betas = ",".join(claude_oauth_betas())
|
||||
return {
|
||||
"anthropic-beta": all_betas,
|
||||
"user-agent": f"claude-cli/{get_claude_code_version()} (external, cli)",
|
||||
"x-app": "cli",
|
||||
"X-Claude-Code-Session-Id": get_claude_code_session_id(),
|
||||
}
|
||||
|
||||
|
||||
def refresh_claude_oauth_credential(refresh_token: str) -> dict[str, Any]:
|
||||
"""Refresh a Claude OAuth token without mutating local files."""
|
||||
if not refresh_token:
|
||||
raise ValueError("refresh_token is required")
|
||||
|
||||
payload = urllib.parse.urlencode(
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": CLAUDE_OAUTH_CLIENT_ID,
|
||||
}
|
||||
).encode("utf-8")
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": f"claude-cli/{get_claude_code_version()} (external, cli)",
|
||||
}
|
||||
last_error: Exception | None = None
|
||||
for endpoint in CLAUDE_OAUTH_TOKEN_ENDPOINTS:
|
||||
request = urllib.request.Request(endpoint, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
access_token = str(result.get("access_token", "") or "")
|
||||
if not access_token:
|
||||
raise ValueError("Claude OAuth refresh response missing access_token")
|
||||
next_refresh = str(result.get("refresh_token", refresh_token) or refresh_token)
|
||||
expires_in = int(result.get("expires_in", 3600) or 3600)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": next_refresh,
|
||||
"expires_at_ms": int(time.time() * 1000) + expires_in * 1000,
|
||||
"scopes": result.get("scope"),
|
||||
}
|
||||
if last_error is not None:
|
||||
raise ValueError(f"Claude OAuth refresh failed: {last_error}") from last_error
|
||||
raise ValueError("Claude OAuth refresh failed")
|
||||
|
||||
|
||||
def write_claude_credentials(
|
||||
source_path: Path,
|
||||
*,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
expires_at_ms: int,
|
||||
) -> None:
|
||||
"""Write refreshed Claude credentials back to the upstream credentials file."""
|
||||
existing: dict[str, Any] = {}
|
||||
if source_path.exists():
|
||||
try:
|
||||
existing = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
existing = {}
|
||||
previous = existing.get("claudeAiOauth")
|
||||
next_oauth: dict[str, Any] = {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expiresAt": expires_at_ms,
|
||||
}
|
||||
if isinstance(previous, dict):
|
||||
for key in ("scopes", "rateLimitTier", "subscriptionType"):
|
||||
if key in previous:
|
||||
next_oauth[key] = previous[key]
|
||||
existing["claudeAiOauth"] = next_oauth
|
||||
source_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
|
||||
try:
|
||||
source_path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""Return True for non-Anthropic endpoints using Anthropic-compatible APIs."""
|
||||
if not base_url:
|
||||
return False
|
||||
normalized = base_url.rstrip("/").lower()
|
||||
return "anthropic.com" not in normalized and "claude.com" not in normalized
|
||||
|
||||
|
||||
def _coerce_int(value: Any) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
trimmed = value.strip()
|
||||
if trimmed.isdigit():
|
||||
return int(trimmed)
|
||||
return None
|
||||
|
||||
|
||||
def _decode_jwt_expiry(token: str) -> int | None:
|
||||
exp = _decode_json_web_token_claim(token, ["exp"])
|
||||
if exp is None:
|
||||
return None
|
||||
if isinstance(exp, int):
|
||||
return exp * 1000
|
||||
if isinstance(exp, float):
|
||||
return int(exp * 1000)
|
||||
if isinstance(exp, str) and exp.strip().isdigit():
|
||||
return int(exp.strip()) * 1000
|
||||
return None
|
||||
|
||||
|
||||
def _decode_json_web_token_claim(token: str, path: list[str]) -> Any | None:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
try:
|
||||
encoded = parts[1]
|
||||
padded = encoded + "=" * (-len(encoded) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
current: Any = payload
|
||||
for key in path:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
+228
-46
@@ -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)
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -19,6 +20,17 @@ _CREDS_FILE_NAME = "credentials.json"
|
||||
_KEYRING_SERVICE = "openharness"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalAuthBinding:
|
||||
"""Pointer to credentials managed by an external CLI."""
|
||||
|
||||
provider: str
|
||||
source_path: str
|
||||
source_kind: str
|
||||
managed_by: str
|
||||
profile_label: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-based backend (always available)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -146,6 +158,36 @@ def list_stored_providers() -> list[str]:
|
||||
return list(_load_creds_file().keys())
|
||||
|
||||
|
||||
def store_external_binding(binding: ExternalAuthBinding) -> None:
|
||||
"""Persist metadata describing an external auth source for *provider*."""
|
||||
data = _load_creds_file()
|
||||
entry = data.setdefault(binding.provider, {})
|
||||
entry["external_binding"] = asdict(binding)
|
||||
_save_creds_file(data)
|
||||
log.debug("Stored external auth binding for provider: %s", binding.provider)
|
||||
|
||||
|
||||
def load_external_binding(provider: str) -> ExternalAuthBinding | None:
|
||||
"""Load external auth binding metadata for *provider* if present."""
|
||||
entry = _load_creds_file().get(provider, {})
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
raw = entry.get("external_binding")
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
return ExternalAuthBinding(
|
||||
provider=str(raw["provider"]),
|
||||
source_path=str(raw["source_path"]),
|
||||
source_kind=str(raw["source_kind"]),
|
||||
managed_by=str(raw["managed_by"]),
|
||||
profile_label=str(raw.get("profile_label", "") or ""),
|
||||
)
|
||||
except KeyError:
|
||||
log.warning("Ignoring malformed external auth binding for provider: %s", provider)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Encrypt/decrypt helpers (lightweight XOR obfuscation, not true encryption)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+227
-23
@@ -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 ---
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user