lightspeed-stack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
models/responses.py ADDED
@@ -0,0 +1,244 @@
1
+ """Models for service responses."""
2
+
3
+ from typing import Any, Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class ModelsResponse(BaseModel):
9
+ """Model representing a response to models request."""
10
+
11
+ models: list[dict[str, Any]]
12
+
13
+
14
+ # TODO(lucasagomes): a lot of fields to add to QueryResponse. For now
15
+ # we are keeping it simple. The missing fields are:
16
+ # - referenced_documents: The optional URLs and titles for the documents used
17
+ # to generate the response.
18
+ # - truncated: Set to True if conversation history was truncated to be within context window.
19
+ # - input_tokens: Number of tokens sent to LLM
20
+ # - output_tokens: Number of tokens received from LLM
21
+ # - available_quotas: Quota available as measured by all configured quota limiters
22
+ # - tool_calls: List of tool requests.
23
+ # - tool_results: List of tool results.
24
+ # See LLMResponse in ols-service for more details.
25
+ class QueryResponse(BaseModel):
26
+ """Model representing LLM response to a query.
27
+
28
+ Attributes:
29
+ conversation_id: The optional conversation ID (UUID).
30
+ response: The response.
31
+ """
32
+
33
+ conversation_id: Optional[str] = None
34
+ response: str
35
+
36
+ # provides examples for /docs endpoint
37
+ model_config = {
38
+ "json_schema_extra": {
39
+ "examples": [
40
+ {
41
+ "conversation_id": "123e4567-e89b-12d3-a456-426614174000",
42
+ "response": "Operator Lifecycle Manager (OLM) helps users install...",
43
+ }
44
+ ]
45
+ }
46
+ }
47
+
48
+
49
+ class InfoResponse(BaseModel):
50
+ """Model representing a response to a info request.
51
+
52
+ Attributes:
53
+ name: Service name.
54
+ version: Service version.
55
+
56
+ Example:
57
+ ```python
58
+ info_response = InfoResponse(
59
+ name="Lightspeed Stack",
60
+ version="1.0.0",
61
+ )
62
+ ```
63
+ """
64
+
65
+ name: str
66
+ version: str
67
+
68
+ # provides examples for /docs endpoint
69
+ model_config = {
70
+ "json_schema_extra": {
71
+ "examples": [
72
+ {
73
+ "name": "Lightspeed Stack",
74
+ "version": "1.0.0",
75
+ }
76
+ ]
77
+ }
78
+ }
79
+
80
+
81
+ class ProviderHealthStatus(BaseModel):
82
+ """Model representing the health status of a provider.
83
+
84
+ Attributes:
85
+ provider_id: The ID of the provider.
86
+ status: The health status ('ok', 'unhealthy', 'not_implemented').
87
+ message: Optional message about the health status.
88
+ """
89
+
90
+ provider_id: str
91
+ status: str
92
+ message: Optional[str] = None
93
+
94
+
95
+ class ReadinessResponse(BaseModel):
96
+ """Model representing response to a readiness request.
97
+
98
+ Attributes:
99
+ ready: If service is ready.
100
+ reason: The reason for the readiness.
101
+ providers: List of unhealthy providers in case of readiness failure.
102
+
103
+ Example:
104
+ ```python
105
+ readiness_response = ReadinessResponse(
106
+ ready=False,
107
+ reason="Service is not ready",
108
+ providers=[
109
+ ProviderHealthStatus(
110
+ provider_id="ollama",
111
+ status="Error",
112
+ message="Server is unavailable"
113
+ )
114
+ ]
115
+ )
116
+ ```
117
+ """
118
+
119
+ ready: bool
120
+ reason: str
121
+ providers: list[ProviderHealthStatus]
122
+
123
+ # provides examples for /docs endpoint
124
+ model_config = {
125
+ "json_schema_extra": {
126
+ "examples": [
127
+ {
128
+ "ready": True,
129
+ "reason": "Service is ready",
130
+ "providers": [],
131
+ }
132
+ ]
133
+ }
134
+ }
135
+
136
+
137
+ class LivenessResponse(BaseModel):
138
+ """Model representing a response to a liveness request.
139
+
140
+ Attributes:
141
+ alive: If app is alive.
142
+
143
+ Example:
144
+ ```python
145
+ liveness_response = LivenessResponse(alive=True)
146
+ ```
147
+ """
148
+
149
+ alive: bool
150
+
151
+ # provides examples for /docs endpoint
152
+ model_config = {
153
+ "json_schema_extra": {
154
+ "examples": [
155
+ {
156
+ "alive": True,
157
+ }
158
+ ]
159
+ }
160
+ }
161
+
162
+
163
+ class NotAvailableResponse(BaseModel):
164
+ """Model representing error response for readiness endpoint."""
165
+
166
+ detail: dict[str, str]
167
+
168
+ # provides examples for /docs endpoint
169
+ model_config = {
170
+ "json_schema_extra": {
171
+ "examples": [
172
+ {
173
+ "detail": {
174
+ "response": "Service is not ready",
175
+ "cause": "Index is not ready",
176
+ }
177
+ },
178
+ {
179
+ "detail": {
180
+ "response": "Service is not ready",
181
+ "cause": "LLM is not ready",
182
+ },
183
+ },
184
+ ]
185
+ }
186
+ }
187
+
188
+
189
+ class FeedbackResponse(BaseModel):
190
+ """Model representing a response to a feedback request.
191
+
192
+ Attributes:
193
+ response: The response of the feedback request.
194
+
195
+ Example:
196
+ ```python
197
+ feedback_response = FeedbackResponse(response="feedback received")
198
+ ```
199
+ """
200
+
201
+ response: str
202
+
203
+ # provides examples for /docs endpoint
204
+ model_config = {
205
+ "json_schema_extra": {
206
+ "examples": [
207
+ {
208
+ "response": "feedback received",
209
+ }
210
+ ]
211
+ }
212
+ }
213
+
214
+
215
+ class StatusResponse(BaseModel):
216
+ """Model representing a response to a status request.
217
+
218
+ Attributes:
219
+ functionality: The functionality of the service.
220
+ status: The status of the service.
221
+
222
+ Example:
223
+ ```python
224
+ status_response = StatusResponse(
225
+ functionality="feedback",
226
+ status={"enabled": True},
227
+ )
228
+ ```
229
+ """
230
+
231
+ functionality: str
232
+ status: dict
233
+
234
+ # provides examples for /docs endpoint
235
+ model_config = {
236
+ "json_schema_extra": {
237
+ "examples": [
238
+ {
239
+ "functionality": "feedback",
240
+ "status": {"enabled": True},
241
+ }
242
+ ]
243
+ }
244
+ }
runners/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Runners."""
runners/uvicorn.py ADDED
@@ -0,0 +1,31 @@
1
+ """Uvicorn runner."""
2
+
3
+ import logging
4
+
5
+ import uvicorn
6
+
7
+ from models.config import ServiceConfiguration
8
+
9
+ logger: logging.Logger = logging.getLogger(__name__)
10
+
11
+
12
+ def start_uvicorn(configuration: ServiceConfiguration) -> None:
13
+ """Start Uvicorn-based REST API service."""
14
+ logger.info("Starting Uvicorn")
15
+
16
+ log_level = logging.INFO
17
+
18
+ # please note:
19
+ # TLS fields can be None, which means we will pass those values as None to uvicorn.run
20
+ uvicorn.run(
21
+ "app.main:app",
22
+ host=configuration.host,
23
+ port=configuration.port,
24
+ workers=configuration.workers,
25
+ log_level=log_level,
26
+ ssl_keyfile=configuration.tls_config.tls_key_path,
27
+ ssl_certfile=configuration.tls_config.tls_certificate_path,
28
+ ssl_keyfile_password=str(configuration.tls_config.tls_key_password or ""),
29
+ use_colors=True,
30
+ access_log=True,
31
+ )
@@ -0,0 +1,2 @@
1
+ # Automatically created by ruff.
2
+ *
@@ -0,0 +1 @@
1
+ Signature: 8a477f597d28d172789f06886806bc55
utils/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Utils."""
utils/checks.py ADDED
@@ -0,0 +1,27 @@
1
+ """Checks that are performed to configuration options."""
2
+
3
+ import os
4
+ from typing import Optional
5
+
6
+ from pydantic import FilePath
7
+
8
+
9
+ class InvalidConfigurationError(Exception):
10
+ """Lightspeed configuration is invalid."""
11
+
12
+
13
+ def get_attribute_from_file(data: dict, file_name_key: str) -> Optional[str]:
14
+ """Retrieve value of an attribute from a file."""
15
+ file_path = data.get(file_name_key)
16
+ if file_path is not None:
17
+ with open(file_path, encoding="utf-8") as f:
18
+ return f.read().rstrip()
19
+ return None
20
+
21
+
22
+ def file_check(path: FilePath, desc: str) -> None:
23
+ """Check that path is a readable regular file."""
24
+ if not os.path.isfile(path):
25
+ raise InvalidConfigurationError(f"{desc} '{path}' is not a file")
26
+ if not os.access(path, os.R_OK):
27
+ raise InvalidConfigurationError(f"{desc} '{path}' is not readable")
utils/common.py ADDED
@@ -0,0 +1,111 @@
1
+ """Common utilities for the project."""
2
+
3
+ from typing import Any, List, cast
4
+ from logging import Logger
5
+
6
+ from llama_stack_client import LlamaStackClient
7
+
8
+ from llama_stack.distribution.library_client import (
9
+ LlamaStackAsLibraryClient,
10
+ AsyncLlamaStackAsLibraryClient,
11
+ )
12
+
13
+ from client import get_llama_stack_client
14
+ from models.config import Configuration, ModelContextProtocolServer
15
+
16
+
17
+ # TODO(lucasagomes): implement this function to retrieve user ID from auth
18
+ def retrieve_user_id(auth: Any) -> str: # pylint: disable=unused-argument
19
+ """Retrieve the user ID from the authentication handler.
20
+
21
+ Args:
22
+ auth: The Authentication handler (FastAPI Depends) that will
23
+ handle authentication Logic.
24
+
25
+ Returns:
26
+ str: The user ID.
27
+ """
28
+ return "user_id_placeholder"
29
+
30
+
31
+ async def register_mcp_servers_async(
32
+ logger: Logger, configuration: Configuration
33
+ ) -> None:
34
+ """Register Model Context Protocol (MCP) servers with the LlamaStack client (async)."""
35
+ # Skip MCP registration if no MCP servers are configured
36
+ if not configuration.mcp_servers:
37
+ logger.debug("No MCP servers configured, skipping registration")
38
+ return
39
+
40
+ if configuration.llama_stack.use_as_library_client:
41
+ # Library client - use async interface
42
+ # config.py validation ensures library_client_config_path is not None
43
+ # when use_as_library_client is True
44
+ config_path = cast(str, configuration.llama_stack.library_client_config_path)
45
+ client = LlamaStackAsLibraryClient(config_path)
46
+ await client.async_client.initialize()
47
+
48
+ await _register_mcp_toolgroups_async(
49
+ client.async_client, configuration.mcp_servers, logger
50
+ )
51
+ else:
52
+ # Service client - use sync interface
53
+ client = get_llama_stack_client(configuration.llama_stack)
54
+
55
+ _register_mcp_toolgroups_sync(client, configuration.mcp_servers, logger)
56
+
57
+
58
+ async def _register_mcp_toolgroups_async(
59
+ client: AsyncLlamaStackAsLibraryClient,
60
+ mcp_servers: List[ModelContextProtocolServer],
61
+ logger: Logger,
62
+ ) -> None:
63
+ """Async logic for registering MCP toolgroups."""
64
+ # Get registered tools
65
+ registered_toolgroups = await client.toolgroups.list()
66
+ registered_toolgroups_ids = [
67
+ tool_group.provider_resource_id for tool_group in registered_toolgroups
68
+ ]
69
+ logger.debug("Registered toolgroups: %s", registered_toolgroups_ids)
70
+
71
+ # Register toolgroups for MCP servers if not already registered
72
+ for mcp in mcp_servers:
73
+ if mcp.name not in registered_toolgroups_ids:
74
+ logger.debug("Registering MCP server: %s, %s", mcp.name, mcp.url)
75
+
76
+ registration_params = {
77
+ "toolgroup_id": mcp.name,
78
+ "provider_id": mcp.provider_id,
79
+ "mcp_endpoint": {"uri": mcp.url},
80
+ }
81
+
82
+ await client.toolgroups.register(**registration_params)
83
+ logger.debug("MCP server %s registered successfully", mcp.name)
84
+
85
+
86
+ def _register_mcp_toolgroups_sync(
87
+ client: LlamaStackClient,
88
+ mcp_servers: List[ModelContextProtocolServer],
89
+ logger: Logger,
90
+ ) -> None:
91
+ """Sync logic for registering MCP toolgroups."""
92
+ # Get registered tool groups
93
+ registered_toolgroups = client.toolgroups.list()
94
+ registered_toolgroups_ids = [
95
+ tool_group.provider_resource_id for tool_group in registered_toolgroups
96
+ ]
97
+ logger.debug("Registered toolgroups: %s", registered_toolgroups_ids)
98
+
99
+ # Register toolgroups for MCP servers if not already registered
100
+ for mcp in mcp_servers:
101
+ if mcp.name not in registered_toolgroups_ids:
102
+ logger.debug("Registering MCP server: %s, %s", mcp.name, mcp.url)
103
+
104
+ registration_params = {
105
+ "toolgroup_id": mcp.name,
106
+ "provider_id": mcp.provider_id,
107
+ "mcp_endpoint": {"uri": mcp.url},
108
+ }
109
+
110
+ client.toolgroups.register(**registration_params)
111
+ logger.debug("MCP server %s registered successfully", mcp.name)
utils/endpoints.py ADDED
@@ -0,0 +1,34 @@
1
+ """Utility functions for endpoint handlers."""
2
+
3
+ from fastapi import HTTPException, status
4
+
5
+ import constants
6
+ from models.requests import QueryRequest
7
+ from configuration import AppConfig
8
+
9
+
10
+ def check_configuration_loaded(configuration: AppConfig) -> None:
11
+ """Check that configuration is loaded and raise exception when it is not."""
12
+ if configuration is None:
13
+ raise HTTPException(
14
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
15
+ detail={"response": "Configuration is not loaded"},
16
+ )
17
+
18
+
19
+ def get_system_prompt(query_request: QueryRequest, configuration: AppConfig) -> str:
20
+ """Get the system prompt: the provided one, configured one, or default one."""
21
+ # system prompt defined in query request has precendence
22
+ if query_request.system_prompt:
23
+ return query_request.system_prompt
24
+
25
+ # customized system prompt should be used when query request
26
+ # does not contain one
27
+ if (
28
+ configuration.customization is not None
29
+ and configuration.customization.system_prompt is not None
30
+ ):
31
+ return configuration.customization.system_prompt
32
+
33
+ # default system prompt has the lowest precedence
34
+ return constants.DEFAULT_SYSTEM_PROMPT
utils/mcp_headers.py ADDED
@@ -0,0 +1,48 @@
1
+ """MCP headers handling."""
2
+
3
+ import json
4
+ import logging
5
+ from fastapi import Request
6
+
7
+ logger = logging.getLogger("app.endpoints.dependencies")
8
+
9
+
10
+ async def mcp_headers_dependency(_request: Request) -> dict[str, dict[str, str]]:
11
+ """Get the mcp headers dependency to passed to mcp servers.
12
+
13
+ mcp headers is a json dictionary or mcp url paths and their respective headers
14
+
15
+ Args:
16
+ request (Request): The FastAPI request object.
17
+
18
+ Returns:
19
+ The mcp headers dictionary, or empty dictionary if not found or on json decoding error
20
+ """
21
+ return extract_mcp_headers(_request)
22
+
23
+
24
+ def extract_mcp_headers(request: Request) -> dict[str, dict[str, str]]:
25
+ """Extract mcp headers from MCP-HEADERS header.
26
+
27
+ Args:
28
+ request: The FastAPI request object
29
+
30
+ Returns:
31
+ The mcp headers dictionary, or empty dictionary if not found or on json decoding error
32
+ """
33
+ mcp_headers_string = request.headers.get("MCP-HEADERS", "")
34
+ mcp_headers = {}
35
+ if mcp_headers_string:
36
+ try:
37
+ mcp_headers = json.loads(mcp_headers_string)
38
+ except json.decoder.JSONDecodeError as e:
39
+ logger.error("MCP headers decode error: %s", e)
40
+
41
+ if not isinstance(mcp_headers, dict):
42
+ logger.error(
43
+ "MCP headers wrong type supplied (mcp headers must be a dictionary), "
44
+ "but type %s was supplied",
45
+ type(mcp_headers),
46
+ )
47
+ mcp_headers = {}
48
+ return mcp_headers
utils/suid.py ADDED
@@ -0,0 +1,28 @@
1
+ """Session ID utility functions."""
2
+
3
+ import uuid
4
+
5
+
6
+ def get_suid() -> str:
7
+ """Generate a unique session ID (SUID) using UUID4.
8
+
9
+ Returns:
10
+ A unique session ID.
11
+ """
12
+ return str(uuid.uuid4())
13
+
14
+
15
+ def check_suid(suid: str) -> bool:
16
+ """Check if given string is a proper session ID.
17
+
18
+ Args:
19
+ suid: The string to check.
20
+
21
+ Returns True if the string is a valid UUID, False otherwise.
22
+ """
23
+ try:
24
+ # accepts strings and bytes only
25
+ uuid.UUID(suid)
26
+ return True
27
+ except (ValueError, TypeError):
28
+ return False