ezshell 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.
- ezshell-0.2.0.dist-info/METADATA +174 -0
- ezshell-0.2.0.dist-info/RECORD +37 -0
- ezshell-0.2.0.dist-info/WHEEL +5 -0
- ezshell-0.2.0.dist-info/entry_points.txt +3 -0
- ezshell-0.2.0.dist-info/licenses/LICENSE +21 -0
- ezshell-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/app.py +286 -0
- src/core/__init__.py +0 -0
- src/core/assistant.py +525 -0
- src/core/capture.py +213 -0
- src/core/chat_bridge.py +107 -0
- src/core/config.py +238 -0
- src/core/context.py +229 -0
- src/core/groq_client.py +376 -0
- src/core/llm_client.py +111 -0
- src/core/prompts.py +262 -0
- src/core/provider.py +233 -0
- src/core/proxy_client.py +166 -0
- src/core/pty_shell.py +273 -0
- src/core/redact.py +95 -0
- src/core/risk.py +455 -0
- src/core/safety.py +268 -0
- src/core/sanitize.py +39 -0
- src/core/schema.py +65 -0
- src/core/shell_hook.py +388 -0
- src/core/suggest.py +94 -0
- src/core/updater.py +428 -0
- src/i18n.py +422 -0
- src/main.py +346 -0
- src/ui/chat_tui.py +634 -0
- src/ui/gui_window.py +1018 -0
- src/ui/inline.py +330 -0
- src/ui/logo.py +194 -0
- src/ui/panel.py +163 -0
- src/ui/prompt.py +184 -0
- src/ui/render.py +269 -0
src/core/context.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CommandHistoryEntry:
|
|
15
|
+
command: str
|
|
16
|
+
exit_code: int
|
|
17
|
+
timestamp: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SystemContext:
|
|
22
|
+
os_name: str
|
|
23
|
+
os_version: str
|
|
24
|
+
architecture: str
|
|
25
|
+
current_user: str
|
|
26
|
+
current_directory: str
|
|
27
|
+
shell_name: str
|
|
28
|
+
git_branch: Optional[str] = None
|
|
29
|
+
git_is_dirty: Optional[bool] = None
|
|
30
|
+
recent_history: List[CommandHistoryEntry] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def collect(cls, history: Optional[List[CommandHistoryEntry]] = None) -> SystemContext:
|
|
34
|
+
"""Collects current runtime environment and system context."""
|
|
35
|
+
os_name = platform.system()
|
|
36
|
+
os_version = platform.release()
|
|
37
|
+
|
|
38
|
+
# Extract Linux distro name if available
|
|
39
|
+
if os_name == "Linux":
|
|
40
|
+
try:
|
|
41
|
+
import distro # type: ignore
|
|
42
|
+
|
|
43
|
+
distro_name = distro.name(pretty=True)
|
|
44
|
+
if distro_name:
|
|
45
|
+
os_name = distro_name
|
|
46
|
+
except Exception:
|
|
47
|
+
if Path("/etc/os-release").is_file():
|
|
48
|
+
try:
|
|
49
|
+
with open("/etc/os-release", "r") as f:
|
|
50
|
+
for line in f:
|
|
51
|
+
if line.startswith("PRETTY_NAME="):
|
|
52
|
+
os_name = line.split("=", 1)[1].strip().strip('"')
|
|
53
|
+
break
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
elif os_name == "Darwin":
|
|
57
|
+
os_name = f"macOS {platform.mac_ver()[0]}"
|
|
58
|
+
|
|
59
|
+
# Git context
|
|
60
|
+
git_branch = None
|
|
61
|
+
git_is_dirty = None
|
|
62
|
+
if shutil.which("git"):
|
|
63
|
+
try:
|
|
64
|
+
# Check branch
|
|
65
|
+
branch_proc = subprocess.run(
|
|
66
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
67
|
+
capture_output=True,
|
|
68
|
+
text=True,
|
|
69
|
+
timeout=1.0,
|
|
70
|
+
)
|
|
71
|
+
if branch_proc.returncode == 0:
|
|
72
|
+
git_branch = branch_proc.stdout.strip()
|
|
73
|
+
|
|
74
|
+
# Check dirty status
|
|
75
|
+
status_proc = subprocess.run(
|
|
76
|
+
["git", "status", "--porcelain"],
|
|
77
|
+
capture_output=True,
|
|
78
|
+
text=True,
|
|
79
|
+
timeout=1.0,
|
|
80
|
+
)
|
|
81
|
+
git_is_dirty = bool(status_proc.stdout.strip())
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
from src.core.suggest import detect_shell
|
|
86
|
+
|
|
87
|
+
shell_name = detect_shell()
|
|
88
|
+
|
|
89
|
+
return cls(
|
|
90
|
+
os_name=os_name,
|
|
91
|
+
os_version=os_version,
|
|
92
|
+
architecture=platform.machine(),
|
|
93
|
+
current_user=getpass.getuser(),
|
|
94
|
+
current_directory=str(Path.cwd()),
|
|
95
|
+
shell_name=shell_name,
|
|
96
|
+
git_branch=git_branch,
|
|
97
|
+
git_is_dirty=git_is_dirty,
|
|
98
|
+
recent_history=history or [],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def to_prompt_context(self) -> str:
|
|
102
|
+
"""Formats the system context as a concise string for LLM prompts."""
|
|
103
|
+
lines = [
|
|
104
|
+
f"OS: {self.os_name} ({self.architecture})",
|
|
105
|
+
f"Shell: {self.shell_name}",
|
|
106
|
+
f"Current Directory: {self.current_directory}",
|
|
107
|
+
f"User: {self.current_user}",
|
|
108
|
+
]
|
|
109
|
+
if self.git_branch:
|
|
110
|
+
dirty_str = " (uncommitted changes)" if self.git_is_dirty else " (clean)"
|
|
111
|
+
lines.append(f"Git Branch: {self.git_branch}{dirty_str}")
|
|
112
|
+
|
|
113
|
+
if self.recent_history:
|
|
114
|
+
recent_str = " -> ".join(
|
|
115
|
+
f"`{entry.command}` (exit: {entry.exit_code})" for entry in self.recent_history[-3:]
|
|
116
|
+
)
|
|
117
|
+
lines.append(f"Recent Commands: {recent_str}")
|
|
118
|
+
|
|
119
|
+
return "\n".join(lines)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_last_shell_command() -> Optional[str]:
|
|
123
|
+
"""Retrieves the last non-ezShell command executed by the user.
|
|
124
|
+
|
|
125
|
+
Inspects in priority order:
|
|
126
|
+
1. ~/.ezshell/.last_command (marker written by shell hook)
|
|
127
|
+
2. $HISTFILE
|
|
128
|
+
3. ~/.zsh_history (handles Zsh extended format ': <timestamp>:0;<cmd>')
|
|
129
|
+
4. ~/.bash_history
|
|
130
|
+
5. ~/.histfile
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Optional[str]: The command string, or None if no command history found.
|
|
134
|
+
"""
|
|
135
|
+
# 0. État par terminal (hooks M0) : plus fiable que l'historique partagé
|
|
136
|
+
try:
|
|
137
|
+
from src.core.capture import read_last_run
|
|
138
|
+
|
|
139
|
+
run = read_last_run()
|
|
140
|
+
if run and run.command:
|
|
141
|
+
return run.command
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
candidates = []
|
|
146
|
+
|
|
147
|
+
# 1. ezShell runtime marker
|
|
148
|
+
ez_marker = Path.home() / ".ezshell" / ".last_command"
|
|
149
|
+
if ez_marker.is_file():
|
|
150
|
+
candidates.append(ez_marker)
|
|
151
|
+
|
|
152
|
+
# 2. $HISTFILE
|
|
153
|
+
histfile = os.environ.get("HISTFILE")
|
|
154
|
+
if histfile and Path(histfile).is_file():
|
|
155
|
+
candidates.append(Path(histfile))
|
|
156
|
+
|
|
157
|
+
# 3. Standard shell history paths
|
|
158
|
+
home = Path.home()
|
|
159
|
+
for p in [
|
|
160
|
+
home / ".zsh_history",
|
|
161
|
+
home / ".bash_history",
|
|
162
|
+
home / ".histfile",
|
|
163
|
+
home / ".zhistory",
|
|
164
|
+
]:
|
|
165
|
+
if p.is_file():
|
|
166
|
+
candidates.append(p)
|
|
167
|
+
|
|
168
|
+
ignored_prefixes = ("ezsh", "ezshell", "eszh", "clear", "exit", "history")
|
|
169
|
+
|
|
170
|
+
for p in candidates:
|
|
171
|
+
try:
|
|
172
|
+
with open(p, "rb") as f:
|
|
173
|
+
f.seek(0, os.SEEK_END)
|
|
174
|
+
size = f.tell()
|
|
175
|
+
f.seek(max(0, size - 8192))
|
|
176
|
+
content = f.read().decode("utf-8", errors="ignore")
|
|
177
|
+
lines = content.splitlines()
|
|
178
|
+
|
|
179
|
+
for raw_line in reversed(lines):
|
|
180
|
+
line = raw_line.strip()
|
|
181
|
+
# Zsh extended history format: ': 1712345678:0;command'
|
|
182
|
+
if line.startswith(":") and ";" in line:
|
|
183
|
+
line = line.split(";", 1)[1].strip()
|
|
184
|
+
|
|
185
|
+
if not line:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
parts = line.split()
|
|
189
|
+
if not parts:
|
|
190
|
+
continue
|
|
191
|
+
cmd_name = parts[0]
|
|
192
|
+
base_cmd = Path(cmd_name).name
|
|
193
|
+
|
|
194
|
+
# Ignore ezshell aliases and system navigation commands
|
|
195
|
+
if base_cmd in ignored_prefixes or cmd_name in ignored_prefixes:
|
|
196
|
+
continue
|
|
197
|
+
if any(x in line for x in ("ezshell", "ezsh", "eszh")):
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
return line
|
|
201
|
+
except Exception:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_last_exit_code() -> Optional[int]:
|
|
208
|
+
"""Retrieves the last command's exit code from ~/.ezshell/.last_exit_code.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Optional[int]: The exit code (e.g. 0, 1, 127), or None if not available.
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
from src.core.capture import read_last_run
|
|
215
|
+
|
|
216
|
+
run = read_last_run()
|
|
217
|
+
if run and run.exit_code is not None:
|
|
218
|
+
return run.exit_code
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
marker = Path.home() / ".ezshell" / ".last_exit_code"
|
|
223
|
+
if marker.is_file():
|
|
224
|
+
try:
|
|
225
|
+
val = marker.read_text(encoding="utf-8").strip()
|
|
226
|
+
return int(val)
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
return None
|
src/core/groq_client.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ezShell Groq API Client
|
|
3
|
+
========================
|
|
4
|
+
This module wraps the official ``groq`` Python SDK to provide a thin,
|
|
5
|
+
opinionated interface for the language-model calls that power ezShell.
|
|
6
|
+
It surfaces a minimal API — :meth:`GroqClient.chat`,
|
|
7
|
+
:meth:`GroqClient.complete`, and :meth:`GroqClient.stream_complete` — and
|
|
8
|
+
translates every ``groq`` SDK exception into a narrow hierarchy of
|
|
9
|
+
application-specific errors so that callers never need to import from
|
|
10
|
+
``groq`` directly.
|
|
11
|
+
|
|
12
|
+
The underlying :class:`groq.Groq` SDK client is created lazily on first use
|
|
13
|
+
and reused for subsequent calls, avoiding redundant TCP handshakes.
|
|
14
|
+
|
|
15
|
+
Exception hierarchy
|
|
16
|
+
-------------------
|
|
17
|
+
::
|
|
18
|
+
|
|
19
|
+
GroqClientError (base — unexpected / uncategorised errors)
|
|
20
|
+
├── GroqAuthError (missing or invalid API key)
|
|
21
|
+
├── GroqRateLimitError (quota exceeded)
|
|
22
|
+
└── GroqConnectionError (network / DNS / timeout failure)
|
|
23
|
+
|
|
24
|
+
Usage example::
|
|
25
|
+
|
|
26
|
+
from ezShell.src.core.groq_client import GroqClient
|
|
27
|
+
|
|
28
|
+
client = GroqClient(api_key="gsk_...")
|
|
29
|
+
reply = client.complete(
|
|
30
|
+
prompt="List the files in /tmp",
|
|
31
|
+
system_prompt="You are a helpful shell assistant.",
|
|
32
|
+
)
|
|
33
|
+
print(reply)
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
Client Groq pour ezShell
|
|
38
|
+
==========================
|
|
39
|
+
Ce module encapsule le SDK Python officiel ``groq`` afin d'exposer une
|
|
40
|
+
interface légère et ciblée pour les appels au modèle de langage utilisés
|
|
41
|
+
par ezShell. Il traduit chaque exception du SDK en une hiérarchie
|
|
42
|
+
d'erreurs propre à l'application, de sorte que les appelants n'aient
|
|
43
|
+
jamais à importer directement depuis ``groq``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import os
|
|
49
|
+
from typing import Generator, Optional
|
|
50
|
+
|
|
51
|
+
import groq
|
|
52
|
+
from groq import Groq
|
|
53
|
+
|
|
54
|
+
from .config import DEFAULT_MODEL
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Application-level exception hierarchy
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GroqClientError(Exception):
|
|
62
|
+
"""Base exception for all errors raised by :class:`GroqClient`.
|
|
63
|
+
|
|
64
|
+
Catch this class to handle any Groq-related failure regardless of the
|
|
65
|
+
specific root cause. More specific sub-classes are also available for
|
|
66
|
+
callers that need fine-grained error handling.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GroqAuthError(GroqClientError):
|
|
71
|
+
"""Raised when API key is missing, malformed, or rejected by the server.
|
|
72
|
+
|
|
73
|
+
This exception is raised in two scenarios:
|
|
74
|
+
|
|
75
|
+
* At construction time when no API key can be found in either the
|
|
76
|
+
constructor argument or the ``GROQ_API_KEY`` environment variable.
|
|
77
|
+
* At request time when the Groq API returns an ``AuthenticationError``
|
|
78
|
+
or ``PermissionDeniedError`` (e.g. the key has been revoked).
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GroqRateLimitError(GroqClientError):
|
|
83
|
+
"""Raised when the Groq API rate limit or token quota is exceeded.
|
|
84
|
+
|
|
85
|
+
Callers should implement back-off / retry logic when catching this
|
|
86
|
+
exception, or prompt the user to wait before retrying.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class GroqConnectionError(GroqClientError):
|
|
91
|
+
"""Raised on network-level failures when contacting the Groq API.
|
|
92
|
+
|
|
93
|
+
Covers DNS resolution failures, TCP connection refusal, TLS errors, and
|
|
94
|
+
request timeouts. The underlying :class:`groq.APIConnectionError` is
|
|
95
|
+
chained as the ``__cause__`` for inspection.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Main client class
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class GroqClient:
|
|
105
|
+
"""Thin, opinionated wrapper around the official Groq SDK for ezShell.
|
|
106
|
+
|
|
107
|
+
The client is initialised with an API key and an optional timeout, then
|
|
108
|
+
exposes three completion methods:
|
|
109
|
+
|
|
110
|
+
* :meth:`chat` — multi-turn conversation with an explicit message list
|
|
111
|
+
* :meth:`complete` — single prompt + system-prompt convenience wrapper
|
|
112
|
+
* :meth:`stream_complete` — streaming generator variant of :meth:`complete`
|
|
113
|
+
|
|
114
|
+
The underlying :class:`groq.Groq` SDK instance is created lazily via the
|
|
115
|
+
:attr:`client` property and reused across calls.
|
|
116
|
+
|
|
117
|
+
Example::
|
|
118
|
+
|
|
119
|
+
client = GroqClient() # reads GROQ_API_KEY from environment
|
|
120
|
+
for chunk in client.stream_complete(
|
|
121
|
+
prompt="Explain what `ls -la` does.",
|
|
122
|
+
system_prompt="You are a concise shell tutor.",
|
|
123
|
+
):
|
|
124
|
+
print(chunk, end="", flush=True)
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
api_key (Optional[str]): Groq platform API key. When ``None``,
|
|
128
|
+
the ``GROQ_API_KEY`` environment variable is used as a fallback.
|
|
129
|
+
timeout (float): HTTP request timeout in seconds applied to every
|
|
130
|
+
API call. Default is ``15.0``.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
GroqAuthError: If no API key is available from either *api_key* or
|
|
134
|
+
the environment at construction time.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, api_key: Optional[str] = None, timeout: float = 15.0):
|
|
138
|
+
# Prefer the explicit argument; fall back to the environment variable
|
|
139
|
+
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
|
140
|
+
if not self.api_key:
|
|
141
|
+
raise GroqAuthError(
|
|
142
|
+
"No Groq API key found. Please set GROQ_API_KEY in your environment, "
|
|
143
|
+
".env.local, or ~/.ezshell/config.json."
|
|
144
|
+
)
|
|
145
|
+
self.timeout = timeout
|
|
146
|
+
# Deferred until first use to avoid creating network connections at import time
|
|
147
|
+
self._client: Optional[Groq] = None
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def client(self) -> Groq:
|
|
151
|
+
"""Lazily initialised :class:`groq.Groq` SDK instance.
|
|
152
|
+
|
|
153
|
+
The instance is created on the first property access and cached for
|
|
154
|
+
all subsequent calls, giving connection reuse without requiring the
|
|
155
|
+
caller to manage the lifecycle explicitly.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Groq: A configured ``groq.Groq`` client ready to make API calls.
|
|
159
|
+
"""
|
|
160
|
+
if self._client is None:
|
|
161
|
+
self._client = Groq(api_key=self.api_key, timeout=self.timeout)
|
|
162
|
+
return self._client
|
|
163
|
+
|
|
164
|
+
def validate_connection(self) -> bool:
|
|
165
|
+
"""Probe the Groq API to verify that the key and network are working.
|
|
166
|
+
|
|
167
|
+
Sends a minimal one-token request to avoid unnecessary token
|
|
168
|
+
consumption. Any response — even an error response — from the server
|
|
169
|
+
indicates network connectivity; only authentication and permission
|
|
170
|
+
errors cause the method to return ``False``.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
bool: ``True`` if the API key is accepted and a response is
|
|
174
|
+
received; ``False`` if authentication fails or any exception is
|
|
175
|
+
raised (including network errors).
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
self.client.models.list()
|
|
179
|
+
return True
|
|
180
|
+
except (groq.AuthenticationError, groq.PermissionDeniedError):
|
|
181
|
+
# Key is present but rejected by the server
|
|
182
|
+
return False
|
|
183
|
+
except Exception:
|
|
184
|
+
# Network errors, timeouts, or other unexpected failures
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
def chat(
|
|
188
|
+
self,
|
|
189
|
+
messages: list[dict[str, str]],
|
|
190
|
+
model: str = DEFAULT_MODEL,
|
|
191
|
+
temperature: float = 0.2,
|
|
192
|
+
max_tokens: int = 1024,
|
|
193
|
+
) -> str:
|
|
194
|
+
"""Send a multi-turn conversation to the Groq API and return the reply.
|
|
195
|
+
|
|
196
|
+
This is the lowest-level completion method; callers supply a fully
|
|
197
|
+
assembled message list, giving full control over the conversation
|
|
198
|
+
history and system prompt.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
messages (list[dict[str, str]]): Ordered list of message dicts,
|
|
202
|
+
each with ``"role"`` (``"system"``, ``"user"``, or
|
|
203
|
+
``"assistant"``) and ``"content"`` keys, following the
|
|
204
|
+
OpenAI Chat Completions format accepted by Groq.
|
|
205
|
+
model (str): Groq model identifier to use for this request.
|
|
206
|
+
Defaults to ``"openai/gpt-oss-120b"``.
|
|
207
|
+
temperature (float): Sampling temperature in the range ``[0, 2]``.
|
|
208
|
+
Lower values produce more focused, deterministic output.
|
|
209
|
+
Defaults to ``0.2``.
|
|
210
|
+
max_tokens (int): Maximum number of tokens the model may generate.
|
|
211
|
+
Defaults to ``1024``.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
str: The model's text reply, or an empty string if the API
|
|
215
|
+
returns a choice with no content.
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
GroqAuthError: If the API key is rejected by the server.
|
|
219
|
+
GroqRateLimitError: If the rate limit or token quota is exceeded.
|
|
220
|
+
GroqConnectionError: If a network-level error prevents the
|
|
221
|
+
request from reaching the API.
|
|
222
|
+
GroqClientError: For any other API error (e.g. invalid model,
|
|
223
|
+
server-side 5xx, unexpected SDK exception).
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
response = self.client.chat.completions.create(
|
|
227
|
+
model=model,
|
|
228
|
+
messages=messages,
|
|
229
|
+
temperature=temperature,
|
|
230
|
+
max_tokens=max_tokens,
|
|
231
|
+
)
|
|
232
|
+
return response.choices[0].message.content or ""
|
|
233
|
+
except groq.AuthenticationError as e:
|
|
234
|
+
raise GroqAuthError(f"Groq authentication failed: {e}") from e
|
|
235
|
+
except groq.RateLimitError as e:
|
|
236
|
+
raise GroqRateLimitError(f"Groq rate limit exceeded: {e}") from e
|
|
237
|
+
except groq.NotFoundError:
|
|
238
|
+
# If requested model is not found on account, fallback to available chat models
|
|
239
|
+
for fallback_model in ("openai/gpt-oss-20b", "qwen/qwen3.8-27b"):
|
|
240
|
+
if fallback_model != model:
|
|
241
|
+
try:
|
|
242
|
+
resp = self.client.chat.completions.create(
|
|
243
|
+
model=fallback_model,
|
|
244
|
+
messages=messages,
|
|
245
|
+
temperature=temperature,
|
|
246
|
+
max_tokens=max_tokens,
|
|
247
|
+
)
|
|
248
|
+
return resp.choices[0].message.content or ""
|
|
249
|
+
except Exception:
|
|
250
|
+
continue
|
|
251
|
+
raise GroqClientError(
|
|
252
|
+
f"Groq model '{model}' not found on account and fallbacks failed."
|
|
253
|
+
)
|
|
254
|
+
except groq.APIConnectionError as e:
|
|
255
|
+
raise GroqConnectionError(f"Network error connecting to Groq API: {e}") from e
|
|
256
|
+
except groq.APIStatusError as e:
|
|
257
|
+
raise GroqClientError(f"Groq API error (status {e.status_code}): {e}") from e
|
|
258
|
+
except Exception as e:
|
|
259
|
+
raise GroqClientError(f"Unexpected Groq error: {e}") from e
|
|
260
|
+
|
|
261
|
+
def complete(
|
|
262
|
+
self,
|
|
263
|
+
prompt: str,
|
|
264
|
+
system_prompt: str,
|
|
265
|
+
model: str = DEFAULT_MODEL,
|
|
266
|
+
temperature: float = 0.2,
|
|
267
|
+
max_tokens: int = 1024,
|
|
268
|
+
) -> str:
|
|
269
|
+
"""Execute a single-turn completion with a dedicated system prompt.
|
|
270
|
+
|
|
271
|
+
Convenience wrapper around :meth:`chat` that constructs a two-message
|
|
272
|
+
conversation (system + user) and returns the model's reply as a plain
|
|
273
|
+
string. Use :meth:`chat` directly when you need to pass a richer
|
|
274
|
+
conversation history.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
prompt (str): The user-facing instruction or question to send to
|
|
278
|
+
the model.
|
|
279
|
+
system_prompt (str): Instruction placed in the ``"system"`` role
|
|
280
|
+
that shapes the model's behaviour for this request.
|
|
281
|
+
model (str): Groq model identifier. Defaults to
|
|
282
|
+
``"openai/gpt-oss-120b"``.
|
|
283
|
+
temperature (float): Sampling temperature. Defaults to ``0.2``.
|
|
284
|
+
max_tokens (int): Maximum tokens to generate. Defaults to
|
|
285
|
+
``1024``.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
str: The model's text response.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
GroqAuthError: Propagated from :meth:`chat`.
|
|
292
|
+
GroqRateLimitError: Propagated from :meth:`chat`.
|
|
293
|
+
GroqConnectionError: Propagated from :meth:`chat`.
|
|
294
|
+
GroqClientError: Propagated from :meth:`chat`.
|
|
295
|
+
"""
|
|
296
|
+
# Assemble the standard two-message format expected by the Chat API
|
|
297
|
+
messages = [
|
|
298
|
+
{"role": "system", "content": system_prompt},
|
|
299
|
+
{"role": "user", "content": prompt},
|
|
300
|
+
]
|
|
301
|
+
return self.chat(messages, model=model, temperature=temperature, max_tokens=max_tokens)
|
|
302
|
+
|
|
303
|
+
def stream_complete(
|
|
304
|
+
self,
|
|
305
|
+
prompt: str,
|
|
306
|
+
system_prompt: str,
|
|
307
|
+
model: str = DEFAULT_MODEL,
|
|
308
|
+
temperature: float = 0.2,
|
|
309
|
+
max_tokens: int = 1024,
|
|
310
|
+
) -> Generator[str, None, None]:
|
|
311
|
+
"""Stream a completion response as an iterable of text chunks.
|
|
312
|
+
|
|
313
|
+
Opens a server-sent-events stream to the Groq API and yields each
|
|
314
|
+
content delta as it arrives, enabling the caller to display tokens
|
|
315
|
+
progressively rather than waiting for the full response. The
|
|
316
|
+
generator silently skips chunks whose ``delta.content`` is ``None``
|
|
317
|
+
or empty (e.g. the final ``[DONE]`` sentinel).
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
prompt (str): The user-facing instruction or question.
|
|
321
|
+
system_prompt (str): Instruction placed in the ``"system"`` role.
|
|
322
|
+
model (str): Groq model identifier. Defaults to
|
|
323
|
+
``"openai/gpt-oss-120b"``.
|
|
324
|
+
temperature (float): Sampling temperature. Defaults to ``0.2``.
|
|
325
|
+
max_tokens (int): Maximum tokens to generate. Defaults to
|
|
326
|
+
``1024``.
|
|
327
|
+
|
|
328
|
+
Yields:
|
|
329
|
+
str: Successive text deltas from the model as they are received.
|
|
330
|
+
Each yielded string is a partial token or word fragment;
|
|
331
|
+
concatenating all yielded values produces the full response.
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
GroqAuthError: If the API key is rejected when the stream opens.
|
|
335
|
+
GroqRateLimitError: If the rate limit is exceeded.
|
|
336
|
+
GroqConnectionError: If the stream cannot be established due to a
|
|
337
|
+
network error.
|
|
338
|
+
GroqClientError: For any other unexpected SDK exception.
|
|
339
|
+
"""
|
|
340
|
+
try:
|
|
341
|
+
stream = self.client.chat.completions.create(
|
|
342
|
+
model=model,
|
|
343
|
+
messages=[
|
|
344
|
+
{"role": "system", "content": system_prompt},
|
|
345
|
+
{"role": "user", "content": prompt},
|
|
346
|
+
],
|
|
347
|
+
temperature=temperature,
|
|
348
|
+
max_tokens=max_tokens,
|
|
349
|
+
stream=True, # Enable server-sent-events streaming mode
|
|
350
|
+
)
|
|
351
|
+
for chunk in stream:
|
|
352
|
+
delta = chunk.choices[0].delta.content
|
|
353
|
+
# Skip empty deltas (e.g. role-only chunks and the final sentinel)
|
|
354
|
+
if delta:
|
|
355
|
+
yield delta
|
|
356
|
+
except groq.AuthenticationError as e:
|
|
357
|
+
raise GroqAuthError(f"Groq authentication failed: {e}") from e
|
|
358
|
+
except groq.RateLimitError as e:
|
|
359
|
+
raise GroqRateLimitError(f"Groq rate limit exceeded: {e}") from e
|
|
360
|
+
except groq.APIConnectionError as e:
|
|
361
|
+
raise GroqConnectionError(f"Network error connecting to Groq API: {e}") from e
|
|
362
|
+
except Exception as e:
|
|
363
|
+
raise GroqClientError(f"Unexpected Groq streaming error: {e}") from e
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# Public API surface
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
__all__ = [
|
|
371
|
+
"GroqClientError",
|
|
372
|
+
"GroqAuthError",
|
|
373
|
+
"GroqRateLimitError",
|
|
374
|
+
"GroqConnectionError",
|
|
375
|
+
"GroqClient",
|
|
376
|
+
]
|