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.
- jsat/__init__.py +38 -0
- jsat/_ai/__init__.py +90 -0
- jsat/_ai/anthropic.py +80 -0
- jsat/_ai/claude_cli.py +278 -0
- jsat/_ai/none.py +60 -0
- jsat/_ai/ollama.py +97 -0
- jsat/_ai/openai.py +81 -0
- jsat/_ai/openai_compat.py +107 -0
- jsat/_cache/__init__.py +17 -0
- jsat/_cache/disk.py +93 -0
- jsat/_cache/memory.py +84 -0
- jsat/_cache/redis.py +139 -0
- jsat/_config.py +469 -0
- jsat/_core.py +416 -0
- jsat/_embed/__init__.py +37 -0
- jsat/_embed/local.py +53 -0
- jsat/_embed/none.py +42 -0
- jsat/_embed/openai.py +61 -0
- jsat/_exceptions.py +253 -0
- jsat/_graph/__init__.py +87 -0
- jsat/_graph/lightgraph.py +140 -0
- jsat/_graph/neo4j.py +141 -0
- jsat/_graph/sqlite.py +170 -0
- jsat/_models.py +233 -0
- jsat/_parsers/__init__.py +46 -0
- jsat/_parsers/go.py +135 -0
- jsat/_parsers/javascript.py +135 -0
- jsat/_parsers/python.py +149 -0
- jsat/cli.py +1273 -0
- jsat/mcp/__init__.py +1 -0
- jsat/mcp/server.py +194 -0
- jsat/mcp/tools.py +162 -0
- jsat/skills/__init__.py +1 -0
- jsat/skills/clusters.py +44 -0
- jsat/skills/manifest.py +82 -0
- jsat/skills/registry.py +81 -0
- jsat/tools/__init__.py +18 -0
- jsat/tools/blast_radius.py +127 -0
- jsat/tools/contract.py +106 -0
- jsat/tools/export.py +131 -0
- jsat/tools/feature.py +85 -0
- jsat/tools/incident.py +136 -0
- jsat/tools/indexer.py +130 -0
- jsat/tools/ithinking.py +126 -0
- jsat/tools/knowledge.py +100 -0
- jsat/tools/migration.py +128 -0
- jsat/tools/orchestrator.py +101 -0
- jsat/tools/query.py +88 -0
- jsat/tools/review.py +116 -0
- jsat/tools/security.py +97 -0
- jsat/tools/shell.py +720 -0
- jsat/tools/test_helper.py +81 -0
- jsat-0.1.0.dist-info/METADATA +209 -0
- jsat-0.1.0.dist-info/RECORD +56 -0
- jsat-0.1.0.dist-info/WHEEL +4 -0
- 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
|