yaicli 0.3.2__py3-none-any.whl → 0.4.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.3.2"
3
+ version = "0.4.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"
@@ -30,9 +30,10 @@ keywords = [
30
30
  "interact with llms",
31
31
  ]
32
32
  dependencies = [
33
+ "cohere>=5.15.0",
33
34
  "distro>=1.9.0",
34
35
  "httpx>=0.28.1",
35
- "jmespath>=1.0.1",
36
+ "openai>=1.76.0",
36
37
  "prompt-toolkit>=3.0.50",
37
38
  "rich>=13.9.4",
38
39
  "socksio>=1.0.0",
yaicli/cli.py CHANGED
@@ -16,7 +16,6 @@ from rich.padding import Padding
16
16
  from rich.panel import Panel
17
17
  from rich.prompt import Prompt
18
18
 
19
- from yaicli.api import ApiClient
20
19
  from yaicli.chat_manager import ChatFileInfo, ChatManager, FileChatManager
21
20
  from yaicli.config import CONFIG_PATH, Config, cfg
22
21
  from yaicli.console import get_console
@@ -40,6 +39,7 @@ from yaicli.const import (
40
39
  )
41
40
  from yaicli.history import LimitedFileHistory
42
41
  from yaicli.printer import Printer
42
+ from yaicli.providers import BaseClient, create_api_client
43
43
  from yaicli.roles import RoleManager
44
44
  from yaicli.utils import detect_os, detect_shell, filter_command
45
45
 
@@ -51,7 +51,7 @@ class CLI:
51
51
  self,
52
52
  verbose: bool = False,
53
53
  stdin: Optional[str] = None,
54
- api_client: Optional[ApiClient] = None,
54
+ api_client: Optional[BaseClient] = None,
55
55
  printer: Optional[Printer] = None,
56
56
  chat_manager: Optional[ChatManager] = None,
57
57
  role: Optional[str] = None,
@@ -64,9 +64,10 @@ class CLI:
64
64
  self.config: Config = cfg
65
65
  self.current_mode: str = TEMP_MODE
66
66
  self.role: str = role or DefaultRoleNames.DEFAULT.value
67
+ self.init_role: str = self.role # --role can specify a role when enter interactive chat
67
68
 
68
69
  # Initialize role manager
69
- self.role_manager = RoleManager()
70
+ self.role_manager = RoleManager() # Singleton
70
71
 
71
72
  # Validate role
72
73
  if not self.role_manager.role_exists(self.role):
@@ -102,7 +103,7 @@ class CLI:
102
103
  self.console.print(f"Current role: {self.role}")
103
104
  self.console.print(Markdown("---", code_theme=self.config.get("CODE_THEME", DEFAULT_CODE_THEME)))
104
105
 
105
- self.api_client = api_client or ApiClient(self.config, self.console, self.verbose)
106
+ self.api_client = api_client or create_api_client(self.config, self.console, self.verbose)
106
107
  self.printer = printer or Printer(self.config, self.console, self.verbose, markdown=True)
107
108
 
108
109
  _origin_stderr = None
@@ -344,12 +345,11 @@ class CLI:
344
345
  def get_system_prompt(self) -> str:
345
346
  """Get the system prompt based on current role and mode"""
346
347
  # Use the role manager to get the system prompt
347
- self.console.print(f"Using role: {self.role}")
348
348
  return self.role_manager.get_system_prompt(self.role)
349
349
 
350
350
  def _build_messages(self, user_input: str) -> List[dict]:
351
351
  """Build message list for LLM API"""
352
- # Create the message list
352
+ # Create the message list with system prompt
353
353
  messages = [{"role": "system", "content": self.get_system_prompt()}]
354
354
 
355
355
  # Add previous conversation if available
@@ -420,7 +420,7 @@ class CLI:
420
420
  ████ ███████ ██ ██ ██ ██
421
421
  ██ ██ ██ ██ ██ ██ ██
422
422
  ██ ██ ██ ██ ██████ ███████ ██
423
- """,
423
+ """,
424
424
  style="bold cyan",
425
425
  )
426
426
  self.console.print("Welcome to YAICLI!", style="bold")
@@ -500,7 +500,7 @@ class CLI:
500
500
  @self.bindings.add(Keys.ControlI) # TAB
501
501
  def _(event: KeyPressEvent) -> None:
502
502
  self.current_mode = EXEC_MODE if self.current_mode == CHAT_MODE else CHAT_MODE
503
- self.role = DefaultRoleNames.SHELL if self.current_mode == EXEC_MODE else DefaultRoleNames.DEFAULT
503
+ self.role = DefaultRoleNames.SHELL if self.current_mode == EXEC_MODE else self.init_role
504
504
 
505
505
  def _run_once(self, input: str, shell: bool) -> None:
506
506
  """Run a single command (non-interactive)."""
yaicli/config.py CHANGED
@@ -13,6 +13,7 @@ from yaicli.const import (
13
13
  DEFAULT_CONFIG_MAP,
14
14
  DEFAULT_JUSTIFY,
15
15
  DEFAULT_MAX_SAVED_CHATS,
16
+ DEFAULT_ROLE_MODIFY_WARNING,
16
17
  )
17
18
  from yaicli.utils import str2bool
18
19
 
@@ -78,6 +79,10 @@ class Config(dict):
78
79
  f.write(f"\nMAX_SAVED_CHATS={DEFAULT_MAX_SAVED_CHATS}\n")
79
80
  if "JUSTIFY" not in config_content.strip():
80
81
  f.write(f"\nJUSTIFY={DEFAULT_JUSTIFY}\n")
82
+ if "ROLE_MODIFY_WARNING" not in config_content.strip():
83
+ f.write(
84
+ f"\n# Set to false to disable warnings about modified built-in roles\nROLE_MODIFY_WARNING={DEFAULT_ROLE_MODIFY_WARNING}\n"
85
+ )
81
86
 
82
87
  def _load_from_file(self) -> None:
