ailoy-py 0.0.2__cp312-cp312-macosx_14_0_arm64.whl → 0.0.3__cp312-cp312-macosx_14_0_arm64.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.
Binary file
Binary file
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
 
@@ -308,22 +279,6 @@ class BearerAuthenticator(ToolAuthenticator):
308
279
  return {**request, "headers": headers}
309
280
 
310
281
 
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
282
  class Agent:
328
283
  """
329
284
  The `Agent` class provides a high-level interface for interacting with large language models (LLMs) in Ailoy.
@@ -337,28 +292,22 @@ class Agent:
337
292
  def __init__(
338
293
  self,
339
294
  runtime: Runtime,
340
- model_name: ModelName,
295
+ model: APIModel | LocalModel,
341
296
  system_message: Optional[str] = None,
342
- api_key: Optional[str] = None,
343
- **attrs,
344
297
  ):
345
298
  """
346
299
  Create an instance.
347
300
 
348
301
  :param runtime: The runtime environment associated with the agent.
349
- :param model_name: The name of the LLM model to use.
302
+ :param model: The model instance.
350
303
  :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
304
  :raises ValueError: If model name is not supported or validation fails.
354
305
  """
355
306
  self._runtime = runtime
356
307
 
357
308
  # Initialize component state
358
- self._component_state = ComponentState(
359
- name=generate_uuid(),
360
- valid=False,
361
- )
309
+ self._component_name = generate_uuid()
310
+ self._component_ready = False
362
311
 
363
312
  # Initialize messages
364
313
  self._messages: list[Message] = []
@@ -373,7 +322,7 @@ class Agent:
373
322
  self._mcp_servers: list[MCPServer] = []
374
323
 
375
324
  # Define the component
376
- self.define(model_name, api_key=api_key, **attrs)
325
+ self.define(model)
377
326
 
378
327
  def __del__(self):
379
328
  self.delete()
@@ -384,70 +333,55 @@ class Agent:
384
333
  def __exit__(self, type, value, traceback):
385
334
  self.delete()
386
335
 
387
- def define(self, model_name: ModelName, api_key: Optional[str] = None, **attrs) -> None:
336
+ def define(self, model: APIModel | LocalModel) -> None:
388
337
  """
389
338
  Initializes the agent by defining its model in the runtime.
390
339
  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)
340
+ :param model: The model instance.
394
341
  """
395
- if self._component_state.valid:
342
+ if self._component_ready:
396
343
  return
397
344
 
398
345
  if not self._runtime.is_alive():
399
346
  raise ValueError("Runtime is currently stopped.")
400
347
 
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
348
  # Set default system message if not given; still can be None
411
349
  if self._system_message is None:
412
- self._system_message = model_desc.default_system_message
350
+ self._system_message = getattr(model, "default_system_message", None)
413
351
 
414
352
  self.clear_messages()
415
353
 
416
- # Add API key
417
- if api_key:
418
- attrs["api_key"] = api_key
419
-
420
354
  # Call runtime's define
421
355
  self._runtime.define(
422
- model_descriptions[model_name].component_type,
423
- self._component_state.name,
424
- attrs,
356
+ model.component_type,
357
+ self._component_name,
358
+ model.to_attrs(),
425
359
  )
426
360
 
427
361
  # Mark as defined
428
- self._component_state.valid = True
362
+ self._component_ready = True
429
363
 
430
364
  def delete(self) -> None:
431
365
  """
432
366
  Deinitializes the agent and releases resources in the runtime.
433
367
  This should be called when the agent is no longer needed. If already deinitialized, this is a no-op.
