Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c49339452 | |||
| 8a113a9bbe | |||
| c305d5cf49 | |||
| 2c9f33868e | |||
| d4f1a1c6a4 |
21
README.md
21
README.md
@@ -101,24 +101,10 @@ Download platform-specific binaries:
|
|||||||
# Extract and install
|
# Extract and install
|
||||||
unzip oai_vx.x.x_mac_arm64.zip # or `oai_vx.x.x-linux-x86_64.zip`
|
unzip oai_vx.x.x_mac_arm64.zip # or `oai_vx.x.x-linux-x86_64.zip`
|
||||||
chmod +x oai
|
chmod +x oai
|
||||||
mkdir -p ~/.local/bin
|
mkdir -p ~/.local/bin # Remember to add this to your path. Or just move to folder already in your $PATH
|
||||||
mv oai ~/.local/bin/
|
mv oai ~/.local/bin/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 3: Build Your Own Binary
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install build dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install nuitka ordered-set zstandard
|
|
||||||
|
|
||||||
# Run build script
|
|
||||||
chmod +x build.sh
|
|
||||||
./build.sh
|
|
||||||
|
|
||||||
# Binary will be in dist/oai
|
|
||||||
cp dist/oai ~/.local/bin/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Alternative: Shell Alias
|
### Alternative: Shell Alias
|
||||||
|
|
||||||
@@ -508,6 +494,7 @@ Full license: https://opensource.org/licenses/MIT
|
|||||||
|
|
||||||
**Rune Olsen**
|
**Rune Olsen**
|
||||||
|
|
||||||
|
- Homepage: https://ai.fubar.pm/
|
||||||
- Blog: https://blog.rune.pm
|
- Blog: https://blog.rune.pm
|
||||||
- Project: https://iurl.no/oai
|
- Project: https://iurl.no/oai
|
||||||
|
|
||||||
@@ -527,3 +514,7 @@ Contributions welcome! Please:
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Star ⭐ this project if you find it useful!**
|
**Star ⭐ this project if you find it useful!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Did you really read all the way down here? WOW! You deserve a 🍾 🥂!
|
||||||
|
|||||||
128
oai.py
128
oai.py
@@ -313,14 +313,14 @@ Use /help mcp for comprehensive guide with examples.'''
|
|||||||
'notes': 'Without arguments, shows info for the currently selected model. Displays pricing per million tokens, supported modalities (text, image, etc.), and parameter support.'
|
'notes': 'Without arguments, shows info for the currently selected model. Displays pricing per million tokens, supported modalities (text, image, etc.), and parameter support.'
|
||||||
},
|
},
|
||||||
'/model': {
|
'/model': {
|
||||||
'description': 'Select or change the AI model for the current session. Shows image and online capabilities.',
|
'description': 'Select or change the AI model for the current session. Shows image, online, and tool capabilities.',
|
||||||
'usage': '/model [search_term]',
|
'usage': '/model [search_term]',
|
||||||
'examples': [
|
'examples': [
|
||||||
('List all models', '/model'),
|
('List all models', '/model'),
|
||||||
('Search for GPT models', '/model gpt'),
|
('Search for GPT models', '/model gpt'),
|
||||||
('Search for Claude models', '/model claude'),
|
('Search for Claude models', '/model claude'),
|
||||||
],
|
],
|
||||||
'notes': 'Models are numbered for easy selection. The table shows Image (✓ if model accepts images) and Online (✓ if model supports web search) columns.'
|
'notes': 'Models are numbered for easy selection. The table shows Image (✓ if model accepts images), Online (✓ if model supports web search), and Tools (✓ if model supports function calling for MCP).'
|
||||||
},
|
},
|
||||||
'/config': {
|
'/config': {
|
||||||
'description': 'View or modify application configuration settings.',
|
'description': 'View or modify application configuration settings.',
|
||||||
@@ -2586,6 +2586,10 @@ def supports_function_calling(model: Dict[str, Any]) -> bool:
|
|||||||
supported_params = model.get("supported_parameters", [])
|
supported_params = model.get("supported_parameters", [])
|
||||||
return "tools" in supported_params or "functions" in supported_params
|
return "tools" in supported_params or "functions" in supported_params
|
||||||
|
|
||||||
|
def supports_tools(model: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if model supports tools/function calling (same as supports_function_calling)."""
|
||||||
|
return supports_function_calling(model)
|
||||||
|
|
||||||
def check_for_updates(current_version: str) -> str:
|
def check_for_updates(current_version: str) -> str:
|
||||||
"""Check for updates."""
|
"""Check for updates."""
|
||||||
try:
|
try:
|
||||||
@@ -4008,11 +4012,12 @@ def chat():
|
|||||||
console.print(f"[bold red]No models match '{search_term}'. Try '/model'.[/]")
|
console.print(f"[bold red]No models match '{search_term}'. Try '/model'.[/]")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
table = Table("No.", "Name", "ID", "Image", "Online", show_header=True, header_style="bold magenta")
|
table = Table("No.", "Name", "ID", "Image", "Online", "Tools", show_header=True, header_style="bold magenta")
|
||||||
for i, model in enumerate(filtered_models, 1):
|
for i, model in enumerate(filtered_models, 1):
|
||||||
image_support = "[green]✓[/green]" if has_image_capability(model) else "[red]✗[/red]"
|
image_support = "[green]✓[/green]" if has_image_capability(model) else "[red]✗[/red]"
|
||||||
online_support = "[green]✓[/green]" if supports_online_mode(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)
|
tools_support = "[green]✓[/green]" if supports_function_calling(model) else "[red]✗[/red]"
|
||||||
|
table.add_row(str(i), model["name"], model["id"], image_support, online_support, tools_support)
|
||||||
|
|
||||||
title = f"[bold green]Available Models ({'All' if not search_term else f'Search: {search_term}'})[/]"
|
title = f"[bold green]Available Models ({'All' if not search_term else f'Search: {search_term}'})[/]"
|
||||||
display_paginated_table(table, title)
|
display_paginated_table(table, title)
|
||||||
@@ -4215,11 +4220,12 @@ def chat():
|
|||||||
console.print(f"[bold red]No models match '{search_term}'. Try without search.[/]")
|
console.print(f"[bold red]No models match '{search_term}'. Try without search.[/]")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
table = Table("No.", "Name", "ID", "Image", "Online", show_header=True, header_style="bold magenta")
|
table = Table("No.", "Name", "ID", "Image", "Online", "Tools", show_header=True, header_style="bold magenta")
|
||||||
for i, model in enumerate(filtered_models, 1):
|
for i, model in enumerate(filtered_models, 1):
|
||||||
image_support = "[green]✓[/green]" if has_image_capability(model) else "[red]✗[/red]"
|
image_support = "[green]✓[/green]" if has_image_capability(model) else "[red]✗[/red]"
|
||||||
online_support = "[green]✓[/green]" if supports_online_mode(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)
|
tools_support = "[green]✓[/green]" if supports_function_calling(model) else "[red]✗[/red]"
|
||||||
|
table.add_row(str(i), model["name"], model["id"], image_support, online_support, tools_support)
|
||||||
|
|
||||||
title = f"[bold green]Available Models for Default ({'All' if not search_term else f'Search: {search_term}'})[/]"
|
title = f"[bold green]Available Models for Default ({'All' if not search_term else f'Search: {search_term}'})[/]"
|
||||||
display_paginated_table(table, title)
|
display_paginated_table(table, title)
|
||||||
@@ -4641,18 +4647,71 @@ def chat():
|
|||||||
# PROCESS FILE ATTACHMENTS
|
# PROCESS FILE ATTACHMENTS
|
||||||
# ============================================================
|
# ============================================================
|
||||||
text_part = user_input
|
text_part = user_input
|
||||||
for match in re.finditer(r'@([^\s]+)', user_input):
|
file_attachments = []
|
||||||
|
content_blocks = []
|
||||||
|
|
||||||
|
# Smart file detection: Simple pattern + extension validation
|
||||||
|
# This avoids extremely long regex that can cause binary signing issues
|
||||||
|
|
||||||
|
# Common file extensions we support
|
||||||
|
ALLOWED_FILE_EXTENSIONS = {
|
||||||
|
# Code
|
||||||
|
'.py', '.js', '.ts', '.jsx', '.tsx', '.vue', '.java', '.c', '.cpp', '.cc', '.cxx',
|
||||||
|
'.h', '.hpp', '.hxx', '.rb', '.go', '.rs', '.swift', '.kt', '.kts', '.php',
|
||||||
|
'.sh', '.bash', '.zsh', '.fish', '.bat', '.cmd', '.ps1',
|
||||||
|
# Data
|
||||||
|
'.json', '.csv', '.yaml', '.yml', '.toml', '.xml', '.sql', '.db', '.sqlite', '.sqlite3',
|
||||||
|
# Documents
|
||||||
|
'.txt', '.md', '.log', '.conf', '.cfg', '.ini', '.env', '.properties',
|
||||||
|
# Images
|
||||||
|
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg', '.ico',
|
||||||
|
# Archives
|
||||||
|
'.zip', '.tar', '.gz', '.bz2', '.7z', '.rar', '.xz',
|
||||||
|
# Config files
|
||||||
|
'.lock', '.gitignore', '.dockerignore', '.editorconfig', '.eslintrc',
|
||||||
|
'.prettierrc', '.babelrc', '.nvmrc', '.npmrc',
|
||||||
|
# Binary/Compiled
|
||||||
|
'.pyc', '.pyo', '.pyd', '.so', '.dll', '.dylib', '.exe', '.app',
|
||||||
|
'.dmg', '.pkg', '.deb', '.rpm', '.apk', '.ipa',
|
||||||
|
# ML/AI
|
||||||
|
'.pkl', '.pickle', '.joblib', '.npy', '.npz', '.safetensors', '.onnx',
|
||||||
|
'.pt', '.pth', '.ckpt', '.pb', '.tflite', '.mlmodel', '.coreml', '.rknn',
|
||||||
|
# Data formats
|
||||||
|
'.wasm', '.proto', '.graphql', '.graphqls', '.grpc', '.avro', '.parquet',
|
||||||
|
'.orc', '.feather', '.arrow', '.hdf5', '.h5', '.mat', '.r', '.rdata', '.rds',
|
||||||
|
# Other
|
||||||
|
'.pdf', '.class', '.jar', '.war'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Simple pattern: @filepath where filepath starts with ~, /, . or has an extension
|
||||||
|
# Much shorter than listing all extensions in the regex
|
||||||
|
file_pattern = r'(?:^|\s)@([~/\.][\S]+|[\w][\w-]*\.[\w]+)(?=\s|$)'
|
||||||
|
|
||||||
|
for match in re.finditer(file_pattern, user_input, re.IGNORECASE):
|
||||||
file_path = match.group(1)
|
file_path = match.group(1)
|
||||||
|
|
||||||
|
# Get the extension
|
||||||
|
file_ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
|
||||||
|
# Skip if it doesn't start with a path char and has no allowed extension
|
||||||
|
if not file_path.startswith(('/', '~', '.', '\\')):
|
||||||
|
if not file_ext or file_ext not in ALLOWED_FILE_EXTENSIONS:
|
||||||
|
# This looks like a domain name, not a file
|
||||||
|
continue
|
||||||
|
|
||||||
expanded_path = os.path.expanduser(os.path.abspath(file_path))
|
expanded_path = os.path.expanduser(os.path.abspath(file_path))
|
||||||
|
|
||||||
if not os.path.exists(expanded_path) or os.path.isdir(expanded_path):
|
if not os.path.exists(expanded_path) or os.path.isdir(expanded_path):
|
||||||
console.print(f"[bold red]File not found or is a directory: {expanded_path}[/]")
|
console.print(f"[bold red]File not found or is a directory: {expanded_path}[/]")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
file_size = os.path.getsize(expanded_path)
|
file_size = os.path.getsize(expanded_path)
|
||||||
if file_size > 10 * 1024 * 1024:
|
if file_size > 10 * 1024 * 1024:
|
||||||
console.print(f"[bold red]File too large (>10MB): {expanded_path}[/]")
|
console.print(f"[bold red]File too large (>10MB): {expanded_path}[/]")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
mime_type, _ = mimetypes.guess_type(expanded_path)
|
mime_type, _ = mimetypes.guess_type(expanded_path)
|
||||||
file_ext = os.path.splitext(expanded_path)[1].lower()
|
|
||||||
try:
|
try:
|
||||||
with open(expanded_path, 'rb') as f:
|
with open(expanded_path, 'rb') as f:
|
||||||
file_data = f.read()
|
file_data = f.read()
|
||||||
@@ -4698,7 +4757,9 @@ def chat():
|
|||||||
console.print(f"[bold red]Error reading file {expanded_path}: {e}[/]")
|
console.print(f"[bold red]Error reading file {expanded_path}: {e}[/]")
|
||||||
app_logger.error(f"File read error for {expanded_path}: {e}")
|
app_logger.error(f"File read error for {expanded_path}: {e}")
|
||||||
continue
|
continue
|
||||||
text_part = re.sub(r'@([^\s]+)', '', text_part).strip()
|
|
||||||
|
# Remove file attachments from text (use same simple pattern)
|
||||||
|
text_part = re.sub(file_pattern, lambda m: m.group(0)[0] if m.group(0)[0].isspace() else '', text_part).strip()
|
||||||
|
|
||||||
# Build message content
|
# Build message content
|
||||||
if text_part or content_blocks:
|
if text_part or content_blocks:
|
||||||
@@ -4710,6 +4771,7 @@ def chat():
|
|||||||
console.print("[bold red]Prompt cannot be empty.[/]")
|
console.print("[bold red]Prompt cannot be empty.[/]")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# BUILD API MESSAGES
|
# BUILD API MESSAGES
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -4985,9 +5047,12 @@ All queries are read-only. INSERT/UPDATE/DELETE are not allowed."""
|
|||||||
# PROCESS FINAL RESPONSE
|
# PROCESS FINAL RESPONSE
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
full_response = ""
|
full_response = ""
|
||||||
|
stream_interrupted = False
|
||||||
|
|
||||||
if is_streaming:
|
if is_streaming:
|
||||||
try:
|
try:
|
||||||
with Live("", console=console, refresh_per_second=10, auto_refresh=True) as live:
|
with Live("", console=console, refresh_per_second=10, auto_refresh=True) as live:
|
||||||
|
try:
|
||||||
for chunk in response:
|
for chunk in response:
|
||||||
if hasattr(chunk, 'error') and chunk.error:
|
if hasattr(chunk, 'error') and chunk.error:
|
||||||
console.print(f"\n[bold red]Stream error: {chunk.error.message}[/]")
|
console.print(f"\n[bold red]Stream error: {chunk.error.message}[/]")
|
||||||
@@ -4998,13 +5063,52 @@ All queries are read-only. INSERT/UPDATE/DELETE are not allowed."""
|
|||||||
full_response += content_chunk
|
full_response += content_chunk
|
||||||
md = Markdown(full_response)
|
md = Markdown(full_response)
|
||||||
live.update(md)
|
live.update(md)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
stream_interrupted = True
|
||||||
|
console.print("\n[bold yellow]⚠️ Streaming interrupted![/]")
|
||||||
|
app_logger.info("Streaming interrupted by user (Ctrl+C)")
|
||||||
|
except Exception as stream_error:
|
||||||
|
stream_interrupted = True
|
||||||
|
console.print(f"\n[bold red]Stream error: {stream_error}[/]")
|
||||||
|
app_logger.error(f"Stream processing error: {stream_error}")
|
||||||
|
|
||||||
|
if not stream_interrupted:
|
||||||
console.print("")
|
console.print("")
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\n[bold yellow]Streaming cancelled![/]")
|
# Outer interrupt handler (in case inner misses it)
|
||||||
app_logger.info("Streaming cancelled by user")
|
stream_interrupted = True
|
||||||
continue
|
console.print("\n[bold yellow]⚠️ Streaming interrupted![/]")
|
||||||
|
app_logger.info("Streaming interrupted by user (outer)")
|
||||||
|
except Exception as e:
|
||||||
|
stream_interrupted = True
|
||||||
|
console.print(f"\n[bold red]Error during streaming: {e}[/]")
|
||||||
|
app_logger.error(f"Streaming error: {e}")
|
||||||
|
|
||||||
|
# If stream was interrupted, skip processing and continue to next prompt
|
||||||
|
if stream_interrupted:
|
||||||
|
if full_response:
|
||||||
|
console.print(f"\n[dim yellow]Partial response received ({len(full_response)} chars). Discarding...[/]")
|
||||||
|
console.print("[dim blue]💡 Ready for next prompt[/]\n")
|
||||||
|
app_logger.info("Stream cleanup completed, returning to prompt")
|
||||||
|
|
||||||
|
# Force close the response if possible
|
||||||
|
try:
|
||||||
|
if hasattr(response, 'close'):
|
||||||
|
response.close()
|
||||||
|
elif hasattr(response, '__exit__'):
|
||||||
|
response.__exit__(None, None, None)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Recreate client to be safe
|
||||||
|
try:
|
||||||
|
client = OpenRouter(api_key=API_KEY)
|
||||||
|
app_logger.info("Client recreated after stream interruption")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
continue # Now it's safe to continue
|
||||||
else:
|
else:
|
||||||
full_response = response.choices[0].message.content if response.choices else ""
|
full_response = response.choices[0].message.content if response.choices else ""
|
||||||
console.print(f"\r{' ' * 50}\r", end="")
|
console.print(f"\r{' ' * 50}\r", end="")
|
||||||
|
|||||||
Reference in New Issue
Block a user