83
88
  """Load configuration from the config file.
@@ -160,7 +165,7 @@ class Config(dict):
160
165
  self[key] = converted_value
161
166
 
162
167
 
163
- @lru_cache(maxsize=1)
168
+ @lru_cache(1)
164
169
  def get_config() -> Config:
165
170
  """Get the configuration singleton"""
166
171
  return Config()
yaicli/const.py CHANGED
@@ -1,10 +1,12 @@
1
1
  from enum import StrEnum
2
2
  from pathlib import Path
3
3
  from tempfile import gettempdir
4
- from typing import Any
4
+ from typing import Any, Literal
5
5
 
6
6
  from rich.console import JustifyMethod
7
7
 
8
+ BOOL_STR = Literal["true", "false", "yes", "no", "y", "n", "1", "0", "on", "off"]
9
+
8
10
 
9
11
  class JustifyEnum(StrEnum):
10
12
  DEFAULT = "default"
@@ -33,25 +35,24 @@ ROLES_DIR = CONFIG_PATH.parent / "roles"
33
35
 
34
36
  # Default configuration values
35
37
  DEFAULT_CODE_THEME = "monokai"
36
- DEFAULT_COMPLETION_PATH = "chat/completions"
37
- DEFAULT_ANSWER_PATH = "choices[0].message.content"
38
38
  DEFAULT_PROVIDER = "openai"
39
39
  DEFAULT_BASE_URL = "https://api.openai.com/v1"
40
40
  DEFAULT_MODEL = "gpt-4o"
41
41
  DEFAULT_SHELL_NAME = "auto"
42
42
  DEFAULT_OS_NAME = "auto"
43
- DEFAULT_STREAM = "true"
43
+ DEFAULT_STREAM: BOOL_STR = "true"
44
44
  DEFAULT_TEMPERATURE: float = 0.7
45
45
  DEFAULT_TOP_P: float = 1.0
46
46
  DEFAULT_MAX_TOKENS: int = 1024
47
47
  DEFAULT_MAX_HISTORY: int = 500
48
- DEFAULT_AUTO_SUGGEST = "true"
49
- DEFAULT_SHOW_REASONING = "true"
48
+ DEFAULT_AUTO_SUGGEST: BOOL_STR = "true"
49
+ DEFAULT_SHOW_REASONING: BOOL_STR = "true"
50
50
  DEFAULT_TIMEOUT: int = 60
51
51
  DEFAULT_INTERACTIVE_ROUND: int = 25
52
- DEFAULT_CHAT_HISTORY_DIR = Path(gettempdir()) / "yaicli/chats"
52
+ DEFAULT_CHAT_HISTORY_DIR: Path = Path(gettempdir()) / "yaicli/chats"
53
53
  DEFAULT_MAX_SAVED_CHATS = 20
54
54
  DEFAULT_JUSTIFY: JustifyMethod = "default"
55
+ DEFAULT_ROLE_MODIFY_WARNING: BOOL_STR = "true"
55
56
 
56
57
 
57
58
  class EventTypeEnum(StrEnum):
@@ -64,12 +65,11 @@ class EventTypeEnum(StrEnum):
64
65
  FINISH = "finish"
65
66
 
66
67
 
67
- SHELL_PROMPT = """Your are a Shell Command Generator named YAICLI.
68
- Generate a command EXCLUSIVELY for {_os} OS with {_shell} shell.
69
- If details are missing, offer the most logical solution.
70
- Ensure the output is a valid shell command.
71
- Combine multiple steps with `&&` when possible.
72
- Supply plain text only, avoiding Markdown formatting."""
68
+ SHELL_PROMPT = """You are YAICLI, a shell command generator.
69
+ The context conversation may contain other types of messages,
70
+ but you should only respond with a single valid {_shell} shell command for {_os}.
71
+ Do not include any explanations, comments, or formatting — only the command as plain text, avoiding Markdown formatting.
72
+ """
73
73
 
74
74
  DEFAULT_PROMPT = """
75
75
  You are YAICLI, a system management and programing assistant,