434
368
  """
435
- if not self._component_state.valid:
369
+ if not self._component_ready:
436
370
  return
437
371
 
438
372
  if self._runtime.is_alive():
439
- self._runtime.delete(self._component_state.name)
373
+ self._runtime.delete(self._component_name)
440
374
 
441
375
  self.clear_messages()
442
376
 
443
377
  for mcp_server in self._mcp_servers:
444
378
  mcp_server.cleanup()
445
379
 
446
- self._component_state.valid = False
380
+ self._component_ready = False
447
381
 
448
382
  def query(
449
383
  self,
450
- message: str,
384
+ message: str | list[str | Image | dict | TextContent | ImageContent | AudioContent],
451
385
  reasoning: bool = False,
452
386
  ) -> Generator[AgentResponse, None, None]:
453
387
  """
@@ -458,13 +392,36 @@ class Agent:
458
392
  :return: An iterator over the output, where each item represents either a generated token from the assistant or a tool call.
459
393
  :rtype: Iterator[:class:`AgentResponse`]
460
394
  """ # noqa: E501
461
- if not self._component_state.valid:
395
+ if not self._component_ready:
462
396
  raise ValueError("Agent is not valid. Create one or define newly.")
463
397
 
464
398
  if not self._runtime.is_alive():
465
399
  raise ValueError("Runtime is currently stopped.")
466
400
 
467
- self._messages.append(UserMessage(role="user", content=[{"type": "text", "text": message}]))
401
+ if isinstance(message, str):
402
+ self._messages.append(UserMessage(content=[TextContent(text=message)]))
403
+ elif isinstance(message, list):
404
+ if len(message) == 0:
405
+ raise ValueError("Message is empty")
406
+
407
+ contents = []
408
+ for content in message:
409
+ if isinstance(content, str):
410
+ contents.append(TextContent(text=content))
411
+ elif isinstance(content, Image):
412
+ contents.append(ImageContent.from_pillow(image=content))
413
+ elif isinstance(content, dict):
414
+ ta: TypeAdapter[TextContent | ImageContent | AudioContent] = TypeAdapter(
415
+ Annotated[TextContent | ImageContent | AudioContent, Field(discriminator="type")]
416
+ )
417
+ validated_content = ta.validate_python(content)
418
+ contents.append(validated_content)
419
+ else:
420
+ contents.append(content)
421
+
422
+ self._messages.append(UserMessage(content=contents))
423
+ else:
424
+ raise ValueError(f"Invalid message type: {type(message)}")
468
425
 
469
426
  prev_resp_type = None
470
427
 
@@ -480,7 +437,7 @@ class Agent:
480
437
  assistant_content = None
481
438
  assistant_tool_calls = None
482
439
  finish_reason = ""
483
- for result in self._runtime.call_iter_method(self._component_state.name, "infer", infer_args):
440
+ for result in self._runtime.call_iter_method(self._component_name, "infer", infer_args):
484
441
  msg = MessageOutput.model_validate(result)
485
442
 
486
443
  if msg.message.reasoning:
@@ -491,13 +448,16 @@ class Agent:
491
448
  assistant_reasoning[0].text += v.text
492
449
  resp = AgentResponseOutputText(
493
450
  type="reasoning",
494
- role="assistant",
495
451
  is_type_switched=(prev_resp_type != "reasoning"),
496
452
  content=v.text,
497
453
  )
498
454
  prev_resp_type = resp.type
499
455
  yield resp
500
- if msg.message.content:
456
+ if msg.message.content is not None:
457
+ # Canonicalize message content to the array of TextContent
458
+ if isinstance(msg.message.content, str):
459
+ msg.message.content = [TextContent(text=msg.message.content)]
460
+
501
461
  for v in msg.message.content:
502
462
  if not assistant_content:
503
463
  assistant_content = [v]
@@ -505,7 +465,6 @@ class Agent:
505
465
  assistant_content[0].text += v.text
506
466
  resp = AgentResponseOutputText(
507
467
  type="output_text",
508
- role="assistant",
509
468
  is_type_switched=(prev_resp_type != "output_text"),
510
469
  content=v.text,
511
470
  )
@@ -518,8 +477,6 @@ class Agent:
518
477
  else:
519
478
  assistant_tool_calls.append(v)
520
479
  resp = AgentResponseToolCall(
521
- type="tool_call",
522
- role="assistant",
523
480
  is_type_switched=True,
524
481
  content=v,
525
482
  )
@@ -532,7 +489,6 @@ class Agent:
532
489
  # Append output
533
490
  self._messages.append(
534
491
  AssistantMessage(
535
- role="assistant",
536
492
  reasoning=assistant_reasoning,
537
493
  content=assistant_content,
538
494
  tool_calls=assistant_tool_calls,
@@ -550,18 +506,14 @@ class Agent:
550
506
  raise RuntimeError("Tool not found")
551
507
  tool_result = tool_.call(**tool_call.function.arguments)
552
508
  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,
509
+ content=[TextContent(text=json.dumps(tool_result))],
510
+ tool_call_id=tool_call.id,
557
511
  )
558
512
 
559
513
  tool_call_results = [run_tool(tc) for tc in assistant_tool_calls]
560
514
  for result_msg in tool_call_results:
561
515
  self._messages.append(result_msg)
562
516
  resp = AgentResponseToolResult(
563
- type="tool_call_result",
564
- role="tool",
565
517
  is_type_switched=True,
566
518
  content=result_msg,
567
519
  )
@@ -571,6 +523,7 @@ class Agent:
571
523
  continue
572
524
 
573
525
  # Finish this generator
526
+ yield AgentResponseOutputText(type="output_text", content="\n")
574
527
  break
575
528
 
576
529
  def get_messages(self) -> list[Message]:
@@ -589,9 +542,7 @@ class Agent:
589
542
  """
