diff --git a/README.md b/README.md index db16588..f824e61 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,8 @@ publish: To run Codex on your ChatGPT subscription instead of an API key, set `agent.codex.use_host_auth: true` (Teich shares your host `codex login` across containers), and enable Codex fast mode with `model.service_tier: fast`. See [Generation](docs/generation.md#using-your-chatgpt-subscription-host-auth). +To run Claude Code on your Claude subscription (Pro/Max), export a `claude setup-token` token as `CLAUDE_CODE_OAUTH_TOKEN` (or set `agent.claude.oauth_token`) — it activates automatically and bills your plan's rate limits, not API credits. Claude Code runs also support `model.reasoning_effort` (`--effort`), `agent.claude.fallback_model` (`--fallback-model`), and `agent.claude.always_thinking` / `agent.claude.max_thinking_tokens` (extended thinking). See [Generation](docs/generation.md#using-your-claude-subscription-host-auth). + ## Python Entry Points ```python diff --git a/config.example.yaml b/config.example.yaml index 38f9f55..c3c52ca 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -29,6 +29,24 @@ agent: # host_auth_file: null # default: $CODEX_HOME/auth.json or ~/.codex/auth.json # auth_dir: ./.teich/codex-auth + # Claude Code-only settings. + # Subscription auth (Pro/Max): create a long-lived token with `claude + # setup-token` on the host and export CLAUDE_CODE_OAUTH_TOKEN (or set + # oauth_token below). When a token is available and api.base_url is unset, + # Teich passes it into each container and withholds ANTHROPIC_API_KEY (an API + # key silently wins over subscription auth inside Claude Code and would bill + # the API). Usage counts against your plan's rate limits, not API credits, + # and the token is safe at any max_concurrency. + # fallback_model forwards as --fallback-model (a model or list, tried in + # order on overload); always_thinking writes alwaysThinkingEnabled into the + # container's ~/.claude/settings.json; max_thinking_tokens sets + # MAX_THINKING_TOKENS in the container (0 disables thinking where allowed). + # claude: + # oauth_token: null # prefer CLAUDE_CODE_OAUTH_TOKEN in the env + # fallback_model: [sonnet, haiku] + # always_thinking: true + # max_thinking_tokens: null + # Trace each agent session to Langfuse (https://langfuse.com). Works for Codex # and Claude Code -- each uses its own native integration (Codex plugin, Claude # Code Stop hook) and Teich passes the credentials into the container. Side- @@ -55,7 +73,9 @@ model: # Optional reasoning / thinking level. # - Codex: forwarded as model_reasoning_effort # - Pi: normalized to low / medium / high when supported - # - Claude Code / Hermes: model/provider specific + # - Claude Code: forwarded as --effort (low | medium | high | xhigh | max on + # supported models) + # - Hermes: model/provider specific # Hermes also enables built-in toolsets: # safe,terminal,file,skills,memory,session_search,delegation reasoning_effort: medium diff --git a/docs/generation.md b/docs/generation.md index 88d83b1..f81cac7 100644 --- a/docs/generation.md +++ b/docs/generation.md @@ -301,6 +301,50 @@ During conversion, Teich: With OpenRouter non-Claude models, Teich runs a local in-container proxy: Claude Code sees a Claude surrogate model name, while the proxy rewrites outbound requests back to the configured model. Native assistant/result events keep provider-returned model and usage fields when Claude Code records them. +#### Using your Claude subscription (host auth) + +By default Claude Code runs on an API key (`ANTHROPIC_API_KEY` via `api.api_key` / env). To run it on your Claude subscription (Pro/Max) instead, create a long-lived OAuth token with `claude setup-token` on the host (valid for a year, purpose-built for headless use) and export it: + +```bash +claude setup-token +export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... +``` + +`TEICH_CLAUDE_OAUTH_TOKEN` also works (and wins over `CLAUDE_CODE_OAUTH_TOKEN`), or set `agent.claude.oauth_token` in the config. There is no separate enable flag: subscription auth activates whenever the provider is `claude-code`, a token is resolvable, and no custom `api.base_url` is configured — Teich prints a notice when it's active. Teich then passes the token into each container as `CLAUDE_CODE_OAUTH_TOKEN` and passes **no** `ANTHROPIC_API_KEY`, because Claude Code silently prefers an API key over subscription credentials when both are present — which would bill the API instead of the subscription. + +Compared to Codex host auth this is much simpler, by design: + +- **No broker, no rotation.** The setup-token credential is a durable token, so containers can share it at any `max_concurrency`, and it does not disturb your interactive host login (no re-login needed afterward). +- **Billing goes to the plan.** Usage counts against your subscription's rate-limit windows (5-hour/weekly), not pay-per-token API credits. Hitting the plan limit behaves like hitting it interactively. +- **Works from any host.** The token is just an env var — no credentials file to find (macOS keeps the interactive login in the Keychain, which containers can't read anyway). +- **No custom base URL.** Subscription auth talks to the first-party Anthropic API. An explicit `api.base_url` (which includes the OpenRouter proxy path) keeps the API/proxy path: an ambient env token is ignored there (with a notice), and configuring `agent.claude.oauth_token` together with `api.base_url` is rejected. + +#### Effort, fallback models, and extended thinking + +```yaml +agent: + provider: claude-code + claude: + fallback_model: [sonnet, haiku] # --fallback-model chain, tried in order on overload + always_thinking: true # alwaysThinkingEnabled in the container's settings.json + max_thinking_tokens: 31999 # MAX_THINKING_TOKENS env; 0 disables thinking where allowed + +model: + model: claude-opus-4-8 + reasoning_effort: xhigh # --effort: low | medium | high | xhigh | max (model-dependent) +``` + +How each setting reaches Claude Code: + +| Setting | Mechanism | +|---------|-----------| +| `model.reasoning_effort` | `--effort` CLI flag (shared field: Codex forwards it as `model_reasoning_effort`, Pi normalizes it) | +| `agent.claude.fallback_model` | `--fallback-model` CLI flag; a list is joined to Claude Code's comma-separated form, and Claude Code keeps up to 3 models after dedup | +| `agent.claude.always_thinking` | `alwaysThinkingEnabled` in the seeded `~/.claude/settings.json` (merged with the Langfuse hooks when tracing is on) | +| `agent.claude.max_thinking_tokens` | `MAX_THINKING_TOKENS` container env var | + +All four are optional free-form passthroughs; leave them unset to use Claude Code's defaults. Note that models with adaptive reasoning treat effort as the primary control and thinking budgets as advisory. + ### `hermes` Runs Hermes Agent with built-in toolsets: diff --git a/src/teich/cli.py b/src/teich/cli.py index 3aa0a8a..4627978 100644 --- a/src/teich/cli.py +++ b/src/teich/cli.py @@ -18,7 +18,7 @@ from typer.core import TyperCommand, TyperGroup from .anonymize import anonymize_path -from .config import Config +from .config import CLAUDE_PROVIDER_ALIASES, Config from .converter import convert_traces_to_training_data from .extract import CURSOR_EXTRACTION_NOTICE, ExtractProvider, extract_local_sessions from .runner import ( @@ -732,8 +732,21 @@ def generate( ) elif agent_provider == "pi": runner = PiRunner(cfg) - elif agent_provider in {"claude", "claude-code", "claude_code"}: + elif agent_provider in CLAUDE_PROVIDER_ALIASES: runner = ClaudeCodeRunner(cfg) + if cfg.claude_host_auth_active(): + console.print( + f"[yellow]Claude OAuth token found ({cfg.get_claude_oauth_token_source()}): " + "running on your Claude subscription (usage counts against your plan's " + "rate limits, not API credits). Remove the token to use an API key " + "instead.[/yellow]" + ) + elif cfg.get_claude_oauth_token() and cfg.get_base_url(): + console.print( + "[yellow]Claude OAuth token found but api.base_url is set; using the " + "API/proxy path (subscription auth talks to the first-party Anthropic " + "API only).[/yellow]" + ) if cfg.agent.langfuse.enabled: console.print( "[cyan]Claude Code Langfuse tracing enabled: uploading each session to " @@ -1054,6 +1067,24 @@ def init( # host_auth_file: null # default: $CODEX_HOME/auth.json or ~/.codex/auth.json # auth_dir: ./.teich/codex-auth + # Claude Code-only settings. + # Subscription auth (Pro/Max): create a long-lived token with `claude + # setup-token` on the host and export CLAUDE_CODE_OAUTH_TOKEN (or set + # oauth_token below). When a token is available and api.base_url is unset, + # Teich passes it into each container and withholds ANTHROPIC_API_KEY (an API + # key silently wins over subscription auth inside Claude Code and would bill + # the API). Usage counts against your plan's rate limits, not API credits, + # and the token is safe at any max_concurrency. + # fallback_model forwards as --fallback-model (a model or list, tried in + # order on overload); always_thinking writes alwaysThinkingEnabled into the + # container's ~/.claude/settings.json; max_thinking_tokens sets + # MAX_THINKING_TOKENS in the container (0 disables thinking where allowed). + # claude: + # oauth_token: null # prefer CLAUDE_CODE_OAUTH_TOKEN in the env + # fallback_model: [sonnet, haiku] + # always_thinking: true + # max_thinking_tokens: null + # Trace each agent session to Langfuse (https://langfuse.com). Works for Codex # and Claude Code -- each uses its own native integration (Codex plugin, Claude # Code Stop hook) and Teich passes the credentials into the container. Side- @@ -1080,7 +1111,9 @@ def init( # Optional reasoning / thinking level. # - Codex: forwarded as model_reasoning_effort # - Pi: normalized to low / medium / high when supported - # - Claude Code / Hermes: model/provider specific + # - Claude Code: forwarded as --effort (low | medium | high | xhigh | max on + # supported models) + # - Hermes: model/provider specific # Hermes also enables built-in toolsets: # safe,terminal,file,skills,memory,session_search,delegation reasoning_effort: medium diff --git a/src/teich/config.py b/src/teich/config.py index 4917124..3ebf17b 100644 --- a/src/teich/config.py +++ b/src/teich/config.py @@ -16,6 +16,13 @@ GITHUB_REPO_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") PLACEHOLDER_API_KEYS = {"", "none", "null", "dummy", "placeholder", "example", "local"} +CLAUDE_PROVIDER_ALIASES = frozenset({"claude", "claude-code", "claude_code"}) +# Resolution order for the Claude OAuth token env fallback; also drives the +# source reporting in the CLI notice, so keep it the single source of truth. +CLAUDE_OAUTH_TOKEN_ENV_ALIASES: tuple[str, ...] = ( + "TEICH_CLAUDE_OAUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", +) def _api_key_env_aliases(provider: str | None) -> list[str]: @@ -148,12 +155,39 @@ class CodexAuthConfig(BaseModel): broker_port: int = Field(default=0, ge=0, le=65535) +class ClaudeConfig(BaseModel): + """Claude Code-specific settings, set under ``agent.claude``. + + Subscription auth (Pro/Max): when a long-lived OAuth token is available — + ``oauth_token`` here, or the ``TEICH_CLAUDE_OAUTH_TOKEN`` / + ``CLAUDE_CODE_OAUTH_TOKEN`` env vars (create one with ``claude + setup-token``) — and ``api.base_url`` is unset, Claude Code runs on your + Claude subscription: the token is passed into each container as + ``CLAUDE_CODE_OAUTH_TOKEN`` and no ``ANTHROPIC_API_KEY`` is passed, because + an API key silently takes precedence over subscription auth inside Claude + Code. Usage counts against the subscription's rate-limit windows, not + pay-per-token API credits, and the token is safe to share across any + ``max_concurrency`` (no rotation, unlike Codex). + + ``fallback_model``, ``always_thinking``, and ``max_thinking_tokens`` are + Claude Code passthroughs: ``--fallback-model`` (a single model/alias or a + list; Claude Code uses up to 3 after dedup), ``alwaysThinkingEnabled`` in + the container's ``~/.claude/settings.json``, and the ``MAX_THINKING_TOKENS`` + container env (0 disables thinking on models that allow it). + """ + oauth_token: str | None = None + fallback_model: str | list[str] | None = None + always_thinking: bool | None = None + max_thinking_tokens: int | None = Field(default=None, ge=0) + + class AgentConfig(BaseModel): """Agent runtime selection.""" provider: str = "codex" # Langfuse tracing, applied to every agent that supports it (Codex, Claude). langfuse: LangfuseConfig = Field(default_factory=LangfuseConfig) codex: CodexAuthConfig = Field(default_factory=CodexAuthConfig) + claude: ClaudeConfig = Field(default_factory=ClaudeConfig) class ModelConfig(BaseModel): @@ -319,6 +353,26 @@ def validate_codex_auth_dir(self) -> Config: ) return self + @model_validator(mode="after") + def validate_claude_host_auth(self) -> Config: + """Subscription auth talks to the first-party Anthropic API only. + + Only an explicitly configured ``oauth_token`` conflicts with + ``api.base_url``; an ambient env token must not fail base_url configs, + so it is simply not used there (see ``claude_host_auth_active``). + """ + if ( + self.agent.claude.oauth_token + and self.get_agent_provider() in CLAUDE_PROVIDER_ALIASES + and self.api.base_url + ): + raise ValueError( + "agent.claude.oauth_token runs Claude Code on your Claude " + f"subscription and cannot be combined with api.base_url ({self.api.base_url}); " + "unset one of them." + ) + return self + @classmethod def from_yaml(cls, path: Path) -> Config: """Load config from YAML file. @@ -401,6 +455,50 @@ def get_codex_host_auth_source(self) -> Path: base = Path(codex_home).expanduser() if codex_home else Path.home() / ".codex" return base / "auth.json" + def get_claude_oauth_token(self) -> str | None: + """Resolve the long-lived Claude Code OAuth token for host auth. + + Honors an explicit ``agent.claude.oauth_token``, then the + ``TEICH_CLAUDE_OAUTH_TOKEN`` and ``CLAUDE_CODE_OAUTH_TOKEN`` env vars. + """ + token = self.agent.claude.oauth_token + if isinstance(token, str) and token.strip(): + return token.strip() + return _get_env_alias(*CLAUDE_OAUTH_TOKEN_ENV_ALIASES) + + def get_claude_oauth_token_source(self) -> str | None: + """Name the source ``get_claude_oauth_token`` resolves from (for CLI messaging).""" + token = self.agent.claude.oauth_token + if isinstance(token, str) and token.strip(): + return "agent.claude.oauth_token" + for name in CLAUDE_OAUTH_TOKEN_ENV_ALIASES: + if _get_env_alias(name): + return name + return None + + def claude_host_auth_active(self) -> bool: + """True when Claude Code runs on subscription auth. + + Active when the provider is Claude Code, an OAuth token is resolvable + (config or env), and no custom ``api.base_url`` is configured — an + explicit base_url keeps the API/proxy path, so an ambient env token + cannot silently override it. + """ + return ( + self.get_agent_provider() in CLAUDE_PROVIDER_ALIASES + and not self.api.base_url + and self.get_claude_oauth_token() is not None + ) + + def get_claude_fallback_model(self) -> str | None: + """Normalize ``agent.claude.fallback_model`` to Claude Code's comma-separated form.""" + fallback = self.agent.claude.fallback_model + if fallback is None: + return None + parts = fallback.split(",") if isinstance(fallback, str) else fallback + names = [str(part).strip() for part in parts] + return ",".join(name for name in names if name) or None + def get_dataset_tags(self) -> list[str]: """Get auto-generated dataset tags for README frontmatter and uploads.""" provider = self.get_agent_provider() diff --git a/src/teich/runner.py b/src/teich/runner.py index 3a38933..37d69c3 100644 --- a/src/teich/runner.py +++ b/src/teich/runner.py @@ -2906,6 +2906,10 @@ def _base_url_env_items(self) -> list[tuple[str, str]]: def _langfuse_env_items(self) -> list[tuple[str, str]]: return [] + def _agent_env_items(self) -> list[tuple[str, str]]: + """Agent-specific container env vars; overridden per runner.""" + return [] + def _prepare_agent_home(self, home_dir: Path) -> None: return @@ -2948,6 +2952,7 @@ def _build_external_docker_base_command( *self._api_env_items(), *self._base_url_env_items(), *self._langfuse_env_items(), + *self._agent_env_items(), ]: command.extend(["-e", f"{key}={value}"]) command.append(self.image_name) @@ -3245,15 +3250,31 @@ def _langfuse_env_items(self) -> list[tuple[str, str]]: ("LANGFUSE_BASE_URL", self._langfuse_container_base_url() or ""), ] + def _agent_env_items(self) -> list[tuple[str, str]]: + # 0 is meaningful (disables thinking on models that allow it), so only + # None is "unset". + if self.config.agent.claude.max_thinking_tokens is None: + return [] + return [("MAX_THINKING_TOKENS", str(self.config.agent.claude.max_thinking_tokens))] + def _prepare_agent_home(self, home_dir: Path) -> None: - if not self.config.agent.langfuse.enabled: + settings = self._claude_settings() + if not settings: return - hook = {"hooks": [{"type": "command", "command": CLAUDE_LANGFUSE_HOOK_COMMAND}]} - settings = {"hooks": {"Stop": [hook], "SessionEnd": [hook]}} settings_path = home_dir / "settings.json" settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") settings_path.chmod(0o666) + def _claude_settings(self) -> dict[str, object]: + """Compose the container's ~/.claude/settings.json from config.""" + settings: dict[str, object] = {} + if self.config.agent.claude.always_thinking is not None: + settings["alwaysThinkingEnabled"] = self.config.agent.claude.always_thinking + if self.config.agent.langfuse.enabled: + hook = {"hooks": [{"type": "command", "command": CLAUDE_LANGFUSE_HOOK_COMMAND}]} + settings["hooks"] = {"Stop": [hook], "SessionEnd": [hook]} + return settings + @staticmethod def _list_native_session_files(home_dir: Path) -> list[Path]: projects_dir = home_dir / "projects" @@ -3336,6 +3357,12 @@ def _base_url_env_items(self) -> list[tuple[str, str]]: ] def _api_env_items(self) -> list[tuple[str, str]]: + token = self.config.get_claude_oauth_token() if self.config.claude_host_auth_active() else None + if token: + # Subscription auth: pass only the long-lived OAuth token, never an + # API key — ANTHROPIC_API_KEY silently takes precedence over + # subscription credentials inside Claude Code and would bill the API. + return [("CLAUDE_CODE_OAUTH_TOKEN", token)] api_key = self.config.get_api_key() or ("none" if self.config.get_base_url() else "") if not api_key: return [] @@ -3398,6 +3425,11 @@ def _build_shell_command( permission_mode = self._permission_mode() if permission_mode: claude_command.extend(["--permission-mode", permission_mode]) + if self.config.model.reasoning_effort: + claude_command.extend(["--effort", self.config.model.reasoning_effort]) + fallback_model = self.config.get_claude_fallback_model() + if fallback_model: + claude_command.extend(["--fallback-model", fallback_model]) if self.config.developer_instructions: claude_command.extend(["--append-system-prompt", self.config.developer_instructions]) if resume_session_id: diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d7ea8b..dc8e2b6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -286,6 +286,54 @@ def test_generate_no_host_auth_warning_when_disabled(tmp_path: Path): assert "host Codex login" not in result.output +def _claude_config_file(tmp_path: Path) -> Path: + config_file = tmp_path / "config.yaml" + config_file.write_text(f""" +agent: + provider: claude-code +model: + model: claude-opus-4-8 +prompts: + - Build a todo app +output: + traces_dir: {tmp_path}/output +""") + return config_file + + +def test_generate_claude_token_prints_subscription_notice(tmp_path: Path, monkeypatch): + """A resolvable token activates subscription auth and says so up front.""" + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-token") + config_file = _claude_config_file(tmp_path) + + with patch('teich.cli.ClaudeCodeRunner') as mock_runner: + mock_instance = MagicMock() + mock_instance.run_all.return_value = [] + mock_runner.return_value = mock_instance + result = runner.invoke(app, ["generate", "-c", str(config_file)]) + + assert "Claude OAuth token found" in result.output + assert "CLAUDE_CODE_OAUTH_TOKEN" in result.output + assert "subscription" in result.output + assert result.exit_code == 0 + + +def test_generate_claude_no_notice_without_token(tmp_path: Path, monkeypatch): + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + config_file = _claude_config_file(tmp_path) + + with patch('teich.cli.ClaudeCodeRunner') as mock_runner: + mock_instance = MagicMock() + mock_instance.run_all.return_value = [] + mock_runner.return_value = mock_instance + result = runner.invoke(app, ["generate", "-c", str(config_file)]) + + assert "Claude OAuth token found" not in result.output + assert result.exit_code == 0 + + def test_generate_command_success_chat(tmp_path: Path): config_file = tmp_path / "config.yaml" config_file.write_text(f""" diff --git a/tests/test_config.py b/tests/test_config.py index 6d8b7d3..151c043 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -476,6 +476,146 @@ def test_codex_host_auth_source_defaults_to_home(monkeypatch): assert config.get_codex_host_auth_source() == Path.home() / ".codex" / "auth.json" +def test_claude_config_defaults(): + """No token, no passthroughs: everything under agent.claude is opt-in.""" + claude = Config().agent.claude + assert claude.oauth_token is None + assert claude.fallback_model is None + assert claude.always_thinking is None + assert claude.max_thinking_tokens is None + + +def test_claude_oauth_token_prefers_explicit_config(monkeypatch): + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "env-token") + config = Config(agent={"claude": {"oauth_token": "config-token"}}) + assert config.get_claude_oauth_token() == "config-token" + + +def test_claude_oauth_token_falls_back_to_env(monkeypatch): + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "env-token") + assert Config().get_claude_oauth_token() == "env-token" + + +def test_claude_oauth_token_teich_env_wins_over_claude_env(monkeypatch): + monkeypatch.setenv("TEICH_CLAUDE_OAUTH_TOKEN", "teich-token") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "claude-token") + assert Config().get_claude_oauth_token() == "teich-token" + + +def test_claude_oauth_token_absent_when_unset(monkeypatch): + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + assert Config().get_claude_oauth_token() is None + + +def test_claude_oauth_token_source_names_the_resolving_source(monkeypatch): + """The CLI notice reports the source; it must track the getter's resolution order.""" + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + assert Config().get_claude_oauth_token_source() is None + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "env-token") + assert Config().get_claude_oauth_token_source() == "CLAUDE_CODE_OAUTH_TOKEN" + monkeypatch.setenv("TEICH_CLAUDE_OAUTH_TOKEN", "teich-token") + assert Config().get_claude_oauth_token_source() == "TEICH_CLAUDE_OAUTH_TOKEN" + config = Config(agent={"claude": {"oauth_token": "config-token"}}) + assert config.get_claude_oauth_token_source() == "agent.claude.oauth_token" + + +def test_claude_host_auth_active_with_token(monkeypatch): + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "env-token") + assert Config(agent={"provider": "claude-code"}).claude_host_auth_active() is True + + +def test_claude_host_auth_inactive_without_token(monkeypatch): + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + assert Config(agent={"provider": "claude-code"}).claude_host_auth_active() is False + + +def test_claude_host_auth_inactive_for_other_providers(monkeypatch): + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "env-token") + assert Config(agent={"provider": "codex"}).claude_host_auth_active() is False + + +def test_claude_ambient_token_yields_to_base_url(monkeypatch): + """An ambient env token must not override an explicit base_url config (no error either).""" + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "env-token") + config = Config( + agent={"provider": "claude-code"}, + api={"provider": "openrouter", "base_url": "https://openrouter.ai/api/v1"}, + ) + assert config.claude_host_auth_active() is False + + +def test_claude_configured_token_rejects_base_url(): + """An explicit config token + base_url is a contradiction, not a silent fallback.""" + with pytest.raises(ValueError, match="oauth_token"): + Config( + agent={"provider": "claude-code", "claude": {"oauth_token": "config-token"}}, + api={"provider": "openrouter", "base_url": "https://openrouter.ai/api/v1"}, + ) + + +def test_claude_configured_token_base_url_allowed_for_other_providers(): + """A leftover agent.claude block must not break codex + base_url configs.""" + config = Config( + agent={"provider": "codex", "claude": {"oauth_token": "config-token"}}, + api={"provider": "openrouter", "base_url": "https://openrouter.ai/api/v1"}, + ) + assert config.agent.claude.oauth_token == "config-token" + + +def test_claude_fallback_model_defaults_to_none(): + assert Config().agent.claude.fallback_model is None + assert Config().get_claude_fallback_model() is None + + +def test_claude_fallback_model_accepts_string_and_list(): + def cfg(fallback: object) -> Config: + return Config(agent={"claude": {"fallback_model": fallback}}) + + assert cfg("sonnet").get_claude_fallback_model() == "sonnet" + assert cfg("sonnet, haiku").get_claude_fallback_model() == "sonnet,haiku" + assert cfg(["sonnet", "haiku"]).get_claude_fallback_model() == "sonnet,haiku" + + +def test_claude_fallback_model_blank_entries_are_dropped(): + assert Config(agent={"claude": {"fallback_model": [" ", ""]}}).get_claude_fallback_model() is None + assert Config(agent={"claude": {"fallback_model": ",,"}}).get_claude_fallback_model() is None + + +def test_claude_max_thinking_tokens_rejects_negative(): + with pytest.raises(ValueError): + Config(agent={"claude": {"max_thinking_tokens": -1}}) + + +def test_claude_settings_from_yaml(tmp_path: Path, monkeypatch): + monkeypatch.delenv("TEICH_MODEL", raising=False) + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ +agent: + provider: claude-code + claude: + oauth_token: sk-ant-oat01-test + fallback_model: [sonnet, haiku] + always_thinking: true + max_thinking_tokens: 31999 +model: + model: claude-opus-4-8 + reasoning_effort: xhigh +""" + ) + config = Config.from_yaml(config_file) + assert config.claude_host_auth_active() is True + assert config.model.reasoning_effort == "xhigh" + assert config.get_claude_fallback_model() == "sonnet,haiku" + assert config.agent.claude.always_thinking is True + assert config.agent.claude.max_thinking_tokens == 31999 + + def test_mcp_config(): """Test MCP server configuration.""" mcp = MCPConfig( diff --git a/tests/test_runner.py b/tests/test_runner.py index dfba6e8..368fb83 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -842,7 +842,9 @@ def test_external_runner_chmods_workspace_before_snapshot(): assert "chmod -R a+rwX /home/codex/.hermes /workspace" in command -def test_claude_code_runner_uses_stream_json_and_prompt_file(tmp_path: Path): +def test_claude_code_runner_uses_stream_json_and_prompt_file(tmp_path: Path, monkeypatch): + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) long_prompt = "x" * 40000 config = Config( agent={"provider": "claude-code"}, @@ -1725,6 +1727,165 @@ def test_claude_omits_system_prompt_without_developer_instructions(tmp_path: Pat assert "--append-system-prompt" not in runner._build_shell_command() +def _claude_runner(tmp_path: Path, **config_kwargs: object) -> ClaudeCodeRunner: + config_kwargs.setdefault("agent", {"provider": "claude-code"}) + config_kwargs.setdefault( + "output", {"traces_dir": tmp_path / "output", "sandbox_dir": tmp_path / "sandbox"} + ) + with patch.object(ClaudeCodeRunner, "_ensure_image"): + return ClaudeCodeRunner(Config(**config_kwargs)) + + +def test_claude_forwards_reasoning_effort_as_effort_flag(tmp_path: Path): + runner = _claude_runner(tmp_path, model={"model": "claude-opus-4-8", "reasoning_effort": "xhigh"}) + assert "--effort xhigh" in runner._build_shell_command() + + +def test_claude_forwards_fallback_model_chain(tmp_path: Path): + runner = _claude_runner( + tmp_path, + agent={"provider": "claude-code", "claude": {"fallback_model": ["sonnet", "haiku"]}}, + model={"model": "claude-opus-4-8"}, + ) + assert "--fallback-model sonnet,haiku" in runner._build_shell_command() + + +def test_claude_omits_effort_and_fallback_flags_when_unset(tmp_path: Path): + runner = _claude_runner(tmp_path, model={"model": "claude-opus-4-8"}) + command = runner._build_shell_command() + assert "--effort" not in command + assert "--fallback-model" not in command + + +def test_claude_prepare_agent_home_writes_always_thinking(tmp_path: Path): + runner = _claude_runner( + tmp_path, + agent={"provider": "claude-code", "claude": {"always_thinking": True}}, + model={"model": "claude-opus-4-8"}, + ) + home_dir = tmp_path / "home" + home_dir.mkdir() + runner._prepare_agent_home(home_dir) + settings = json.loads((home_dir / "settings.json").read_text(encoding="utf-8")) + assert settings == {"alwaysThinkingEnabled": True} + + +def test_claude_prepare_agent_home_merges_thinking_with_langfuse_hooks(tmp_path: Path): + runner = _claude_runner( + tmp_path, + agent={ + "provider": "claude-code", + "claude": {"always_thinking": False}, + "langfuse": { + "enabled": True, + "public_key": "pk-lf-x", + "secret_key": "sk-lf-x", + "base_url": "https://cloud.langfuse.com", + }, + }, + model={"model": "claude-opus-4-8"}, + ) + home_dir = tmp_path / "home" + home_dir.mkdir() + runner._prepare_agent_home(home_dir) + settings = json.loads((home_dir / "settings.json").read_text(encoding="utf-8")) + assert settings["alwaysThinkingEnabled"] is False + assert "Stop" in settings["hooks"] + assert "SessionEnd" in settings["hooks"] + + +def test_claude_prepare_agent_home_writes_no_settings_by_default(tmp_path: Path): + runner = _claude_runner(tmp_path, model={"model": "claude-opus-4-8"}) + home_dir = tmp_path / "home" + home_dir.mkdir() + runner._prepare_agent_home(home_dir) + assert not (home_dir / "settings.json").exists() + + +def test_claude_max_thinking_tokens_env_in_docker_command(tmp_path: Path): + runner = _claude_runner( + tmp_path, + agent={"provider": "claude-code", "claude": {"max_thinking_tokens": 31999}}, + api={"provider": "anthropic", "api_key": "sk-ant-test"}, + ) + command = runner._build_external_docker_base_command( + tmp_path / "ws", tmp_path / "home", "teich-claude-x" + ) + assert "MAX_THINKING_TOKENS=31999" in command + + +def test_claude_max_thinking_tokens_zero_is_forwarded(tmp_path: Path): + """0 disables thinking, so it must not be treated as unset.""" + runner = _claude_runner( + tmp_path, + agent={"provider": "claude-code", "claude": {"max_thinking_tokens": 0}}, + ) + command = runner._build_external_docker_base_command( + tmp_path / "ws", tmp_path / "home", "teich-claude-x" + ) + assert "MAX_THINKING_TOKENS=0" in command + + +def test_claude_max_thinking_tokens_absent_when_unset(tmp_path: Path): + runner = _claude_runner(tmp_path, model={"model": "claude-opus-4-8"}) + command = runner._build_external_docker_base_command( + tmp_path / "ws", tmp_path / "home", "teich-claude-x" + ) + assert not any(part.startswith("MAX_THINKING_TOKENS=") for part in command) + + +def test_claude_token_activates_subscription_auth_and_hides_api_key(tmp_path: Path, monkeypatch): + """With a token resolvable, only the subscription token enters the container — + an API key would silently win over it inside Claude Code.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-ambient-should-not-leak") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-token") + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + runner = _claude_runner( + tmp_path, + api={"provider": "anthropic", "api_key": "sk-ant-configured"}, + ) + command = runner._build_external_docker_base_command( + tmp_path / "ws", tmp_path / "home", "teich-claude-x" + ) + assert "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-token" in command + assert not any(part.startswith("ANTHROPIC_API_KEY=") for part in command) + assert not any(part.startswith("ANTHROPIC_AUTH_TOKEN=") for part in command) + assert not any(part.startswith("TEICH_API_KEY=") for part in command) + + +def test_claude_without_token_passes_api_key(tmp_path: Path, monkeypatch): + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + runner = _claude_runner( + tmp_path, + api={"provider": "anthropic", "api_key": "sk-ant-test"}, + ) + command = runner._build_external_docker_base_command( + tmp_path / "ws", tmp_path / "home", "teich-claude-x" + ) + assert "ANTHROPIC_API_KEY=sk-ant-test" in command + assert not any(part.startswith("CLAUDE_CODE_OAUTH_TOKEN=") for part in command) + + +def test_claude_base_url_wins_over_ambient_token(tmp_path: Path, monkeypatch): + """An explicit base_url keeps the API/proxy path even with an ambient token.""" + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-token") + runner = _claude_runner( + tmp_path, + api={ + "provider": "openrouter", + "api_key": "sk-or-test", + "base_url": "https://openrouter.ai/api/v1", + }, + model={"model": "minimax/minimax-m2.5:free"}, + ) + command = runner._build_external_docker_base_command( + tmp_path / "ws", tmp_path / "home", "teich-claude-x" + ) + assert not any(part.startswith("CLAUDE_CODE_OAUTH_TOKEN=") for part in command) + assert "ANTHROPIC_AUTH_TOKEN=sk-or-test" in command + + def test_pi_appends_developer_instructions_as_system_prompt(tmp_path: Path): with patch.object(PiRunner, "_ensure_image"): runner = PiRunner(_dev_instructions_config("pi", tmp_path))