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
18 changes: 17 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
59 changes: 58 additions & 1 deletion src/lecode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import asyncio
import re
import sys
from collections.abc import Callable, Coroutine
from pathlib import Path
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/lecode/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions src/lecode/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/lecode/providers/openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
104 changes: 104 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import re

import pytest
from typer.testing import CliRunner

from lecode import __version__
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading