yuho 5.0.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.
- yuho/__init__.py +16 -0
- yuho/ast/__init__.py +196 -0
- yuho/ast/builder.py +926 -0
- yuho/ast/constant_folder.py +280 -0
- yuho/ast/dead_code.py +199 -0
- yuho/ast/exhaustiveness.py +503 -0
- yuho/ast/nodes.py +907 -0
- yuho/ast/overlap.py +291 -0
- yuho/ast/reachability.py +293 -0
- yuho/ast/scope_analysis.py +490 -0
- yuho/ast/transformer.py +490 -0
- yuho/ast/type_check.py +471 -0
- yuho/ast/type_inference.py +425 -0
- yuho/ast/visitor.py +239 -0
- yuho/cli/__init__.py +14 -0
- yuho/cli/commands/__init__.py +1 -0
- yuho/cli/commands/api.py +431 -0
- yuho/cli/commands/ast_viz.py +334 -0
- yuho/cli/commands/check.py +218 -0
- yuho/cli/commands/config.py +311 -0
- yuho/cli/commands/contribute.py +122 -0
- yuho/cli/commands/diff.py +487 -0
- yuho/cli/commands/explain.py +240 -0
- yuho/cli/commands/fmt.py +253 -0
- yuho/cli/commands/generate.py +316 -0
- yuho/cli/commands/graph.py +410 -0
- yuho/cli/commands/init.py +120 -0
- yuho/cli/commands/library.py +656 -0
- yuho/cli/commands/lint.py +503 -0
- yuho/cli/commands/lsp.py +36 -0
- yuho/cli/commands/preview.py +377 -0
- yuho/cli/commands/repl.py +444 -0
- yuho/cli/commands/serve.py +44 -0
- yuho/cli/commands/test.py +528 -0
- yuho/cli/commands/transpile.py +121 -0
- yuho/cli/commands/wizard.py +370 -0
- yuho/cli/completions.py +182 -0
- yuho/cli/error_formatter.py +193 -0
- yuho/cli/main.py +1064 -0
- yuho/config/__init__.py +46 -0
- yuho/config/loader.py +235 -0
- yuho/config/mask.py +194 -0
- yuho/config/schema.py +147 -0
- yuho/library/__init__.py +84 -0
- yuho/library/index.py +328 -0
- yuho/library/install.py +699 -0
- yuho/library/lockfile.py +330 -0
- yuho/library/package.py +421 -0
- yuho/library/resolver.py +791 -0
- yuho/library/signature.py +335 -0
- yuho/llm/__init__.py +45 -0
- yuho/llm/config.py +75 -0
- yuho/llm/factory.py +123 -0
- yuho/llm/prompts.py +146 -0
- yuho/llm/providers.py +383 -0
- yuho/llm/utils.py +470 -0
- yuho/lsp/__init__.py +14 -0
- yuho/lsp/code_action_handler.py +518 -0
- yuho/lsp/completion_handler.py +85 -0
- yuho/lsp/diagnostics.py +100 -0
- yuho/lsp/hover_handler.py +130 -0
- yuho/lsp/server.py +1425 -0
- yuho/mcp/__init__.py +10 -0
- yuho/mcp/server.py +1452 -0
- yuho/parser/__init__.py +8 -0
- yuho/parser/source_location.py +108 -0
- yuho/parser/wrapper.py +311 -0
- yuho/testing/__init__.py +48 -0
- yuho/testing/coverage.py +274 -0
- yuho/testing/fixtures.py +263 -0
- yuho/transpile/__init__.py +52 -0
- yuho/transpile/alloy_transpiler.py +546 -0
- yuho/transpile/base.py +100 -0
- yuho/transpile/blocks_transpiler.py +338 -0
- yuho/transpile/english_transpiler.py +470 -0
- yuho/transpile/graphql_transpiler.py +404 -0
- yuho/transpile/json_transpiler.py +217 -0
- yuho/transpile/jsonld_transpiler.py +250 -0
- yuho/transpile/latex_preamble.py +161 -0
- yuho/transpile/latex_transpiler.py +406 -0
- yuho/transpile/latex_utils.py +206 -0
- yuho/transpile/mermaid_transpiler.py +357 -0
- yuho/transpile/registry.py +275 -0
- yuho/verify/__init__.py +43 -0
- yuho/verify/alloy.py +352 -0
- yuho/verify/combined.py +218 -0
- yuho/verify/z3_solver.py +1155 -0
- yuho-5.0.0.dist-info/METADATA +186 -0
- yuho-5.0.0.dist-info/RECORD +91 -0
- yuho-5.0.0.dist-info/WHEEL +4 -0
- yuho-5.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ed25519 signature verification for Yuho packages.
|
|
3
|
+
|
|
4
|
+
Provides cryptographic signing and verification of statute packages
|
|
5
|
+
to ensure authenticity and integrity.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional, Tuple
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Try to import cryptography library
|
|
20
|
+
try:
|
|
21
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
22
|
+
Ed25519PrivateKey,
|
|
23
|
+
Ed25519PublicKey,
|
|
24
|
+
)
|
|
25
|
+
from cryptography.hazmat.primitives import serialization
|
|
26
|
+
from cryptography.exceptions import InvalidSignature
|
|
27
|
+
CRYPTO_AVAILABLE = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
CRYPTO_AVAILABLE = False
|
|
30
|
+
Ed25519PrivateKey = None
|
|
31
|
+
Ed25519PublicKey = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class KeyPair:
|
|
36
|
+
"""Ed25519 key pair for signing packages."""
|
|
37
|
+
private_key: bytes # PEM-encoded private key
|
|
38
|
+
public_key: bytes # PEM-encoded public key
|
|
39
|
+
key_id: str # Fingerprint/identifier for the key
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Signature:
|
|
44
|
+
"""Package signature with metadata."""
|
|
45
|
+
signature: str # Base64-encoded signature
|
|
46
|
+
key_id: str # Key identifier used to sign
|
|
47
|
+
algorithm: str # Always "ed25519"
|
|
48
|
+
content_hash: str # SHA-256 hash of signed content
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SignatureManager:
|
|
52
|
+
"""
|
|
53
|
+
Manages Ed25519 key pairs and package signatures.
|
|
54
|
+
|
|
55
|
+
Keys are stored in ~/.yuho/keys/ directory.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
DEFAULT_KEY_DIR = Path.home() / ".yuho" / "keys"
|
|
59
|
+
|
|
60
|
+
def __init__(self, key_dir: Optional[Path] = None):
|
|
61
|
+
"""
|
|
62
|
+
Initialize signature manager.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
key_dir: Directory for key storage
|
|
66
|
+
"""
|
|
67
|
+
self.key_dir = key_dir or self.DEFAULT_KEY_DIR
|
|
68
|
+
self.key_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def is_available() -> bool:
|
|
72
|
+
"""Check if cryptography library is available."""
|
|
73
|
+
return CRYPTO_AVAILABLE
|
|
74
|
+
|
|
75
|
+
def generate_keypair(self, name: str = "default") -> KeyPair:
|
|
76
|
+
"""
|
|
77
|
+
Generate a new Ed25519 key pair.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
name: Key name for storage
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Generated KeyPair
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ImportError: If cryptography not installed
|
|
87
|
+
"""
|
|
88
|
+
if not CRYPTO_AVAILABLE:
|
|
89
|
+
raise ImportError(
|
|
90
|
+
"Cryptography library not installed. "
|
|
91
|
+
"Install with: pip install cryptography"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Generate private key
|
|
95
|
+
private_key = Ed25519PrivateKey.generate()
|
|
96
|
+
public_key = private_key.public_key()
|
|
97
|
+
|
|
98
|
+
# Serialize to PEM
|
|
99
|
+
private_pem = private_key.private_bytes(
|
|
100
|
+
encoding=serialization.Encoding.PEM,
|
|
101
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
102
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
public_pem = public_key.public_bytes(
|
|
106
|
+
encoding=serialization.Encoding.PEM,
|
|
107
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Generate key ID (fingerprint of public key)
|
|
111
|
+
key_id = hashlib.sha256(public_pem).hexdigest()[:16]
|
|
112
|
+
|
|
113
|
+
keypair = KeyPair(
|
|
114
|
+
private_key=private_pem,
|
|
115
|
+
public_key=public_pem,
|
|
116
|
+
key_id=key_id,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Save to disk
|
|
120
|
+
self._save_keypair(name, keypair)
|
|
121
|
+
|
|
122
|
+
return keypair
|
|
123
|
+
|
|
124
|
+
def _save_keypair(self, name: str, keypair: KeyPair) -> None:
|
|
125
|
+
"""Save key pair to disk."""
|
|
126
|
+
private_path = self.key_dir / f"{name}.key"
|
|
127
|
+
public_path = self.key_dir / f"{name}.pub"
|
|
128
|
+
|
|
129
|
+
private_path.write_bytes(keypair.private_key)
|
|
130
|
+
private_path.chmod(0o600) # Owner read/write only
|
|
131
|
+
|
|
132
|
+
public_path.write_bytes(keypair.public_key)
|
|
133
|
+
|
|
134
|
+
def load_keypair(self, name: str = "default") -> Optional[KeyPair]:
|
|
135
|
+
"""
|
|
136
|
+
Load key pair from disk.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
name: Key name
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
KeyPair or None if not found
|
|
143
|
+
"""
|
|
144
|
+
private_path = self.key_dir / f"{name}.key"
|
|
145
|
+
public_path = self.key_dir / f"{name}.pub"
|
|
146
|
+
|
|
147
|
+
if not private_path.exists() or not public_path.exists():
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
private_pem = private_path.read_bytes()
|
|
151
|
+
public_pem = public_path.read_bytes()
|
|
152
|
+
key_id = hashlib.sha256(public_pem).hexdigest()[:16]
|
|
153
|
+
|
|
154
|
+
return KeyPair(
|
|
155
|
+
private_key=private_pem,
|
|
156
|
+
public_key=public_pem,
|
|
157
|
+
key_id=key_id,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def load_public_key(self, name: str = "default") -> Optional[bytes]:
|
|
161
|
+
"""Load just the public key."""
|
|
162
|
+
public_path = self.key_dir / f"{name}.pub"
|
|
163
|
+
if public_path.exists():
|
|
164
|
+
return public_path.read_bytes()
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
def list_keys(self) -> list:
|
|
168
|
+
"""List available key names."""
|
|
169
|
+
keys = []
|
|
170
|
+
for path in self.key_dir.glob("*.pub"):
|
|
171
|
+
keys.append(path.stem)
|
|
172
|
+
return keys
|
|
173
|
+
|
|
174
|
+
def sign_content(self, content: bytes, keypair: KeyPair) -> Signature:
|
|
175
|
+
"""
|
|
176
|
+
Sign content with Ed25519 private key.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
content: Content to sign
|
|
180
|
+
keypair: Key pair to use
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Signature object
|
|
184
|
+
"""
|
|
185
|
+
if not CRYPTO_AVAILABLE:
|
|
186
|
+
raise ImportError("Cryptography library not installed")
|
|
187
|
+
|
|
188
|
+
# Load private key
|
|
189
|
+
private_key = serialization.load_pem_private_key(
|
|
190
|
+
keypair.private_key,
|
|
191
|
+
password=None,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Sign
|
|
195
|
+
signature_bytes = private_key.sign(content)
|
|
196
|
+
|
|
197
|
+
return Signature(
|
|
198
|
+
signature=base64.b64encode(signature_bytes).decode("ascii"),
|
|
199
|
+
key_id=keypair.key_id,
|
|
200
|
+
algorithm="ed25519",
|
|
201
|
+
content_hash=hashlib.sha256(content).hexdigest(),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def verify_signature(
|
|
205
|
+
self,
|
|
206
|
+
content: bytes,
|
|
207
|
+
signature: Signature,
|
|
208
|
+
public_key_pem: bytes,
|
|
209
|
+
) -> Tuple[bool, str]:
|
|
210
|
+
"""
|
|
211
|
+
Verify content signature.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
content: Content that was signed
|
|
215
|
+
signature: Signature to verify
|
|
216
|
+
public_key_pem: PEM-encoded public key
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Tuple of (is_valid, message)
|
|
220
|
+
"""
|
|
221
|
+
if not CRYPTO_AVAILABLE:
|
|
222
|
+
return (False, "Cryptography library not installed")
|
|
223
|
+
|
|
224
|
+
# Verify content hash
|
|
225
|
+
content_hash = hashlib.sha256(content).hexdigest()
|
|
226
|
+
if content_hash != signature.content_hash:
|
|
227
|
+
return (False, "Content hash mismatch - content may have been modified")
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
# Load public key
|
|
231
|
+
public_key = serialization.load_pem_public_key(public_key_pem)
|
|
232
|
+
|
|
233
|
+
# Verify signature
|
|
234
|
+
signature_bytes = base64.b64decode(signature.signature)
|
|
235
|
+
public_key.verify(signature_bytes, content)
|
|
236
|
+
|
|
237
|
+
return (True, "Signature verified successfully")
|
|
238
|
+
|
|
239
|
+
except InvalidSignature:
|
|
240
|
+
return (False, "Invalid signature - content may have been tampered with")
|
|
241
|
+
except Exception as e:
|
|
242
|
+
return (False, f"Verification error: {e}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def sign_package(
|
|
246
|
+
package_path: Path,
|
|
247
|
+
key_name: str = "default",
|
|
248
|
+
key_dir: Optional[Path] = None,
|
|
249
|
+
) -> Tuple[bool, str]:
|
|
250
|
+
"""
|
|
251
|
+
Sign a package file.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
package_path: Path to .yhpkg file
|
|
255
|
+
key_name: Name of key to use
|
|
256
|
+
key_dir: Key directory
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Tuple of (success, message)
|
|
260
|
+
"""
|
|
261
|
+
manager = SignatureManager(key_dir)
|
|
262
|
+
|
|
263
|
+
keypair = manager.load_keypair(key_name)
|
|
264
|
+
if not keypair:
|
|
265
|
+
return (False, f"Key '{key_name}' not found. Generate with: yuho key generate")
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
content = package_path.read_bytes()
|
|
269
|
+
signature = manager.sign_content(content, keypair)
|
|
270
|
+
|
|
271
|
+
# Write signature file
|
|
272
|
+
sig_path = package_path.with_suffix(".yhpkg.sig")
|
|
273
|
+
sig_data = {
|
|
274
|
+
"signature": signature.signature,
|
|
275
|
+
"key_id": signature.key_id,
|
|
276
|
+
"algorithm": signature.algorithm,
|
|
277
|
+
"content_hash": signature.content_hash,
|
|
278
|
+
}
|
|
279
|
+
sig_path.write_text(json.dumps(sig_data, indent=2))
|
|
280
|
+
|
|
281
|
+
return (True, f"Package signed with key {signature.key_id}")
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
return (False, f"Signing failed: {e}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def verify_package(
|
|
288
|
+
package_path: Path,
|
|
289
|
+
trusted_keys_dir: Optional[Path] = None,
|
|
290
|
+
) -> Tuple[bool, str]:
|
|
291
|
+
"""
|
|
292
|
+
Verify a package signature.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
package_path: Path to .yhpkg file
|
|
296
|
+
trusted_keys_dir: Directory containing trusted public keys
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Tuple of (is_valid, message)
|
|
300
|
+
"""
|
|
301
|
+
manager = SignatureManager(trusted_keys_dir)
|
|
302
|
+
|
|
303
|
+
sig_path = package_path.with_suffix(".yhpkg.sig")
|
|
304
|
+
if not sig_path.exists():
|
|
305
|
+
return (False, "No signature file found")
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
# Load signature
|
|
309
|
+
sig_data = json.loads(sig_path.read_text())
|
|
310
|
+
signature = Signature(
|
|
311
|
+
signature=sig_data["signature"],
|
|
312
|
+
key_id=sig_data["key_id"],
|
|
313
|
+
algorithm=sig_data["algorithm"],
|
|
314
|
+
content_hash=sig_data["content_hash"],
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Find matching public key
|
|
318
|
+
public_key = None
|
|
319
|
+
for key_name in manager.list_keys():
|
|
320
|
+
key_pem = manager.load_public_key(key_name)
|
|
321
|
+
if key_pem:
|
|
322
|
+
key_id = hashlib.sha256(key_pem).hexdigest()[:16]
|
|
323
|
+
if key_id == signature.key_id:
|
|
324
|
+
public_key = key_pem
|
|
325
|
+
break
|
|
326
|
+
|
|
327
|
+
if not public_key:
|
|
328
|
+
return (False, f"No trusted key found for key_id {signature.key_id}")
|
|
329
|
+
|
|
330
|
+
# Verify
|
|
331
|
+
content = package_path.read_bytes()
|
|
332
|
+
return manager.verify_signature(content, signature, public_key)
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
return (False, f"Verification failed: {e}")
|
yuho/llm/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Yuho LLM module - local-first LLM integration.
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- Ollama (local)
|
|
6
|
+
- HuggingFace Transformers (local)
|
|
7
|
+
- OpenAI API (cloud)
|
|
8
|
+
- Anthropic API (cloud)
|
|
9
|
+
|
|
10
|
+
Local providers are preferred, with cloud fallback.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from yuho.llm.config import LLMConfig
|
|
14
|
+
from yuho.llm.providers import (
|
|
15
|
+
LLMProvider,
|
|
16
|
+
OllamaProvider,
|
|
17
|
+
HuggingFaceProvider,
|
|
18
|
+
)
|
|
19
|
+
from yuho.llm.factory import get_provider, LLMProviderFactory
|
|
20
|
+
from yuho.llm.prompts import (
|
|
21
|
+
STATUTE_EXPLANATION_PROMPT,
|
|
22
|
+
STATUTE_TO_YUHO_PROMPT,
|
|
23
|
+
)
|
|
24
|
+
from yuho.llm.utils import (
|
|
25
|
+
ResponseCache,
|
|
26
|
+
RateLimiter,
|
|
27
|
+
TokenCounter,
|
|
28
|
+
PromptCompressor,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"LLMConfig",
|
|
33
|
+
"LLMProvider",
|
|
34
|
+
"OllamaProvider",
|
|
35
|
+
"HuggingFaceProvider",
|
|
36
|
+
"get_provider",
|
|
37
|
+
"LLMProviderFactory",
|
|
38
|
+
"STATUTE_EXPLANATION_PROMPT",
|
|
39
|
+
"STATUTE_TO_YUHO_PROMPT",
|
|
40
|
+
# Utilities
|
|
41
|
+
"ResponseCache",
|
|
42
|
+
"RateLimiter",
|
|
43
|
+
"TokenCounter",
|
|
44
|
+
"PromptCompressor",
|
|
45
|
+
]
|
yuho/llm/config.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM configuration dataclass.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional, List
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProviderType(Enum):
|
|
11
|
+
"""Supported LLM providers."""
|
|
12
|
+
OLLAMA = "ollama"
|
|
13
|
+
HUGGINGFACE = "huggingface"
|
|
14
|
+
OPENAI = "openai"
|
|
15
|
+
ANTHROPIC = "anthropic"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class LLMConfig:
|
|
20
|
+
"""
|
|
21
|
+
Configuration for LLM provider.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
provider: The provider type (ollama, huggingface, openai, anthropic)
|
|
25
|
+
model_name: Model identifier (e.g., "llama3", "gpt-4", "claude-3-opus")
|
|
26
|
+
api_key: API key for cloud providers
|
|
27
|
+
base_url: Base URL for API (useful for custom endpoints)
|
|
28
|
+
ollama_host: Ollama server host (default: localhost)
|
|
29
|
+
ollama_port: Ollama server port (default: 11434)
|
|
30
|
+
huggingface_cache: Cache directory for HuggingFace models
|
|
31
|
+
max_tokens: Default max tokens for generation
|
|
32
|
+
temperature: Generation temperature
|
|
33
|
+
fallback_providers: List of fallback providers to try
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
provider: str = "ollama"
|
|
37
|
+
model_name: str = "llama3"
|
|
38
|
+
api_key: Optional[str] = None
|
|
39
|
+
base_url: Optional[str] = None
|
|
40
|
+
ollama_host: str = "localhost"
|
|
41
|
+
ollama_port: int = 11434
|
|
42
|
+
huggingface_cache: Optional[str] = None
|
|
43
|
+
max_tokens: int = 2048
|
|
44
|
+
temperature: float = 0.7
|
|
45
|
+
fallback_providers: List[str] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def provider_type(self) -> ProviderType:
|
|
49
|
+
"""Get provider type enum."""
|
|
50
|
+
return ProviderType(self.provider.lower())
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def ollama_url(self) -> str:
|
|
54
|
+
"""Get full Ollama API URL."""
|
|
55
|
+
return f"http://{self.ollama_host}:{self.ollama_port}"
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, data: dict) -> "LLMConfig":
|
|
59
|
+
"""Create config from dictionary."""
|
|
60
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
"""Convert to dictionary."""
|
|
64
|
+
return {
|
|
65
|
+
"provider": self.provider,
|
|
66
|
+
"model_name": self.model_name,
|
|
67
|
+
"api_key": "***" if self.api_key else None, # Redact API key
|
|
68
|
+
"base_url": self.base_url,
|
|
69
|
+
"ollama_host": self.ollama_host,
|
|
70
|
+
"ollama_port": self.ollama_port,
|
|
71
|
+
"huggingface_cache": self.huggingface_cache,
|
|
72
|
+
"max_tokens": self.max_tokens,
|
|
73
|
+
"temperature": self.temperature,
|
|
74
|
+
"fallback_providers": self.fallback_providers,
|
|
75
|
+
}
|
yuho/llm/factory.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM provider factory with fallback support.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional, List
|
|
7
|
+
|
|
8
|
+
from yuho.llm.config import LLMConfig, ProviderType
|
|
9
|
+
from yuho.llm.providers import (
|
|
10
|
+
LLMProvider,
|
|
11
|
+
OllamaProvider,
|
|
12
|
+
HuggingFaceProvider,
|
|
13
|
+
OpenAIProvider,
|
|
14
|
+
AnthropicProvider,
|
|
15
|
+
)
|
|
16
|
+
from yuho.config.mask import mask_error
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LLMProviderFactory:
|
|
22
|
+
"""
|
|
23
|
+
Factory for creating LLM providers from config.
|
|
24
|
+
|
|
25
|
+
Supports automatic fallback to alternative providers.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_provider_classes = {
|
|
29
|
+
ProviderType.OLLAMA: OllamaProvider,
|
|
30
|
+
ProviderType.HUGGINGFACE: HuggingFaceProvider,
|
|
31
|
+
ProviderType.OPENAI: OpenAIProvider,
|
|
32
|
+
ProviderType.ANTHROPIC: AnthropicProvider,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def create(cls, config: LLMConfig) -> LLMProvider:
|
|
37
|
+
"""
|
|
38
|
+
Create a provider from config.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
config: LLM configuration
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
An LLMProvider instance
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If provider type is unknown
|
|
48
|
+
"""
|
|
49
|
+
provider_type = config.provider_type
|
|
50
|
+
provider_class = cls._provider_classes.get(provider_type)
|
|
51
|
+
|
|
52
|
+
if not provider_class:
|
|
53
|
+
raise ValueError(f"Unknown provider type: {config.provider}")
|
|
54
|
+
|
|
55
|
+
return provider_class(config)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def create_with_fallback(cls, config: LLMConfig) -> LLMProvider:
|
|
59
|
+
"""
|
|
60
|
+
Create a provider with automatic fallback.
|
|
61
|
+
|
|
62
|
+
Tries the primary provider first, then falls back to
|
|
63
|
+
configured alternatives if unavailable.
|
|
64
|
+
"""
|
|
65
|
+
# Try primary provider
|
|
66
|
+
try:
|
|
67
|
+
primary = cls.create(config)
|
|
68
|
+
if primary.is_available():
|
|
69
|
+
logger.info(f"Using {config.provider} provider")
|
|
70
|
+
return primary
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.warning(f"Primary provider {config.provider} failed: {mask_error(e)}")
|
|
73
|
+
|
|
74
|
+
# Try fallback providers
|
|
75
|
+
for fallback_name in config.fallback_providers:
|
|
76
|
+
try:
|
|
77
|
+
fallback_config = LLMConfig(
|
|
78
|
+
provider=fallback_name,
|
|
79
|
+
model_name=config.model_name,
|
|
80
|
+
api_key=config.api_key,
|
|
81
|
+
base_url=config.base_url,
|
|
82
|
+
ollama_host=config.ollama_host,
|
|
83
|
+
ollama_port=config.ollama_port,
|
|
84
|
+
huggingface_cache=config.huggingface_cache,
|
|
85
|
+
max_tokens=config.max_tokens,
|
|
86
|
+
temperature=config.temperature,
|
|
87
|
+
)
|
|
88
|
+
fallback = cls.create(fallback_config)
|
|
89
|
+
if fallback.is_available():
|
|
90
|
+
logger.info(f"Falling back to {fallback_name} provider")
|
|
91
|
+
return fallback
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.warning(f"Fallback provider {fallback_name} failed: {mask_error(e)}")
|
|
94
|
+
|
|
95
|
+
# Last resort: try Ollama with default settings
|
|
96
|
+
try:
|
|
97
|
+
default_ollama = OllamaProvider(LLMConfig(provider="ollama", model_name="llama3"))
|
|
98
|
+
if default_ollama.is_available():
|
|
99
|
+
logger.info("Falling back to default Ollama")
|
|
100
|
+
return default_ollama
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"No LLM provider available. Install and configure at least one of: "
|
|
106
|
+
"Ollama, HuggingFace, OpenAI, or Anthropic"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_provider(config: Optional[LLMConfig] = None) -> LLMProvider:
|
|
111
|
+
"""
|
|
112
|
+
Get an LLM provider, using fallback if needed.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
config: Optional config. Uses defaults if not provided.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
An LLMProvider instance
|
|
119
|
+
"""
|
|
120
|
+
if config is None:
|
|
121
|
+
config = LLMConfig()
|
|
122
|
+
|
|
123
|
+
return LLMProviderFactory.create_with_fallback(config)
|