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.
@@ -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)
@@ -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)