tether-sdk 0.0.2__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.
- tether/__init__.py +35 -0
- tether/cli.py +235 -0
- tether/context.py +170 -0
- tether/decorator.py +280 -0
- tether/harness.py +435 -0
- tether/multiplexer.py +124 -0
- tether/providers/__init__.py +66 -0
- tether/providers/anthropic.py +135 -0
- tether/providers/base.py +136 -0
- tether/providers/ollama.py +112 -0
- tether/providers/openai.py +89 -0
- tether/sandbox.py +342 -0
- tether/skills.py +157 -0
- tether/store.py +660 -0
- tether/tools/__init__.py +22 -0
- tether/tools/agent.py +190 -0
- tether/tools/bash.py +29 -0
- tether/tools/done.py +33 -0
- tether/tools/edit.py +107 -0
- tether/tools/mem.py +93 -0
- tether/tools/web.py +222 -0
- tether_sdk-0.0.2.dist-info/METADATA +346 -0
- tether_sdk-0.0.2.dist-info/RECORD +26 -0
- tether_sdk-0.0.2.dist-info/WHEEL +4 -0
- tether_sdk-0.0.2.dist-info/entry_points.txt +2 -0
- tether_sdk-0.0.2.dist-info/licenses/LICENSE +20 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Native Anthropic `/v1/messages` httpx adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from tether.providers.base import (
|
|
11
|
+
ActionCall,
|
|
12
|
+
ModelResponse,
|
|
13
|
+
extract_fallback_action,
|
|
14
|
+
parse_tool_arguments,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AnthropicProvider:
|
|
19
|
+
"""Thin async adapter for Anthropic `/v1/messages`."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
model_name: str = "claude-3-7-sonnet-latest",
|
|
24
|
+
*,
|
|
25
|
+
api_key: str | None = None,
|
|
26
|
+
workspace_id: str | None = None,
|
|
27
|
+
base_url: str | None = None,
|
|
28
|
+
max_tokens: int = 4096,
|
|
29
|
+
timeout: float = 180.0,
|
|
30
|
+
client: httpx.AsyncClient | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.model_name = model_name
|
|
33
|
+
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") or ""
|
|
34
|
+
self.workspace_id = workspace_id or os.environ.get("ANTHROPIC_WORKSPACE_ID") or ""
|
|
35
|
+
self.base_url = (
|
|
36
|
+
base_url or os.environ.get("ANTHROPIC_BASE_URL") or "https://api.anthropic.com"
|
|
37
|
+
).rstrip("/")
|
|
38
|
+
self.max_tokens = max_tokens
|
|
39
|
+
self.timeout = timeout
|
|
40
|
+
self._client = client
|
|
41
|
+
|
|
42
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
43
|
+
if self._client is None or self._client.is_closed:
|
|
44
|
+
self._client = httpx.AsyncClient(timeout=self.timeout)
|
|
45
|
+
return self._client
|
|
46
|
+
|
|
47
|
+
async def aclose(self) -> None:
|
|
48
|
+
"""Close the underlying HTTP connection pool."""
|
|
49
|
+
if self._client is not None and not self._client.is_closed:
|
|
50
|
+
await self._client.aclose()
|
|
51
|
+
|
|
52
|
+
async def generate(
|
|
53
|
+
self,
|
|
54
|
+
messages: list[dict[str, Any]],
|
|
55
|
+
tools: list[dict[str, Any]],
|
|
56
|
+
) -> ModelResponse:
|
|
57
|
+
system_text = ""
|
|
58
|
+
chat_msgs: list[dict[str, str]] = []
|
|
59
|
+
for m in messages:
|
|
60
|
+
if m.get("role") == "system":
|
|
61
|
+
system_text = str(m.get("content") or "")
|
|
62
|
+
else:
|
|
63
|
+
role = "assistant" if m.get("role") == "assistant" else "user"
|
|
64
|
+
content_str = str(m.get("content") or "").strip() or "(empty)"
|
|
65
|
+
if chat_msgs and chat_msgs[-1]["role"] == role:
|
|
66
|
+
chat_msgs[-1]["content"] = f"{chat_msgs[-1]['content']}\n\n{content_str}"
|
|
67
|
+
else:
|
|
68
|
+
chat_msgs.append({"role": role, "content": content_str})
|
|
69
|
+
|
|
70
|
+
anthropic_tools: list[dict[str, Any]] = []
|
|
71
|
+
for t in tools:
|
|
72
|
+
fn = t.get("function") or {}
|
|
73
|
+
anthropic_tools.append(
|
|
74
|
+
{
|
|
75
|
+
"name": fn.get("name", "act"),
|
|
76
|
+
"description": fn.get("description", ""),
|
|
77
|
+
"input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
if anthropic_tools:
|
|
81
|
+
anthropic_tools[-1]["cache_control"] = {"type": "ephemeral"}
|
|
82
|
+
|
|
83
|
+
headers: dict[str, str] = {
|
|
84
|
+
"x-api-key": self.api_key,
|
|
85
|
+
"anthropic-version": "2023-06-01",
|
|
86
|
+
"content-type": "application/json",
|
|
87
|
+
}
|
|
88
|
+
if self.workspace_id:
|
|
89
|
+
headers["anthropic-workspace-id"] = self.workspace_id
|
|
90
|
+
|
|
91
|
+
payload: dict[str, Any] = {
|
|
92
|
+
"model": self.model_name,
|
|
93
|
+
"max_tokens": self.max_tokens,
|
|
94
|
+
"messages": chat_msgs,
|
|
95
|
+
"tools": anthropic_tools,
|
|
96
|
+
}
|
|
97
|
+
if system_text:
|
|
98
|
+
payload["system"] = [
|
|
99
|
+
{
|
|
100
|
+
"type": "text",
|
|
101
|
+
"text": system_text,
|
|
102
|
+
"cache_control": {"type": "ephemeral"},
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
client = self._get_client()
|
|
107
|
+
resp = await client.post(
|
|
108
|
+
f"{self.base_url}/v1/messages",
|
|
109
|
+
headers=headers,
|
|
110
|
+
json=payload,
|
|
111
|
+
)
|
|
112
|
+
resp.raise_for_status()
|
|
113
|
+
data = resp.json()
|
|
114
|
+
|
|
115
|
+
text_parts: list[str] = []
|
|
116
|
+
action: ActionCall | None = None
|
|
117
|
+
for block in data.get("content") or []:
|
|
118
|
+
btype = block.get("type")
|
|
119
|
+
if btype == "text":
|
|
120
|
+
text_parts.append(str(block.get("text") or ""))
|
|
121
|
+
elif btype == "tool_use" and action is None:
|
|
122
|
+
action = parse_tool_arguments(block.get("input") or {})
|
|
123
|
+
|
|
124
|
+
content = "\n".join(text_parts).strip()
|
|
125
|
+
if action is None and content:
|
|
126
|
+
action = extract_fallback_action(content)
|
|
127
|
+
|
|
128
|
+
usage = data.get("usage") or {}
|
|
129
|
+
prompt_tokens = (
|
|
130
|
+
int(usage.get("input_tokens") or 0)
|
|
131
|
+
+ int(usage.get("cache_creation_input_tokens") or 0)
|
|
132
|
+
+ int(usage.get("cache_read_input_tokens") or 0)
|
|
133
|
+
+ int(usage.get("output_tokens") or 0)
|
|
134
|
+
)
|
|
135
|
+
return ModelResponse(content=content, action=action, prompt_tokens=prompt_tokens)
|
tether/providers/base.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Base provider protocol and 9B fallback tool-call parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class ActionCall:
|
|
14
|
+
cmd: str
|
|
15
|
+
stdin: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class ModelResponse:
|
|
20
|
+
content: str
|
|
21
|
+
action: ActionCall | None = None
|
|
22
|
+
prompt_tokens: int = 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ModelProvider(Protocol):
|
|
26
|
+
model_name: str
|
|
27
|
+
|
|
28
|
+
async def generate(
|
|
29
|
+
self,
|
|
30
|
+
messages: list[dict[str, Any]],
|
|
31
|
+
tools: list[dict[str, Any]],
|
|
32
|
+
) -> ModelResponse: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_ACT_CALL_RE = re.compile(
|
|
36
|
+
r"act\(\s*(?P<args>.*)\s*\)",
|
|
37
|
+
re.DOTALL,
|
|
38
|
+
)
|
|
39
|
+
_MULTILINE_ACT_RE = re.compile(
|
|
40
|
+
r"act\(\s*(?:(?:cmd|command)\s*=\s*)?(?P<q1>\"\"\"|'''|\"|')(?P<cmd>.*?)(?P=q1)"
|
|
41
|
+
r"(?:\s*,\s*(?:stdin\s*=\s*)?(?P<q2>\"\"\"|'''|\"|')(?P<stdin>[\s\S]*)(?P=q2))?\s*\)",
|
|
42
|
+
)
|
|
43
|
+
_TOOL_XML_RE = re.compile(
|
|
44
|
+
r"<tool_call>\s*(?P<body>.*?)\s*</tool_call>",
|
|
45
|
+
re.DOTALL,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_tool_arguments(raw_args: str | dict[str, Any]) -> ActionCall | None:
|
|
50
|
+
"""Parse tool arguments from a dict or JSON string into an ActionCall."""
|
|
51
|
+
data: dict[str, Any]
|
|
52
|
+
if isinstance(raw_args, dict):
|
|
53
|
+
data = raw_args
|
|
54
|
+
else:
|
|
55
|
+
try:
|
|
56
|
+
parsed = json.loads(raw_args)
|
|
57
|
+
if not isinstance(parsed, dict):
|
|
58
|
+
return None
|
|
59
|
+
data = parsed
|
|
60
|
+
except Exception:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# If the model called `act` with {"cmd": ..., "stdin": ...}
|
|
64
|
+
if "cmd" in data:
|
|
65
|
+
cmd = str(data.get("cmd") or "").strip()
|
|
66
|
+
stdin = str(data["stdin"]) if data.get("stdin") is not None else None
|
|
67
|
+
return ActionCall(cmd=cmd, stdin=stdin) if cmd else None
|
|
68
|
+
|
|
69
|
+
if "command" in data:
|
|
70
|
+
cmd = str(data.get("command") or "").strip()
|
|
71
|
+
stdin = str(data["stdin"]) if data.get("stdin") is not None else None
|
|
72
|
+
return ActionCall(cmd=cmd, stdin=stdin) if cmd else None
|
|
73
|
+
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def extract_fallback_action(text: str) -> ActionCall | None:
|
|
78
|
+
"""Extract an ActionCall when a small 9B model emits tool syntax inside plain text."""
|
|
79
|
+
if not text:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
# 1. Check <tool_call>...</tool_call> XML blocks (common in Qwen models)
|
|
83
|
+
xml_match = _TOOL_XML_RE.search(text)
|
|
84
|
+
if xml_match:
|
|
85
|
+
body = xml_match.group("body").strip()
|
|
86
|
+
try:
|
|
87
|
+
payload = json.loads(body)
|
|
88
|
+
if isinstance(payload, dict):
|
|
89
|
+
args = payload.get("arguments") or payload.get("parameters") or payload
|
|
90
|
+
call = parse_tool_arguments(args)
|
|
91
|
+
if call:
|
|
92
|
+
return call
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
# 2. Check raw JSON object containing "cmd"
|
|
97
|
+
if '"cmd"' in text:
|
|
98
|
+
start = text.find("{")
|
|
99
|
+
end = text.rfind("}")
|
|
100
|
+
if 0 <= start < end:
|
|
101
|
+
call = parse_tool_arguments(text[start : end + 1])
|
|
102
|
+
if call:
|
|
103
|
+
return call
|
|
104
|
+
|
|
105
|
+
# 3. Check multiline-tolerant `act("...", stdin="...")` call in text
|
|
106
|
+
ml_match = _MULTILINE_ACT_RE.search(text)
|
|
107
|
+
if ml_match:
|
|
108
|
+
cmd_str = ml_match.group("cmd").strip()
|
|
109
|
+
stdin_str = ml_match.group("stdin")
|
|
110
|
+
if cmd_str:
|
|
111
|
+
return ActionCall(cmd=cmd_str, stdin=stdin_str)
|
|
112
|
+
|
|
113
|
+
# 4. Fallback to AST parse for single-line Python-style `act(...)`
|
|
114
|
+
act_match = _ACT_CALL_RE.search(text)
|
|
115
|
+
if act_match:
|
|
116
|
+
expr = f"f({act_match.group('args')})"
|
|
117
|
+
try:
|
|
118
|
+
tree = ast.parse(expr, mode="eval")
|
|
119
|
+
if isinstance(tree.body, ast.Call):
|
|
120
|
+
cmd_val: str | None = None
|
|
121
|
+
stdin_val: str | None = None
|
|
122
|
+
if tree.body.args and isinstance(tree.body.args[0], ast.Constant):
|
|
123
|
+
cmd_val = str(tree.body.args[0].value)
|
|
124
|
+
if len(tree.body.args) > 1 and isinstance(tree.body.args[1], ast.Constant):
|
|
125
|
+
stdin_val = str(tree.body.args[1].value)
|
|
126
|
+
for kw in tree.body.keywords:
|
|
127
|
+
if kw.arg in ("cmd", "command") and isinstance(kw.value, ast.Constant):
|
|
128
|
+
cmd_val = str(kw.value.value)
|
|
129
|
+
elif kw.arg == "stdin" and isinstance(kw.value, ast.Constant):
|
|
130
|
+
stdin_val = str(kw.value.value)
|
|
131
|
+
if cmd_val:
|
|
132
|
+
return ActionCall(cmd=cmd_val, stdin=stdin_val)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
return None
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Native Ollama `/api/chat` httpx adapter with num_ctx and keep_alive support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from tether.providers.base import (
|
|
11
|
+
ActionCall,
|
|
12
|
+
ModelResponse,
|
|
13
|
+
extract_fallback_action,
|
|
14
|
+
parse_tool_arguments,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OllamaProvider:
|
|
19
|
+
"""Thin async adapter for Ollama `/api/chat`."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
model_name: str = "qwen3.5:9b",
|
|
24
|
+
*,
|
|
25
|
+
host: str | None = None,
|
|
26
|
+
num_ctx: int = 8192,
|
|
27
|
+
keep_alive: str = "10m",
|
|
28
|
+
timeout: float = 120.0,
|
|
29
|
+
client: httpx.AsyncClient | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.model_name = model_name
|
|
32
|
+
self.host = (host or os.environ.get("OLLAMA_HOST") or "http://localhost:11434").rstrip("/")
|
|
33
|
+
self.num_ctx = num_ctx
|
|
34
|
+
self.keep_alive = keep_alive
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
self._client = client
|
|
37
|
+
|
|
38
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
39
|
+
if self._client is None or self._client.is_closed:
|
|
40
|
+
self._client = httpx.AsyncClient(timeout=self.timeout)
|
|
41
|
+
return self._client
|
|
42
|
+
|
|
43
|
+
async def aclose(self) -> None:
|
|
44
|
+
"""Close the underlying HTTP connection pool."""
|
|
45
|
+
if self._client is not None and not self._client.is_closed:
|
|
46
|
+
await self._client.aclose()
|
|
47
|
+
|
|
48
|
+
async def _resolve_installed_model(self, client: httpx.AsyncClient) -> str | None:
|
|
49
|
+
"""Query Ollama `/api/tags` to find an installed Qwen (or fallback) model if 404."""
|
|
50
|
+
try:
|
|
51
|
+
resp = await client.get(f"{self.host}/api/tags")
|
|
52
|
+
resp.raise_for_status()
|
|
53
|
+
models = [str(m.get("name") or "") for m in (resp.json().get("models") or [])]
|
|
54
|
+
models = [m for m in models if m]
|
|
55
|
+
if not models:
|
|
56
|
+
return None
|
|
57
|
+
# Prefer any installed Qwen model first
|
|
58
|
+
for m in models:
|
|
59
|
+
if "qwen" in m.lower():
|
|
60
|
+
return m
|
|
61
|
+
return models[0]
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
async def generate(
|
|
66
|
+
self,
|
|
67
|
+
messages: list[dict[str, Any]],
|
|
68
|
+
tools: list[dict[str, Any]],
|
|
69
|
+
) -> ModelResponse:
|
|
70
|
+
payload: dict[str, Any] = {
|
|
71
|
+
"model": self.model_name,
|
|
72
|
+
"messages": messages,
|
|
73
|
+
"tools": tools,
|
|
74
|
+
"stream": False,
|
|
75
|
+
"keep_alive": self.keep_alive,
|
|
76
|
+
"options": {"num_ctx": self.num_ctx, "temperature": 0.2},
|
|
77
|
+
}
|
|
78
|
+
client = self._get_client()
|
|
79
|
+
resp = await client.post(f"{self.host}/api/chat", json=payload)
|
|
80
|
+
if resp.status_code == 404:
|
|
81
|
+
fallback = await self._resolve_installed_model(client)
|
|
82
|
+
if fallback and fallback != self.model_name:
|
|
83
|
+
self.model_name = fallback
|
|
84
|
+
payload["model"] = fallback
|
|
85
|
+
resp = await client.post(f"{self.host}/api/chat", json=payload)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
data = resp.json()
|
|
88
|
+
|
|
89
|
+
msg = data.get("message") or {}
|
|
90
|
+
content = str(msg.get("content") or "").strip()
|
|
91
|
+
prompt_tokens = int(data.get("prompt_eval_count") or 0) + int(data.get("eval_count") or 0)
|
|
92
|
+
|
|
93
|
+
action: ActionCall | None = None
|
|
94
|
+
tool_calls = msg.get("tool_calls") or []
|
|
95
|
+
if tool_calls:
|
|
96
|
+
first_tc = tool_calls[0]
|
|
97
|
+
fn_obj = first_tc.get("function") or {}
|
|
98
|
+
fn_name = str(fn_obj.get("name") or "")
|
|
99
|
+
raw_args = fn_obj.get("arguments") or {}
|
|
100
|
+
action = parse_tool_arguments(raw_args)
|
|
101
|
+
if action is None and fn_name and fn_name != "act":
|
|
102
|
+
# Graceful 9B fallback if model called tool name directly
|
|
103
|
+
if isinstance(raw_args, dict):
|
|
104
|
+
arg_str = " ".join(f"--{k} {v!r}" for k, v in raw_args.items())
|
|
105
|
+
action = ActionCall(cmd=f"{fn_name} {arg_str}".strip())
|
|
106
|
+
else:
|
|
107
|
+
action = ActionCall(cmd=fn_name)
|
|
108
|
+
|
|
109
|
+
if action is None and content:
|
|
110
|
+
action = extract_fallback_action(content)
|
|
111
|
+
|
|
112
|
+
return ModelResponse(content=content, action=action, prompt_tokens=prompt_tokens)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""OpenAI-compatible `/v1/chat/completions` httpx adapter (GPT, OpenRouter, vLLM, Groq)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from tether.providers.base import (
|
|
11
|
+
ActionCall,
|
|
12
|
+
ModelResponse,
|
|
13
|
+
extract_fallback_action,
|
|
14
|
+
parse_tool_arguments,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OpenAIProvider:
|
|
19
|
+
"""Thin async adapter for OpenAI-compatible `/v1/chat/completions` endpoints."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
model_name: str = "gpt-4o-mini",
|
|
24
|
+
*,
|
|
25
|
+
api_key: str | None = None,
|
|
26
|
+
base_url: str | None = None,
|
|
27
|
+
timeout: float = 120.0,
|
|
28
|
+
client: httpx.AsyncClient | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.model_name = model_name
|
|
31
|
+
self.api_key = api_key or os.environ.get("OPENAI_API_KEY") or ""
|
|
32
|
+
self.base_url = (
|
|
33
|
+
base_url or os.environ.get("OPENAI_BASE_URL") or "https://api.openai.com/v1"
|
|
34
|
+
).rstrip("/")
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
self._client = client
|
|
37
|
+
|
|
38
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
39
|
+
if self._client is None or self._client.is_closed:
|
|
40
|
+
self._client = httpx.AsyncClient(timeout=self.timeout)
|
|
41
|
+
return self._client
|
|
42
|
+
|
|
43
|
+
async def aclose(self) -> None:
|
|
44
|
+
"""Close the underlying HTTP connection pool."""
|
|
45
|
+
if self._client is not None and not self._client.is_closed:
|
|
46
|
+
await self._client.aclose()
|
|
47
|
+
|
|
48
|
+
async def generate(
|
|
49
|
+
self,
|
|
50
|
+
messages: list[dict[str, Any]],
|
|
51
|
+
tools: list[dict[str, Any]],
|
|
52
|
+
) -> ModelResponse:
|
|
53
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
54
|
+
if self.api_key:
|
|
55
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
56
|
+
|
|
57
|
+
payload: dict[str, Any] = {
|
|
58
|
+
"model": self.model_name,
|
|
59
|
+
"messages": messages,
|
|
60
|
+
"tools": tools,
|
|
61
|
+
"temperature": 0.2,
|
|
62
|
+
}
|
|
63
|
+
client = self._get_client()
|
|
64
|
+
resp = await client.post(
|
|
65
|
+
f"{self.base_url}/chat/completions",
|
|
66
|
+
headers=headers,
|
|
67
|
+
json=payload,
|
|
68
|
+
)
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
data = resp.json()
|
|
71
|
+
|
|
72
|
+
choices = data.get("choices") or [{}]
|
|
73
|
+
msg = choices[0].get("message") or {}
|
|
74
|
+
content = str(msg.get("content") or "").strip()
|
|
75
|
+
usage = data.get("usage") or {}
|
|
76
|
+
prompt_tokens = int(usage.get("prompt_tokens") or 0)
|
|
77
|
+
|
|
78
|
+
action: ActionCall | None = None
|
|
79
|
+
tool_calls = msg.get("tool_calls") or []
|
|
80
|
+
if tool_calls:
|
|
81
|
+
first_tc = tool_calls[0]
|
|
82
|
+
fn_obj = first_tc.get("function") or {}
|
|
83
|
+
raw_args = fn_obj.get("arguments") or "{}"
|
|
84
|
+
action = parse_tool_arguments(raw_args)
|
|
85
|
+
|
|
86
|
+
if action is None and content:
|
|
87
|
+
action = extract_fallback_action(content)
|
|
88
|
+
|
|
89
|
+
return ModelResponse(content=content, action=action, prompt_tokens=prompt_tokens)
|