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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand All @@ -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
Expand Down
44 changes: 44 additions & 0 deletions docs/generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 36 additions & 3 deletions src/teich/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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-
Expand All @@ -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
Expand Down
98 changes: 98 additions & 0 deletions src/teich/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Comment on lines +465 to +466

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize configured Claude token before enabling host auth

agent.claude.oauth_token is returned verbatim here, unlike API keys and env token aliases that go through _normalize_api_key. If a config uses a placeholder such as oauth_token: none/dummy while an API key is configured, claude_host_auth_active() treats that placeholder as a real subscription token and _api_env_items() then withholds ANTHROPIC_API_KEY, so Claude runs fail authentication; the same raw value also trips the base_url conflict validator. Please normalize the configured token before treating it as present.

Useful? React with 👍 / 👎.

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()
Expand Down
38 changes: 35 additions & 3 deletions src/teich/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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])
Comment on lines +3428 to +3432

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mirror Claude flags in Studio terminal command

These passthroughs are only appended by _build_shell_command, but Studio terminal sessions build a separate Claude command in src/teich/studio/interactive.py::_native_cli_command and do not call this method. In that context, a user starting Claude Code from Studio with model.reasoning_effort or agent.claude.fallback_model gets a plain claude --model ..., so the newly documented settings are silently ignored; mirror the same flags there or scope the feature to batch generation.

Useful? React with 👍 / 👎.

if self.config.developer_instructions:
claude_command.extend(["--append-system-prompt", self.config.developer_instructions])
if resume_session_id:
Expand Down
Loading
Loading