afsar 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.
- afsar/__init__.py +27 -0
- afsar/adapters/__init__.py +15 -0
- afsar/adapters/agy.py +87 -0
- afsar/adapters/base.py +25 -0
- afsar/adapters/claude.py +103 -0
- afsar/adapters/gemini.py +86 -0
- afsar/adapters/openai.py +89 -0
- afsar/core.py +164 -0
- afsar/exceptions.py +25 -0
- afsar/resources/afsar.png +0 -0
- afsar/schema.py +111 -0
- afsar-1.0.dist-info/METADATA +149 -0
- afsar-1.0.dist-info/RECORD +14 -0
- afsar-1.0.dist-info/WHEEL +4 -0
afsar/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""afsar - Simple students' API to LLMs."""
|
|
2
|
+
|
|
3
|
+
from afsar.core import ask, get_active_adapter, reset, setup
|
|
4
|
+
from afsar.exceptions import (
|
|
5
|
+
AfsarError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
ExecutionError,
|
|
8
|
+
ModelNotFoundError,
|
|
9
|
+
NotSetupError,
|
|
10
|
+
UnsupportedFeatureError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__version__ = "1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"setup",
|
|
17
|
+
"ask",
|
|
18
|
+
"reset",
|
|
19
|
+
"get_active_adapter",
|
|
20
|
+
"AfsarError",
|
|
21
|
+
"NotSetupError",
|
|
22
|
+
"ModelNotFoundError",
|
|
23
|
+
"UnsupportedFeatureError",
|
|
24
|
+
"ExecutionError",
|
|
25
|
+
"AuthenticationError",
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Adapters package for afsar."""
|
|
2
|
+
|
|
3
|
+
from afsar.adapters.agy import AgyAdapter
|
|
4
|
+
from afsar.adapters.base import BaseAdapter
|
|
5
|
+
from afsar.adapters.claude import ClaudeAdapter
|
|
6
|
+
from afsar.adapters.gemini import GeminiAdapter
|
|
7
|
+
from afsar.adapters.openai import OpenAIAdapter
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BaseAdapter",
|
|
11
|
+
"ClaudeAdapter",
|
|
12
|
+
"AgyAdapter",
|
|
13
|
+
"GeminiAdapter",
|
|
14
|
+
"OpenAIAdapter",
|
|
15
|
+
]
|
afsar/adapters/agy.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Antigravity (agy) CLI subprocess adapter."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from afsar.adapters.base import BaseAdapter
|
|
8
|
+
from afsar.exceptions import ExecutionError
|
|
9
|
+
from afsar.schema import _strip_markdown_fence, to_json_schema, validate_schema_output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AgyAdapter(BaseAdapter):
|
|
13
|
+
"""Adapter for local Antigravity (agy) CLI."""
|
|
14
|
+
|
|
15
|
+
name = "agy"
|
|
16
|
+
supports_tools = True
|
|
17
|
+
supports_thinking = True
|
|
18
|
+
supports_schema = True
|
|
19
|
+
|
|
20
|
+
def __init__(self, model: str | None = None, tools: list[str] | None = None) -> None:
|
|
21
|
+
self.model = model
|
|
22
|
+
self.tools = tools if tools is not None else ["python", "files"]
|
|
23
|
+
|
|
24
|
+
def _build_cmd(
|
|
25
|
+
self,
|
|
26
|
+
prompt: str,
|
|
27
|
+
format: str,
|
|
28
|
+
schema: Any,
|
|
29
|
+
thinking: bool | None,
|
|
30
|
+
) -> list[str]:
|
|
31
|
+
cmd = ["agy", "-p", prompt, "--output-format", "json", "--dangerously-skip-permissions"]
|
|
32
|
+
if self.model:
|
|
33
|
+
cmd.extend(["--model", self.model])
|
|
34
|
+
|
|
35
|
+
if schema is not None:
|
|
36
|
+
cmd.extend(["--json-schema", json.dumps(to_json_schema(schema))])
|
|
37
|
+
elif format == "json":
|
|
38
|
+
cmd.extend(["--json-schema", json.dumps({"type": "object"})])
|
|
39
|
+
|
|
40
|
+
if thinking is not None:
|
|
41
|
+
cmd.extend(["--effort", "high" if thinking else "low"])
|
|
42
|
+
|
|
43
|
+
return cmd
|
|
44
|
+
|
|
45
|
+
def _parse_output(self, stdout: str) -> str:
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(stdout)
|
|
48
|
+
if data.get("status") not in (None, "SUCCESS"):
|
|
49
|
+
raise ExecutionError(f"agy error: {stdout}")
|
|
50
|
+
if "structured_output" in data and data["structured_output"] is not None:
|
|
51
|
+
return json.dumps(data["structured_output"])
|
|
52
|
+
return str(data.get("response", ""))
|
|
53
|
+
except json.JSONDecodeError:
|
|
54
|
+
return stdout.strip()
|
|
55
|
+
|
|
56
|
+
def ask(
|
|
57
|
+
self,
|
|
58
|
+
prompt: str,
|
|
59
|
+
*,
|
|
60
|
+
format: str = "text",
|
|
61
|
+
schema: Any = None,
|
|
62
|
+
temperature: float | None = None,
|
|
63
|
+
thinking: bool | None = None,
|
|
64
|
+
) -> Any:
|
|
65
|
+
cmd = self._build_cmd(prompt, format, schema, thinking)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
res = subprocess.run(
|
|
69
|
+
cmd,
|
|
70
|
+
stdin=subprocess.DEVNULL,
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
check=False,
|
|
74
|
+
)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
raise ExecutionError(f"Failed to execute agy CLI: {e}") from e
|
|
77
|
+
|
|
78
|
+
if res.returncode != 0 and not res.stdout:
|
|
79
|
+
raise ExecutionError(f"agy CLI exited with code {res.returncode}: {res.stderr.strip()}")
|
|
80
|
+
|
|
81
|
+
result_str = self._parse_output(res.stdout)
|
|
82
|
+
if schema is not None:
|
|
83
|
+
return validate_schema_output(schema, result_str)
|
|
84
|
+
if format == "json":
|
|
85
|
+
return _strip_markdown_fence(result_str)
|
|
86
|
+
|
|
87
|
+
return result_str
|
afsar/adapters/base.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Base adapter interface for LLM models and local agents."""
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseAdapter(abc.ABC):
|
|
8
|
+
"""Abstract base class for all model adapters."""
|
|
9
|
+
|
|
10
|
+
name: str = "base"
|
|
11
|
+
supports_tools: bool = False
|
|
12
|
+
supports_thinking: bool = False
|
|
13
|
+
supports_schema: bool = False
|
|
14
|
+
|
|
15
|
+
@abc.abstractmethod
|
|
16
|
+
def ask(
|
|
17
|
+
self,
|
|
18
|
+
prompt: str,
|
|
19
|
+
*,
|
|
20
|
+
format: str = "text",
|
|
21
|
+
schema: Any = None,
|
|
22
|
+
temperature: float | None = None,
|
|
23
|
+
thinking: bool | None = None,
|
|
24
|
+
) -> Any:
|
|
25
|
+
"""Execute a prompt against the model and return the result."""
|
afsar/adapters/claude.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Claude Code CLI subprocess adapter."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from afsar.adapters.base import BaseAdapter
|
|
8
|
+
from afsar.exceptions import AuthenticationError, ExecutionError
|
|
9
|
+
from afsar.schema import _strip_markdown_fence, to_json_schema, validate_schema_output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClaudeAdapter(BaseAdapter):
|
|
13
|
+
"""Adapter for local Claude Code CLI."""
|
|
14
|
+
|
|
15
|
+
name = "claude"
|
|
16
|
+
supports_tools = True
|
|
17
|
+
supports_thinking = True
|
|
18
|
+
supports_schema = True
|
|
19
|
+
|
|
20
|
+
def __init__(self, model: str | None = None, tools: list[str] | None = None) -> None:
|
|
21
|
+
self.model = model
|
|
22
|
+
self.tools = tools if tools is not None else ["python", "files"]
|
|
23
|
+
|
|
24
|
+
def _build_cmd(
|
|
25
|
+
self,
|
|
26
|
+
prompt: str,
|
|
27
|
+
format: str,
|
|
28
|
+
schema: Any,
|
|
29
|
+
thinking: bool | None,
|
|
30
|
+
) -> list[str]:
|
|
31
|
+
cmd = ["claude", "-p", prompt, "--output-format", "json", "--dangerously-skip-permissions"]
|
|
32
|
+
if self.model:
|
|
33
|
+
cmd.extend(["--model", self.model])
|
|
34
|
+
|
|
35
|
+
tools_arg = "default" if self.tools else ""
|
|
36
|
+
cmd.extend(["--tools", tools_arg])
|
|
37
|
+
|
|
38
|
+
if schema is not None:
|
|
39
|
+
cmd.extend(["--json-schema", json.dumps(to_json_schema(schema))])
|
|
40
|
+
elif format == "json":
|
|
41
|
+
cmd.extend(["--json-schema", json.dumps({"type": "object"})])
|
|
42
|
+
|
|
43
|
+
if thinking is not None:
|
|
44
|
+
cmd.extend(["--effort", "high" if thinking else "low"])
|
|
45
|
+
|
|
46
|
+
return cmd
|
|
47
|
+
|
|
48
|
+
def _parse_output(self, stdout: str) -> str:
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(stdout)
|
|
51
|
+
if data.get("is_error"):
|
|
52
|
+
err_msg = str(data.get("result", stdout))
|
|
53
|
+
if (
|
|
54
|
+
data.get("api_error_status") == 401
|
|
55
|
+
or "authentication" in err_msg.lower()
|
|
56
|
+
or "oauth" in err_msg.lower()
|
|
57
|
+
):
|
|
58
|
+
raise AuthenticationError(
|
|
59
|
+
"Claude authentication failed (OAuth access token is invalid or expired).\n"
|
|
60
|
+
"Please run 'claude auth login' in your terminal to re-authenticate, "
|
|
61
|
+
"or switch provider using: afsar.setup(provider='agy')"
|
|
62
|
+
)
|
|
63
|
+
raise ExecutionError(f"Claude error: {err_msg}")
|
|
64
|
+
if "structured_output" in data and data["structured_output"] is not None:
|
|
65
|
+
return json.dumps(data["structured_output"])
|
|
66
|
+
return str(data.get("result", ""))
|
|
67
|
+
except json.JSONDecodeError:
|
|
68
|
+
return stdout.strip()
|
|
69
|
+
|
|
70
|
+
def ask(
|
|
71
|
+
self,
|
|
72
|
+
prompt: str,
|
|
73
|
+
*,
|
|
74
|
+
format: str = "text",
|
|
75
|
+
schema: Any = None,
|
|
76
|
+
temperature: float | None = None,
|
|
77
|
+
thinking: bool | None = None,
|
|
78
|
+
) -> Any:
|
|
79
|
+
cmd = self._build_cmd(prompt, format, schema, thinking)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
res = subprocess.run(
|
|
83
|
+
cmd,
|
|
84
|
+
stdin=subprocess.DEVNULL,
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
check=False,
|
|
88
|
+
)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
raise ExecutionError(f"Failed to execute claude CLI: {e}") from e
|
|
91
|
+
|
|
92
|
+
if res.returncode != 0 and not res.stdout:
|
|
93
|
+
raise ExecutionError(
|
|
94
|
+
f"Claude CLI exited with code {res.returncode}: {res.stderr.strip()}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
result_str = self._parse_output(res.stdout)
|
|
98
|
+
if schema is not None:
|
|
99
|
+
return validate_schema_output(schema, result_str)
|
|
100
|
+
if format == "json":
|
|
101
|
+
return _strip_markdown_fence(result_str)
|
|
102
|
+
|
|
103
|
+
return result_str
|
afsar/adapters/gemini.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Google Gemini REST API adapter using standard library urllib."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from afsar.adapters.base import BaseAdapter
|
|
10
|
+
from afsar.exceptions import ExecutionError
|
|
11
|
+
from afsar.schema import _strip_markdown_fence, to_json_schema, validate_schema_output
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GeminiAdapter(BaseAdapter):
|
|
15
|
+
"""Adapter for Google Gemini REST API."""
|
|
16
|
+
|
|
17
|
+
name = "gemini"
|
|
18
|
+
supports_tools = False
|
|
19
|
+
supports_thinking = True
|
|
20
|
+
supports_schema = True
|
|
21
|
+
|
|
22
|
+
def __init__(self, model: str | None = None, api_key: str | None = None) -> None:
|
|
23
|
+
self.model = model or "gemini-2.0-flash"
|
|
24
|
+
self.api_key = api_key or os.getenv("GEMINI_API_KEY")
|
|
25
|
+
if not self.api_key:
|
|
26
|
+
raise ValueError("Gemini API key is required. Set GEMINI_API_KEY or pass api_key.")
|
|
27
|
+
|
|
28
|
+
def ask(
|
|
29
|
+
self,
|
|
30
|
+
prompt: str,
|
|
31
|
+
*,
|
|
32
|
+
format: str = "text",
|
|
33
|
+
schema: Any = None,
|
|
34
|
+
temperature: float | None = None,
|
|
35
|
+
thinking: bool | None = None,
|
|
36
|
+
) -> Any:
|
|
37
|
+
url = (
|
|
38
|
+
f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent"
|
|
39
|
+
f"?key={self.api_key}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
gen_config: dict[str, Any] = {}
|
|
43
|
+
if temperature is not None:
|
|
44
|
+
gen_config["temperature"] = temperature
|
|
45
|
+
|
|
46
|
+
if schema is not None:
|
|
47
|
+
gen_config["responseMimeType"] = "application/json"
|
|
48
|
+
gen_config["responseSchema"] = to_json_schema(schema)
|
|
49
|
+
elif format == "json":
|
|
50
|
+
gen_config["responseMimeType"] = "application/json"
|
|
51
|
+
|
|
52
|
+
payload: dict[str, Any] = {
|
|
53
|
+
"contents": [{"parts": [{"text": prompt}]}],
|
|
54
|
+
}
|
|
55
|
+
if gen_config:
|
|
56
|
+
payload["generationConfig"] = gen_config
|
|
57
|
+
|
|
58
|
+
req = urllib.request.Request(
|
|
59
|
+
url,
|
|
60
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
61
|
+
headers={"Content-Type": "application/json"},
|
|
62
|
+
method="POST",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with urllib.request.urlopen(req) as resp:
|
|
67
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
68
|
+
except urllib.error.HTTPError as e:
|
|
69
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
70
|
+
raise ExecutionError(f"Gemini API error ({e.code}): {error_body}") from e
|
|
71
|
+
except Exception as e:
|
|
72
|
+
raise ExecutionError(f"Failed to communicate with Gemini API: {e}") from e
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
candidates = data.get("candidates", [])
|
|
76
|
+
parts = candidates[0].get("content", {}).get("parts", [])
|
|
77
|
+
result_str = parts[0].get("text", "")
|
|
78
|
+
except (IndexError, KeyError) as e:
|
|
79
|
+
raise ExecutionError(f"Unexpected response structure from Gemini API: {data}") from e
|
|
80
|
+
|
|
81
|
+
if schema is not None:
|
|
82
|
+
return validate_schema_output(schema, result_str)
|
|
83
|
+
if format == "json":
|
|
84
|
+
return _strip_markdown_fence(result_str)
|
|
85
|
+
|
|
86
|
+
return result_str
|
afsar/adapters/openai.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""OpenAI REST API adapter using standard library urllib."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from afsar.adapters.base import BaseAdapter
|
|
10
|
+
from afsar.exceptions import ExecutionError
|
|
11
|
+
from afsar.schema import _strip_markdown_fence, to_json_schema, validate_schema_output
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenAIAdapter(BaseAdapter):
|
|
15
|
+
"""Adapter for OpenAI Chat Completions REST API."""
|
|
16
|
+
|
|
17
|
+
name = "openai"
|
|
18
|
+
supports_tools = False
|
|
19
|
+
supports_thinking = True
|
|
20
|
+
supports_schema = True
|
|
21
|
+
|
|
22
|
+
def __init__(self, model: str | None = None, api_key: str | None = None) -> None:
|
|
23
|
+
self.model = model or "gpt-4o-mini"
|
|
24
|
+
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
|
|
25
|
+
if not self.api_key:
|
|
26
|
+
raise ValueError("OpenAI API key is required. Set OPENAI_API_KEY or pass api_key.")
|
|
27
|
+
|
|
28
|
+
def ask(
|
|
29
|
+
self,
|
|
30
|
+
prompt: str,
|
|
31
|
+
*,
|
|
32
|
+
format: str = "text",
|
|
33
|
+
schema: Any = None,
|
|
34
|
+
temperature: float | None = None,
|
|
35
|
+
thinking: bool | None = None,
|
|
36
|
+
) -> Any:
|
|
37
|
+
url = "https://api.openai.com/v1/chat/completions"
|
|
38
|
+
|
|
39
|
+
payload: dict[str, Any] = {
|
|
40
|
+
"model": self.model,
|
|
41
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
42
|
+
}
|
|
43
|
+
if temperature is not None:
|
|
44
|
+
payload["temperature"] = temperature
|
|
45
|
+
|
|
46
|
+
if schema is not None:
|
|
47
|
+
schema_dict = to_json_schema(schema)
|
|
48
|
+
payload["response_format"] = {
|
|
49
|
+
"type": "json_schema",
|
|
50
|
+
"json_schema": {
|
|
51
|
+
"name": getattr(schema, "__name__", "ResponseSchema"),
|
|
52
|
+
"strict": False,
|
|
53
|
+
"schema": schema_dict,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
elif format == "json":
|
|
57
|
+
payload["response_format"] = {"type": "json_object"}
|
|
58
|
+
|
|
59
|
+
req = urllib.request.Request(
|
|
60
|
+
url,
|
|
61
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
62
|
+
headers={
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
65
|
+
},
|
|
66
|
+
method="POST",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
with urllib.request.urlopen(req) as resp:
|
|
71
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
72
|
+
except urllib.error.HTTPError as e:
|
|
73
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
74
|
+
raise ExecutionError(f"OpenAI API error ({e.code}): {error_body}") from e
|
|
75
|
+
except Exception as e:
|
|
76
|
+
raise ExecutionError(f"Failed to communicate with OpenAI API: {e}") from e
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
choices = data.get("choices", [])
|
|
80
|
+
result_str = choices[0].get("message", {}).get("content", "")
|
|
81
|
+
except (IndexError, KeyError) as e:
|
|
82
|
+
raise ExecutionError(f"Unexpected response structure from OpenAI API: {data}") from e
|
|
83
|
+
|
|
84
|
+
if schema is not None:
|
|
85
|
+
return validate_schema_output(schema, result_str)
|
|
86
|
+
if format == "json":
|
|
87
|
+
return _strip_markdown_fence(result_str)
|
|
88
|
+
|
|
89
|
+
return result_str
|
afsar/core.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Core session management and procedural API for afsar."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from afsar.adapters.base import BaseAdapter
|
|
9
|
+
from afsar.exceptions import (
|
|
10
|
+
ModelNotFoundError,
|
|
11
|
+
NotSetupError,
|
|
12
|
+
UnsupportedFeatureError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_active_adapter: BaseAdapter | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_active_adapter() -> BaseAdapter | None:
|
|
19
|
+
"""Return the currently configured adapter, or None if not setup."""
|
|
20
|
+
return _active_adapter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def reset() -> None:
|
|
24
|
+
"""Reset the global afsar session state. Useful for tests."""
|
|
25
|
+
global _active_adapter
|
|
26
|
+
_active_adapter = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_explicit_provider(provider: str) -> str:
|
|
30
|
+
"""Normalize and validate an explicitly provided provider string."""
|
|
31
|
+
normalized = provider.strip().lower()
|
|
32
|
+
alias_map = {
|
|
33
|
+
"claude": "claude",
|
|
34
|
+
"claude-code": "claude",
|
|
35
|
+
"agy": "agy",
|
|
36
|
+
"antigravity": "agy",
|
|
37
|
+
"gemini": "gemini",
|
|
38
|
+
"openai": "openai",
|
|
39
|
+
}
|
|
40
|
+
if normalized not in alias_map:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"Unknown provider '{provider}'. Supported: 'claude', 'agy', 'gemini', 'openai'."
|
|
43
|
+
)
|
|
44
|
+
return alias_map[normalized]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _find_candidate_providers(api_key: str | None = None) -> list[str]:
|
|
48
|
+
"""Auto-detect available providers according to priority."""
|
|
49
|
+
candidates: list[str] = []
|
|
50
|
+
if shutil.which("claude") is not None:
|
|
51
|
+
candidates.append("claude")
|
|
52
|
+
if shutil.which("agy") is not None:
|
|
53
|
+
candidates.append("agy")
|
|
54
|
+
if api_key or os.getenv("GEMINI_API_KEY"):
|
|
55
|
+
candidates.append("gemini")
|
|
56
|
+
if api_key or os.getenv("OPENAI_API_KEY"):
|
|
57
|
+
candidates.append("openai")
|
|
58
|
+
return candidates
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect_providers(
|
|
62
|
+
*,
|
|
63
|
+
provider: str | None = None,
|
|
64
|
+
api_key: str | None = None,
|
|
65
|
+
) -> list[str]:
|
|
66
|
+
"""Detect candidate providers based on explicit overrides, env vars, or auto-detection."""
|
|
67
|
+
chosen = provider or os.getenv("AFSAR_PROVIDER")
|
|
68
|
+
if chosen is not None:
|
|
69
|
+
return [_normalize_explicit_provider(chosen)]
|
|
70
|
+
|
|
71
|
+
candidates = _find_candidate_providers(api_key=api_key)
|
|
72
|
+
if not candidates:
|
|
73
|
+
raise ModelNotFoundError(
|
|
74
|
+
"No local agent (claude-code, agy) or remote API keys (GEMINI_API_KEY, OPENAI_API_KEY) found."
|
|
75
|
+
)
|
|
76
|
+
return candidates
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def detect_provider(
|
|
80
|
+
*,
|
|
81
|
+
provider: str | None = None,
|
|
82
|
+
api_key: str | None = None,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Detect the first provider based on explicit overrides or auto-detection."""
|
|
85
|
+
return detect_providers(provider=provider, api_key=api_key)[0]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _create_adapter(
|
|
89
|
+
provider: str,
|
|
90
|
+
model: str | None,
|
|
91
|
+
api_key: str | None,
|
|
92
|
+
tools: Sequence[str] | None,
|
|
93
|
+
) -> BaseAdapter:
|
|
94
|
+
"""Instantiate the corresponding adapter instance for a provider."""
|
|
95
|
+
tool_list = list(tools) if tools else []
|
|
96
|
+
if provider == "claude":
|
|
97
|
+
from afsar.adapters.claude import ClaudeAdapter
|
|
98
|
+
|
|
99
|
+
return ClaudeAdapter(model=model, tools=tool_list)
|
|
100
|
+
if provider == "agy":
|
|
101
|
+
from afsar.adapters.agy import AgyAdapter
|
|
102
|
+
|
|
103
|
+
return AgyAdapter(model=model, tools=tool_list)
|
|
104
|
+
if provider == "gemini":
|
|
105
|
+
from afsar.adapters.gemini import GeminiAdapter
|
|
106
|
+
|
|
107
|
+
return GeminiAdapter(model=model, api_key=api_key)
|
|
108
|
+
if provider == "openai":
|
|
109
|
+
from afsar.adapters.openai import OpenAIAdapter
|
|
110
|
+
|
|
111
|
+
return OpenAIAdapter(model=model, api_key=api_key)
|
|
112
|
+
|
|
113
|
+
raise ModelNotFoundError(f"Unsupported provider '{provider}'.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def setup(
|
|
117
|
+
model: str | None = None,
|
|
118
|
+
api_key: str | None = None,
|
|
119
|
+
tools: Sequence[str] | None = ("python", "files"),
|
|
120
|
+
provider: str | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Configure the active LLM provider and tool settings."""
|
|
123
|
+
global _active_adapter
|
|
124
|
+
|
|
125
|
+
detected = detect_provider(provider=provider, api_key=api_key)
|
|
126
|
+
|
|
127
|
+
if bool(tools) and detected not in {"claude", "agy"}:
|
|
128
|
+
raise UnsupportedFeatureError(
|
|
129
|
+
f"Remote models ({detected}) do not support local code execution or file tools yet.\n"
|
|
130
|
+
f"To use remote models, explicitly initialize with: afsar.setup(tools=[])"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
_active_adapter = _create_adapter(detected, model, api_key, tools)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def ask(
|
|
137
|
+
prompt: str,
|
|
138
|
+
*,
|
|
139
|
+
format: str = "text",
|
|
140
|
+
schema: Any = None,
|
|
141
|
+
temperature: float | None = None,
|
|
142
|
+
thinking: bool | None = None,
|
|
143
|
+
) -> Any:
|
|
144
|
+
"""Send a prompt to the configured LLM and return the answer."""
|
|
145
|
+
if _active_adapter is None:
|
|
146
|
+
raise NotSetupError(
|
|
147
|
+
"afsar is not configured. Please call afsar.setup() before afsar.ask()."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if format not in {"text", "json"}:
|
|
151
|
+
raise ValueError(f"Invalid format '{format}'. Supported formats: 'text', 'json'.")
|
|
152
|
+
|
|
153
|
+
if thinking is True and not _active_adapter.supports_thinking:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"Thinking mode requested, but adapter '{_active_adapter.name}' does not support thinking."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return _active_adapter.ask(
|
|
159
|
+
prompt,
|
|
160
|
+
format=format,
|
|
161
|
+
schema=schema,
|
|
162
|
+
temperature=temperature,
|
|
163
|
+
thinking=thinking,
|
|
164
|
+
)
|
afsar/exceptions.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Custom exception hierarchy for afsar."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AfsarError(Exception):
|
|
5
|
+
"""Base exception for all afsar errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotSetupError(AfsarError):
|
|
9
|
+
"""Raised when afsar.ask() is called before afsar.setup()."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ModelNotFoundError(AfsarError):
|
|
13
|
+
"""Raised when no local agent or remote API key can be found."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsupportedFeatureError(AfsarError):
|
|
17
|
+
"""Raised when a requested feature or tool is unsupported by the selected model."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExecutionError(AfsarError):
|
|
21
|
+
"""Raised when an underlying agent execution fails."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthenticationError(ExecutionError):
|
|
25
|
+
"""Raised when an underlying agent fails due to missing or invalid authentication."""
|
|
Binary file
|
afsar/schema.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Schema extraction and validation helpers without mandatory dependencies."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any, cast, get_type_hints
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_pydantic_model(cls: Any) -> bool:
|
|
10
|
+
"""Check if cls is a Pydantic model class without importing pydantic at module load."""
|
|
11
|
+
return isinstance(cls, type) and (
|
|
12
|
+
hasattr(cls, "model_json_schema") or hasattr(cls, "parse_raw")
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _infer_type_string(field_type: Any) -> str:
|
|
17
|
+
"""Map a Python type to a JSON schema type string."""
|
|
18
|
+
if field_type in (int, float):
|
|
19
|
+
return "number" if field_type is float else "integer"
|
|
20
|
+
if field_type is bool:
|
|
21
|
+
return "boolean"
|
|
22
|
+
if getattr(field_type, "__origin__", None) in (list, Sequence):
|
|
23
|
+
return "array"
|
|
24
|
+
return "string"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _dataclass_to_json_schema(schema_cls: Any) -> dict[str, Any]:
|
|
28
|
+
"""Convert a dataclass to a JSON schema dict."""
|
|
29
|
+
properties: dict[str, Any] = {}
|
|
30
|
+
required: list[str] = []
|
|
31
|
+
try:
|
|
32
|
+
type_hints = get_type_hints(schema_cls)
|
|
33
|
+
except Exception:
|
|
34
|
+
type_hints = {}
|
|
35
|
+
|
|
36
|
+
for f in dataclasses.fields(schema_cls):
|
|
37
|
+
field_type = type_hints.get(f.name, f.type)
|
|
38
|
+
properties[f.name] = {"type": _infer_type_string(field_type)}
|
|
39
|
+
if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING:
|
|
40
|
+
required.append(f.name)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": properties,
|
|
45
|
+
"required": required,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def to_json_schema(schema_cls: Any) -> dict[str, Any]:
|
|
50
|
+
"""Convert a schema class (Pydantic, dataclass, or dict) to a standard JSON schema dict."""
|
|
51
|
+
if isinstance(schema_cls, dict):
|
|
52
|
+
return schema_cls
|
|
53
|
+
|
|
54
|
+
if is_pydantic_model(schema_cls):
|
|
55
|
+
if hasattr(schema_cls, "model_json_schema"):
|
|
56
|
+
return cast(dict[str, Any], schema_cls.model_json_schema())
|
|
57
|
+
if hasattr(schema_cls, "schema"):
|
|
58
|
+
return cast(dict[str, Any], schema_cls.schema())
|
|
59
|
+
|
|
60
|
+
if dataclasses.is_dataclass(schema_cls):
|
|
61
|
+
return _dataclass_to_json_schema(schema_cls)
|
|
62
|
+
|
|
63
|
+
raise TypeError(
|
|
64
|
+
f"Unsupported schema type '{schema_cls}'. Pass a Pydantic model, @dataclass, or JSON schema dict."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _strip_markdown_fence(raw_text: str) -> str:
|
|
69
|
+
"""Strip markdown code fence blocks (```json ... ```) if present."""
|
|
70
|
+
cleaned = raw_text.strip()
|
|
71
|
+
if "```json" in cleaned:
|
|
72
|
+
cleaned = cleaned.split("```json", 1)[1].split("```", 1)[0].strip()
|
|
73
|
+
elif "```" in cleaned:
|
|
74
|
+
cleaned = cleaned.split("```", 1)[1].split("```", 1)[0].strip()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
json.loads(cleaned)
|
|
78
|
+
return cleaned
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
start = cleaned.find("{")
|
|
81
|
+
end = cleaned.rfind("}")
|
|
82
|
+
if start != -1 and end > start:
|
|
83
|
+
candidate = cleaned[start : end + 1]
|
|
84
|
+
try:
|
|
85
|
+
json.loads(candidate)
|
|
86
|
+
return candidate
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
pass
|
|
89
|
+
return cleaned
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate_schema_output(schema_cls: Any, raw_json_str: str) -> Any:
|
|
93
|
+
"""Validate and instantiate the target schema class from a raw JSON string."""
|
|
94
|
+
cleaned = _strip_markdown_fence(raw_json_str)
|
|
95
|
+
|
|
96
|
+
if is_pydantic_model(schema_cls):
|
|
97
|
+
if hasattr(schema_cls, "model_validate_json"):
|
|
98
|
+
return schema_cls.model_validate_json(cleaned)
|
|
99
|
+
if hasattr(schema_cls, "parse_raw"):
|
|
100
|
+
return schema_cls.parse_raw(cleaned)
|
|
101
|
+
|
|
102
|
+
parsed = json.loads(cleaned)
|
|
103
|
+
if dataclasses.is_dataclass(schema_cls):
|
|
104
|
+
cls_factory = cast(Any, schema_cls)
|
|
105
|
+
if isinstance(parsed, dict):
|
|
106
|
+
field_names = {f.name for f in dataclasses.fields(schema_cls)}
|
|
107
|
+
filtered = {k: v for k, v in parsed.items() if k in field_names}
|
|
108
|
+
return cls_factory(**filtered)
|
|
109
|
+
return cls_factory(**parsed)
|
|
110
|
+
|
|
111
|
+
return parsed
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: afsar
|
|
3
|
+
Version: 1.0
|
|
4
|
+
Summary: LLM API library for students utilizing local agents or remote APIs
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Provides-Extra: dev
|
|
7
|
+
Requires-Dist: mypy>=1.9.0; extra == 'dev'
|
|
8
|
+
Requires-Dist: pre-commit>=3.6.0; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff>=0.17.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: xenon>=0.9.0; extra == 'dev'
|
|
12
|
+
Provides-Extra: schema
|
|
13
|
+
Requires-Dist: pydantic>=2.0.0; extra == 'schema'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="src/afsar/resources/afsar.png" alt="afsar logo" width="180" />
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
# afsar
|
|
21
|
+
|
|
22
|
+
A simple, student-friendly procedural Python library providing a uniform interface to LLMs. `afsar` prioritizes installed local coding agents (`claude-code`, `antigravity`) so students can use their existing agents and tools, and smoothly falls back to remote cloud APIs (`Gemini`, `OpenAI`).
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Install via pip:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install afsar
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
### 1. Basic Text Query
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import afsar
|
|
42
|
+
|
|
43
|
+
# Setup is explicit and required once before asking
|
|
44
|
+
afsar.setup()
|
|
45
|
+
|
|
46
|
+
answer = afsar.ask("How do you define a function in Python?")
|
|
47
|
+
print(answer)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. Structured Output with `@dataclass`
|
|
51
|
+
|
|
52
|
+
You don't need Pydantic to get typed objects. Standard Python dataclasses work out of the box:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from dataclasses import dataclass
|
|
56
|
+
import afsar
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class QuizQuestion:
|
|
61
|
+
question: str
|
|
62
|
+
points: int
|
|
63
|
+
topic: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
afsar.setup()
|
|
67
|
+
quiz = afsar.ask("Create a beginner Python question.", schema=QuizQuestion)
|
|
68
|
+
|
|
69
|
+
print(quiz.question)
|
|
70
|
+
print(f"Points: {quiz.points}")
|
|
71
|
+
print(f"Topic: {quiz.topic}")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 3. Raw JSON Output
|
|
75
|
+
|
|
76
|
+
If you just want a JSON-formatted string:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import afsar
|
|
80
|
+
|
|
81
|
+
afsar.setup()
|
|
82
|
+
raw_json = afsar.ask("List 3 Python data structures.", format="json")
|
|
83
|
+
print(raw_json) # Returns clean JSON string
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 4. Student Coding Exercises (Local Tools)
|
|
87
|
+
|
|
88
|
+
When using local agents (`claude` or `agy`), `afsar` defaults to `tools=["python", "files"]`. This allows asking the agent to write and test code locally:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import afsar
|
|
92
|
+
|
|
93
|
+
afsar.setup()
|
|
94
|
+
response = afsar.ask(
|
|
95
|
+
"Write a solution to check if a word is a palindrome, save it to palindrome.py, run it with python, and show the result."
|
|
96
|
+
)
|
|
97
|
+
print(response)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Remote APIs (Gemini & OpenAI)
|
|
103
|
+
|
|
104
|
+
Remote models do not support local file or execution tools. When connecting to remote APIs, explicitly pass `tools=[]`:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import afsar
|
|
108
|
+
|
|
109
|
+
# Using Gemini
|
|
110
|
+
afsar.setup(provider="gemini", api_key="YOUR_GEMINI_KEY", tools=[])
|
|
111
|
+
|
|
112
|
+
# Or using OpenAI
|
|
113
|
+
afsar.setup(provider="openai", api_key="YOUR_OPENAI_KEY", tools=[])
|
|
114
|
+
|
|
115
|
+
answer = afsar.ask("Explain recursion in simple terms.")
|
|
116
|
+
print(answer)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Auto-Detection & Priority
|
|
122
|
+
|
|
123
|
+
Calling `afsar.setup()` without arguments automatically searches for available providers in this order:
|
|
124
|
+
|
|
125
|
+
1. **Claude Code CLI** (`claude`) in `PATH`
|
|
126
|
+
2. **Antigravity CLI** (`agy`) in `PATH`
|
|
127
|
+
3. **Google Gemini** (`GEMINI_API_KEY` in environment)
|
|
128
|
+
4. **OpenAI** (`OPENAI_API_KEY` in environment)
|
|
129
|
+
|
|
130
|
+
You can override the provider explicitly in code:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
afsar.setup(provider="agy")
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Or via the `AFSAR_PROVIDER` environment variable:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
export AFSAR_PROVIDER=agy
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Exceptions
|
|
145
|
+
|
|
146
|
+
- `NotSetupError`: Raised if `afsar.ask()` is called before `afsar.setup()`.
|
|
147
|
+
- `UnsupportedFeatureError`: Raised if requesting local tools (`tools=["python", "files"]`) on remote API providers.
|
|
148
|
+
- `ModelNotFoundError`: Raised if no supported local CLI or remote API key is discovered.
|
|
149
|
+
- `ExecutionError`: Raised if a CLI command or remote API request fails.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
afsar/__init__.py,sha256=vF9I9mmYi85I7WxQn8PuTO3ROivRWnnYYyb9qWqS5SU,535
|
|
2
|
+
afsar/core.py,sha256=0shFUDzqg7rHgGi5-EIR-jcE8nRlH_VxCJT7qMTVLCY,5046
|
|
3
|
+
afsar/exceptions.py,sha256=TNtpkYc-Q_g1O95TM8KI9iaW-Br6NMTeBjmtmoYFGg0,695
|
|
4
|
+
afsar/schema.py,sha256=NwykFkX_zyhSLxrPyRtBhm73JR3RfWNg5c39e0MxIxQ,3864
|
|
5
|
+
afsar/adapters/__init__.py,sha256=_W4xYYSYTfCmTIN2Nq7vhS72ffwBfc1yFqjJRuHRjPU,380
|
|
6
|
+
afsar/adapters/agy.py,sha256=2HboL_Bz0T9nHo-ZkuSH_3SXo-smtAfdVwX6v7204cY,2804
|
|
7
|
+
afsar/adapters/base.py,sha256=fSfvb1X7X5QHN8GMdudY_r2cEKzF8WtEC2MDTTU8nZc,616
|
|
8
|
+
afsar/adapters/claude.py,sha256=8fsmG9KCSdAa0W5v0Av2P58wRBFKwaEZcAOVrZXpbf8,3541
|
|
9
|
+
afsar/adapters/gemini.py,sha256=Flzxp1qniYvRQOls4aLyE6XDJhQlGJyA-M53016Lyg4,2963
|
|
10
|
+
afsar/adapters/openai.py,sha256=kyud17lkVcloPisIcShrwzg5DOAbKgFTqvMFFbpQqrk,3039
|
|
11
|
+
afsar/resources/afsar.png,sha256=VMJvk2p-0zA2zGvc_5IAi5Vz64EWEZrRJEioBpbTplM,25240
|
|
12
|
+
afsar-1.0.dist-info/METADATA,sha256=BIX2jPoYGwhiNqCb5V1QWB3KfArPJWQXhNfdkYJHSPM,3615
|
|
13
|
+
afsar-1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
14
|
+
afsar-1.0.dist-info/RECORD,,
|