yaicli 0.0.18__py3-none-any.whl → 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.
- pyproject.toml +10 -4
- yaicli/__init__.py +0 -0
- yaicli/api.py +324 -0
- yaicli/cli.py +332 -0
- yaicli/config.py +183 -0
- yaicli/const.py +119 -0
- yaicli/entry.py +95 -0
- yaicli/history.py +72 -0
- yaicli/printer.py +244 -0
- yaicli/utils.py +112 -0
- {yaicli-0.0.18.dist-info → yaicli-0.1.0.dist-info}/METADATA +280 -233
- yaicli-0.1.0.dist-info/RECORD +15 -0
- yaicli-0.1.0.dist-info/entry_points.txt +3 -0
- yaicli-0.0.18.dist-info/RECORD +0 -7
- yaicli-0.0.18.dist-info/entry_points.txt +0 -2
- yaicli.py +0 -640
- {yaicli-0.0.18.dist-info → yaicli-0.1.0.dist-info}/WHEEL +0 -0
- {yaicli-0.0.18.dist-info → yaicli-0.1.0.dist-info}/licenses/LICENSE +0 -0
pyproject.toml
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "yaicli"
|
3
|
-
version = "0.0
|
3
|
+
version = "0.1.0"
|
4
4
|
description = "A simple CLI tool to interact with LLM"
|
5
5
|
authors = [{ name = "belingud", email = "im.victor@qq.com" }]
|
6
6
|
readme = "README.md"
|
@@ -44,13 +44,19 @@ Repository = "https://github.com/belingud/yaicli"
|
|
44
44
|
Documentation = "https://github.com/belingud/yaicli"
|
45
45
|
|
46
46
|
[project.scripts]
|
47
|
-
ai = "yaicli:app"
|
47
|
+
ai = "yaicli.entry:app"
|
48
|
+
yai = "yaicli.entry:app"
|
48
49
|
|
49
50
|
[tool.uv]
|
50
51
|
resolution = "highest"
|
51
52
|
|
52
53
|
[dependency-groups]
|
53
|
-
dev = [
|
54
|
+
dev = [
|
55
|
+
"bump2version>=1.0.1",
|
56
|
+
"pytest>=8.3.5",
|
57
|
+
"pytest-cov>=6.1.1",
|
58
|
+
"ruff>=0.11.2",
|
59
|
+
]
|
54
60
|
|
55
61
|
[tool.isort]
|
56
62
|
profile = "black"
|
@@ -65,4 +71,4 @@ build-backend = "hatchling.build"
|
|
65
71
|
|
66
72
|
[tool.hatch.build]
|
67
73
|
exclude = ["test_*.py", "tests/*", ".gitignore"]
|
68
|
-
include = ["yaicli
|
74
|
+
include = ["yaicli", "pyproject.toml"]
|
yaicli/__init__.py
ADDED
File without changes
|
yaicli/api.py
ADDED
@@ -0,0 +1,324 @@
|
|
1
|
+
import json
|
2
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
|
3
|
+
|
4
|
+
import httpx
|
5
|
+
import jmespath
|
6
|
+
from rich.console import Console
|
7
|
+
|
8
|
+
from yaicli.const import (
|
9
|
+
DEFAULT_BASE_URL,
|
10
|
+
DEFAULT_COMPLETION_PATH,
|
11
|
+
DEFAULT_MAX_TOKENS,
|
12
|
+
DEFAULT_MODEL,
|
13
|
+
DEFAULT_TEMPERATURE,
|
14
|
+
DEFAULT_TIMEOUT,
|
15
|
+
DEFAULT_TOP_P,
|
16
|
+
EventTypeEnum,
|
17
|
+
)
|
18
|
+
|
19
|
+
|
20
|
+
def parse_stream_line(line: Union[bytes, str], console: Console, verbose: bool) -> Optional[dict]:
|
21
|
+
"""(Helper Function) Parse a single line from the SSE stream response."""
|
22
|
+
line_str: str
|
23
|
+
if isinstance(line, bytes):
|
24
|
+
try:
|
25
|
+
line_str = line.decode("utf-8")
|
26
|
+
except UnicodeDecodeError:
|
27
|
+
if verbose:
|
28
|
+
console.print(f"Warning: Could not decode stream line bytes: {line!r}", style="yellow")
|
29
|
+
return None
|
30
|
+
elif isinstance(line, str):
|
31
|
+
line_str = line
|
32
|
+
else:
|
33
|
+
# Handle unexpected line types
|
34
|
+
if verbose:
|
35
|
+
console.print(f"Warning: Received unexpected line type: {type(line)}", style="yellow")
|
36
|
+
return None
|
37
|
+
|
38
|
+
line_str = line_str.strip()
|
39
|
+
if not line_str or not line_str.startswith("data: "):
|
40
|
+
return None
|
41
|
+
|
42
|
+
data_part = line_str[6:]
|
43
|
+
if data_part.lower() == "[done]":
|
44
|
+
return {"done": True} # Use a specific dictionary to signal DONE
|
45
|
+
|
46
|
+
try:
|
47
|
+
json_data = json.loads(data_part)
|
48
|
+
if not isinstance(json_data, dict) or "choices" not in json_data:
|
49
|
+
if verbose:
|
50
|
+
console.print(f"Warning: Invalid stream data format (missing 'choices'): {data_part}", style="yellow")
|
51
|
+
return None
|
52
|
+
return json_data
|
53
|
+
except json.JSONDecodeError:
|
54
|
+
console.print("Error decoding response JSON", style="red")
|
55
|
+
if verbose:
|
56
|
+
console.print(f"Invalid JSON data: {data_part}", style="red")
|
57
|
+
return None
|
58
|
+
|
59
|
+
|
60
|
+
class ApiClient:
|
61
|
+
"""Handles communication with the LLM API."""
|
62
|
+
|
63
|
+
def __init__(self, config: Dict[str, Any], console: Console, verbose: bool, client: Optional[httpx.Client] = None):
|
64
|
+
"""Initialize the API client with configuration."""
|
65
|
+
self.config = config
|
66
|
+
self.console = console
|
67
|
+
self.verbose = verbose
|
68
|
+
self.base_url = str(config.get("BASE_URL", DEFAULT_BASE_URL))
|
69
|
+
self.completion_path = str(config.get("COMPLETION_PATH", DEFAULT_COMPLETION_PATH))
|
70
|
+
self.api_key = str(config.get("API_KEY", ""))
|
71
|
+
self.model = str(config.get("MODEL", DEFAULT_MODEL))
|
72
|
+
self.timeout = self.config.get("TIMEOUT", DEFAULT_TIMEOUT)
|
73
|
+
self.client = client or httpx.Client(timeout=self.config.get("TIMEOUT", DEFAULT_TIMEOUT))
|
74
|
+
|
75
|
+
def _prepare_request_body(self, messages: List[Dict[str, str]], stream: bool) -> Dict[str, Any]:
|
76
|
+
"""Prepare the common request body for API calls."""
|
77
|
+
return {
|
78
|
+
"messages": messages,
|
79
|
+
"model": self.model,
|
80
|
+
"stream": stream,
|
81
|
+
"temperature": self.config.get("TEMPERATURE", DEFAULT_TEMPERATURE),
|
82
|
+
"top_p": self.config.get("TOP_P", DEFAULT_TOP_P),
|
83
|
+
"max_tokens": self.config.get("MAX_TOKENS", DEFAULT_MAX_TOKENS),
|
84
|
+
}
|
85
|
+
|
86
|
+
def _handle_api_error(self, e: httpx.HTTPError) -> None:
|
87
|
+
"""Handle and print HTTP errors consistently."""
|
88
|
+
if isinstance(e, httpx.TimeoutException):
|
89
|
+
self.console.print(f"Error: API request timed out after {self.timeout} seconds. {e}", style="red")
|
90
|
+
elif isinstance(e, httpx.HTTPStatusError):
|
91
|
+
self.console.print(f"Error calling API: {e.response.status_code} {e.response.reason_phrase}", style="red")
|
92
|
+
if self.verbose:
|
93
|
+
self.console.print(f"Response Text: {e.response.text}")
|
94
|
+
elif isinstance(e, httpx.RequestError):
|
95
|
+
api_url = self.get_completion_url()
|
96
|
+
self.console.print(f"Error: Could not connect to API endpoint '{api_url}'. {e}", style="red")
|
97
|
+
else:
|
98
|
+
self.console.print(f"An unexpected HTTP error occurred: {e}", style="red")
|
99
|
+
|
100
|
+
def get_completion_url(self) -> str:
|
101
|
+
"""Get the full completion URL."""
|
102
|
+
base_url = self.base_url.rstrip("/")
|
103
|
+
completion_path = self.completion_path.lstrip("/")
|
104
|
+
return f"{base_url}/{completion_path}"
|
105
|
+
|
106
|
+
def get_headers(self) -> Dict[str, str]:
|
107
|
+
"""Get the request headers."""
|
108
|
+
return {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
109
|
+
|
110
|
+
def _process_completion_response(self, response_json: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
|
111
|
+
"""Process the JSON response from a non-streamed completion request."""
|
112
|
+
answer_path = self.config["ANSWER_PATH"]
|
113
|
+
message_path = answer_path.rsplit(".", 1)[0]
|
114
|
+
|
115
|
+
# Extract content and reasoning using JMESPath
|
116
|
+
content = jmespath.search(answer_path, response_json)
|
117
|
+
message = jmespath.search(message_path, response_json)
|
118
|
+
reasoning = self._get_reasoning_content(
|
119
|
+
message
|
120
|
+
) # Reuse reasoning extraction if applicable to the whole message
|
121
|
+
|
122
|
+
# Process string content and extract reasoning from <think> tags if present
|
123
|
+
if isinstance(content, str):
|
124
|
+
content = content.lstrip()
|
125
|
+
if content.startswith("<think>"):
|
126
|
+
think_end = content.find("</think>")
|
127
|
+
if think_end != -1:
|
128
|
+
# Extract reasoning from <think> tag only if not already found via message path
|
129
|
+
if reasoning is None:
|
130
|
+
reasoning = content[7:think_end].strip() # Start after <think>
|
131
|
+
# Remove the <think> block from the main content
|
132
|
+
content = content[think_end + 8 :].strip() # Start after </think>
|
133
|
+
# If it doesn't start with <think>, or if </think> wasn't found, return content as is
|
134
|
+
return content, reasoning
|
135
|
+
elif content:
|
136
|
+
self.console.print(
|
137
|
+
f"Warning: Unexpected content type from API: {type(content)}. Path: {answer_path}", style="yellow"
|
138
|
+
)
|
139
|
+
# Attempt to convert unexpected content to string, return existing reasoning
|
140
|
+
return str(content), reasoning
|
141
|
+
else:
|
142
|
+
self.console.print(f"Warning: Could not extract content using JMESPath '{answer_path}'.", style="yellow")
|
143
|
+
if self.verbose:
|
144
|
+
self.console.print(f"API Response: {response_json}")
|
145
|
+
return None, reasoning
|
146
|
+
|
147
|
+
def completion(self, messages: List[Dict[str, str]]) -> Tuple[Optional[str], Optional[str]]:
|
148
|
+
"""Get a complete non-streamed response from the API."""
|
149
|
+
url = self.get_completion_url()
|
150
|
+
body = self._prepare_request_body(messages, stream=False)
|
151
|
+
headers = self.get_headers()
|
152
|
+
|
153
|
+
try:
|
154
|
+
response = self.client.post(url, json=body, headers=headers)
|
155
|
+
response.raise_for_status()
|
156
|
+
response_json = response.json()
|
157
|
+
# Delegate processing to the helper method
|
158
|
+
return self._process_completion_response(response_json)
|
159
|
+
|
160
|
+
except httpx.HTTPError as e:
|
161
|
+
self._handle_api_error(e)
|
162
|
+
return None, None
|
163
|
+
|
164
|
+
def _handle_http_error(self, e: httpx.HTTPStatusError) -> Dict[str, Any]:
|
165
|
+
"""Handle HTTP errors during streaming and return an error event.
|
166
|
+
|
167
|
+
Args:
|
168
|
+
e: The HTTP status error that occurred
|
169
|
+
|
170
|
+
Returns:
|
171
|
+
An error event dictionary to be yielded to the client
|
172
|
+
"""
|
173
|
+
error_body = e.response.read()
|
174
|
+
self._handle_api_error(e)
|
175
|
+
|
176
|
+
try:
|
177
|
+
error_json = json.loads(error_body)
|
178
|
+
error_message = error_json.get("error", {}).get("message")
|
179
|
+
except (json.JSONDecodeError, AttributeError):
|
180
|
+
error_message = None
|
181
|
+
|
182
|
+
if not error_message:
|
183
|
+
error_message = error_body.decode() if error_body else str(e)
|
184
|
+
|
185
|
+
return {"type": EventTypeEnum.ERROR, "message": error_message}
|
186
|
+
|
187
|
+
def _process_stream_chunk(
|
188
|
+
self, parsed_data: Dict[str, Any], in_reasoning: bool
|
189
|
+
) -> Iterator[Tuple[Dict[str, Any], bool]]:
|
190
|
+
"""Process a single chunk from the stream and yield events with updated reasoning state.
|
191
|
+
|
192
|
+
Args:
|
193
|
+
parsed_data: The parsed JSON data from a stream line
|
194
|
+
in_reasoning: Whether we're currently in a reasoning state
|
195
|
+
|
196
|
+
Yields:
|
197
|
+
A tuple containing:
|
198
|
+
- An event dictionary to yield to the client
|
199
|
+
- The updated reasoning state
|
200
|
+
"""
|
201
|
+
# Handle stream errors
|
202
|
+
if "error" in parsed_data:
|
203
|
+
error_msg = parsed_data["error"].get("message", "Unknown error in stream data")
|
204
|
+
self.console.print(f"Error in stream data: {error_msg}", style="red")
|
205
|
+
yield {"type": EventTypeEnum.ERROR, "message": error_msg}, in_reasoning
|
206
|
+
return
|
207
|
+
|
208
|
+
# Get and validate the choice
|
209
|
+
choices = parsed_data.get("choices", [])
|
210
|
+
if not choices or not isinstance(choices, list):
|
211
|
+
if self.verbose:
|
212
|
+
self.console.print(f"Skipping stream chunk with no choices: {parsed_data}", style="dim")
|
213
|
+
return
|
214
|
+
|
215
|
+
choice = choices[0]
|
216
|
+
if not isinstance(choice, dict):
|
217
|
+
if self.verbose:
|
218
|
+
self.console.print(f"Skipping stream chunk with invalid choice structure: {choice}", style="dim")
|
219
|
+
return
|
220
|
+
|
221
|
+
# Get content from delta
|
222
|
+
delta = choice.get("delta", {})
|
223
|
+
if not isinstance(delta, dict):
|
224
|
+
if self.verbose:
|
225
|
+
self.console.print(f"Skipping stream chunk with invalid delta structure: {delta}", style="dim")
|
226
|
+
return
|
227
|
+
|
228
|
+
# Process content
|
229
|
+
reason = self._get_reasoning_content(delta)
|
230
|
+
content_chunk = delta.get("content", "")
|
231
|
+
finish_reason = choice.get("finish_reason")
|
232
|
+
|
233
|
+
# Yield events based on content type
|
234
|
+
if reason is not None:
|
235
|
+
in_reasoning = True
|
236
|
+
yield {"type": EventTypeEnum.REASONING, "chunk": reason}, in_reasoning
|
237
|
+
elif in_reasoning and content_chunk and isinstance(content_chunk, str):
|
238
|
+
# Signal the end of reasoning before yielding content
|
239
|
+
in_reasoning = False
|
240
|
+
yield {"type": EventTypeEnum.REASONING_END, "chunk": ""}, in_reasoning
|
241
|
+
yield {"type": EventTypeEnum.CONTENT, "chunk": content_chunk}, in_reasoning
|
242
|
+
elif content_chunk and isinstance(content_chunk, str):
|
243
|
+
yield {"type": EventTypeEnum.CONTENT, "chunk": content_chunk}, in_reasoning
|
244
|
+
|
245
|
+
if finish_reason:
|
246
|
+
yield {"type": EventTypeEnum.FINISH, "reason": finish_reason}, in_reasoning
|
247
|
+
|
248
|
+
def stream_completion(self, messages: List[Dict[str, str]]) -> Iterator[Dict[str, Any]]:
|
249
|
+
"""Connect to the API and yield parsed stream events.
|
250
|
+
|
251
|
+
This method handles the streaming API connection and processes the response,
|
252
|
+
yielding events that can be consumed by the client. It handles various types
|
253
|
+
of content including regular content and reasoning content.
|
254
|
+
|
255
|
+
Args:
|
256
|
+
messages: The list of message dictionaries to send to the API
|
257
|
+
|
258
|
+
Yields:
|
259
|
+
Event dictionaries with the following structure:
|
260
|
+
- type: The event type (from EventTypeEnum)
|
261
|
+
- chunk/message/reason: The content of the event
|
262
|
+
"""
|
263
|
+
url = self.get_completion_url()
|
264
|
+
body = self._prepare_request_body(messages, stream=True)
|
265
|
+
headers = self.get_headers()
|
266
|
+
in_reasoning = False
|
267
|
+
|
268
|
+
try:
|
269
|
+
with self.client.stream("POST", url, json=body, headers=headers) as response:
|
270
|
+
try:
|
271
|
+
response.raise_for_status()
|
272
|
+
except httpx.HTTPStatusError as e:
|
273
|
+
yield self._handle_http_error(e)
|
274
|
+
return
|
275
|
+
|
276
|
+
# Process the stream line by line
|
277
|
+
for line in response.iter_lines():
|
278
|
+
parsed_data = parse_stream_line(line, self.console, self.verbose)
|
279
|
+
if parsed_data is None:
|
280
|
+
continue
|
281
|
+
if parsed_data.get("done"):
|
282
|
+
break
|
283
|
+
|
284
|
+
# Process chunks and yield events
|
285
|
+
for event, updated_state in self._process_stream_chunk(parsed_data, in_reasoning):
|
286
|
+
in_reasoning = updated_state
|
287
|
+
# event: {type: str, Optional[chunk]: str, Optional[message]: str, Optional[reason]: str}
|
288
|
+
yield event
|
289
|
+
|
290
|
+
except httpx.HTTPError as e:
|
291
|
+
self._handle_api_error(e)
|
292
|
+
yield {"type": EventTypeEnum.ERROR, "message": str(e)}
|
293
|
+
except Exception as e:
|
294
|
+
self.console.print(f"An unexpected error occurred during streaming: {e}", style="red")
|
295
|
+
if self.verbose:
|
296
|
+
import traceback
|
297
|
+
|
298
|
+
traceback.print_exc()
|
299
|
+
yield {"type": EventTypeEnum.ERROR, "message": f"Unexpected stream error: {e}"}
|
300
|
+
|
301
|
+
def _get_reasoning_content(self, delta: dict) -> Optional[str]:
|
302
|
+
"""Extract reasoning content from delta if available based on specific keys.
|
303
|
+
|
304
|
+
This method checks for various keys that might contain reasoning content
|
305
|
+
in different API implementations.
|
306
|
+
|
307
|
+
Args:
|
308
|
+
delta: The delta dictionary from the API response
|
309
|
+
|
310
|
+
Returns:
|
311
|
+
The reasoning content string if found, None otherwise
|
312
|
+
"""
|
313
|
+
if not delta:
|
314
|
+
return None
|
315
|
+
# reasoning_content: deepseek/infi-ai
|
316
|
+
# reasoning: openrouter
|
317
|
+
# <think> block implementation not in here
|
318
|
+
for key in ("reasoning_content", "reasoning", "metadata"):
|
319
|
+
# Check if the key exists and its value is a non-empty string
|
320
|
+
value = delta.get(key)
|
321
|
+
if isinstance(value, str) and value:
|
322
|
+
return value
|
323
|
+
|
324
|
+
return None # Return None if no relevant key with a string value is found
|