ivcap-lambda 0.7.22__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.
- ivcap_lambda/__init__.py +17 -0
- ivcap_lambda/builder.py +260 -0
- ivcap_lambda/decorators.py +60 -0
- ivcap_lambda/executor.py +292 -0
- ivcap_lambda/logger.py +37 -0
- ivcap_lambda/logging.json +77 -0
- ivcap_lambda/mcp.py +261 -0
- ivcap_lambda/py.typed +1 -0
- ivcap_lambda/secret.py +44 -0
- ivcap_lambda/server.py +217 -0
- ivcap_lambda/service_definition.py +44 -0
- ivcap_lambda/utils.py +100 -0
- ivcap_lambda/version.py +22 -0
- ivcap_lambda-0.7.22.dist-info/METADATA +134 -0
- ivcap_lambda-0.7.22.dist-info/RECORD +18 -0
- ivcap_lambda-0.7.22.dist-info/WHEEL +4 -0
- ivcap_lambda-0.7.22.dist-info/licenses/AUTHORS.md +5 -0
- ivcap_lambda-0.7.22.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"filters": {
|
|
4
|
+
"suppress_paths": {
|
|
5
|
+
"()": "ivcap_lambda.logger.SuppressPathsFilter",
|
|
6
|
+
"targets": [
|
|
7
|
+
"/_healtz"
|
|
8
|
+
]
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"formatters": {
|
|
12
|
+
"default": {
|
|
13
|
+
"format": "%(asctime)s %(levelname)s (%(name)s): %(message)s",
|
|
14
|
+
"datefmt": "%Y-%m-%dT%H:%M:%S%z"
|
|
15
|
+
},
|
|
16
|
+
"access": {
|
|
17
|
+
"()": "uvicorn.logging.AccessFormatter",
|
|
18
|
+
"fmt": "%(asctime)s %(levelname)s (access): \"%(request_line)s\" %(status_code)s",
|
|
19
|
+
"datefmt": "%Y-%m-%dT%H:%M:%S%z"
|
|
20
|
+
},
|
|
21
|
+
"renamed": {
|
|
22
|
+
"format": "%(asctime)s %(levelname)s (uvicorn): %(message)s",
|
|
23
|
+
"datefmt": "%Y-%m-%dT%H:%M:%S%z"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"handlers": {
|
|
27
|
+
"default": {
|
|
28
|
+
"class": "logging.StreamHandler",
|
|
29
|
+
"level": "DEBUG",
|
|
30
|
+
"formatter": "default",
|
|
31
|
+
"stream": "ext://sys.stderr"
|
|
32
|
+
},
|
|
33
|
+
"access": {
|
|
34
|
+
"formatter": "access",
|
|
35
|
+
"class": "logging.StreamHandler",
|
|
36
|
+
"stream": "ext://sys.stdout",
|
|
37
|
+
"filters": [
|
|
38
|
+
"suppress_paths"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"uvicorn_error": {
|
|
42
|
+
"class": "logging.StreamHandler",
|
|
43
|
+
"level": "DEBUG",
|
|
44
|
+
"formatter": "renamed",
|
|
45
|
+
"stream": "ext://sys.stderr"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"root": {
|
|
49
|
+
"level": "INFO",
|
|
50
|
+
"handlers": [
|
|
51
|
+
"default"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"loggers": {
|
|
55
|
+
"app": {
|
|
56
|
+
"level": "DEBUG"
|
|
57
|
+
},
|
|
58
|
+
"event": {
|
|
59
|
+
"level": "DEBUG"
|
|
60
|
+
},
|
|
61
|
+
"uvicorn.access": {
|
|
62
|
+
"handlers": [
|
|
63
|
+
"access"
|
|
64
|
+
],
|
|
65
|
+
"level": "INFO",
|
|
66
|
+
"propagate": false
|
|
67
|
+
},
|
|
68
|
+
"uvicorn.error": {
|
|
69
|
+
"handlers": [
|
|
70
|
+
"uvicorn_error"
|
|
71
|
+
],
|
|
72
|
+
"level": "INFO",
|
|
73
|
+
"propagate": false
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"disable_existing_loggers": false
|
|
77
|
+
}
|
ivcap_lambda/mcp.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2023 Commonwealth Scientific and Industrial Research Organisation (CSIRO). All rights reserved.
|
|
3
|
+
# Use of this source code is governed by a BSD-style license that can be
|
|
4
|
+
# found in the LICENSE file. See the AUTHORS file for names of contributors.
|
|
5
|
+
#
|
|
6
|
+
import asyncio
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from typing import Dict, Any, Union
|
|
9
|
+
import json
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
from typing import Any
|
|
12
|
+
from typing import Dict, List, Optional, Union
|
|
13
|
+
from typing_extensions import Literal
|
|
14
|
+
import json
|
|
15
|
+
from typing import Optional, Union
|
|
16
|
+
from fastapi import FastAPI, Request, Response, status
|
|
17
|
+
|
|
18
|
+
from ivcap_service import getLogger, IvcapResult, ExecutionError
|
|
19
|
+
|
|
20
|
+
from .builder import ToolDescription, tools
|
|
21
|
+
|
|
22
|
+
logger = getLogger("mcp")
|
|
23
|
+
|
|
24
|
+
class Notification(BaseModel):
|
|
25
|
+
type: str = "notification"
|
|
26
|
+
message: str
|
|
27
|
+
|
|
28
|
+
# {
|
|
29
|
+
# "jsonrpc": "2.0",
|
|
30
|
+
# "id": 5,
|
|
31
|
+
# "result": {
|
|
32
|
+
# "content": [
|
|
33
|
+
# {
|
|
34
|
+
# "type": "text",
|
|
35
|
+
# "text": "{\"temperature\": 22.5, \"conditions\": \"Partly cloudy\", \"humidity\": 65}"
|
|
36
|
+
# }
|
|
37
|
+
# ],
|
|
38
|
+
# "structuredContent": {
|
|
39
|
+
# "temperature": 22.5,
|
|
40
|
+
# "conditions": "Partly cloudy",
|
|
41
|
+
# "humidity": 65
|
|
42
|
+
# }
|
|
43
|
+
# }
|
|
44
|
+
# }
|
|
45
|
+
class Result(BaseModel):
|
|
46
|
+
type: str = "result"
|
|
47
|
+
data: Any
|
|
48
|
+
|
|
49
|
+
class JsonRpcRequest(BaseModel):
|
|
50
|
+
"""
|
|
51
|
+
Pydantic model for a JSON-RPC 2.0 request object.
|
|
52
|
+
"""
|
|
53
|
+
jsonrpc: Literal["2.0"]
|
|
54
|
+
method: str
|
|
55
|
+
params: Optional[Union[Dict[str, Any], List[Any]]] = None
|
|
56
|
+
id: Union[int, str, None] = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class JsonRpcSuccessResponse(BaseModel):
|
|
60
|
+
"""
|
|
61
|
+
Pydantic model for a JSON-RPC 2.0 success response.
|
|
62
|
+
"""
|
|
63
|
+
jsonrpc: Literal["2.0"]
|
|
64
|
+
result: Any
|
|
65
|
+
id: Union[int, str, None]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class JsonRpcErrorObject(BaseModel):
|
|
69
|
+
"""
|
|
70
|
+
Pydantic model for the error object in a JSON-RPC 2.0 error response.
|
|
71
|
+
"""
|
|
72
|
+
code: int
|
|
73
|
+
message: str
|
|
74
|
+
data: Optional[Any] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class JsonRpcErrorResponse(BaseModel):
|
|
78
|
+
"""
|
|
79
|
+
Pydantic model for a JSON-RPC 2.0 error response.
|
|
80
|
+
"""
|
|
81
|
+
jsonrpc: Literal["2.0"]
|
|
82
|
+
error: JsonRpcErrorObject
|
|
83
|
+
id: Union[int, str, None]
|
|
84
|
+
|
|
85
|
+
#JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse
|
|
86
|
+
|
|
87
|
+
# === Tool Runner (non-streaming path) ===
|
|
88
|
+
async def run_tool_once(req_id: str, tool_name: str, input: dict, httpReq: Request) -> Result:
|
|
89
|
+
tool = next((t for t in tools if t.name == tool_name), None)
|
|
90
|
+
if not tool:
|
|
91
|
+
return Result(type="error", data=f"Tool '{tool_name}' not found")
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
input_model = tool.input[0]
|
|
95
|
+
if input_model:
|
|
96
|
+
# verify parameters
|
|
97
|
+
m = input_model(**input)
|
|
98
|
+
else:
|
|
99
|
+
m = input
|
|
100
|
+
queue = await tool.executor.execute(m, f"urn:mcp:{req_id}", httpReq, report_result=False)
|
|
101
|
+
result = await asyncio.wait_for(queue.get(), timeout=600)
|
|
102
|
+
queue.task_done()
|
|
103
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
104
|
+
# allow cooperative shutdown; propagate cancellation cleanly
|
|
105
|
+
raise
|
|
106
|
+
except Exception as e:
|
|
107
|
+
return Result(type="error", data=str(e))
|
|
108
|
+
|
|
109
|
+
if isinstance(result, IvcapResult):
|
|
110
|
+
if isinstance(result.raw, BaseModel):
|
|
111
|
+
try:
|
|
112
|
+
data = result.raw.model_dump()
|
|
113
|
+
return Result(type="result", data=data)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
data = str(result.content)
|
|
119
|
+
return Result(type="result", data=data)
|
|
120
|
+
except Exception as ex:
|
|
121
|
+
result = ExecutionError(error=f"while converting result to string - {ex}", type="")
|
|
122
|
+
|
|
123
|
+
if not isinstance(result, ExecutionError):
|
|
124
|
+
# this should never happen
|
|
125
|
+
logger.error(f"expected 'ExecutionError' but got {type(result)}")
|
|
126
|
+
result = ExecutionError(
|
|
127
|
+
error="please report unexpected internal error - expected 'ExecutionError' but got {type(result)}",
|
|
128
|
+
type="internal_error",
|
|
129
|
+
)
|
|
130
|
+
return Result(type="error", data=str(result.error))
|
|
131
|
+
|
|
132
|
+
async def handle_tools_call(req_id, params, req: JsonRpcRequest, httpReq: Request):
|
|
133
|
+
tool_name = params["name"]
|
|
134
|
+
tool_args = params.get("arguments", {})
|
|
135
|
+
|
|
136
|
+
message = await run_tool_once(req_id, tool_name, tool_args, httpReq)
|
|
137
|
+
mtype = message.type
|
|
138
|
+
if mtype == "error":
|
|
139
|
+
data = message.data or "???"
|
|
140
|
+
error = JsonRpcErrorObject(
|
|
141
|
+
code=1000,
|
|
142
|
+
message=str(data),
|
|
143
|
+
data=data
|
|
144
|
+
)
|
|
145
|
+
return JsonRpcErrorResponse(id=req_id, error=error, jsonrpc="2.0")
|
|
146
|
+
|
|
147
|
+
elif mtype == "notification":
|
|
148
|
+
# Not expected in non-streaming mode; treat as no-op
|
|
149
|
+
error = JsonRpcErrorObject(
|
|
150
|
+
code=1001,
|
|
151
|
+
message="Unexpected notification message type in non-streaming mode",
|
|
152
|
+
)
|
|
153
|
+
return JsonRpcErrorResponse(id=req_id, error=error, jsonrpc="2.0")
|
|
154
|
+
|
|
155
|
+
elif mtype == "result":
|
|
156
|
+
return _result_response(req_id, message)
|
|
157
|
+
|
|
158
|
+
else:
|
|
159
|
+
error = JsonRpcErrorObject(
|
|
160
|
+
code=1002,
|
|
161
|
+
message=f"Unknown message type `{mtype}' received from tool",
|
|
162
|
+
)
|
|
163
|
+
return JsonRpcErrorResponse(id=req_id, error=error, jsonrpc="2.0")
|
|
164
|
+
|
|
165
|
+
def _result_response(req_id, message):
|
|
166
|
+
# If result is not a string, convert to string
|
|
167
|
+
data = message.data or ""
|
|
168
|
+
result = {}
|
|
169
|
+
if not isinstance(data, str):
|
|
170
|
+
text = json.dumps(data)
|
|
171
|
+
result["structuredContent"] = data
|
|
172
|
+
else:
|
|
173
|
+
text = data
|
|
174
|
+
|
|
175
|
+
result["content"] = [
|
|
176
|
+
{
|
|
177
|
+
"type": "text",
|
|
178
|
+
"text": text
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
return JsonRpcSuccessResponse(id=req_id, result=result, jsonrpc="2.0")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def register_mcp(app: FastAPI, path_prefix: str = "/mcp"):
|
|
185
|
+
"""
|
|
186
|
+
Register the MCP JSON-RPC handler on the given FastAPI app and path_prefix.
|
|
187
|
+
This replaces the @app.post(...) decorator usage.
|
|
188
|
+
The worker_fn and executor parameters are accepted for API symmetry but are not
|
|
189
|
+
directly used by the MCP route.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
async def handle_rpc(rpcReq: JsonRpcRequest, httpReq: Request) -> Union[JsonRpcSuccessResponse, JsonRpcErrorResponse, Response]:
|
|
193
|
+
method = rpcReq.method
|
|
194
|
+
req_id = rpcReq.id
|
|
195
|
+
params = rpcReq.params
|
|
196
|
+
|
|
197
|
+
if method == "tools/call":
|
|
198
|
+
return await handle_tools_call(req_id, params, rpcReq, httpReq)
|
|
199
|
+
|
|
200
|
+
elif method == "tools/list":
|
|
201
|
+
return await handle_tools_list(req_id)
|
|
202
|
+
|
|
203
|
+
elif method == "initialize":
|
|
204
|
+
return await handle_initialize(req_id, app)
|
|
205
|
+
|
|
206
|
+
elif method == "notifications/initialized":
|
|
207
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
208
|
+
|
|
209
|
+
return await handle_unknown_method(req_id)
|
|
210
|
+
|
|
211
|
+
app.add_api_route(
|
|
212
|
+
path_prefix,
|
|
213
|
+
handle_rpc,
|
|
214
|
+
methods=["POST"],
|
|
215
|
+
response_model=None,
|
|
216
|
+
response_model_exclude_none=True,
|
|
217
|
+
response_model_by_alias=True,
|
|
218
|
+
)
|
|
219
|
+
logger.info(f"Added MCP endpoint at '{path_prefix}'")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def handle_unknown_method(req_id):
|
|
223
|
+
return JsonRpcErrorResponse({
|
|
224
|
+
"jsonrpc": "2.0",
|
|
225
|
+
"id": req_id,
|
|
226
|
+
"error": JsonRpcErrorObject(code=-32601, message="Unknown method"),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
async def handle_tools_list(req_id) -> JsonRpcSuccessResponse:
|
|
230
|
+
def f(td: ToolDescription):
|
|
231
|
+
_, description = (td.worker_fn.__doc__.lstrip() + "\n").split("\n", 1)
|
|
232
|
+
input_type = td.input[0]
|
|
233
|
+
return {
|
|
234
|
+
"name": td.name,
|
|
235
|
+
"description": description.strip(),
|
|
236
|
+
"inputSchema": input_type.model_json_schema(),
|
|
237
|
+
}
|
|
238
|
+
tl = [f(t) for t in tools]
|
|
239
|
+
result = { "tools": tl, "isLast": True} # "nextCursor": None }
|
|
240
|
+
return JsonRpcSuccessResponse(id=req_id, result=result, jsonrpc="2.0")
|
|
241
|
+
|
|
242
|
+
async def handle_initialize(req_id, app) -> JsonRpcSuccessResponse:
|
|
243
|
+
result = {
|
|
244
|
+
"protocolVersion": "2024-11-05",
|
|
245
|
+
"serverInfo": {
|
|
246
|
+
"name": f"MCP Server for {app.title}",
|
|
247
|
+
"version": app.version
|
|
248
|
+
},
|
|
249
|
+
"capabilities": {
|
|
250
|
+
"tools": {
|
|
251
|
+
"listChanged": False
|
|
252
|
+
},
|
|
253
|
+
# "resources": {},
|
|
254
|
+
# "prompts": {},
|
|
255
|
+
"toolProvider": {
|
|
256
|
+
"version": "1.0.0",
|
|
257
|
+
"toolInvocationModes": ["standard", "streaming"]
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return JsonRpcSuccessResponse(id=req_id, result=result, jsonrpc="2.0")
|
ivcap_lambda/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561
|
ivcap_lambda/secret.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2023 Commonwealth Scientific and Industrial Research Organisation (CSIRO). All rights reserved.
|
|
3
|
+
# Use of this source code is governed by a BSD-style license that can be
|
|
4
|
+
# found in the LICENSE file. See the AUTHORS file for names of contributors.
|
|
5
|
+
#
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
DEPRECATED: ivcap_lambda.secret.SecretMgrClient
|
|
9
|
+
|
|
10
|
+
The SecretMgrClient has moved to ivcap_service.secret.SecretMgrClient.
|
|
11
|
+
This module keeps a backwards-compatible shim so existing imports like
|
|
12
|
+
|
|
13
|
+
from ivcap_lambda.secret import SecretMgrClient
|
|
14
|
+
|
|
15
|
+
continue to work. Please migrate to:
|
|
16
|
+
|
|
17
|
+
from ivcap_service.secret import SecretMgrClient
|
|
18
|
+
|
|
19
|
+
This shim will be removed in a future release.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import warnings
|
|
25
|
+
|
|
26
|
+
__all__ = ["SecretMgrClient"]
|
|
27
|
+
|
|
28
|
+
from ivcap_service.secret import SecretMgrClient as _SecretMgrClient # type: ignore
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SecretMgrClient(_SecretMgrClient):
|
|
32
|
+
"""Backward-compatibility shim for SecretMgrClient.
|
|
33
|
+
|
|
34
|
+
Deprecated: Use ivcap_service.secret.SecretMgrClient instead.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, *args, **kwargs):
|
|
38
|
+
warnings.warn(
|
|
39
|
+
"ivcap_lambda.secret.SecretMgrClient is deprecated; use "
|
|
40
|
+
"ivcap_service.secret.SecretMgrClient",
|
|
41
|
+
DeprecationWarning,
|
|
42
|
+
stacklevel=2,
|
|
43
|
+
)
|
|
44
|
+
super().__init__(*args, **kwargs)
|
ivcap_lambda/server.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2023 Commonwealth Scientific and Industrial Research Organisation (CSIRO). All rights reserved.
|
|
3
|
+
# Use of this source code is governed by a BSD-style license that can be
|
|
4
|
+
# found in the LICENSE file. See the AUTHORS file for names of contributors.
|
|
5
|
+
#
|
|
6
|
+
import argparse
|
|
7
|
+
from logging import Logger
|
|
8
|
+
from signal import SIGTERM, signal
|
|
9
|
+
from typing import Any, Callable, Dict, Optional
|
|
10
|
+
from fastapi import FastAPI, Request, Response
|
|
11
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
12
|
+
import uvicorn
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from ivcap_service import (
|
|
17
|
+
Service,
|
|
18
|
+
service_log_config,
|
|
19
|
+
getLogger,
|
|
20
|
+
print_tool_definition,
|
|
21
|
+
otel_instrument,
|
|
22
|
+
set_context,
|
|
23
|
+
set_event_reporter_factory,
|
|
24
|
+
SidecarReporter,
|
|
25
|
+
get_version as get_service_version,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .executor import Executor, get_job_context
|
|
29
|
+
from .version import get_version
|
|
30
|
+
from .utils import find_first
|
|
31
|
+
|
|
32
|
+
# from .context import set_context, otel_instrument
|
|
33
|
+
from .builder import tools
|
|
34
|
+
|
|
35
|
+
# shutdown pod cracefully
|
|
36
|
+
signal(SIGTERM, lambda _1, _2: sys.exit(0))
|
|
37
|
+
|
|
38
|
+
_app = FastAPI(
|
|
39
|
+
docs_url="/api",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_fast_app() -> FastAPI:
|
|
44
|
+
"""Get the FastAPI app instance.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
FastAPI: The FastAPI app instance.
|
|
48
|
+
"""
|
|
49
|
+
return _app
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def start_tool_server(
|
|
53
|
+
service: Service,
|
|
54
|
+
*,
|
|
55
|
+
logger: Optional[Logger] = None,
|
|
56
|
+
custom_args: Optional[
|
|
57
|
+
Callable[[argparse.ArgumentParser], argparse.Namespace]
|
|
58
|
+
] = None,
|
|
59
|
+
run_opts: Optional[Dict[str, Any]] = None,
|
|
60
|
+
with_telemetry: Optional[bool] = None,
|
|
61
|
+
):
|
|
62
|
+
"""A helper function to start a FastApi server
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
service (Service): service description
|
|
66
|
+
logger (Logger): _description_
|
|
67
|
+
custom_args (Optional[Callable[[argparse.ArgumentParser], argparse.Namespace]], optional): _description_. Defaults to None.
|
|
68
|
+
run_opts (Optional[Dict[str, Any]], optional): _description_. Defaults to None.
|
|
69
|
+
with_telemetry: (Optional[bool]): Instantiate or block use of OpenTelemetry tracing
|
|
70
|
+
"""
|
|
71
|
+
if len(tools) == 0:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"No tools have been registered. Please register at least one tool using the ivcap_lambda decorator."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
app = get_fast_app()
|
|
77
|
+
app.title = service.name
|
|
78
|
+
app.version = service.version or os.environ.get("VERSION", "???")
|
|
79
|
+
app.contact = dict(service.contact) if service.contact else None
|
|
80
|
+
app.license_info = dict(service.license) if service.license else None
|
|
81
|
+
|
|
82
|
+
title = service.name
|
|
83
|
+
if logger is None:
|
|
84
|
+
logger = getLogger("app")
|
|
85
|
+
|
|
86
|
+
tool_names = [tool.name for tool in tools]
|
|
87
|
+
parser = argparse.ArgumentParser(description=title)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--host",
|
|
90
|
+
type=str,
|
|
91
|
+
default=os.environ.get("HOST", "0.0.0.0"),
|
|
92
|
+
help="Host address",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--port", type=int, default=os.environ.get("PORT", "8090"), help="Port number"
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--with-telemetry", action="store_true", help="Initialise OpenTelemetry"
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument("--with-mcp", action="store_true", help="Add an MCP endpoint")
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--print-service-description",
|
|
103
|
+
type=str,
|
|
104
|
+
metavar="NAME",
|
|
105
|
+
nargs="?",
|
|
106
|
+
const=tool_names[0],
|
|
107
|
+
default=None,
|
|
108
|
+
help=f"Print service description to stdout [{','.join(tool_names)}]",
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--print-tool-description",
|
|
112
|
+
type=str,
|
|
113
|
+
metavar="NAME",
|
|
114
|
+
nargs="?",
|
|
115
|
+
const=tool_names[0],
|
|
116
|
+
default=None,
|
|
117
|
+
help=f"Print tool description to stdout [{','.join(tool_names)}]",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if custom_args is not None:
|
|
121
|
+
args = custom_args(parser)
|
|
122
|
+
else:
|
|
123
|
+
args = parser.parse_args()
|
|
124
|
+
|
|
125
|
+
if args.print_tool_description:
|
|
126
|
+
tool = next((t for t in tools if t.name == args.print_tool_description), None)
|
|
127
|
+
if tool is None:
|
|
128
|
+
print(
|
|
129
|
+
f"Tool '{args.print_tool_description}' not found. Available tools: {', '.join(tool_names)}",
|
|
130
|
+
file=sys.stderr,
|
|
131
|
+
)
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
print_tool_definition(tool.worker_fn)
|
|
134
|
+
sys.exit(0)
|
|
135
|
+
|
|
136
|
+
if args.print_service_description:
|
|
137
|
+
from .service_definition import print_rest_service_definition
|
|
138
|
+
|
|
139
|
+
tool = next(
|
|
140
|
+
(t for t in tools if t.name == args.print_service_description), None
|
|
141
|
+
)
|
|
142
|
+
if tool is None:
|
|
143
|
+
print(
|
|
144
|
+
f"Tool '{args.print_service_description}' not found. Available tools: {', '.join(tool_names)}",
|
|
145
|
+
file=sys.stderr,
|
|
146
|
+
)
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
print_rest_service_definition(service, tool.worker_fn)
|
|
149
|
+
sys.exit(0)
|
|
150
|
+
|
|
151
|
+
logger.info(
|
|
152
|
+
f"{title} - {os.getenv('VERSION')} - v{get_version()}|v{get_service_version()}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Check for '_healtz' service
|
|
156
|
+
healtz = find_first(app.routes, lambda r: r.path == "/_healtz")
|
|
157
|
+
if healtz is None:
|
|
158
|
+
|
|
159
|
+
@app.get("/_healtz", tags=["System"])
|
|
160
|
+
def healtz():
|
|
161
|
+
return {"version": os.environ.get("VERSION", "???")}
|
|
162
|
+
|
|
163
|
+
if args.with_mcp:
|
|
164
|
+
from .mcp import register_mcp
|
|
165
|
+
|
|
166
|
+
register_mcp(app, "/mcp")
|
|
167
|
+
|
|
168
|
+
# print(f">>>> OTEL_EXPORTER_OTLP_ENDPOINT: {os.environ.get('OTEL_EXPORTER_OTLP_ENDPOINT')}")
|
|
169
|
+
set_event_reporter_factory(SidecarReporter)
|
|
170
|
+
|
|
171
|
+
def get_context():
|
|
172
|
+
jctxt = get_job_context()
|
|
173
|
+
if jctxt is None or jctxt.job_id is None:
|
|
174
|
+
logger.warning("missing job context in thread")
|
|
175
|
+
return None
|
|
176
|
+
return jctxt
|
|
177
|
+
|
|
178
|
+
set_context(get_context)
|
|
179
|
+
|
|
180
|
+
otel_instrument(
|
|
181
|
+
with_telemetry, lambda _: FastAPIInstrumentor.instrument_app(app), logger
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def _add_version(request: Request, call_next) -> Response:
|
|
185
|
+
from .version import __version__
|
|
186
|
+
|
|
187
|
+
resp = await call_next(request)
|
|
188
|
+
resp.headers["Ivcap-AI-Tool-Version"] = __version__
|
|
189
|
+
return resp
|
|
190
|
+
|
|
191
|
+
app.middleware("http")(_add_version)
|
|
192
|
+
|
|
193
|
+
if run_opts is None:
|
|
194
|
+
run_opts = {}
|
|
195
|
+
|
|
196
|
+
class Server(uvicorn.Server):
|
|
197
|
+
def handle_exit(self, sig: int, frame: any) -> None:
|
|
198
|
+
logger.info(
|
|
199
|
+
f"Received request for shutdown. Waiting for all running requests to finish first."
|
|
200
|
+
)
|
|
201
|
+
Executor.wait_for_exit_ready()
|
|
202
|
+
super().handle_exit(sig, frame)
|
|
203
|
+
|
|
204
|
+
server = Server(
|
|
205
|
+
config=uvicorn.Config(
|
|
206
|
+
app,
|
|
207
|
+
host=args.host,
|
|
208
|
+
port=args.port,
|
|
209
|
+
log_config=service_log_config(),
|
|
210
|
+
**run_opts,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Start the server
|
|
215
|
+
server.run()
|
|
216
|
+
|
|
217
|
+
# uvicorn.run(app, host=args.host, port=args.port, log_config=service_log_config(), **run_opts)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2023 Commonwealth Scientific and Industrial Research Organisation (CSIRO). All rights reserved.
|
|
3
|
+
# Use of this source code is governed by a BSD-style license that can be
|
|
4
|
+
# found in the LICENSE file. See the AUTHORS file for names of contributors.
|
|
5
|
+
#
|
|
6
|
+
import os
|
|
7
|
+
from typing import Callable, List, Any, Optional, Union
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from ivcap_service import Service, IMAGE_PLACEHOLDER, Resources, ServiceDefinition
|
|
11
|
+
from ivcap_service import create_service_definition, find_resources_file, find_command
|
|
12
|
+
|
|
13
|
+
REST_CONTROLLER_SCHEMA = "urn:ivcap:schema.service.rest.1"
|
|
14
|
+
|
|
15
|
+
class RestController(BaseModel):
|
|
16
|
+
jschema: str = Field(default=REST_CONTROLLER_SCHEMA, alias="$schema")
|
|
17
|
+
image: str
|
|
18
|
+
command: Union[List[str], str]
|
|
19
|
+
resources: Resources = Field(default_factory=Resources)
|
|
20
|
+
|
|
21
|
+
def print_rest_service_definition(
|
|
22
|
+
service_description: Service,
|
|
23
|
+
fn: Callable[..., Any],
|
|
24
|
+
service_id: Optional[str] = None,
|
|
25
|
+
):
|
|
26
|
+
sd = create_rest_service_definition(
|
|
27
|
+
service_description,
|
|
28
|
+
fn,
|
|
29
|
+
service_id=service_id,
|
|
30
|
+
)
|
|
31
|
+
print(sd.model_dump_json(indent=2, by_alias=True, exclude_none=True))
|
|
32
|
+
|
|
33
|
+
def create_rest_service_definition(
|
|
34
|
+
service_description: Service,
|
|
35
|
+
fn: Callable[..., Any],
|
|
36
|
+
service_id: Optional[str] = None,
|
|
37
|
+
) -> ServiceDefinition:
|
|
38
|
+
# controller
|
|
39
|
+
image = os.getenv("DOCKER_IMG", IMAGE_PLACEHOLDER)
|
|
40
|
+
|
|
41
|
+
command = find_command()
|
|
42
|
+
resources = find_resources_file()
|
|
43
|
+
controller = RestController(image=image, command=command, resources=resources)
|
|
44
|
+
return create_service_definition(service_description, fn, REST_CONTROLLER_SCHEMA, controller, service_id)
|