chat-console 0.2.99__py3-none-any.whl → 0.3.4__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.
app/__init__.py CHANGED
@@ -3,4 +3,4 @@ Chat CLI
3
3
  A command-line interface for chatting with various LLM providers like ChatGPT and Claude.
4
4
  """
5
5
 
6
- __version__ = "0.2.99"
6
+ __version__ = "0.3.4"
app/api/anthropic.py CHANGED
@@ -144,86 +144,110 @@ class AnthropicClient(BaseModelClient):
144
144
  except ImportError:
145
145
  debug_log = lambda msg: None
146
146
 
147
+ # Always include a reliable fallback list in case API calls fail
148
+ fallback_models = [
149
+ {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"},
150
+ {"id": "claude-3-sonnet-20240229", "name": "Claude 3 Sonnet"},
151
+ {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"},
152
+ {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"},
153
+ {"id": "claude-3-7-sonnet-20250219", "name": "Claude 3.7 Sonnet"},
154
+ ]
155
+
156
+ # If no client is initialized, return fallback immediately
157
+ if not self.client:
158
+ debug_log("Anthropic: No client initialized, using fallback models")
159
+ return fallback_models
160
+
147
161
  try:
148
162
  debug_log("Anthropic: Fetching models from API...")
149
- # The Anthropic Python SDK might not have a direct high-level method for listing models yet.
150
- # We might need to use the underlying HTTP client or make a direct request.
151
- # Let's assume for now the SDK client *does* have a way, like self.client.models.list()
152
- # If this fails, we'd need to implement a direct HTTP GET request.
153
- # response = await self.client.models.list() # Hypothetical SDK method
154
-
155
- # --- Alternative: Direct HTTP Request using httpx (if client exposes it) ---
156
- # Check if the client has an internal http_client we can use
163
+
164
+ # Try using the models.list method if available in newer SDK versions
165
+ if hasattr(self.client, 'models') and hasattr(self.client.models, 'list'):
166
+ try:
167
+ debug_log("Anthropic: Using client.models.list() method")
168
+ models_response = await self.client.models.list()
169
+ if hasattr(models_response, 'data') and isinstance(models_response.data, list):
170
+ formatted_models = [
171
+ {"id": model.id, "name": getattr(model, "name", model.id)}
172
+ for model in models_response.data
173
+ ]
174
+ debug_log(f"Anthropic: Found {len(formatted_models)} models via SDK")
175
+ return formatted_models
176
+ except Exception as sdk_err:
177
+ debug_log(f"Anthropic: Error using models.list(): {str(sdk_err)}")
178
+ # Continue to next method
179
+
180
+ # Try direct HTTP request if client exposes the underlying HTTP client
157
181
  if hasattr(self.client, '_client') and hasattr(self.client._client, 'get'):
158
- response = await self.client._client.get(
159
- "/v1/models",
160
- headers={"anthropic-version": "2023-06-01"} # Add required version header
161
- )
162
- response.raise_for_status() # Raise HTTP errors
163
- models_data = response.json()
164
- debug_log(f"Anthropic: API response received: {models_data}")
165
- if 'data' in models_data and isinstance(models_data['data'], list):
166
- # Format the response as expected: list of {"id": ..., "name": ...}
167
- formatted_models = [
168
- {"id": model.get("id"), "name": model.get("display_name", model.get("id"))}
169
- for model in models_data['data']
170
- if model.get("id") # Ensure model has an ID
171
- ]
172
- # Log each model ID clearly for debugging
173
- debug_log(f"Anthropic: Available models from API:")
174
- for model in formatted_models:
175
- debug_log(f" - ID: {model.get('id')}, Name: {model.get('name')}")
176
- return formatted_models
177
- else:
178
- debug_log("Anthropic: Unexpected API response format for models.")
179
- return []
180
- else:
181
- debug_log("Anthropic: Client does not expose HTTP client for model listing. Returning empty list.")
182
- return [] # Cannot fetch dynamically
182
+ try:
183
+ debug_log("Anthropic: Using direct HTTP request to /v1/models")
184
+ response = await self.client._client.get(
185
+ "/v1/models",
186
+ headers={"anthropic-version": "2023-06-01"}
187
+ )
188
+ response.raise_for_status()
189
+ models_data = response.json()
190
+
191
+ if 'data' in models_data and isinstance(models_data['data'], list):
192
+ formatted_models = [
193
+ {"id": model.get("id"), "name": model.get("display_name", model.get("id"))}
194
+ for model in models_data['data']
195
+ if model.get("id")
196
+ ]
197
+ debug_log(f"Anthropic: Found {len(formatted_models)} models via HTTP request")
198
+ return formatted_models
199
+ else:
200
+ debug_log("Anthropic: Unexpected API response format")
201
+ except Exception as http_err:
202
+ debug_log(f"Anthropic: HTTP request error: {str(http_err)}")
203
+ # Continue to fallback
204
+
205
+ # If we reach here, both methods failed
206
+ debug_log("Anthropic: All API methods failed, using fallback models")
207
+ return fallback_models
183
208
 
184
209
  except Exception as e:
185
210
  debug_log(f"Anthropic: Failed to fetch models from API: {str(e)}")
186
- # Fallback to a minimal hardcoded list in case of API error
187
- # Include Claude 3.7 Sonnet with the correct full ID
188
- fallback_models = [
189
- {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"},
190
- {"id": "claude-3-sonnet-20240229", "name": "Claude 3 Sonnet"},
191
- {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"},
192
- {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"},
193
- {"id": "claude-3-7-sonnet-20250219", "name": "Claude 3.7 Sonnet"}, # Add Claude 3.7 Sonnet
194
- ]
195
- debug_log("Anthropic: Using fallback model list:")
196
- for model in fallback_models:
197
- debug_log(f" - ID: {model['id']}, Name: {model['name']}")
211
+ debug_log("Anthropic: Using fallback model list")
198
212
  return fallback_models
199
213
 
200
- # Keep this synchronous for now, but make it call the async fetcher
201
- # Note: This is slightly awkward. Ideally, config loading would be async.
202
- # For now, we'll run the async fetcher within the sync method using asyncio.run()
203
- # This is NOT ideal for performance but avoids larger refactoring of config loading.
204
214
  def get_available_models(self) -> List[Dict[str, Any]]:
205
215
  """Get list of available Claude models by fetching from API."""
216
+ # Reliable fallback list that doesn't depend on async operations
217
+ fallback_models = [
218
+ {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"},
219
+ {"id": "claude-3-sonnet-20240229", "name": "Claude 3 Sonnet"},
220
+ {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"},
221
+ {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"},
222
+ {"id": "claude-3-7-sonnet-20250219", "name": "Claude 3.7 Sonnet"},
223
+ ]
224
+
206
225
  try:
207
- # Run the async fetcher method synchronously
208
- models = asyncio.run(self._fetch_models_from_api())
209
- return models
210
- except RuntimeError as e:
211
- # Handle cases where asyncio.run can't be called (e.g., already in an event loop)
212
- # This might happen during app runtime if called again. Fallback needed.
213
- try:
214
- from app.main import debug_log
215
- except ImportError:
216
- debug_log = lambda msg: None
217
- debug_log(f"Anthropic: Cannot run async model fetch synchronously ({e}). Falling back to hardcoded list.")
218
- # Use the same fallback list as in _fetch_models_from_api
219
- fallback_models = [
220
- {"id": "claude-3-opus-20240229", "name": "Claude 3 Opus"},
221
- {"id": "claude-3-sonnet-20240229", "name": "Claude 3 Sonnet"},
222
- {"id": "claude-3-haiku-20240307", "name": "Claude 3 Haiku"},
223
- {"id": "claude-3-5-sonnet-20240620", "name": "Claude 3.5 Sonnet"},
224
- {"id": "claude-3-7-sonnet-20250219", "name": "Claude 3.7 Sonnet"}, # Add Claude 3.7 Sonnet
225
- ]
226
- debug_log("Anthropic: Using fallback model list in get_available_models:")
227
- for model in fallback_models:
228
- debug_log(f" - ID: {model['id']}, Name: {model['name']}")
229
- return fallback_models
226
+ # Check if we're already in an event loop
227
+ try:
228
+ loop = asyncio.get_running_loop()
229
+ in_loop = True
230
+ except RuntimeError:
231
+ in_loop = False
232
+
233
+ if in_loop:
234
+ # We're already in an event loop, create a future
235
+ try:
236
+ from app.main import debug_log
237
+ except ImportError:
238
+ debug_log = lambda msg: None
239
+
240
+ debug_log("Anthropic: Already in event loop, using fallback models")
241
+ return fallback_models
242
+ else:
243
+ # Not in an event loop, we can use asyncio.run
244
+ models = asyncio.run(self._fetch_models_from_api())
245
+ return models
246
+ except Exception as e:
247
+ try:
248
+ from app.main import debug_log
249
+ except ImportError:
250
+ debug_log = lambda msg: None
251
+
252
+ debug_log(f"Anthropic: Error in get_available_models: {str(e)}")
253
+ return fallback_models
app/api/base.py CHANGED
@@ -120,8 +120,8 @@ class BaseModelClient(ABC):
120
120
  if provider == "ollama":
121
121
  return await OllamaClient.create()
122
122
  elif provider == "openai":
123
- return OpenAIClient()
123
+ return await OpenAIClient.create()
124
124
  elif provider == "anthropic":
125
- return AnthropicClient()
125
+ return await AnthropicClient.create()
126
126
  else:
127
127
  raise ValueError(f"Unknown provider: {provider}")
app/api/ollama.py CHANGED
@@ -369,6 +369,10 @@ class OllamaClient(BaseModelClient):
369
369
 
370
370
  # Use a simpler async iteration pattern that's less error-prone
371
371
  debug_log("Starting to process response stream")
372
+
373
+ # Set a flag to track if we've yielded any content
374
+ has_yielded_content = False
375
+
372
376
  async for line in response.content:
373
377
  # Check cancellation periodically
374
378
  if self._active_stream_session is None:
@@ -378,31 +382,38 @@ class OllamaClient(BaseModelClient):
378
382
  try:
379
383
  # Process the chunk
380
384
  if line:
381
- chunk = line.decode().strip()
382
385
  chunk_str = line.decode().strip()
383
386
  # Check if it looks like JSON before trying to parse
384
387
  if chunk_str.startswith('{') and chunk_str.endswith('}'):
385
388
  try:
386
389
  data = json.loads(chunk_str)
387
390
  if isinstance(data, dict) and "response" in data:
388
- chunk_length = len(data["response"]) if data["response"] else 0
389
- debug_log(f"Yielding chunk of length: {chunk_length}")
390
- yield data["response"]
391
+ response_text = data["response"]
392
+ if response_text: # Only yield non-empty responses
393
+ has_yielded_content = True
394
+ chunk_length = len(response_text)
395
+ # Only log occasionally to reduce console spam
396
+ if chunk_length % 20 == 0:
397
+ debug_log(f"Yielding chunk of length: {chunk_length}")
398
+ yield response_text
391
399
  else:
392
- debug_log(f"JSON chunk missing 'response' key: {chunk_str}")
400
+ debug_log(f"JSON chunk missing 'response' key: {chunk_str[:100]}")
393
401
  except json.JSONDecodeError:
394
- debug_log(f"JSON decode error for chunk: {chunk_str}")
402
+ debug_log(f"JSON decode error for chunk: {chunk_str[:100]}")
395
403
  else:
396
404
  # Log unexpected non-JSON lines but don't process them
397
- if chunk_str: # Avoid logging empty lines
398
- debug_log(f"Received unexpected non-JSON line: {chunk_str}")
399
- # Continue processing next line regardless of parsing success/failure of current line
400
- continue
405
+ if chunk_str and len(chunk_str) > 5: # Avoid logging empty or tiny lines
406
+ debug_log(f"Received unexpected non-JSON line: {chunk_str[:100]}")
401
407
  except Exception as chunk_err:
402
408
  debug_log(f"Error processing chunk: {str(chunk_err)}")
403
409
  # Continue instead of breaking to try processing more chunks
404
410
  continue
405
411
 
412
+ # If we didn't yield any content, yield a default message
413
+ if not has_yielded_content:
414
+ debug_log("No content was yielded from stream, providing fallback response")
415
+ yield "I'm sorry, but I couldn't generate a response. Please try again or try a different model."
416
+
406
417
  logger.info("Streaming completed successfully")
407
418
  debug_log("Streaming completed successfully")
408
419
  return
app/api/openai.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from openai import AsyncOpenAI
2
+ import asyncio
2
3
  from typing import List, Dict, Any, Optional, Generator, AsyncGenerator
3
4
  from .base import BaseModelClient
4
5
  from ..config import OPENAI_API_KEY
@@ -84,47 +85,103 @@ class OpenAIClient(BaseModelClient):
84
85
  debug_log(f"OpenAI: skipping invalid message: {m}")
85
86
 
86
87
  debug_log(f"OpenAI: prepared {len(api_messages)} valid messages")
88
+
89
+ # Check for empty or very short prompts and enhance them slightly
90
+ # This helps with the "hi" case where OpenAI might not generate a meaningful response
91
+ if api_messages and len(api_messages) > 0:
92
+ last_message = api_messages[-1]
93
+ if last_message["role"] == "user" and len(last_message["content"].strip()) <= 3:
94
+ debug_log(f"OpenAI: Enhancing very short user prompt: '{last_message['content']}'")
95
+ last_message["content"] = f"{last_message['content']} - Please respond conversationally."
96
+ debug_log(f"OpenAI: Enhanced to: '{last_message['content']}'")
97
+
87
98
  except Exception as msg_error:
88
99
  debug_log(f"OpenAI: error preparing messages: {str(msg_error)}")
89
100
  # Fallback to a simpler message format if processing fails
90
101
  api_messages = [{"role": "user", "content": "Please respond to my request."}]
91
102
 
92
103
  debug_log("OpenAI: requesting stream")
93
- stream = await self.client.chat.completions.create(
94
- model=model,
95
- messages=api_messages,
96
- temperature=temperature,
97
- max_tokens=max_tokens,
98
- stream=True,
99
- )
100
104
 
101
- debug_log("OpenAI: stream created successfully, processing chunks")
102
- async for chunk in stream:
105
+ # Use more robust error handling with retry for connection issues
106
+ max_retries = 2
107
+ retry_count = 0
108
+
109
+ while retry_count <= max_retries:
103
110
  try:
104
- if chunk.choices and hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content'):
105
- content = chunk.choices[0].delta.content
106
- if content is not None:
107
- # Ensure we're returning a string
108
- text = str(content)
109
- debug_log(f"OpenAI: yielding chunk of length: {len(text)}")
110
- yield text
111
- else:
112
- debug_log("OpenAI: skipping None content chunk")
113
- else:
114
- debug_log("OpenAI: skipping chunk with missing content")
115
- except Exception as chunk_error:
116
- debug_log(f"OpenAI: error processing chunk: {str(chunk_error)}")
117
- # Skip problematic chunks but continue processing
118
- continue
111
+ stream = await self.client.chat.completions.create(
112
+ model=model,
113
+ messages=api_messages,
114
+ temperature=temperature,
115
+ max_tokens=max_tokens,
116
+ stream=True,
117
+ )
118
+ debug_log("OpenAI: stream created successfully")
119
+
120
+ # Yield a small padding token at the beginning for very short prompts
121
+ # This ensures the UI sees immediate content updates
122
+ if any(m["role"] == "user" and len(m["content"].strip()) <= 3 for m in api_messages):
123
+ debug_log("OpenAI: Adding initial padding token for short message")
124
+ yield "" # Empty string to trigger UI update cycle
125
+
126
+ # Process stream chunks
127
+ chunk_count = 0
128
+ debug_log("OpenAI: starting to process chunks")
119
129
 
130
+ async for chunk in stream:
131
+ chunk_count += 1
132
+ try:
133
+ if chunk.choices and hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content'):
134
+ content = chunk.choices[0].delta.content
135
+ if content is not None:
136
+ # Ensure we're returning a string
137
+ text = str(content)
138
+ debug_log(f"OpenAI: yielding chunk {chunk_count} of length: {len(text)}")
139
+ yield text
140
+ else:
141
+ debug_log(f"OpenAI: skipping None content chunk {chunk_count}")
142
+ else:
143
+ debug_log(f"OpenAI: skipping chunk {chunk_count} with missing content")
144
+ except Exception as chunk_error:
145
+ debug_log(f"OpenAI: error processing chunk {chunk_count}: {str(chunk_error)}")
146
+ # Skip problematic chunks but continue processing
147
+ continue
148
+
149
+ debug_log(f"OpenAI: stream completed successfully with {chunk_count} chunks")
150
+
151
+ # If we reach this point, we've successfully processed the stream
152
+ break
153
+
154
+ except Exception as e:
155
+ debug_log(f"OpenAI: error in attempt {retry_count+1}/{max_retries+1}: {str(e)}")
156
+ retry_count += 1
157
+ if retry_count <= max_retries:
158
+ debug_log(f"OpenAI: retrying after error (attempt {retry_count+1})")
159
+ # Simple exponential backoff
160
+ await asyncio.sleep(1 * retry_count)
161
+ else:
162
+ debug_log("OpenAI: max retries reached, raising exception")
163
+ raise Exception(f"OpenAI streaming error after {max_retries+1} attempts: {str(e)}")
164
+
120
165
  except Exception as e:
121
166
  debug_log(f"OpenAI: error in generate_stream: {str(e)}")
167
+ # Yield a simple error message as a last resort to ensure UI updates
168
+ yield f"Error: {str(e)}"
122
169
  raise Exception(f"OpenAI streaming error: {str(e)}")
123
170
 
124
- def get_available_models(self) -> List[Dict[str, Any]]:
125
- """Get list of available OpenAI models"""
126
- return [
127
- {"id": "gpt-3.5-turbo", "name": "GPT-3.5 Turbo"},
128
- {"id": "gpt-4", "name": "GPT-4"},
129
- {"id": "gpt-4-turbo", "name": "GPT-4 Turbo"}
130
- ]
171
+ async def get_available_models(self) -> List[Dict[str, Any]]:
172
+ """Fetch list of available OpenAI models from the /models endpoint"""
173
+ try:
174
+ models_response = await self.client.models.list()
175
+ # Each model has an 'id' and possibly other metadata
176
+ models = []
177
+ for model in models_response.data:
178
+ # Use 'id' as both id and name for now; can enhance with more info if needed
179
+ models.append({"id": model.id, "name": model.id})
180
+ return models
181
+ except Exception as e:
182
+ # Fallback to a static list if API call fails
183
+ return [
184
+ {"id": "gpt-3.5-turbo", "name": "gpt-3.5-turbo"},
185
+ {"id": "gpt-4", "name": "gpt-4"},
186
+ {"id": "gpt-4-turbo", "name": "gpt-4-turbo"}
187
+ ]