jsat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. jsat/__init__.py +38 -0
  2. jsat/_ai/__init__.py +90 -0
  3. jsat/_ai/anthropic.py +80 -0
  4. jsat/_ai/claude_cli.py +278 -0
  5. jsat/_ai/none.py +60 -0
  6. jsat/_ai/ollama.py +97 -0
  7. jsat/_ai/openai.py +81 -0
  8. jsat/_ai/openai_compat.py +107 -0
  9. jsat/_cache/__init__.py +17 -0
  10. jsat/_cache/disk.py +93 -0
  11. jsat/_cache/memory.py +84 -0
  12. jsat/_cache/redis.py +139 -0
  13. jsat/_config.py +469 -0
  14. jsat/_core.py +416 -0
  15. jsat/_embed/__init__.py +37 -0
  16. jsat/_embed/local.py +53 -0
  17. jsat/_embed/none.py +42 -0
  18. jsat/_embed/openai.py +61 -0
  19. jsat/_exceptions.py +253 -0
  20. jsat/_graph/__init__.py +87 -0
  21. jsat/_graph/lightgraph.py +140 -0
  22. jsat/_graph/neo4j.py +141 -0
  23. jsat/_graph/sqlite.py +170 -0
  24. jsat/_models.py +233 -0
  25. jsat/_parsers/__init__.py +46 -0
  26. jsat/_parsers/go.py +135 -0
  27. jsat/_parsers/javascript.py +135 -0
  28. jsat/_parsers/python.py +149 -0
  29. jsat/cli.py +1273 -0
  30. jsat/mcp/__init__.py +1 -0
  31. jsat/mcp/server.py +194 -0
  32. jsat/mcp/tools.py +162 -0
  33. jsat/skills/__init__.py +1 -0
  34. jsat/skills/clusters.py +44 -0
  35. jsat/skills/manifest.py +82 -0
  36. jsat/skills/registry.py +81 -0
  37. jsat/tools/__init__.py +18 -0
  38. jsat/tools/blast_radius.py +127 -0
  39. jsat/tools/contract.py +106 -0
  40. jsat/tools/export.py +131 -0
  41. jsat/tools/feature.py +85 -0
  42. jsat/tools/incident.py +136 -0
  43. jsat/tools/indexer.py +130 -0
  44. jsat/tools/ithinking.py +126 -0
  45. jsat/tools/knowledge.py +100 -0
  46. jsat/tools/migration.py +128 -0
  47. jsat/tools/orchestrator.py +101 -0
  48. jsat/tools/query.py +88 -0
  49. jsat/tools/review.py +116 -0
  50. jsat/tools/security.py +97 -0
  51. jsat/tools/shell.py +720 -0
  52. jsat/tools/test_helper.py +81 -0
  53. jsat-0.1.0.dist-info/METADATA +209 -0
  54. jsat-0.1.0.dist-info/RECORD +56 -0
  55. jsat-0.1.0.dist-info/WHEEL +4 -0
  56. jsat-0.1.0.dist-info/entry_points.txt +2 -0
