Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion harness2/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@

from __future__ import annotations

import os
import subprocess
from pathlib import Path

from .. import providers, registry
from ..core.proposer import Proposer
Expand Down Expand Up @@ -79,7 +81,7 @@ def resolve(model=model, channel=channel, role=role):
if channel == "claude":
providers.claude_env(provider)
elif channel == "codex":
providers.codex_config(provider, bare, role["effort"])
providers.codex_args(provider, bare, role["effort"])
elif channel == "opencode":
providers.opencode_config(provider, bare)
if channel != "command":
Expand Down Expand Up @@ -128,6 +130,16 @@ def binary_check(binary=binary, channel=channel):
"detail": "; ".join(problems) or detail,
}
)
if any(config.provider(name).type == "chatgpt-login" for name in referenced):
agents = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "AGENTS.md"
if agents.is_file() and (text := agents.read_text(encoding="utf-8")):
rows.append(
{
"check": "Codex global AGENTS.md",
"ok": True,
"detail": f"your global AGENTS.md ({len(text)} characters) is part of every Codex run",
}
)
if args.live:
for (channel, model), spec in sorted(models.items()):

Expand Down
60 changes: 39 additions & 21 deletions harness2/core/proposer.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from pathlib import Path

from .. import providers
from ..substrate.codex import parse_items_text
from ..substrate.codex import CodexSubstrate, parse_items_text
from . import events
from .spec import ChannelSpec

