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.
@@ -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)