chat-console 0.2.8__py3-none-any.whl → 0.2.98__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/ui/model_selector.py CHANGED
@@ -7,6 +7,7 @@ from textual.widget import Widget
7
7
  from textual.message import Message
8
8
 
9
9
  from ..config import CONFIG
10
+ from ..utils import resolve_model_id # Import the resolve_model_id function
10
11
  from ..api.ollama import OllamaClient
11
12
  from .chat_interface import ChatInterface
12
13
 
@@ -58,7 +59,10 @@ class ModelSelector(Container):
58
59
  class ModelSelected(Message):
59
60
  """Event sent when a model is selected"""
60
61
  def __init__(self, model_id: str):
61
- self.model_id = model_id
62
+ # Always resolve the model ID before sending it to the main app
63
+ # This ensures short names like "claude-3.7-sonnet" are converted to full IDs
64
+ self.model_id = resolve_model_id(model_id)
65
+ logger.info(f"ModelSelected: Original ID '{model_id}' resolved to '{self.model_id}'")
62
66
  super().__init__()
63
67
 
64
68
  def __init__(
@@ -68,7 +72,10 @@ class ModelSelector(Container):
68
72
  id: Optional[str] = None
69
73
  ):
70
74
  super().__init__(name=name, id=id)
71
- self.selected_model = selected_model or CONFIG["default_model"]
75
+ # Resolve the model ID during initialization
76
+ original_id = selected_model or CONFIG["default_model"]
77
+ self.selected_model = resolve_model_id(original_id)
78
+ logger.info(f"ModelSelector.__init__: Original ID '{original_id}' resolved to '{self.selected_model}'")
72
79
  # Handle custom models not in CONFIG
73
80
  if self.selected_model in CONFIG["available_models"]:
74
81
  self.selected_provider = CONFIG["available_models"][self.selected_model]["provider"]
@@ -220,12 +227,48 @@ class ModelSelector(Container):
220
227
  model_options = await self._get_model_options(self.selected_provider)
221
228
  model_select.set_options(model_options)
222
229
  # Select first model of new provider
223
- if model_options:
224
- self.selected_model = model_options[0][1]
225
- model_select.value = self.selected_model
226
- model_select.remove_class("hide")
227
- self.query_one("#custom-model-input").add_class("hide")
228
- self.post_message(self.ModelSelected(self.selected_model))
230
+ if model_options and len(model_options) > 0:
231
+ # Check if model_options is properly structured as a list of tuples
232
+ try:
233
+ # Get the first non-custom model if available
234
+ first_model = None
235
+ for model_option in model_options:
236
+ if isinstance(model_option, tuple) and len(model_option) >= 2 and model_option[1] != "custom":
237
+ first_model = model_option
238
+ break
239
+
240
+ # If no non-custom models, use the first model
241
+ if not first_model and isinstance(model_options[0], tuple) and len(model_options[0]) >= 2:
242
+ first_model = model_options[0]
243
+
244
+ # Set the model if we found one
245
+ if first_model and len(first_model) >= 2:
246
+ # Resolve the model ID before storing and sending
247
+ original_id = first_model[1]
248
+ resolved_id = resolve_model_id(original_id)
249
+ logger.info(f"on_select_changed (provider): Original ID '{original_id}' resolved to '{resolved_id}'")
250
+ self.selected_model = resolved_id
251
+ model_select.value = resolved_id
252
+ model_select.remove_class("hide")
253
+ self.query_one("#custom-model-input").add_class("hide")
254
+ self.post_message(self.ModelSelected(resolved_id))
255
+ else:
256
+ # Fall back to custom if no valid model found
257
+ self.selected_model = "custom"
258
+ model_select.value = "custom"
259
+ model_select.add_class("hide")
260
+ custom_input = self.query_one("#custom-model-input")
261
+ custom_input.remove_class("hide")
262
+ custom_input.focus()
263
+ except (IndexError, TypeError) as e:
264
+ logger.error(f"Error selecting first model: {e}")
265
+ # Fall back to custom
266
+ self.selected_model = "custom"
267
+ model_select.value = "custom"
268
+ model_select.add_class("hide")
269
+ custom_input = self.query_one("#custom-model-input")
270
+ custom_input.remove_class("hide")
271
+ custom_input.focus()
229
272
 
