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 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."""
@@ -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
@@ -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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any