strands-exaone 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- strands_exaone/__init__.py +7 -0
- strands_exaone/errors.py +104 -0
- strands_exaone/formatting.py +131 -0
- strands_exaone/metadata.py +95 -0
- strands_exaone/model.py +823 -0
- strands_exaone/py.typed +0 -0
- strands_exaone/retry.py +51 -0
- strands_exaone/retry_strategy.py +78 -0
- strands_exaone/streaming.py +351 -0
- strands_exaone-0.1.0.dist-info/METADATA +271 -0
- strands_exaone-0.1.0.dist-info/RECORD +13 -0
- strands_exaone-0.1.0.dist-info/WHEEL +4 -0
- strands_exaone-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""EXAONE model provider for Strands Agents."""
|
|
2
|
+
|
|
3
|
+
from .errors import ExaoneContextWindowError, ExaoneError, ExaoneThrottledError
|
|
4
|
+
from .model import ExaoneModel
|
|
5
|
+
from .retry_strategy import ExaoneRetryStrategy
|
|
6
|
+
|
|
7
|
+
__all__ = ["ExaoneContextWindowError", "ExaoneError", "ExaoneModel", "ExaoneRetryStrategy", "ExaoneThrottledError"]
|
strands_exaone/errors.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Provider errors with safe, request-specific response details."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from strands.types.exceptions import ContextWindowOverflowException, ModelThrottledException
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExaoneError(Exception):
|
|
11
|
+
"""EXAONE request failed or returned an invalid stream."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
status_code: int | None = None,
|
|
18
|
+
code: Any = None,
|
|
19
|
+
error_type: str | None = None,
|
|
20
|
+
param: Any = None,
|
|
21
|
+
request_id: str | None = None,
|
|
22
|
+
headers: dict[str, str] | None = None,
|
|
23
|
+
body: Any = None,
|
|
24
|
+
finish_reason: str | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status_code = status_code
|
|
28
|
+
self.code = code
|
|
29
|
+
self.error_type = error_type
|
|
30
|
+
self.param = param
|
|
31
|
+
self.request_id = request_id
|
|
32
|
+
self.headers = headers or {}
|
|
33
|
+
self.body = body
|
|
34
|
+
self.finish_reason = finish_reason
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ExaoneThrottledError(ModelThrottledException):
|
|
38
|
+
"""HTTP 429 with the sanitized response attached as ``details``."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, details: ExaoneError) -> None:
|
|
41
|
+
super().__init__(str(details))
|
|
42
|
+
self.details = details
|
|
43
|
+
self.status_code = details.status_code
|
|
44
|
+
self.error_type = details.error_type
|
|
45
|
+
self.request_id = details.request_id
|
|
46
|
+
self.headers = details.headers
|
|
47
|
+
self.body = details.body
|
|
48
|
+
self.code = details.code
|
|
49
|
+
self.param = details.param
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ExaoneContextWindowError(ContextWindowOverflowException):
|
|
53
|
+
"""The service explicitly reported that the context window was exceeded."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, details: ExaoneError) -> None:
|
|
56
|
+
super().__init__(str(details))
|
|
57
|
+
self.details = details
|
|
58
|
+
self.status_code = details.status_code
|
|
59
|
+
self.error_type = details.error_type
|
|
60
|
+
self.request_id = details.request_id
|
|
61
|
+
self.headers = details.headers
|
|
62
|
+
self.body = details.body
|
|
63
|
+
self.code = details.code
|
|
64
|
+
self.param = details.param
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def redact(value: Any, secret: str) -> Any:
|
|
68
|
+
"""Remove a credential from a response before exposing it."""
|
|
69
|
+
if isinstance(value, str):
|
|
70
|
+
return value.replace(secret, "[REDACTED]") if secret else value
|
|
71
|
+
if isinstance(value, dict):
|
|
72
|
+
return {redact(key, secret): redact(item, secret) for key, item in value.items()}
|
|
73
|
+
if isinstance(value, list):
|
|
74
|
+
return [redact(item, secret) for item in value]
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def response_error(status: int, body: Any, headers: dict[str, str], secret: str) -> Exception:
|
|
79
|
+
"""Map an error envelope without conflating arbitrary bad requests with context overflow."""
|
|
80
|
+
body = redact(body, secret)
|
|
81
|
+
envelope = body if isinstance(body, dict) else {}
|
|
82
|
+
nested = envelope.get("error", {})
|
|
83
|
+
if not isinstance(nested, dict):
|
|
84
|
+
nested = {}
|
|
85
|
+
fallback = f"HTTP {status}"
|
|
86
|
+
if 200 <= status < 300:
|
|
87
|
+
fallback = "EXAONE returned an error in the response body"
|
|
88
|
+
message = nested.get("message") or envelope.get("description") or fallback
|
|
89
|
+
code = nested["code"] if "code" in nested else envelope.get("result_code")
|
|
90
|
+
details = ExaoneError(
|
|
91
|
+
str(message),
|
|
92
|
+
status_code=status,
|
|
93
|
+
code=code,
|
|
94
|
+
error_type=nested.get("type") if isinstance(nested.get("type"), str) else None,
|
|
95
|
+
param=nested.get("param"),
|
|
96
|
+
request_id=headers.get("x-request-id"),
|
|
97
|
+
headers=headers,
|
|
98
|
+
body=body,
|
|
99
|
+
)
|
|
100
|
+
if status == 429:
|
|
101
|
+
return ExaoneThrottledError(details)
|
|
102
|
+
if status in {400, 413} and nested.get("code") == "context_length_exceeded":
|
|
103
|
+
return ExaoneContextWindowError(details)
|
|
104
|
+
return details
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Translate Strands conversation history to EXAONE Chat Completions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from strands.types.content import ContentBlock, Message, Messages, SystemContentBlock
|
|
9
|
+
from strands.types.tools import ToolChoice, ToolResult, ToolSpec, ToolUse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _text_of_result(result: ToolResult) -> str:
|
|
13
|
+
parts: list[str] = []
|
|
14
|
+
for item in result.get("content", []):
|
|
15
|
+
if "text" in item:
|
|
16
|
+
parts.append(item["text"])
|
|
17
|
+
elif "json" in item:
|
|
18
|
+
parts.append(json.dumps(item["json"], ensure_ascii=False, separators=(",", ":")))
|
|
19
|
+
else:
|
|
20
|
+
raise ValueError("EXAONE supports text and JSON tool results only")
|
|
21
|
+
text = "\n".join(parts)
|
|
22
|
+
return f"[ERROR] {text}" if result.get("status") == "error" else text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _tool_call(tool: ToolUse) -> dict[str, Any]:
|
|
26
|
+
if tool.get("reasoningSignature"):
|
|
27
|
+
raise ValueError("EXAONE does not support tool reasoning signatures")
|
|
28
|
+
return {
|
|
29
|
+
"id": tool["toolUseId"],
|
|
30
|
+
"type": "function",
|
|
31
|
+
"function": {
|
|
32
|
+
"name": tool["name"],
|
|
33
|
+
"arguments": json.dumps(tool["input"], ensure_ascii=False, separators=(",", ":")),
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _reasoning_text(block: ContentBlock) -> str:
|
|
39
|
+
reasoning = block["reasoningContent"]
|
|
40
|
+
if reasoning.get("redactedContent") is not None:
|
|
41
|
+
raise ValueError("EXAONE does not support redacted reasoning")
|
|
42
|
+
value = reasoning.get("reasoningText") or {}
|
|
43
|
+
if value.get("signature"):
|
|
44
|
+
raise ValueError("EXAONE does not support reasoning signatures")
|
|
45
|
+
return value.get("text", "")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _message_parts(message: Message) -> tuple[list[str], list[str], list[dict[str, Any]], list[dict[str, Any]]]:
|
|
49
|
+
text: list[str] = []
|
|
50
|
+
reasoning: list[str] = []
|
|
51
|
+
calls: list[dict[str, Any]] = []
|
|
52
|
+
results: list[dict[str, Any]] = []
|
|
53
|
+
role = message["role"]
|
|
54
|
+
for block in message["content"]:
|
|
55
|
+
if "text" in block:
|
|
56
|
+
text.append(block["text"])
|
|
57
|
+
elif "reasoningContent" in block and role == "assistant":
|
|
58
|
+
reasoning.append(_reasoning_text(block))
|
|
59
|
+
elif "toolUse" in block and role == "assistant":
|
|
60
|
+
calls.append(_tool_call(block["toolUse"]))
|
|
61
|
+
elif "toolResult" in block and role == "user":
|
|
62
|
+
result = block["toolResult"]
|
|
63
|
+
results.append({"role": "tool", "tool_call_id": result["toolUseId"], "content": _text_of_result(result)})
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError(f"Unsupported EXAONE content block for {role}: {', '.join(block)}")
|
|
66
|
+
return text, reasoning, calls, results
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _system_messages(
|
|
70
|
+
system_prompt: str | None, system_prompt_content: list[SystemContentBlock] | None
|
|
71
|
+
) -> list[dict[str, Any]]:
|
|
72
|
+
if system_prompt_content is not None:
|
|
73
|
+
return [{"role": "system", "content": block["text"]} for block in system_prompt_content if "text" in block]
|
|
74
|
+
return [{"role": "system", "content": system_prompt}] if system_prompt else []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _format_message(message: Message) -> list[dict[str, Any]]:
|
|
78
|
+
role = message["role"]
|
|
79
|
+
if role not in {"user", "assistant"}:
|
|
80
|
+
raise ValueError(f"Unsupported Strands message role: {role}")
|
|
81
|
+
text, reasoning, calls, results = _message_parts(message)
|
|
82
|
+
if role == "assistant":
|
|
83
|
+
assistant: dict[str, Any] = {"role": "assistant", "content": "".join(text)}
|
|
84
|
+
if reasoning:
|
|
85
|
+
assistant["reasoning_content"] = "".join(reasoning)
|
|
86
|
+
if calls:
|
|
87
|
+
assistant["tool_calls"] = calls
|
|
88
|
+
return [assistant]
|
|
89
|
+
if text:
|
|
90
|
+
return [*results, {"role": "user", "content": "".join(text)}]
|
|
91
|
+
if not results:
|
|
92
|
+
raise ValueError("User message must contain text or a tool result")
|
|
93
|
+
return results
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def format_messages(
|
|
97
|
+
messages: Messages,
|
|
98
|
+
system_prompt: str | None,
|
|
99
|
+
system_prompt_content: list[SystemContentBlock] | None,
|
|
100
|
+
) -> list[dict[str, Any]]:
|
|
101
|
+
"""Build ordered wire messages without losing thinking or tool result IDs."""
|
|
102
|
+
output = _system_messages(system_prompt, system_prompt_content)
|
|
103
|
+
for message in messages:
|
|
104
|
+
output.extend(_format_message(message))
|
|
105
|
+
return output
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def format_tools(specs: list[ToolSpec]) -> list[dict[str, Any]]:
|
|
109
|
+
"""Convert Strands tool specifications to OpenAI-compatible functions."""
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
"type": "function",
|
|
113
|
+
"function": {
|
|
114
|
+
"name": spec["name"],
|
|
115
|
+
"description": spec["description"],
|
|
116
|
+
"parameters": spec["inputSchema"]["json"],
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
for spec in specs
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def format_tool_choice(choice: ToolChoice) -> str | dict[str, Any]:
|
|
124
|
+
"""Map a Strands tool choice to the Chat Completions wire format."""
|
|
125
|
+
if "auto" in choice:
|
|
126
|
+
return "auto"
|
|
127
|
+
if "any" in choice:
|
|
128
|
+
return "required"
|
|
129
|
+
if "tool" in choice:
|
|
130
|
+
return {"type": "function", "function": {"name": cast(dict[str, Any], choice)["tool"]["name"]}}
|
|
131
|
+
raise ValueError("Unsupported tool choice")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Collect response metadata without duplicating message or reasoning content."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_FIELDS = (
|
|
9
|
+
"id",
|
|
10
|
+
"object",
|
|
11
|
+
"created",
|
|
12
|
+
"model",
|
|
13
|
+
"metadata",
|
|
14
|
+
"service_tier",
|
|
15
|
+
"system_fingerprint",
|
|
16
|
+
"moderation",
|
|
17
|
+
"result_code",
|
|
18
|
+
"description",
|
|
19
|
+
)
|
|
20
|
+
_CHOICE_FIELDS = ("index", "finish_reason", "logprobs", "matched_stop")
|
|
21
|
+
_MESSAGE_FIELDS = ("refusal", "annotations", "audio", "function_call")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ResponseMetadata:
|
|
25
|
+
"""Merge the metadata fields of consecutive Chat Completions chunks."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, enabled: bool = True) -> None:
|
|
28
|
+
self.enabled = enabled
|
|
29
|
+
self.response: dict[str, Any] = {}
|
|
30
|
+
self._choice: dict[str, Any] = {}
|
|
31
|
+
|
|
32
|
+
def add(self, chunk: dict[str, Any]) -> None:
|
|
33
|
+
if not self.enabled:
|
|
34
|
+
return
|
|
35
|
+
for key in _FIELDS:
|
|
36
|
+
if key in chunk and (chunk[key] is not None or key not in self.response):
|
|
37
|
+
self.response[key] = deepcopy(chunk[key])
|
|
38
|
+
choices = chunk.get("choices")
|
|
39
|
+
if not isinstance(choices, list):
|
|
40
|
+
return
|
|
41
|
+
for choice in choices:
|
|
42
|
+
if isinstance(choice, dict):
|
|
43
|
+
self._add_choice(choice)
|
|
44
|
+
if choices:
|
|
45
|
+
self.response["choices"] = [self._choice]
|
|
46
|
+
|
|
47
|
+
def _add_choice(self, choice: dict[str, Any]) -> None:
|
|
48
|
+
for key in _CHOICE_FIELDS:
|
|
49
|
+
if key not in choice:
|
|
50
|
+
continue
|
|
51
|
+
value = choice[key]
|
|
52
|
+
if key == "logprobs" and isinstance(value, dict):
|
|
53
|
+
self._merge_logprobs(value)
|
|
54
|
+
elif value is not None or key not in self._choice:
|
|
55
|
+
self._choice[key] = deepcopy(value)
|
|
56
|
+
message = choice.get("message")
|
|
57
|
+
if isinstance(message, dict):
|
|
58
|
+
self._add_message(message)
|
|
59
|
+
delta = choice.get("delta")
|
|
60
|
+
if isinstance(delta, dict):
|
|
61
|
+
self._add_delta(delta)
|
|
62
|
+
|
|
63
|
+
def _add_message(self, message: dict[str, Any]) -> None:
|
|
64
|
+
fields = {key: deepcopy(message[key]) for key in _MESSAGE_FIELDS if key in message}
|
|
65
|
+
if fields:
|
|
66
|
+
self._choice["message"] = fields
|
|
67
|
+
|
|
68
|
+
def _add_delta(self, delta: dict[str, Any]) -> None:
|
|
69
|
+
message = self._choice.setdefault("message", {})
|
|
70
|
+
for key in ("refusal", "annotations"):
|
|
71
|
+
if key not in delta:
|
|
72
|
+
continue
|
|
73
|
+
value = delta[key]
|
|
74
|
+
previous = message.get(key)
|
|
75
|
+
if isinstance(previous, str) and isinstance(value, str) and key == "refusal":
|
|
76
|
+
message[key] = previous + value
|
|
77
|
+
elif isinstance(previous, list) and isinstance(value, list) and key == "annotations":
|
|
78
|
+
previous.extend(deepcopy(value))
|
|
79
|
+
elif value is not None or key not in message:
|
|
80
|
+
message[key] = deepcopy(value)
|
|
81
|
+
if not message:
|
|
82
|
+
self._choice.pop("message", None)
|
|
83
|
+
raw = {key: deepcopy(delta[key]) for key in ("audio", "function_call") if key in delta}
|
|
84
|
+
if raw:
|
|
85
|
+
self._choice.setdefault("message_deltas", []).append(raw)
|
|
86
|
+
|
|
87
|
+
def _merge_logprobs(self, value: dict[str, Any]) -> None:
|
|
88
|
+
previous = self._choice.get("logprobs")
|
|
89
|
+
result: dict[str, Any] = previous if isinstance(previous, dict) else {}
|
|
90
|
+
for key, item in value.items():
|
|
91
|
+
if key in {"content", "refusal"} and isinstance(item, list) and isinstance(result.get(key), list):
|
|
92
|
+
result[key].extend(deepcopy(item))
|
|
93
|
+
elif item is not None or key not in result:
|
|
94
|
+
result[key] = deepcopy(item)
|
|
95
|
+
self._choice["logprobs"] = result
|