590
543
  self._messages.clear()
591
544
  if self._system_message is not None:
592
- self._messages.append(
593
- SystemMessage(role="system", content=[TextData(type="text", text=self._system_message)])
594
- )
545
+ self._messages.append(SystemMessage(role="system", content=[TextContent(text=self._system_message)]))
595
546
 
596
547
  def print(self, resp: AgentResponse):
597
548
  resp.print()
@@ -779,7 +730,7 @@ class Agent:
779
730
  continue
780
731
 
781
732
  desc = ToolDescription(
782
- name=f"{name}/{tool.name}", description=tool.description, parameters=tool.inputSchema
733
+ name=f"{name}-{tool.name}", description=tool.description, parameters=tool.inputSchema
783
734
  )
784
735
 
785
736
  def call(tool: MCPTool, **inputs: dict[str, Any]) -> list[str]:
@@ -803,4 +754,16 @@ class Agent:
803
754
  mcp_server.cleanup()
804
755
 
805
756
  # 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))
757
+ self._tools = list(filter(lambda t: not t.desc.name.startswith(f"{mcp_server.name}-"), self._tools))
758
+
759
+ def get_tools(self):
760
+ """
761
+ Get the list of registered tools.
762
+ """
763
+ return self._tools
764
+
765
+ def clear_tools(self):
766
+ """
767
+ Clear the registered tools.
768
+ """
769
+ self._tools.clear()
Binary file
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,71 @@
1
+ from typing import Literal, Optional, Self, 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
+ APIModelProvider = Literal["openai", "gemini", "claude"]
38
+
39
+
40
+ @dataclass
41
+ class APIModel:
42
+ id: OpenAIModelId | GeminiModelId | ClaudeModelId | str
43
+ api_key: str
44
+ provider: Optional[APIModelProvider] = None
45
+
46
+ @model_validator(mode="after")
47
+ def validate_provider(self) -> Self:
48
+ if self.provider is None:
49
+ if self.id in get_args(OpenAIModelId):
50
+ self.provider = "openai"
51
+ elif self.id in get_args(GeminiModelId):
52
+ self.provider = "gemini"
53
+ elif self.id in get_args(ClaudeModelId):
54
+ self.provider = "claude"
55
+ else:
56
+ raise ValueError(
57
+ f'Failed to infer the model provider based on the model id "{self.id}". '
58
+ "Please provide an explicit model provider."
59
+ )
60
+
61
+ return self
62
+
63
+ @property
64
+ def component_type(self) -> str:
65
+ return self.provider
66
+
67
+ def to_attrs(self):
68
+ return {
69
+ "model": self.id,
70
+ "api_key": self.api_key,
71
+ }
@@ -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}")
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.3
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/ailoy_py.cpython-312-darwin.so,sha256=ibnFI77OPkrq4X0SDy2FiB04pEtWHQ-av5oi-LaGyr8,15149856
2
+ ailoy/ailoy_py.pyi,sha256=Yf90FEXkslpCpr1r2eqQ3-_1jLo65zmG94bBXDRqinU,991
3
+ ailoy/vector_store.py,sha256=ZfIuGYKv2dQmjOuDlSKDc-BBPlQ8no_70mZwnPzbBzo,7515
4
+ ailoy/tools.py,sha256=RnTfmWlqYY1q0V377CpAAyAK-yET7k45GgEhgM9G8eI,8207
5
+ ailoy/__init__.py,sha256=INeFsnHtrqTwkMKUPSdD_9l8DhudnW574e9rsRtrMJc,783
6
+ ailoy/runtime.py,sha256=-75KawEMQSwxGvX5wtECVCWiTNdcHojsQ1e-OVB4IQ8,10545
7
+ ailoy/mcp.py,sha256=wVzXfwUh4UcU60PYq17kCFG7ZClmEDBRt8LxetuSkns,6800
8
+ ailoy/agent.py,sha256=F15s3FzyrQ3pfNb0ZCLOPV2mrFDNCcFbYFP02hEbAk4,26780
9
+ ailoy/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ ailoy/utils/image.py,sha256=zkufequcQVTmIkArreYUyB8r2nrcOL_8O6KOv5B-yis,288
11
+ ailoy/models/__init__.py,sha256=1AtlJV9gYThw_3snu0jPEH_aQGI74ip7ZcVJLtN5nMU,117
12
+ ailoy/models/local_model.py,sha256=Iyur0UEUSbLKzptx9croP_OAF-qh9S-ZDukDthHNz9w,1206
13
+ ailoy/models/api_model.py,sha256=_-2TSunBh8kZQOF1CkQXBKi81XNClPWH4U5hQA7ioQ4,1787
14
+ ailoy/cli/model.py,sha256=cerCHE-VY9TOwqRcLBtmqnV-5vphpvyhtrfPFZiTKCM,2979
15
+ ailoy/cli/__main__.py,sha256=HnBVb2em1F2NLPeNX5r3xRndRrnGaXVCduo8WBULAI0,179
16
+ ailoy/.dylibs/libomp.dylib,sha256=s963nLMIJ2lfA448nbin9lL52fnoeGySlgLfm4Y3HHs,735616
17
+ ailoy/.dylibs/libtvm_runtime.dylib,sha256=Fqp5JsiIMAsS7p94oDdxY_-kAqbwGpBGpkS2LhYfhZs,4620384
18
+ ailoy/presets/tools/tmdb.json,sha256=UGLN5uAJ2b-Hu3nLcW95WXDLB3mfC3rBYfQANp_e8Ps,7046
19
+ ailoy/presets/tools/calculator.json,sha256=ePnZsjZChnvS08s9eVdIp4Bys_PlJBXPHCCjv6oMvzA,1040
20
+ ailoy/presets/tools/nytimes.json,sha256=wrfe9bnAlSPzHladoGEX2oCAeE0wed3BvgXQ_Z2PdXg,918
21
+ ailoy/presets/tools/frankfurter.json,sha256=bZ5vhszf_aR-B_QN4L2xrI5nR-f4AMZk41UUDq1dTXg,1152
22
+ ailoy_py-0.0.3.dist-info/RECORD,,
23
+ ailoy_py-0.0.3.dist-info/WHEEL,sha256=QOe84RDUunyjg7JwgI5YbDt4ixoQu4ZhxxlOWcSiCqI,141
24
+ ailoy_py-0.0.3.dist-info/entry_points.txt,sha256=gVG45uDE6kef0wm6SEMYSgZgRNNRhSAeP2n2lPR00dI,50
25
+ ailoy_py-0.0.3.dist-info/METADATA,sha256=ZvX6pFs2Fb3uBmm_73Cq3rtYs52pfjd5_cEVZSluK68,2053
@@ -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: cp312-cp312-macosx_14_0_arm64
5
5
  Generator: delocate 0.13.0