230
273
  elif event.select.id == "model-select":
231
274
  if event.value == "custom":
@@ -241,28 +284,43 @@ class ModelSelector(Container):
241
284
  custom_input = self.query_one("#custom-model-input")
242
285
  model_select.remove_class("hide")
243
286
  custom_input.add_class("hide")
244
- self.selected_model = event.value
245
- self.post_message(self.ModelSelected(event.value))
287
+ # Resolve the model ID before storing and sending
288
+ resolved_id = resolve_model_id(event.value)
289
+ logger.info(f"on_select_changed: Original ID '{event.value}' resolved to '{resolved_id}'")
290
+ self.selected_model = resolved_id
291
+ self.post_message(self.ModelSelected(resolved_id))
246
292
 
247
293
  def on_input_changed(self, event: Input.Changed) -> None:
248
294
  """Handle custom model input changes"""
249
295
  if event.input.id == "custom-model-input":
250
296
  value = event.value.strip()
251
297
  if value: # Only update if there's actual content
252
- self.selected_model = value
253
- self.post_message(self.ModelSelected(value))
298
+ # Resolve the model ID before storing and sending
299
+ resolved_id = resolve_model_id(value)
300
+ logger.info(f"on_input_changed: Original ID '{value}' resolved to '{resolved_id}'")
301
+ self.selected_model = resolved_id
302
+ self.post_message(self.ModelSelected(resolved_id))
254
303
 
255
304
  def get_selected_model(self) -> str:
256
- """Get the current selected model ID"""
257
- return self.selected_model
305
+ """Get the current selected model ID, ensuring it's properly resolved"""
306
+ resolved_id = resolve_model_id(self.selected_model)
307
+ logger.info(f"get_selected_model: Original ID '{self.selected_model}' resolved to '{resolved_id}'")
308
+ return resolved_id
258
309
 
259
310
  def set_selected_model(self, model_id: str) -> None:
260
- """Set the selected model"""
261
- self.selected_model = model_id
262
- if model_id in CONFIG["available_models"]:
311
+ """Set the selected model, ensuring it's properly resolved"""
312
+ # First resolve the model ID to ensure we're using the full ID
313
+ resolved_id = resolve_model_id(model_id)
314
+ logger.info(f"set_selected_model: Original ID '{model_id}' resolved to '{resolved_id}'")
315
+
316
+ # Store the resolved ID
317
+ self.selected_model = resolved_id
318
+
319
+ # Update the UI based on whether this is a known model or custom
320
+ if resolved_id in CONFIG["available_models"]:
263
321
  select = self.query_one("#model-select", Select)
264
322
  custom_input = self.query_one("#custom-model-input")
265
- select.value = model_id
323
+ select.value = resolved_id
266
324
  select.remove_class("hide")
267
325
  custom_input.add_class("hide")
268
326
  else:
@@ -270,7 +328,7 @@ class ModelSelector(Container):
270
328
  custom_input = self.query_one("#custom-model-input")
271
329
  select.value = "custom"
272
330
  select.add_class("hide")
273
- custom_input.value = model_id
331
+ custom_input.value = resolved_id
274
332
  custom_input.remove_class("hide")
275
333
 
276
334
  class StyleSelector(Container):
app/utils.py CHANGED
@@ -4,13 +4,15 @@ import time
4
4
  import asyncio
5
5
  import subprocess
6
6
  import logging
7
- from typing import Optional, Dict, Any, List, TYPE_CHECKING
7
+ import anthropic # Add missing import
8
+ from typing import Optional, Dict, Any, List, TYPE_CHECKING, Callable, Awaitable
8
9
  from datetime import datetime
10
+ from textual import work # Import work decorator
9
11
  from .config import CONFIG, save_config
10
12
 
11
13
  # Import SimpleChatApp for type hinting only if TYPE_CHECKING is True
12
14
  if TYPE_CHECKING:
