mycode-sdk 0.8.9__tar.gz → 0.9.0__tar.gz
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.
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/PKG-INFO +32 -4
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/README.md +29 -3
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/pyproject.toml +3 -1
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/__init__.py +6 -5
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/agent.py +26 -7
- mycode_sdk-0.9.0/src/mycode/attachments.py +175 -0
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/models_catalog.json +146 -328
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/anthropic_like.py +21 -20
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/base.py +21 -17
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/gemini.py +1 -0
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/openai_chat.py +46 -44
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/openai_responses.py +129 -98
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/session.py +51 -15
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/tools.py +368 -449
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/utils.py +10 -0
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/.gitignore +0 -0
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/LICENSE +0 -0
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/compact.py +0 -0
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/hooks.py +0 -0
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/messages.py +0 -0
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/models.py +0 -0
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/__init__.py +0 -0
- {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mycode-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Lightweight Python SDK for building AI agents.
|
|
5
5
|
Project-URL: Homepage, https://github.com/legibet/mycode
|
|
6
6
|
Project-URL: Repository, https://github.com/legibet/mycode
|
|
@@ -20,7 +20,9 @@ Classifier: Topic :: Software Development
|
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Requires-Dist: anthropic>=0.102.0
|
|
22
22
|
Requires-Dist: google-genai>=2.3.0
|
|
23
|
+
Requires-Dist: griffelib>=2.0.2
|
|
23
24
|
Requires-Dist: openai>=2.36.0
|
|
25
|
+
Requires-Dist: pydantic>=2.13.4
|
|
24
26
|
Description-Content-Type: text/markdown
|
|
25
27
|
|
|
26
28
|
# mycode-sdk
|
|
@@ -78,6 +80,28 @@ agent.run("What is 2 + 2?")
|
|
|
78
80
|
agent.run("Now multiply that by 10.") # remembers the earlier answer
|
|
79
81
|
```
|
|
80
82
|
|
|
83
|
+
## Attachments
|
|
84
|
+
|
|
85
|
+
Pass `attachments` to `achat()` or `run()` to add files alongside the prompt:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from mycode import Attachment
|
|
89
|
+
|
|
90
|
+
agent.run("Describe these.", attachments=["diagram.png", "report.pdf", "notes.txt"])
|
|
91
|
+
|
|
92
|
+
# Or build them explicitly — raw bytes and inline text never touch the disk:
|
|
93
|
+
agent.run(
|
|
94
|
+
"Review.",
|
|
95
|
+
attachments=[
|
|
96
|
+
Attachment.path("diagram.png"),
|
|
97
|
+
Attachment.bytes(png_data, media_type="image/png"),
|
|
98
|
+
Attachment.text("TODO: ship it", name="note.md"),
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Images support `image/png`, `image/jpeg`, `image/gif`, `image/webp`; documents support `application/pdf`. Sending an image or PDF to a model that lacks that capability yields an `error` event; a bad path or unsupported type raises `ValueError` before the model is called.
|
|
104
|
+
|
|
81
105
|
## Saving sessions
|
|
82
106
|
|
|
83
107
|
Pass `session_dir` to persist the conversation to disk. Each session lives in a subdirectory named by `session_id`:
|
|
@@ -105,7 +129,7 @@ Four tools for reading, writing, editing files, and running shell commands. Opt
|
|
|
105
129
|
|
|
106
130
|
## Custom tools
|
|
107
131
|
|
|
108
|
-
Decorate
|
|
132
|
+
Decorate a typed function with `@tool`:
|
|
109
133
|
|
|
110
134
|
```python
|
|
111
135
|
from mycode import Agent, tool
|
|
@@ -113,7 +137,11 @@ from mycode import Agent, tool
|
|
|
113
137
|
|
|
114
138
|
@tool
|
|
115
139
|
def greet(name: str) -> str:
|
|
116
|
-
"""Return a friendly greeting.
|
|
140
|
+
"""Return a friendly greeting.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
name: Person name.
|
|
144
|
+
"""
|
|
117
145
|
|
|
118
146
|
return f"hello, {name}"
|
|
119
147
|
|
|
@@ -125,7 +153,7 @@ agent = Agent(
|
|
|
125
153
|
)
|
|
126
154
|
```
|
|
127
155
|
|
|
128
|
-
To call a built-in tool from inside your own, type the first parameter as `ToolContext`:
|
|
156
|
+
To call a built-in tool from inside your own tool, type the first parameter as `ToolContext`:
|
|
129
157
|
|
|
130
158
|
```python
|
|
131
159
|
from mycode import ToolContext, tool
|
|
@@ -53,6 +53,28 @@ agent.run("What is 2 + 2?")
|
|
|
53
53
|
agent.run("Now multiply that by 10.") # remembers the earlier answer
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
## Attachments
|
|
57
|
+
|
|
58
|
+
Pass `attachments` to `achat()` or `run()` to add files alongside the prompt:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from mycode import Attachment
|
|
62
|
+
|
|
63
|
+
agent.run("Describe these.", attachments=["diagram.png", "report.pdf", "notes.txt"])
|
|
64
|
+
|
|
65
|
+
# Or build them explicitly — raw bytes and inline text never touch the disk:
|
|
66
|
+
agent.run(
|
|
67
|
+
"Review.",
|
|
68
|
+
attachments=[
|
|
69
|
+
Attachment.path("diagram.png"),
|
|
70
|
+
Attachment.bytes(png_data, media_type="image/png"),
|
|
71
|
+
Attachment.text("TODO: ship it", name="note.md"),
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Images support `image/png`, `image/jpeg`, `image/gif`, `image/webp`; documents support `application/pdf`. Sending an image or PDF to a model that lacks that capability yields an `error` event; a bad path or unsupported type raises `ValueError` before the model is called.
|
|
77
|
+
|
|
56
78
|
## Saving sessions
|
|
57
79
|
|
|
58
80
|
Pass `session_dir` to persist the conversation to disk. Each session lives in a subdirectory named by `session_id`:
|
|
@@ -80,7 +102,7 @@ Four tools for reading, writing, editing files, and running shell commands. Opt
|
|
|
80
102
|
|
|
81
103
|
## Custom tools
|
|
82
104
|
|
|
83
|
-
Decorate
|
|
105
|
+
Decorate a typed function with `@tool`:
|
|
84
106
|
|
|
85
107
|
```python
|
|
86
108
|
from mycode import Agent, tool
|
|
@@ -88,7 +110,11 @@ from mycode import Agent, tool
|
|
|
88
110
|
|
|
89
111
|
@tool
|
|
90
112
|
def greet(name: str) -> str:
|
|
91
|
-
"""Return a friendly greeting.
|
|
113
|
+
"""Return a friendly greeting.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
name: Person name.
|
|
117
|
+
"""
|
|
92
118
|
|
|
93
119
|
return f"hello, {name}"
|
|
94
120
|
|
|
@@ -100,7 +126,7 @@ agent = Agent(
|
|
|
100
126
|
)
|
|
101
127
|
```
|
|
102
128
|
|
|
103
|
-
To call a built-in tool from inside your own, type the first parameter as `ToolContext`:
|
|
129
|
+
To call a built-in tool from inside your own tool, type the first parameter as `ToolContext`:
|
|
104
130
|
|
|
105
131
|
```python
|
|
106
132
|
from mycode import ToolContext, tool
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mycode-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
description = "Lightweight Python SDK for building AI agents."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -25,7 +25,9 @@ keywords = ["agent", "llm", "anthropic", "openai", "gemini", "sdk"]
|
|
|
25
25
|
dependencies = [
|
|
26
26
|
"anthropic>=0.102.0",
|
|
27
27
|
"google-genai>=2.3.0",
|
|
28
|
+
"griffelib>=2.0.2",
|
|
28
29
|
"openai>=2.36.0",
|
|
30
|
+
"pydantic>=2.13.4",
|
|
29
31
|
]
|
|
30
32
|
|
|
31
33
|
[project.urls]
|
|
@@ -10,6 +10,7 @@ silently exposing file system and shell access.
|
|
|
10
10
|
from importlib import metadata
|
|
11
11
|
|
|
12
12
|
from mycode.agent import Agent, Event, PersistCallback, RunResult
|
|
13
|
+
from mycode.attachments import Attachment
|
|
13
14
|
from mycode.hooks import AfterToolHook, BeforeToolHook, HookResult, Hooks, ToolHookContext
|
|
14
15
|
from mycode.messages import (
|
|
15
16
|
ContentBlock,
|
|
@@ -27,25 +28,25 @@ from mycode.messages import (
|
|
|
27
28
|
)
|
|
28
29
|
from mycode.session import SessionStore
|
|
29
30
|
from mycode.tools import (
|
|
30
|
-
DEFAULT_TOOL_SPECS,
|
|
31
31
|
ToolContext,
|
|
32
32
|
ToolExecutionResult,
|
|
33
33
|
ToolExecutor,
|
|
34
34
|
ToolSpec,
|
|
35
|
+
bash_tool,
|
|
35
36
|
cancel_all_tools,
|
|
37
|
+
edit_tool,
|
|
38
|
+
read_tool,
|
|
36
39
|
tool,
|
|
40
|
+
write_tool,
|
|
37
41
|
)
|
|
38
42
|
|
|
39
|
-
# The package metadata in mycode/pyproject.toml is the single version source.
|
|
40
43
|
__version__ = metadata.version("mycode-sdk")
|
|
41
44
|
|
|
42
|
-
read_tool, write_tool, edit_tool, bash_tool = DEFAULT_TOOL_SPECS
|
|
43
|
-
|
|
44
45
|
__all__ = [
|
|
45
46
|
"Agent",
|
|
47
|
+
"Attachment",
|
|
46
48
|
"ContentBlock",
|
|
47
49
|
"ConversationMessage",
|
|
48
|
-
"DEFAULT_TOOL_SPECS",
|
|
49
50
|
"Event",
|
|
50
51
|
"AfterToolHook",
|
|
51
52
|
"BeforeToolHook",
|
|
@@ -18,10 +18,10 @@ from pathlib import Path
|
|
|
18
18
|
from typing import Any, cast
|
|
19
19
|
from uuid import uuid4
|
|
20
20
|
|
|
21
|
+
from mycode.attachments import AttachmentLike, build_attachment_blocks
|
|
21
22
|
from mycode.compact import (
|
|
22
23
|
COMPACT_SUMMARY_PROMPT,
|
|
23
24
|
DEFAULT_COMPACT_THRESHOLD,
|
|
24
|
-
apply_compact_replay,
|
|
25
25
|
build_compact_event,
|
|
26
26
|
should_compact,
|
|
27
27
|
)
|
|
@@ -79,6 +79,7 @@ class Agent:
|
|
|
79
79
|
messages: list[ConversationMessage] | None = None,
|
|
80
80
|
max_turns: int | None = None,
|
|
81
81
|
max_tokens: int | None = None,
|
|
82
|
+
temperature: float = 1.0,
|
|
82
83
|
context_window: int | None = None,
|
|
83
84
|
compact_threshold: float | None = None,
|
|
84
85
|
reasoning_effort: str | None = None,
|
|
@@ -111,6 +112,16 @@ class Agent:
|
|
|
111
112
|
self.api_key = api_key
|
|
112
113
|
self.api_base = api_base
|
|
113
114
|
self.max_turns = max_turns
|
|
115
|
+
if not 0 <= temperature <= 1:
|
|
116
|
+
raise ValueError("temperature must be between 0 and 1")
|
|
117
|
+
if (
|
|
118
|
+
provider in {"anthropic", "moonshotai", "minimax"}
|
|
119
|
+
and reasoning_effort
|
|
120
|
+
and reasoning_effort != "none"
|
|
121
|
+
and temperature != 1.0
|
|
122
|
+
):
|
|
123
|
+
raise ValueError(f"{provider} does not support custom temperature when thinking is enabled")
|
|
124
|
+
self.temperature = float(temperature)
|
|
114
125
|
self.compact_threshold = compact_threshold if compact_threshold is not None else DEFAULT_COMPACT_THRESHOLD
|
|
115
126
|
self.reasoning_effort = reasoning_effort
|
|
116
127
|
|
|
@@ -431,6 +442,7 @@ class Agent:
|
|
|
431
442
|
self,
|
|
432
443
|
user_input: str | ConversationMessage,
|
|
433
444
|
*,
|
|
445
|
+
attachments: Sequence[AttachmentLike] = (),
|
|
434
446
|
on_persist: PersistCallback | None = None,
|
|
435
447
|
) -> AsyncIterator[Event]:
|
|
436
448
|
"""Run the full agent loop for one user message."""
|
|
@@ -462,6 +474,10 @@ class Agent:
|
|
|
462
474
|
if isinstance(raw_meta, dict):
|
|
463
475
|
user_message["meta"] = {str(k): v for k, v in raw_meta.items()}
|
|
464
476
|
|
|
477
|
+
if attachments:
|
|
478
|
+
blocks = await asyncio.to_thread(build_attachment_blocks, attachments, cwd=self.cwd)
|
|
479
|
+
user_message["content"] = list(user_message.get("content") or []) + blocks
|
|
480
|
+
|
|
465
481
|
content_blocks = user_message.get("content") or []
|
|
466
482
|
if not self.supports_image_input and any(
|
|
467
483
|
isinstance(block, dict) and block.get("type") == "image" for block in content_blocks
|
|
@@ -495,15 +511,17 @@ class Agent:
|
|
|
495
511
|
provider=self.provider,
|
|
496
512
|
model=self.model,
|
|
497
513
|
session_id=self.session_id,
|
|
498
|
-
messages=
|
|
514
|
+
messages=self.messages,
|
|
499
515
|
system=self.system,
|
|
500
516
|
tools=self.tools.definitions,
|
|
501
517
|
max_tokens=self.max_tokens,
|
|
518
|
+
temperature=self.temperature,
|
|
502
519
|
api_key=self.api_key,
|
|
503
520
|
api_base=self.api_base,
|
|
504
521
|
reasoning_effort=self.reasoning_effort,
|
|
505
522
|
supports_image_input=self.supports_image_input,
|
|
506
523
|
supports_pdf_input=self.supports_pdf_input,
|
|
524
|
+
transcript_path=self.transcript_path,
|
|
507
525
|
)
|
|
508
526
|
|
|
509
527
|
try:
|
|
@@ -682,6 +700,7 @@ class Agent:
|
|
|
682
700
|
self,
|
|
683
701
|
user_input: str | ConversationMessage,
|
|
684
702
|
*,
|
|
703
|
+
attachments: Sequence[AttachmentLike] = (),
|
|
685
704
|
on_persist: PersistCallback | None = None,
|
|
686
705
|
) -> RunResult:
|
|
687
706
|
"""Run one user turn synchronously and collect the streamed result."""
|
|
@@ -695,7 +714,7 @@ class Agent:
|
|
|
695
714
|
|
|
696
715
|
async def collect() -> RunResult:
|
|
697
716
|
result = RunResult()
|
|
698
|
-
async for event in self.achat(user_input, on_persist=on_persist):
|
|
717
|
+
async for event in self.achat(user_input, attachments=attachments, on_persist=on_persist):
|
|
699
718
|
result.events.append(event)
|
|
700
719
|
if event.type == "text":
|
|
701
720
|
result.text += str(event.data.get("delta") or "")
|
|
@@ -716,21 +735,21 @@ class Agent:
|
|
|
716
735
|
) -> None:
|
|
717
736
|
"""Ask the provider for a summary, persist the compact event, append it."""
|
|
718
737
|
|
|
719
|
-
compact_messages = apply_compact_replay(self.messages, transcript_path=self.transcript_path) + [
|
|
720
|
-
user_text_message(COMPACT_SUMMARY_PROMPT)
|
|
721
|
-
]
|
|
722
738
|
request = ProviderRequest(
|
|
723
739
|
provider=self.provider,
|
|
724
740
|
model=self.model,
|
|
725
741
|
session_id=self.session_id,
|
|
726
|
-
messages=
|
|
742
|
+
messages=self.messages,
|
|
727
743
|
system=self.system,
|
|
728
744
|
tools=[],
|
|
729
745
|
max_tokens=min(self.max_tokens, 8192),
|
|
746
|
+
temperature=self.temperature,
|
|
730
747
|
api_key=self.api_key,
|
|
731
748
|
api_base=self.api_base,
|
|
732
749
|
supports_image_input=self.supports_image_input,
|
|
733
750
|
supports_pdf_input=self.supports_pdf_input,
|
|
751
|
+
transcript_path=self.transcript_path,
|
|
752
|
+
append_messages=[user_text_message(COMPACT_SUMMARY_PROMPT)],
|
|
734
753
|
)
|
|
735
754
|
|
|
736
755
|
summary_message: ConversationMessage | None = None
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Attachment input for user messages.
|
|
2
|
+
|
|
3
|
+
Conversion rules in one place so the SDK, CLI, and HTTP server all produce the
|
|
4
|
+
same content blocks for the same input.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import html
|
|
10
|
+
from base64 import b64encode
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from mimetypes import guess_type
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Self
|
|
16
|
+
|
|
17
|
+
from mycode.messages import ContentBlock, document_block, image_block, text_block
|
|
18
|
+
from mycode.utils import resolve_path
|
|
19
|
+
|
|
20
|
+
SUPPORTED_IMAGE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
|
|
21
|
+
SUPPORTED_DOCUMENT_MIME_TYPES = frozenset({"application/pdf"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, kw_only=True)
|
|
25
|
+
class Attachment:
|
|
26
|
+
"""File path, raw bytes, or text snippet attached to a user message.
|
|
27
|
+
|
|
28
|
+
Construct via ``Attachment.path`` / ``.bytes`` / ``.text``; ``kw_only`` stops a
|
|
29
|
+
bare ``Attachment("notes.txt")`` from silently becoming text instead of a path.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
source: Path | bytes | str
|
|
33
|
+
media_type: str | None = None
|
|
34
|
+
name: str | None = None
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def path(cls, path: str | Path, *, name: str | None = None) -> Self:
|
|
38
|
+
"""``name`` defaults to the file's basename when omitted."""
|
|
39
|
+
|
|
40
|
+
return cls(source=Path(path), name=name)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def bytes(cls, data: bytes, *, media_type: str, name: str | None = None) -> Self:
|
|
44
|
+
"""``media_type`` must be a supported image type or ``application/pdf``."""
|
|
45
|
+
|
|
46
|
+
if not media_type:
|
|
47
|
+
raise ValueError("Attachment.bytes requires a non-empty media_type")
|
|
48
|
+
return cls(source=bytes(data), media_type=media_type, name=name)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def text(cls, data: str, *, name: str) -> Self:
|
|
52
|
+
"""Wrapped as a named ``<file>`` snippet in the user message."""
|
|
53
|
+
|
|
54
|
+
if not name:
|
|
55
|
+
raise ValueError("Attachment.text requires a non-empty name")
|
|
56
|
+
return cls(source=data, name=name)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
AttachmentLike = str | Path | Attachment
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_attachment_blocks(
|
|
63
|
+
attachments: Sequence[AttachmentLike],
|
|
64
|
+
*,
|
|
65
|
+
cwd: str,
|
|
66
|
+
) -> list[ContentBlock]:
|
|
67
|
+
"""Return one content block per attachment, in input order.
|
|
68
|
+
|
|
69
|
+
``str`` / ``Path`` items are treated as ``Attachment.path``. Raises
|
|
70
|
+
``ValueError`` on a missing path, a directory, a binary file that is
|
|
71
|
+
neither image nor PDF, undecodable text, or an unsupported ``media_type``.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
blocks: list[ContentBlock] = []
|
|
75
|
+
for item in attachments:
|
|
76
|
+
att = item if isinstance(item, Attachment) else Attachment.path(item)
|
|
77
|
+
match att.source:
|
|
78
|
+
case bytes() as data:
|
|
79
|
+
media_type = att.media_type or ""
|
|
80
|
+
if media_type in SUPPORTED_IMAGE_MIME_TYPES:
|
|
81
|
+
blocks.append(image_block(b64encode(data).decode("ascii"), mime_type=media_type, name=att.name))
|
|
82
|
+
elif media_type in SUPPORTED_DOCUMENT_MIME_TYPES:
|
|
83
|
+
blocks.append(document_block(b64encode(data).decode("ascii"), mime_type=media_type, name=att.name))
|
|
84
|
+
else:
|
|
85
|
+
supported = sorted(SUPPORTED_IMAGE_MIME_TYPES | SUPPORTED_DOCUMENT_MIME_TYPES)
|
|
86
|
+
raise ValueError(f"unsupported media_type {media_type!r}; want one of {supported}")
|
|
87
|
+
case Path() as raw:
|
|
88
|
+
path = Path(resolve_path(str(raw), cwd=cwd))
|
|
89
|
+
if not path.exists():
|
|
90
|
+
raise ValueError(f"attachment not found: {raw}")
|
|
91
|
+
if path.is_dir():
|
|
92
|
+
raise ValueError(f"attachment is a directory: {raw}")
|
|
93
|
+
display = att.name or path.name
|
|
94
|
+
if mime := detect_image_mime_type(path):
|
|
95
|
+
blocks.append(
|
|
96
|
+
image_block(b64encode(path.read_bytes()).decode("ascii"), mime_type=mime, name=display)
|
|
97
|
+
)
|
|
98
|
+
elif mime := detect_document_mime_type(path):
|
|
99
|
+
blocks.append(
|
|
100
|
+
document_block(b64encode(path.read_bytes()).decode("ascii"), mime_type=mime, name=display)
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
try:
|
|
104
|
+
text = path.read_text(encoding="utf-8")
|
|
105
|
+
except UnicodeDecodeError as exc:
|
|
106
|
+
raise ValueError(f"unsupported attachment {raw}: not image, PDF, or UTF-8 text") from exc
|
|
107
|
+
blocks.append(_wrap_file(text, display))
|
|
108
|
+
case str() as text:
|
|
109
|
+
blocks.append(_wrap_file(text, att.name or ""))
|
|
110
|
+
return blocks
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def unsupported_attachment_block(
|
|
114
|
+
*,
|
|
115
|
+
name: str,
|
|
116
|
+
mime_type: str,
|
|
117
|
+
kind: str,
|
|
118
|
+
path: str | None = None,
|
|
119
|
+
) -> ContentBlock:
|
|
120
|
+
"""Text-block stand-in for an image/PDF block a model can't ingest.
|
|
121
|
+
|
|
122
|
+
Pass ``path`` (e.g. the original ``@path`` typed by the user) to keep it on
|
|
123
|
+
``meta.path`` for UI labels; omit it when replaying history where the
|
|
124
|
+
on-disk path is no longer authoritative.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
label = "image input" if kind == "image" else "PDF input"
|
|
128
|
+
safe_name = html.escape(name, quote=True)
|
|
129
|
+
safe_mime = html.escape(mime_type, quote=True)
|
|
130
|
+
body = f"Current model does not support {label}."
|
|
131
|
+
meta: dict[str, object] = {"attachment": True}
|
|
132
|
+
if path is not None:
|
|
133
|
+
meta["path"] = path
|
|
134
|
+
return text_block(
|
|
135
|
+
f'<file name="{safe_name}" media_type="{safe_mime}" kind="{kind}">{body}</file>',
|
|
136
|
+
meta=meta,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def detect_image_mime_type(path: Path) -> str | None:
|
|
141
|
+
"""Sniff PNG/JPEG/GIF/WebP magic bytes; fall back to the filename extension."""
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
with path.open("rb") as file:
|
|
145
|
+
header = file.read(16)
|
|
146
|
+
except OSError:
|
|
147
|
+
return None
|
|
148
|
+
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
149
|
+
return "image/png"
|
|
150
|
+
if header.startswith(b"\xff\xd8\xff"):
|
|
151
|
+
return "image/jpeg"
|
|
152
|
+
if header.startswith((b"GIF87a", b"GIF89a")):
|
|
153
|
+
return "image/gif"
|
|
154
|
+
if header.startswith(b"RIFF") and header[8:12] == b"WEBP":
|
|
155
|
+
return "image/webp"
|
|
156
|
+
guessed, _ = guess_type(path.name)
|
|
157
|
+
return guessed if guessed in SUPPORTED_IMAGE_MIME_TYPES else None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def detect_document_mime_type(path: Path) -> str | None:
|
|
161
|
+
"""Return ``application/pdf`` if the file is a PDF (by magic bytes or extension)."""
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
with path.open("rb") as file:
|
|
165
|
+
if file.read(5).startswith(b"%PDF-"):
|
|
166
|
+
return "application/pdf"
|
|
167
|
+
except OSError:
|
|
168
|
+
pass
|
|
169
|
+
guessed, _ = guess_type(path.name)
|
|
170
|
+
return "application/pdf" if guessed == "application/pdf" else None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _wrap_file(text: str, name: str) -> ContentBlock:
|
|
174
|
+
safe = html.escape(name, quote=True)
|
|
175
|
+
return text_block(f'<file name="{safe}">\n{text}\n</file>', meta={"attachment": True, "path": name})
|