ailoy-py 0.0.2__cp310-cp310-manylinux_2_28_x86_64.whl → 0.0.5__cp310-cp310-manylinux_2_28_x86_64.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.

Potentially problematic release.


This version of ailoy-py might be problematic. Click here for more details.

ailoy/__init__.py CHANGED
@@ -16,6 +16,7 @@ if __doc__ is None:
16
16
  else: # fallback docstring
17
17
  __doc__ = "# ailoy-py\n\nPython binding for Ailoy runtime APIs"
18
18
 
19
- from .agent import Agent # noqa: F401
19
+ from .agent import Agent, AudioContent, BearerAuthenticator, ImageContent, TextContent, ToolAuthenticator # noqa: F401
20
+ from .models import APIModel, LocalModel # noqa: F401
20
21
  from .runtime import AsyncRuntime, Runtime # noqa: F401
21
22
  from .vector_store import VectorStore # noqa: F401
ailoy/agent.py CHANGED
@@ -1,69 +1,102 @@
1
+ import base64
1
2
  import json
2
3
  import warnings
3
4
  from abc import ABC, abstractmethod
4
- from collections.abc import Awaitable, Callable, Generator
5
+ from collections.abc import Callable, Generator
5
6
  from functools import partial
6
7
  from pathlib import Path
7
8
  from typing import (
9
+ Annotated,
8
10
  Any,
9
11
  Literal,
10
12
  Optional,
11
- TypeVar,
12
13
  Union,
13
14
  )
14
15
  from urllib.parse import urlencode, urlparse, urlunparse
15
16
 
16
17
  import jmespath
17
- from pydantic import BaseModel, ConfigDict, Field
18
+ from PIL.Image import Image
19
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
18
20
  from rich.console import Console
19
21
  from rich.panel import Panel
20
22
 
21
23
  from ailoy.ailoy_py import generate_uuid
22
24
  from ailoy.mcp import MCPServer, MCPTool, StdioServerParameters
25
+ from ailoy.models import APIModel, LocalModel
23
26
  from ailoy.runtime import Runtime
24
27
  from ailoy.tools import DocstringParsingException, TypeHintParsingException, get_json_schema
25
-
26
- __all__ = ["Agent"]
28
+ from ailoy.utils.image import pillow_image_to_base64
27
29
 
28
30
  ## Types for internal data structures
29
31
 
30
32
 
31
- class TextData(BaseModel):
32
- type: Literal["text"]
33
+ class TextContent(BaseModel):
34
+ type: Literal["text"] = "text"
33
35
  text: str
34
36
 
35
37
 
38
+ class ImageContent(BaseModel):
39
+ class UrlData(BaseModel):
40
+ url: str
41
+
42
+ type: Literal["image_url"] = "image_url"
43
+ image_url: UrlData
44
+
45
+ @staticmethod
46
+ def from_url(url: str):
47
+ return ImageContent(image_url={"url": url})
48
+
49
+ @staticmethod
50
+ def from_pillow(image: Image):
51
+ return ImageContent(image_url={"url": pillow_image_to_base64(image)})
52
+
53
+
54
+ class AudioContent(BaseModel):
55
+ class AudioData(BaseModel):
56
+ data: str
57
+ format: Literal["mp3", "wav"]
58
+
59
+ type: Literal["input_audio"] = "input_audio"
60
+ input_audio: AudioData
61
+
62
+ @staticmethod
63
+ def from_bytes(data: bytes, format: Literal["mp3", "wav"]):
64
+ return AudioContent(input_audio={"data": base64.b64encode(data).decode("utf-8"), "format": format})
65
+
66
+
36
67
  class FunctionData(BaseModel):
37
68
  class FunctionBody(BaseModel):
38
69
  name: str
39
70
  arguments: Any
40
71
 
41
- type: Literal["function"]
72
+ type: Literal["function"] = "function"
42
73
  id: Optional[str] = None
43
74
  function: FunctionBody
44
75
 
45
76
 
46
77
  class SystemMessage(BaseModel):
47
- role: Literal["system"]
48
- content: list[TextData]
78
+ role: Literal["system"] = "system"
79
+ content: str | list[TextContent]
49
80
 
50
81
 
51
82
  class UserMessage(BaseModel):
52
- role: Literal["user"]
53
- content: list[TextData]
83
+ role: Literal["user"] = "user"
84
+ content: str | list[TextContent | ImageContent | AudioContent]
54
85
 
55
86
 
56
87
  class AssistantMessage(BaseModel):
57
- role: Literal["assistant"]
58
- reasoning: Optional[list[TextData]] = None
59
- content: Optional[list[TextData]] = None
88
+ role: Literal["assistant"] = "assistant"
89
+ content: Optional[str | list[TextContent]] = None
90
+ name: Optional[str] = None
60
91
  tool_calls: Optional[list[FunctionData]] = None
61
92
 
93
+ # Non-OpenAI fields
94
+ reasoning: Optional[list[TextContent]] = None
95
+
62
96
 
63
97
  class ToolMessage(BaseModel):
64
- role: Literal["tool"]
65
- name: str
66
- content: list[TextData]
98
+ role: Literal["tool"] = "tool"
99
+ content: str | list[TextContent]
67
100
  tool_call_id: Optional[str] = None
68
101
 
69
102
 
