Coverage for src/lilbee/app/agent_configs/hermes.py: 100%

23 statements  

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

1"""hermes config.yaml fragment builder: lilbee as a provider plus MCP search tool.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

7from lilbee.app.agent_configs.merge import LILBEE_PROVIDER_KEY 

8from lilbee.app.endpoints import MCP_PATH, OPENAI_PATH 

9from lilbee.catalog import agent_model_id 

10 

11# hermes's custom provider defaults max_tokens to the full window, leaving ~no 

12# room for input. Pin a sane output cap so the system prompt + history fit. 

13_MAX_OUTPUT_TOKENS = 8192 

14# hermes truncates a project context file (AGENTS.md and the like) to this many 

15# chars by default, printing a visible TRUNCATED warning. Raised to the served 

16# window below so large context files load intact. 

17_HERMES_DEFAULT_CONTEXT_FILE_MAX_CHARS = 20000 

18 

19 

20def hermes_config( 

21 *, 

22 base_url: str, 

23 api_key: str, 

24 model_refs: list[str], 

25 default_ref: str | None = None, 

26 chat_ctx: int | None = None, 

27 include_mcp: bool = True, 

28) -> dict[str, Any]: 

29 """Return the hermes config fragment registering lilbee as a provider (and MCP). 

30 

31 ``api_key`` is embedded verbatim: a literal token for the paste path, or the 

32 ``${LILBEE_TOKEN}`` reference for the launcher (hermes expands it from the env 

33 at load, so the on-disk file never holds the literal token).""" 

34 pin = default_ref or (model_refs[0] if model_refs else None) 

35 # hermes shows the model id it is pinned to and has no separate display field, 

36 # so pin the clean agent id (e.g. "Qwen3-235B-A22B") instead of the full GGUF 

37 # path. lilbee's /v1 resolves it back to the ref, so routing is unchanged. 

38 pin_id = agent_model_id(pin) if pin is not None else None 

39 provider: dict[str, Any] = { 

40 "api": f"{base_url}{OPENAI_PATH}", 

41 "api_key": api_key, 

42 "max_tokens": _MAX_OUTPUT_TOKENS, 

43 } 

44 if pin_id is not None: 

45 provider["default_model"] = pin_id 

46 if chat_ctx is not None: 

47 provider["context_length"] = chat_ctx 

48 config: dict[str, Any] = {"providers": {LILBEE_PROVIDER_KEY: provider}} 

49 if chat_ctx is not None: 

50 # Size hermes's context-file budget to the served window so a large project 

51 # context file loads instead of being cut to the 20k default. chat_ctx is 

52 # tokens; used as a char budget it stays within the model window. 

53 config["context_file_max_chars"] = max(_HERMES_DEFAULT_CONTEXT_FILE_MAX_CHARS, chat_ctx) 

54 if include_mcp: 

55 # An `url` (no `transport` key) is hermes's HTTP MCP shape; `headers` 

56 # carries the bearer with ${VAR} env resolution. A `transport` string 

57 # makes hermes reject the entry (it must be a mapping) -> 0 connected. 

58 config["mcp_servers"] = { 

59 LILBEE_PROVIDER_KEY: { 

60 "url": f"{base_url}{MCP_PATH}", 

61 "headers": {"Authorization": f"Bearer {api_key}"}, 

62 } 

63 } 

64 if pin_id is not None: 

65 # Dict form binds the active model to the lilbee provider explicitly, so the 

66 # pin is unambiguous even when another provider could serve the same ref. 

67 # `model.max_tokens` is hermes's documented winning output cap: without it 

68 # hermes requests the full window as output, so input+output overflows and 

69 # it misreads the error as "prompt too long" and compresses to nothing. 

70 config["model"] = { 

71 "default": pin_id, 

72 "provider": LILBEE_PROVIDER_KEY, 

73 "max_tokens": _MAX_OUTPUT_TOKENS, 

74 } 

75 return config