@@ -113,9 +113,6 @@ DEFAULT_CONFIG_MAP = {
113
113
  # System detection hints
114
114
  "SHELL_NAME": {"value": DEFAULT_SHELL_NAME, "env_key": "YAI_SHELL_NAME", "type": str},
115
115
  "OS_NAME": {"value": DEFAULT_OS_NAME, "env_key": "YAI_OS_NAME", "type": str},
116
- # API paths (usually no need to change for OpenAI compatible APIs)
117
- "COMPLETION_PATH": {"value": DEFAULT_COMPLETION_PATH, "env_key": "YAI_COMPLETION_PATH", "type": str},
118
- "ANSWER_PATH": {"value": DEFAULT_ANSWER_PATH, "env_key": "YAI_ANSWER_PATH", "type": str},
119
116
  # API call parameters
120
117
  "STREAM": {"value": DEFAULT_STREAM, "env_key": "YAI_STREAM", "type": bool},
121
118
  "TEMPERATURE": {"value": DEFAULT_TEMPERATURE, "env_key": "YAI_TEMPERATURE", "type": float},
@@ -136,6 +133,8 @@ DEFAULT_CONFIG_MAP = {
136
133
  # Chat history settings
137
134
  "CHAT_HISTORY_DIR": {"value": DEFAULT_CHAT_HISTORY_DIR, "env_key": "YAI_CHAT_HISTORY_DIR", "type": str},
138
135
  "MAX_SAVED_CHATS": {"value": DEFAULT_MAX_SAVED_CHATS, "env_key": "YAI_MAX_SAVED_CHATS", "type": int},
136
+ # Role settings
137
+ "ROLE_MODIFY_WARNING": {"value": DEFAULT_ROLE_MODIFY_WARNING, "env_key": "YAI_ROLE_MODIFY_WARNING", "type": bool},
139
138
  }
140
139
 
141
140
  DEFAULT_CONFIG_INI = f"""[core]
@@ -148,10 +147,6 @@ MODEL={DEFAULT_CONFIG_MAP["MODEL"]["value"]}
148
147
  SHELL_NAME={DEFAULT_CONFIG_MAP["SHELL_NAME"]["value"]}
149
148
  OS_NAME={DEFAULT_CONFIG_MAP["OS_NAME"]["value"]}
150
149
 
151
- # API paths (usually no need to change for OpenAI compatible APIs)
152
- COMPLETION_PATH={DEFAULT_CONFIG_MAP["COMPLETION_PATH"]["value"]}
153
- ANSWER_PATH={DEFAULT_CONFIG_MAP["ANSWER_PATH"]["value"]}
154
-
155
150
  # true: streaming response, false: non-streaming
156
151
  STREAM={DEFAULT_CONFIG_MAP["STREAM"]["value"]}
157
152
 
@@ -177,4 +172,8 @@ JUSTIFY={DEFAULT_CONFIG_MAP["JUSTIFY"]["value"]}
177
172
  # Chat history settings
178
173
  CHAT_HISTORY_DIR={DEFAULT_CONFIG_MAP["CHAT_HISTORY_DIR"]["value"]}
179
174
  MAX_SAVED_CHATS={DEFAULT_CONFIG_MAP["MAX_SAVED_CHATS"]["value"]}
175
+
176
+ # Role settings
177
+ # Set to false to disable warnings about modified built-in roles
178
+ ROLE_MODIFY_WARNING={DEFAULT_CONFIG_MAP["ROLE_MODIFY_WARNING"]["value"]}
180
179
  """
@@ -0,0 +1,34 @@
1
+ from yaicli.const import DEFAULT_PROVIDER
2
+ from yaicli.providers.base import BaseClient
3
+ from yaicli.providers.cohere import CohereClient
4
+ from yaicli.providers.openai import OpenAIClient
5
+
6
+
7
+ def create_api_client(config, console, verbose):
8
+ """Factory function to create the appropriate API client based on provider.
9
+
10
+ Args:
11
+ config: The configuration dictionary
12
+ console: The rich console for output
13
+ verbose: Whether to enable verbose output
14
+
15
+ Returns:
16
+ An instance of the appropriate ApiClient implementation
17
+ """
18
+ provider = config.get("PROVIDER", DEFAULT_PROVIDER).lower()
19
+
20
+ if provider == "openai":
21
+ return OpenAIClient(config, console, verbose)
22
+ elif provider == "cohere":
23
+ return CohereClient(config, console, verbose)
24
+ # elif provider == "google":
25
+ # return GoogleApiClient(config, console, verbose)
26
+ # elif provider == "claude":
27
+ # return ClaudeApiClient(config, console, verbose)
28
+ else:
29
+ # Fallback to openai client
30
+ console.print(f"Using generic HTTP client for provider: {provider}", style="yellow")
31
+ return OpenAIClient(config, console, verbose)
32
+
33
+
34
+ __all__ = ["BaseClient", "OpenAIClient", "CohereClient", "create_api_client"]
@@ -0,0 +1,51 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Dict, Iterator, List, Optional, Tuple
3
+
4
+ from rich.console import Console
5
+
6
+
7
+ class BaseClient(ABC):
8
+ """Base abstract class for LLM API clients."""
9
+
10
+ def __init__(self, config: Dict[str, Any], console: Console, verbose: bool):
11
+ """Initialize the API client with configuration."""
12
+ self.config = config
13
+ self.console = console
14
+ self.verbose = verbose
15
+ self.timeout = self.config["TIMEOUT"]
16
+
17
+ @abstractmethod
18
+ def completion(self, messages: List[Dict[str, str]]) -> Tuple[Optional[str], Optional[str]]:
19
+ """Get a complete non-streamed response from the API."""
20
+ pass
21
+
22
+ @abstractmethod
23
+ def stream_completion(self, messages: List[Dict[str, str]]) -> Iterator[Dict[str, Any]]:
24
+ """Connect to the API and yield parsed stream events."""
25
+ pass
26
+
27
+ def _get_reasoning_content(self, delta: dict) -> Optional[str]:
28
+ """Extract reasoning content from delta if available based on specific keys.
29
+
30
+ This method checks for various keys that might contain reasoning content
31
+ in different API implementations.
32
+
33
+ Args:
34
+ delta: The delta dictionary from the API response
35
+
36
+ Returns:
37
+ The reasoning content string if found, None otherwise
38
+ """
39
+ if not delta:
40
+ return None
41
+ # Reasoning content keys from API:
42
+ # reasoning_content: deepseek/infi-ai
43
+ # reasoning: openrouter
44
+ # <think> block implementation not in here
45
+ for key in ("reasoning_content", "reasoning"):
46
+ # Check if the key exists and its value is a non-empty string
47
+ value = delta.get(key)
48
+ if isinstance(value, str) and value:
49
+ return value
50
+
51
+ return None # Return None if no relevant key with a string value is found
@@ -0,0 +1,136 @@
1
+ from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple, TypeVar
2
+
3
+ from cohere import ChatResponse, ClientV2, StreamedChatResponseV2
4
+ from cohere.core.api_error import ApiError
5
+
6
+ from yaicli.const import EventTypeEnum
7
+ from yaicli.providers.base import BaseClient
8
+
9
+ ChunkType = Literal[
10
+ "message-start",
11
+ "content-start",
12
+ "content-delta",
13
+ "content-end",
14
+ "tool-plan-delta",
15
+ "tool-call-start",
16
+ "tool-call-delta",
17
+ "tool-call-end",
18
+ "citation-start",
19
+ "citation-end",
20
+ "message-end",
21
+ "debug",
22
+ ]
23
+
24
+ # Type variable for chunks that have delta attribute
25
+ T = TypeVar("T", bound=StreamedChatResponseV2)
26
+
27
+
28
+ class CohereClient(BaseClient):
29
+ """Cohere API client implementation using the official Cohere Python library."""
30
+
31
+ def __init__(self, config: Dict[str, Any], console, verbose: bool):
32
+ """Initialize the Cohere API client with configuration."""
33
+ super().__init__(config, console, verbose)
34
+ self.api_key = config["API_KEY"]
35
+ self.model = config["MODEL"]
36
+ if not config["BASE_URL"] or "cohere" not in config["BASE_URL"]:
37
+ # BASE_URL can be empty, in which case we use the default base_url
38
+ self.base_url = "https://api.cohere.com"
39
+ else:
40
+ self.base_url = config["BASE_URL"]
41
+ self.base_url = self.base_url.rstrip("/")
42
+ if self.base_url.endswith("v2") or self.base_url.endswith("v1"):
43
+ self.base_url = self.base_url[:-2]
44
+
45
+ # Initialize the Cohere client with our custom configuration
46
+ self.client = ClientV2(
47
+ api_key=self.api_key,
48
+ base_url=self.base_url,
49
+ client_name="Yaicli",
50
+ timeout=self.timeout,
51
+ )
52
+
53
+ def _prepare_request_params(self, messages: List[Dict[str, str]]) -> Dict[str, Any]:
54
+ """Prepare the common request parameters for Cohere API calls."""
55
+ # P value must be between 0.01 and 0.99, default to 0.75 if outside this range, also cohere api default is 0.75
56
+ p = 0.75 if not (0.01 < self.config["TOP_P"] < 0.99) else self.config["TOP_P"]
57
+ return {
58
+ "messages": messages,
59
+ "model": self.model,
60
+ "temperature": self.config["TEMPERATURE"],
61
+ "max_tokens": self.config["MAX_TOKENS"],
62
+ "p": p,
63
+ }
64
+
65
+ def _process_completion_response(self, response: ChatResponse) -> Tuple[Optional[str], Optional[str]]:
66
+ """Process the response from a non-streamed Cohere completion request."""
67
+ try:
68
+ content = response.message.content
69
+ if not content:
70
+ return None, None
71
+ text = content[0].text
72
+ if not text:
73
+ return None, None
74
+ return text, None
75
+
76
+ except Exception as e:
77
+ self.console.print(f"Error processing Cohere response: {e}", style="red")
78
+ if self.verbose:
79
+ self.console.print(f"Response: {response}")
80
+ return None, None
81
+
82
+ def completion(self, messages: List[Dict[str, str]]) -> Tuple[Optional[str], Optional[str]]:
83
+ """Get a complete non-streamed response from the Cohere API."""
84
+ params = self._prepare_request_params(messages)
85
+
86
+ try:
87
+ response: ChatResponse = self.client.chat(**params)
88
+ return self._process_completion_response(response)
89
+ except ApiError as e:
90
+ self.console.print(f"Cohere API error: {e}", style="red")
91
+ if self.verbose:
92
+ self.console.print(f"Response: {e.body}")
93
+ return None, None
94
+
95
+ def stream_completion(self, messages: List[Dict[str, str]]) -> Iterator[Dict[str, Any]]:
96
+ """Connect to the Cohere API and yield parsed stream events."""
97
+ params = self._prepare_request_params(messages)
98
+
99
+ try:
100
+ for chunk in self.client.v2.chat_stream(**params):
101
+ # Skip message start/end events
102
+ if chunk.type in ("message-start", "message-end", "content-end"): # type: ignore
103
+ continue
104
+
105
+ # Safe attribute checking - skip if any required attribute is missing
106
+ if not hasattr(chunk, "delta"):
107
+ continue
108
+
109
+ # At this point we know chunk has delta attribute
110
+ delta = getattr(chunk, "delta")
111
+ if delta is None or not hasattr(delta, "message"):
112
+ continue
113
+
114
+ message = getattr(delta, "message")
115
+ if message is None or not hasattr(message, "content"):
116
+ continue
117
+
118
+ content = getattr(message, "content")
119
+ if content is None or not hasattr(content, "text"):
120
+ continue
121
+
122
+ # Access text safely
123
+ text = getattr(content, "text")
124
+ if text:
125
+ yield {"type": EventTypeEnum.CONTENT, "chunk": text}
126
+
127
+ except ApiError as e:
128
+ self.console.print(f"Cohere API error during streaming: {e}", style="red")
129
+ yield {"type": EventTypeEnum.ERROR, "message": str(e)}
130
+ except Exception as e:
131
+ self.console.print(f"Unexpected error during Cohere streaming: {e}", style="red")
132
+ if self.verbose:
133
+ import traceback
134
+
135
+ traceback.print_exc()
136
+ yield {"type": EventTypeEnum.ERROR, "message": f"Unexpected stream error: {e}"}
@@ -0,0 +1,176 @@
1
+ from typing import Any, Dict, Iterator, List, Optional, Tuple
2
+
3
+ import openai
4
+ from openai import OpenAI
5
+ from openai.types.chat.chat_completion import ChatCompletion
6
+ from openai.types.chat.chat_completion_chunk import Choice, ChoiceDelta
7
+
8
+ from yaicli.const import EventTypeEnum
9
+ from yaicli.providers.base import BaseClient
10
+
11
+
12
+ class OpenAIClient(BaseClient):
13
+ """OpenAI API client implementation using the official OpenAI Python library."""
14
+
15
+ def __init__(self, config: Dict[str, Any], console, verbose: bool):
16
+ """Initialize the OpenAI API client with configuration."""
17
+ super().__init__(config, console, verbose)
18
+ self.api_key = config["API_KEY"]
19
+ self.model = config["MODEL"]
20
+ self.base_url = config["BASE_URL"]
21
+
22
+ # Initialize the OpenAI client with our custom configuration
23
+ self.client = OpenAI(
24
+ api_key=self.api_key,
25
+ base_url=self.base_url,
26
+ timeout=self.timeout,
27
+ default_headers={"X-Title": "Yaicli"},
28
+ max_retries=2, # Add retry logic for resilience
29
+ )
30
+
31
+ def _prepare_request_params(self, messages: List[Dict[str, str]], stream: bool) -> Dict[str, Any]:
32
+ """Prepare the common request parameters for OpenAI API calls."""
33
+ return {
34
+ "messages": messages,
35
+ "model": self.model,
36
+ "stream": stream,
37
+ "temperature": self.config["TEMPERATURE"],
38
+ "top_p": self.config["TOP_P"],
39
+ # Openai: This value is now deprecated in favor of max_completion_tokens
40
+ "max_tokens": self.config["MAX_TOKENS"],
41
+ "max_completion_tokens": self.config["MAX_TOKENS"],
42
+ }
43
+
44
+ def _process_completion_response(self, conpletion: ChatCompletion) -> Tuple[Optional[str], Optional[str]]:
45
+ """Process the response from a non-streamed OpenAI completion request."""
46
+ try:
47
+ # OpenAI SDK returns structured objects
48
+ content = conpletion.choices[0].message.content
49
+ reasoning = None
50
+
51
+ # Check for reasoning in model_extra
52
+ if hasattr(conpletion.choices[0].message, "model_extra") and conpletion.choices[0].message.model_extra:
53
+ extra = conpletion.choices[0].message.model_extra
54
+ if extra and "reasoning" in extra:
55
+ reasoning = extra["reasoning"]
56
+
57
+ # If no reasoning in model_extra, try extracting from <think> tags
58
+ if reasoning is None and isinstance(content, str):
59
+ content = content.lstrip()
60
+ if content.startswith("<think>"):
61
+ think_end = content.find("</think>")
62
+ if think_end != -1:
63
+ reasoning = content[7:think_end].strip() # Start after <think>
64
+ # Remove the <think> block from the main content
65
+ content = content[think_end + 8 :].strip() # Start after </think>
66
+
67
+ return content, reasoning
68
+ except Exception as e:
69
+ self.console.print(f"Error processing OpenAI response: {e}", style="red")
70
+ if self.verbose:
71
+ self.console.print(f"Response: {conpletion}")
72
+ return None, None
73
+
74
+ def completion(self, messages: List[Dict[str, str]]) -> Tuple[Optional[str], Optional[str]]:
75
+ """Get a complete non-streamed response from the OpenAI API."""
76
+ params = self._prepare_request_params(messages, stream=False)
77
+
78
+ try:
79
+ # Use context manager for proper resource management
80
+ with self.client.with_options(timeout=self.timeout) as client:
81
+ response: ChatCompletion = client.chat.completions.create(**params)
82
+ return self._process_completion_response(response)
83
+ except openai.APIConnectionError as e:
84
+ self.console.print(f"OpenAI connection error: {e}", style="red")
85
+ if self.verbose:
86
+ self.console.print(f"Underlying error: {e.__cause__}")
87
+ return None, None
88
+ except openai.RateLimitError as e:
89
+ self.console.print(f"OpenAI rate limit error (429): {e}", style="red")
90
+ return None, None
91
+ except openai.APIStatusError as e:
92
+ self.console.print(f"OpenAI API error (status {e.status_code}): {e}", style="red")
93
+ if self.verbose:
94
+ self.console.print(f"Response: {e.response}")
95
+ return None, None
96
+ except Exception as e:
97
+ self.console.print(f"Unexpected error during OpenAI completion: {e}", style="red")
98
+ if self.verbose:
99
+ import traceback
100
+
101
+ traceback.print_exc()
102
+ return None, None
103
+
104
+ def stream_completion(self, messages: List[Dict[str, str]]) -> Iterator[Dict[str, Any]]:
105
+ """Connect to the OpenAI API and yield parsed stream events.
106
+
107
+ Args:
108
+ messages: The list of message dictionaries to send to the API
109
+
110
+ Yields:
111
+ Event dictionaries with the following structure:
112
+ - type: The event type (from EventTypeEnum)
113
+ - chunk/message/reason: The content of the event
114
+ """
115
+ params: Dict[str, Any] = self._prepare_request_params(messages, stream=True)
116
+ in_reasoning: bool = False
117
+
118
+ try:
119
+ # Use context manager to ensure proper cleanup
120
+ with self.client.chat.completions.create(**params) as stream:
121
+ for chunk in stream:
122
+ choices: List[Choice] = chunk.choices
123
+ if not choices:
124
+ # Some APIs may return empty choices upon reaching the end of content.
125
+ continue
126
+ choice: Choice = choices[0]
127
+ delta: ChoiceDelta = choice.delta
128
+ finish_reason: Optional[str] = choice.finish_reason
129
+
130
+ # Process model_extra for reasoning content
131
+ if hasattr(delta, "model_extra") and delta.model_extra:
132
+ reasoning: Optional[str] = self._get_reasoning_content(delta.model_extra)
133
+ if reasoning:
134
+ yield {"type": EventTypeEnum.REASONING, "chunk": reasoning}
135
+ in_reasoning = True
136
+ continue
137
+
138
+ # Process content delta
139
+ if hasattr(delta, "content") and delta.content:
140
+ content_chunk = delta.content
141
+ if in_reasoning and content_chunk:
142
+ # Send reasoning end signal before content
143
+ in_reasoning = False
144
+ yield {"type": EventTypeEnum.REASONING_END, "chunk": ""}
145
+ yield {"type": EventTypeEnum.CONTENT, "chunk": content_chunk}
146
+ elif content_chunk:
147
+ yield {"type": EventTypeEnum.CONTENT, "chunk": content_chunk}
148
+
149
+ # Process finish reason
150
+ if finish_reason:
151
+ # Send reasoning end signal if still in reasoning state
152
+ if in_reasoning:
153
+ in_reasoning = False
154
+ yield {"type": EventTypeEnum.REASONING_END, "chunk": ""}
155
+ yield {"type": EventTypeEnum.FINISH, "reason": finish_reason}
156
+
157
+ except openai.APIConnectionError as e:
158
+ self.console.print(f"OpenAI connection error during streaming: {e}", style="red")
159
+ if self.verbose:
160
+ self.console.print(f"Underlying error: {e.__cause__}")
161
+ yield {"type": EventTypeEnum.ERROR, "message": str(e)}
162
+ except openai.RateLimitError as e:
163
+ self.console.print(f"OpenAI rate limit error (429) during streaming: {e}", style="red")
164
+ yield {"type": EventTypeEnum.ERROR, "message": str(e)}
165
+ except openai.APIStatusError as e:
166
+ self.console.print(f"OpenAI API error (status {e.status_code}) during streaming: {e}", style="red")
167
+ if self.verbose:
168
+ self.console.print(f"Response: {e.response}")
169
+ yield {"type": EventTypeEnum.ERROR, "message": str(e)}
170
+ except Exception as e:
171
+ self.console.print(f"Unexpected error during OpenAI streaming: {e}", style="red")
172
+ if self.verbose:
173
+ import traceback
174
+
175
+ traceback.print_exc()
176
+ yield {"type": EventTypeEnum.ERROR, "message": f"Unexpected stream error: {e}"}
yaicli/roles.py CHANGED
@@ -19,19 +19,19 @@ class Role:
19
19
  self, name: str, prompt: str, variables: Optional[Dict[str, Any]] = None, filepath: Optional[str] = None
20
20
  ):
