diff --git a/src/teich/config.py b/src/teich/config.py index 3ebf17b..f4fcb3d 100644 --- a/src/teich/config.py +++ b/src/teich/config.py @@ -362,7 +362,7 @@ def validate_claude_host_auth(self) -> Config: so it is simply not used there (see ``claude_host_auth_active``). """ if ( - self.agent.claude.oauth_token + _normalize_api_key(self.agent.claude.oauth_token) and self.get_agent_provider() in CLAUDE_PROVIDER_ALIASES and self.api.base_url ): @@ -461,15 +461,13 @@ def get_claude_oauth_token(self) -> str | None: 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() + if token := _normalize_api_key(self.agent.claude.oauth_token): + return token 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(): + if _normalize_api_key(self.agent.claude.oauth_token): return "agent.claude.oauth_token" for name in CLAUDE_OAUTH_TOKEN_ENV_ALIASES: if _get_env_alias(name): diff --git a/src/teich/runner.py b/src/teich/runner.py index 37d69c3..2117156 100644 --- a/src/teich/runner.py +++ b/src/teich/runner.py @@ -3407,6 +3407,15 @@ def _openrouter_proxy_shell_prefix(self) -> str: ) return f"node {shlex.quote(proxy_script)} >/tmp/claude-openrouter-proxy.log 2>&1 & {readiness_probe}" + def _claude_passthrough_args(self) -> list[str]: + """Build model-related flags shared by batch and Studio Claude sessions.""" + args: list[str] = [] + if self.config.model.reasoning_effort: + args.extend(["--effort", self.config.model.reasoning_effort]) + if fallback_model := self.config.get_claude_fallback_model(): + args.extend(["--fallback-model", fallback_model]) + return args + def _build_shell_command( self, *, @@ -3425,11 +3434,7 @@ 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]) + claude_command.extend(self._claude_passthrough_args()) if self.config.developer_instructions: claude_command.extend(["--append-system-prompt", self.config.developer_instructions]) if resume_session_id: diff --git a/src/teich/studio/interactive.py b/src/teich/studio/interactive.py index 20685eb..205997d 100644 --- a/src/teich/studio/interactive.py +++ b/src/teich/studio/interactive.py @@ -382,6 +382,7 @@ def _native_cli_command(self) -> list[str]: permission_mode = runner._permission_mode() if permission_mode: cli.extend(["--permission-mode", permission_mode]) + cli.extend(runner._claude_passthrough_args()) return cli # hermes return [ diff --git a/tests/test_config.py b/tests/test_config.py index 151c043..fd58a9f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -509,6 +509,31 @@ def test_claude_oauth_token_absent_when_unset(monkeypatch): assert Config().get_claude_oauth_token() is None +def test_claude_oauth_token_placeholder_is_treated_as_absent(monkeypatch): + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + config = Config( + agent={"provider": "claude-code", "claude": {"oauth_token": " dummy "}}, + api={"provider": "anthropic", "api_key": "sk-ant-test"}, + ) + + assert config.get_claude_oauth_token() is None + assert config.get_claude_oauth_token_source() is None + assert config.claude_host_auth_active() is False + assert config.get_api_key() == "sk-ant-test" + + +def test_claude_oauth_token_placeholder_allows_base_url(monkeypatch): + monkeypatch.delenv("TEICH_CLAUDE_OAUTH_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + config = Config( + agent={"provider": "claude-code", "claude": {"oauth_token": "none"}}, + api={"provider": "openrouter", "base_url": "https://openrouter.ai/api/v1"}, + ) + + 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) diff --git a/tests/test_studio.py b/tests/test_studio.py index 93ac4e4..658b8f5 100644 --- a/tests/test_studio.py +++ b/tests/test_studio.py @@ -16,7 +16,7 @@ from teich.cli import _configure_studio_event_loop_policy from teich.config import Config from teich.extract import CURSOR_EXTRACTION_NOTICE -from teich.runner import SessionProgressUpdate +from teich.runner import ClaudeCodeRunner, SessionProgressUpdate from teich.studio.events import summarize_chat_row, summarize_event, summarize_trace_events from teich.studio.generation import RUNNER_CLASSES, GenerationJob from teich.studio.interactive import InteractiveSession @@ -889,6 +889,31 @@ def test_chat_session_discard_rejected_while_turn_running(tmp_path): assert session.status == "running" +def test_claude_terminal_forwards_effort_and_fallback_model(tmp_path): + config = Config( + agent={"provider": "claude-code", "claude": {"fallback_model": ["sonnet", "haiku"]}}, + model={"model": "claude-opus-4-8", "reasoning_effort": "high"}, + output={"traces_dir": tmp_path / "output", "sandbox_dir": tmp_path / "sandbox"}, + ) + session = InteractiveSession(config) + with patch.object(ClaudeCodeRunner, "_ensure_image"): + session._runner = ClaudeCodeRunner(config) + + command = session._native_cli_command() + + assert command == [ + "claude", + "--model", + "claude-opus-4-8", + "--permission-mode", + "bypassPermissions", + "--effort", + "high", + "--fallback-model", + "sonnet,haiku", + ] + + def test_generation_stop_prevents_later_prompt_starts(tmp_path, monkeypatch): first_started = threading.Event() stop_called = threading.Event()