Expand Down Expand Up @@ -165,8 +165,11 @@ def call(
tmp, mission, system, wall
)
spent_usd += cost
if err and providers.USAGE_LIMIT.search(err):
events.request_stop("usage_limit")
raise events.Stopped("usage_limit")
if not text.strip():
last = err or "empty reply"
last = err[-300:] or "empty reply"
continue
r = Reply(
label=label,
Expand All @@ -193,6 +196,9 @@ def call(
last = f"{type(exc).__name__}: {exc}"
finally:
shutil.rmtree(tmp, ignore_errors=True)
if providers.USAGE_LIMIT.search(last):
events.request_stop("usage_limit")
raise events.Stopped("usage_limit")

r = Reply(
label=label,
Expand All @@ -214,8 +220,12 @@ def call(
)

def _claude(self, cwd: Path, mission: str, system: str, wall: int) -> tuple[str, float, str]:
home = cwd / ".claude-home"
home.mkdir()
login = self.spec.provider is not None and self.spec.provider.type == "claude-login"
env = self.env()
if not login:
home = cwd / ".claude-home"
home.mkdir()
env["CLAUDE_CONFIG_DIR"] = str(home)
cmd = [
*self._bin(),
"--dangerously-skip-permissions",
Expand All @@ -228,6 +238,7 @@ def _claude(self, cwd: Path, mission: str, system: str, wall: int) -> tuple[str,
self._model(),
"--setting-sources",
"project,local",
*(["--strict-mcp-config", "--no-session-persistence"] if login else []),
]
stdin = mission
if system:
Expand All @@ -243,7 +254,7 @@ def _claude(self, cwd: Path, mission: str, system: str, wall: int) -> tuple[str,
cmd,
input=stdin,
cwd=cwd,
env=self.env() | {"CLAUDE_CONFIG_DIR": str(home)},
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
Expand All @@ -252,24 +263,28 @@ def _claude(self, cwd: Path, mission: str, system: str, wall: int) -> tuple[str,
payload = _parse_json(p.stdout)
text = str(payload.get("result") or payload.get("text") or "")
err = ""
if payload.get("is_error") or payload.get("subtype") not in (None, "success"):
err = f"CLI error: {payload.get('subtype')} {str(text)[:200]}"
if (
p.returncode
or payload.get("is_error")
or payload.get("subtype") not in (None, "success")
):
err = f"rc={p.returncode}: {p.stdout} {p.stderr}"
text = ""
elif p.returncode != 0 and not text.strip():
err = f"rc={p.returncode}: {(p.stderr or '')[-300:]}"
return text, float(payload.get("total_cost_usd") or 0.0), err

def _codex(self, cwd: Path, mission: str, system: str, wall: int) -> tuple[str, float, str]:
home = cwd / ".codex-home"
home.mkdir()
model = self._model()
config = (
providers.codex_config(self.spec.provider, model, self.spec.effort)
if self.spec.provider is not None
else f"model = {json.dumps(model)}\n"
f"model_reasoning_effort = {json.dumps(self.spec.effort)}\n"
)
(home / "config.toml").write_text(config, encoding="utf-8")
home = None
if self.spec.provider is None or self.spec.provider.type != "chatgpt-login":
home = cwd / ".codex-home"
home.mkdir()
config = (
providers.codex_config(self.spec.provider, model, self.spec.effort)
if self.spec.provider is not None
else f"model = {json.dumps(model)}\n"
f"model_reasoning_effort = {json.dumps(self.spec.effort)}\n"
)
(home / "config.toml").write_text(config, encoding="utf-8")
p = subprocess.run(
[
*self._bin(),
Expand All @@ -278,13 +293,14 @@ def _codex(self, cwd: Path, mission: str, system: str, wall: int) -> tuple[str,
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"--ephemeral",
*providers.codex_args(self.spec.provider, model, self.spec.effort),
"-C",
str(cwd),
"-",
],
input=f"# SYSTEM INSTRUCTIONS\n\n{system}\n\n# END\n\n{mission}" if system else mission,
cwd=cwd,
env=self.env() | {"CODEX_HOME": str(home)},
env=CodexSubstrate().env(self.env(), home, provider=self.spec.provider),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
Expand All @@ -299,7 +315,7 @@ def _codex(self, cwd: Path, mission: str, system: str, wall: int) -> tuple[str,
if item.get("type") == "agent_message":
text = str(item.get("text") or "")
if errors or p.returncode:
return "", 0.0, "; ".join(errors)[-300:] or f"rc={p.returncode}: {p.stderr[-300:]}"
return "", 0.0, f"rc={p.returncode}: {p.stdout} {p.stderr}"
return text, 0.0, ""

def _opencode(self, cwd: Path, mission: str, system: str, wall: int) -> tuple[str, float, str]:
Expand Down Expand Up @@ -348,10 +364,12 @@ def _opencode(self, cwd: Path, mission: str, system: str, wall: int) -> tuple[st
timeout=wall,
)
cost = _opencode_cost(p.stdout)
text, err = _opencode_stdout(p.stdout)
if (p.returncode or err) and providers.USAGE_LIMIT.search(p.stdout + p.stderr):
return "", cost, p.stdout + p.stderr
ans = out / "answer.md"
if ans.is_file() and ans.read_text(errors="replace").strip():
return ans.read_text(errors="replace"), cost, ""
text, err = _opencode_stdout(p.stdout)
if text.strip():
return text, cost, ""
tail = _strip_oc_banner(p.stderr or "").strip()[-300:]
Expand Down
58 changes: 57 additions & 1 deletion harness2/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,17 @@

import json
import os
import re
import subprocess
from dataclasses import dataclass
from urllib.parse import urlsplit

from .settings import ConfigError

PROVIDER_TYPES = ("anthropic", "openai", "vertex", "bedrock")
PROVIDER_TYPES = ("anthropic", "openai", "vertex", "bedrock", "claude-login", "chatgpt-login")
USAGE_LIMIT = re.compile(
r"(?i)usage limit|rate[ _-]?limit|too many requests|\b429\b|quota|credit balance is too low"
)
STRIP_ENV = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
Expand Down Expand Up @@ -60,6 +65,10 @@ def split_model(ref: str) -> tuple[str, str]:


def claude_env(p: Provider) -> dict[str, str]:
if p.type == "claude-login":
return {}
if p.type == "chatgpt-login":
raise ConfigError("Claude Code cannot use a ChatGPT login.")
if p.type == "anthropic":
env = {}
if p.api_key_env:
Expand Down Expand Up @@ -104,7 +113,24 @@ def quote(value):
)


def codex_args(p: Provider | None, model: str, effort: str) -> list[str]:
if p is not None and p.type == "chatgpt-login":
return [
"--ignore-user-config",
"--ignore-rules",
"-m",
model,
"-c",
f"model_reasoning_effort={json.dumps(effort)}",
]
if p is not None:
codex_config(p, model, effort)
return []


def opencode_config(p: Provider, model: str) -> tuple[dict, dict[str, str]]:
if p.type in ("claude-login", "chatgpt-login"):
raise ConfigError("OpenCode needs an API provider; subscription logins are not supported.")
if p.type == "vertex":
region = p.region or "global"
return {}, {
Expand Down Expand Up @@ -135,6 +161,10 @@ def opencode_config(p: Provider, model: str) -> tuple[dict, dict[str, str]]:


def litellm_params(p: Provider, model: str) -> dict:
if p.type in ("claude-login", "chatgpt-login"):
raise ConfigError(
"The bridge needs an API provider; subscription logins are not supported."
)
if p.type in ("anthropic", "openai"):
params = {"model": f"{p.type}/{model}", "api_key": f"os.environ/{p.api_key_env}"}
if p.base_url or p.type == "openai":
Expand All @@ -152,6 +182,32 @@ def litellm_params(p: Provider, model: str) -> dict:


def check(p: Provider) -> list[str]:
if p.type in ("claude-login", "chatgpt-login"):
argv = (
["claude", "auth", "status"]
if p.type == "claude-login"
else ["codex", "login", "status"]
)
login = "claude auth login" if p.type == "claude-login" else "codex login"
try:
result = subprocess.run(
argv,
env={
k: v
for k, v in os.environ.items()
if k not in STRIP_ENV
and (p.type != "chatgpt-login" or k not in ("OPENAI_API_KEY", "CODEX_API_KEY"))
},
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=10,
)
if result.returncode == 0:
return []
except (OSError, subprocess.SubprocessError):
pass
return [f"{p.type}: not logged in; run `{login}`"]
problems = []
if p.api_key_env and not os.environ.get(p.api_key_env):
problems.append(f"{p.name}: set {p.api_key_env}")
Expand Down
2 changes: 2 additions & 0 deletions harness2/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@
"base_url": "https://api.openai.com/v1",
"api_key_env": "OPENAI_API_KEY",
},
"claude-login": {"type": "claude-login"},
"chatgpt-login": {"type": "chatgpt-login"},
},
}
_MISSING = object()
Expand Down
21 changes: 18 additions & 3 deletions harness2/substrate/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,15 @@ def strip_native(self, workspace: Path) -> None:
def install_into(self, install: Install, workspace: Path) -> None:
self.export_tree(self.import_tree(install.staging_dir), workspace, base={})

def argv(self, prompt_file: Path, workspace: Path, model: str, effort: str) -> list[str]:
def argv(
self,
prompt_file: Path,
workspace: Path,
model: str,
effort: str,
*,
provider: providers.Provider | None = None,
) -> list[str]:
return [
*cli_argv(self.bin, "claude"),
"-p",
Expand All @@ -211,16 +219,23 @@ def argv(self, prompt_file: Path, workspace: Path, model: str, effort: str) -> l
model,
"--effort",
effort,
*(
["--strict-mcp-config", "--no-session-persistence"]
if provider is not None and provider.type == "claude-login"
else []
),
]

def env(
self, base_env: Mapping[str, str], provider: providers.Provider, config_dir: Path
) -> dict[str, str]:
config_dir.mkdir(parents=True, exist_ok=True)
login = provider.type == "claude-login"
if not login:
config_dir.mkdir(parents=True, exist_ok=True)
return {
**{k: v for k, v in base_env.items() if k not in providers.STRIP_ENV},
**providers.claude_env(provider),
"CLAUDE_CONFIG_DIR": str(config_dir),
**({} if login else {"CLAUDE_CONFIG_DIR": str(config_dir)}),
}

def read_trajectory(
Expand Down
26 changes: 22 additions & 4 deletions harness2/substrate/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,15 @@ def strip_native(self, workspace: Path) -> None:
def install_into(self, install: Install, workspace: Path) -> None:
self.export_tree(self.import_tree(install.staging_dir), workspace, base={})

def argv(self, prompt_file: Path, workspace: Path, model: str, effort: str) -> list[str]:
def argv(
self,
prompt_file: Path,
workspace: Path,
model: str,
effort: str,
*,
provider: providers.Provider | None = None,
) -> list[str]:
return [
*cli_argv(self.bin, "codex"),
"exec",
Expand All @@ -219,6 +227,7 @@ def argv(self, prompt_file: Path, workspace: Path, model: str, effort: str) -> l
"--dangerously-bypass-approvals-and-sandbox",
"--dangerously-bypass-hook-trust",
"--ephemeral",
*providers.codex_args(provider, model, effort),
"-C",
str(workspace),
"-",
Expand All @@ -229,10 +238,19 @@ def write_home(self, home: Path, provider: providers.Provider, model: str, effor
home.mkdir(parents=True, exist_ok=True)
(home / "config.toml").write_text(content, encoding="utf-8")

def env(self, base_env: Mapping[str, str], home: Path) -> dict[str, str]:
def env(
self,
base_env: Mapping[str, str],
home: Path | None,
*,
provider: providers.Provider | None = None,
) -> dict[str, str]:
stripped = providers.STRIP_ENV
if provider is not None and provider.type == "chatgpt-login":
stripped += ("OPENAI_API_KEY", "CODEX_API_KEY")
return {
**{k: v for k, v in base_env.items() if k not in providers.STRIP_ENV},
"CODEX_HOME": str(home),
**{k: v for k, v in base_env.items() if k not in stripped},
**({"CODEX_HOME": str(home)} if home is not None else {}),
}

def final_message(self, stream_path: Path) -> str:
Expand Down
Loading
Loading