21
21
  self.name = name
22
- self.prompt = prompt
22
+ self._prompt = prompt
23
23
  if not variables:
24
24
  variables = {"_os": detect_os(cfg), "_shell": detect_shell(cfg)}
25
25
  self.variables = variables
26
26
  self.filepath = filepath
27
27
 
28
- self.prompt = self.prompt.format(**self.variables)
28
+ self.prompt = self._prompt.format(**self.variables)
29
29
 
30
30
  def to_dict(self) -> Dict[str, Any]:
31
31
  """Convert Role to dictionary for serialization"""
32
32
  return {
33
33
  "name": self.name,
34
- "prompt": self.prompt,
34
+ "prompt": self._prompt,
35
35
  }
36
36
 
37
37
  @classmethod
@@ -51,13 +51,23 @@ class Role:
51
51
  class RoleManager:
52
52
  roles_dir: Path = ROLES_DIR
53
53
  console: Console = get_console()
54
+ _roles: Optional[Dict[str, Role]] = None
54
55
 
55
- def __init__(self):
56
- self.roles: Dict[str, Role] = self._load_roles()
56
+ def __new__(cls):
57
+ """Singleton class for RoleManager"""
58
+ if not hasattr(cls, "instance"):
59
+ cls.instance = super().__new__(cls)
60
+ return cls.instance
61
+
62
+ @property
63
+ def roles(self) -> Dict[str, Role]:
64
+ if self._roles is None:
65
+ self._roles = self._load_roles()
66
+ return self._roles
57
67
 