13
- from .main import SimpleChatApp
15
+ from .main import SimpleChatApp # Keep this for type hinting
14
16
 
15
17
  # Set up logging
16
18
  logging.basicConfig(level=logging.INFO)
@@ -18,8 +20,34 @@ logger = logging.getLogger(__name__)
18
20
 
19
21
  async def generate_conversation_title(message: str, model: str, client: Any) -> str:
20
22
  """Generate a descriptive title for a conversation based on the first message"""
21
- logger.info(f"Generating title for conversation using model: {model}")
22
-
23
+ # --- Choose a specific, reliable model for title generation ---
24
+ # Prefer Haiku if Anthropic is available, otherwise fallback
25
+ title_model_id = None
26
+ if client and isinstance(client, anthropic.AsyncAnthropic): # Check if the passed client is Anthropic
27
+ # Check if Haiku is listed in the client's available models (more robust)
28
+ available_anthropic_models = client.get_available_models()
29
+ haiku_id = "claude-3-haiku-20240307"
30
+ if any(m["id"] == haiku_id for m in available_anthropic_models):
31
+ title_model_id = haiku_id
32
+ logger.info(f"Using Anthropic Haiku for title generation: {title_model_id}")
33
+ else:
34
+ # If Haiku not found, try Sonnet
35
+ sonnet_id = "claude-3-sonnet-20240229"
36
+ if any(m["id"] == sonnet_id for m in available_anthropic_models):
37
+ title_model_id = sonnet_id
38
+ logger.info(f"Using Anthropic Sonnet for title generation: {title_model_id}")
39
+ else:
40
+ logger.warning(f"Neither Haiku nor Sonnet found in Anthropic client's list. Falling back.")
41
+
42
+ # Fallback logic if no specific Anthropic model was found or client is not Anthropic
43
+ if not title_model_id:
44
+ # Use the originally passed model (user's selected chat model) as the final fallback
45
+ title_model_id = model
46
+ logger.warning(f"Falling back to originally selected model for title generation: {title_model_id}")
47
+ # Consider adding fallbacks to OpenAI/Ollama here if needed based on config/availability
48
+
49
+ logger.info(f"Generating title for conversation using model: {title_model_id}")
50
+
23
51
  # Create a special prompt for title generation
