New functionality++ Verision bump

This commit is contained in:
2025-12-23 15:08:20 +01:00
parent d4e43e6cb2
commit 4c95a18678
3 changed files with 235 additions and 116 deletions

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ build*
compiled/
images/oai-iOS-Default-1024x1024@1x.png
images/oai.icon/
b0.sh

345
oai.py
View File

@@ -20,11 +20,17 @@ import re
import sqlite3
import json
import datetime
import logging # Added missing import for logging
import logging
from logging.handlers import RotatingFileHandler # Added for log rotation
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from rich.logging import RichHandler
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from packaging import version as pkg_version
import io # Added for custom handler
# App version. Changes by author with new releases.
version = '1.9.5'
app = typer.Typer()
@@ -71,27 +77,9 @@ LOW_CREDIT_RATIO = 0.1 # Warn if credits left < 10% of total
LOW_CREDIT_AMOUNT = 1.0 # Warn if credits left < $1 in absolute terms
HIGH_COST_WARNING = "cost_warning_threshold" # Configurable key for cost threshold, default $0.01
# Setup Rich-powered logging to file as per [rich.readthedocs.io](https://rich.readthedocs.io/en/latest/logging.html)
log_console = Console(file=open(log_file, 'a'), width=120) # Append to log file, wider for tracebacks
handler = RichHandler(
console=log_console,
level=logging.INFO,
rich_tracebacks=True,
tracebacks_suppress=['requests', 'openrouter', 'urllib3', 'httpx', 'openai'] # Suppress irrelevant tracebacks for cleaner logs
)
logging.basicConfig(
level=logging.NOTSET, # Let handler control what gets written
format="%(message)s", # Rich formats it
datefmt="[%X]",
handlers=[handler]
)
app_logger = logging.getLogger("oai_app")
app_logger.setLevel(logging.INFO)
# DB configuration
database = config_dir / 'oai_config.db'
DB_FILE = str(database)
version = '1.9'
def create_table_if_not_exists():
"""Ensure the config and conversation_sessions tables exist."""
@@ -122,6 +110,124 @@ def set_config(key: str, value: str):
conn.execute('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', (key, value))
conn.commit()
# ============================================================================
# ROTATING RICH HANDLER - Combines RotatingFileHandler with Rich formatting
# ============================================================================
class RotatingRichHandler(RotatingFileHandler):
"""Custom handler that combines RotatingFileHandler with Rich formatting."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Create a Rich console that writes to a string buffer
self.rich_console = Console(file=io.StringIO(), width=120, force_terminal=False)
self.rich_handler = RichHandler(
console=self.rich_console,
show_time=True,
show_path=True,
rich_tracebacks=True,
tracebacks_suppress=['requests', 'openrouter', 'urllib3', 'httpx', 'openai']
)
def emit(self, record):
try:
# Let RichHandler format the record
self.rich_handler.emit(record)
# Get the formatted output from the string buffer
output = self.rich_console.file.getvalue()
# Clear the buffer for next use
self.rich_console.file.seek(0)
self.rich_console.file.truncate(0)
# Write the Rich-formatted output to our rotating file
if output:
self.stream.write(output)
self.flush()
except Exception:
self.handleError(record)
# Get log configuration from DB
LOG_MAX_SIZE_MB = int(get_config('log_max_size_mb') or "10")
LOG_BACKUP_COUNT = int(get_config('log_backup_count') or "2")
# Create the custom rotating handler
app_handler = RotatingRichHandler(
filename=str(log_file),
maxBytes=LOG_MAX_SIZE_MB * 1024 * 1024, # Convert MB to bytes
backupCount=LOG_BACKUP_COUNT,
encoding='utf-8'
)
logging.basicConfig(
level=logging.NOTSET,
format="%(message)s", # Rich formats it
datefmt="[%X]",
handlers=[app_handler]
)
app_logger = logging.getLogger("oai_app")
app_logger.setLevel(logging.INFO)
# ============================================================================
# END OF LOGGING SETUP
# ============================================================================
logger = logging.getLogger(__name__)
def check_for_updates(current_version: str) -> str:
"""
Check if a new version is available using semantic versioning.
Returns:
Formatted status string for display
"""
try:
response = requests.get(
'https://gitlab.pm/api/v1/repos/rune/oai/releases/latest',
headers={"Content-Type": "application/json"},
timeout=1.0,
allow_redirects=True
)
response.raise_for_status()
data = response.json()
version_online = data.get('tag_name', '').lstrip('v')
if not version_online:
logger.warning("No version found in API response")
return f"[bold green]oAI version {current_version}[/]"
current = pkg_version.parse(current_version)
latest = pkg_version.parse(version_online)
if latest > current:
logger.info(f"Update available: {current_version}{version_online}")
return f"[bold green]oAI version {current_version} [/][bold red](Update available: {current_version}{version_online})[/]"
else:
logger.debug(f"Already up to date: {current_version}")
return f"[bold green]oAI version {current_version} (up to date)[/]"
except requests.exceptions.HTTPError as e:
logger.warning(f"HTTP error checking for updates: {e.response.status_code}")
return f"[bold green]oAI version {current_version}[/]"
except requests.exceptions.ConnectionError:
logger.warning("Network error checking for updates (offline?)")
return f"[bold green]oAI version {current_version}[/]"
except requests.exceptions.Timeout:
logger.warning("Timeout checking for updates")
return f"[bold green]oAI version {current_version}[/]"
except requests.exceptions.RequestException as e:
logger.warning(f"Request error checking for updates: {type(e).__name__}")
return f"[bold green]oAI version {current_version}[/]"
except (KeyError, ValueError) as e:
logger.warning(f"Invalid API response checking for updates: {e}")
return f"[bold green]oAI version {current_version}[/]"
except Exception as e:
logger.error(f"Unexpected error checking for updates: {e}")
return f"[bold green]oAI version {current_version}[/]"
def save_conversation(name: str, data: List[Dict[str, str]]):
"""Save conversation history to DB."""
timestamp = datetime.datetime.now().isoformat()
@@ -309,6 +415,7 @@ STREAM_ENABLED = get_config('stream_enabled') or "on"
DEFAULT_MODEL_ID = get_config('default_model')
MAX_TOKEN = int(get_config('max_token') or "100000")
COST_WARNING_THRESHOLD = float(get_config(HIGH_COST_WARNING) or "0.01") # Configurable cost threshold for alerts
DEFAULT_ONLINE_MODE = get_config('default_online_mode') or "off" # New: Default online mode setting
# Fetch models with app identification headers
models_data = []
@@ -416,16 +523,15 @@ def display_paginated_table(table: Table, title: str):
return
# Separate header from data rows
# Typically the first 3 lines are: top border, header row, separator
header_lines = []
data_lines = []
# Find where the header ends (usually after the first horizontal line after header text)
# Find where the header ends
header_end_index = 0
found_header_text = False
for i, line_segments in enumerate(all_lines):
# Check if this line contains header-style text (bold/magenta usually)
# Check if this line contains header-style text
has_header_style = any(
seg.style and ('bold' in str(seg.style) or 'magenta' in str(seg.style))
for seg in line_segments
@@ -437,21 +543,20 @@ def display_paginated_table(table: Table, title: str):
# After finding header text, the next line with box-drawing chars is the separator
if found_header_text and i > 0:
line_text = ''.join(seg.text for seg in line_segments)
# Check for horizontal line characters (─ ━ ╌ etc.)
if any(char in line_text for char in ['', '', '', '', '', '']):
header_end_index = i
break
# If we found a header separator, split there
if header_end_index > 0:
header_lines = all_lines[:header_end_index + 1] # Include the separator
header_lines = all_lines[:header_end_index + 1]
data_lines = all_lines[header_end_index + 1:]
else:
# Fallback: assume first 3 lines are header
header_lines = all_lines[:min(3, len(all_lines))]
data_lines = all_lines[min(3, len(all_lines)):]
# Calculate how many data lines fit per page (accounting for header)
# Calculate how many data lines fit per page
lines_per_page = terminal_height - len(header_lines)
# Display with pagination
@@ -469,7 +574,7 @@ def display_paginated_table(table: Table, title: str):
for line_segments in header_lines:
for segment in line_segments:
console.print(segment.text, style=segment.style, end="")
console.print() # New line after each row
console.print()
# Calculate how many data lines to show on this page
end_line = min(current_line + lines_per_page, len(data_lines))
@@ -478,7 +583,7 @@ def display_paginated_table(table: Table, title: str):
for line_segments in data_lines[current_line:end_line]:
for segment in line_segments:
console.print(segment.text, style=segment.style, end="")
console.print() # New line after each row
console.print()
# Update position
current_line = end_line
@@ -492,20 +597,16 @@ def display_paginated_table(table: Table, title: str):
import tty
import termios
# Save terminal settings
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
# Set terminal to raw mode to read single character
tty.setraw(fd)
char = sys.stdin.read(1)
# If not space, break pagination
if char != ' ':
break
finally:
# Restore terminal settings
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
except:
# Fallback for Windows or if termios not available
@@ -513,12 +614,11 @@ def display_paginated_table(table: Table, title: str):
if input_char != '':
break
else:
# No more content
break
@app.command()
def chat():
global API_KEY, OPENROUTER_BASE_URL, STREAM_ENABLED, MAX_TOKEN, COST_WARNING_THRESHOLD
global API_KEY, OPENROUTER_BASE_URL, STREAM_ENABLED, MAX_TOKEN, COST_WARNING_THRESHOLD, DEFAULT_ONLINE_MODE, LOG_MAX_SIZE_MB, LOG_BACKUP_COUNT
session_max_token = 0
session_system_prompt = ""
session_history = []
@@ -531,9 +631,9 @@ def chat():
conversation_memory_enabled = True # Memory ON by default
memory_start_index = 0 # Track when memory was last enabled
saved_conversations_cache = [] # Cache for /list results to use with /load by number
online_mode_enabled = False # Online mode (web search) disabled by default
online_mode_enabled = DEFAULT_ONLINE_MODE == "on" # Initialize from config
app_logger.info("Starting new chat session with memory enabled") # Log session start
app_logger.info("Starting new chat session with memory enabled")
if not API_KEY:
console.print("[bold red]API key not found. Use '/config api'.[/]")
@@ -567,7 +667,8 @@ def chat():
client = OpenRouter(api_key=API_KEY)
if selected_model:
console.print(f"[bold blue]Welcome to oAI![/] [bold red]Active model: {selected_model['name']}[/]")
online_status = "enabled" if online_mode_enabled else "disabled"
console.print(f"[bold blue]Welcome to oAI![/] [bold red]Active model: {selected_model['name']}[/] [dim cyan](Online mode: {online_status})[/]")
else:
console.print("[bold blue]Welcome to oAI![/] [italic blue]Select a model with '/model'.[/]")
@@ -584,14 +685,11 @@ def chat():
# Handle // escape sequence - convert to single / and treat as regular text
if user_input.startswith("//"):
user_input = user_input[1:] # Remove first slash, keep the rest
# Don't process as command, jump to message processing
# Check for unknown commands (starts with / but not a valid command)
# Check for unknown commands
elif user_input.startswith("/") and user_input.lower() not in ["exit", "quit", "bye"]:
# Extract command (first word after /)
command_word = user_input.split()[0].lower() if user_input.split() else user_input.lower()
# Check if it's a valid command or partial match
if not any(command_word.startswith(cmd) for cmd in VALID_COMMANDS):
console.print(f"[bold red]Unknown command: {command_word}[/]")
console.print("[bold yellow]Type /help to see all available commands.[/]")
@@ -600,7 +698,7 @@ def chat():
if user_input.lower() in ["exit", "quit", "bye"]:
total_tokens = total_input_tokens + total_output_tokens
app_logger.info(f"Session ended. Total messages: {message_count}, Total tokens: {total_tokens}, Total cost: ${total_cost:.4f}") # Log session summary
app_logger.info(f"Session ended. Total messages: {message_count}, Total tokens: {total_tokens}, Total cost: ${total_cost:.4f}")
console.print("[bold yellow]Goodbye![/]")
return
@@ -618,7 +716,9 @@ def chat():
args = user_input[8:].strip()
if not args:
status = "enabled" if online_mode_enabled else "disabled"
default_status = "enabled" if DEFAULT_ONLINE_MODE == "on" else "disabled"
console.print(f"[bold blue]Online mode (web search) {status}.[/]")
console.print(f"[dim blue]Default setting: {default_status} (use '/config online on|off' to change)[/]")
if selected_model:
if supports_online_mode(selected_model):
console.print(f"[dim green]Current model '{selected_model['name']}' supports online mode.[/]")
@@ -635,12 +735,12 @@ def chat():
app_logger.warning(f"Online mode activation failed - model {selected_model['id']} doesn't support it")
continue
online_mode_enabled = True
console.print("[bold green]Online mode enabled. Model will use web search capabilities.[/]")
console.print("[bold green]Online mode enabled for this session. Model will use web search capabilities.[/]")
console.print(f"[dim blue]Effective model ID: {get_effective_model_id(selected_model['id'], True)}[/]")
app_logger.info(f"Online mode enabled for model {selected_model['id']}")
elif args.lower() == "off":
online_mode_enabled = False
console.print("[bold green]Online mode disabled. Model will not use web search.[/]")
console.print("[bold green]Online mode disabled for this session. Model will not use web search.[/]")
if selected_model:
console.print(f"[dim blue]Effective model ID: {selected_model['id']}[/]")
app_logger.info("Online mode disabled")
@@ -660,7 +760,7 @@ def chat():
continue
if args.lower() == "on":
conversation_memory_enabled = True
memory_start_index = len(session_history) # Remember where we started
memory_start_index = len(session_history)
console.print("[bold green]Conversation memory enabled. Will remember conversations from this point forward.[/]")
console.print(f"[dim blue]Memory will track messages starting from index {memory_start_index}.[/]")
app_logger.info(f"Conversation memory enabled at index {memory_start_index}")
@@ -673,7 +773,6 @@ def chat():
console.print("[bold yellow]Usage: /memory on|off (or /memory to view status)[/]")
continue
elif user_input.lower().startswith("/paste"):
# Get optional prompt after /paste
optional_prompt = user_input[7:].strip()
try:
@@ -688,13 +787,10 @@ def chat():
app_logger.warning("Paste attempted with empty clipboard")
continue
# Validate it's text (check if it's valid UTF-8 and printable)
try:
# Try to encode/decode to ensure it's valid text
clipboard_content.encode('utf-8')
# Show preview of pasted content
preview_lines = clipboard_content.split('\n')[:10] # First 10 lines
preview_lines = clipboard_content.split('\n')[:10]
preview_text = '\n'.join(preview_lines)
if len(clipboard_content.split('\n')) > 10:
preview_text += "\n... (content truncated for preview)"
@@ -709,7 +805,6 @@ def chat():
border_style="cyan"
))
# Build the final prompt
if optional_prompt:
final_prompt = f"{optional_prompt}\n\n```\n{clipboard_content}\n```"
console.print(f"[dim blue]Sending with prompt: '{optional_prompt}'[/]")
@@ -717,7 +812,6 @@ def chat():
final_prompt = clipboard_content
console.print("[dim blue]Sending clipboard content without additional prompt[/]")
# Set user_input to the pasted content so it gets processed normally
user_input = final_prompt
app_logger.info(f"Pasted content from clipboard: {char_count} chars, {line_count} lines, with prompt: {bool(optional_prompt)}")
@@ -745,13 +839,11 @@ def chat():
console.print("[bold red]No conversation history to export.[/]")
continue
# Validate format
if export_format not in ['md', 'json', 'html']:
console.print("[bold red]Invalid format. Use: md, json, or html[/]")
continue
try:
# Generate export content
if export_format == 'md':
content = export_as_markdown(session_history, session_system_prompt)
elif export_format == 'json':
@@ -759,7 +851,6 @@ def chat():
elif export_format == 'html':
content = export_as_html(session_history, session_system_prompt)
# Write to file
export_path = Path(filename).expanduser()
with open(export_path, 'w', encoding='utf-8') as f:
f.write(content)
@@ -790,7 +881,6 @@ def chat():
console.print("[bold yellow]Tip: Use /list to see numbered conversations[/]")
continue
# Check if input is a number
conversation_name = None
if args.isdigit():
conv_number = int(args)
@@ -811,9 +901,8 @@ def chat():
continue
session_history = loaded_data
current_index = len(session_history) - 1
# When loading, reset memory tracking if memory is enabled
if conversation_memory_enabled:
memory_start_index = 0 # Include all loaded messages in memory
memory_start_index = 0
total_input_tokens = 0
total_output_tokens = 0
total_cost = 0.0
@@ -828,7 +917,6 @@ def chat():
console.print("[bold yellow]Tip: Use /list to see numbered conversations[/]")
continue
# Check if input is a number
conversation_name = None
if args.isdigit():
conv_number = int(args)
@@ -842,7 +930,6 @@ def chat():
else:
conversation_name = args
# Confirm deletion
try:
confirm = typer.confirm(f"Delete conversation '{conversation_name}'? This cannot be undone.", default=False)
if not confirm:
@@ -856,7 +943,6 @@ def chat():
if deleted_count > 0:
console.print(f"[bold green]Conversation '{conversation_name}' deleted ({deleted_count} version(s) removed).[/]")
app_logger.info(f"Conversation '{conversation_name}' deleted - {deleted_count} version(s)")
# Refresh cache if deleted conversation was in it
if saved_conversations_cache:
saved_conversations_cache = [c for c in saved_conversations_cache if c['name'] != conversation_name]
else:
@@ -871,12 +957,10 @@ def chat():
saved_conversations_cache = []
continue
# Update cache for /load command
saved_conversations_cache = conversations
table = Table("No.", "Name", "Messages", "Last Saved", show_header=True, header_style="bold magenta")
for idx, conv in enumerate(conversations, 1):
# Parse ISO timestamp and format it nicely
try:
dt = datetime.datetime.fromisoformat(conv['timestamp'])
formatted_time = dt.strftime('%Y-%m-%d %H:%M:%S')
@@ -899,7 +983,6 @@ def chat():
continue
current_index -= 1
prev_response = session_history[current_index]['response']
# Render as markdown with proper formatting
md = Markdown(prev_response)
console.print(Panel(md, title=f"[bold green]Previous Response ({current_index + 1}/{len(session_history)})[/]", title_align="left"))
app_logger.debug(f"Viewed previous response at index {current_index}")
@@ -910,7 +993,6 @@ def chat():
continue
current_index += 1
next_response = session_history[current_index]['response']
# Render as markdown with proper formatting
md = Markdown(next_response)
console.print(Panel(md, title=f"[bold green]Next Response ({current_index + 1}/{len(session_history)})[/]", title_align="left"))
app_logger.debug(f"Viewed next response at index {current_index}")
@@ -925,7 +1007,6 @@ def chat():
console.print(Panel(table, title="[bold green]Session Cost Summary[/]", title_align="left"))
app_logger.info(f"User viewed stats: {stats}")
# Cost warnings in /stats
warnings = check_credit_alerts(credits)
if warnings:
warning_text = '|'.join(warnings)
@@ -954,7 +1035,7 @@ def chat():
session_history = []
current_index = -1
session_system_prompt = ""
memory_start_index = 0 # Reset memory tracking
memory_start_index = 0
total_input_tokens = 0
total_output_tokens = 0
total_cost = 0.0
@@ -976,7 +1057,6 @@ def chat():
console.print(f"[bold red]Model '{args}' not found.[/]")
continue
# Display model info
pricing = model_to_show.get("pricing", {})
architecture = model_to_show.get("architecture", {})
supported_params = ", ".join(model_to_show.get("supported_parameters", [])) or "None"
@@ -1002,7 +1082,6 @@ def chat():
console.print(Panel(table, title=f"[bold green]Model Info: {model_to_show['name']}[/]", title_align="left"))
continue
# Model selection with Image and Online columns
elif user_input.startswith("/model"):
app_logger.info("User initiated model selection")
args = user_input[7:].strip()
@@ -1014,14 +1093,12 @@ def chat():
console.print(f"[bold red]No models match '{search_term}'. Try '/model'.[/]")
continue
# Create table with Image and Online columns
table = Table("No.", "Name", "ID", "Image", "Online", show_header=True, header_style="bold magenta")
for i, model in enumerate(filtered_models, 1):
image_support = "[green]✓[/green]" if has_image_capability(model) else "[red]✗[/red]"
online_support = "[green]✓[/green]" if supports_online_mode(model) else "[red]✗[/red]"
table.add_row(str(i), model["name"], model["id"], image_support, online_support)
# Use pagination for the table
title = f"[bold green]Available Models ({'All' if not search_term else f'Search: {search_term}'})[/]"
display_paginated_table(table, title)
@@ -1032,14 +1109,19 @@ def chat():
break
if 1 <= choice <= len(filtered_models):
selected_model = filtered_models[choice - 1]
# Disable online mode when switching models (user must re-enable)
if online_mode_enabled:
# Apply default online mode if model supports it
if supports_online_mode(selected_model) and DEFAULT_ONLINE_MODE == "on":
online_mode_enabled = True
console.print(f"[bold cyan]Selected: {selected_model['name']} ({selected_model['id']})[/]")
console.print("[dim cyan]✓ Online mode auto-enabled (default setting). Use '/online off' to disable for this session.[/]")
else:
online_mode_enabled = False
console.print("[dim yellow]Note: Online mode auto-disabled when changing models.[/]")
console.print(f"[bold cyan]Selected: {selected_model['name']} ({selected_model['id']})[/]")
if supports_online_mode(selected_model):
console.print("[dim green]✓ This model supports online mode. Use '/online on' to enable web search.[/]")
app_logger.info(f"Model selected: {selected_model['name']} ({selected_model['id']})")
console.print(f"[bold cyan]Selected: {selected_model['name']} ({selected_model['id']})[/]")
if supports_online_mode(selected_model):
console.print("[dim green]✓ This model supports online mode. Use '/online on' to enable web search.[/]")
app_logger.info(f"Model selected: {selected_model['name']} ({selected_model['id']}), Online: {online_mode_enabled}")
break
console.print("[bold red]Invalid choice. Try again.[/]")
except ValueError:
@@ -1047,7 +1129,6 @@ def chat():
continue
elif user_input.startswith("/maxtoken"):
# (unchanged)
args = user_input[10:].strip()
if not args:
console.print(f"[bold blue]Current session max tokens: {session_max_token}[/]")
@@ -1067,7 +1148,6 @@ def chat():
continue
elif user_input.startswith("/system"):
# (unchanged but added to history view)
args = user_input[8:].strip()
if not args:
if session_system_prompt:
@@ -1085,13 +1165,14 @@ def chat():
elif user_input.startswith("/config"):
args = user_input[8:].strip().lower()
update = check_for_updates(version)
if args == "api":
try:
new_api_key = typer.prompt("Enter new API key")
if new_api_key.strip():
set_config('api_key', new_api_key.strip())
API_KEY = new_api_key.strip()
# Reinitialize client with new API key
client = OpenRouter(api_key=API_KEY)
console.print("[bold green]API key updated![/]")
else:
@@ -1110,7 +1191,7 @@ def chat():
except Exception as e:
console.print(f"[bold red]Error updating URL: {e}[/]")
elif args.startswith("costwarning"):
sub_args = args[11:].strip() # Extract everything after "costwarning"
sub_args = args[11:].strip()
if not sub_args:
console.print(f"[bold blue]Stored cost warning threshold: ${COST_WARNING_THRESHOLD:.4f}[/]")
continue
@@ -1132,6 +1213,43 @@ def chat():
console.print(f"[bold green]Streaming {'enabled' if sub_args == 'on' else 'disabled'}.[/]")
else:
console.print("[bold yellow]Usage: /config stream on|off[/]")
elif args.startswith("log"):
sub_args = args[4:].strip()
if not sub_args:
console.print(f"[bold blue]Current log file size limit: {LOG_MAX_SIZE_MB} MB[/]")
console.print(f"[bold blue]Log backup count: {LOG_BACKUP_COUNT} files[/]")
console.print(f"[dim yellow]Total max disk usage: ~{LOG_MAX_SIZE_MB * (LOG_BACKUP_COUNT + 1)} MB[/]")
continue
try:
new_size_mb = int(sub_args)
if new_size_mb < 1:
console.print("[bold red]Log size must be at least 1 MB.[/]")
continue
if new_size_mb > 100:
console.print("[bold yellow]Warning: Log size > 100MB. Capping at 100MB.[/]")
new_size_mb = 100
set_config('log_max_size_mb', str(new_size_mb))
LOG_MAX_SIZE_MB = new_size_mb
console.print(f"[bold green]Log size limit set to {new_size_mb} MB.[/]")
console.print("[bold yellow]⚠️ Restart the application for this change to take effect.[/]")
app_logger.info(f"Log size limit updated to {new_size_mb} MB (requires restart)")
except ValueError:
console.print("[bold red]Invalid size. Provide a number in MB.[/]")
elif args.startswith("online"):
sub_args = args[7:].strip()
if not sub_args:
current_default = "enabled" if DEFAULT_ONLINE_MODE == "on" else "disabled"
console.print(f"[bold blue]Default online mode: {current_default}[/]")
console.print("[dim yellow]This sets the default for new models. Use '/online on|off' to override in current session.[/]")
continue
if sub_args in ["on", "off"]:
set_config('default_online_mode', sub_args)
DEFAULT_ONLINE_MODE = sub_args
console.print(f"[bold green]Default online mode {'enabled' if sub_args == 'on' else 'disabled'}.[/]")
console.print("[dim blue]Note: This affects new model selections. Current session unchanged.[/]")
app_logger.info(f"Default online mode set to {sub_args}")
else:
console.print("[bold yellow]Usage: /config online on|off[/]")
elif args.startswith("maxtoken"):
sub_args = args[9:].strip()
if not sub_args:
@@ -1163,14 +1281,12 @@ def chat():
console.print(f"[bold red]No models match '{search_term}'. Try without search.[/]")
continue
# Create table with Image and Online columns
table = Table("No.", "Name", "ID", "Image", "Online", show_header=True, header_style="bold magenta")
for i, model in enumerate(filtered_models, 1):
image_support = "[green]✓[/green]" if has_image_capability(model) else "[red]✗[/red]"
online_support = "[green]✓[/green]" if supports_online_mode(model) else "[red]✗[/red]"
table.add_row(str(i), model["name"], model["id"], image_support, online_support)
# Use pagination for the table
title = f"[bold green]Available Models for Default ({'All' if not search_term else f'Search: {search_term}'})[/]"
display_paginated_table(table, title)
@@ -1197,10 +1313,13 @@ def chat():
table.add_row("Base URL", OPENROUTER_BASE_URL or "[Not set]")
table.add_row("DB Path", str(database) or "[Not set]")
table.add_row("Logfile", str(log_file) or "[Not set]")
table.add_row("Log Size Limit", f"{LOG_MAX_SIZE_MB} MB")
table.add_row("Log Backups", str(LOG_BACKUP_COUNT))
table.add_row("Streaming", "Enabled" if STREAM_ENABLED == "on" else "Disabled")
table.add_row("Default Model", DEFAULT_MODEL_ID or "[Not set]")
table.add_row("Current Model", "[Not set]" if selected_model is None else str(selected_model["name"]))
table.add_row("Online Mode", "Enabled" if online_mode_enabled else "Disabled")
table.add_row("Default Online Mode", "Enabled" if DEFAULT_ONLINE_MODE == "on" else "Disabled")
table.add_row("Session Online Mode", "Enabled" if online_mode_enabled else "Disabled")
table.add_row("Max Token", str(MAX_TOKEN))
table.add_row("Session Token", "[Not set]" if session_max_token == 0 else str(session_max_token))
table.add_row("Session System Prompt", session_system_prompt or "[Not set]")
@@ -1222,7 +1341,7 @@ def chat():
table.add_row("Used Credits", "[Unavailable - Check API key]")
table.add_row("Credits Left", "[Unavailable - Check API key]")
console.print(Panel(table, title="[bold green]Current Configurations[/]", title_align="left", subtitle="[bold green]oAI Version %s" % version, subtitle_align="right"))
console.print(Panel(table, title="[bold green]Current Configurations[/]", title_align="left", subtitle="%s" %(update), subtitle_align="right"))
continue
if user_input.lower() == "/credits":
@@ -1250,7 +1369,7 @@ def chat():
if user_input.lower() == "/help":
help_table = Table("Command", "Description", "Example", show_header=True, header_style="bold cyan", width=console.width - 10)
# ===== SESSION COMMANDS =====
# SESSION COMMANDS
help_table.add_row(
"[bold yellow]━━━ SESSION COMMANDS ━━━[/]",
"",
@@ -1278,7 +1397,7 @@ def chat():
)
help_table.add_row(
"/online [on|off]",
"Enable/disable online mode (web search) for current model. Only works with models that support tools.",
"Enable/disable online mode (web search) for current session. Overrides default setting.",
"/online on\n/online off"
)
help_table.add_row(
@@ -1302,7 +1421,7 @@ def chat():
"/retry"
)
# ===== MODEL COMMANDS =====
# MODEL COMMANDS
help_table.add_row(
"[bold yellow]━━━ MODEL COMMANDS ━━━[/]",
"",
@@ -1319,7 +1438,7 @@ def chat():
"/model\n/model gpt"
)
# ===== CONFIGURATION COMMANDS (ALPHABETICAL) =====
# CONFIGURATION COMMANDS
help_table.add_row(
"[bold yellow]━━━ CONFIGURATION ━━━[/]",
"",
@@ -1340,6 +1459,11 @@ def chat():
"Set the cost warning threshold. Alerts when response exceeds this cost (in USD).",
"/config costwarning 0.05"
)
help_table.add_row(
"/config log [size_mb]",
"Set log file size limit in MB. Older logs are rotated automatically. Requires restart.",
"/config log 20"
)
help_table.add_row(
"/config maxtoken [value]",
"Set stored max token limit (persisted in DB). View current if no value provided.",
@@ -1350,6 +1474,11 @@ def chat():
"Set default model that loads on startup. Shows image and online capabilities. Doesn't change current session model.",
"/config model gpt"
)
help_table.add_row(
"/config online [on|off]",
"Set default online mode for new model selections. Use '/online on|off' to override current session.",
"/config online on"
)
help_table.add_row(
"/config stream [on|off]",
"Enable or disable response streaming.",
@@ -1361,7 +1490,7 @@ def chat():
"/config url"
)
# ===== TOKEN & SYSTEM COMMANDS =====
# TOKEN & SYSTEM COMMANDS
help_table.add_row(
"[bold yellow]━━━ TOKEN & SYSTEM ━━━[/]",
"",
@@ -1383,7 +1512,7 @@ def chat():
"/system You are a Python expert"
)
# ===== CONVERSATION MANAGEMENT =====
# CONVERSATION MANAGEMENT
help_table.add_row(
"[bold yellow]━━━ CONVERSATION MGMT ━━━[/]",
"",
@@ -1415,7 +1544,7 @@ def chat():
"/save my_chat"
)
# ===== MONITORING & STATS =====
# MONITORING & STATS
help_table.add_row(
"[bold yellow]━━━ MONITORING & STATS ━━━[/]",
"",
@@ -1432,7 +1561,7 @@ def chat():
"/stats"
)
# ===== FILE ATTACHMENTS =====
# INPUT METHODS
help_table.add_row(
"[bold yellow]━━━ INPUT METHODS ━━━[/]",
"",
@@ -1454,7 +1583,7 @@ def chat():
"//help sends '/help' as text"
)
# ===== EXIT =====
# EXIT
help_table.add_row(
"[bold yellow]━━━ EXIT ━━━[/]",
"",
@@ -1514,7 +1643,6 @@ def chat():
# Handle PDFs
elif mime_type == 'application/pdf' or file_ext == '.pdf':
modalities = selected_model.get("architecture", {}).get("input_modalities", [])
# Check for various possible modality indicators for PDFs
supports_pdf = any(mod in modalities for mod in ["document", "pdf", "file"])
if not supports_pdf:
console.print("[bold red]Selected model does not support PDF attachments.[/]")
@@ -1560,13 +1688,10 @@ def chat():
# Build API messages with conversation history if memory is enabled
api_messages = []
# Add system prompt if set
if session_system_prompt:
api_messages.append({"role": "system", "content": session_system_prompt})
# Add conversation history only if memory is enabled (from memory start point onwards)
if conversation_memory_enabled:
# Only include history from when memory was last enabled
for i in range(memory_start_index, len(session_history)):
history_entry = session_history[i]
api_messages.append({
@@ -1578,13 +1703,12 @@ def chat():
"content": history_entry['response']
})
# Add current user message
api_messages.append({"role": "user", "content": message_content})
# Get effective model ID with :online suffix if enabled
effective_model_id = get_effective_model_id(selected_model["id"], online_mode_enabled)
# Build API params with app identification headers (using http_headers)
# Build API params
api_params = {
"model": effective_model_id,
"messages": api_messages,
@@ -1606,17 +1730,17 @@ def chat():
online_status = "ON" if online_mode_enabled else "OFF"
app_logger.info(f"API Request: Model '{effective_model_id}' (Online: {online_status}), Prompt length: {len(text_part)} chars, {file_count} file(s) attached, Memory: {memory_status}, History sent: {history_messages_count} messages, Transforms: middle-out {'enabled' if middle_out_enabled else 'disabled'}, App: {APP_NAME} ({APP_URL}).")
# Send and handle response with metrics and timing
# Send and handle response
is_streaming = STREAM_ENABLED == "on"
if is_streaming:
console.print("[bold green]Streaming response...[/] [dim](Press Ctrl+C to cancel)[/]")
if online_mode_enabled:
console.print("[dim cyan]🌐 Online mode active - model has web search access[/]")
console.print("") # Add spacing before response
console.print("")
else:
console.print("[bold green]Thinking...[/]", end="\r")
start_time = time.time() # Start timing request
start_time = time.time()
try:
response = client.chat.send(**api_params)
app_logger.info(f"API call successful for model '{effective_model_id}'")
@@ -1625,12 +1749,11 @@ def chat():
app_logger.error(f"API Error: {type(e).__name__}: {e}")
continue
response_time = time.time() - start_time # Calculate response time
response_time = time.time() - start_time
full_response = ""
if is_streaming:
try:
# Use Live display for smooth streaming with proper wrapping
with Live("", console=console, refresh_per_second=10, auto_refresh=True) as live:
for chunk in response:
if hasattr(chunk, 'error') and chunk.error:
@@ -1640,11 +1763,9 @@ def chat():
if hasattr(chunk.choices[0].delta, 'content') and chunk.choices[0].delta.content:
content_chunk = chunk.choices[0].delta.content
full_response += content_chunk
# Update live display with markdown rendering
md = Markdown(full_response)
live.update(md)
# Add newline after streaming completes
console.print("")
except KeyboardInterrupt:
@@ -1656,15 +1777,14 @@ def chat():
console.print(f"\r{' ' * 20}\r", end="")
if full_response:
# Render response with proper markdown formatting
if not is_streaming: # Only show panel for non-streaming (streaming already displayed)
if not is_streaming:
md = Markdown(full_response)
console.print(Panel(md, title="[bold green]AI Response[/]", title_align="left", border_style="green"))
session_history.append({'prompt': user_input, 'response': full_response})
current_index = len(session_history) - 1
# Process metrics for per-message display and session tracking
# Process metrics
usage = getattr(response, 'usage', None)
input_tokens = usage.input_tokens if usage and hasattr(usage, 'input_tokens') else 0
output_tokens = usage.output_tokens if usage and hasattr(usage, 'output_tokens') else 0
@@ -1675,10 +1795,9 @@ def chat():
total_cost += msg_cost
message_count += 1
# Log response metrics
app_logger.info(f"Response: Tokens - I:{input_tokens} O:{output_tokens} T:{input_tokens + output_tokens}, Cost: ${msg_cost:.4f}, Time: {response_time:.2f}s, Online: {online_mode_enabled}")
# Per-message metrics display with context info
# Per-message metrics display
if conversation_memory_enabled:
context_count = len(session_history) - memory_start_index
context_info = f", Context: {context_count} msg(s)" if context_count > 1 else ""
@@ -1701,7 +1820,6 @@ def chat():
console.print(f"[bold red]⚠️ {warning_text}[/]")
app_logger.warning(f"Warnings triggered: {warning_text}")
# Add spacing before copy prompt
console.print("")
try:
copy_choice = input("💾 Type 'c' to copy response, or press Enter to continue: ").strip().lower()
@@ -1711,7 +1829,6 @@ def chat():
except (EOFError, KeyboardInterrupt):
pass
# Add spacing after interaction
console.print("")
else:
console.print("[bold red]No response received.[/]")

View File

@@ -14,8 +14,9 @@ markdown2==2.5.4
mdurl==0.1.2
natsort==8.4.0
openrouter==0.0.19
packaging==25.0
pipreqs==0.4.13
prompt_toolkit==3.0.52
prompt-toolkit==3.0.52
Pygments==2.19.2
pyperclip==1.11.0
python-dateutil==2.9.0.post0
@@ -30,7 +31,7 @@ soupsieve==2.8
svgwrite==1.4.3
tqdm==4.67.1
typer==0.20.0
typing_extensions==4.15.0
typing-extensions==4.15.0
urllib3==2.5.0
wavedrom==2.0.3.post3
wcwidth==0.2.14