58
68
  def _load_roles(self) -> Dict[str, Role]:
59
69
  """Load all role configurations"""
60
- roles = {}
70
+ roles: Dict[str, Role] = {}
61
71
  self.roles_dir.mkdir(parents=True, exist_ok=True)
62
72
 
63
73
  # Check if any role files exist
@@ -87,11 +97,34 @@ class RoleManager:
87
97
  # Ensure default roles exist
88
98
  for role_id, role_config in DEFAULT_ROLES.items():
89
99
  if role_id not in roles:
100
+ # Default role doesn't exist locally, create it
90
101
  role_file = self.roles_dir / f"{role_id}.json"
91
102
  filepath = str(role_file)
92
103
  roles[role_id] = Role.from_dict(role_id, role_config, filepath)
93
104
  with role_file.open("w", encoding="utf-8") as f:
94
105
  json.dump(role_config, f, indent=2)
106
+ else:
107
+ # TODO: Maybe not necessary
108
+ # Check if the local role's prompt differs from the built-in one
109
+ local_role = roles[role_id]
110
+ builtin_prompt = role_config.get("prompt", "")
111
+
112
+ # Only show warning if ROLE_MODIFY_WARNING is enabled
113
+ if cfg["ROLE_MODIFY_WARNING"]:
114
+ if local_role._prompt != builtin_prompt:
115
+ self.console.print(
116
+ f"[yellow]Warning:[/yellow] Local role '{role_id}' has a different prompt than the built-in role.",
117
+ style="yellow",
118
+ )
119
+ self.console.print(
120
+ "To reset to the built-in version, delete the local role file with: "
121
+ f'[bold]ai --delete-role "{role_id}"[/bold]',
122
+ style="dim",
123
+ )
124
+ self.console.print(
125
+ "To disable this warning, set [bold]ROLE_MODIFY_WARNING=false[/bold] in your config file.",
126
+ style="dim",
127
+ )
95
128
 
