diff --git a/docs/configuration.md b/docs/configuration.md index 24b8bb7..7771aa8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,7 +8,7 @@ lecode reads TOML config only (`config.toml`): - **Project**: `.lecode/config.toml`, found by walking from the cwd up to the git root. Deep-merged over the global config: dicts merge recursively, scalars and lists replace. -- **CLI flags** apply on top of both (`--model`, `--provider`, `--base-url`, +- **CLI flags** apply on top of both (`--model`, `--thinking`, `--header`, `--provider`, `--base-url`, `--api-key`, …). Unknown keys produce startup warnings. `schema_version = 1` is the current @@ -19,6 +19,22 @@ The API key resolution chain is: `--api-key` > provider env var > `[llm].api_key` in the config file. If you store the key in the file, keep it owner-only (`chmod 600`); `lecode --setup` does that for you. +`--thinking none|low|medium|high` overrides reasoning effort for one run in +interactive (including resume), headless, loop, and chain modes. `none` omits +the reasoning-effort field, leaving the provider's default behavior in effect. + +Repeat `--header 'Name: value'` to supply HTTP headers for that run's chat and +catalog requests, for example +`lecode -p 'Review this code' --thinking high --header 'X-Team: infra'`. +Names are case-insensitive: CLI headers override +provider headers and the last flag with the same name wins. A resolved API key +still takes precedence over an explicit Authorization header. `--auth-policy none` +suppresses generated API-key auth but permits explicit Authorization; +`required` still requires an API key. Values may contain colons or be empty; +surrounding spaces and tabs are trimmed. Invalid names, control characters +(except tabs in values), and non-ASCII values are rejected without echoing the +header value. Run headers are never written to config or session files. + ## `[llm]` | field | default | meaning | diff --git a/src/lecode/cli.py b/src/lecode/cli.py index 6933ad4..286adf6 100644 --- a/src/lecode/cli.py +++ b/src/lecode/cli.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import re import sys from collections.abc import Callable, Coroutine from pathlib import Path @@ -24,7 +25,7 @@ from lecode.agent.runner import AgentRunner, RunResult from lecode.auth import AuthError, resolve_api_key from lecode.config.loader import config_dir, find_config_file, load_config -from lecode.config.models import AuthPolicy, Config +from lecode.config.models import AuthPolicy, Config, ThinkingLevel from lecode.deps import find_missing_binaries, format_missing_error from lecode.extras import herdr from lecode.extras.background import BACKGROUND_EXTRA @@ -240,6 +241,8 @@ def _apply_cli_overrides( config: Config, *, model: str | None, + thinking: ThinkingLevel | None, + headers: dict[str, str] | None, provider: str | None, base_url: str | None, auth_policy: AuthPolicy | None, @@ -249,6 +252,10 @@ def _apply_cli_overrides( """CLI flag overrides apply on top of the merged config.""" if model: config.llm.model = model + if thinking is not None: + config.llm.thinking = thinking + if headers: + config.llm._cli_headers = dict(headers) if provider: config.llm.provider = provider if base_url: @@ -261,6 +268,24 @@ def _apply_cli_overrides( config.agent.max_turns = max_turns +def _parse_headers(values: list[str] | None) -> dict[str, str]: + headers: dict[str, str] = {} + for header in values or []: + name, separator, value = header.partition(":") + name = name.strip(" \t") + if ( + not separator + or not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", name) + or not re.fullmatch(r"[\t\x20-\x7e]*", value) + ): + raise typer.BadParameter( + "Expected 'Name: value' with a valid HTTP name and ASCII value without controls.", + param_hint="--header", + ) + headers[name.lower()] = value.strip(" \t") + return headers + + def _tool_filter(allowed_tools: str | None) -> list[str] | None: if not allowed_tools: return None @@ -290,6 +315,8 @@ def run_headless( prompt: str, *, model: str | None = None, + thinking: ThinkingLevel | None = None, + headers: dict[str, str] | None = None, provider: str | None = None, base_url: str | None = None, api_key: str | None = None, @@ -305,6 +332,8 @@ def run_headless( _apply_cli_overrides( config, model=model, + thinking=thinking, + headers=headers, provider=provider, base_url=base_url, auth_policy=auth_policy, @@ -416,6 +445,8 @@ def run_loop_mode( loop_cmd: str | None = None, max_iterations: int = DEFAULT_MAX_ITERATIONS, model: str | None = None, + thinking: ThinkingLevel | None = None, + headers: dict[str, str] | None = None, provider: str | None = None, base_url: str | None = None, api_key: str | None = None, @@ -434,6 +465,8 @@ def run_loop_mode( _apply_cli_overrides( config, model=model, + thinking=thinking, + headers=headers, provider=provider, base_url=base_url, auth_policy=auth_policy, @@ -541,6 +574,8 @@ def run_chain_mode( topic: str, *, model: str | None = None, + thinking: ThinkingLevel | None = None, + headers: dict[str, str] | None = None, provider: str | None = None, base_url: str | None = None, api_key: str | None = None, @@ -557,6 +592,8 @@ def run_chain_mode( _apply_cli_overrides( config, model=model, + thinking=thinking, + headers=headers, provider=provider, base_url=base_url, auth_policy=auth_policy, @@ -664,6 +701,8 @@ async def _run_tui( def run_interactive( *, model: str | None = None, + thinking: ThinkingLevel | None = None, + headers: dict[str, str] | None = None, provider: str | None = None, base_url: str | None = None, api_key: str | None = None, @@ -716,6 +755,8 @@ def run_interactive( _apply_cli_overrides( config, model=model, + thinking=thinking, + headers=headers, provider=provider, base_url=base_url, auth_policy=auth_policy, @@ -941,6 +982,13 @@ def callback( ), ] = None, model: Annotated[str | None, typer.Option("--model", help="Model id.")] = None, + thinking: Annotated[ + ThinkingLevel | None, + typer.Option("--thinking", help="Reasoning effort: none | low | medium | high."), + ] = None, + header: Annotated[ + list[str] | None, typer.Option("--header", help="HTTP header 'Name: value' (repeatable).") + ] = None, provider: Annotated[str | None, typer.Option("--provider", help="Provider name.")] = None, base_url: Annotated[ str | None, typer.Option("--base-url", help="OpenAI-compatible endpoint URL.") @@ -1010,6 +1058,7 @@ def callback( ] = None, ) -> None: """lecode — minimalist terminal AI coding agent.""" + headers = _parse_headers(header) if setup: raise typer.Exit(run_setup()) if hooks_test: @@ -1025,6 +1074,8 @@ def callback( loop_cmd=loop_cmd, max_iterations=max_iterations, model=model, + thinking=thinking, + headers=headers, provider=provider, base_url=base_url, api_key=api_key, @@ -1039,6 +1090,8 @@ def callback( run_chain_mode( chain, model=model, + thinking=thinking, + headers=headers, provider=provider, base_url=base_url, api_key=api_key, @@ -1052,6 +1105,8 @@ def callback( raise typer.Exit( run_interactive( model=model, + thinking=thinking, + headers=headers, provider=provider, base_url=base_url, api_key=api_key, @@ -1078,6 +1133,8 @@ def callback( run_headless( prompt, model=model, + thinking=thinking, + headers=headers, provider=provider, base_url=base_url, api_key=api_key, diff --git a/src/lecode/config/models.py b/src/lecode/config/models.py index fd1bf38..50cc082 100644 --- a/src/lecode/config/models.py +++ b/src/lecode/config/models.py @@ -9,7 +9,7 @@ from typing import Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator #: Current on-disk schema version. Bump when adding a migration. CURRENT_SCHEMA_VERSION = 2 @@ -42,6 +42,8 @@ class LlmConfig(BaseModel): api_key: str | None = None base_url: str | None = None thinking: ThinkingLevel = "medium" + # Run-only headers from --header; excluded from the config schema and serialization. + _cli_headers: dict[str, str] = PrivateAttr(default_factory=dict) connect_timeout_s: float = 10.0 read_timeout_s: float = 300.0 auth_policy: AuthPolicy = "auto" diff --git a/src/lecode/providers/__init__.py b/src/lecode/providers/__init__.py index 6985984..64a5241 100644 --- a/src/lecode/providers/__init__.py +++ b/src/lecode/providers/__init__.py @@ -58,6 +58,7 @@ def resolve_provider( name="custom", base_url=cli_base_url, model=config.llm.model, + headers=dict(config.llm._cli_headers), auth_policy=config.llm.auth_policy, tls_verify=config.llm.tls_verify, ) @@ -84,6 +85,13 @@ def resolve_provider( "or a [custom_providers] entry (any OpenRouter-compatible endpoint)" ) + headers = { + name: value + for name, value in headers.items() + if name.lower() not in config.llm._cli_headers + } + headers.update(config.llm._cli_headers) + return ProviderSpec( name=name, base_url=base_url, diff --git a/src/lecode/providers/openai_compat.py b/src/lecode/providers/openai_compat.py index e85cbdc..abf1382 100644 --- a/src/lecode/providers/openai_compat.py +++ b/src/lecode/providers/openai_compat.py @@ -112,9 +112,10 @@ def __init__( tls_verify: bool = True, default_extra_body: dict[str, Any] | None = None, ) -> None: - headers = {"Content-Type": "application/json"} + headers = httpx.Headers({"Content-Type": "application/json"}) if default_headers: - headers.update(default_headers) + for name, value in default_headers.items(): + headers[name] = value if api_key: headers["Authorization"] = f"Bearer {api_key}" if timeout is None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 56f10a4..ca5f8b8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,7 @@ import re +import pytest from typer.testing import CliRunner from lecode import __version__ @@ -79,6 +80,109 @@ async def fake_load(client): assert closed # built, used, and closed inside the fetch's own loop +@pytest.mark.parametrize( + "mode", + [ + [], + ["-p", "hi"], + ["--loop", "plan.md"], + ["--chain", "topic"], + ["--resume", "existing"], + ["--continue"], + ], +) +def test_run_flags_override_config_in_every_mode(mode, tmp_path, monkeypatch): + from lecode.auth import AuthError + from lecode.session.storage import SessionStore + + monkeypatch.setenv("LECODE_CONFIG_DIR", str(tmp_path / "cfg")) + monkeypatch.setenv("LECODE_SKILLS_DIR", str(tmp_path / "skills")) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("lecode.cli.check_dependencies", lambda: None) + config_path = tmp_path / "cfg" / "config.toml" + config_path.parent.mkdir() + original = 'schema_version = 2\n\n[llm]\nthinking = "low"\n' + config_path.write_text(original) + SessionStore().create("existing", tmp_path) + + async def name_prompt(store): + return "new-session" + + monkeypatch.setattr("lecode.tui.name_prompt.prompt_session_name", name_prompt) + captured = [] + + def build_provider(config, api_key=None): + captured.append(config) + raise AuthError("stopped before network startup") + + monkeypatch.setattr("lecode.cli.build_provider", build_provider) + result = runner.invoke( + app, + [ + *mode, + "--thinking", + "high", + "--header", + "X-Team: earlier", + "--header", + "x-team: runtime-value", + "--header", + "X-Route: https://example.test:8443", + "--header", + "X-Empty:", + ], + ) + assert result.exit_code == EXIT_STARTUP, result.output + assert len(captured) == 1 + config = captured[0] + assert config.llm.thinking == "high" + assert config.llm._cli_headers == { + "x-team": "runtime-value", + "x-route": "https://example.test:8443", + "x-empty": "", + } + assert "runtime-value" not in config.model_dump_json() + assert "runtime-value" not in repr(config) + assert config_path.read_text() == original + assert all( + "runtime-value" not in path.read_text() for path in config_path.parent.rglob("*.jsonl") + ) + + +@pytest.mark.parametrize( + "header", + [ + "sensitive-value", + ": sensitive-value", + "Bad Name: sensitive-value", + "Bad/Name: sensitive-value", + "X-Test: sensitive-value\r\nX-Injected: yes", + "X-Test: sensitive-value\n", + "X-Test: sensitive-value\x00", + "X-Test: sensitive-value\x7f", + "X-Test: sensitive-valueé", + ], +) +def test_invalid_header_fails_before_startup_without_echoing_value(header, monkeypatch): + def unexpected_startup(): + pytest.fail("invalid header reached startup") + + monkeypatch.setattr("lecode.cli.check_dependencies", unexpected_startup) + result = runner.invoke(app, ["--header", header]) + assert result.exit_code == EXIT_STARTUP + plain = re.sub(r"\x1b\[[0-9;]*m", "", result.output) + assert "--header" in plain + assert "sensitive-value" not in result.output + + +def test_invalid_thinking_fails_before_startup(monkeypatch): + monkeypatch.setattr("lecode.cli.check_dependencies", lambda: pytest.fail("reached startup")) + result = runner.invoke(app, ["--thinking", "ultra"]) + assert result.exit_code == EXIT_STARTUP + plain = re.sub(r"\x1b\[[0-9;]*m", "", result.output) + assert "--thinking" in plain + + def _fake_missing(): from lecode.deps import MissingBinary diff --git a/tests/test_headless.py b/tests/test_headless.py index 4cc8ba1..096499c 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -47,6 +47,23 @@ def test_headless_prints_final_text_and_cost(headless): assert float(match.group(1)) == pytest.approx(0.0, abs=1e-9) +@pytest.mark.parametrize("level", [None, "none", "low", "medium", "high"]) +def test_headless_thinking_reaches_model_request(headless, monkeypatch, level): + tmp_path, use_script = headless + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "cfg" / "config.toml" + config_path.parent.mkdir() + original = 'schema_version = 2\n\n[llm]\nthinking = "high"\n' + config_path.write_text(original) + provider = use_script([{"text": "ok"}]) + flags = [] if level is None else ["--thinking", level] + result = runner.invoke(app, ["-p", "hi", *flags]) + assert result.exit_code == EXIT_OK, result.output + expected = "high" if level is None else None if level == "none" else level + assert provider.requests[0]["kwargs"]["reasoning_effort"] == expected + assert config_path.read_text() == original + + def test_headless_reads_prompt_from_stdin(headless): _, use_script = headless provider = use_script([{"text": "from stdin"}]) @@ -140,8 +157,6 @@ def test_headless_does_not_load_interactive_modules(tmp_path, case): for name in ("HERDR_ENV", "HERDR_BIN_PATH", "HERDR_PANE_ID"): os.environ.pop(name, None) config = Config() -config.mcp.enable_exa = False -config.mcp.enable_context7 = False cli.load_config = lambda: SimpleNamespace(config=config) turns = 20 if sys.argv[2] == "tools" else 0 provider = FakeProvider([ diff --git a/tests/test_provider_resolution.py b/tests/test_provider_resolution.py index 681c912..5089410 100644 --- a/tests/test_provider_resolution.py +++ b/tests/test_provider_resolution.py @@ -3,11 +3,13 @@ from __future__ import annotations import pytest +import respx from lecode.auth import ResolvedKey, resolve_api_key from lecode.config.models import Config from lecode.providers import build_client, resolve_provider from lecode.providers.openrouter import OPENROUTER_BASE_URL +from lecode.providers.types import collect def _config(**kwargs) -> Config: @@ -92,3 +94,64 @@ def test_build_client_keyless(): ResolvedKey(key=None, source="none"), ) assert "authorization" not in client._client.headers + + +@pytest.mark.parametrize("provider", ["openrouter", "custom", "adhoc"]) +@pytest.mark.parametrize("with_key", [False, True]) +@respx.mock +async def test_run_headers_reach_chat_and_catalog(provider, with_key): + config = _config( + llm={"provider": "custom" if provider == "custom" else "openrouter"}, + custom_providers={ + "custom": { + "base_url": "https://provider.test/v1", + "headers": {"X-Title": "configured"}, + } + }, + ) + config.llm._cli_headers = { + "x-title": "runtime", + "content-type": "application/custom+json", + "authorization": "Custom runtime", + "x-route": "https://example.test:8443", + "x-empty": "", + } + cli_base_url = "https://provider.test/v1" if provider == "adhoc" else None + spec = resolve_provider(config, cli_base_url=cli_base_url) + catalog = respx.get(f"{spec.base_url}/models").respond(200, json={"data": []}) + chat = respx.post(f"{spec.base_url}/chat/completions").respond( + 200, + content=b"data: [DONE]\n\n", + ) + key = ResolvedKey(key="test-key" if with_key else None, source="cli" if with_key else "none") + async with build_client(spec, key) as client: + await client.list_models() + await collect(client.stream_chat([{"role": "user", "content": "hi"}], model="m")) + for route in (catalog, chat): + headers = route.calls.last.request.headers + assert headers.get_list("x-title") == ["runtime"] + assert headers.get_list("content-type") == ["application/custom+json"] + expected_auth = "Bearer test-key" if with_key else "Custom runtime" + assert headers.get_list("authorization") == [expected_auth] + assert headers["x-route"] == "https://example.test:8443" + assert headers["x-empty"] == "" + if provider == "openrouter": + assert "HTTP-Referer" in headers + + +@pytest.mark.parametrize("policy", ["none", "required"]) +def test_run_authorization_preserves_auth_policy(policy, monkeypatch): + from lecode.auth import AuthError + + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + config = _config(llm={"auth_policy": policy}) + config.llm._cli_headers = {"authorization": "Custom runtime"} + spec = resolve_provider(config) + if policy == "required": + with pytest.raises(AuthError, match="required"): + resolve_api_key(spec.name, config) + else: + key = resolve_api_key(spec.name, config, cli_key="test-key") + assert key.key is None + assert spec.headers["authorization"] == "Custom runtime"