synap-git 0.2.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 (43) hide show
  1. synap_git/__init__.py +5 -0
  2. synap_git/api/__init__.py +5 -0
  3. synap_git/api/app.py +111 -0
  4. synap_git/api/static/index.html +615 -0
  5. synap_git/cli/__init__.py +3 -0
  6. synap_git/cli/__main__.py +4 -0
  7. synap_git/cli/main.py +1174 -0
  8. synap_git/config.py +169 -0
  9. synap_git/diagnostics/__init__.py +0 -0
  10. synap_git/diagnostics/logger.py +82 -0
  11. synap_git/diagnostics/tracing.py +71 -0
  12. synap_git/embeddings/__init__.py +0 -0
  13. synap_git/git/__init__.py +11 -0
  14. synap_git/git/state.py +139 -0
  15. synap_git/indexer/__init__.py +0 -0
  16. synap_git/indexer/daemon.py +143 -0
  17. synap_git/indexer/engine.py +327 -0
  18. synap_git/indexer/scanner.py +463 -0
  19. synap_git/indexer/wiki.py +78 -0
  20. synap_git/mcp/__init__.py +5 -0
  21. synap_git/mcp/server.py +232 -0
  22. synap_git/parser/__init__.py +0 -0
  23. synap_git/parser/registry.py +209 -0
  24. synap_git/provider/anthropic.py +86 -0
  25. synap_git/provider/base.py +44 -0
  26. synap_git/provider/factory.py +101 -0
  27. synap_git/provider/gemini.py +98 -0
  28. synap_git/provider/mock.py +66 -0
  29. synap_git/provider/ollama.py +98 -0
  30. synap_git/provider/openai.py +90 -0
  31. synap_git/py.typed +1 -0
  32. synap_git/retrieval/__init__.py +0 -0
  33. synap_git/retrieval/engine.py +258 -0
  34. synap_git/retrieval/memory.py +123 -0
  35. synap_git/storage/__init__.py +3 -0
  36. synap_git/storage/sqlite.py +575 -0
  37. synap_git/utils/__init__.py +0 -0
  38. synap_git/utils/serialization.py +68 -0
  39. synap_git-0.2.0.dist-info/METADATA +251 -0
  40. synap_git-0.2.0.dist-info/RECORD +43 -0
  41. synap_git-0.2.0.dist-info/WHEEL +4 -0
  42. synap_git-0.2.0.dist-info/entry_points.txt +2 -0
  43. synap_git-0.2.0.dist-info/licenses/LICENSE.md +201 -0
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import inspect
5
+ import json
6
+ import time
7
+ import uuid
8
+ from typing import Any
9
+
10
+ from mcp.server.fastmcp import FastMCP
11
+
12
+ from synap_git.indexer.engine import SynapRuntime
13
+
14
+
15
+ class SynapMCPFacade:
16
+ """Model Context Protocol facade over the simplified Synap runtime."""
17
+
18
+ def __init__(self, runtime: SynapRuntime) -> None:
19
+ self.runtime = runtime
20
+
21
+ def get_status(self) -> dict[str, Any]:
22
+ status = self.runtime.status()
23
+ return status.__dict__
24
+
25
+ def search(self, query: str, max_tokens: int = 4000) -> dict[str, Any]:
26
+ result, ctx, debug = self.runtime.query_hybrid(query, max_tokens=max_tokens)
27
+ return {"result": result, "context": ctx, "trace": debug}
28
+
29
+ def create_checkpoint(
30
+ self, doing: str, changed_files: list[str], next_step: str, blockers: str
31
+ ) -> dict[str, Any]:
32
+ status = self.runtime.status()
33
+ checkpoint_id = str(uuid.uuid4())
34
+ branch = status.branch
35
+ commit = status.git_commit or "unknown"
36
+ self.runtime.store.put_checkpoint(
37
+ checkpoint_id, branch, commit, doing, json.dumps(changed_files), next_step, blockers
38
+ )
39
+ return {"status": "success", "checkpoint_id": checkpoint_id}
40
+
41
+ def restore_checkpoint(self, checkpoint_id: str) -> dict[str, Any]:
42
+ status = self.runtime.status()
43
+ cp = self.runtime.store.get_latest_checkpoint(status.branch)
44
+ if not cp:
45
+ raise ValueError("No checkpoint found for current branch.")
46
+ return dict(cp)
47
+
48
+ def log_decision(self, content: str, context_info: str) -> dict[str, Any]:
49
+ status = self.runtime.status()
50
+ decision_id = str(uuid.uuid4())
51
+ branch = status.branch
52
+ commit = status.git_commit or "unknown"
53
+ self.runtime.store.put_decision(decision_id, branch, commit, content, context_info)
54
+ return {"status": "success", "decision_id": decision_id}
55
+
56
+ def verify_system(self) -> dict[str, Any]:
57
+ res = self.runtime.doctor()
58
+ return res
59
+
60
+ def submit_lesson_analysis(self, lesson_id: str, why_failed: str) -> dict[str, Any]:
61
+ self.runtime.store.update_lesson(lesson_id, why_failed, "pending", actor="mcp_agent")
62
+ return {"status": "success", "message": "Lesson awaiting human approval"}
63
+
64
+ def get_approved_memory(self) -> dict[str, Any]:
65
+ lessons = self.runtime.store.get_lessons("approved")
66
+ return {"status": "success", "lessons": lessons}
67
+
68
+ def get_pending_memory(self) -> dict[str, Any]:
69
+ lessons = self.runtime.store.get_lessons("pending")
70
+ return {"status": "success", "lessons": lessons}
71
+
72
+
73
+ class SynapMCPServer:
74
+ """Exposes Synap tools to AI agents via MCP Stdio."""
75
+
76
+ def __init__(self, runtime: SynapRuntime) -> None:
77
+ self.mcp = FastMCP("Synap Context Runtime")
78
+ self.facade = SynapMCPFacade(runtime)
79
+ self._register_tools()
80
+
81
+ def _register_tools(self) -> None:
82
+ from synap_git.diagnostics.logger import get_logger
83
+
84
+ logger = get_logger("mcp_server")
85
+
86
+ from collections.abc import Callable
87
+
88
+ def _wrap(f: Callable[..., dict[str, Any]]) -> Callable[..., str]:
89
+ @functools.wraps(f)
90
+ def wrapper(*args: Any, **kwargs: Any) -> str:
91
+ trace_id = str(uuid.uuid4())
92
+ try:
93
+ status = self.facade.runtime.status()
94
+ dirty = status.is_dirty
95
+ warnings = []
96
+ if dirty:
97
+ warnings.append("Working tree is dirty. Index may be stale.")
98
+
99
+ start = time.monotonic()
100
+ data = f(*args, **kwargs)
101
+ duration_ms = (time.monotonic() - start) * 1000
102
+ logger.info(
103
+ "mcp_tool_invoked",
104
+ tool=f.__name__,
105
+ latency_ms=duration_ms,
106
+ )
107
+
108
+ try:
109
+ self.facade.runtime.trace_store.record_trace(
110
+ trace_type="mcp",
111
+ summary=f"MCP Tool Invoked: {f.__name__}",
112
+ details={
113
+ "tool": f.__name__,
114
+ "arguments": kwargs,
115
+ "duration_ms": duration_ms,
116
+ "success": True,
117
+ "trace_id": trace_id,
118
+ "dirty_tree": dirty,
119
+ },
120
+ )
121
+ except Exception:
122
+ pass
123
+
124
+ return json.dumps(
125
+ {
126
+ "ok": True,
127
+ "data": data,
128
+ "warnings": warnings,
129
+ "trace_id": trace_id,
130
+ "dirty_tree": dirty,
131
+ }
132
+ )
133
+ except Exception as e:
134
+ msg = str(e)
135
+ code = "INTERNAL_ERROR"
136
+ suggestion = "Check daemon logs."
137
+
138
+ if "stale" in msg.lower():
139
+ code = "INDEX_STALE"
140
+ suggestion = "Run `synap reindex` or wait for daemon."
141
+ elif "no checkpoint" in msg.lower():
142
+ code = "NOT_FOUND"
143
+ suggestion = "Ensure checkpoints exist for this branch."
144
+
145
+ logger.error("mcp_tool_error", tool=f.__name__, error=msg)
146
+
147
+ try:
148
+ self.facade.runtime.trace_store.record_trace(
149
+ trace_type="mcp",
150
+ summary=f"MCP Tool Failed: {f.__name__}",
151
+ details={
152
+ "tool": f.__name__,
153
+ "arguments": kwargs,
154
+ "error": msg,
155
+ "success": False,
156
+ "trace_id": trace_id,
157
+ },
158
+ )
159
+ except Exception:
160
+ pass
161
+
162
+ return json.dumps(
163
+ {
164
+ "ok": False,
165
+ "error": {"code": code, "message": msg, "suggestion": suggestion},
166
+ "warnings": [],
167
+ "trace_id": trace_id,
168
+ "dirty_tree": False,
169
+ }
170
+ )
171
+
172
+ wrapper.__signature__ = inspect.signature(f) # type: ignore
173
+ return wrapper
174
+
175
+ @self.mcp.tool()
176
+ @_wrap
177
+ def get_status() -> dict[str, Any]:
178
+ """Get current repository indexing status and active commit."""
179
+ return self.facade.get_status()
180
+
181
+ @self.mcp.tool()
182
+ @_wrap
183
+ def search(query: str, max_tokens: int = 4000) -> dict[str, Any]:
184
+ """Search the repository context for semantic and structural matches."""
185
+ return self.facade.search(query, max_tokens)
186
+
187
+ @self.mcp.tool()
188
+ @_wrap
189
+ def create_checkpoint(
190
+ doing: str, changed_files: list[str], next_step: str, blockers: str
191
+ ) -> dict[str, Any]:
192
+ """Save the current agent's thought process and state as a checkpoint."""
193
+ return self.facade.create_checkpoint(doing, changed_files, next_step, blockers)
194
+
195
+ @self.mcp.tool()
196
+ @_wrap
197
+ def restore_checkpoint() -> dict[str, Any]:
198
+ """Restore the latest checkpoint for the current branch to resume context."""
199
+ return self.facade.restore_checkpoint("latest")
200
+
201
+ @self.mcp.tool()
202
+ @_wrap
203
+ def log_decision(content: str, context_info: str) -> dict[str, Any]:
204
+ """Log an architectural or technical decision to the project's decision log."""
205
+ return self.facade.log_decision(content, context_info)
206
+
207
+ @self.mcp.tool()
208
+ @_wrap
209
+ def verify_system() -> dict[str, Any]:
210
+ """Verify database integrity and run internal diagnostics."""
211
+ return self.facade.verify_system()
212
+
213
+ @self.mcp.tool()
214
+ @_wrap
215
+ def submit_lesson_analysis(lesson_id: str, why_failed: str) -> dict[str, Any]:
216
+ """Submit an analysis for a pending lesson (e.g., after a revert). Lesson remains pending until human approval."""
217
+ return self.facade.submit_lesson_analysis(lesson_id, why_failed)
218
+
219
+ @self.mcp.tool()
220
+ @_wrap
221
+ def get_approved_memory() -> dict[str, Any]:
222
+ """Retrieve APPROVED memory lessons that MUST be strictly adhered to."""
223
+ return self.facade.get_approved_memory()
224
+
225
+ @self.mcp.tool()
226
+ @_wrap
227
+ def get_pending_memory() -> dict[str, Any]:
228
+ """Retrieve PENDING memory lessons currently awaiting human review."""
229
+ return self.facade.get_pending_memory()
230
+
231
+ async def run(self) -> None:
232
+ await self.mcp.run_stdio_async()
File without changes
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import tree_sitter_languages # type: ignore
8
+ from tree_sitter import Node, Parser
9
+
10
+ from synap_git.utils.serialization import stable_hash
11
+
12
+ PYTHON_SUFFIXES = {".py"}
13
+ JAVASCRIPT_SUFFIXES = {".js", ".jsx"}
14
+ TYPESCRIPT_SUFFIXES = {".ts", ".tsx"}
15
+ GO_SUFFIXES = {".go"}
16
+ RUST_SUFFIXES = {".rs"}
17
+ JAVA_SUFFIXES = {".java"}
18
+ CPP_SUFFIXES = {".cpp", ".cc", ".cxx", ".hpp", ".h"}
19
+ RUBY_SUFFIXES = {".rb"}
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class CodeSymbol:
24
+ stable_id: str
25
+ kind: str
26
+ name: str
27
+ source_path: str
28
+ start_line: int
29
+ end_line: int
30
+ ast_hash: str
31
+ metadata: dict[str, Any] | None = None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class CodeParseResult:
36
+ path: str
37
+ language: str
38
+ symbols: tuple[CodeSymbol, ...]
39
+ imports: tuple[str, ...] = ()
40
+ syntax_error: str | None = None
41
+
42
+
43
+ class CodeParserRegistry:
44
+ """Deterministic AST parser using Tree-sitter for structural grounding."""
45
+
46
+ def __init__(self) -> None:
47
+ self._parsers: dict[str, Parser] = {}
48
+
49
+ def _get_parser(self, lang_name: str) -> Parser:
50
+ if lang_name not in self._parsers:
51
+ lang = tree_sitter_languages.get_language(lang_name)
52
+ parser = Parser()
53
+ parser.set_language(lang)
54
+ self._parsers[lang_name] = parser
55
+ return self._parsers[lang_name]
56
+
57
+ def parse(self, path: Path, *, relative_path: str) -> CodeParseResult:
58
+ suffix = path.suffix.lower()
59
+ text = path.read_text(encoding="utf-8", errors="replace")
60
+
61
+ if suffix in PYTHON_SUFFIXES:
62
+ return self._parse_tree_sitter(text, "python", relative_path)
63
+ if suffix in JAVASCRIPT_SUFFIXES:
64
+ return self._parse_tree_sitter(text, "javascript", relative_path)
65
+ if suffix in TYPESCRIPT_SUFFIXES:
66
+ return self._parse_tree_sitter(text, "tsx", relative_path)
67
+ if suffix in GO_SUFFIXES:
68
+ return self._parse_tree_sitter(text, "go", relative_path)
69
+ if suffix in RUST_SUFFIXES:
70
+ return self._parse_tree_sitter(text, "rust", relative_path)
71
+ if suffix in JAVA_SUFFIXES:
72
+ return self._parse_tree_sitter(text, "java", relative_path)
73
+ if suffix in CPP_SUFFIXES:
74
+ return self._parse_tree_sitter(text, "cpp", relative_path)
75
+ if suffix in RUBY_SUFFIXES:
76
+ return self._parse_tree_sitter(text, "ruby", relative_path)
77
+
78
+ return CodeParseResult(path=relative_path, language="unknown", symbols=())
79
+
80
+ def _parse_tree_sitter(self, text: str, lang_name: str, relative_path: str) -> CodeParseResult:
81
+ parser = self._get_parser(lang_name)
82
+ text_bytes = bytes(text, "utf8")
83
+ tree = parser.parse(text_bytes)
84
+
85
+ symbols: list[CodeSymbol] = []
86
+ imports: list[str] = []
87
+
88
+ def extract_text(node: Node | None) -> str | None:
89
+ if not node:
90
+ return None
91
+ return text_bytes[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
92
+
93
+ # Simple recursive traversal to extract symbols
94
+ def traverse(node: Node) -> None:
95
+ kind = node.type
96
+ name = None
97
+
98
+ # Basic symbol extraction logic per language
99
+ if lang_name == "python":
100
+ if kind in ("class_definition", "function_definition"):
101
+ name = extract_text(node.child_by_field_name("name"))
102
+ elif kind == "import_from_statement":
103
+ mod_name = extract_text(node.child_by_field_name("module_name"))
104
+ if mod_name:
105
+ imports.append(mod_name)
106
+ elif kind == "import_statement":
107
+ for child in node.children:
108
+ if child.type == "dotted_name":
109
+ imp_name = extract_text(child)
110
+ if imp_name:
111
+ imports.append(imp_name)
112
+
113
+ elif lang_name in ("javascript", "tsx"):
114
+ # Capturing classes, functions, methods, interfaces, and types
115
+ if kind in (
116
+ "class_declaration",
117
+ "function_declaration",
118
+ "method_definition",
119
+ "interface_declaration",
120
+ "type_alias_declaration",
121
+ "class",
122
+ ):
123
+ name = extract_text(node.child_by_field_name("name"))
124
+ elif kind == "variable_declarator":
125
+ # Catch const foo = () => ...
126
+ value_node = node.child_by_field_name("value")
127
+ if value_node and value_node.type in ("arrow_function", "function_expression"):
128
+ name = extract_text(node.child_by_field_name("name"))
129
+ if name:
130
+ kind = "function_definition"
131
+
132
+ elif kind == "import_statement":
133
+ src = extract_text(node.child_by_field_name("source"))
134
+ if src:
135
+ imports.append(src.strip("'\""))
136
+
137
+ elif lang_name == "go":
138
+ if kind in ("function_declaration", "method_declaration", "type_spec"):
139
+ name = extract_text(node.child_by_field_name("name"))
140
+ elif kind == "import_spec":
141
+ path_val = extract_text(node.child_by_field_name("path"))
142
+ if path_val:
143
+ imports.append(path_val.strip('"'))
144
+
145
+ elif lang_name == "rust":
146
+ if kind in ("function_item", "struct_item", "enum_item", "trait_item", "impl_item"):
147
+ name = extract_text(node.child_by_field_name("name"))
148
+ if not name and kind == "impl_item":
149
+ t_name = extract_text(node.child_by_field_name("type"))
150
+ if t_name:
151
+ name = "impl " + t_name
152
+
153
+ elif lang_name == "java":
154
+ if kind in (
155
+ "class_declaration",
156
+ "method_declaration",
157
+ "interface_declaration",
158
+ "enum_declaration",
159
+ ):
160
+ name = extract_text(node.child_by_field_name("name"))
161
+
162
+ elif lang_name == "cpp":
163
+ if kind in ("function_definition", "class_specifier", "struct_specifier"):
164
+ name = extract_text(node.child_by_field_name("name"))
165
+ if not name:
166
+ # Fallback to declarator
167
+ declarator = node.child_by_field_name("declarator")
168
+ if declarator:
169
+ name = extract_text(declarator.child_by_field_name("declarator"))
170
+
171
+ elif lang_name == "ruby":
172
+ if kind in ("class", "module", "method"):
173
+ name = extract_text(node.child_by_field_name("name"))
174
+
175
+ if name:
176
+ # Compute hash of the node's subtree for change detection
177
+ ast_content = extract_text(node) or ""
178
+ ast_hash = stable_hash(ast_content)
179
+
180
+ # Ensure uniqueness by appending start/end lines if needed, but for now we rely on name/kind
181
+ # since stable_id uses path, name, kind. Wait, if multiple methods have same name (e.g. overloads)
182
+ # it will duplicate. Let's include start_line in stable_id to prevent IntegrityError!
183
+ sym_id = stable_hash(
184
+ {"path": relative_path, "name": name, "kind": kind, "line": node.start_point[0]}
185
+ )
186
+
187
+ symbols.append(
188
+ CodeSymbol(
189
+ stable_id=sym_id,
190
+ kind=kind,
191
+ name=name,
192
+ source_path=relative_path,
193
+ start_line=node.start_point[0] + 1,
194
+ end_line=node.end_point[0] + 1,
195
+ ast_hash=ast_hash,
196
+ )
197
+ )
198
+
199
+ for child in node.children:
200
+ traverse(child)
201
+
202
+ traverse(tree.root_node)
203
+
204
+ return CodeParseResult(
205
+ path=relative_path,
206
+ language=lang_name,
207
+ symbols=tuple(symbols),
208
+ imports=tuple(sorted(set(imports))),
209
+ )
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.request
5
+ from typing import Any
6
+
7
+ from synap_git.provider.base import LLMProvider, LLMResponse
8
+
9
+
10
+ class AnthropicProvider(LLMProvider):
11
+ """Anthropic API provider implementation."""
12
+
13
+ def __init__(self, api_key: str, default_model: str = "claude-3-5-sonnet-latest") -> None:
14
+ self.api_key = api_key
15
+ self.default_model = default_model
16
+
17
+ def generate(
18
+ self,
19
+ system_prompt: str,
20
+ user_prompt: str,
21
+ *,
22
+ model: str | None = None,
23
+ max_tokens: int | None = None,
24
+ temperature: float = 0.2,
25
+ ) -> LLMResponse:
26
+ model_name = model or self.default_model
27
+ url = "https://api.anthropic.com/v1/messages"
28
+ headers = {
29
+ "Content-Type": "application/json",
30
+ "x-api-key": self.api_key,
31
+ "anthropic-version": "2023-06-01",
32
+ }
33
+
34
+ payload: dict[str, Any] = {
35
+ "model": model_name,
36
+ "system": system_prompt,
37
+ "messages": [
38
+ {"role": "user", "content": user_prompt},
39
+ ],
40
+ "temperature": temperature,
41
+ "max_tokens": max_tokens or 4096,
42
+ }
43
+
44
+ req = urllib.request.Request(
45
+ url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST"
46
+ )
47
+ try:
48
+ with urllib.request.urlopen(req, timeout=45.0) as resp: # nosec B310
49
+ data = json.loads(resp.read().decode("utf-8"))
50
+
51
+ content = data["content"][0]["text"]
52
+ usage = data.get("usage", {})
53
+ prompt_tokens = usage.get("input_tokens", 0)
54
+ completion_tokens = usage.get("output_tokens", 0)
55
+ cached_tokens = usage.get("cache_creation_input_tokens", 0) + usage.get(
56
+ "cache_read_input_tokens", 0
57
+ )
58
+
59
+ return LLMResponse(
60
+ content=content,
61
+ prompt_tokens=prompt_tokens,
62
+ completion_tokens=completion_tokens,
63
+ cached_tokens=cached_tokens,
64
+ )
65
+ except Exception as exc:
66
+ raise RuntimeError(f"Anthropic generate failed: {exc}") from exc
67
+
68
+ def embed(
69
+ self,
70
+ text: str,
71
+ *,
72
+ model: str | None = None,
73
+ ) -> list[float]:
74
+ # Anthropic does not have a public embedding endpoint as of mid 2024.
75
+ # Typically developers use Voyage or OpenAI for embeddings when using Anthropic.
76
+ # We will throw a clear error.
77
+ raise NotImplementedError(
78
+ "Anthropic does not provide a native embeddings API. Please configure SYNAP_EMBED_PROVIDER=ollama or openai."
79
+ )
80
+
81
+ def count_tokens(self, text: str) -> int:
82
+ import tiktoken
83
+
84
+ # Anthropic approximates closely with cl100k_base
85
+ encoding = tiktoken.get_encoding("cl100k_base")
86
+ return len(encoding.encode(text, disallowed_special=()))
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class LLMResponse:
9
+ content: str
10
+ prompt_tokens: int = 0
11
+ completion_tokens: int = 0
12
+ cached_tokens: int = 0
13
+
14
+
15
+ class LLMProvider(ABC):
16
+ """Abstract interface for LLM provider clients."""
17
+
18
+ @abstractmethod
19
+ def generate(
20
+ self,
21
+ system_prompt: str,
22
+ user_prompt: str,
23
+ *,
24
+ model: str | None = None,
25
+ max_tokens: int | None = None,
26
+ temperature: float = 0.2,
27
+ ) -> LLMResponse:
28
+ """Generate a response for the given prompts."""
29
+ pass
30
+
31
+ @abstractmethod
32
+ def embed(
33
+ self,
34
+ text: str,
35
+ *,
36
+ model: str | None = None,
37
+ ) -> list[float]:
38
+ """Generate a vector embedding for the given text."""
39
+ pass
40
+
41
+ @abstractmethod
42
+ def count_tokens(self, text: str) -> int:
43
+ """Count the number of tokens in the given text."""
44
+ pass
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from synap_git.config import RuntimeProfile, SynapSettings
4
+ from synap_git.provider.anthropic import AnthropicProvider
5
+ from synap_git.provider.base import LLMProvider
6
+ from synap_git.provider.gemini import GeminiProvider
7
+ from synap_git.provider.mock import MockLLMProvider
8
+ from synap_git.provider.ollama import OllamaProvider
9
+ from synap_git.provider.openai import OpenAIProvider
10
+
11
+
12
+ def get_llm_provider(settings: SynapSettings) -> LLMProvider | None:
13
+ """Create an LLM provider based on runtime settings.
14
+
15
+ Strictly enforces provider configuration. Returns None for explicit Mode A
16
+ (structural only). Mock provider is ONLY allowed during TEST profile.
17
+ """
18
+ if settings.profile == RuntimeProfile.TEST:
19
+ return MockLLMProvider()
20
+
21
+ if not settings.llm_provider:
22
+ return None
23
+
24
+ provider_name = str(settings.llm_provider).lower().strip()
25
+
26
+ if provider_name == "openai":
27
+ if not settings.openai_api_key:
28
+ raise ValueError(
29
+ "OpenAI API key missing. Set SYNAP_OPENAI_API_KEY or "
30
+ "configure it in ~/.config/synap/config.toml"
31
+ )
32
+ return OpenAIProvider(
33
+ api_key=settings.openai_api_key,
34
+ default_model=settings.llm_model or "",
35
+ )
36
+
37
+ if provider_name == "gemini":
38
+ if not settings.gemini_api_key:
39
+ raise ValueError(
40
+ "Gemini API key missing. Set SYNAP_GEMINI_API_KEY or "
41
+ "configure it in ~/.config/synap/config.toml"
42
+ )
43
+ return GeminiProvider(
44
+ api_key=settings.gemini_api_key,
45
+ default_model=settings.llm_model or "",
46
+ )
47
+
48
+ if provider_name == "anthropic":
49
+ if not settings.anthropic_api_key:
50
+ raise ValueError(
51
+ "Anthropic API key missing. Set SYNAP_ANTHROPIC_API_KEY or "
52
+ "configure it in ~/.config/synap/config.toml"
53
+ )
54
+ return AnthropicProvider(
55
+ api_key=settings.anthropic_api_key,
56
+ default_model=settings.llm_model or "",
57
+ )
58
+
59
+ if provider_name == "ollama":
60
+ return OllamaProvider(
61
+ base_url=settings.ollama_url,
62
+ default_model=settings.llm_model or "",
63
+ )
64
+
65
+ raise ValueError(f"Unsupported or unconfigured LLM provider: {provider_name}")
66
+
67
+
68
+ def get_embed_provider(settings: SynapSettings) -> LLMProvider | None:
69
+ """Create an embedding provider based on runtime settings."""
70
+ if settings.profile == RuntimeProfile.TEST:
71
+ return MockLLMProvider()
72
+
73
+ if not settings.llm_provider:
74
+ return None
75
+
76
+ provider_name = str(settings.llm_provider).lower().strip()
77
+ model = settings.llm_model or ""
78
+
79
+ if provider_name == "openai":
80
+ if not settings.openai_api_key:
81
+ raise ValueError("OpenAI API key missing for embeddings.")
82
+ return OpenAIProvider(
83
+ api_key=settings.openai_api_key,
84
+ default_model=model,
85
+ )
86
+
87
+ if provider_name == "gemini":
88
+ if not settings.gemini_api_key:
89
+ raise ValueError("Gemini API key missing for embeddings.")
90
+ return GeminiProvider(
91
+ api_key=settings.gemini_api_key,
92
+ default_model=model,
93
+ )
94
+
95
+ if provider_name == "ollama":
96
+ return OllamaProvider(
97
+ base_url=settings.ollama_url,
98
+ default_model=model,
99
+ )
100
+
101
+ raise ValueError(f"Unsupported or unconfigured embedding provider: {provider_name}")