yaicli 0.0.19__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 CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yaicli"
3
- version = "0.0.19"
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 = ["bump2version>=1.0.1", "pytest>=8.3.5", "ruff>=0.11.2"]
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.py", "pyproject.toml"]
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