Coverage for src / lilbee / server / routes / models.py: 100%

67 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-05-15 20:55 +0000

1"""Model management route handlers: catalog, installed, pull, show, delete, set.""" 

2 

3from __future__ import annotations 

4 

5from litestar import delete, get, post, put 

6from litestar.exceptions import HTTPException 

7from litestar.params import Parameter 

8from litestar.response import Stream 

9from pydantic import BaseModel 

10 

11from lilbee.core.config.validators import TaskMismatchError 

12from lilbee.server import handlers 

13from lilbee.server.auth import read_only 

14from lilbee.server.handlers import ModelsResponse, format_task_mismatch 

15from lilbee.server.models import ( 

16 ExternalModelsResponse, 

17 ModelsCatalogResponse, 

18 ModelsDeleteResponse, 

19 ModelsInstalledResponse, 

20 ModelsShowResponse, 

21 SetModelRequest, 

22 SetModelResponse, 

23) 

24 

25 

26def _task_mismatch_detail(exc: ValueError) -> str: 

27 """Format a 422 detail string, expanding TaskMismatchError into HTTP guidance.""" 

28 if isinstance(exc, TaskMismatchError): 

29 return format_task_mismatch(exc.ref, exc.entry_task, exc.expected_task) 

30 return str(exc) 

31 

32 

33class PullRequest(BaseModel): 

34 """Request body for /api/models/pull.""" 

35 

36 model: str 

37 source: str = "native" 

38 

39 

40@get("/api/models") 

41@read_only 

42async def models_list_route() -> ModelsResponse: 

43 """Available chat, embedding, vision, and reranker models.""" 

44 return await handlers.list_models() 

45 

46 

47@get("/api/models/external") 

48@read_only 

49async def models_external_route() -> ExternalModelsResponse: 

50 """Discover models available from the configured external provider.""" 

51 return await handlers.list_external_models() 

52 

53 

54@put("/api/models/chat") 

55async def models_set_chat_route(data: SetModelRequest) -> SetModelResponse: 

56 """Switch the active chat model used for RAG answers.""" 

57 try: 

58 return await handlers.set_chat_model(model=data.model) 

59 except ValueError as exc: 

60 raise HTTPException(status_code=422, detail=_task_mismatch_detail(exc)) from exc 

61 

62 

63@put("/api/models/embedding") 

64async def models_set_embedding_route(data: SetModelRequest) -> SetModelResponse: 

65 """Switch the active embedding model.""" 

66 try: 

67 return await handlers.set_embedding_model(model=data.model) 

68 except ValueError as exc: 

69 raise HTTPException(status_code=422, detail=_task_mismatch_detail(exc)) from exc 

70 

71 

72@put("/api/models/vision") 

73async def models_set_vision_route(data: SetModelRequest) -> SetModelResponse: 

74 """Switch the active vision model for scanned PDF OCR. Empty disables OCR.""" 

75 try: 

76 return await handlers.set_vision_model(model=data.model) 

77 except ValueError as exc: 

78 raise HTTPException(status_code=422, detail=_task_mismatch_detail(exc)) from exc 

79 

80 

81@put("/api/models/reranker") 

82async def models_set_reranker_route(data: SetModelRequest) -> SetModelResponse: 

83 """Switch the active reranker model. Empty disables reranking.""" 

84 try: 

85 return await handlers.set_reranker_model(model=data.model) 

86 except ValueError as exc: 

87 raise HTTPException(status_code=422, detail=_task_mismatch_detail(exc)) from exc 

88 

89 

90@get("/api/models/catalog") 

91@read_only 

92async def models_catalog_route( 

93 task: str | None = Parameter(query="task", default=None), 

94 search: str = Parameter(query="search", default=""), 

95 size: str | None = Parameter(query="size", default=None), 

96 featured: bool | None = Parameter(query="featured", default=None), 

97 sort: str = Parameter(query="sort", default="featured"), 

98 limit: int = Parameter(query="limit", default=20, le=1000), 

99 offset: int = Parameter(query="offset", default=0, ge=0), 

100) -> ModelsCatalogResponse: 

101 """Browse the model catalog with optional filters.""" 

102 return await handlers.models_catalog( 

103 task=task, 

104 search=search, 

105 size=size, 

106 featured=featured, 

107 sort=sort, 

108 limit=limit, 

109 offset=offset, 

110 ) 

111 

112 

113@get("/api/models/installed") 

114@read_only 

115async def models_installed_route() -> ModelsInstalledResponse: 

116 """List installed models with their source (native or remote).""" 

117 return await handlers.models_installed() 

118 

119 

120@post("/api/models/pull") 

121async def models_pull_route(data: PullRequest) -> Stream: 

122 """Pull a model with streaming SSE progress events.""" 

123 return Stream( 

124 handlers.models_pull(data.model, source=data.source), 

125 media_type="text/event-stream", 

126 ) 

127 

128 

129@post("/api/models/show") 

130async def models_show_route(data: SetModelRequest) -> ModelsShowResponse: 

131 """Get model metadata and parameter defaults.""" 

132 return await handlers.models_show(model=data.model) 

133 

134 

135@delete("/api/models/{model:str}", status_code=200) 

136async def models_delete_route(model: str, source: str = "native") -> ModelsDeleteResponse: 

137 """Delete a model from the specified source.""" 

138 return await handlers.models_delete(model, source=source)