Coverage for src/lilbee/app/agent_configs/document.py: 100%
54 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""The per-client config document `lilbee agent-config` prints and /api/agent-config serves."""
3from __future__ import annotations
5from collections.abc import Callable
6from dataclasses import dataclass
7from enum import StrEnum
8from typing import Any
10import yaml
12from lilbee.app.agent_configs.claude import claude_http_config, claude_stdio_config
13from lilbee.app.agent_configs.hermes import hermes_config
14from lilbee.app.agent_configs.opencode import opencode_config
17class AgentClient(StrEnum):
18 """An AI client lilbee builds a config document for."""
20 CLAUDE = "claude"
21 HERMES = "hermes"
22 OPENCODE = "opencode"
25class ConfigFormat(StrEnum):
26 """The serialization the client's own config file is written in."""
28 JSON = "json"
29 YAML = "yaml"
32class AgentSurface(StrEnum):
33 """A lilbee surface a client's config wires the client up to."""
35 MODEL_PROVIDER = "model_provider"
36 MCP = "mcp"
39@dataclass(frozen=True)
40class AgentConfigDocument:
41 """One client's paste-ready lilbee config, carrying a live base URL and token.
43 ``config`` holds the block for a JSON client and ``content`` the rendered
44 text for a YAML one; exactly one of the two is set. ``stdio_config`` is an
45 alternative block for a client that can also run lilbee as a subprocess.
46 """
48 client: AgentClient
49 format: ConfigFormat
50 surfaces: tuple[AgentSurface, ...]
51 config: dict[str, Any] | None = None
52 content: str | None = None
53 stdio_config: dict[str, Any] | None = None
56@dataclass(frozen=True)
57class _ClientInputs:
58 """The live server state every client document is rendered from."""
60 base_url: str
61 api_key: str
62 model_refs: list[str]
63 default_ref: str | None
64 chat_ctx: int | None
67CLIENT_SURFACES: dict[AgentClient, tuple[AgentSurface, ...]] = {
68 # Claude Code takes lilbee's MCP tools only. It brings its own model, so
69 # there is no provider block to write.
70 AgentClient.CLAUDE: (AgentSurface.MCP,),
71 AgentClient.HERMES: (AgentSurface.MODEL_PROVIDER, AgentSurface.MCP),
72 AgentClient.OPENCODE: (AgentSurface.MODEL_PROVIDER, AgentSurface.MCP),
73}
76def client_serves_models(client: AgentClient) -> bool:
77 """True when *client*'s document registers lilbee as its model provider."""
78 return AgentSurface.MODEL_PROVIDER in CLIENT_SURFACES[client]
81def parse_agent_client(value: str) -> AgentClient:
82 """Return the client named *value*, or raise ValueError naming the valid ones."""
83 try:
84 return AgentClient(value)
85 except ValueError as exc:
86 valid = ", ".join(client.value for client in AgentClient)
87 raise ValueError(f"lilbee has no config for '{value}'. Ask for one of: {valid}.") from exc
90def _claude_document(inputs: _ClientInputs) -> AgentConfigDocument:
91 """Claude Code's MCP registration, over http with the stdio form alongside."""
92 return AgentConfigDocument(
93 client=AgentClient.CLAUDE,
94 format=ConfigFormat.JSON,
95 surfaces=CLIENT_SURFACES[AgentClient.CLAUDE],
96 config=claude_http_config(base_url=inputs.base_url, api_key=inputs.api_key),
97 stdio_config=claude_stdio_config(),
98 )
101def _hermes_document(inputs: _ClientInputs) -> AgentConfigDocument:
102 """hermes's config.yaml fragment, rendered to the text the user pastes."""
103 fragment = hermes_config(
104 base_url=inputs.base_url,
105 api_key=inputs.api_key,
106 model_refs=inputs.model_refs,
107 default_ref=inputs.default_ref,
108 chat_ctx=inputs.chat_ctx,
109 )
110 return AgentConfigDocument(
111 client=AgentClient.HERMES,
112 format=ConfigFormat.YAML,
113 surfaces=CLIENT_SURFACES[AgentClient.HERMES],
114 content=yaml.safe_dump(fragment, sort_keys=False),
115 )
118def _opencode_document(inputs: _ClientInputs) -> AgentConfigDocument:
119 """opencode.json's provider plus MCP block."""
120 return AgentConfigDocument(
121 client=AgentClient.OPENCODE,
122 format=ConfigFormat.JSON,
123 surfaces=CLIENT_SURFACES[AgentClient.OPENCODE],
124 config=opencode_config(
125 base_url=inputs.base_url,
126 api_key=inputs.api_key,
127 model_refs=inputs.model_refs,
128 default_ref=inputs.default_ref,
129 chat_ctx=inputs.chat_ctx,
130 ),
131 )
134_BUILDERS: dict[AgentClient, Callable[[_ClientInputs], AgentConfigDocument]] = {
135 AgentClient.CLAUDE: _claude_document,
136 AgentClient.HERMES: _hermes_document,
137 AgentClient.OPENCODE: _opencode_document,
138}
141def build_agent_config(
142 client: AgentClient,
143 *,
144 base_url: str,
145 api_key: str,
146 model_refs: list[str] | None = None,
147 default_ref: str | None = None,
148 chat_ctx: int | None = None,
149) -> AgentConfigDocument:
150 """Build *client*'s config document against a live server URL and token.
152 The model arguments describe what lilbee serves and are ignored by a client
153 that only takes the MCP tools (see :func:`client_serves_models`).
154 """
155 inputs = _ClientInputs(
156 base_url=base_url,
157 api_key=api_key,
158 model_refs=model_refs or [],
159 default_ref=default_ref,
160 chat_ctx=chat_ctx,
161 )
162 return _BUILDERS[client](inputs)