96
129
  return roles
97
130
 
@@ -142,12 +175,12 @@ class RoleManager:
142
175
  return self.roles.get(role_id)
143
176
 
144
177
  @classmethod
145
- def check_id_ok(cls, role_id: str):
178
+ def check_id_ok(cls, param: typer.CallbackParam, role_id: str):
146
179
  """Check if role exists by ID.
147
180
  This method is a cli option callback.
148
181
  If role does not exist, exit with error.
149
182
  """
150
- if not role_id:
183
+ if not role_id or role_id == param.default:
151
184
  return role_id
152
185
  self = cls()
153
186
  if not self.role_exists(role_id):
@@ -216,11 +249,6 @@ class RoleManager:
216
249
  self.console.print(f"Role '{role_id}' does not exist", style="red")
217
250
  return False
218
251
 
219
- # Don't allow deleting default roles
220
- if role_id in DEFAULT_ROLES:
221
- self.console.print(f"Cannot delete default role: '{role_id}'", style="red")
222
- return False
223
-
224
252
  try:
225
253
  role = self.roles[role_id]
226
254
  if role.filepath:
@@ -244,5 +272,5 @@ class RoleManager:
244
272
  role = Role.from_dict(DefaultRoleNames.DEFAULT, default_config)
245
273
 
246
274
  # Create a copy of the role with system variables
247
- system_role = Role(name=role.name, prompt=role.prompt)
275
+ system_role = Role(name=role.name, prompt=role._prompt)
248
276
  return system_role.prompt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yaicli
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: A simple CLI tool to interact with LLM
5
5
  Project-URL: Homepage, https://github.com/belingud/yaicli
6
6
  Project-URL: Repository, https://github.com/belingud/yaicli
@@ -213,9 +213,10 @@ Classifier: License :: OSI Approved :: MIT License
213
213
  Classifier: Operating System :: OS Independent
214
214
  Classifier: Programming Language :: Python :: 3
215
215
  Requires-Python: >=3.9
216
+ Requires-Dist: cohere>=5.15.0
216
217
  Requires-Dist: distro>=1.9.0
217
218
  Requires-Dist: httpx>=0.28.1
218
- Requires-Dist: jmespath>=1.0.1
219
+ Requires-Dist: openai>=1.76.0
219
220
  Requires-Dist: prompt-toolkit>=3.0.50
220
221
  Requires-Dist: rich>=13.9.4
221
222
  Requires-Dist: socksio>=1.0.0
@@ -235,9 +236,7 @@ generate and execute shell commands, or get quick answers without leaving your w
235
236
 
236
237
  **Supports both standard and deep reasoning models across all major LLM providers.**
237
238
 
238
- <p align="center">
239
- <img src="https://vhs.charm.sh/vhs-5U1BBjJkTUBReRswsSgIVx.gif" alt="YAICLI Chat Demo" width="85%">
240
- </p>
239
+ <a href="https://asciinema.org/a/vyreM0n576GjGL2asjI3QzUIY" target="_blank"><img src="https://asciinema.org/a/vyreM0n576GjGL2asjI3QzUIY.svg" width="85%"/></a>
241
240
 
242
241
  > [!NOTE]
243
242
  > YAICLI is actively developed. While core functionality is stable, some features may evolve in future releases.
@@ -329,10 +328,6 @@ MODEL = gpt-4o
329
328
  SHELL_NAME = auto
330
329
  OS_NAME = auto
331
330
 
332
- # API paths (usually no need to change for OpenAI compatible APIs)
333
- COMPLETION_PATH = chat/completions
334
- ANSWER_PATH = choices[0].message.content
335
-
336
331
  # true: streaming response, false: non-streaming
337
332
  STREAM = true
338
333
 
@@ -362,29 +357,28 @@ MAX_SAVED_CHATS = 20
362
357
 
363
358
  ### Configuration Options Reference
364
359
 
