yaicli 0.3.3__py3-none-any.whl → 0.5.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.
yaicli/api.py DELETED
@@ -1,316 +0,0 @@
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_MODEL,
12
- EventTypeEnum,
13
- )
14
-
15
-
16
- def parse_stream_line(line: Union[bytes, str], console: Console, verbose: bool) -> Optional[dict]:
17
- """(Helper Function) Parse a single line from the SSE stream response."""
18
- if not isinstance(line, (bytes, str)):
19
- if verbose:
20
- console.print(f"Warning: Received non-string/bytes line: {line!r}", style="yellow")
21
- return None
22
- line_str: str = line.decode("utf-8") if isinstance(line, bytes) else line
23
- line_str = line_str.strip()
24
- if not line_str or not line_str.startswith("data: "):
25
- return None
26
-
27
- data_part = line_str[6:]
28
- if data_part.lower() == "[done]":
29
- return {"done": True} # Use a specific dictionary to signal DONE
30
-
31
- try:
32
- json_data = json.loads(data_part)
33
- if not isinstance(json_data, dict) or "choices" not in json_data:
34
- if verbose:
35
- console.print(f"Warning: Invalid stream data format (missing 'choices'): {data_part}", style="yellow")
36
- return None
37
- return json_data
38
- except json.JSONDecodeError:
39
- console.print("Error decoding response JSON", style="red")
40
- if verbose:
41
- console.print(f"Invalid JSON data: {data_part}", style="red")
42
- return None
43
-
44
-
45
- class ApiClient:
46
- """Handles communication with the LLM API."""
47
-
48
- def __init__(self, config: Dict[str, Any], console: Console, verbose: bool, client: Optional[httpx.Client] = None):
49
- """Initialize the API client with configuration."""
50
- self.config = config
51
- self.console = console
52
- self.verbose = verbose
53
- self.base_url = str(config.get("BASE_URL", DEFAULT_BASE_URL))
54
- self.completion_path = str(config.get("COMPLETION_PATH", DEFAULT_COMPLETION_PATH))
55
- self.api_key = str(config.get("API_KEY", ""))
56
- self.model = str(config.get("MODEL", DEFAULT_MODEL))
57
- self.timeout = self.config["TIMEOUT"]
58
- self.client = client or httpx.Client(timeout=self.config["TIMEOUT"])
59
-
60
- def _prepare_request_body(self, messages: List[Dict[str, str]], stream: bool) -> Dict[str, Any]:
61
- """Prepare the common request body for API calls."""
62
- return {
63
- "messages": messages,
64
- "model": self.model,
65
- "stream": stream,
66
- "temperature": self.config["TEMPERATURE"],
67
- "top_p": self.config["TOP_P"],
68
- "max_tokens": self.config[
69
- "MAX_TOKENS"
70
- ], # Openai: This value is now deprecated in favor of max_completion_tokens
71
- "max_completion_tokens": self.config["MAX_TOKENS"],
72
- }
73
-
74
- def _handle_api_error(self, e: httpx.HTTPError) -> None:
75
- """Handle and print HTTP errors consistently."""
76
- if isinstance(e, httpx.TimeoutException):
77
- self.console.print(f"Error: API request timed out after {self.timeout} seconds. {e}", style="red")
78
- elif isinstance(e, httpx.HTTPStatusError):
79
- self.console.print(f"Error calling API: {e.response.status_code} {e.response.reason_phrase}", style="red")
80
- if self.verbose:
81
- self.console.print(f"Response Text: {e.response.text}")
82
- elif isinstance(e, httpx.RequestError):
83
- api_url = self.get_completion_url()
84
- self.console.print(f"Error: Could not connect to API endpoint '{api_url}'. {e}", style="red")
85
- else:
86
- self.console.print(f"An unexpected HTTP error occurred: {e}", style="red")
87
-
88
- def get_completion_url(self) -> str:
89
- """Get the full completion URL."""
90
- base_url = self.base_url.rstrip("/")
91
- completion_path = self.completion_path.lstrip("/")
92
- return f"{base_url}/{completion_path}"
93
-
94
- def get_headers(self) -> Dict[str, str]:
95
- """Get the request headers."""
96
- return {
97
- "Authorization": f"Bearer {self.api_key}",
98
- "Content-Type": "application/json",
99
- "X-Title": "Yaicli",
100
- }
101
-
102
- def _process_completion_response(self, response_json: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
103
- """Process the JSON response from a non-streamed completion request."""
104
- answer_path = self.config["ANSWER_PATH"]
105
- message_path = answer_path.rsplit(".", 1)[0]
106
-
107
- # Extract content and reasoning using JMESPath
108
- content = jmespath.search(answer_path, response_json)
109
- message = jmespath.search(message_path, response_json)
110
- reasoning = self._get_reasoning_content(
111
- message
112
- ) # Reuse reasoning extraction if applicable to the whole message
113
-
114
- # Process string content and extract reasoning from <think> tags if present
115
- if isinstance(content, str):
116
- content = content.lstrip()
117
- if content.startswith("<think>"):
118
- think_end = content.find("</think>")
119
- if think_end != -1:
120
- # Extract reasoning from <think> tag only if not already found via message path
121
- if reasoning is None:
122
- reasoning = content[7:think_end].strip() # Start after <think>
123
- # Remove the <think> block from the main content
124
- content = content[think_end + 8 :].strip() # Start after </think>
125
- # If it doesn't start with <think>, or if </think> wasn't found, return content as is
126
- return content, reasoning
127
- elif content:
128
- self.console.print(
129
- f"Warning: Unexpected content type from API: {type(content)}. Path: {answer_path}", style="yellow"
130
- )
131
- # Attempt to convert unexpected content to string, return existing reasoning
132
- return str(content), reasoning
133
- else:
134
- self.console.print(f"Warning: Could not extract content using JMESPath '{answer_path}'.", style="yellow")
135
- if self.verbose:
136
- self.console.print(f"API Response: {response_json}")
137
- return None, reasoning
138
-
139
- def completion(self, messages: List[Dict[str, str]]) -> Tuple[Optional[str], Optional[str]]:
140
- """Get a complete non-streamed response from the API."""
141
- url = self.get_completion_url()
142
- body = self._prepare_request_body(messages, stream=False)
143
- headers = self.get_headers()
144
-
145
- try:
146
- response = self.client.post(url, json=body, headers=headers)
147
- response.raise_for_status()
148
- response_json = response.json()
149
- # Delegate processing to the helper method
150
- return self._process_completion_response(response_json)
151
-
152
- except httpx.HTTPError as e:
153
- self._handle_api_error(e)
154
- return None, None
155
-
156
- def _handle_http_error(self, e: httpx.HTTPStatusError) -> Dict[str, Any]:
157
- """Handle HTTP errors during streaming and return an error event.
158
-
159
- Args:
160
- e: The HTTP status error that occurred
161
-
162
- Returns:
163
- An error event dictionary to be yielded to the client
164
- """
165
- error_body = e.response.read()
166
- self._handle_api_error(e)
167
-
168
- try:
169
- error_json = json.loads(error_body)
170
- error_message = error_json.get("error", {}).get("message")
171
- except (json.JSONDecodeError, AttributeError):
172
- error_message = None
173
-
174
- if not error_message:
175
- error_message = error_body.decode() if error_body else str(e)
176
-
177
- return {"type": EventTypeEnum.ERROR, "message": error_message}
178
-
179
- def _process_stream_chunk(
180
- self, parsed_data: Dict[str, Any], in_reasoning: bool
181
- ) -> Iterator[Tuple[Dict[str, Any], bool]]:
182
- """Process a single chunk from the stream and yield events with updated reasoning state.
183
-
184
- Args:
185
- parsed_data: The parsed JSON data from a streamline
186
- in_reasoning: Whether we're currently in a reasoning state
187
-
188
- Yields:
189
- A tuple containing:
190
- - An event dictionary to yield to the client
191
- - The updated reasoning state
192
- """
193
- # Handle stream errors
194
- if "error" in parsed_data:
195
- error_msg = parsed_data["error"].get("message", "Unknown error in stream data")
196
- self.console.print(f"Error in stream data: {error_msg}", style="red")
197
- yield {"type": EventTypeEnum.ERROR, "message": error_msg}, in_reasoning
198
- return
199
-
200
- # Get and validate the choice
201
- choices = parsed_data.get("choices", [])
202
- if not choices or not isinstance(choices, list):
203
- if self.verbose:
204
- self.console.print(f"Skipping stream chunk with no choices: {parsed_data}", style="dim")
205
- return
206
-
207
- choice = choices[0]
208
- if not isinstance(choice, dict):
209
- if self.verbose:
210
- self.console.print(f"Skipping stream chunk with invalid choice structure: {choice}", style="dim")
211
- return
212
-
213
- # Get content from delta
214
- delta = choice.get("delta", {})
215
- if not isinstance(delta, dict):
216
- if self.verbose:
217
- self.console.print(f"Skipping stream chunk with invalid delta structure: {delta}", style="dim")
218
- return
219
-
220
- # Process content
221
- reason = self._get_reasoning_content(delta)
222
- content_chunk = delta.get("content", "")
223
- finish_reason = choice.get("finish_reason")
224
-
225
- # Yield events based on content type
226
- if reason is not None:
227
- in_reasoning = True
228
- yield {"type": EventTypeEnum.REASONING, "chunk": reason}, in_reasoning
229
- elif in_reasoning and content_chunk and isinstance(content_chunk, str):
230
- # Signal the end of reasoning before yielding content
231
- in_reasoning = False
232
- yield {"type": EventTypeEnum.REASONING_END, "chunk": ""}, in_reasoning
233
- yield {"type": EventTypeEnum.CONTENT, "chunk": content_chunk}, in_reasoning
234
- elif content_chunk and isinstance(content_chunk, str):
235
- yield {"type": EventTypeEnum.CONTENT, "chunk": content_chunk}, in_reasoning
236
-
237
- if finish_reason:
238
- yield {"type": EventTypeEnum.FINISH, "reason": finish_reason}, in_reasoning
239
-
240
- def stream_completion(self, messages: List[Dict[str, str]]) -> Iterator[Dict[str, Any]]:
241
- """Connect to the API and yield parsed stream events.
242
-
243
- This method handles the streaming API connection and processes the response,
244
- yielding events that can be consumed by the client. It handles various types
245
- of content including regular content and reasoning content.
246
-
247
- Args:
248
- messages: The list of message dictionaries to send to the API
249
-
250
- Yields:
251
- Event dictionaries with the following structure:
252
- - type: The event type (from EventTypeEnum)
253
- - chunk/message/reason: The content of the event
254
- """
255
- url = self.get_completion_url()
256
- body = self._prepare_request_body(messages, stream=True)
257
- headers = self.get_headers()
258
- in_reasoning = False
259
-
260
- try:
261
- with self.client.stream("POST", url, json=body, headers=headers) as response:
262
- try:
263
- response.raise_for_status()
264
- except httpx.HTTPStatusError as e:
265
- yield self._handle_http_error(e)
266
- return
267
-
268
- # Process the streamline by line
269
- for line in response.iter_lines():
270
- parsed_data = parse_stream_line(line, self.console, self.verbose)
271
- if parsed_data is None:
272
- continue
273
- if parsed_data.get("done"):
274
- break
275
-
276
- # Process chunks and yield events
277
- for event, updated_state in self._process_stream_chunk(parsed_data, in_reasoning):
278
- in_reasoning = updated_state
279
- # event: {type: str, Optional[chunk]: str, Optional[message]: str, Optional[reason]: str}
280
- yield event
281
-
282
- except httpx.HTTPError as e:
283
- self._handle_api_error(e)
284
- yield {"type": EventTypeEnum.ERROR, "message": str(e)}
285
- except Exception as e:
286
- self.console.print(f"An unexpected error occurred during streaming: {e}", style="red")
287
- if self.verbose:
288
- import traceback
289
-
290
- traceback.print_exc()
291
- yield {"type": EventTypeEnum.ERROR, "message": f"Unexpected stream error: {e}"}
292
-
293
- def _get_reasoning_content(self, delta: dict) -> Optional[str]:
294
- """Extract reasoning content from delta if available based on specific keys.
295
-
296
- This method checks for various keys that might contain reasoning content
297
- in different API implementations.
298
-
299
- Args:
300
- delta: The delta dictionary from the API response
301
-
302
- Returns:
303
- The reasoning content string if found, None otherwise
304
- """
305
- if not delta:
306
- return None
307
- # reasoning_content: deepseek/infi-ai
308
- # reasoning: openrouter
309
- # <think> block implementation not in here
310
- for key in ("reasoning_content", "reasoning"):
311
- # Check if the key exists and its value is a non-empty string
312
- value = delta.get(key)
313
- if isinstance(value, str) and value:
314
- return value
315
-
316
- return None # Return None if no relevant key with a string value is found
yaicli/chat_manager.py DELETED
@@ -1,290 +0,0 @@
1
- import json
2
- import time
3
- from abc import ABC, abstractmethod
4
- from datetime import datetime
5
- from pathlib import Path
6
- from typing import Any, Dict, List, Optional, TypedDict, Union
7
-
8
- from rich.console import Console
9
-
10
- from yaicli.config import Config, cfg
11
- from yaicli.console import get_console
12
- from yaicli.utils import option_callback
13
-
14
-
15
- class ChatFileInfo(TypedDict):
16
- """Chat info, parse chat filename and store metadata"""
17
-
18
- index: int
19
- path: str
20
- title: str
21
- date: str
22
- timestamp: int
23
-
24
-
25
- class ChatsMap(TypedDict):
26
- """Chat info cache for chat manager"""
27
-
28
- title: Dict[str, ChatFileInfo]
29
- index: Dict[int, ChatFileInfo]
30
-
31
-
32
- class ChatManager(ABC):
33
- """Abstract base class that defines the chat manager interface"""
34
-
35
- @abstractmethod
36
- def make_chat_title(self, prompt: Optional[str] = None) -> str:
37
- """Make a chat title from a given full prompt"""
38
- pass
39
-
40
- @abstractmethod
41
- def save_chat(self, history: List[Dict[str, Any]], title: Optional[str] = None) -> str:
42
- """Save a chat and return the chat title"""
43
- pass
44
-
45
- @abstractmethod
46
- def list_chats(self) -> List[ChatFileInfo]:
47
- """List all saved chats and return the chat list"""
48
- pass
49
-
50
- @abstractmethod
51
- def refresh_chats(self) -> None:
52
- """Force refresh the chat list"""
53
- pass
54
-
55
- @abstractmethod
56
- def load_chat_by_index(self, index: int) -> Union[ChatFileInfo, Dict]:
57
- """Load a chat by index and return the chat data"""
58
- pass
59
-
60
- @abstractmethod
61
- def load_chat_by_title(self, title: str) -> Union[ChatFileInfo, Dict]:
62
- """Load a chat by title and return the chat data"""
63
- pass
64
-
65
- @abstractmethod
66
- def delete_chat(self, index: int) -> bool:
67
- """Delete a chat by index and return success status"""
68
- pass
69
-
70
- @abstractmethod
71
- def validate_chat_index(self, index: int) -> bool:
72
- """Validate a chat index and return success status"""
73
- pass
74
-
75
-
76
- class FileChatManager(ChatManager):
77
- """File system based chat manager implementation"""
78
-
79
- console: Console = get_console()
80
- config: Config = cfg
81
- chat_dir = Path(config["CHAT_HISTORY_DIR"])
82
- max_saved_chats = config["MAX_SAVED_CHATS"]
83
- chat_dir.mkdir(parents=True, exist_ok=True)
84
-
85
- def __init__(self):
86
- self._chats_map: Optional[ChatsMap] = None # Cache for chat map
87
-
88
- @property
89
- def chats_map(self) -> ChatsMap:
90
- """Get the map of chats, loading from disk only when needed"""
91
- if self._chats_map is None:
92
- self._load_chats()
93
- return self._chats_map or {"index": {}, "title": {}}
94
-
95
- @classmethod
96
- @option_callback
97
- def print_list_option(cls, _: Any):
98
- """Print the list of chats"""
99
- cls.console.print("Finding Chats...")
100
- c = -1
101
- for c, file in enumerate(sorted(cls.chat_dir.glob("*.json"), key=lambda f: f.stat().st_mtime)):
102
- info: ChatFileInfo = cls._parse_filename(file, c + 1)
103
- cls.console.print(f"{c + 1}. {info['title']} ({info['date']})")
104
- if c == -1:
105
- cls.console.print("No chats found", style="dim")
106
-
107
- def make_chat_title(self, prompt: Optional[str] = None) -> str:
108
- """Make a chat title from a given full prompt"""
109
- if prompt:
110
- return prompt[:100]
111
- else:
112
- return f"Chat-{int(time.time())}"
113
-
114
- def validate_chat_index(self, index: int) -> bool:
115
- """Validate a chat index and return success status"""
116
- return index > 0 and index in self.chats_map["index"]
117
-
118
- def refresh_chats(self) -> None:
119
- """Force refresh the chat list from disk"""
120
- self._load_chats()
121
-
122
- @staticmethod
123
- def _parse_filename(chat_file: Path, index: int) -> ChatFileInfo:
124
- """Parse a chat filename and extract metadata"""
125
- # filename: "20250421-214005-title-meaning of life"
126
- filename = chat_file.stem
127
- parts = filename.split("-")
128
- title_str_len = 6 # "title-" marker length
129
-
130
- # Check if the filename has the expected format
131
- if len(parts) >= 4 and "title" in parts:
132
- str_title_index = filename.find("title")
133
- if str_title_index == -1:
134
- # If "title" is not found, use full filename as the title
135
- # Just in case, fallback to use fullname, but this should never happen when `len(parts) >= 4 and "title" in parts`
136
- str_title_index = 0
137
- title_str_len = 0
138
-
139
- # "20250421-214005-title-meaning of life" ==> "meaning of life"
140
- title = filename[str_title_index + title_str_len :]
141
- date_ = parts[0]
142
- time_ = parts[1]
143
- # Format date
144
- date_str = f"{date_[:4]}-{date_[4:6]}-{date_[6:]} {time_[:2]}:{time_[2:4]}"
145
-
146
- # Calculate timestamp from date parts
147
- try:
148
- date_time_str = f"{date_}{time_}"
149
- timestamp = int(datetime.strptime(date_time_str, "%Y%m%d%H%M%S").timestamp())
150
- except ValueError:
151
- timestamp = 0
152
- else:
153
- # Fallback for files that don't match expected format
154
- title = filename
155
- date_str = ""
156
- timestamp = 0
157
-
158
- # The actual title is stored in the JSON file, so we'll use that when loading
159
- # This is just for the initial listing before the file is opened
160
- return {
161
- "index": index,
162
- "path": str(chat_file),
163
- "title": title,
164
- "date": date_str,
165
- "timestamp": timestamp,
166
- }
167
-
168
- def _load_chats(self) -> None:
169
- """Load chats from disk into memory"""
170
- chat_files = sorted(list(self.chat_dir.glob("*.json")), reverse=True)
171
- chats_map: ChatsMap = {"title": {}, "index": {}}
172
-
173
- for i, chat_file in enumerate(chat_files[: self.max_saved_chats]):
174
- try:
175
- info = self._parse_filename(chat_file, i + 1)
176
- chats_map["title"][info["title"]] = info
177
- chats_map["index"][i + 1] = info
178
- except Exception as e:
179
- # Log the error but continue processing other files
180
- self.console.print(f"Error parsing session file {chat_file}: {e}", style="dim")
181
- continue
182
-
183
- self._chats_map = chats_map
184
-
185
- def list_chats(self) -> List[ChatFileInfo]:
186
- """List all saved chats and return the chat list"""
187
- return list(self.chats_map["index"].values())
188
-
189
- def save_chat(self, history: List[Dict[str, Any]], title: Optional[str] = None) -> str:
190
- """Save chat history to the file system, overwriting existing chats with the same title.
191
-
192
- If no title is provided, the chat will be saved with a default title.
193
- The default title is "Chat-{current timestamp}".
194
-
195
- Args:
196
- history (List[Dict[str, Any]]): The chat history to save
197
- title (Optional[str]): The title of the chat provided by the user
198
-
199
- Returns:
200
- str: The title of the saved chat
201
- """
202
- history = history or []
203
-
204
- save_title = title or f"Chat-{int(time.time())}"
205
- save_title = self.make_chat_title(save_title)
206
-
207
- # Check for existing session with the same title and delete it
208
- existing_chat = self.chats_map["title"].get(save_title)
209
- if existing_chat:
210
- try:
211
- existing_path = Path(existing_chat["path"])
212
- existing_path.unlink()
213
- except OSError as e:
214
- self.console.print(
215
- f"Warning: Could not delete existing chat file {existing_chat['path']}: {e}",
216
- style="dim",
217
- )
218
-
219
- timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
220
- filename = f"{timestamp}-title-{save_title}.json"
221
- filepath = self.chat_dir / filename
222
-
223
- try:
224
- with open(filepath, "w", encoding="utf-8") as f:
225
- json.dump({"history": history, "title": save_title}, f, ensure_ascii=False, indent=2)
226
- # Force refresh the chat list after saving
227
- self.refresh_chats()
228
- return save_title
229
- except Exception as e:
230
- self.console.print(f"Error saving chat '{save_title}': {e}", style="dim")
231
- return ""
232
-
233
- def _load_chat_data(self, chat_info: Optional[ChatFileInfo]) -> Union[ChatFileInfo, Dict]:
234
- """Common method to load chat data from a chat info dict"""
235
- if not chat_info:
236
- return {}
237
-
238
- try:
239
- chat_file = Path(chat_info["path"])
240
- with open(chat_file, "r", encoding="utf-8") as f:
241
- chat_data = json.load(f)
242
-
243
- return {
244
- "title": chat_data.get("title", chat_info["title"]),
245
- "timestamp": chat_info["timestamp"],
246
- "history": chat_data.get("history", []),
247
- }
248
- except FileNotFoundError:
249
- self.console.print(f"Chat file not found: {chat_info['path']}", style="dim")
250
- return {}
251
- except json.JSONDecodeError as e:
252
- self.console.print(f"Invalid JSON in chat file {chat_info['path']}: {e}", style="dim")
253
- return {}
254
- except Exception as e:
255
- self.console.print(f"Error loading chat from {chat_info['path']}: {e}", style="dim")
256
- return {}
257
-
258
- def load_chat_by_index(self, index: int) -> Union[ChatFileInfo, Dict]:
259
- """Load a chat by index and return the chat data"""
260
- if not self.validate_chat_index(index):
261
- return {}
262
- chat_info = self.chats_map.get("index", {}).get(index)
263
- return self._load_chat_data(chat_info)
264
-
265
- def load_chat_by_title(self, title: str) -> Union[ChatFileInfo, Dict]:
266
- """Load a chat by title and return the chat data"""
267
- chat_info = self.chats_map.get("title", {}).get(title)
268
- return self._load_chat_data(chat_info)
269
-
270
- def delete_chat(self, index: int) -> bool:
271
- """Delete a chat by index and return success status"""
272
- if not self.validate_chat_index(index):
273
- return False
274
-
275
- chat_info = self.chats_map["index"].get(index)
276
- if not chat_info:
277
- return False
278
-
279
- try:
280
- chat_file = Path(chat_info["path"])
281
- chat_file.unlink()
282
- # Force refresh the chat list
283
- self.refresh_chats()
284
- return True
285
- except FileNotFoundError:
286
- self.console.print(f"Chat file not found: {chat_info['path']}", style="dim")
287
- return False
288
- except Exception as e:
289
- self.console.print(f"Error deleting chat {index}: {e}", style="dim")
290
- return False