24
52
  title_prompt = [
25
53
  {
@@ -43,7 +71,7 @@ async def generate_conversation_title(message: str, model: str, client: Any) ->
43
71
  if hasattr(client, 'generate_completion'):
44
72
  title = await client.generate_completion(
45
73
  messages=title_prompt,
46
- model=model,
74
+ model=title_model_id, # Use the chosen title model
47
75
  temperature=0.7,
48
76
  max_tokens=60 # Titles should be short
49
77
  )
@@ -53,9 +81,18 @@ async def generate_conversation_title(message: str, model: str, client: Any) ->
53
81
  # For now, let's assume a hypothetical non-streaming call or adapt stream
54
82
  # Simplified adaptation: collect stream chunks
55
83
  title_chunks = []
56
- async for chunk in client.generate_stream(title_prompt, model, style=""): # Assuming style might not apply or needs default
57
- title_chunks.append(chunk)
58
- title = "".join(title_chunks)
84
+ try:
85
+ # Use the chosen title model here too
86
+ async for chunk in client.generate_stream(title_prompt, title_model_id, style=""):
87
+ if chunk is not None: # Ensure we only process non-None chunks
88
+ title_chunks.append(chunk)
89
+ title = "".join(title_chunks)
90
+ # If we didn't get any content, use a default
91
+ if not title.strip():
92
+ title = f"Conversation ({datetime.now().strftime('%Y-%m-%d %H:%M')})"
93
+ except Exception as stream_error:
94
+ logger.error(f"Error during title stream processing: {str(stream_error)}")
95
+ title = f"Conversation ({datetime.now().strftime('%Y-%m-%d %H:%M')})"
59
96
  else:
60
97
  raise NotImplementedError("Client does not support a suitable method for title generation.")
61
98
 
@@ -78,59 +115,251 @@ async def generate_conversation_title(message: str, model: str, client: Any) ->
78
115
  logger.error(f"Failed to generate title after multiple retries. Last error: {last_error}")
79
116
  return f"Conversation ({datetime.now().strftime('%Y-%m-%d %H:%M')})"
80
117
 
81
- # Modified signature to accept app instance
82
- async def generate_streaming_response(app: 'SimpleChatApp', messages: List[Dict], model: str, style: str, client: Any, callback: Any) -> str:
83
- """Generate a streaming response from the model"""
118
+ # Make this the worker function directly
119
+ @work(exit_on_error=True)
120
+ async def generate_streaming_response(
121
+ app: 'SimpleChatApp',
122
+ messages: List[Dict],
123
+ model: str,
124
+ style: str,
125
+ client: Any,
126
+ callback: Callable[[str], Awaitable[None]] # More specific type hint for callback
127
+ ) -> Optional[str]: # Return Optional[str] as cancellation might return None implicitly or error
128
+ """Generate a streaming response from the model (as a Textual worker)"""
129
+ # Import debug_log function from main
130
+ # Note: This import might be slightly less reliable inside a worker, but let's try
131
+ try:
132
+ from app.main import debug_log
133
+ except ImportError:
134
+ debug_log = lambda msg: None # Fallback
135
+
136
+ # Worker function needs to handle its own state and cleanup partially
137
+ # The main app will also need cleanup logic in generate_response
138
+
84
139
  logger.info(f"Starting streaming response with model: {model}")
140
+ debug_log(f"Starting streaming response with model: '{model}', client type: {type(client).__name__}")
141
+
142
+ # Very defensive check of messages format
143
+ if not messages:
144
+ debug_log("Error: messages list is empty")
145
+ raise ValueError("Messages list cannot be empty")
146
+
147
+ for i, msg in enumerate(messages):
148
+ try:
149
+ debug_log(f"Message {i}: role={msg.get('role', 'missing')}, content_len={len(msg.get('content', ''))}")
150
+ # Ensure essential fields exist
151
+ if 'role' not in msg:
152
+ debug_log(f"Adding missing 'role' to message {i}")
153
+ msg['role'] = 'user' # Default to user
154
+ if 'content' not in msg:
155
+ debug_log(f"Adding missing 'content' to message {i}")
156
+ msg['content'] = '' # Default to empty string
157
+ except Exception as e:
158
+ debug_log(f"Error checking message {i}: {str(e)}")
159
+ # Try to repair the message
160
+ messages[i] = {
161
+ 'role': 'user',
162
+ 'content': str(msg) if msg else ''
163
+ }
164
+ debug_log(f"Repaired message {i}")
165
+
166
+ debug_log(f"Messages validation complete: {len(messages)} total messages")
167
+
85
168
  full_response = ""
86
169
  buffer = []
87
170
  last_update = time.time()
88
171
  update_interval = 0.1 # Update UI every 100ms
89
- generation_task = None
90
172
 
91
173
  try:
92
- # The cancellation is now handled by cancelling the asyncio Task in main.py
93
- # which will raise CancelledError here, interrupting the loop.
94
- async for chunk in client.generate_stream(messages, model, style):
95
- if chunk: # Only process non-empty chunks
96
- buffer.append(chunk)
97
- current_time = time.time()
174
+ # Check that we have a valid client and model before proceeding
175
+ if client is None:
176
+ debug_log("Error: client is None, cannot proceed with streaming")
177
+ raise ValueError("Model client is None, cannot proceed with streaming")
178
+
179
+ # Check if the client has the required generate_stream method
180
+ if not hasattr(client, 'generate_stream'):
181
+ debug_log(f"Error: client {type(client).__name__} does not have generate_stream method")
182
+ raise ValueError(f"Client {type(client).__name__} does not support streaming")
183
+
184
+ # Set initial model loading state if using Ollama
185
+ # Always show the model loading indicator for Ollama until we confirm otherwise
186
+ is_ollama = 'ollama' in str(type(client)).lower()
187
+ debug_log(f"Is Ollama client: {is_ollama}")
188
+
189
+ if is_ollama and hasattr(app, 'query_one'):
190
+ try:
191
+ # Show model loading indicator by default for Ollama
192
+ debug_log("Showing initial model loading indicator for Ollama")
193
+ logger.info("Showing initial model loading indicator for Ollama")
194
+ loading = app.query_one("#loading-indicator")
195
+ loading.add_class("model-loading")
196
+ loading.update("⚙️ Loading Ollama model...")
197
+ except Exception as e:
198
+ debug_log(f"Error setting initial Ollama loading state: {str(e)}")
199
+ logger.error(f"Error setting initial Ollama loading state: {str(e)}")
200
+
201
+ # Now proceed with streaming
202
+ debug_log(f"Starting stream generation with messages length: {len(messages)}")
203
+ logger.info(f"Starting stream generation for model: {model}")
204
+
205
+ # Defensive approach - wrap the stream generation in a try-except
206
+ try:
207
+ debug_log("Calling client.generate_stream()")
208
+ stream_generator = client.generate_stream(messages, model, style)
209
+ debug_log("Successfully obtained stream generator")
210
+ except Exception as stream_init_error:
211
+ debug_log(f"Error initializing stream generator: {str(stream_init_error)}")
212
+ logger.error(f"Error initializing stream generator: {str(stream_init_error)}")
213
+ raise # Re-raise to be handled in the main catch block
214
+
215
+ # After getting the generator, check if we're NOT in model loading state
216
+ if hasattr(client, 'is_loading_model') and not client.is_loading_model() and hasattr(app, 'query_one'):
217
+ try:
218
+ debug_log("Model is ready for generation, updating UI")
219
+ logger.info("Model is ready for generation, updating UI")
220
+ loading = app.query_one("#loading-indicator")
221
+ loading.remove_class("model-loading")
222
+ loading.update("▪▪▪ Generating response...")
223
+ except Exception as e:
224
+ debug_log(f"Error updating UI after stream init: {str(e)}")
225
+ logger.error(f"Error updating UI after stream init: {str(e)}")
226
+
227
+ # Process the stream with careful error handling
228
+ debug_log("Beginning to process stream chunks")
229
+ try:
230
+ async for chunk in stream_generator:
231
+ # Check for cancellation frequently
232
+ if asyncio.current_task().cancelled():
233
+ debug_log("Task cancellation detected during chunk processing")
234
+ logger.info("Task cancellation detected during chunk processing")
235
+ # Close the client stream if possible
236
+ if hasattr(client, 'cancel_stream'):
237
+ debug_log("Calling client.cancel_stream() due to task cancellation")
238
+ await client.cancel_stream()
239
+ raise asyncio.CancelledError()
240
+
241
+ # Check if model loading state changed, but more safely
242
+ if hasattr(client, 'is_loading_model'):
243
+ try:
244
+ # Get the model loading state
245
+ model_loading = client.is_loading_model()
246
+ debug_log(f"Model loading state: {model_loading}")
247
+
248
+ # Safely update the UI elements if they exist
249
+ if hasattr(app, 'query_one'):
250
+ try:
251
+ loading = app.query_one("#loading-indicator")
252
+
253
+ # Check for class existence first
254
+ if model_loading and hasattr(loading, 'has_class') and not loading.has_class("model-loading"):
255
+ # Model loading started
256
+ debug_log("Model loading started during streaming")
257
+ logger.info("Model loading started during streaming")
258
+ loading.add_class("model-loading")
259
+ loading.update("⚙️ Loading Ollama model...")
260
+ elif not model_loading and hasattr(loading, 'has_class') and loading.has_class("model-loading"):
261
+ # Model loading finished
262
+ debug_log("Model loading finished during streaming")
263
+ logger.info("Model loading finished during streaming")
264
+ loading.remove_class("model-loading")
265
+ loading.update("▪▪▪ Generating response...")
266
+ except Exception as ui_e:
267
+ debug_log(f"Error updating UI elements: {str(ui_e)}")
268
+ logger.error(f"Error updating UI elements: {str(ui_e)}")
269
+ except Exception as e:
270
+ debug_log(f"Error checking model loading state: {str(e)}")
271
+ logger.error(f"Error checking model loading state: {str(e)}")
98
272
 
99
- # Update UI if enough time has passed or buffer is large
100
- if current_time - last_update >= update_interval or len(''.join(buffer)) > 100:
101
- new_content = ''.join(buffer)
102
- full_response += new_content
103
- # No need to check app.is_generating here, rely on CancelledError
104
- await callback(full_response)
105
- buffer = []
106
- last_update = current_time
273
+ # Process the chunk - with careful type handling
274
+ if chunk: # Only process non-empty chunks
275
+ # Ensure chunk is a string - critical fix for providers returning other types
276
+ if not isinstance(chunk, str):
277
+ debug_log(f"WARNING: Received non-string chunk of type: {type(chunk).__name__}")
278
+ try:
279
+ # Try to convert to string if possible
280
+ chunk = str(chunk)
281
+ debug_log(f"Successfully converted chunk to string, length: {len(chunk)}")
282
+ except Exception as e:
283
+ debug_log(f"Error converting chunk to string: {str(e)}")
284
+ # Skip this chunk since it can't be converted
285
+ continue
286
+
287
+ debug_log(f"Received chunk of length: {len(chunk)}")
288
+ buffer.append(chunk)
289
+ current_time = time.time()
107
290
 
108
- # Small delay to let UI catch up
109
- await asyncio.sleep(0.05)
291
+ # Update UI if enough time has passed or buffer is large
292
+ if current_time - last_update >= update_interval or len(''.join(buffer)) > 100:
293
+ new_content = ''.join(buffer)
294
+ full_response += new_content
295
+ # Send content to UI
296
+ debug_log(f"Updating UI with content length: {len(full_response)}")
297
+ await callback(full_response)
298
+ buffer = []
299
+ last_update = current_time
300
+
301
+ # Small delay to let UI catch up
302
+ await asyncio.sleep(0.05)
303
+ except asyncio.CancelledError:
304
+ debug_log("CancelledError in stream processing")
305
+ raise
306
+ except Exception as chunk_error:
307
+ debug_log(f"Error processing stream chunks: {str(chunk_error)}")
308
+ logger.error(f"Error processing stream chunks: {str(chunk_error)}")
309
+ raise
110
310
 
111
311
  # Send any remaining content if the loop finished normally
112
312
  if buffer:
113
313
  new_content = ''.join(buffer)
114
314
  full_response += new_content
315
+ debug_log(f"Sending final content, total length: {len(full_response)}")
115
316
  await callback(full_response)
116
317
 
117
- logger.info("Streaming response loop finished normally.") # Clarify log message
118
- # Add log before returning
119
- logger.info(f"generate_streaming_response returning normally. Full response length: {len(full_response)}")
318
+ debug_log(f"Streaming response completed successfully. Response length: {len(full_response)}")
319
+ logger.info(f"Streaming response completed successfully. Response length: {len(full_response)}")
120
320
  return full_response
321
+
121
322
  except asyncio.CancelledError:
122
323
  # This is expected when the user cancels via Escape
123
- logger.info("Streaming response task cancelled.") # Clarify log message
124
- # Add log before returning
125
- logger.info(f"generate_streaming_response returning after cancellation. Partial response length: {len(full_response)}")
126
- # Do not re-raise CancelledError, let the caller handle it
127
- return full_response # Return whatever was collected so far (might be partial)
324
+ debug_log(f"Streaming response task cancelled. Partial response length: {len(full_response)}")
325
+ logger.info(f"Streaming response task cancelled. Partial response length: {len(full_response)}")
326
+ # Ensure the client stream is closed
327
+ if hasattr(client, 'cancel_stream'):
328
+ debug_log("Calling client.cancel_stream() after cancellation")
329
+ try:
330
+ await client.cancel_stream()
331
+ debug_log("Successfully cancelled client stream")
332
+ except Exception as cancel_err:
333
+ debug_log(f"Error cancelling client stream: {str(cancel_err)}")
334
+ # Return whatever was collected so far
335
+ return full_response
336
+
128
337
  except Exception as e:
129
- logger.error(f"Error during streaming response: {str(e)}") # Clarify log message
130
- # Ensure the app knows generation stopped on error ONLY if it wasn't cancelled
131
- if not isinstance(e, asyncio.CancelledError):
132
- app.is_generating = False # Reset flag on other errors
133
- raise # Re-raise other exceptions
338
+ debug_log(f"Error during streaming response: {str(e)}")
339
+ logger.error(f"Error during streaming response: {str(e)}")
340
+ # Close the client stream if possible
341
+ if hasattr(client, 'cancel_stream'):
342
+ debug_log("Attempting to cancel client stream after error")
343
+ try:
344
+ await client.cancel_stream()
345
+ debug_log("Successfully cancelled client stream after error")
346
+ except Exception as cancel_err:
347
+ debug_log(f"Error cancelling client stream after error: {str(cancel_err)}")
348
+ # Re-raise the exception for the worker runner to handle
349
+ # The @work decorator might catch this depending on exit_on_error
350
+ raise
351
+ finally:
352
+ # Basic cleanup within the worker itself (optional, main cleanup in app)
353
+ debug_log("generate_streaming_response worker finished or errored.")
354
+ # Return the full response if successful, otherwise error is raised or cancellation occurred
355
+ # Note: If cancelled, CancelledError is raised, and @work might handle it.
356
+ # If successful, return the response.
357
+ # If error, exception is raised.
358
+ # Let's explicitly return the response on success.
359
+ # If cancelled or error, this return might not be reached.
360
+ if 'full_response' in locals():
361
+ return full_response
362
+ return None # Indicate completion without full response (e.g., error before loop)
134
363
 
135
364
  def ensure_ollama_running() -> bool:
136
365
  """
@@ -193,3 +422,107 @@ def save_settings_to_config(model: str, style: str) -> None:
193
422
  CONFIG["default_model"] = model
194
423
  CONFIG["default_style"] = style
195
424
  save_config(CONFIG)
425
+
426
+ def resolve_model_id(model_id_or_name: str) -> str:
427
+ """
428
+ Resolves a potentially short model ID or display name to the full model ID
429
+ stored in the configuration. Tries multiple matching strategies.
430
+ """
431
+ if not model_id_or_name:
432
+ logger.warning("resolve_model_id called with empty input, returning empty string.")
433
+ return ""
434
+
435
+ input_lower = model_id_or_name.lower().strip()
436
+ logger.info(f"Attempting to resolve model identifier: '{input_lower}'")
437
+
438
+ available_models = CONFIG.get("available_models", {})
439
+ if not available_models:
440
+ logger.warning("No available_models found in CONFIG to resolve against.")
441
+ return model_id_or_name # Return original if no models to check
442
+
443
+ # 1. Check if the input is already a valid full ID (must contain a date suffix)
444
+ # Full Claude IDs should have format like "claude-3-opus-20240229" with a date suffix
445
+ for full_id in available_models:
446
+ if full_id.lower() == input_lower:
447
+ # Only consider it a full ID if it contains a date suffix (like -20240229)
448
+ if "-202" in full_id: # Check for date suffix
449
+ logger.info(f"Input '{model_id_or_name}' is already a full ID with date suffix: '{full_id}'.")
450
+ return full_id # Return the canonical full_id
451
+ else:
452
+ logger.warning(f"Input '{model_id_or_name}' matches a model ID but lacks date suffix.")
453
+ # Continue searching for a better match with date suffix
454
+
455
+ logger.debug(f"Input '{input_lower}' is not a direct full ID match. Checking other criteria...")
456
+ logger.debug(f"Available models for matching: {list(available_models.keys())}")
457
+
458
+ best_match = None
459
+ match_type = "None"
460
+
461
+ # 2. Iterate through available models for other matches
462
+ for full_id, model_info in available_models.items():
463
+ full_id_lower = full_id.lower()
464
+ display_name = model_info.get("display_name", "")
465
+ display_name_lower = display_name.lower()
466
+
467
+ logger.debug(f"Comparing '{input_lower}' against '{full_id_lower}' (Display: '{display_name}')")
468
+
469
+ # 2a. Exact match on display name (case-insensitive)
470
+ if display_name_lower == input_lower:
471
+ logger.info(f"Resolved '{model_id_or_name}' to '{full_id}' via exact display name match.")
472
+ return full_id # Exact display name match is high confidence
473
+
474
+ # 2b. Check if input is a known short alias (handle common cases explicitly)
475
+ # Special case for Claude 3.7 Sonnet which seems to be causing issues
476
+ if input_lower == "claude-3.7-sonnet":
477
+ # Hardcoded resolution for this specific model
478
+ claude_37_id = "claude-3-7-sonnet-20250219"
479
+ logger.warning(f"Special case: Directly mapping '{input_lower}' to '{claude_37_id}'")
480
+ # Check if this ID exists in available models
481
+ for model_id in available_models:
482
+ if model_id.lower() == claude_37_id.lower():
483
+ logger.info(f"Found exact match for hardcoded ID: {model_id}")
484
+ return model_id
485
+ # If not found in available models, return the hardcoded ID anyway
486
+ logger.warning(f"Hardcoded ID '{claude_37_id}' not found in available models, returning it anyway")
487
+ return claude_37_id
488
+
489
+ # Map common short names to their expected full ID prefixes
490
+ short_aliases = {
491
+ "claude-3-opus": "claude-3-opus-",
492
+ "claude-3-sonnet": "claude-3-sonnet-",
493
+ "claude-3-haiku": "claude-3-haiku-",
494
+ "claude-3.5-sonnet": "claude-3-5-sonnet-", # Note the dot vs hyphen
495
+ "claude-3.7-sonnet": "claude-3-7-sonnet-" # Added this specific case
496
+ }
497
+ if input_lower in short_aliases and full_id_lower.startswith(short_aliases[input_lower]):
498
+ logger.info(f"Resolved '{model_id_or_name}' to '{full_id}' via known short alias match.")
499
+ # This is also high confidence
500
+ return full_id
501
+
502
+ # 2c. Check if input is a prefix of the full ID (more general, lower confidence)
503
+ if full_id_lower.startswith(input_lower):
504
+ logger.debug(f"Potential prefix match: '{input_lower}' vs '{full_id_lower}'")
505
+ # Don't return immediately, might find a better match (e.g., display name or alias)
506
+ if best_match is None: # Only take prefix if no other match found yet
507
+ best_match = full_id
508
+ match_type = "Prefix"
509
+ logger.debug(f"Setting best_match to '{full_id}' based on prefix.")
510
+
511
+ # 2d. Check derived short name from display name (less reliable, keep as lower priority)
512
+ # Normalize display name: lower, replace space and dot with hyphen
513
+ derived_short_name = display_name_lower.replace(" ", "-").replace(".", "-")
514
+ if derived_short_name == input_lower:
515
+ logger.debug(f"Potential derived short name match: '{input_lower}' vs derived '{derived_short_name}' from '{display_name}'")
516
+ # Prioritize this over a simple prefix match if found
517
+ if best_match is None or match_type == "Prefix":
518
+ best_match = full_id
519
+ match_type = "Derived Short Name"
520
+ logger.debug(f"Updating best_match to '{full_id}' based on derived name.")
521
+
522
+ # 3. Return best match found or original input
523
+ if best_match:
524
+ logger.info(f"Returning best match found for '{model_id_or_name}': '{best_match}' (Type: {match_type})")
525
+ return best_match
526
+ else:
527
+ logger.warning(f"Could not resolve model ID or name '{model_id_or_name}' to any known full ID. Returning original.")
528
+ return model_id_or_name
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chat-console
3
- Version: 0.2.8
3
+ Version: 0.2.98
4
4
  Summary: A command-line interface for chatting with LLMs, storing chats and (future) rag interactions
5
5
  Home-page: https://github.com/wazacraftrfid/chat-console
6
6
  Author: Johnathan Greenaway