jsat/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """
2
+ JSAT — JaySoft AI Tools
3
+ Codebase intelligence shell and SDK. Lightweight by default.
4
+
5
+ pip install jsat # core only (~80MB)
6
+ pip install jsat[local] # + Ollama
7
+ pip install jsat[standard] # + analysis tools
8
+ pip install jsat[team] # + Neo4j, Qdrant, Redis
9
+ pip install jsat[all] # everything
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from jsat._core import JSAT
14
+ from jsat._config import load_config
15
+ from jsat._exceptions import (
16
+ JSATError, ConfigError, ConfigFileNotFound, ConfigSchemaError,
17
+ MissingRequiredConfig, IndexError, IndexNotFound, IndexCorrupted,
18
+ IndexOutOfDate, UnsupportedLanguage, AIError, AIProviderError,
19
+ AIRateLimitError, AITimeoutError, AIContextLengthError, AIAuthError,
20
+ GraphError, GraphConnectionError, GraphQueryError, GraphCapacityError,
21
+ ProfileError, ExportError, ExportPermissionError, ImportVersionMismatch,
22
+ ImportCorrupted, SkillError, SkillNotFound, SkillManifestError,
23
+ SkillExecutionError,
24
+ )
25
+
26
+ __version__ = "0.1.0"
27
+ __author__ = "Jay Prakash Sonkar"
28
+ __email__ = "iamjpsonkar@gmail.com"
29
+ __license__ = "MIT"
30
+
31
+ __all__ = [
32
+ "JSAT",
33
+ "load_config",
34
+ "JSATError",
35
+ "__version__",
36
+ "__author__",
37
+ "__email__",
38
+ ]
jsat/_ai/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ """jsat._ai — AIProvider ABC and factory."""
2
+ from __future__ import annotations
3
+
4
+ from abc import ABC, abstractmethod
5
+ from typing import Any, Iterator
6
+
7
+
8
+ class AIProvider(ABC):
9
+ """Contract all AI/LLM backends must implement."""
10
+
11
+ @abstractmethod
12
+ def complete(self, prompt: str, max_tokens: int = 2048, temperature: float = 0.1) -> str: ...
13
+
14
+ @abstractmethod
15
+ async def complete_async(self, prompt: str, max_tokens: int = 2048, temperature: float = 0.1) -> str: ...
16
+
17
+ @abstractmethod
18
+ def stream(self, prompt: str, max_tokens: int = 2048) -> Iterator[str]: ...
19
+
20
+ @property
21
+ @abstractmethod
22
+ def provider_name(self) -> str: ...
23
+
24
+ @property
25
+ @abstractmethod
26
+ def model_name(self) -> str: ...
27
+
28
+ def is_available(self) -> bool:
29
+ return True
30
+
31
+
32
+ def get_ai_provider(cfg: Any) -> AIProvider:
33
+ """Factory: returns the right AIProvider based on cfg.ai.provider."""
34
+ import structlog
35
+ log = structlog.get_logger(__name__)
36
+
37
+ provider_name: str = getattr(getattr(cfg, "ai", None), "provider", "none") or "none"
38
+ log.info("ai_provider_factory", provider=provider_name)
39
+
40
+ if provider_name == "none":
41
+ from jsat._ai.none import NoOpProvider
42
+ return NoOpProvider()
43
+
44
+ if provider_name == "claude_cli":
45
+ from jsat._ai.claude_cli import ClaudeCliProvider
46
+ return ClaudeCliProvider(cfg)
47
+
48
+ if provider_name == "ollama":
49
+ try:
50
+ from jsat._ai.ollama import OllamaProvider
51
+ return OllamaProvider(cfg)
52
+ except ImportError as e:
53
+ _profile_error("ollama", "local", e)
54
+
55
+ if provider_name == "anthropic":
56
+ try:
57
+ from jsat._ai.anthropic import AnthropicProvider # type: ignore[import]
58
+ return AnthropicProvider(cfg)
59
+ except ImportError as e:
60
+ _profile_error("anthropic", "anthropic", e)
61
+
62
+ if provider_name == "openai":
63
+ try:
64
+ from jsat._ai.openai import OpenAIProvider # type: ignore[import]
65
+ return OpenAIProvider(cfg)
66
+ except ImportError as e:
67
+ _profile_error("openai", "openai", e)
68
+
69
+ raise ValueError(
70
+ f"Unknown ai.provider '{provider_name}'. "
71
+ "Valid: none, ollama, anthropic, openai. Run: jsat init --profile solo"
72
+ )
73
+
74
+
75
+ def _profile_error(provider: str, extra: str, cause: ImportError) -> None:
76
+ import structlog
77
+ structlog.get_logger(__name__).error(
78
+ "ai_provider_import_failed", provider=provider, extra=extra, error=str(cause)
79
+ )
80
+ try:
81
+ from jsat._exceptions import ProfileError
82
+ raise ProfileError(
83
+ f"AI provider '{provider}' requires jsat[{extra}].\n"
84
+ f"Install: pip install 'jsat[{extra}]'"
85
+ ) from cause
86
+ except ImportError:
87
+ raise ImportError(f"Install: pip install 'jsat[{extra}]'") from cause
88
+
89
+
90
+ __all__ = ["AIProvider", "get_ai_provider"]
jsat/_ai/anthropic.py ADDED
@@ -0,0 +1,80 @@
1
+ """jsat._ai.anthropic — Anthropic/Claude AI provider (jsat[anthropic] extra)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import os
6
+ import time
7
+ from typing import TYPE_CHECKING, Iterator
8
+
9
+ from jsat._ai import AIProvider
10
+ from jsat._exceptions import AIAuthError, AIRateLimitError, AITimeoutError, ProfileError
11
+
12
+ if TYPE_CHECKING:
13
+ from jsat._models import JSATConfig
14
+
15
+
16
+ class AnthropicProvider(AIProvider):
17
+ """AI provider backed by the Anthropic SDK."""
18
+
19
+ def __init__(self, cfg: JSATConfig) -> None:
20
+ import structlog
21
+ self._log = structlog.get_logger(__name__)
22
+ try:
23
+ import anthropic as _anthropic
24
+ self._anthropic = _anthropic
25
+ except ImportError as e:
26
+ raise ProfileError(
27
+ "Anthropic SDK not installed.\nInstall: pip install 'jsat[anthropic]'",
28
+ required_extra="anthropic",
29
+ ) from e
30
+
31
+ self._model: str = cfg.ai.model or "claude-sonnet-4-6"
32
+ api_key_env = cfg.ai.api_key_env or "ANTHROPIC_API_KEY"
33
+ self._client = self._anthropic.Anthropic(api_key=os.environ.get(api_key_env))
34
+ self._log.info("anthropic_init", model=self._model,
35
+ api_key_set=bool(os.environ.get(api_key_env)))
36
+
37
+ @property
38
+ def provider_name(self) -> str:
39
+ return "anthropic"
40
+
41
+ @property
42
+ def model_name(self) -> str:
43
+ return self._model
44
+
45
+ def complete(self, prompt: str, max_tokens: int = 2048, temperature: float = 0.1) -> str:
46
+ self._log.debug("anthropic_complete", prompt_len=len(prompt), max_tokens=max_tokens)
47
+ t0 = time.monotonic()
48
+ try:
49
+ resp = self._client.messages.create(
50
+ model=self._model, max_tokens=max_tokens, temperature=temperature,
51
+ messages=[{"role": "user", "content": prompt}],
52
+ )
53
+ except self._anthropic.RateLimitError as e:
54
+ raise AIRateLimitError("Anthropic rate limit", provider="anthropic") from e
55
+ except self._anthropic.AuthenticationError as e:
56
+ raise AIAuthError(provider="anthropic") from e
57
+ except self._anthropic.APITimeoutError as e:
58
+ raise AITimeoutError("Anthropic timeout", provider="anthropic",
59
+ timeout_seconds=120) from e
60
+ elapsed = round((time.monotonic() - t0) * 1000)
61
+ text: str = resp.content[0].text
62
+ self._log.info("anthropic_complete_done", response_len=len(text), duration_ms=elapsed)
63
+ return text
64
+
65
+ async def complete_async(self, prompt: str, max_tokens: int = 2048, temperature: float = 0.1) -> str:
66
+ return await asyncio.to_thread(self.complete, prompt, max_tokens, temperature)
67
+
68
+ def stream(self, prompt: str, max_tokens: int = 2048) -> Iterator[str]:
69
+ with self._client.messages.stream(
70
+ model=self._model, max_tokens=max_tokens,
71
+ messages=[{"role": "user", "content": prompt}],
72
+ ) as s:
73
+ yield from s.text_stream
74
+
75
+ def is_available(self) -> bool:
76
+ try:
77
+ self._client.models.list()
78
+ return True
79
+ except Exception:
80
+ return False
jsat/_ai/claude_cli.py ADDED
@@ -0,0 +1,278 @@
1
+ """
2
+ jsat._ai.claude_cli — Full Claude CLI integration.
3
+
4
+ Uses the real 'claude' binary with:
5
+ --resume <session-id> → multi-turn conversation with full history
6
+ --system-prompt → inject codebase context
7
+ --add-dir <repo> → claude can read/write files in the project
8
+ --output-format stream-json → real streaming
9
+ --model <model> → model selection
10
+
11
+ This gives ALL claude CLI features inside jsat shell:
12
+ ✓ Multi-turn conversation memory
13
+ ✓ File reading / writing (Claude's Edit/Read tools)
14
+ ✓ Bash execution (Claude's Bash tool)
15
+ ✓ Model selection
16
+ ✓ All claude slash commands
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import shutil
23
+ import subprocess
24
+ import time
25
+ from typing import Iterator
26
+
27
+ from jsat._ai import AIProvider
28
+
29
+
30
+ class ClaudeCliProvider(AIProvider):
31
+ """Full-featured Claude CLI provider with session continuity."""
32
+
33
+ def __init__(self, cfg=None) -> None:
34
+ import structlog
35
+ self._log = structlog.get_logger(__name__)
36
+
37
+ self._binary = shutil.which("claude") or "claude"
38
+ self._model = getattr(getattr(cfg, "ai", None), "model", None) or "claude-sonnet-4-6"
39
+ self._timeout = getattr(getattr(cfg, "ai", None), "timeout_seconds", None) or 180
40
+
41
+ # Session state
42
+ self._session_id: str | None = None
43
+ self._call_count: int = 0
44
+ self._repo_dir: str | None = None
45
+ self._system_prompt: str | None = None
46
+ # stateful=False (default): each call independent — for MCP server
47
+ # stateful=True: uses --continue for multi-turn — for interactive shell
48
+ self._stateful: bool = False
49
+
50
+ if not shutil.which("claude"):
51
+ self._log.warning(
52
+ "claude_cli_not_found",
53
+ message="'claude' binary not in PATH. Install: https://claude.ai/code",
54
+ )
55
+ else:
56
+ self._log.info("claude_cli_init", binary=self._binary, model=self._model)
57
+
58
+ # ── Configuration (called by JSATShell before first message) ─────────────
59
+
60
+ def configure(self, repo_dir: str | None = None,
61
+ system_prompt: str | None = None,
62
+ stateful: bool = True) -> None:
63
+ """Inject repo context and enable stateful (multi-turn) mode.
64
+
65
+ Call this from the interactive shell before the first complete().
66
+ Do NOT call from the MCP server — MCP uses stateless mode by default.
67
+ """
68
+ self._repo_dir = repo_dir
69
+ self._system_prompt = system_prompt
70
+ self._stateful = stateful
71
+
72
+ def new_session(self) -> None:
73
+ """Start a fresh conversation (clears history)."""
74
+ self._session_id = None
75
+ self._call_count = 0
76
+ self._log.info("claude_cli_new_session")
77
+
78
+ @property
79
+ def session_id(self) -> str | None:
80
+ return self._session_id
81
+
82
+ # ── AIProvider interface ──────────────────────────────────────────────────
83
+
84
+ @property
85
+ def provider_name(self) -> str:
86
+ return "claude_cli"
87
+
88
+ @property
89
+ def model_name(self) -> str:
90
+ return self._model
91
+
92
+ def is_available(self) -> bool:
93
+ return bool(shutil.which("claude"))
94
+
95
+ def _build_args(self, prompt: str, stream: bool = False) -> list[str]:
96
+ """Build the full claude CLI command for this call.
97
+
98
+ Two modes:
99
+ stateless (default, used by MCP server): each call is independent.
100
+ Claude Code maintains conversation context itself.
101
+ stateful (enabled by configure()): uses --continue to resume the
102
+ last conversation. Used by the interactive jsat shell.
103
+ """
104
+ args = [self._binary]
105
+
106
+ # Print mode (non-interactive)
107
+ args += ["-p", prompt]
108
+
109
+ # Output format
110
+ if stream:
111
+ args += ["--output-format", "stream-json"]
112
+ else:
113
+ args += ["--output-format", "text"]
114
+
115
+ # Model selection — only pass --model for known Claude model names.
116
+ # Never pass Ollama/GPT model names (e.g. "llama3.2") to the claude CLI.
117
+ _CLAUDE_MODELS = {
118
+ "claude-sonnet-4-6", "claude-haiku-4-5-20251001", "claude-opus-4-8",
119
+ "claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022",
120
+ "claude-3-opus-20240229",
121
+ }
122
+ if self._model and self._model in _CLAUDE_MODELS and self._model != "claude-sonnet-4-6":
123
+ args += ["--model", self._model]
124
+
125
+ if self._stateful:
126
+ # Stateful mode (interactive shell): maintain conversation history.
127
+ # Use --continue on calls after the first to resume the last session.
128
+ if self._call_count == 0:
129
+ # First call: inject context
130
+ if self._system_prompt:
131
+ args += ["--system-prompt", self._system_prompt]
132
+ if self._repo_dir:
133
+ args += ["--add-dir", self._repo_dir]
134
+ else:
135
+ # Continue the last conversation in this directory
136
+ args += ["--continue"]
137
+ else:
138
+ # Stateless mode (MCP server default): each call is independent.
139
+ # Claude Code handles conversation context via its own history.
140
+ # Do NOT use --session-id or --resume — they cause "Invalid session ID".
141
+ if self._system_prompt:
142
+ args += ["--append-system-prompt", self._system_prompt]
143
+ if self._repo_dir:
144
+ args += ["--add-dir", self._repo_dir]
145
+
146
+ return args
147
+
148
+ def complete(self, prompt: str, max_tokens: int = 8192,
149
+ temperature: float = 0.1) -> str:
150
+ import structlog
151
+ log = structlog.get_logger(__name__)
152
+
153
+ if not shutil.which("claude"):
154
+ raise RuntimeError(
155
+ "'claude' CLI not found. Install Claude Code: https://claude.ai/code"
156
+ )
157
+
158
+ args = self._build_args(prompt, stream=False)
159
+ log.debug("claude_cli_complete",
160
+ session=self._session_id, call=self._call_count,
161
+ prompt_len=len(prompt))
162
+
163
+ t0 = time.monotonic()
164
+ try:
165
+ result = subprocess.run(
166
+ args,
167
+ capture_output=True, text=True, timeout=self._timeout,
168
+ )
169
+ except subprocess.TimeoutExpired:
170
+ from jsat._exceptions import AITimeoutError
171
+ raise AITimeoutError(
172
+ f"claude timed out after {self._timeout}s",
173
+ provider="claude_cli", timeout_seconds=self._timeout,
174
+ )
175
+
176
+ elapsed = round((time.monotonic() - t0) * 1000)
177
+
178
+ if result.returncode != 0:
179
+ stderr = result.stderr.strip()
180
+ log.error("claude_cli_error", returncode=result.returncode,
181
+ stderr=stderr[:300], elapsed_ms=elapsed)
182
+ raise RuntimeError(
183
+ f"claude exited {result.returncode}: {stderr[:200]}"
184
+ )
185
+
186
+ self._call_count += 1
187
+ text = result.stdout.strip()
188
+ log.info("claude_cli_done", response_len=len(text), elapsed_ms=elapsed,
189
+ session=self._session_id, turn=self._call_count)
190
+ return text
191
+
192
+ async def complete_async(self, prompt: str, max_tokens: int = 8192,
193
+ temperature: float = 0.1) -> str:
194
+ return await asyncio.to_thread(self.complete, prompt, max_tokens, temperature)
195
+
196
+ def stream(self, prompt: str, max_tokens: int = 8192) -> Iterator[str]:
197
+ """Stream response using --output-format stream-json (NDJSON events)."""
198
+ import structlog
199
+ log = structlog.get_logger(__name__)
200
+
201
+ if not shutil.which("claude"):
202
+ raise RuntimeError("'claude' CLI not found.")
203
+
204
+ args = self._build_args(prompt, stream=True)
205
+ log.debug("claude_cli_stream_start",
206
+ session=self._session_id, call=self._call_count)
207
+
208
+ t0 = time.monotonic()
209
+ total_chars = 0
210
+
211
+ try:
212
+ proc = subprocess.Popen(
213
+ args,
214
+ stdout=subprocess.PIPE,
215
+ stderr=subprocess.PIPE,
216
+ text=True,
217
+ bufsize=1,
218
+ )
219
+ assert proc.stdout is not None
220
+
221
+ for raw_line in proc.stdout:
222
+ line = raw_line.strip()
223
+ if not line:
224
+ continue
225
+
226
+ # Parse the stream-json NDJSON event
227
+ try:
228
+ event = json.loads(line)
229
+ except json.JSONDecodeError:
230
+ # Plain text fallback (some claude versions)
231
+ yield raw_line
232
+ total_chars += len(raw_line)
233
+ continue
234
+
235
+ event_type = event.get("type", "")
236
+
237
+ # Text delta — the actual streamed content
238
+ if event_type == "content_block_delta":
239
+ delta = event.get("delta", {})
240
+ if delta.get("type") == "text_delta":
241
+ text = delta.get("text", "")
242
+ if text:
243
+ yield text
244
+ total_chars += len(text)
245
+
246
+ # assistant message block (non-streaming text)
247
+ elif event_type == "assistant":
248
+ for block in event.get("message", {}).get("content", []):
249
+ if block.get("type") == "text":
250
+ text = block.get("text", "")
251
+ if text:
252
+ yield text
253
+ total_chars += len(text)
254
+
255
+ # Result event — session ID may be updated here
256
+ elif event_type == "result":
257
+ sid = event.get("session_id")
258
+ if sid:
259
+ self._session_id = sid
260
+
261
+ proc.wait(timeout=10)
262
+
263
+ except subprocess.TimeoutExpired:
264
+ proc.kill()
265
+ from jsat._exceptions import AITimeoutError
266
+ raise AITimeoutError(
267
+ f"claude stream timed out after {self._timeout}s",
268
+ provider="claude_cli", timeout_seconds=self._timeout,
269
+ )
270
+ except Exception as e:
271
+ log.error("claude_cli_stream_error", error=str(e))
272
+ raise
273
+
274
+ self._call_count += 1
275
+ elapsed = round((time.monotonic() - t0) * 1000)
276
+ log.info("claude_cli_stream_done",
277
+ total_chars=total_chars, elapsed_ms=elapsed,
278
+ session=self._session_id, turn=self._call_count)
jsat/_ai/none.py ADDED
@@ -0,0 +1,60 @@
1
+ """jsat._ai.none — No-op AIProvider for CI mode."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Iterator
5
+
6
+ from jsat._ai import AIProvider
7
+
8
+ _warned = False
9
+ _MSG = (
10
+ "No AI provider configured. "
11
+ "Set ai.provider in .jsat.yaml or run: jsat init --profile solo"
12
+ )
13
+
14
+
15
+ def _warn_once() -> None:
16
+ global _warned
17
+ if _warned:
18
+ return
19
+ _warned = True
20
+ import structlog
21
+ structlog.get_logger(__name__).warning("noop_ai_provider_active", message=_MSG)
22
+
23
+
24
+ def _ai_error() -> type[Exception]:
25
+ try:
26
+ from jsat._exceptions import AIError
27
+ return AIError
28
+ except ImportError:
29
+ return RuntimeError
30
+
31
+
32
+ class NoOpProvider(AIProvider):
33
+ """No-op provider — raises AIError on every call. Used in CI mode."""
34
+
35
+ @property
36
+ def provider_name(self) -> str:
37
+ return "none"
38
+
39
+ @property
40
+ def model_name(self) -> str:
41
+ return "none"
42
+
43
+ def is_available(self) -> bool:
44
+ return False
45
+
46
+ def complete(self, prompt: str, max_tokens: int = 2048, temperature: float = 0.1) -> str:
47
+ _warn_once()
48
+ raise _ai_error()(_MSG)
49
+
50
+ async def complete_async(self, prompt: str, max_tokens: int = 2048, temperature: float = 0.1) -> str:
51
+ _warn_once()
52
+ raise _ai_error()(_MSG)
53
+
54
+ def stream(self, prompt: str, max_tokens: int = 2048) -> Iterator[str]:
55
+ _warn_once()
56
+ return
57
+ yield
58
+
59
+
60
+ __all__ = ["NoOpProvider"]
jsat/_ai/ollama.py ADDED
@@ -0,0 +1,97 @@
1
+ """jsat._ai.ollama — Ollama AI provider (jsat[local] extra)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import time
6
+ from typing import TYPE_CHECKING, Any, Iterator
7
+
8
+ from jsat._ai import AIProvider
9
+
10
+ if TYPE_CHECKING:
11
+ from jsat._models import JSATConfig
12
+
13
+
14
+ class OllamaProvider(AIProvider):
15
+ """AI provider backed by a local Ollama instance."""
16
+
17
+ def __init__(self, cfg: JSATConfig) -> None:
18
+ import structlog
19
+ self._log = structlog.get_logger(__name__)
20
+
21
+ try:
22
+ import ollama as _ollama # type: ignore[import]
23
+ self._ollama = _ollama
24
+ except ImportError as e:
25
+ from jsat._exceptions import ProfileError
26
+ raise ProfileError(
27
+ "Ollama package not installed.\nInstall: pip install 'jsat[local]'",
28
+ required_extra="local",
29
+ ) from e
30
+
31
+ ai = cfg.ai
32
+ self._model: str = getattr(ai, "model", None) or "llama3.2"
33
+ self._base_url: str = getattr(ai, "base_url", None) or "http://localhost:11434"
34
+ self._max_tokens: int = getattr(ai, "max_tokens", 8192)
35
+ self._timeout: int = getattr(ai, "timeout_seconds", 120)
36
+
37
+ self._log.info("ollama_init", model=self._model, base_url=self._base_url)
38
+
39
+ @property
40
+ def provider_name(self) -> str:
41
+ return "ollama"
42
+
43
+ @property
44
+ def model_name(self) -> str:
45
+ return self._model
46
+
47
+ def complete(self, prompt: str, max_tokens: int = 2048, temperature: float = 0.1) -> str:
48
+ self._log.debug("ollama_complete", prompt_len=len(prompt), max_tokens=max_tokens)
49
+ t0 = time.monotonic()
50
+ try:
51
+ resp = self._ollama.generate(
52
+ model=self._model, prompt=prompt,
53
+ options={"num_predict": max_tokens, "temperature": temperature},
54
+ )
55
+ except Exception as e:
56
+ elapsed = round((time.monotonic() - t0) * 1000)
57
+ self._log.error("ollama_complete_error", error=str(e), elapsed_ms=elapsed)
58
+ from jsat._exceptions import AIProviderError
59
+ raise AIProviderError(
60
+ f"Ollama error: {e}", provider="ollama", status_code=0
61
+ ) from e
62
+
63
+ elapsed = round((time.monotonic() - t0) * 1000)
64
+ text: str = resp["response"]
65
+ self._log.info("ollama_complete_done", response_len=len(text), duration_ms=elapsed)
66
+ return text
67
+
68
+ async def complete_async(self, prompt: str, max_tokens: int = 2048, temperature: float = 0.1) -> str:
69
+ return await asyncio.to_thread(self.complete, prompt, max_tokens, temperature)
70
+
71
+ def stream(self, prompt: str, max_tokens: int = 2048) -> Iterator[str]:
72
+ self._log.debug("ollama_stream_start", prompt_len=len(prompt))
73
+ total = 0
74
+ try:
75
+ for chunk in self._ollama.generate(
76
+ model=self._model, prompt=prompt,
77
+ options={"num_predict": max_tokens}, stream=True,
78
+ ):
79
+ piece: str = chunk.get("response", "")
80
+ if piece:
81
+ total += len(piece)
82
+ yield piece
83
+ except Exception as e:
84
+ self._log.error("ollama_stream_error", error=str(e), chars_so_far=total)
85
+ raise
86
+ self._log.info("ollama_stream_done", total_chars=total)
87
+
88
+ def is_available(self) -> bool:
89
+ try:
90
+ import httpx
91
+ resp = httpx.get(f"{self._base_url}/api/tags", timeout=0.5)
92
+ up = resp.status_code < 500
93
+ self._log.debug("ollama_available", up=up, status=resp.status_code)
94
+ return up
95
+ except Exception as e:
96
+ self._log.debug("ollama_unavailable", error=str(e))
97
+ return False