Coverage for src/lilbee/cli/commands/agent_config.py: 100%

63 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""`lilbee agent-config <client>`, print a paste-ready config block.""" 

2 

3from __future__ import annotations 

4 

5import json 

6from pathlib import Path 

7 

8import typer 

9 

10from lilbee.app.agent_configs.document import ( 

11 AgentClient, 

12 AgentConfigDocument, 

13 ConfigFormat, 

14 build_agent_config, 

15 client_serves_models, 

16) 

17from lilbee.app.agent_configs.litellm import litellm_config 

18from lilbee.app.agent_configs.window import AGENT_CHAT_CTX_FLOOR 

19from lilbee.app.models import installed_chat_model_refs, with_configured_chat 

20from lilbee.cli.app import apply_overrides, data_dir_option, global_option 

21from lilbee.cli.launchers.hermes_mcp import MCP_EXTRA_HINT 

22from lilbee.cli.launchers.server import ( 

23 LOOPBACK, 

24 client_chat_ctx, 

25 running_server_session, 

26) 

27from lilbee.core.config import cfg 

28 

29agent_config_app = typer.Typer(help="Print a paste-ready config block for an AI client.") 

30 

31_SERVE_HINT = "Start `lilbee serve --port 8080` first, then re-run this command." 

32 

33_STDIO_HINT = ( 

34 "To have the client start lilbee itself instead of using the running " 

35 "server, register this block instead:" 

36) 

37 

38 

39def _apply_overrides(*, data_dir: Path | None, use_global: bool) -> None: 

40 """Apply CLI overrides for a command that only writes config, with the fleet left cold. 

41 

42 The command lists installed models and reads the served window from the 

43 running server. It runs no inference, so the services it builds must not 

44 start the engine warm-up. 

45 """ 

46 apply_overrides(data_dir=data_dir, use_global=use_global) 

47 cfg.worker_pool_eager_start = False 

48 

49 

50def _session_or_exit() -> tuple[str, int]: 

51 """The running server's ``(token, port)``; exits non-zero when none is up.""" 

52 session = running_server_session() 

53 if session is None: 

54 typer.secho(_SERVE_HINT, err=True, fg=typer.colors.RED) 

55 raise typer.Exit(1) 

56 return session 

57 

58 

59def _served_chat_models() -> list[str]: 

60 """Chat refs to advertise: the installed ones plus the configured model the server serves.""" 

61 return with_configured_chat(installed_chat_model_refs(), cfg.chat_model) 

62 

63 

64def _warn_on_small_agent_window(chat_ctx: int | None) -> None: 

65 """Say when the served window cannot hold an agent's first turn, with the remedy. 

66 

67 ``lilbee launch`` sizes the window itself; this paste path runs against a 

68 server the user started, whose window is fixed at boot, so the printed 

69 config would otherwise carry a window the first message overflows. An 

70 unknown window (no chat engine yet) stays silent. 

71 """ 

72 if chat_ctx is None or chat_ctx >= AGENT_CHAT_CTX_FLOOR: 

73 return 

74 typer.secho( 

75 f"Warning: the server serves a {chat_ctx:,}-token context window, but an " 

76 f"agent's first turn (system prompt plus tool schemas) needs about " 

77 f"{AGENT_CHAT_CTX_FLOOR:,}, so the first message can overflow. To raise it: " 

78 f"stop the server, run 'lilbee engine stop', set chat_n_ctx_target to " 

79 f"{AGENT_CHAT_CTX_FLOOR} (or start with " 

80 f"LILBEE_CHAT_N_CTX_TARGET={AGENT_CHAT_CTX_FLOOR}), then run 'lilbee serve' " 

81 "again. If the window stays small, the model's trained context or device " 

82 "memory is the limit; use a longer-context model or a smaller quantization.", 

83 err=True, 

84 fg=typer.colors.YELLOW, 

85 ) 

86 

87 

88def _build(client: AgentClient) -> AgentConfigDocument: 

89 """Build *client*'s document from the running server's port and token.""" 

90 token, port = _session_or_exit() 

91 serves_models = client_serves_models(client) 

92 chat_ctx = client_chat_ctx(port) if serves_models else None 

93 _warn_on_small_agent_window(chat_ctx) 

94 return build_agent_config( 

95 client, 

96 base_url=f"http://{LOOPBACK}:{port}", 

97 api_key=token, 

98 model_refs=_served_chat_models() if serves_models else None, 

99 # Match the launchers: pin the served model as default and pass the 

100 # context window, so the pasted config opens on a lilbee model and trims 

101 # history to the right limit. 

102 default_ref=str(cfg.chat_model) if serves_models else None, 

103 chat_ctx=chat_ctx, 

104 ) 

105 

106 

107def _emit(document: AgentConfigDocument) -> None: 

108 """Print the block on stdout, with any client-specific note on stderr.""" 

109 if document.format is ConfigFormat.YAML: 

110 # Use typer.echo (no Rich word-wrap) so YAML stays parseable when 

111 # piped to a file or wrapped in narrow test terminals. 

112 typer.echo(document.content, nl=False) 

113 else: 

114 typer.echo(json.dumps(document.config, indent=2)) 

115 if document.stdio_config is not None: 

116 typer.secho(_STDIO_HINT, err=True, fg=typer.colors.YELLOW) 

117 typer.echo(json.dumps(document.stdio_config, indent=2), err=True) 

118 if document.client is AgentClient.HERMES: 

119 # Parity with `lilbee launch hermes`: the MCP block only works once 

120 # hermes has its `mcp` extra. The paste path can't install it, so 

121 # surface the same hint (to stderr, keeping the YAML pipe-clean). 

122 typer.secho(MCP_EXTRA_HINT, err=True, fg=typer.colors.YELLOW) 

123 

124 

125@agent_config_app.command("claude") 

126def _claude_cmd( 

127 data_dir: Path | None = data_dir_option, 

128 use_global: bool = global_option, 

129) -> None: 

130 """Print a Claude Code mcpServers block registering lilbee's MCP tools.""" 

131 _apply_overrides(data_dir=data_dir, use_global=use_global) 

132 _emit(_build(AgentClient.CLAUDE)) 

133 

134 

135@agent_config_app.command("opencode") 

136def _opencode_cmd( 

137 data_dir: Path | None = data_dir_option, 

138 use_global: bool = global_option, 

139) -> None: 

140 """Print an opencode.json block (OpenAI-compatible provider + MCP server).""" 

141 _apply_overrides(data_dir=data_dir, use_global=use_global) 

142 _emit(_build(AgentClient.OPENCODE)) 

143 

144 

145@agent_config_app.command("hermes") 

146def _hermes_cmd( 

147 data_dir: Path | None = data_dir_option, 

148 use_global: bool = global_option, 

149) -> None: 

150 """Print a hermes config.yaml block (OpenAI-compatible provider + MCP server).""" 

151 _apply_overrides(data_dir=data_dir, use_global=use_global) 

152 _emit(_build(AgentClient.HERMES)) 

153 

154 

155@agent_config_app.command("litellm") 

156def _litellm_cmd( 

157 data_dir: Path | None = data_dir_option, 

158 use_global: bool = global_option, 

159) -> None: 

160 """Print a LiteLLM `config.yaml` snippet routing model names to lilbee.""" 

161 _apply_overrides(data_dir=data_dir, use_global=use_global) 

162 token, port = _session_or_exit() 

163 snippet = litellm_config( 

164 base_url=f"http://{LOOPBACK}:{port}", 

165 api_key=token, 

166 model_refs=_served_chat_models(), 

167 ) 

168 typer.echo(snippet, nl=False)