@@ -1,20 +0,0 @@
1
- ailoy/ailoy_py.cpython-312-darwin.so,sha256=6G8_ANcUX79PwlaN3G9cglXLeE-2zAdP96xAJVmBVx4,14964576
2
- ailoy/ailoy_py.pyi,sha256=Yf90FEXkslpCpr1r2eqQ3-_1jLo65zmG94bBXDRqinU,991
3
- ailoy/vector_store.py,sha256=ZfIuGYKv2dQmjOuDlSKDc-BBPlQ8no_70mZwnPzbBzo,7515
4
- ailoy/tools.py,sha256=RnTfmWlqYY1q0V377CpAAyAK-yET7k45GgEhgM9G8eI,8207
5
- ailoy/__init__.py,sha256=mzkLUc95OCc2okURWm9iA5xR8WZdxwvPgaanc9fwoH4,647
6
- ailoy/runtime.py,sha256=-75KawEMQSwxGvX5wtECVCWiTNdcHojsQ1e-OVB4IQ8,10545
7
- ailoy/mcp.py,sha256=bC58tAWqhvMdZVCKHSdOVNUoAuYfZiou1hSH1oa_9Ag,6190
8
- ailoy/agent.py,sha256=uQ1o4CjQEO1vP7y0frGtVknN-A_iLNtkqt8h3vobgiM,27613
9
- ailoy/cli/model.py,sha256=cerCHE-VY9TOwqRcLBtmqnV-5vphpvyhtrfPFZiTKCM,2979
10
- ailoy/cli/__main__.py,sha256=HnBVb2em1F2NLPeNX5r3xRndRrnGaXVCduo8WBULAI0,179
11
- ailoy/.dylibs/libomp.dylib,sha256=GX9Rol1LBJAoha7wZJMQxuCUi_o41Yo5QLBTMN85rRI,735616
12
- ailoy/.dylibs/libtvm_runtime.dylib,sha256=uEIIGsPZAY0SSfGtqSeyHljo2QsjBrd3CSGZSpwPwd8,3678320
13
- ailoy/presets/tools/tmdb.json,sha256=UGLN5uAJ2b-Hu3nLcW95WXDLB3mfC3rBYfQANp_e8Ps,7046
14
- ailoy/presets/tools/calculator.json,sha256=ePnZsjZChnvS08s9eVdIp4Bys_PlJBXPHCCjv6oMvzA,1040
15
- ailoy/presets/tools/nytimes.json,sha256=wrfe9bnAlSPzHladoGEX2oCAeE0wed3BvgXQ_Z2PdXg,918
16
- ailoy/presets/tools/frankfurter.json,sha256=bZ5vhszf_aR-B_QN4L2xrI5nR-f4AMZk41UUDq1dTXg,1152
17
- ailoy_py-0.0.2.dist-info/RECORD,,
18
- ailoy_py-0.0.2.dist-info/WHEEL,sha256=eIeGDHdBM78ikRuuQz8ZKfrdHQwszxUfpZIUvykpuZI,141
19
- ailoy_py-0.0.2.dist-info/entry_points.txt,sha256=gVG45uDE6kef0wm6SEMYSgZgRNNRhSAeP2n2lPR00dI,50
20
- ailoy_py-0.0.2.dist-info/METADATA,sha256=B5RbxeITquJfdiw9bhA6w02q9OvLkuFH7jMRg6Lxc2A,2010