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

1"""The per-client config document `lilbee agent-config` prints and /api/agent-config serves.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Callable 

6from dataclasses import dataclass 

7from enum import StrEnum 

8from typing import Any 

9 

10import yaml 

11 

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 

15 

16 

17class AgentClient(StrEnum): 

18 """An AI client lilbee builds a config document for.""" 

19 

20 CLAUDE = "claude" 

21 HERMES = "hermes" 

22 OPENCODE = "opencode" 

23 

24 

25class ConfigFormat(StrEnum): 

26 """The serialization the client's own config file is written in.""" 

27 

28 JSON = "json" 

29 YAML = "yaml" 

30 

31 

32class AgentSurface(StrEnum): 

33 """A lilbee surface a client's config wires the client up to.""" 

34 

35 MODEL_PROVIDER = "model_provider" 

36 MCP = "mcp" 

37 

38 

39@dataclass(frozen=True) 

40class AgentConfigDocument: 

41 """One client's paste-ready lilbee config, carrying a live base URL and token. 

42 

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 """ 

47 

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 

54 

55 

56@dataclass(frozen=True) 

57class _ClientInputs: 

58 """The live server state every client document is rendered from.""" 

59 

60 base_url: str 

61 api_key: str 

62 model_refs: list[str] 

63 default_ref: str | None 

64 chat_ctx: int | None 

65 

66 

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} 

74 

75 

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] 

79 

80 

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 

88 

89 

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 ) 

99 

100 

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 ) 

116 

117 

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 ) 

132 

133 

134_BUILDERS: dict[AgentClient, Callable[[_ClientInputs], AgentConfigDocument]] = { 

135 AgentClient.CLAUDE: _claude_document, 

136 AgentClient.HERMES: _hermes_document, 

137 AgentClient.OPENCODE: _opencode_document, 

138} 

139 

140 

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. 

151 

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)