365
- | Option | Description | Default | Env Variable |
366
- |---------------------|---------------------------------------------|------------------------------|-------------------------|
367
- | `PROVIDER` | LLM provider (openai, claude, cohere, etc.) | `openai` | `YAI_PROVIDER` |
368
- | `BASE_URL` | API endpoint URL | `https://api.openai.com/v1` | `YAI_BASE_URL` |
369
- | `API_KEY` | Your API key | - | `YAI_API_KEY` |
370
- | `MODEL` | LLM model to use | `gpt-4o` | `YAI_MODEL` |
371
- | `SHELL_NAME` | Shell type | `auto` | `YAI_SHELL_NAME` |
372
- | `OS_NAME` | Operating system | `auto` | `YAI_OS_NAME` |
373
- | `COMPLETION_PATH` | API completion path | `chat/completions` | `YAI_COMPLETION_PATH` |
374
- | `ANSWER_PATH` | JSON path for response | `choices[0].message.content` | `YAI_ANSWER_PATH` |
375
- | `STREAM` | Enable streaming | `true` | `YAI_STREAM` |
376
- | `TIMEOUT` | API timeout (seconds) | `60` | `YAI_TIMEOUT` |
377
- | `INTERACTIVE_ROUND` | Interactive mode rounds | `25` | `YAI_INTERACTIVE_ROUND` |
378
- | `CODE_THEME` | Syntax highlighting theme | `monokai` | `YAI_CODE_THEME` |
379
- | `TEMPERATURE` | Response randomness | `0.7` | `YAI_TEMPERATURE` |
380
- | `TOP_P` | Top-p sampling | `1.0` | `YAI_TOP_P` |
381
- | `MAX_TOKENS` | Max response tokens | `1024` | `YAI_MAX_TOKENS` |
382
- | `MAX_HISTORY` | Max history entries | `500` | `YAI_MAX_HISTORY` |
383
- | `AUTO_SUGGEST` | Enable history suggestions | `true` | `YAI_AUTO_SUGGEST` |
384
- | `SHOW_REASONING` | Enable reasoning display | `true` | `YAI_SHOW_REASONING` |
385
- | `JUSTIFY` | Text alignment | `default` | `YAI_JUSTIFY` |
386
- | `CHAT_HISTORY_DIR` | Chat history directory | `<tempdir>/yaicli/history` | `YAI_CHAT_HISTORY_DIR` |
387
- | `MAX_SAVED_CHATS` | Max saved chats | `20` | `YAI_MAX_SAVED_CHATS` |
360
+ | Option | Description | Default | Env Variable |
361
+ | --------------------- | ------------------------------------------- | --------------------------- | ------------------------- |
362
+ | `PROVIDER` | LLM provider (openai, claude, cohere, etc.) | `openai` | `YAI_PROVIDER` |
363
+ | `BASE_URL` | API endpoint URL | `https://api.openai.com/v1` | `YAI_BASE_URL` |
364
+ | `API_KEY` | Your API key | - | `YAI_API_KEY` |
365
+ | `MODEL` | LLM model to use | `gpt-4o` | `YAI_MODEL` |
366
+ | `SHELL_NAME` | Shell type | `auto` | `YAI_SHELL_NAME` |
367
+ | `OS_NAME` | Operating system | `auto` | `YAI_OS_NAME` |
368
+ | `STREAM` | Enable streaming | `true` | `YAI_STREAM` |
369
+ | `TIMEOUT` | API timeout (seconds) | `60` | `YAI_TIMEOUT` |
370
+ | `INTERACTIVE_ROUND` | Interactive mode rounds | `25` | `YAI_INTERACTIVE_ROUND` |
371
+ | `CODE_THEME` | Syntax highlighting theme | `monokai` | `YAI_CODE_THEME` |
372
+ | `TEMPERATURE` | Response randomness | `0.7` | `YAI_TEMPERATURE` |
373
+ | `TOP_P` | Top-p sampling | `1.0` | `YAI_TOP_P` |
374
+ | `MAX_TOKENS` | Max response tokens | `1024` | `YAI_MAX_TOKENS` |
375
+ | `MAX_HISTORY` | Max history entries | `500` | `YAI_MAX_HISTORY` |
376
+ | `AUTO_SUGGEST` | Enable history suggestions | `true` | `YAI_AUTO_SUGGEST` |
377
+ | `SHOW_REASONING` | Enable reasoning display | `true` | `YAI_SHOW_REASONING` |
378
+ | `JUSTIFY` | Text alignment | `default` | `YAI_JUSTIFY` |
379
+ | `CHAT_HISTORY_DIR` | Chat history directory | `<tempdir>/yaicli/history` | `YAI_CHAT_HISTORY_DIR` |
380
+ | `MAX_SAVED_CHATS` | Max saved chats | `20` | `YAI_MAX_SAVED_CHATS` |
381
+ | `ROLE_MODIFY_WARNING` | Warn user when modifying role | `true` | `YAI_ROLE_MODIFY_WARNING` |
388
382
 
389
383
  ### LLM Provider Configuration
390
384
 
@@ -393,52 +387,23 @@ other providers.
393
387
 
394
388
  #### Pre-configured Provider Settings
395
389
 
