diff --git a/config.example.yaml b/config.example.yaml index 0c747e3..38f9f55 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -29,6 +29,19 @@ agent: # host_auth_file: null # default: $CODEX_HOME/auth.json or ~/.codex/auth.json # auth_dir: ./.teich/codex-auth + # 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- + # channel only: it reads transcripts, fails open, and doesn't change agent + # behavior or Teich's output files. All three credentials are required when + # enabled. base_url may be Langfuse Cloud or a self-hosted URL; for a host-local + # instance use http://host.docker.internal:. + # langfuse: + # enabled: true + # public_key: pk-lf-... + # secret_key: sk-lf-... + # base_url: https://cloud.langfuse.com + model: # Model id passed to the selected agent/provider. model: deepseek/deepseek-v4-flash diff --git a/docker/codex-runtime.Dockerfile b/docker/codex-runtime.Dockerfile index 91c4ca9..9c85b31 100644 --- a/docker/codex-runtime.Dockerfile +++ b/docker/codex-runtime.Dockerfile @@ -26,6 +26,7 @@ RUN ln -sf /usr/bin/python3 /usr/local/bin/python && \ ln -sf /opt/venv/bin/pip3 /usr/local/bin/pip3 ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright ENV VIRTUAL_ENV=/opt/venv +ARG TEICH_INSTALL_LANGFUSE=0 # Install Astral uv and npm-backed agent CLIs in one layer # Use npm cache mount for faster installs @@ -47,6 +48,31 @@ RUN --mount=type=cache,target=/root/.cache/uv \ chmod +x /usr/local/bin/hermes && \ (hermes --version || hermes --help >/dev/null) +# Bake Langfuse tooling only for the tracing-enabled runtime image. The default +# runtime image leaves this off so normal Teich runs do not depend on Langfuse +# plugin, pip, or git availability during Docker rebuilds. +RUN HOME=/opt/codex-langfuse CODEX_HOME=/opt/codex-langfuse/.codex \ + sh -c 'mkdir -p "$CODEX_HOME" \ + && if [ "$TEICH_INSTALL_LANGFUSE" = "1" ]; then \ + codex plugin marketplace add langfuse/codex-observability-plugin \ + && codex plugin add tracing@codex-observability-plugin; \ + fi \ + && chmod -R a+rX /opt/codex-langfuse' + +# Langfuse hook + SDK for Claude Code, pinned to a known-good commit so the +# baked hook script is reproducible. The SDK goes in the venv because Claude +# strips it from a hook's PATH, so the hook calls it by full path. +RUN if [ "$TEICH_INSTALL_LANGFUSE" = "1" ]; then \ + /opt/venv/bin/pip install --no-cache-dir "langfuse>=4.0,<5" \ + && git clone https://github.com/langfuse/Claude-Observability-Plugin.git \ + /opt/claude-langfuse-plugin \ + && git -C /opt/claude-langfuse-plugin checkout 597af67d6c6b369f3e55db6cfa2ebe444f1af46c \ + && rm -rf /opt/claude-langfuse-plugin/.git \ + && chmod -R a+rX /opt/claude-langfuse-plugin; \ + else \ + mkdir -p /opt/claude-langfuse-plugin; \ + fi + # Create working directory and user in one layer WORKDIR /workspace RUN useradd -m -s /bin/bash codex && \ diff --git a/src/teich/cli.py b/src/teich/cli.py index cfc1843..3aa0a8a 100644 --- a/src/teich/cli.py +++ b/src/teich/cli.py @@ -725,10 +725,20 @@ def generate( "[yellow]Heads up: once the rotating token is refreshed, your host Codex login " "will be invalidated — run `codex login` on the host afterward to restore it.[/yellow]" ) + if cfg.agent.langfuse.enabled: + console.print( + "[cyan]Codex Langfuse tracing enabled: uploading each session to " + f"{cfg.agent.langfuse.base_url}.[/cyan]" + ) elif agent_provider == "pi": runner = PiRunner(cfg) elif agent_provider in {"claude", "claude-code", "claude_code"}: runner = ClaudeCodeRunner(cfg) + if cfg.agent.langfuse.enabled: + console.print( + "[cyan]Claude Code Langfuse tracing enabled: uploading each session to " + f"{cfg.agent.langfuse.base_url}.[/cyan]" + ) elif agent_provider in {"hermes", "hermes-agent", "hermes_agent"}: runner = HermesRunner(cfg) elif agent_provider == "chat": @@ -1044,6 +1054,19 @@ def init( # host_auth_file: null # default: $CODEX_HOME/auth.json or ~/.codex/auth.json # auth_dir: ./.teich/codex-auth + # 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- + # channel only: it reads transcripts, fails open, and doesn't change agent + # behavior or Teich's output files. All three credentials are required when + # enabled. base_url may be Langfuse Cloud or a self-hosted URL; for a host-local + # instance use http://host.docker.internal:. + # langfuse: + # enabled: true + # public_key: pk-lf-... + # secret_key: sk-lf-... + # base_url: https://cloud.langfuse.com + model: # Model id passed to the selected agent/provider. model: deepseek/deepseek-v4-flash diff --git a/src/teich/config.py b/src/teich/config.py index fb27cf1..4917124 100644 --- a/src/teich/config.py +++ b/src/teich/config.py @@ -96,6 +96,38 @@ class APIConfig(BaseModel): wire_api: str = "responses" +class LangfuseConfig(BaseModel): + """Langfuse tracing credentials, set under ``agent.langfuse``. + + When enabled, Teich wires each agent's Langfuse integration (the Codex + plugin, the Claude Code Stop hook) and passes these credentials into the + container. Tracing is side-channel only: it reads transcripts, fails open, + and does not change agent behavior or Teich's output files. + """ + enabled: bool = False + public_key: str | None = None + secret_key: str | None = None + base_url: str | None = None + + @model_validator(mode="after") + def require_credentials_when_enabled(self) -> LangfuseConfig: + if self.enabled: + missing = [ + name + for name, value in ( + ("public_key", self.public_key), + ("secret_key", self.secret_key), + ("base_url", self.base_url), + ) + if not (value and value.strip()) + ] + if missing: + raise ValueError( + "langfuse requires " + ", ".join(missing) + " when enabled" + ) + return self + + class CodexAuthConfig(BaseModel): """Codex ChatGPT-subscription auth handling. @@ -119,6 +151,8 @@ class CodexAuthConfig(BaseModel): 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) diff --git a/src/teich/runner.py b/src/teich/runner.py index d73b0e9..3a38933 100644 --- a/src/teich/runner.py +++ b/src/teich/runner.py @@ -42,11 +42,26 @@ from .tool_schema import snapshot_configured_tools RUNTIME_IMAGE_NAME = "teich-runtime:v3" +LANGFUSE_RUNTIME_IMAGE_NAME = "teich-runtime:v3-langfuse" RUNTIME_DOCKERFILE_NAME = "codex-runtime.Dockerfile" RUNTIME_CONTAINER_USER = "codex" CODEX_HOME_IN_CONTAINER = "/home/codex/.codex" CLAUDE_HOME_IN_CONTAINER = "/home/codex/.claude" HERMES_HOME_IN_CONTAINER = "/home/codex/.hermes" + +# Langfuse Codex observability plugin: baked into the tracing-enabled runtime +# image at a staging CODEX_HOME (see codex-runtime.Dockerfile) so Teich can seed +# it into each session offline. The default runtime image does not install these +# optional dependencies. +LANGFUSE_PLUGIN_STAGE_CODEX_HOME = "/opt/codex-langfuse/.codex" +LANGFUSE_PLUGIN_ID = "tracing@codex-observability-plugin" + +# Claude strips /opt/venv from a hook's PATH, so call the venv python (which has +# the langfuse SDK) by absolute path. Script is baked into the tracing image. +CLAUDE_LANGFUSE_PLUGIN_DIR = "/opt/claude-langfuse-plugin" +CLAUDE_LANGFUSE_HOOK_COMMAND = ( + f"/opt/venv/bin/python3 {CLAUDE_LANGFUSE_PLUGIN_DIR}/hooks/langfuse_hook.py" +) PI_AGENT_DIR_IN_CONTAINER = "/home/codex/.pi/agent" PI_SESSIONS_DIR_IN_CONTAINER = "/home/codex/pi-sessions" WORKSPACE_IN_CONTAINER = "/workspace" @@ -928,7 +943,7 @@ class DockerRuntimeRunner: def __init__(self, config: Config): self.config = config - self.image_name = RUNTIME_IMAGE_NAME + self.image_name = self._runtime_image_name() self._configured_tools_snapshot: list[dict[str, Any]] | None = None self._active_processes: dict[subprocess.Popen[str], str | None] = {} self._active_processes_lock = threading.Lock() @@ -936,6 +951,12 @@ def __init__(self, config: Config): self._active_containers_lock = threading.Lock() self._ensure_image() + def _runtime_image_name(self) -> str: + return RUNTIME_IMAGE_NAME + + def _runtime_build_args(self) -> list[str]: + return [] + def _configured_tools(self) -> list[dict[str, Any]]: if self._configured_tools_snapshot is None: self._configured_tools_snapshot = snapshot_configured_tools(self.config) @@ -1009,7 +1030,16 @@ def _build_image(self) -> None: context = dockerfile_path.parent subprocess.run( - ["docker", "build", "-t", self.image_name, "-f", str(dockerfile_path), str(context)], + [ + "docker", + "build", + *self._runtime_build_args(), + "-t", + self.image_name, + "-f", + str(dockerfile_path), + str(context), + ], check=True, ) @@ -1024,6 +1054,15 @@ def _container_base_url(base_url: str | None) -> str | None: netloc = parsed.netloc.replace(hostname, "host.docker.internal", 1) return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)) + def _langfuse_container_base_url(self) -> str | None: + return self._container_base_url(self.config.agent.langfuse.base_url) + + def _langfuse_is_host_local(self) -> bool: + langfuse = self.config.agent.langfuse + return langfuse.enabled and "host.docker.internal" in ( + self._langfuse_container_base_url() or "" + ) + @staticmethod def _prompt_preview(prompt: str, limit: int = 60) -> str: normalized = " ".join(prompt.split()) @@ -2011,8 +2050,20 @@ class CodexRunner(DockerRuntimeRunner): def __init__(self, config: Config): self._broker: CodexTokenBroker | None = None self._broker_lock = threading.Lock() + self._langfuse_plugin_cache: Path | None = None + self._langfuse_plugin_lock = threading.Lock() super().__init__(config) + def _runtime_image_name(self) -> str: + if self.config.agent.langfuse.enabled: + return LANGFUSE_RUNTIME_IMAGE_NAME + return RUNTIME_IMAGE_NAME + + def _runtime_build_args(self) -> list[str]: + if self.config.agent.langfuse.enabled: + return ["--build-arg", "TEICH_INSTALL_LANGFUSE=1"] + return [] + @staticmethod def _toml_string(value: str) -> str: return json.dumps(value) @@ -2089,6 +2140,58 @@ def _is_likely_incompatible_custom_provider(provider: str) -> bool: normalized = provider.strip().lower() return normalized in {"llama.cpp", "llama_cpp", "llamacpp"} + def _ensure_langfuse_plugin_cache(self) -> Path | None: + """Extract the image-baked Langfuse plugin once per run.""" + if not self.config.agent.langfuse.enabled: + return None + if self._langfuse_plugin_cache is None: + with self._langfuse_plugin_lock: + if self._langfuse_plugin_cache is None: + self._ensure_image() + cache_dir = Path(tempfile.mkdtemp(prefix="teich-langfuse-plugin-")) + self._extract_langfuse_plugin(cache_dir) + self._langfuse_plugin_cache = cache_dir + return self._langfuse_plugin_cache + + def _extract_langfuse_plugin(self, cache_dir: Path) -> None: + created = subprocess.run( + ["docker", "create", self.image_name], + capture_output=True, + text=True, + check=True, + ) + container_id = created.stdout.strip() + try: + subprocess.run( + [ + "docker", + "cp", + f"{container_id}:{LANGFUSE_PLUGIN_STAGE_CODEX_HOME}/plugins", + str(cache_dir / "plugins"), + ], + capture_output=True, + text=True, + check=True, + ) + finally: + subprocess.run( + ["docker", "rm", "-f", container_id], + capture_output=True, + text=True, + check=False, + ) + + def _install_codex_langfuse_plugin(self, codex_home: Path) -> None: + cache = self._ensure_langfuse_plugin_cache() + if cache is None: + return + source = cache / "plugins" + dest = codex_home / "plugins" + shutil.copytree(source, dest, dirs_exist_ok=True) + for path in dest.rglob("*"): + path.chmod(0o777 if path.is_dir() else 0o644) + dest.chmod(0o777) + def _prepare_shared_host_auth(self) -> Path: """Seed (once) and return the shared project ``auth.json`` snapshot. @@ -2217,6 +2320,18 @@ def _write_codex_config(self, codex_home: Path) -> None: for key, value in mcp.env.items(): lines.append(f"{self._toml_string(key)} = {self._toml_string(value)}") + if self.config.agent.langfuse.enabled: + lines.extend( + [ + "", + "[features]", + "plugin_hooks = true", + "", + f'[plugins."{LANGFUSE_PLUGIN_ID}"]', + "enabled = true", + ] + ) + config_text = "\n".join(lines).strip() config_path = codex_home / "config.toml" config_path.write_text( @@ -2343,6 +2458,8 @@ def _build_codex_docker_base_command( provider_name = self.config.api.provider provider_env_key = self._provider_env_key(provider_name) broker = self._ensure_broker() + langfuse = self.config.agent.langfuse + langfuse_base_url = self._langfuse_container_base_url() cmd = [ "docker", "run", @@ -2364,7 +2481,12 @@ def _build_codex_docker_base_command( WORKSPACE_IN_CONTAINER, ] broker_active = broker is not None - if proxy_target or broker_active or (configured_base_url and base_url != configured_base_url): + if ( + proxy_target + or broker_active + or self._langfuse_is_host_local() + or (configured_base_url and base_url != configured_base_url) + ): cmd.extend([ "--add-host", "host.docker.internal:host-gateway", @@ -2375,6 +2497,19 @@ def _build_codex_docker_base_command( # sole owner of the rotating refresh token. Codex is seeded with its # own auth.json copy in CODEX_HOME (see _write_seeded_codex_auth). cmd.extend(["-e", f"CODEX_REFRESH_TOKEN_URL_OVERRIDE={broker.override_url}"]) + if langfuse.enabled: + cmd.extend( + [ + "-e", + "TRACE_TO_LANGFUSE=true", + "-e", + f"LANGFUSE_PUBLIC_KEY={langfuse.public_key}", + "-e", + f"LANGFUSE_SECRET_KEY={langfuse.secret_key}", + "-e", + f"LANGFUSE_BASE_URL={langfuse_base_url or ''}", + ] + ) if proxy_target: cmd.extend( [ @@ -2413,6 +2548,8 @@ def _build_codex_agent_command(self, resume: bool = False) -> list[str]: codex_cmd.extend(["resume", "--last"]) codex_cmd.extend(["--model", model]) codex_cmd.append("--skip-git-repo-check") + if self.config.agent.langfuse.enabled: + codex_cmd.append("--dangerously-bypass-hook-trust") if base_url and not self._is_oss_local_provider(provider_name): provider_key = self._custom_provider_key(provider_name) provider_literal = ( @@ -2566,6 +2703,7 @@ def run_session( broker = self._ensure_broker() if broker is not None: self._write_seeded_codex_auth(codex_home, broker) + self._install_codex_langfuse_plugin(codex_home) if self._local_provider_proxy_target(self.config.api.provider, self.config.get_base_url()): self._write_local_provider_proxy(codex_home) existing_sessions = {path.resolve() for path in self._list_session_files(codex_home)} @@ -2710,6 +2848,9 @@ def run_all( if self._broker is not None: self._broker.stop() self._broker = None + if self._langfuse_plugin_cache is not None: + shutil.rmtree(self._langfuse_plugin_cache, ignore_errors=True) + self._langfuse_plugin_cache = None class ExternalCliRunner(DockerRuntimeRunner): @@ -2762,6 +2903,12 @@ def _base_url_env_items(self) -> list[tuple[str, str]]: ("ANTHROPIC_BASE_URL", base_url), ] + def _langfuse_env_items(self) -> list[tuple[str, str]]: + return [] + + def _prepare_agent_home(self, home_dir: Path) -> None: + return + def _build_external_docker_base_command( self, workspace: Path, @@ -2791,9 +2938,17 @@ def _build_external_docker_base_command( WORKSPACE_IN_CONTAINER, ] configured_base_url = self.config.get_base_url() - if configured_base_url and self._container_base_url(configured_base_url) != configured_base_url: + base_url_is_host_local = bool( + configured_base_url + and self._container_base_url(configured_base_url) != configured_base_url + ) + if base_url_is_host_local or self._langfuse_is_host_local(): command.extend(["--add-host", "host.docker.internal:host-gateway"]) - for key, value in [*self._api_env_items(), *self._base_url_env_items()]: + for key, value in [ + *self._api_env_items(), + *self._base_url_env_items(), + *self._langfuse_env_items(), + ]: command.extend(["-e", f"{key}={value}"]) command.append(self.image_name) return command @@ -2996,6 +3151,7 @@ def run_session( workspace_root, workspace = self._prepare_workspace(session_id, prompt_input, self.container_kind) home_dir = Path(tempfile.mkdtemp(prefix=f"{self.container_kind}-home-{session_id}-")) home_dir.chmod(0o777) + self._prepare_agent_home(home_dir) started_at = datetime.now(timezone.utc) container_name = self._container_name(self.container_kind, session_id) turn_prompts = _agent_turn_prompts(prompt, prompt_input) @@ -3068,6 +3224,36 @@ class ClaudeCodeRunner(ExternalCliRunner): source_name = "claude-code" default_model_provider = "anthropic" + def _runtime_image_name(self) -> str: + if self.config.agent.langfuse.enabled: + return LANGFUSE_RUNTIME_IMAGE_NAME + return RUNTIME_IMAGE_NAME + + def _runtime_build_args(self) -> list[str]: + if self.config.agent.langfuse.enabled: + return ["--build-arg", "TEICH_INSTALL_LANGFUSE=1"] + return [] + + def _langfuse_env_items(self) -> list[tuple[str, str]]: + langfuse = self.config.agent.langfuse + if not langfuse.enabled: + return [] + return [ + ("TRACE_TO_LANGFUSE", "true"), + ("LANGFUSE_PUBLIC_KEY", langfuse.public_key or ""), + ("LANGFUSE_SECRET_KEY", langfuse.secret_key or ""), + ("LANGFUSE_BASE_URL", self._langfuse_container_base_url() or ""), + ] + + def _prepare_agent_home(self, home_dir: Path) -> None: + if not self.config.agent.langfuse.enabled: + 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) + @staticmethod def _list_native_session_files(home_dir: Path) -> list[Path]: projects_dir = home_dir / "projects" @@ -3402,6 +3588,7 @@ def run_session( workspace_root, workspace = self._prepare_workspace(session_id, prompt_input, self.container_kind) home_dir = Path(tempfile.mkdtemp(prefix=f"{self.container_kind}-home-{session_id}-")) home_dir.chmod(0o777) + self._prepare_agent_home(home_dir) started_at = datetime.now(timezone.utc) container_name = self._container_name(self.container_kind, session_id) turn_prompts = _agent_turn_prompts(prompt, prompt_input) @@ -3954,6 +4141,7 @@ def run_session( workspace_root, workspace = self._prepare_workspace(session_id, prompt_input, self.container_kind) home_dir = Path(tempfile.mkdtemp(prefix=f"{self.container_kind}-home-{session_id}-")) home_dir.chmod(0o777) + self._prepare_agent_home(home_dir) container_name = self._container_name(self.container_kind, session_id) turn_prompts = _agent_turn_prompts(prompt, prompt_input) fallback_destination = self._resolve_hermes_trace_path() diff --git a/tests/test_codex_langfuse.py b/tests/test_codex_langfuse.py new file mode 100644 index 0000000..b247417 --- /dev/null +++ b/tests/test_codex_langfuse.py @@ -0,0 +1,181 @@ +"""Tests for Codex -> Langfuse tracing wiring. + +These tests are hermetic: they never touch Docker, the network, or a real +Langfuse instance. They cover the config.toml blocks Teich writes to enable the +plugin, the Langfuse env vars passed to the container, the exec hook-trust flag, +and the per-session install of the (image-baked) plugin tree into CODEX_HOME. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from teich.config import Config, ModelConfig +from teich.runner import CodexRunner + + +def _langfuse_config(**overrides) -> Config: + langfuse = { + "enabled": True, + "public_key": "pk-lf-123", + "secret_key": "sk-lf-456", + "base_url": "https://langfuse.example.com", + } + langfuse.update(overrides) + return Config( + model=ModelConfig(model="gpt-5.5"), + agent={"provider": "codex", "langfuse": langfuse}, + ) + + +# -- runtime image selection ------------------------------------------------- + +def test_codex_langfuse_uses_tracing_runtime_image(): + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(_langfuse_config()) + assert runner.image_name == "teich-runtime:v3-langfuse" + assert runner._runtime_build_args() == ["--build-arg", "TEICH_INSTALL_LANGFUSE=1"] + + +def test_codex_without_langfuse_uses_standard_runtime_image(): + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(Config(model=ModelConfig(model="gpt-5.5"))) + assert runner.image_name == "teich-runtime:v3" + assert runner._runtime_build_args() == [] + + +# -- config.toml blocks ------------------------------------------------------ + +def test_codex_config_writes_langfuse_blocks_when_enabled(tmp_path: Path): + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(_langfuse_config()) + codex_home = tmp_path / ".codex" + runner._write_codex_config(codex_home) + content = (codex_home / "config.toml").read_text(encoding="utf-8") + assert "[features]" in content + assert "plugin_hooks = true" in content + assert '[plugins."tracing@codex-observability-plugin"]' in content + assert "enabled = true" in content + + +def test_codex_config_omits_langfuse_blocks_when_disabled(tmp_path: Path): + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(Config(model=ModelConfig(model="gpt-5.5"))) + codex_home = tmp_path / ".codex" + runner._write_codex_config(codex_home) + content = (codex_home / "config.toml").read_text(encoding="utf-8") + assert "plugin_hooks" not in content + assert "tracing@codex-observability-plugin" not in content + + +# -- container env vars ------------------------------------------------------ + +def test_codex_command_passes_langfuse_env_when_enabled(tmp_path: Path): + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(_langfuse_config()) + cmd = runner._build_codex_command( + "Build app", + workspace=tmp_path / "ws", + codex_home=tmp_path / "ch", + container_name="teich-codex-x", + ) + assert "TRACE_TO_LANGFUSE=true" in cmd + assert "LANGFUSE_PUBLIC_KEY=pk-lf-123" in cmd + assert "LANGFUSE_SECRET_KEY=sk-lf-456" in cmd + assert "LANGFUSE_BASE_URL=https://langfuse.example.com" in cmd + + +def test_codex_command_omits_langfuse_env_when_disabled(tmp_path: Path): + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(Config(model=ModelConfig(model="gpt-5.5"))) + cmd = runner._build_codex_command( + "Build app", + workspace=tmp_path / "ws", + codex_home=tmp_path / "ch", + container_name="teich-codex-x", + ) + assert not any(part.startswith("TRACE_TO_LANGFUSE=") for part in cmd) + assert not any(part.startswith("LANGFUSE_") for part in cmd) + + +@pytest.mark.parametrize("host", ["localhost", "127.0.0.1"]) +def test_codex_command_rewrites_host_local_langfuse_base_url(tmp_path: Path, host: str): + cfg = _langfuse_config(base_url=f"http://{host}:3000") + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(cfg) + cmd = runner._build_codex_command( + "Build app", + workspace=tmp_path / "ws", + codex_home=tmp_path / "ch", + container_name="teich-codex-x", + ) + assert "LANGFUSE_BASE_URL=http://host.docker.internal:3000" in cmd + assert "host.docker.internal:host-gateway" in cmd + + +def test_codex_command_adds_host_gateway_for_host_local_langfuse(tmp_path: Path): + cfg = _langfuse_config(base_url="http://host.docker.internal:3000") + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(cfg) + cmd = runner._build_codex_command( + "Build app", + workspace=tmp_path / "ws", + codex_home=tmp_path / "ch", + container_name="teich-codex-x", + ) + assert "host.docker.internal:host-gateway" in cmd + + +# -- hook-trust bypass (exec) ------------------------------------------------ + +def test_codex_agent_command_bypasses_hook_trust_when_enabled(): + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(_langfuse_config()) + cmd = runner._build_codex_agent_command() + assert "exec" in cmd + assert "--dangerously-bypass-hook-trust" in cmd + + +def test_codex_agent_command_no_hook_trust_bypass_when_disabled(): + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(Config(model=ModelConfig(model="gpt-5.5"))) + cmd = runner._build_codex_agent_command() + assert "--dangerously-bypass-hook-trust" not in cmd + + +# -- per-session plugin install --------------------------------------------- + +def test_install_codex_langfuse_plugin_copies_tree(tmp_path: Path): + # Fake the image-baked cache so the test never touches Docker. + cache = tmp_path / "cache" + leaf = cache / "plugins" / "cache" / "codex-observability-plugin" / "tracing" / "0.1.0" / "dist" + leaf.mkdir(parents=True) + (leaf / "index.mjs").write_text("// bundle", encoding="utf-8") + + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(_langfuse_config()) + codex_home = tmp_path / ".codex" + codex_home.mkdir() + + with patch.object(runner, "_ensure_langfuse_plugin_cache", return_value=cache): + runner._install_codex_langfuse_plugin(codex_home) + + installed = ( + codex_home / "plugins" / "cache" / "codex-observability-plugin" + / "tracing" / "0.1.0" / "dist" / "index.mjs" + ) + assert installed.exists() + assert installed.read_text(encoding="utf-8") == "// bundle" + + +def test_install_codex_langfuse_plugin_noop_when_disabled(tmp_path: Path): + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(Config(model=ModelConfig(model="gpt-5.5"))) + codex_home = tmp_path / ".codex" + codex_home.mkdir() + # Disabled -> cache is None -> nothing copied, no error. + runner._install_codex_langfuse_plugin(codex_home) + assert not (codex_home / "plugins").exists() diff --git a/tests/test_langfuse_agents.py b/tests/test_langfuse_agents.py new file mode 100644 index 0000000..aad8ab5 --- /dev/null +++ b/tests/test_langfuse_agents.py @@ -0,0 +1,132 @@ +"""Tests for the shared Langfuse config + Claude Code tracing wiring. + +Hermetic: no Docker, network, or real Langfuse. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from teich.config import Config, LangfuseConfig, ModelConfig +from teich.runner import ClaudeCodeRunner + + +# -- shared config ----------------------------------------------------------- + +def test_langfuse_disabled_by_default(): + assert Config().agent.langfuse.enabled is False + + +def test_langfuse_ok_with_all_credentials(): + cfg = LangfuseConfig(enabled=True, public_key="pk", secret_key="sk", base_url="https://x") + assert cfg.enabled and cfg.public_key == "pk" + + +@pytest.mark.parametrize("missing", ["public_key", "secret_key", "base_url"]) +def test_langfuse_requires_each_credential(missing: str): + kwargs = {"public_key": "pk", "secret_key": "sk", "base_url": "https://x"} + del kwargs[missing] + with pytest.raises(ValueError, match=missing): + LangfuseConfig(enabled=True, **kwargs) + + +@pytest.mark.parametrize("blank", ["public_key", "secret_key", "base_url"]) +def test_langfuse_rejects_blank_credential(blank: str): + kwargs = {"public_key": "pk", "secret_key": "sk", "base_url": "https://x", blank: " "} + with pytest.raises(ValueError, match=blank): + LangfuseConfig(enabled=True, **kwargs) + + +# -- Claude Code env items --------------------------------------------------- + +def _claude_langfuse_config(base_url: str = "https://langfuse.example.com") -> Config: + return Config( + model=ModelConfig(model="claude-sonnet-4-6"), + agent={ + "provider": "claude-code", + "langfuse": { + "enabled": True, + "public_key": "pk-lf-1", + "secret_key": "sk-lf-2", + "base_url": base_url, + }, + }, + ) + + +def test_claude_langfuse_uses_tracing_runtime_image(): + with patch.object(ClaudeCodeRunner, "_ensure_image"): + runner = ClaudeCodeRunner(_claude_langfuse_config()) + assert runner.image_name == "teich-runtime:v3-langfuse" + assert runner._runtime_build_args() == ["--build-arg", "TEICH_INSTALL_LANGFUSE=1"] + + +def test_claude_without_langfuse_uses_standard_runtime_image(): + with patch.object(ClaudeCodeRunner, "_ensure_image"): + runner = ClaudeCodeRunner(Config(model=ModelConfig(model="claude-sonnet-4-6"))) + assert runner.image_name == "teich-runtime:v3" + assert runner._runtime_build_args() == [] + + +def test_claude_langfuse_env_items_when_enabled(): + with patch.object(ClaudeCodeRunner, "_ensure_image"): + runner = ClaudeCodeRunner(_claude_langfuse_config()) + items = dict(runner._langfuse_env_items()) + assert items["TRACE_TO_LANGFUSE"] == "true" + assert items["LANGFUSE_PUBLIC_KEY"] == "pk-lf-1" + assert items["LANGFUSE_SECRET_KEY"] == "sk-lf-2" + assert items["LANGFUSE_BASE_URL"] == "https://langfuse.example.com" + + +def test_claude_langfuse_env_items_empty_when_disabled(): + with patch.object(ClaudeCodeRunner, "_ensure_image"): + runner = ClaudeCodeRunner(Config(model=ModelConfig(model="claude-sonnet-4-6"))) + assert runner._langfuse_env_items() == [] + + +# -- Claude settings.json hook ----------------------------------------------- + +def test_claude_prepare_home_writes_stop_hook(tmp_path: Path): + with patch.object(ClaudeCodeRunner, "_ensure_image"): + runner = ClaudeCodeRunner(_claude_langfuse_config()) + home = tmp_path / "home" + home.mkdir() + runner._prepare_agent_home(home) + settings = json.loads((home / "settings.json").read_text()) + for event in ("Stop", "SessionEnd"): + cmd = settings["hooks"][event][0]["hooks"][0]["command"] + # Must use the venv python by absolute path (claude sanitizes PATH for hooks). + assert cmd.startswith("/opt/venv/bin/python3 ") + assert cmd.endswith("langfuse_hook.py") + + +def test_claude_prepare_home_noop_when_disabled(tmp_path: Path): + with patch.object(ClaudeCodeRunner, "_ensure_image"): + runner = ClaudeCodeRunner(Config(model=ModelConfig(model="claude-sonnet-4-6"))) + home = tmp_path / "home" + home.mkdir() + runner._prepare_agent_home(home) + assert not (home / "settings.json").exists() + + +# -- host-local base_url rewriting ------------------------------------------- + +@pytest.mark.parametrize("host", ["localhost", "127.0.0.1"]) +def test_claude_langfuse_base_url_rewrites_host_local(host: str): + with patch.object(ClaudeCodeRunner, "_ensure_image"): + runner = ClaudeCodeRunner(_claude_langfuse_config(base_url=f"http://{host}:3000")) + items = dict(runner._langfuse_env_items()) + assert items["LANGFUSE_BASE_URL"] == "http://host.docker.internal:3000" + assert runner._langfuse_is_host_local() is True + + +def test_cloud_base_url_is_not_rewritten(): + with patch.object(ClaudeCodeRunner, "_ensure_image"): + runner = ClaudeCodeRunner(_claude_langfuse_config()) + items = dict(runner._langfuse_env_items()) + assert items["LANGFUSE_BASE_URL"] == "https://langfuse.example.com" + assert runner._langfuse_is_host_local() is False