@@ -76,72 +109,10 @@ Message = Union[
76
109
 
77
110
 
78
111
  class MessageOutput(BaseModel):
79
- class AssistantMessageDelta(BaseModel):
80
- content: Optional[list[TextData]] = None
81
- reasoning: Optional[list[TextData]] = None
82
- tool_calls: Optional[list[FunctionData]] = None
83
-
84
- message: AssistantMessageDelta
112
+ message: AssistantMessage
85
113
  finish_reason: Optional[Literal["stop", "tool_calls", "invalid_tool_call", "length", "error"]] = None
86
114
 
87
115
 
88
- ## Types for LLM Model Definitions
89
-
90
- TVMModelName = Literal["Qwen/Qwen3-0.6B", "Qwen/Qwen3-1.7B", "Qwen/Qwen3-4B", "Qwen/Qwen3-8B"]
91
- OpenAIModelName = Literal["gpt-4o"]
92
- ModelName = Union[TVMModelName, OpenAIModelName]
93
-
94
-
95
- class TVMModel(BaseModel):
96
- name: TVMModelName
97
- quantization: Optional[Literal["q4f16_1"]] = None
98
- mode: Optional[Literal["interactive"]] = None
99
-
100
-
101
- class OpenAIModel(BaseModel):
102
- name: OpenAIModelName
103
- api_key: str
104
-
105
-
106
- class ModelDescription(BaseModel):
107
- model_id: str
108
- component_type: str
109
- default_system_message: Optional[str] = None
110
-
111
-
112
- model_descriptions: dict[ModelName, ModelDescription] = {
113
- "Qwen/Qwen3-0.6B": ModelDescription(
114
- model_id="Qwen/Qwen3-0.6B",
115
- component_type="tvm_language_model",
116
- default_system_message="You are Qwen, created by Alibaba Cloud. You are a helpful assistant.",
117
- ),
118
- "Qwen/Qwen3-1.7B": ModelDescription(
119
- model_id="Qwen/Qwen3-1.7B",
120
- component_type="tvm_language_model",
121
- default_system_message="You are Qwen, created by Alibaba Cloud. You are a helpful assistant.",
122
- ),
123
- "Qwen/Qwen3-4B": ModelDescription(
124
- model_id="Qwen/Qwen3-4B",
125
- component_type="tvm_language_model",
126
- default_system_message="You are Qwen, created by Alibaba Cloud. You are a helpful assistant.",
127
- ),
128
- "Qwen/Qwen3-8B": ModelDescription(
129
- model_id="Qwen/Qwen3-8B",
130
- component_type="tvm_language_model",
131
- default_system_message="You are Qwen, created by Alibaba Cloud. You are a helpful assistant.",
132
- ),
133
- "gpt-4o": ModelDescription(
134
- model_id="gpt-4o",
135
- component_type="openai",
136
- ),
137
- }
138
-
139
-
140
- class ComponentState(BaseModel):
141
- name: str
142
- valid: bool
143
-
144
-
145
116
  ## Types for agent's responses
146
117
 
147
118
  _console = Console(highlight=False, force_jupyter=False, force_terminal=True)
@@ -149,7 +120,7 @@ _console = Console(highlight=False, force_jupyter=False, force_terminal=True)
149
120
 
150
121
  class AgentResponseOutputText(BaseModel):
151
122
  type: Literal["output_text", "reasoning"]
152
- role: Literal["assistant"]
123
+ role: Literal["assistant"] = "assistant"
153
124
  is_type_switched: bool = False
154
125
  content: str
155
126
 
@@ -160,14 +131,14 @@ class AgentResponseOutputText(BaseModel):
160
131
 
161
132
 
162
133
  class AgentResponseToolCall(BaseModel):
163
- type: Literal["tool_call"]
164
- role: Literal["assistant"]
134
+ type: Literal["tool_call"] = "tool_call"
135
+ role: Literal["assistant"] = "assistant"
165
136
  is_type_switched: bool = False
166
137
  content: FunctionData
167
138
 
168
139
  def print(self):
169
140
  title = f"[magenta]Tool Call[/magenta]: [bold]{self.content.function.name}[/bold]"
170
- if self.content.id is not None:
141
+ if self.content.id is not None and len(self.content.id) > 0:
171
142
  title += f" ({self.content.id})"
172
143
  panel = Panel(
173
144
  json.dumps(self.content.function.arguments, indent=2),
@@ -178,8 +149,8 @@ class AgentResponseToolCall(BaseModel):
178
149
 
179
150
 
180
151
  class AgentResponseToolResult(BaseModel):
181
- type: Literal["tool_call_result"]
182
- role: Literal["tool"]
152
+ type: Literal["tool_call_result"] = "tool_call_result"
153
+ role: Literal["tool"] = "tool"
183
154
  is_type_switched: bool = False
184
155
  content: ToolMessage
185
156
 
@@ -194,8 +165,8 @@ class AgentResponseToolResult(BaseModel):
194
165
  if len(content) > 500:
195
166
  content = content[:500] + "...(truncated)"
196
167
 
197
- title = f"[green]Tool Result[/green]: [bold]{self.content.name}[/bold]"
198
- if self.content.tool_call_id is not None:
168
+ title = "[green]Tool Result[/green]"
169
+ if self.content.tool_call_id is not None and len(self.content.tool_call_id) > 0:
199
170
  title += f" ({self.content.tool_call_id})"
200
171
  panel = Panel(
201
172
  content,
@@ -206,8 +177,8 @@ class AgentResponseToolResult(BaseModel):
206
177
 
207
178
 
208
179
  class AgentResponseError(BaseModel):
209
- type: Literal["error"]
210
- role: Literal["assistant"]
180
+ type: Literal["error"] = "error"
181
+ role: Literal["assistant"] = "assistant"
211
182
  is_type_switched: bool = False
212
183
  content: str
213
184
 
@@ -245,8 +216,11 @@ class ToolParameters(BaseModel):
245
216
  required: Optional[list[str]] = []
246
217
 
247
218
 
219
+ JsonSchemaTypes = Literal["string", "integer", "number", "boolean", "object", "array", "null"]
220
+
221
+
248
222
  class ToolParametersProperty(BaseModel):
249
- type: Literal["string", "number", "boolean", "object", "array", "null"]
223
+ type: JsonSchemaTypes | list[JsonSchemaTypes]
250
224
  description: Optional[str] = None
251
225
  model_config = ConfigDict(extra="allow")
252
226
 
@@ -308,22 +282,6 @@ class BearerAuthenticator(ToolAuthenticator):
308
282
  return {**request, "headers": headers}
309
283
 
310
284
 
311
- T_Retval = TypeVar("T_Retval")
312
-
313
-
314
- def run_async(coro: Callable[..., Awaitable[T_Retval]]) -> T_Retval:
315
- try:
316
- import anyio
317
-
318
- # Running outside async loop
319
- return anyio.run(lambda: coro)
320
- except RuntimeError:
321
- import anyio.from_thread
322
-
323
- # Already in a running event loop: use anyio from_thread
324
- return anyio.from_thread.run(coro)
325
-
326
-
327
285
  class Agent:
328
286
  """
329
287
  The `Agent` class provides a high-level interface for interacting with large language models (LLMs) in Ailoy.
@@ -337,28 +295,22 @@ class Agent:
337
295
  def __init__(
338
296
  self,
339
297
  runtime: Runtime,
340
- model_name: ModelName,
298
+ model: APIModel | LocalModel,
341
299
  system_message: Optional[str] = None,
342
- api_key: Optional[str] = None,
343
- **attrs,
344
300
  ):
345
301
  """
346
302
  Create an instance.
347
303
 
348
304
  :param runtime: The runtime environment associated with the agent.
349
- :param model_name: The name of the LLM model to use.
305
+ :param model: The model instance.
350
306
  :param system_message: Optional system message to set the initial assistant context.
351
- :param api_key: (web agent only) The API key for AI API.
352
- :param attrs: Additional initialization parameters (for `define_component` runtime call)
353
307
  :raises ValueError: If model name is not supported or validation fails.
354
308
  """
355
309
  self._runtime = runtime
356
310
 
357
311
  # Initialize component state
358
- self._component_state = ComponentState(
359
- name=generate_uuid(),
360
- valid=False,
361
- )
312
+ self._component_name = generate_uuid()
313
+ self._component_ready = False
362
314
 
363
315
  # Initialize messages
364
316
  self._messages: list[Message] = []
@@ -373,7 +325,7 @@ class Agent:
373
325
  self._mcp_servers: list[MCPServer] = []
374
326
 
375
327
  # Define the component
376
- self.define(model_name, api_key=api_key, **attrs)
328
+ self.define(model)
377
329
 
378
330
  def __del__(self):
379
331
  self.delete()
@@ -384,70 +336,55 @@ class Agent:
384
336
  def __exit__(self, type, value, traceback):
385
337
  self.delete()
386
338
 
387
- def define(self, model_name: ModelName, api_key: Optional[str] = None, **attrs) -> None:
339
+ def define(self, model: APIModel | LocalModel) -> None:
388
340
  """
389
341
  Initializes the agent by defining its model in the runtime.
390
342
  This must be called before running the agent. If already initialized, this is a no-op.
391
- :param model_name: The name of the LLM model to use.
392
- :param api_key: (web agent only) The API key for AI API.
393
- :param attrs: Additional initialization parameters (for `define_component` runtime call)
343
+ :param model: The model instance.
394
344
  """
395
- if self._component_state.valid:
345
+ if self._component_ready:
396
346
  return
397
347
 
398
348
  if not self._runtime.is_alive():
399
349
  raise ValueError("Runtime is currently stopped.")
400
350
 
401
- if model_name not in model_descriptions:
402
- raise ValueError(f"Model `{model_name}` not supported")
403
-
404
- model_desc = model_descriptions[model_name]
405
-
406
- # Add model name into attrs
407
- if "model" not in attrs:
408
- attrs["model"] = model_desc.model_id
409
-
410
351
  # Set default system message if not given; still can be None
411
352
  if self._system_message is None:
412
- self._system_message = model_desc.default_system_message
353
+ self._system_message = getattr(model, "default_system_message", None)
413
354
 
414
355
  self.clear_messages()
415
356
 
416
- # Add API key
417
- if api_key:
418
- attrs["api_key"] = api_key
419
-
420
357
  # Call runtime's define
421
358
  self._runtime.define(
422
- model_descriptions[model_name].component_type,
423
- self._component_state.name,
424
- attrs,
359
+ model.component_type,
360
+ self._component_name,
361
+ model.to_attrs(),
425
362
  )
426
363
 
427
364
  # Mark as defined
428
- self._component_state.valid = True
365
+ self._component_ready = True
429
366
 
430
367
  def delete(self) -> None:
431
368
  """
432
369
  Deinitializes the agent and releases resources in the runtime.
433
370
  This should be called when the agent is no longer needed. If already deinitialized, this is a no-op.
434
371
  """
435
- if not self._component_state.valid:
372
+ if not self._component_ready:
436
373
  return
437
374
 
438
375
  if self._runtime.is_alive():
439
- self._runtime.delete(self._component_state.name)
376
+ self._runtime.delete(self._component_name)
440
377
 
441
378
  self.clear_messages()
442
379
 
443
380
  for mcp_server in self._mcp_servers:
444
381
  mcp_server.cleanup()
445
382
 
446
- self._component_state.valid = False
383
+ self._component_ready = False
447
384
 
448
385
  def query(
449
386
  self,
450
- message: str,
387
+ message: str | list[str | Image | dict | TextContent | ImageContent | AudioContent],
451
388
  reasoning: bool = False,
452
389
  ) -> Generator[AgentResponse, None, None]:
453
390
  """
@@ -458,13 +395,36 @@ class Agent:
458
395
  :return: An iterator over the output, where each item represents either a generated token from the assistant or a tool call.
459
396
  :rtype: Iterator[:class:`AgentResponse`]
460
397
  """ # noqa: E501
461
- if not self._component_state.valid:
398
+ if not self._component_ready:
462
399
  raise ValueError("Agent is not valid. Create one or define newly.")
463
400
 
464
401
  if not self._runtime.is_alive():
465
402
  raise ValueError("Runtime is currently stopped.")
466
403
 
467
- self._messages.append(UserMessage(role="user", content=[{"type": "text", "text": message}]))
404
+ if isinstance(message, str):
405
+ self._messages.append(UserMessage(content=[TextContent(text=message)]))
406
+ elif isinstance(message, list):
407
+ if len(message) == 0:
408
+ raise ValueError("Message is empty")
409
+
410
+ contents = []
411
+ for content in message:
412
+ if isinstance(content, str):
413
+ contents.append(TextContent(text=content))
414
+ elif isinstance(content, Image):
415
+ contents.append(ImageContent.from_pillow(image=content))
416
+ elif isinstance(content, dict):
417
+ ta: TypeAdapter[TextContent | ImageContent | AudioContent] = TypeAdapter(
418
+ Annotated[TextContent | ImageContent | AudioContent, Field(discriminator="type")]
419
+ )
420
+ validated_content = ta.validate_python(content)
421
+ contents.append(validated_content)
422
+ else:
423
+ contents.append(content)
424
+
425
+ self._messages.append(UserMessage(content=contents))
426
+ else:
427
+ raise ValueError(f"Invalid message type: {type(message)}")
468
428
 
469
429
  prev_resp_type = None
470
430
 
@@ -480,7 +440,7 @@ class Agent:
480
440
  assistant_content = None
481
441
  assistant_tool_calls = None
482
442
  finish_reason = ""
483
- for result in self._runtime.call_iter_method(self._component_state.name, "infer", infer_args):
443
+ for result in self._runtime.call_iter_method(self._component_name, "infer", infer_args):
484
444
  msg = MessageOutput.model_validate(result)
485
445
 
486
446
  if msg.message.reasoning:
@@ -491,13 +451,16 @@ class Agent:
491
451
  assistant_reasoning[0].text += v.text
492
452
  resp = AgentResponseOutputText(
493
453
  type="reasoning",
494
- role="assistant",
495
454
  is_type_switched=(prev_resp_type != "reasoning"),
496
455
  content=v.text,
497
456
  )
498
457
  prev_resp_type = resp.type
499
458
  yield resp
500
- if msg.message.content:
459
+ if msg.message.content is not None:
460
+ # Canonicalize message content to the array of TextContent
461
+ if isinstance(msg.message.content, str):
462
+ msg.message.content = [TextContent(text=msg.message.content)]
463
+
501
464
  for v in msg.message.content:
502
465
  if not assistant_content:
503
466
  assistant_content = [v]
@@ -505,7 +468,6 @@ class Agent:
505
468
  assistant_content[0].text += v.text
506
469
  resp = AgentResponseOutputText(
507
470
  type="output_text",
508
- role="assistant",
509
471
  is_type_switched=(prev_resp_type != "output_text"),
510
472
  content=v.text,
511
473
  )
@@ -518,8 +480,6 @@ class Agent:
518
480
  else:
519
481
  assistant_tool_calls.append(v)
520
482
  resp = AgentResponseToolCall(
521
- type="tool_call",
522
- role="assistant",
523
483
  is_type_switched=True,
524
484
  content=v,
525
485
  )
@@ -532,7 +492,6 @@ class Agent:
532
492
  # Append output
533
493
  self._messages.append(
534
494
  AssistantMessage(
535
- role="assistant",
536
495
  reasoning=assistant_reasoning,
537
496
  content=assistant_content,
538
497
  tool_calls=assistant_tool_calls,
@@ -550,18 +509,16 @@ class Agent:
550
509
  raise RuntimeError("Tool not found")
551
510
  tool_result = tool_.call(**tool_call.function.arguments)
552
511
  return ToolMessage(
553
- role="tool",
554
- name=tool_call.function.name,
555
- content=[TextData(type="text", text=json.dumps(tool_result))],
556
- tool_call_id=tool_call.id if tool_call.id else None,
512
+ content=[
513
+ TextContent(text=tool_result if isinstance(tool_result, str) else json.dumps(tool_result))
514
+ ],
515
+ tool_call_id=tool_call.id,
557
516
  )
558
517
 
559
518
  tool_call_results = [run_tool(tc) for tc in assistant_tool_calls]
560
519
  for result_msg in tool_call_results:
561
520
  self._messages.append(result_msg)
562
521
  resp = AgentResponseToolResult(
563
- type="tool_call_result",
564
- role="tool",
565
522
  is_type_switched=True,
566
523
  content=result_msg,
567
524
  )
@@ -571,6 +528,7 @@ class Agent:
571
528
  continue
572
529
 
573
530
  # Finish this generator
531
+ yield AgentResponseOutputText(type="output_text", content="\n")
574
532
  break
575
533
 
576
534
  def get_messages(self) -> list[Message]:
@@ -589,9 +547,7 @@ class Agent:
589
547
  """
590
548
  self._messages.clear()
591
549
  if self._system_message is not None:
592
- self._messages.append(
593
- SystemMessage(role="system", content=[TextData(type="text", text=self._system_message)])
594
- )
550
+ self._messages.append(SystemMessage(role="system", content=[TextContent(text=self._system_message)]))
595
551
 
596
552
  def print(self, resp: AgentResponse):
597
553
  resp.print()
@@ -779,7 +735,7 @@ class Agent:
779
735
  continue
780
736
 
781
737
  desc = ToolDescription(
782
- name=f"{name}/{tool.name}", description=tool.description, parameters=tool.inputSchema
738
+ name=f"{name}-{tool.name}", description=tool.description, parameters=tool.inputSchema
783
739
  )
784
740
 
785
741
  def call(tool: MCPTool, **inputs: dict[str, Any]) -> list[str]:
@@ -803,4 +759,16 @@ class Agent:
803
759
  mcp_server.cleanup()
804
760
 
805
761
  # Remove tools registered from the MCP server
806
- self._tools = list(filter(lambda t: not t.desc.name.startswith(f"{mcp_server.name}/"), self._tools))
762
+ self._tools = list(filter(lambda t: not t.desc.name.startswith(f"{mcp_server.name}-"), self._tools))
763
+
764
+ def get_tools(self):
765
+ """
766
+ Get the list of registered tools.
767
+ """
768
+ return self._tools
769
+
770
+ def clear_tools(self):
771
+ """
772
+ Clear the registered tools.
773
+ """
774
+ self._tools.clear()
ailoy/mcp.py CHANGED
@@ -2,7 +2,7 @@ import asyncio
2
2
  import json
3
3
  import multiprocessing
4
4
  import platform
5
- import subprocess
5
+ import tempfile
6
6
  from multiprocessing.connection import Connection
7
7
  from typing import Annotated, Any, Literal, Union
8
8
 
@@ -13,6 +13,7 @@ from mcp.client.stdio import (
13
13
  StdioServerParameters,
14
14
  stdio_client,
15
15
  )
16
+ from mcp.shared.exceptions import McpError
16
17
  from pydantic import BaseModel, Field, TypeAdapter
17
18
 
18
19
  __all__ = ["MCPServer"]
@@ -73,11 +74,15 @@ class MCPServer:
73
74
  self._parent_conn, self._child_conn = multiprocessing.Pipe()
74
75
 
75
76
  ctx = multiprocessing.get_context("fork" if platform.system() != "Windows" else "spawn")
76
- self._proc = ctx.Process(target=self._run_process, args=(self._child_conn,))
77
+ self._proc: multiprocessing.Process = ctx.Process(target=self._run_process, args=(self._child_conn,))
77
78
  self._proc.start()
78
79
 
79
80
  # Wait for subprocess to signal initialization complete
80
- self._recv_response()
81
+ try:
82
+ self._recv_response()
83
+ except RuntimeError as e:
84
+ self.cleanup()
85
+ raise e
81
86
 
82
87
  def __del__(self):
83
88
  self.cleanup()
@@ -86,52 +91,59 @@ class MCPServer:
86
91
  asyncio.run(self._process_main(conn))
87
92
 
88
93
  async def _process_main(self, conn: Connection):
89
- async with stdio_client(self.params, errlog=subprocess.PIPE) as (read, write):
90
- async with ClientSession(read, write) as session:
91
- # Notify to main process that the initialization has been finished and ready to receive requests
92
- try:
93
- await session.initialize()
94
- conn.send(ResultMessage(result=True).model_dump())
95
- except Exception as e:
96
- conn.send(ErrorMessage(error=f"Failed to initialize MCP subprocess: {e}").model_dump())
97
-
98
- while True:
99
- if not conn.poll(0.1):
100
- await asyncio.sleep(0.1)
101
- continue
102
-
94
+ with tempfile.TemporaryFile(mode="w+t") as _errlog:
95
+ async with stdio_client(self.params, errlog=_errlog) as (read, write):
96
+ async with ClientSession(read, write) as session:
97
+ # Notify to main process that the initialization has been finished and ready to receive requests
103
98
  try:
104
- raw = conn.recv()
105
- req = TypeAdapter(RequestMessage).validate_python(raw)
106
-
107
- if isinstance(req, ListToolsRequest):
108
- result = await session.list_tools()
109
- conn.send(ResultMessage(result=result.tools).model_dump())
110
-
111
- elif isinstance(req, CallToolRequest):
112
- result = await session.call_tool(req.tool.name, req.arguments)
113
- contents: list[str] = []
114
- for item in result.content:
115
- if isinstance(item, mcp_types.TextContent):
116
- try:
117
- content = json.loads(item.text)
118
- contents.append(json.dumps(content))
119
- except json.JSONDecodeError:
120
- contents.append(item.text)
121
- elif isinstance(item, mcp_types.ImageContent):
122
- contents.append(item.data)
123
- elif isinstance(item, mcp_types.EmbeddedResource):
124
- if isinstance(item.resource, mcp_types.TextResourceContents):
125
- contents.append(item.resource.text)
126
- else:
127
- contents.append(item.resource.blob)
128
- conn.send(ResultMessage(result=contents).model_dump())
129
-
130
- elif isinstance(req, ShutdownRequest):
131
- break
132
-
133
- except Exception as e:
134
- conn.send(ErrorMessage(error=str(e)).model_dump())
99
+ await session.initialize()
100
+ conn.send(ResultMessage(result=True).model_dump())
101
+ except McpError:
102
+ _errlog.seek(0)
103
+ error = _errlog.read()
104
+ conn.send(
105
+ ErrorMessage(
106
+ error=f"Failed to initialize MCP subprocess. Check the error output below.\n\n{error}"
107
+ ).model_dump()
108
+ )
109
+
110
+ while True:
111
+ if not conn.poll(0.1):
112
+ await asyncio.sleep(0.1)
113
+ continue
114
+
115
+ try:
116
+ raw = conn.recv()
117
+ req = TypeAdapter(RequestMessage).validate_python(raw)
118
+
119
+ if isinstance(req, ListToolsRequest):
120
+ result = await session.list_tools()
121
+ conn.send(ResultMessage(result=result.tools).model_dump())
122
+
123
+ elif isinstance(req, CallToolRequest):
124
+ result = await session.call_tool(req.tool.name, req.arguments)
125
+ contents: list[str] = []
126
+ for item in result.content:
127
+ if isinstance(item, mcp_types.TextContent):
128
+ try:
129
+ content = json.loads(item.text)
130
+ contents.append(json.dumps(content))
131
+ except json.JSONDecodeError:
132
+ contents.append(item.text)
133
+ elif isinstance(item, mcp_types.ImageContent):
134
+ contents.append(item.data)
135
+ elif isinstance(item, mcp_types.EmbeddedResource):
136
+ if isinstance(item.resource, mcp_types.TextResourceContents):
137
+ contents.append(item.resource.text)
138
+ else:
139
+ contents.append(item.resource.blob)
140
+ conn.send(ResultMessage(result=contents).model_dump())
141
+
142
+ elif isinstance(req, ShutdownRequest):
143
+ break
144
+
145
+ except Exception as e:
146
+ conn.send(ErrorMessage(error=str(e)).model_dump())
135
147
 
136
148
  def _send_request(self, msg: RequestMessage):
137
149
  self._parent_conn.send(msg.model_dump())
@@ -0,0 +1,7 @@
1
+ from .api_model import APIModel
2
+ from .local_model import LocalModel
3
+
4
+ __all__ = [
5
+ "APIModel",
6
+ "LocalModel",
7
+ ]
@@ -0,0 +1,86 @@
1
+ from typing import Literal, Optional, get_args
2
+
3
+ from pydantic import model_validator
4
+ from pydantic.dataclasses import dataclass
5
+
6
+ OpenAIModelId = Literal[
7
+ "o4-mini",
8
+ "o3",
9
+ "o3-pro",
10
+ "o3-mini",
11
+ "gpt-4o",
12
+ "gpt-4o-mini",
13
+ "gpt-4.1",
14
+ "gpt-4.1-mini",
15
+ "gpt-4.1-nano",
16
+ ]
17
+
18
+ GeminiModelId = Literal[
19
+ "gemini-2.5-flash",
20
+ "gemini-2.5-pro",
21
+ "gemini-2.0-flash",
22
+ "gemini-1.5-flash",
23
+ "gemini-1.5-pro",
24
+ ]
25
+
26
+ ClaudeModelId = Literal[
27
+ "claude-sonnet-4-20250514",
28
+ "claude-3-7-sonnet-20250219",
29
+ "claude-3-5-sonnet-20241022",
30
+ "claude-3-5-sonnet-20240620",
31
+ "claude-opus-4-20250514",
32
+ "claude-3-opus-20240229",
33
+ "claude-3-5-haiku-20241022",
34
+ "claude-3-haiku-20240307",
35
+ ]
36
+
37
+ GrokModelId = Literal[
38
+ "grok-4",
39
+ "grok-4-0709",
40
+ "grok-3",
41
+ "grok-3-fast",
42
+ "grok-3-mini",
43
+ "grok-3-mini-fast",
44
+ "grok-2",
45
+ "grok-2-1212",
46
+ "grok-2-vision-1212",
47
+ "grok-2-image-1212",
48
+ ]
49
+
50
+ APIModelProvider = Literal["openai", "gemini", "claude", "grok"]
51
+
52
+
53
+ @dataclass
54
+ class APIModel:
55
+ id: OpenAIModelId | GeminiModelId | ClaudeModelId | str
56
+ api_key: str
57
+ provider: Optional[APIModelProvider] = None
58
+
59
+ @model_validator(mode="after")
60
+ def validate_provider(self):
61
+ if self.provider is None:
62
+ if self.id in get_args(OpenAIModelId):
63
+ self.provider = "openai"
64
+ elif self.id in get_args(GeminiModelId):
65
+ self.provider = "gemini"
66
+ elif self.id in get_args(ClaudeModelId):
67
+ self.provider = "claude"
68
+ elif self.id in get_args(GrokModelId):
69
+ self.provider = "grok"
70
+ else:
71
+ raise ValueError(
72
+ f'Failed to infer the model provider based on the model id "{self.id}". '
73
+ "Please provide an explicit model provider."
74
+ )
75
+
76
+ return self
77
+
78
+ @property
79
+ def component_type(self) -> str:
80
+ return self.provider
81
+
82
+ def to_attrs(self):
83
+ return {
84
+ "model": self.id,
85
+ "api_key": self.api_key,
86
+ }
@@ -0,0 +1,44 @@
1
+ from typing import Literal, Optional
2
+
3
+ from pydantic.dataclasses import dataclass
4
+
5
+ LocalModelBackend = Literal["tvm"]
6
+ LocalModelId = Literal[
7
+ "Qwen/Qwen3-0.6B",
8
+ "Qwen/Qwen3-1.7B",
9
+ "Qwen/Qwen3-4B",
10
+ "Qwen/Qwen3-8B",
11
+ "Qwen/Qwen3-14B",
12
+ "Qwen/Qwen3-32B",
13
+ "Qwen/Qwen3-30B-A3B",
14
+ ]
15
+ Quantization = Literal["q4f16_1"]
16
+
17
+
18
+ @dataclass
19
+ class LocalModel:
20
+ id: LocalModelId
21
+ backend: LocalModelBackend = "tvm"
22
+ quantization: Quantization = "q4f16_1"
23
+ device: int = 0
24
+
25
+ @property
26
+ def default_system_message(self) -> Optional[str]:
27
+ if self.id.startswith("Qwen"):
28
+ return "You are Qwen, created by Alibaba Cloud. You are a helpful assistant."
29
+ return None
30
+
31
+ @property
32
+ def component_type(self) -> str:
33
+ if self.backend == "tvm":
34
+ return "tvm_language_model"
35
+ raise ValueError(f"Unknown local model backend: {self.backend}")
36
+
37
+ def to_attrs(self) -> dict:
38
+ if self.backend == "tvm":
39
+ return {
40
+ "model": self.id,
41
+ "quantization": self.quantization,
42
+ "device": self.device,
43
+ }
44
+ raise ValueError(f"Unknown local model backend: {self.backend}")
ailoy/tools.py CHANGED
@@ -5,6 +5,7 @@ import types
5
5
  from typing import (
6
6
  Any,
7
7
  Callable,
8
+ Literal,
8
9
  Optional,
9
10
  Union,
10
11
  get_args,
@@ -41,7 +42,7 @@ class DocstringParsingException(Exception):
41
42
  pass
42
43
 
43
44
 
44
- def _get_json_schema_type(param_type: str) -> dict[str, str]:
45
+ def _get_json_schema_type(param_type: type) -> dict[str, str]:
45
46
  type_mapping = {
46
47
  int: {"type": "integer"},
47
48
  float: {"type": "number"},
@@ -85,6 +86,20 @@ def _parse_type_hint(hint: str) -> dict:
85
86
  return_dict["nullable"] = True
86
87
  return return_dict
87
88
 
89
+ elif origin is Literal and len(args) > 0:
90
+ LITERAL_TYPES = (int, float, str, bool, type(None))
91
+ args_types = []
92
+ for arg in args:
93
+ if type(arg) not in LITERAL_TYPES:
94
+ raise TypeHintParsingException("Only the valid python literals can be listed in typing.Literal.")
95
+ arg_type = _get_json_schema_type(type(arg)).get("type")
96
+ if arg_type is not None and arg_type not in args_types:
97
+ args_types.append(arg_type)
98
+ return {
99
+ "type": args_types.pop() if len(args_types) == 1 else list(args_types),
100
+ "enum": list(args),
101
+ }
102
+
88
103
  elif origin is list:
89
104
  if not args:
90
105
  return {"type": "array"}
@@ -100,13 +115,13 @@ def _parse_type_hint(hint: str) -> dict:
100
115
  f"The type hint {str(hint).replace('typing.', '')} is a Tuple with a single element, which "
101
116
  "we do not automatically convert to JSON schema as it is rarely necessary. If this input can contain "
102
117
  "more than one element, we recommend "
103
- "using a List[] type instead, or if it really is a single element, remove the Tuple[] wrapper and just "
118
+ "using a list[] type instead, or if it really is a single element, remove the tuple[] wrapper and just "
104
119
  "pass the element directly."
105
120
  )
106
121
  if ... in args:
107
122
  raise TypeHintParsingException(
108
123
  "Conversion of '...' is not supported in Tuple type hints. "
109
- "Use List[] types for variable-length"
124
+ "Use list[] types for variable-length"
110
125
  " inputs instead."
111
126
  )
112
127
  return {"type": "array", "prefixItems": [_parse_type_hint(t) for t in args]}
File without changes
ailoy/utils/image.py ADDED
@@ -0,0 +1,11 @@
1
+ import base64
2
+ import io
3
+
4
+ from PIL.Image import Image
5
+
6
+
7
+ def pillow_image_to_base64(img: Image):
8
+ buffered = io.BytesIO()
9
+ img.save(buffered, format=img.format)
10
+ b64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
11
+ return f"data:image/{img.format.lower()};base64,{b64}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ailoy-py
3
- Version: 0.0.2
3
+ Version: 0.0.5
4
4
  Summary: Python binding for Ailoy runtime APIs
5
5
  Author-Email: "Brekkylab Inc." <contact@brekkylab.com>
6
6
  License-Expression: Apache-2.0
@@ -18,6 +18,7 @@ Requires-Dist: anyio>=4.9.0
18
18
  Requires-Dist: jmespath>=1.0.1
19
19
  Requires-Dist: mcp>=1.8.0
20
20
  Requires-Dist: numpy>=2.0.2
21
+ Requires-Dist: pillow>=11.2.1
21
22
  Requires-Dist: pydantic>=2.11.4
22
23
  Requires-Dist: rich>=14.0.0
23
24
  Requires-Dist: typer>=0.15.4
@@ -38,14 +39,14 @@ pip install ailoy-py
38
39
  ## Quickstart
39
40
 
40
41
  ```python
41
- from ailoy import Runtime, Agent
42
+ from ailoy import Runtime, Agent, LocalModel
42
43
 
43
44
  # The runtime must be started to use Ailoy
44
45
  rt = Runtime()
45
46
 
46
47
  # Defines an agent
47
48
  # During this step, the model parameters are downloaded and the LLM is set up for execution
48
- with Agent(rt, model_name="Qwen/Qwen3-0.6B") as agent:
49
+ with Agent(rt, LocalModel("Qwen/Qwen3-0.6B")) as agent:
49
50
  # This is where the actual LLM call happens
50
51
  for resp in agent.query("Please give me a short poem about AI"):
51
52
  agent.print(resp)
@@ -0,0 +1,25 @@
1
+ ailoy/__init__.py,sha256=INeFsnHtrqTwkMKUPSdD_9l8DhudnW574e9rsRtrMJc,783
2
+ ailoy/agent.py,sha256=7v2E1ft1Aaufem9lxLEra3S-nX2fzeutJDgT-4LXwL8,26954
3
+ ailoy/ailoy_py.cpython-310-x86_64-linux-gnu.so,sha256=Tkdn4uQLJYq-0SO0dyFx6Q5zVWCA9qg7j5VyKWeQrKs,22810457
4
+ ailoy/ailoy_py.pyi,sha256=Yf90FEXkslpCpr1r2eqQ3-_1jLo65zmG94bBXDRqinU,991
5
+ ailoy/mcp.py,sha256=wVzXfwUh4UcU60PYq17kCFG7ZClmEDBRt8LxetuSkns,6800
6
+ ailoy/runtime.py,sha256=-75KawEMQSwxGvX5wtECVCWiTNdcHojsQ1e-OVB4IQ8,10545
7
+ ailoy/tools.py,sha256=hLdKe3TN_yn2qSbNG0uX2extokPIdJ6inLbSbhXdYTo,8861
8
+ ailoy/vector_store.py,sha256=ZfIuGYKv2dQmjOuDlSKDc-BBPlQ8no_70mZwnPzbBzo,7515
9
+ ailoy/cli/__main__.py,sha256=HnBVb2em1F2NLPeNX5r3xRndRrnGaXVCduo8WBULAI0,179
10
+ ailoy/cli/model.py,sha256=cerCHE-VY9TOwqRcLBtmqnV-5vphpvyhtrfPFZiTKCM,2979
11
+ ailoy/models/__init__.py,sha256=1AtlJV9gYThw_3snu0jPEH_aQGI74ip7ZcVJLtN5nMU,117
12
+ ailoy/models/api_model.py,sha256=kLDD1-R4hjgtTbIQkD-boT-ZK8UocmVyyHkI6tuxiFU,2090
13
+ ailoy/models/local_model.py,sha256=Iyur0UEUSbLKzptx9croP_OAF-qh9S-ZDukDthHNz9w,1206
14
+ ailoy/presets/tools/calculator.json,sha256=ePnZsjZChnvS08s9eVdIp4Bys_PlJBXPHCCjv6oMvzA,1040
15
+ ailoy/presets/tools/frankfurter.json,sha256=bZ5vhszf_aR-B_QN4L2xrI5nR-f4AMZk41UUDq1dTXg,1152
16
+ ailoy/presets/tools/nytimes.json,sha256=wrfe9bnAlSPzHladoGEX2oCAeE0wed3BvgXQ_Z2PdXg,918
17
+ ailoy/presets/tools/tmdb.json,sha256=UGLN5uAJ2b-Hu3nLcW95WXDLB3mfC3rBYfQANp_e8Ps,7046
18
+ ailoy/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
+ ailoy/utils/image.py,sha256=zkufequcQVTmIkArreYUyB8r2nrcOL_8O6KOv5B-yis,288
20
+ ailoy_py.libs/libgomp-870cb1d0.so.1.0.0,sha256=Ta6ZPLbakQH8LP74JzBt0DuJIBHS4nicjkSCjKnyWDw,253289
21
+ ailoy_py.libs/libtvm_runtime-c9b1997b.so,sha256=OLiYdpcijjzZc_5wzEpaEkxSiavSA5JCfuhcmiWWG-4,5617177
22
+ ailoy_py-0.0.5.dist-info/METADATA,sha256=WpOovIib4t4AWYNmIBRRixucdz9-wBetyxyDaLjzOk4,2053
23
+ ailoy_py-0.0.5.dist-info/WHEEL,sha256=zoVaZapIUnOpzt0hU8TDEPq4sy8rbtXAzSVdE0SY4gI,118
24
+ ailoy_py-0.0.5.dist-info/entry_points.txt,sha256=gVG45uDE6kef0wm6SEMYSgZgRNNRhSAeP2n2lPR00dI,50
25
+ ailoy_py-0.0.5.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: scikit-build-core 0.11.4
2
+ Generator: scikit-build-core 0.11.5
3
3
  Root-Is-Purelib: false
4
4
  Tag: cp310-cp310-manylinux_2_28_x86_64
5
5
 
Binary file
@@ -1,20 +0,0 @@
1
- ailoy/__init__.py,sha256=mzkLUc95OCc2okURWm9iA5xR8WZdxwvPgaanc9fwoH4,647
2
- ailoy/agent.py,sha256=uQ1o4CjQEO1vP7y0frGtVknN-A_iLNtkqt8h3vobgiM,27613
3
- ailoy/ailoy_py.cpython-310-x86_64-linux-gnu.so,sha256=vcWOM7sIPwrsURLwp2A8dp9ENkJzb3TuWN1XXuXSwC0,21241233
4
- ailoy/ailoy_py.pyi,sha256=Yf90FEXkslpCpr1r2eqQ3-_1jLo65zmG94bBXDRqinU,991
5
- ailoy/mcp.py,sha256=bC58tAWqhvMdZVCKHSdOVNUoAuYfZiou1hSH1oa_9Ag,6190
6
- ailoy/runtime.py,sha256=-75KawEMQSwxGvX5wtECVCWiTNdcHojsQ1e-OVB4IQ8,10545
7
- ailoy/tools.py,sha256=RnTfmWlqYY1q0V377CpAAyAK-yET7k45GgEhgM9G8eI,8207
8
- ailoy/vector_store.py,sha256=ZfIuGYKv2dQmjOuDlSKDc-BBPlQ8no_70mZwnPzbBzo,7515
9
- ailoy/cli/__main__.py,sha256=HnBVb2em1F2NLPeNX5r3xRndRrnGaXVCduo8WBULAI0,179
10
- ailoy/cli/model.py,sha256=cerCHE-VY9TOwqRcLBtmqnV-5vphpvyhtrfPFZiTKCM,2979
11
- ailoy/presets/tools/calculator.json,sha256=ePnZsjZChnvS08s9eVdIp4Bys_PlJBXPHCCjv6oMvzA,1040
12
- ailoy/presets/tools/frankfurter.json,sha256=bZ5vhszf_aR-B_QN4L2xrI5nR-f4AMZk41UUDq1dTXg,1152
13
- ailoy/presets/tools/nytimes.json,sha256=wrfe9bnAlSPzHladoGEX2oCAeE0wed3BvgXQ_Z2PdXg,918
14
- ailoy/presets/tools/tmdb.json,sha256=UGLN5uAJ2b-Hu3nLcW95WXDLB3mfC3rBYfQANp_e8Ps,7046
15
- ailoy_py.libs/libgomp-870cb1d0.so.1.0.0,sha256=Ta6ZPLbakQH8LP74JzBt0DuJIBHS4nicjkSCjKnyWDw,253289
16
- ailoy_py.libs/libtvm_runtime-2d14ca42.so,sha256=qPtn3HaKtxt-sL0wdu6Wqz7QsTmKY2ZWOPwO92TPfzU,5061889
17
- ailoy_py-0.0.2.dist-info/METADATA,sha256=B5RbxeITquJfdiw9bhA6w02q9OvLkuFH7jMRg6Lxc2A,2010
18
- ailoy_py-0.0.2.dist-info/WHEEL,sha256=VVjTMh1gjoiSjlh90KmkjL10Nq1LgP-3G8MTl99dyME,118
19
- ailoy_py-0.0.2.dist-info/entry_points.txt,sha256=gVG45uDE6kef0wm6SEMYSgZgRNNRhSAeP2n2lPR00dI,50
20
- ailoy_py-0.0.2.dist-info/RECORD,,
Binary file