396
- | Provider | BASE_URL | COMPLETION_PATH | ANSWER_PATH |
397
- |--------------------------------|-----------------------------------------------------------|--------------------|------------------------------|
398
- | **OpenAI** (default) | `https://api.openai.com/v1` | `chat/completions` | `choices[0].message.content` |
399
- | **Claude** (native API) | `https://api.anthropic.com/v1` | `messages` | `content[0].text` |
400
- | **Claude** (OpenAI-compatible) | `https://api.anthropic.com/v1/openai` | `chat/completions` | `choices[0].message.content` |
401
- | **Cohere** | `https://api.cohere.com/v2` | `chat` | `message.content[0].text` |
402
- | **Google Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai` | `chat/completions` | `choices[0].message.content` |
390
+ `provider` is not case sensitive.
391
+
392
+ Claude and gemini native api will support soon.
393
+
394
+ | Provider | BASE_URL |
395
+ | ------------------------------ | --------------------------------------------------------- |
396
+ | **OpenAI** (default) | `https://api.openai.com/v1` |
397
+ | **Claude** (native API) | `https://api.anthropic.com/v1` |
398
+ | **Claude** (OpenAI-compatible) | `https://api.anthropic.com/v1/openai` |
399
+ | **Cohere** | `https://api.cohere.com` |
400
+ | **Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai` |
403
401
 
404
402
  > **Note**: Many providers offer OpenAI-compatible endpoints that work with the default settings.
405
403
  >
406
404
  > - Google Gemini: https://ai.google.dev/gemini-api/docs/openai
407
405
  > - Claude: https://docs.anthropic.com/en/api/openai-sdk
408
406
 
409
- #### Custom Provider Configuration Guide
410
-
411
- To configure a custom provider:
412
-
413
- 1. **Find the API Endpoint**:
414
-
415
- - Check the provider's API documentation for their chat completion endpoint
416
-
417
- 2. **Identify the Response Structure**:
418
-
419
- - Look at the JSON response format to find where the text content is located
420
-
421
- 3. **Set the Path Expression**:
422
- - Use jmespath syntax to specify the path to the text content
423
-
424
- **Example**: For Claude's native API, the response looks like:
425
-
426
- ```json
427
- {
428
- "content": [
429
- {
430
- "text": "Hi! My name is Claude.",
431
- "type": "text"
432
- }
433
- ],
434
- "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
435
- "model": "claude-3-7-sonnet-20250219",
436
- "role": "assistant"
437
- }
438
- ```
439
-
440
- The path to extract the text is: `content.[0].text`
441
-
442
407
  ### Syntax Highlighting Themes
443
408
 
444
409
  YAICLI supports all Pygments syntax highlighting themes. You can set your preferred theme in the config file:
@@ -449,7 +414,7 @@ CODE_THEME = monokai
449
414
 
450
415
  Browse available themes at: https://pygments.org/styles/
451
416
 
452
- ![monokai theme example](artwork/monokia.png)
417
+ ![monokia theme example](artwork/monokia.png)
453
418
 
454
419
  ## 🚀 Usage
455
420
 
@@ -849,12 +814,10 @@ YAICLI is designed with a modular architecture that separates concerns and makes
849
814
  ### Dependencies
850
815
 
851
816
  | Library | Purpose |
852
- |-----------------------------------------------------------------|----------------------------------------------------|
817
+ | --------------------------------------------------------------- | -------------------------------------------------- |
853
818
  | [Typer](https://typer.tiangolo.com/) | Command-line interface with type hints |
854
819
  | [Rich](https://rich.readthedocs.io/) | Terminal formatting and beautiful display |
855
820
  | [prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/) | Interactive input with history and auto-completion |
856
- | [httpx](https://www.python-httpx.org/) | Modern HTTP client with async support |
857
- | [jmespath](https://jmespath.org/) | JSON data extraction |
858
821
 
859
822
  ## 👨‍💻 Contributing
860
823
 
@@ -0,0 +1,23 @@
1
+ pyproject.toml,sha256=qBb73j9dLZ13fGtQDN81nxfqfpUVs5HNDq5hyfwxOtQ,1540
2
+ yaicli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ yaicli/chat_manager.py,sha256=I7BAMz91FLYT6x69wbomtAGLx0WsoTwS4Wo0MgP6P9I,10644
4
+ yaicli/cli.py,sha256=kqUXSDQej4lMefI2QQpM-Do01Be6CUphh65_Nu7sDck,22948
5
+ yaicli/config.py,sha256=nfnrMz_Q78oHqDVvWSrQRGFyBl-q5MScvnYlQRWDQ_g,6515
6
+ yaicli/console.py,sha256=291F4hGksJtxYpg_mehepCIJ-eB2MaDNIyv1JAMgJ1Y,1985
7
+ yaicli/const.py,sha256=WjNzJAP37XizJYyd-LBTWxZAfJ2Nj7mj-eECPMOr3Do,6927
8
+ yaicli/entry.py,sha256=Yp0Z--x-7dowrz-h8hJJ4_BoCzuDjS11NcM8YgFzUoY,7460
9
+ yaicli/exceptions.py,sha256=ndedSdE0uaxxHrWN944BkbhMfRMSMxGDfmqmCKCGJco,924
10
+ yaicli/history.py,sha256=s-57X9FMsaQHF7XySq1gGH_jpd_cHHTYafYu2ECuG6M,2472
11
+ yaicli/printer.py,sha256=nXpralD5qZJQga3OTdEPhj22g7UoF-4mJbZeOtWXojo,12430
12
+ yaicli/render.py,sha256=mB1OT9859_PTwI9f-KY802lPaeQXKRw6ls_5jN21jWc,511
13
+ yaicli/roles.py,sha256=LdPqpdBKQr_eX7PV7iwRtFT0_trmWXVvZUmb3OMhK0w,10582
14
+ yaicli/utils.py,sha256=MLvb-C5n19AD9Z1nW4Z3Z43ZKNH8STxQmNDnL7mq26E,4490
15
+ yaicli/providers/__init__.py,sha256=zYXcnV8HdNeLEPRJEeSgSboNuphkdA1PrbB8MkDErAk,1243
16
+ yaicli/providers/base.py,sha256=Q6QoxrIVqDYcUp1-FY5O-LT33rKSb1jeXGnrbd4Xlog,1870
17
+ yaicli/providers/cohere.py,sha256=m_HIHm6NEd1VG4wCmDKYn3KHo2jzC98FsfxRzjWR8-M,5411
18
+ yaicli/providers/openai.py,sha256=CKNN3wDsnCEOwZAMrARi43-AV65WgJ7AvHONgvpLZw8,8541
19
+ yaicli-0.4.0.dist-info/METADATA,sha256=oeusTm4BZ1Dvow0IF_c2urp_onnV41OgE7QzwcuP09c,43917
20
+ yaicli-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ yaicli-0.4.0.dist-info/entry_points.txt,sha256=iMhGm3btBaqrknQoF6WCg5sdx69ZyNSC73tRpCcbcLw,63
22
+ yaicli-0.4.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
23
+ yaicli-0.4.0.dist-info/RECORD,,
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
@@ -1,20 +0,0 @@
1
- pyproject.toml,sha256=C7FTGTVlazP67RAED5c9JjzINHUfFe7xcu05LrZv1mk,1519
2
- yaicli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- yaicli/api.py,sha256=9kRozzxBKduQsda3acnxzvOD9wRLL0cH182L4ddHY8E,13666
4
- yaicli/chat_manager.py,sha256=I7BAMz91FLYT6x69wbomtAGLx0WsoTwS4Wo0MgP6P9I,10644
5
- yaicli/cli.py,sha256=z5t4XxKcl4MRGMTaASr0bc9PiidPpoSaBsgJd6d69-Y,22850
6
- yaicli/config.py,sha256=xtzgXApM93zCqSUxmVSBdph0co_NKfEUU3hWtPe8qvM,6236
7
- yaicli/console.py,sha256=291F4hGksJtxYpg_mehepCIJ-eB2MaDNIyv1JAMgJ1Y,1985
8
- yaicli/const.py,sha256=iOQNG6M4EBmKgbwZdNqRsHrcQ7Od1nKOyLAqUhfMEBM,7020
9
- yaicli/entry.py,sha256=Yp0Z--x-7dowrz-h8hJJ4_BoCzuDjS11NcM8YgFzUoY,7460
10
- yaicli/exceptions.py,sha256=ndedSdE0uaxxHrWN944BkbhMfRMSMxGDfmqmCKCGJco,924
11
- yaicli/history.py,sha256=s-57X9FMsaQHF7XySq1gGH_jpd_cHHTYafYu2ECuG6M,2472
12
- yaicli/printer.py,sha256=nXpralD5qZJQga3OTdEPhj22g7UoF-4mJbZeOtWXojo,12430
13
- yaicli/render.py,sha256=mB1OT9859_PTwI9f-KY802lPaeQXKRw6ls_5jN21jWc,511
14
- yaicli/roles.py,sha256=bhXpLnGTPRZp3-K1Tt6ppTsuG2v9S0RAXikfMFhDs_U,9144
15
- yaicli/utils.py,sha256=MLvb-C5n19AD9Z1nW4Z3Z43ZKNH8STxQmNDnL7mq26E,4490
16
- yaicli-0.3.2.dist-info/METADATA,sha256=YHnAg8uWg41eBr1GwnMO4ch5S_sf1x2zdTnqlGQm-5g,45320
17
- yaicli-0.3.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
- yaicli-0.3.2.dist-info/entry_points.txt,sha256=iMhGm3btBaqrknQoF6WCg5sdx69ZyNSC73tRpCcbcLw,63
19
- yaicli-0.3.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
20
- yaicli-0.3.2.dist-info/RECORD,,
File without changes