diff --git a/.Jules/palette.md b/.Jules/palette.md deleted file mode 100644 index ce7844e7..00000000 --- a/.Jules/palette.md +++ /dev/null @@ -1,11 +0,0 @@ -# Palette's Journal - -## 2024-05-24 - Accessibility Patterns in Chat UIs -**Learning:** Chat interfaces often lack keyboard accessibility for message actions (copy, regenerate). -**Action:** Ensure message actions are reachable via keyboard and have proper ARIA labels. - -This journal tracks critical UX/accessibility learnings. Only add entries for specific, reusable insights. - -## YYYY-MM-DD - [Title] -**Learning:** [UX/a11y insight] -**Action:** [How to apply next time] diff --git a/.jules/bolt.md b/.jules/bolt.md deleted file mode 100644 index c86a870d..00000000 --- a/.jules/bolt.md +++ /dev/null @@ -1,3 +0,0 @@ -## 2026-02-15 - [Inefficient Log Streaming] -**Learning:** `pathlib.Path.read_text()` reads the entire file into memory every time. When polling a log file (like `tail -f`), this becomes O(N) per tick (total complexity O(N*Time)) which scales poorly as the log grows. Using `open()` with `readline()` keeps the file handle open and reads only new data (O(1) per tick). -**Action:** Always use incremental reading for log streaming or "tailing" files. Avoid `read_text()` inside loops. diff --git a/.local/worklog b/.local/worklog deleted file mode 100644 index 9a87c22d..00000000 --- a/.local/worklog +++ /dev/null @@ -1,64 +0,0 @@ -# Heidi CLI UI Migration Worklog -## Date: 2026-02-17 -## Branch: feat/ui-work-theme (migrating to feat/ui-packaging) - -### Summary -Successfully migrated Heidi CLI UI from legacy architecture to new Vite-based React application, integrated with Heidi backend API, and established production packaging/release pipeline. - -### Commit Range -Base: bfd38de (Merge pull request #69 - palette-ux-improvements) -Changes: Working tree modifications (not yet committed as single squashed commit) -Estimated files changed: 36 files, +2682/-3648 lines - -### Key Deliverables - -1. **UI Migration (dev_1_ui_migrator)** - - Cloned work UI repo (commit: d512c199) as visual base - - Removed Next.js server.ts, socket.io, SSH dependencies - - Created Vite React app structure (src/main.tsx entry) - - Implemented Heidi API client layer: - - src/api/heidi.ts: health(), listAgents(), listRuns(), getRun(), runOnce(), runLoop(), chat(), cancelRun() - - src/api/stream.ts: SSE streaming with polling fallback - - Migrated components: Sidebar, ChatArea, AgentArea, TerminalArea, SettingsModal, RightSidebar - - Terminal tab: Safe MVP placeholder (no SSH, no socket.io) - -2. **Configuration** - - vite.config.ts: port 3002, strictPort, allowedHosts (heidiai.com.au) - - Proxy routes to backend: /health, /agents, /run, /loop, /chat, /runs, /api - - No direct Gemini/OpenAI keys in browser (all through Heidi backend) - -3. **Packaging & Release (dev_3_packaging_release)** - - UI builds to ui/dist (Vite standard output) - - CLI command: `heidi ui build` (builds with --base=/ui/, copies to ~/.cache/heidi/ui/dist) - - Backend serves SPA at /ui/ with fallback routing - - Package data: pyproject.toml includes ui_dist/**/* in setuptools package-data - - Dist resolution order: HEIDI_UI_DIST env -> HEIDI_HOME/ui/dist -> XDG_CACHE/heidi/ui/dist -> bundled ui_dist - - CI guardrail: GitHub Actions job ui-build (Node 20, npm cache, verifies dist artifacts) - - Git policy: src/heidi_cli/ui_dist/ ignored (line 80 in .gitignore) - -4. **Documentation** - - README.md: Added Web UI section with dev/prod instructions - - Port reference table: 3002 (Vite dev), 7777 (backend) - - CORS/allowedHosts documentation for custom domains - -### Verification Steps Completed -1. ✓ UI builds: npm ci && npm run build → ui/dist/index.html + assets/ -2. ✓ CLI build: heidi ui build --force → ~/.cache/heidi/ui/dist/ -3. ✓ Backend serve: HEIDI_UI_DIST=... heidi serve → /ui/ accessible -4. ✓ Package install: pip install -e '.[dev]' → heidi ui --help works -5. ✓ CI job: .github/workflows/ci.yml includes ui-build job -6. ✓ Git ignore: src/heidi_cli/ui_dist/ properly excluded - -### Notes -- UI assets are built during packaging and included in wheel/sdist -- Source UI in ui/ remains in git (source code) -- Built UI in src/heidi_cli/ui_dist/ is git-ignored but setuptools-included -- Default behavior: heidi serve falls back to bundled ui_dist if no cache built - -### Breaking Changes -None - this is additive. Legacy CLI commands remain functional. - -### Testing Required Before Merge -1. Clean install smoke test (container/fresh venv) -2. Verify no 404s on asset paths with correct base=/ui/ -3. Confirm SPA routing works (refresh on /ui/ doesn't 404) diff --git a/=0.20.0 b/=0.20.0 deleted file mode 100644 index 5d7a75ac..00000000 --- a/=0.20.0 +++ /dev/null @@ -1,24 +0,0 @@ -Defaulting to user installation because normal site-packages is not writeable -Requirement already satisfied: huggingface_hub in /home/ubuntu/.local/lib/python3.12/site-packages (1.4.1) -Requirement already satisfied: filelock in /home/ubuntu/.local/lib/python3.12/site-packages (from huggingface_hub) (3.24.2) -Requirement already satisfied: fsspec>=2023.5.0 in /home/ubuntu/.local/lib/python3.12/site-packages (from huggingface_hub) (2026.2.0) -Requirement already satisfied: hf-xet<2.0.0,>=1.2.0 in /home/ubuntu/.local/lib/python3.12/site-packages (from huggingface_hub) (1.2.0) -Requirement already satisfied: httpx<1,>=0.23.0 in /home/ubuntu/.local/lib/python3.12/site-packages (from huggingface_hub) (0.28.1) -Requirement already satisfied: packaging>=20.9 in /home/ubuntu/.local/lib/python3.12/site-packages (from huggingface_hub) (26.0) -Requirement already satisfied: pyyaml>=5.1 in /usr/lib/python3/dist-packages (from huggingface_hub) (6.0.1) -Requirement already satisfied: shellingham in /home/ubuntu/.local/lib/python3.12/site-packages (from huggingface_hub) (1.5.4) -Requirement already satisfied: tqdm>=4.42.1 in /home/ubuntu/.local/lib/python3.12/site-packages (from huggingface_hub) (4.67.3) -Requirement already satisfied: typer-slim in /home/ubuntu/.local/lib/python3.12/site-packages (from huggingface_hub) (0.24.0) -Requirement already satisfied: typing-extensions>=4.1.0 in /home/ubuntu/.local/lib/python3.12/site-packages (from huggingface_hub) (4.15.0) -Requirement already satisfied: anyio in /home/ubuntu/.local/lib/python3.12/site-packages (from httpx<1,>=0.23.0->huggingface_hub) (4.12.1) -Requirement already satisfied: certifi in /usr/lib/python3/dist-packages (from httpx<1,>=0.23.0->huggingface_hub) (2023.11.17) -Requirement already satisfied: httpcore==1.* in /home/ubuntu/.local/lib/python3.12/site-packages (from httpx<1,>=0.23.0->huggingface_hub) (1.0.9) -Requirement already satisfied: idna in /usr/lib/python3/dist-packages (from httpx<1,>=0.23.0->huggingface_hub) (3.6) -Requirement already satisfied: h11>=0.16 in /home/ubuntu/.local/lib/python3.12/site-packages (from httpcore==1.*->httpx<1,>=0.23.0->huggingface_hub) (0.16.0) -Requirement already satisfied: typer>=0.24.0 in /home/ubuntu/.local/lib/python3.12/site-packages (from typer-slim->huggingface_hub) (0.24.0) -Requirement already satisfied: click>=8.2.1 in /home/ubuntu/.local/lib/python3.12/site-packages (from typer>=0.24.0->typer-slim->huggingface_hub) (8.3.1) -Requirement already satisfied: rich>=12.3.0 in /usr/lib/python3/dist-packages (from typer>=0.24.0->typer-slim->huggingface_hub) (13.7.1) -Requirement already satisfied: annotated-doc>=0.0.2 in /home/ubuntu/.local/lib/python3.12/site-packages (from typer>=0.24.0->typer-slim->huggingface_hub) (0.0.4) -Requirement already satisfied: markdown-it-py>=2.2.0 in /usr/lib/python3/dist-packages (from rich>=12.3.0->typer>=0.24.0->typer-slim->huggingface_hub) (3.0.0) -Requirement already satisfied: pygments<3.0.0,>=2.13.0 in /usr/lib/python3/dist-packages (from rich>=12.3.0->typer>=0.24.0->typer-slim->huggingface_hub) (2.17.2) -Requirement already satisfied: mdurl~=0.1 in /usr/lib/python3/dist-packages (from markdown-it-py>=2.2.0->rich>=12.3.0->typer>=0.24.0->typer-slim->huggingface_hub) (0.1.2) diff --git a/QUICK_START.md b/docs/QUICK_START.md similarity index 100% rename from QUICK_START.md rename to docs/QUICK_START.md diff --git a/docs/api-keys.md b/docs/api-keys.md new file mode 100644 index 00000000..135d69a7 --- /dev/null +++ b/docs/api-keys.md @@ -0,0 +1,511 @@ +# 🔑 Heidi API Keys - Unified Model Access + +> **One API Key to Rule Them All** +> Use a single Heidi API key to access models from any provider - local, HuggingFace, OpenCode, and more! + +--- + +## 📋 **Table of Contents** + +1. [🚀 Quick Start](#-quick-start) - Generate and use your first API key +2. [🔑 API Key Management](#-api-key-management) - Generate, list, and manage keys +3. [🌐 API Usage](#-api-usage) - Use API keys in your applications +4. [🤖 Model Access](#-model-access) - Access different model providers +5. [📊 Rate Limiting](#-rate-limiting) - Understanding usage limits +6. [🔒 Security](#-security) - Best practices for API key security +7. [💼 Integration Examples](#-integration-examples) - Real-world usage examples + +--- + +## 🚀 **Quick Start** + +### **Step 1: Generate Your API Key** +```bash +# Generate a new API key +heidi api generate --name "My App Key" --user "my-user-id" + +# Example output +🔑 API Key: heidik_OjawUC19Lc6a4YfY5WMJTyR4J1nwQNrcSP0fN6MESbo +``` + +### **Step 2: Use It in Your Application** +```python +import requests + +# Set up your request +headers = { + "Authorization": "Bearer heidik_OjawUC19Lc6a4YfY5WMJTyR4J1nwQNrcSP0fN6MESbo", + "Content-Type": "application/json" +} + +data = { + "model": "local://my-model", + "messages": [{"role": "user", "content": "Hello!"}] +} + +# Make the request +response = requests.post( + "http://localhost:8000/v1/chat/completions", + headers=headers, + json=data +) + +print(response.json()) +``` + +### **Step 3: Start Building!** +That's it! You now have a unified API key that works across all Heidi-managed models. + +--- + +## 🔑 **API Key Management** + +### **Generate API Keys** +```bash +# Basic key generation +heidi api generate --name "Production Key" --user "user123" + +# Advanced options +heidi api generate \ + --name "Production Key" \ + --user "user123" \ + --expires 30 \ + --rate-limit 200 \ + --permissions "read,write,admin" +``` + +**Options:** +- `--name`: Descriptive name for the key +- `--user`: User ID (default: "default") +- `--expires`: Days until expiration (optional) +- `--rate-limit`: Requests per minute (default: 100) +- `--permissions`: Comma-separated permissions (default: "read,write") + +### **List API Keys** +```bash +# List all keys for a user +heidi api list --user "user123" + +# Example output +┏━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━┓ +┃ Key ID ┃ Name ┃ Created ┃ Expires ┃ Status ┃ Usage ┃ Rate Limit ┃ +┡━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━┩ +│ 5e83c033... │ Prod Key │ 2024-03-10 │ 2024-04-10 │ ✅ Active │ 1,234 req │ 200/min │ +└─────────────┴──────────┴────────────┴─────────┴───────────┴────────────┴────────────┘ +``` + +### **Revoke API Keys** +```bash +# Revoke a specific key +heidi api revoke "5e83c033-16d1-4eb6-8ad4-f103d5018a64" +``` + +### **View Usage Statistics** +```bash +# Get detailed usage stats +heidi api stats "5e83c033-16d1-4eb6-8ad4-f103d5018a64" + +# Example output +📊 Usage Statistics for 5e83c033... +┌─────────────────┬─────────────────┐ +│ Total Requests │ 1,234 │ +│ Created │ 2024-03-10 │ +│ Last Used │ 2024-03-10 15:30│ +│ Days Active │ 10 │ +│ Avg Daily Usage │ 123.4 requests │ +└─────────────────┴─────────────────┘ +``` + +--- + +## 🌐 **API Usage** + +### **Authentication** +Heidi API uses Bearer token authentication: + +```bash +# Environment variable +export HEIDI_API_KEY=heidik_OjawUC19Lc6a4YfY5WMJTyR4J1nwQNrcSP0fN6MESbo + +# HTTP Header +Authorization: Bearer heidik_OjawUC19Lc6a4YfY5WMJTyR4J1nwQNrcSP0fN6MESbo +``` + +### **API Endpoints** + +#### **Chat Completions** +```bash +curl -X POST http://localhost:8000/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "local://my-model", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +#### **List Models** +```bash +curl -X GET http://localhost:8000/v1/models \ + -H "Authorization: Bearer YOUR_API_KEY" +``` + +#### **Rate Limit Info** +```bash +curl -X GET http://localhost:8000/v1/rate-limit \ + -H "Authorization: Bearer YOUR_API_KEY" +``` + +#### **User Info** +```bash +curl -X GET http://localhost:8000/v1/user/info \ + -H "Authorization: Bearer YOUR_API_KEY" +``` + +--- + +## 🤖 **Model Access** + +Heidi API provides unified access to multiple model providers: + +### **Model Identifier Format** +``` +provider://model-id +``` + +**Available Providers:** +- `local://` - Local models hosted by Heidi +- `hf://` - HuggingFace models +- `opencode://` - OpenCode models +- `heidi://` - Heidi-specific models (defaults to local) + +### **Examples** +```json +{ + "model": "local://my-gpt-model", + "model": "hf://TinyLlama/TinyLlama-1.1B-Chat-v1.0", + "model": "opencode://gpt-4", + "model": "heidi://specialized-model" +} +``` + +### **List Available Models** +```bash +heidi api models + +# Example output +🤖 Local Models: + • local://opencode-gpt-4 - OpenCode's GPT-4 model + • local://my-custom-model - Custom trained model + +🤖 HuggingFace Models: + • hf://TinyLlama/TinyLlama-1.1B-Chat-v1.0 - Small conversational model + • hf://microsoft/DialoGPT-small - Conversational AI model + +📖 Usage Examples: +• Local model: local://my-model +• HuggingFace: hf://model-name +• OpenCode: opencode://gpt-4 +``` + +--- + +## 📊 **Rate Limiting** + +### **How Rate Limiting Works** +- **Per-key rate limiting** - Each API key has its own limits +- **Sliding window** - Requests counted over last 60 seconds +- **Automatic reset** - Limits reset automatically every minute + +### **Rate Limit Headers** +When you make API requests, you'll get rate limit information: + +```http +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 95 +X-RateLimit-Reset: 1647123456 +``` + +### **Handling Rate Limits** +```python +import requests +import time + +def make_api_request_with_retry(api_key, data): + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + while True: + response = requests.post( + "http://localhost:8000/v1/chat/completions", + headers=headers, + json=data + ) + + if response.status_code == 429: + # Rate limited - wait and retry + retry_after = int(response.headers.get('Retry-After', 60)) + print(f"Rate limited. Waiting {retry_after} seconds...") + time.sleep(retry_after) + continue + + response.raise_for_status() + return response.json() +``` + +--- + +## 🔒 **Security Best Practices** + +### **API Key Security** +- ✅ **Store securely** - Use environment variables or secret management +- ✅ **Rotate regularly** - Generate new keys periodically +- ✅ **Limit permissions** - Only grant necessary permissions +- ✅ **Monitor usage** - Check usage stats regularly +- ❌ **Don't commit to code** - Never hardcode API keys +- ❌ **Don't share publicly** - Keep keys private + +### **Environment Variables** +```bash +# Set in your environment +export HEIDI_API_KEY=heidik_OjawUC19Lc6a4YfY5WMJTyR4J1nwQNrcSP0fN6MESbo + +# Use in application +import os +api_key = os.getenv("HEIDI_API_KEY") +``` + +### **Secret Management** +```python +# Using python-dotenv +from dotenv import load_dotenv +import os + +load_dotenv() +api_key = os.getenv("HEIDI_API_KEY") +``` + +### **Key Rotation Strategy** +1. **Generate new key**: `heidi api generate --name "New Key" --user "user123"` +2. **Update application**: Replace old key with new one +3. **Test thoroughly**: Ensure new key works +4. **Revoke old key**: `heidi api revoke "old-key-id"` + +--- + +## 💼 **Integration Examples** + +### **Python Integration** +```python +import requests +import os + +class HeidiClient: + def __init__(self, api_key=None, base_url="http://localhost:8000"): + self.api_key = api_key or os.getenv("HEIDI_API_KEY") + self.base_url = base_url + self.headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + def chat(self, model, messages, **kwargs): + data = {"model": model, "messages": messages, **kwargs} + response = requests.post( + f"{self.base_url}/v1/chat/completions", + headers=self.headers, + json=data + ) + return response.json() + +# Usage +client = HeidiClient() +response = client.chat( + model="local://my-model", + messages=[{"role": "user", "content": "Hello!"}] +) +``` + +### **JavaScript Integration** +```javascript +class HeidiClient { + constructor(apiKey, baseUrl = 'http://localhost:8000') { + this.apiKey = apiKey; + this.baseUrl = baseUrl; + this.headers = { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }; + } + + async chat(model, messages, options = {}) { + const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + model: model, + messages: messages, + ...options + }) + }); + + return response.json(); + } +} + +// Usage +const client = new HeidiClient('your-api-key'); +client.chat('local://my-model', [ + {role: 'user', content: 'Hello!'} +]).then(response => console.log(response)); +``` + +### **cURL Integration** +```bash +#!/bin/bash + +API_KEY="heidik_OjawUC19Lc6a4YfY5WMJTyR4J1nwQNrcSP0fN6MESbo" +BASE_URL="http://localhost:8000" + +# Chat completion +curl -X POST "${BASE_URL}/v1/chat/completions" \ + -H "Authorization: Bearer ${API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "local://my-model", + "messages": [{"role": "user", "content": "Hello!"}] + }' + +# List models +curl -X GET "${BASE_URL}/v1/models" \ + -H "Authorization: Bearer ${API_KEY}" +``` + +### **Docker Integration** +```dockerfile +FROM python:3.9 + +# Set API key as environment variable +ENV HEIDI_API_KEY=heidik_OjawUC19Lc6a4YfY5WMJTyR4J1nwQNrcSP0fN6MESbo + +# Install dependencies +COPY requirements.txt . +RUN pip install -r requirements.txt + +# Copy application +COPY app.py . + +# Run the application +CMD ["python", "app.py"] +``` + +--- + +## 🎯 **Use Cases** + +### **Web Applications** +- **Chat interfaces** - Add AI chat to your web app +- **Content generation** - Generate articles, summaries, etc. +- **Code assistance** - Help with coding tasks + +### **Mobile Apps** +- **AI assistants** - Voice/text AI assistants +- **Translation** - Multi-language support +- **Content moderation** - Automated content filtering + +### **Enterprise Integration** +- **Internal tools** - AI-powered internal applications +- **Customer support** - Automated support responses +- **Data analysis** - AI-powered data insights + +### **Developer Tools** +- **IDE plugins** - AI assistance in code editors +- **CLI tools** - Command-line AI helpers +- **API services** - AI-powered microservices + +--- + +## 🛠️ **Troubleshooting** + +### **Common Issues** + +#### **Invalid API Key** +``` +Error: 401 Unauthorized - Invalid API key +``` +**Solution**: Check your API key and ensure it's active. + +#### **Rate Limited** +``` +Error: 429 Too Many Requests - Rate limit exceeded +``` +**Solution**: Wait and retry, or request higher rate limits. + +#### **Model Not Found** +``` +Error: 404 Not Found - Model not available +``` +**Solution**: Check available models with `heidi api models`. + +#### **Connection Error** +``` +Error: Connection refused +``` +**Solution**: Ensure Heidi API server is running with `heidi api server`. + +### **Getting Help** +```bash +# Check API configuration +heidi api config + +# List available models +heidi api models + +# Check API key status +heidi api list --user "your-user-id" + +# Get help +heidi api --help +``` + +--- + +## 📚 **Additional Resources** + +### **Documentation** +- **API Reference**: `docs/api-reference.md` +- **Model Management**: `docs/model-management.md` +- **Troubleshooting**: `docs/troubleshooting.md` + +### **Examples** +- **Python Client**: `examples/api_demo.py` +- **Web Integration**: `examples/web_integration/` +- **Mobile Integration**: `examples/mobile_integration/` + +### **Community** +- **GitHub Issues**: `https://github.com/heidi-dang/heidi-cli/issues` +- **Discord**: `https://discord.gg/heidi-cli` +- **Documentation**: `https://docs.heidi-cli.com` + +--- + +## 🎉 **Congratulations!** + +You now have a **Heidi API key** that provides unified access to all AI models! + +**What you can do:** +- ✅ **Access any model** with a single API key +- ✅ **Switch providers** without changing your code +- ✅ **Monitor usage** with built-in analytics +- ✅ **Control access** with rate limiting and permissions +- ✅ **Scale easily** with enterprise-grade features + +**Ready to build?** Check out the examples and start integrating! + +--- + +*Last updated: March 2026* +*API version: 1.0.0* +*Heidi CLI version: 0.1.1* diff --git a/formatted-evidence.md b/docs/formatted-evidence.md similarity index 100% rename from formatted-evidence.md rename to docs/formatted-evidence.md diff --git a/sample-config.json b/docs/sample-config.json similarity index 100% rename from sample-config.json rename to docs/sample-config.json diff --git a/verification_retries.png b/docs/verification_retries.png similarity index 100% rename from verification_retries.png rename to docs/verification_retries.png diff --git a/examples/api_demo.py b/examples/api_demo.py new file mode 100644 index 00000000..454f7403 --- /dev/null +++ b/examples/api_demo.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Heidi API Demo + +Demonstrates how to use Heidi API keys to access models from any application. +""" + +import requests +import json +import time + + +class HeidiAPIClient: + """Simple client for Heidi API.""" + + def __init__(self, api_key: str, base_url: str = "http://localhost:8000"): + self.api_key = api_key + self.base_url = base_url.rstrip('/') + self.headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + def chat_completion(self, model: str, messages: list, temperature: float = 1.0, max_tokens: int = None): + """Create a chat completion.""" + url = f"{self.base_url}/v1/chat/completions" + + data = { + "model": model, + "messages": messages, + "temperature": temperature + } + + if max_tokens: + data["max_tokens"] = max_tokens + + response = requests.post(url, headers=self.headers, json=data) + response.raise_for_status() + return response.json() + + def list_models(self): + """List available models.""" + url = f"{self.base_url}/v1/models" + response = requests.get(url, headers=self.headers) + response.raise_for_status() + return response.json() + + def get_rate_limit(self): + """Get rate limit information.""" + url = f"{self.base_url}/v1/rate-limit" + response = requests.get(url, headers=self.headers) + response.raise_for_status() + return response.json() + + def get_user_info(self): + """Get user information.""" + url = f"{self.base_url}/v1/user/info" + response = requests.get(url, headers=self.headers) + response.raise_for_status() + return response.json() + + +def main(): + """Demo the Heidi API.""" + + # Replace with your actual API key + API_KEY = "heidik_OjawUC19Lc6a4YfY5WMJTyR4J1nwQNrcSP0fN6MESbo" + + print("🚀 Heidi API Demo") + print("=" * 50) + + # Initialize client + client = HeidiAPIClient(API_KEY) + + try: + # Get user info + print("\n📋 User Information:") + user_info = client.get_user_info() + print(f"User ID: {user_info['user_id']}") + print(f"Key Name: {user_info['key_name']}") + print(f"Rate Limit: {user_info['rate_limit']} requests/minute") + print(f"Usage Count: {user_info['usage_count']}") + + # Get rate limit info + print("\n📊 Rate Limit:") + rate_limit = client.get_rate_limit() + print(f"Limit: {rate_limit['limit']} requests/minute") + print(f"Used: {rate_limit['used']} requests") + print(f"Remaining: {rate_limit['remaining']} requests") + + # List models + print("\n🤖 Available Models:") + models = client.list_models() + for model in models['data'][:5]: # Show first 5 models + print(f"• {model['id']} - {model.get('name', 'Unknown')}") + + # Test chat completion + print("\n💬 Chat Completion Demo:") + + # Try different models + test_models = [ + "local://opencode-gpt-4", + "hf://TinyLlama/TinyLlama-1.1B-Chat-v1.0" + ] + + for model in test_models: + print(f"\nTesting {model}:") + + messages = [ + {"role": "user", "content": "Hello! Can you tell me a fun fact about space?"} + ] + + try: + response = client.chat_completion( + model=model, + messages=messages, + temperature=0.7, + max_tokens=100 + ) + + content = response['choices'][0]['message']['content'] + usage = response['usage'] + + print(f"✅ Response: {content[:100]}...") + print(f"📊 Usage: {usage['total_tokens']} tokens") + + except Exception as e: + print(f"❌ Error: {e}") + + print("\n🎉 Demo completed successfully!") + + except requests.exceptions.ConnectionError: + print("❌ Connection Error: Make sure the Heidi API server is running") + print("💡 Start the server with: heidi api server") + except Exception as e: + print(f"❌ Error: {e}") + + +if __name__ == "__main__": + main() diff --git a/heidi-cli-landing-page b/heidi-cli-landing-page deleted file mode 160000 index 7afeaf6b..00000000 --- a/heidi-cli-landing-page +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7afeaf6b4bae280b8d3fc59fba80b431e45ce296 diff --git a/fix_ci.sh b/scripts/fix_ci.sh similarity index 100% rename from fix_ci.sh rename to scripts/fix_ci.sh diff --git a/fix_heidid.sh b/scripts/fix_heidid.sh similarity index 100% rename from fix_heidid.sh rename to scripts/fix_heidid.sh diff --git a/fix_ruff.sh b/scripts/fix_ruff.sh similarity index 100% rename from fix_ruff.sh rename to scripts/fix_ruff.sh diff --git a/fix_ruff_autofix.sh b/scripts/fix_ruff_autofix.sh similarity index 100% rename from fix_ruff_autofix.sh rename to scripts/fix_ruff_autofix.sh diff --git a/fix_ruff_final.sh b/scripts/fix_ruff_final.sh similarity index 100% rename from fix_ruff_final.sh rename to scripts/fix_ruff_final.sh diff --git a/fix_toml.sh b/scripts/fix_toml.sh similarity index 100% rename from fix_toml.sh rename to scripts/fix_toml.sh diff --git a/install.ps1 b/scripts/install.ps1 similarity index 100% rename from install.ps1 rename to scripts/install.ps1 diff --git a/install.sh b/scripts/install.sh similarity index 100% rename from install.sh rename to scripts/install.sh diff --git a/test_setup.py b/scripts/test_setup.py similarity index 100% rename from test_setup.py rename to scripts/test_setup.py diff --git a/verify_accessibility.py b/scripts/verify_accessibility.py similarity index 100% rename from verify_accessibility.py rename to scripts/verify_accessibility.py diff --git a/src/heidi_cli/api/__init__.py b/src/heidi_cli/api/__init__.py new file mode 100644 index 00000000..7648812f --- /dev/null +++ b/src/heidi_cli/api/__init__.py @@ -0,0 +1,19 @@ +""" +Heidi API Key Management System + +Provides unified API keys that work across different model providers, +giving users a single key to access all Heidi-managed models. +""" + +from .key_manager import APIKeyManager, get_api_key_manager +from .auth import HeidiAuthenticator, get_authenticator +from .router import APIRouter, get_api_router + +__all__ = [ + "APIKeyManager", + "get_api_key_manager", + "HeidiAuthenticator", + "get_authenticator", + "APIRouter", + "get_api_router" +] diff --git a/src/heidi_cli/api/auth.py b/src/heidi_cli/api/auth.py new file mode 100644 index 00000000..493d79bd --- /dev/null +++ b/src/heidi_cli/api/auth.py @@ -0,0 +1,172 @@ +""" +Heidi API Authentication + +Handles authentication and authorization for Heidi API keys. +""" + +import time +from typing import Dict, Optional, Tuple +from dataclasses import dataclass + +from .key_manager import get_api_key_manager, APIKey +from ..integrations.analytics import UsageAnalytics + + +@dataclass +class AuthResult: + """Result of API key authentication.""" + success: bool + api_key: Optional[APIKey] = None + error_message: Optional[str] = None + rate_limited: bool = False + + +class HeidiAuthenticator: + """Authenticates Heidi API keys and enforces rate limits.""" + + def __init__(self): + self.key_manager = get_api_key_manager() + self.analytics = UsageAnalytics() + self._rate_limit_cache: Dict[str, Dict] = {} + + def authenticate(self, api_key: str, request_info: Dict = None) -> AuthResult: + """Authenticate an API key and check rate limits.""" + + # Validate API key + key_obj = self.key_manager.validate_api_key(api_key) + if not key_obj: + return AuthResult( + success=False, + error_message="Invalid or expired API key" + ) + + # Check rate limits + if self._is_rate_limited(key_obj): + return AuthResult( + success=False, + api_key=key_obj, + error_message="Rate limit exceeded", + rate_limited=True + ) + + # Record successful authentication + self._record_auth_success(key_obj, request_info) + + return AuthResult( + success=True, + api_key=key_obj + ) + + def _is_rate_limited(self, api_key: APIKey) -> bool: + """Check if the API key is rate limited.""" + current_time = time.time() + key_id = api_key.key_id + + # Get or create rate limit entry + if key_id not in self._rate_limit_cache: + self._rate_limit_cache[key_id] = { + "requests": [], + "last_cleanup": current_time + } + + rate_info = self._rate_limit_cache[key_id] + + # Clean old requests (older than 1 minute) + cutoff_time = current_time - 60 + rate_info["requests"] = [ + req_time for req_time in rate_info["requests"] + if req_time > cutoff_time + ] + + # Check rate limit + if len(rate_info["requests"]) >= api_key.rate_limit: + return True + + # Add current request + rate_info["requests"].append(current_time) + + # Cleanup old entries periodically + if current_time - rate_info["last_cleanup"] > 300: # 5 minutes + self._cleanup_rate_limits() + rate_info["last_cleanup"] = current_time + + return False + + def _cleanup_rate_limits(self): + """Clean up old rate limit entries.""" + current_time = time.time() + cutoff_time = current_time - 300 # 5 minutes + + # Remove old entries + old_keys = [ + key_id for key_id, info in self._rate_limit_cache.items() + if info["last_cleanup"] < cutoff_time + ] + + for key_id in old_keys: + del self._rate_limit_cache[key_id] + + def _record_auth_success(self, api_key: APIKey, request_info: Dict = None): + """Record successful authentication for analytics.""" + try: + # Record usage analytics + self.analytics.record_request( + model_id="heidi-api-auth", + request_tokens=0, + response_tokens=0, + response_time_ms=0, + success=True, + metadata={ + "api_key_id": api_key.key_id, + "user_id": api_key.user_id, + "key_name": api_key.name, + "request_info": request_info or {} + } + ) + except Exception: + # Don't fail authentication if analytics fails + pass + + def check_permission(self, api_key: APIKey, permission: str) -> bool: + """Check if the API key has a specific permission.""" + return permission in api_key.permissions + + def get_rate_limit_info(self, api_key: APIKey) -> Dict: + """Get rate limit information for an API key.""" + key_id = api_key.key_id + current_time = time.time() + + if key_id not in self._rate_limit_cache: + return { + "limit": api_key.rate_limit, + "remaining": api_key.rate_limit, + "reset_time": current_time + 60 + } + + rate_info = self._rate_limit_cache[key_id] + + # Count requests in the last minute + cutoff_time = current_time - 60 + recent_requests = [ + req_time for req_time in rate_info["requests"] + if req_time > cutoff_time + ] + + return { + "limit": api_key.rate_limit, + "used": len(recent_requests), + "remaining": max(0, api_key.rate_limit - len(recent_requests)), + "reset_time": current_time + 60 + } + + +# Global instance +_authenticator = None + + +def get_authenticator() -> HeidiAuthenticator: + """Get the global authenticator instance.""" + global _authenticator + if _authenticator is None: + _authenticator = HeidiAuthenticator() + return _authenticator diff --git a/src/heidi_cli/api/cli.py b/src/heidi_cli/api/cli.py new file mode 100644 index 00000000..db8be616 --- /dev/null +++ b/src/heidi_cli/api/cli.py @@ -0,0 +1,257 @@ +""" +Heidi API Key CLI Commands + +Command-line interface for managing Heidi API keys. +""" + +import typer +import json +from typing import Optional, List +from datetime import datetime +from rich.console import Console +from rich.table import Table +from rich.panel import Panel +from rich import print as rprint + +from .key_manager import get_api_key_manager, APIKey +from ..shared.config import ConfigLoader + +console = Console() +api_app = typer.Typer(name="api", help="Heidi API Key Management") + + +def register_api_app(main_app): + """Register the API app with the main CLI app.""" + main_app.add_typer(api_app, name="api") + + +@api_app.command("generate") +def generate_api_key( + name: str = typer.Option(..., "--name", "-n", help="Name for the API key"), + user_id: str = typer.Option("default", "--user", "-u", help="User ID"), + expires_days: Optional[int] = typer.Option(None, "--expires", "-e", help="Days until expiration"), + rate_limit: int = typer.Option(100, "--rate-limit", "-r", help="Requests per minute"), + permissions: Optional[str] = typer.Option("read,write", "--permissions", "-p", help="Permissions (comma-separated)") +): + """Generate a new Heidi API key.""" + + try: + key_manager = get_api_key_manager() + + # Parse permissions + perm_list = [p.strip() for p in permissions.split(",")] + + # Generate API key + api_key = key_manager.generate_api_key( + name=name, + user_id=user_id, + expires_days=expires_days, + rate_limit=rate_limit, + permissions=perm_list + ) + + # Display results + console.print(Panel.fit( + f"[bold green]✅ API Key Generated Successfully![/bold green]\n\n" + f"[bold]Key ID:[/bold] {api_key.key_id}\n" + f"[bold]Name:[/bold] {api_key.name}\n" + f"[bold]User:[/bold] {api_key.user_id}\n" + f"[bold]Created:[/bold] {api_key.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n" + f"[bold]Expires:[/bold] {api_key.expires_at.strftime('%Y-%m-%d %H:%M:%S') if api_key.expires_at else 'Never'}\n" + f"[bold]Rate Limit:[/bold] {api_key.rate_limit} requests/minute\n" + f"[bold]Permissions:[/bold] {', '.join(api_key.permissions)}", + title="🔑 Heidi API Key", + border_style="green" + )) + + # Show the API key (only once!) + console.print("\n[bold yellow]⚠️ Save this API key securely - it will not be shown again![/bold yellow]") + console.print(f"[bold blue]🔑 API Key:[/bold blue] [code]{api_key.api_key}[/code]") + + # Usage instructions + console.print("\n[bold]📖 Usage Instructions:[/bold]") + console.print("1. Use this key with any Heidi-compatible application") + console.print("2. Set as environment variable: [code]export HEIDI_API_KEY=your_key[/code]") + console.print("3. Or pass in Authorization header: [code]Authorization: Bearer your_key[/code]") + + except Exception as e: + console.print(f"[red]❌ Failed to generate API key: {e}[/red]") + raise typer.Exit(1) + + +@api_app.command("list") +def list_api_keys( + user_id: str = typer.Option("default", "--user", "-u", help="User ID") +): + """List all API keys for a user.""" + + try: + key_manager = get_api_key_manager() + keys = key_manager.list_api_keys(user_id) + + if not keys: + console.print(f"[yellow]ℹ️ No API keys found for user: {user_id}[/yellow]") + return + + # Create table + table = Table(title=f"🔑 API Keys for {user_id}") + table.add_column("Key ID", style="cyan") + table.add_column("Name", style="magenta") + table.add_column("Created", style="green") + table.add_column("Expires", style="yellow") + table.add_column("Status", style="red") + table.add_column("Usage", style="blue") + table.add_column("Rate Limit", style="white") + + for key in keys: + status = "✅ Active" if key.is_valid else "❌ Inactive" + expires = key.expires_at.strftime("%Y-%m-%d") if key.expires_at else "Never" + + table.add_row( + key.key_id[:8] + "...", + key.name, + key.created_at.strftime("%Y-%m-%d"), + expires, + status, + f"{key.usage_count} requests", + f"{key.rate_limit}/min" + ) + + console.print(table) + + except Exception as e: + console.print(f"[red]❌ Failed to list API keys: {e}[/red]") + raise typer.Exit(1) + + +@api_app.command("revoke") +def revoke_api_key( + key_id: str = typer.Argument(..., help="API Key ID to revoke") +): + """Revoke an API key.""" + + try: + key_manager = get_api_key_manager() + + if key_manager.revoke_api_key(key_id): + console.print(f"[green]✅ API key {key_id} has been revoked[/green]") + else: + console.print(f"[red]❌ API key {key_id} not found[/red]") + raise typer.Exit(1) + + except Exception as e: + console.print(f"[red]❌ Failed to revoke API key: {e}[/red]") + raise typer.Exit(1) + + +@api_app.command("stats") +def api_key_stats( + key_id: str = typer.Argument(..., help="API Key ID") +): + """Show usage statistics for an API key.""" + + try: + key_manager = get_api_key_manager() + stats = key_manager.get_usage_stats(key_id) + + if not stats: + console.print(f"[red]❌ API key {key_id} not found[/red]") + raise typer.Exit(1) + + # Display stats + console.print(Panel.fit( + f"[bold]📊 Usage Statistics for {key_id}[/bold]\n\n" + f"[bold]Total Requests:[/bold] {stats['usage_count']}\n" + f"[bold]Created:[/bold] {stats['created_at'].strftime('%Y-%m-%d %H:%M:%S')}\n" + f"[bold]Last Used:[/bold] {stats['last_used'].strftime('%Y-%m-%d %H:%M:%S') if stats['last_used'] else 'Never'}\n" + f"[bold]Days Active:[/bold] {stats['days_active']}\n" + f"[bold]Avg Daily Usage:[/bold] {stats['avg_daily_usage']:.2f} requests", + title="📈 API Key Statistics", + border_style="blue" + )) + + except Exception as e: + console.print(f"[red]❌ Failed to get stats: {e}[/red]") + raise typer.Exit(1) + + +@api_app.command("models") +def list_available_models(): + """List all available models that can be accessed with Heidi API keys.""" + + try: + from .router import get_api_router + router = get_api_router() + models = router.list_available_models() + + # Display models by provider + for provider, model_list in models.items(): + if not model_list: + continue + + console.print(f"\n[bold]🤖 {provider.title()} Models:[/bold]") + + for model in model_list: + console.print(f" • [cyan]{model['id']}[/cyan]") + console.print(f" [dim]{model.get('description', 'No description')}[/dim]") + + console.print("\n[bold]📖 Usage Examples:[/bold]") + console.print("• Local model: [code]local://my-model[/code]") + console.print("• HuggingFace: [code]hf://TinyLlama/TinyLlama-1.1B-Chat-v1.0[/code]") + console.print("• OpenCode: [code]opencode://gpt-4[/code]") + + except Exception as e: + console.print(f"[red]❌ Failed to list models: {e}[/red]") + raise typer.Exit(1) + + +@api_app.command("config") +def show_api_config(): + """Show current API configuration.""" + + try: + config = ConfigLoader.load() + + console.print(Panel.fit( + f"[bold]⚙️ Heidi API Configuration[/bold]\n\n" + f"[bold]API Enabled:[/bold] {getattr(config, 'api_enabled', False)}\n" + f"[bold]API Host:[/bold] {getattr(config, 'api_host', '127.0.0.1')}\n" + f"[bold]API Port:[/bold] {getattr(config, 'api_port', 8000)}\n" + f"[bold]Default Rate Limit:[/bold] {getattr(config, 'default_rate_limit', 100)}\n" + f"[bold]Analytics Enabled:[/bold] {getattr(config, 'analytics_enabled', True)}\n" + f"[bold]Token Tracking Enabled:[/bold] {getattr(config, 'token_tracking_enabled', True)}", + title="🔧 API Configuration", + border_style="cyan" + )) + + except Exception as e: + console.print(f"[red]❌ Failed to get configuration: {e}[/red]") + raise typer.Exit(1) + + +@api_app.command("server") +def start_api_server( + host: str = typer.Option("127.0.0.1", "--host", help="Host to bind to"), + port: int = typer.Option(8000, "--port", help="Port to bind to"), + workers: int = typer.Option(1, "--workers", help="Number of worker processes") +): + """Start the Heidi API server.""" + + try: + console.print(f"[green]🚀 Starting Heidi API Server...[/green]") + console.print(f"[blue]🌐 Host:[/blue] {host}") + console.print(f"[blue]🔌 Port:[/blue] {port}") + console.print(f"[blue]👥 Workers:[/blue] {workers}") + + # This would start the actual FastAPI server + # For now, just show the configuration + console.print(f"\n[yellow]⚠️ API server startup not implemented in this demo[/yellow]") + console.print(f"[dim]In production, this would start a FastAPI server with:[/dim]") + console.print(f"[dim]• Authentication middleware[/dim]") + console.print(f"[dim]• Rate limiting[/dim]") + console.print(f"[dim]• Request routing[/dim]") + console.print(f"[dim]• Usage analytics[/dim]") + + except Exception as e: + console.print(f"[red]❌ Failed to start server: {e}[/red]") + raise typer.Exit(1) diff --git a/src/heidi_cli/api/key_manager.py b/src/heidi_cli/api/key_manager.py new file mode 100644 index 00000000..34045974 --- /dev/null +++ b/src/heidi_cli/api/key_manager.py @@ -0,0 +1,283 @@ +""" +Heidi API Key Manager + +Generates and manages custom API keys for unified model access. +Users get a single Heidi API key that works across all model providers. +""" + +import uuid +import hashlib +import secrets +from typing import Dict, List, Optional, Tuple +from datetime import datetime, timedelta +from pathlib import Path +import json +import sqlite3 +from dataclasses import dataclass, asdict + +from ..shared.config import ConfigLoader +from ..runtime.db import db + + +@dataclass +class APIKey: + """Represents a Heidi API key with metadata.""" + key_id: str + api_key: str + name: str + user_id: str + created_at: datetime + expires_at: Optional[datetime] + is_active: bool + rate_limit: int # requests per minute + usage_count: int = 0 + last_used: Optional[datetime] = None + permissions: List[str] = None + metadata: Dict = None + + def __post_init__(self): + if self.permissions is None: + self.permissions = ["read", "write"] + if self.metadata is None: + self.metadata = {} + + @property + def is_expired(self) -> bool: + """Check if the API key has expired.""" + if self.expires_at is None: + return False + return datetime.now() > self.expires_at + + @property + def is_valid(self) -> bool: + """Check if the API key is valid and active.""" + return self.is_active and not self.is_expired + + +class APIKeyManager: + """Manages Heidi API keys for unified model access.""" + + def __init__(self): + self.config = ConfigLoader.load() + self._init_database() + + def _init_database(self): + """Initialize the API keys database table.""" + with db.get_connection() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS api_keys ( + key_id TEXT PRIMARY KEY, + api_key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + rate_limit INTEGER NOT NULL DEFAULT 100, + usage_count INTEGER NOT NULL DEFAULT 0, + last_used TEXT, + permissions TEXT, + metadata TEXT + ) + """) + + # Create indexes + conn.execute("CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active)") + + conn.commit() + + def generate_api_key( + self, + name: str, + user_id: str, + expires_days: Optional[int] = None, + rate_limit: int = 100, + permissions: List[str] = None + ) -> APIKey: + """Generate a new Heidi API key.""" + + # Generate unique key + key_id = str(uuid.uuid4()) + raw_key = f"heidik_{secrets.token_urlsafe(32)}" + api_key = self._hash_api_key(raw_key) + + # Set expiration + expires_at = None + if expires_days: + expires_at = datetime.now() + timedelta(days=expires_days) + + # Create API key object + api_key_obj = APIKey( + key_id=key_id, + api_key=api_key, + name=name, + user_id=user_id, + created_at=datetime.now(), + expires_at=expires_at, + is_active=True, + rate_limit=rate_limit, + permissions=permissions or ["read", "write"] + ) + + # Store in database + self._store_api_key(api_key_obj) + + # Return with raw key (only shown once) + api_key_obj.api_key = raw_key + return api_key_obj + + def _hash_api_key(self, raw_key: str) -> str: + """Hash the API key for secure storage.""" + return hashlib.sha256(raw_key.encode()).hexdigest() + + def _store_api_key(self, api_key: APIKey): + """Store API key in database.""" + with db.get_connection() as conn: + conn.execute(""" + INSERT INTO api_keys ( + key_id, api_key, name, user_id, created_at, expires_at, + is_active, rate_limit, usage_count, last_used, permissions, metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + api_key.key_id, + api_key.api_key, # hashed + api_key.name, + api_key.user_id, + api_key.created_at.isoformat(), + api_key.expires_at.isoformat() if api_key.expires_at else None, + 1 if api_key.is_active else 0, + api_key.rate_limit, + api_key.usage_count, + api_key.last_used.isoformat() if api_key.last_used else None, + json.dumps(api_key.permissions), + json.dumps(api_key.metadata) + )) + conn.commit() + + def validate_api_key(self, api_key: str) -> Optional[APIKey]: + """Validate an API key and return the key object if valid.""" + hashed_key = self._hash_api_key(api_key) + + with db.get_connection() as conn: + cursor = conn.execute(""" + SELECT * FROM api_keys + WHERE api_key = ? AND is_active = 1 + """, (hashed_key,)) + + row = cursor.fetchone() + if not row: + return None + + # Convert to APIKey object + api_key_obj = APIKey( + key_id=row['key_id'], + api_key=hashed_key, + name=row['name'], + user_id=row['user_id'], + created_at=datetime.fromisoformat(row['created_at']), + expires_at=datetime.fromisoformat(row['expires_at']) if row['expires_at'] else None, + is_active=bool(row['is_active']), + rate_limit=row['rate_limit'], + usage_count=row['usage_count'], + last_used=datetime.fromisoformat(row['last_used']) if row['last_used'] else None, + permissions=json.loads(row['permissions']), + metadata=json.loads(row['metadata']) + ) + + # Check if expired + if api_key_obj.is_expired: + return None + + # Update usage stats + self._update_usage(api_key_obj.key_id) + + return api_key_obj + + def _update_usage(self, key_id: str): + """Update usage statistics for an API key.""" + with db.get_connection() as conn: + conn.execute(""" + UPDATE api_keys + SET usage_count = usage_count + 1, + last_used = ? + WHERE key_id = ? + """, (datetime.now().isoformat(), key_id)) + conn.commit() + + def list_api_keys(self, user_id: str) -> List[APIKey]: + """List all API keys for a user.""" + with db.get_connection() as conn: + cursor = conn.execute(""" + SELECT * FROM api_keys + WHERE user_id = ? + ORDER BY created_at DESC + """, (user_id,)) + + keys = [] + for row in cursor.fetchall(): + api_key_obj = APIKey( + key_id=row['key_id'], + api_key=row['api_key'], # hashed + name=row['name'], + user_id=row['user_id'], + created_at=datetime.fromisoformat(row['created_at']), + expires_at=datetime.fromisoformat(row['expires_at']) if row['expires_at'] else None, + is_active=bool(row['is_active']), + rate_limit=row['rate_limit'], + usage_count=row['usage_count'], + last_used=datetime.fromisoformat(row['last_used']) if row['last_used'] else None, + permissions=json.loads(row['permissions']), + metadata=json.loads(row['metadata']) + ) + keys.append(api_key_obj) + + return keys + + def revoke_api_key(self, key_id: str) -> bool: + """Revoke an API key.""" + with db.get_connection() as conn: + cursor = conn.execute(""" + UPDATE api_keys + SET is_active = 0 + WHERE key_id = ? + """, (key_id,)) + conn.commit() + return cursor.rowcount > 0 + + def get_usage_stats(self, key_id: str) -> Dict: + """Get usage statistics for an API key.""" + with db.get_connection() as conn: + cursor = conn.execute(""" + SELECT usage_count, last_used, created_at + FROM api_keys + WHERE key_id = ? + """, (key_id,)) + + row = cursor.fetchone() + if not row: + return {} + + created_at = datetime.fromisoformat(row['created_at']) + last_used = datetime.fromisoformat(row['last_used']) if row['last_used'] else None + + return { + "usage_count": row['usage_count'], + "created_at": created_at, + "last_used": last_used, + "days_active": (datetime.now() - created_at).days, + "avg_daily_usage": row['usage_count'] / max(1, (datetime.now() - created_at).days) + } + + +# Global instance +_api_key_manager = None + + +def get_api_key_manager() -> APIKeyManager: + """Get the global API key manager instance.""" + global _api_key_manager + if _api_key_manager is None: + _api_key_manager = APIKeyManager() + return _api_key_manager diff --git a/src/heidi_cli/api/router.py b/src/heidi_cli/api/router.py new file mode 100644 index 00000000..568b84fb --- /dev/null +++ b/src/heidi_cli/api/router.py @@ -0,0 +1,317 @@ +""" +Heidi API Router + +Routes requests to appropriate model providers based on Heidi API key authentication. +Provides a unified interface for all model access. +""" + +import asyncio +import time +from typing import Dict, List, Optional, Any +from fastapi import HTTPException, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from .key_manager import get_api_key_manager +from .auth import get_authenticator, AuthResult +from ..model_host.manager import ModelManager +from ..integrations.huggingface import get_huggingface_integration +from ..integrations.analytics import UsageAnalytics +from ..token_tracking.models import get_token_database, TokenUsage + + +class APIRouter: + """Routes authenticated requests to appropriate model providers.""" + + def __init__(self): + self.key_manager = get_api_key_manager() + self.authenticator = get_authenticator() + self.model_manager = ModelManager() + self.huggingface = get_huggingface_integration() + self.analytics = UsageAnalytics() + self.token_db = get_token_database() + self.security = HTTPBearer() + + async def route_request( + self, + model: str, + messages: List[Dict[str, str]], + temperature: float = 1.0, + max_tokens: Optional[int] = None, + **kwargs + ) -> Dict[str, Any]: + """Route a request to the appropriate model provider.""" + + # This will be called after authentication + # The actual authentication is handled by FastAPI middleware + + start_time = time.time() + + try: + # Determine model provider and route + provider, model_id = self._parse_model_identifier(model) + + if provider == "local": + response = await self._route_to_local_model( + model_id, messages, temperature, max_tokens, **kwargs + ) + elif provider == "huggingface": + response = await self._route_to_huggingface( + model_id, messages, temperature, max_tokens, **kwargs + ) + elif provider == "opencode": + response = await self._route_to_opencode( + model_id, messages, temperature, max_tokens, **kwargs + ) + else: + raise HTTPException( + status_code=400, + detail=f"Unknown model provider: {provider}" + ) + + # Record usage analytics + end_time = time.time() + response_time_ms = (end_time - start_time) * 1000 + + self._record_usage( + model, messages, response, response_time_ms, True + ) + + return response + + except Exception as e: + # Record failed request + end_time = time.time() + response_time_ms = (end_time - start_time) * 1000 + + self._record_usage( + model, messages, {}, response_time_ms, False, str(e) + ) + + raise HTTPException( + status_code=500, + detail=f"Model request failed: {str(e)}" + ) + + def _parse_model_identifier(self, model: str) -> tuple[str, str]: + """Parse model identifier to determine provider and model ID.""" + + if model.startswith("local://"): + return "local", model[8:] + elif model.startswith("hf://"): + return "huggingface", model[5:] + elif model.startswith("opencode://"): + return "opencode", model[10:] + elif model.startswith("heidi://"): + # Heidi-specific model - route to local by default + return "local", model[8:] + else: + # Default to local for backward compatibility + return "local", model + + async def _route_to_local_model( + self, + model_id: str, + messages: List[Dict[str, str]], + temperature: float, + max_tokens: Optional[int], + **kwargs + ) -> Dict[str, Any]: + """Route request to local model manager.""" + + try: + response = await self.model_manager.get_response( + model_id, messages, temperature=temperature, max_tokens=max_tokens, **kwargs + ) + return response + except Exception as e: + raise HTTPException( + status_code=503, + detail=f"Local model unavailable: {str(e)}" + ) + + async def _route_to_huggingface( + self, + model_id: str, + messages: List[Dict[str, str]], + temperature: float, + max_tokens: Optional[int], + **kwargs + ) -> Dict[str, Any]: + """Route request to HuggingFace model.""" + + try: + # Convert messages to prompt + prompt = self._messages_to_prompt(messages) + + # Use HuggingFace inference API + response = await self.huggingface.generate_text( + model_id, prompt, temperature=temperature, max_tokens=max_tokens + ) + + # Format response like OpenAI + return { + "id": f"hf-{model_id}", + "object": "chat.completion", + "created": int(time.time()), + "model": model_id, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": response + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": len(prompt.split()), + "completion_tokens": len(response.split()), + "total_tokens": len(prompt.split()) + len(response.split()) + } + } + except Exception as e: + raise HTTPException( + status_code=503, + detail=f"HuggingFace model unavailable: {str(e)}" + ) + + async def _route_to_opencode( + self, + model_id: str, + messages: List[Dict[str, str]], + temperature: float, + max_tokens: Optional[int], + **kwargs + ) -> Dict[str, Any]: + """Route request to OpenCode API.""" + + try: + # This would integrate with OpenCode API + # For now, fallback to local model + return await self._route_to_local_model( + model_id, messages, temperature, max_tokens, **kwargs + ) + except Exception as e: + raise HTTPException( + status_code=503, + detail=f"OpenCode model unavailable: {str(e)}" + ) + + def _messages_to_prompt(self, messages: List[Dict[str, str]]) -> str: + """Convert messages to a single prompt string.""" + prompt_parts = [] + for message in messages: + role = message.get("role", "user") + content = message.get("content", "") + prompt_parts.append(f"{role}: {content}") + + return "\n".join(prompt_parts) + + def _record_usage( + self, + model: str, + messages: List[Dict[str, str]], + response: Dict[str, Any], + response_time_ms: float, + success: bool, + error_message: str = None + ): + """Record usage analytics and token tracking.""" + + try: + # Record analytics + self.analytics.record_request( + model_id=model, + request_tokens=self._estimate_tokens(messages), + response_tokens=self._extract_response_tokens(response), + response_time_ms=response_time_ms, + success=success + ) + + # Record token usage + if success and "usage" in response: + usage_data = response["usage"] + token_usage = TokenUsage( + model_id=model, + session_id="heidi-api", + user_id="api-user", # Will be set by auth middleware + prompt_tokens=usage_data.get("prompt_tokens", 0), + completion_tokens=usage_data.get("completion_tokens", 0), + total_tokens=usage_data.get("total_tokens", 0), + cost_usd=0.0 # Will be calculated based on model pricing + ) + + self.token_db.record_usage(token_usage) + + except Exception: + # Don't fail the request if usage tracking fails + pass + + def _estimate_tokens(self, messages: List[Dict[str, str]]) -> int: + """Estimate token count for messages.""" + total_chars = sum(len(msg.get("content", "")) for msg in messages) + # Rough estimation: 1 token ≈ 4 characters + return total_chars // 4 + + def _extract_response_tokens(self, response: Dict[str, Any]) -> int: + """Extract token count from response.""" + if "usage" in response: + return response["usage"].get("completion_tokens", 0) + return 0 + + def list_available_models(self) -> Dict[str, List[Dict]]: + """List all available models from all providers.""" + + models = { + "local": [], + "huggingface": [], + "opencode": [] + } + + # Local models + try: + local_models = self.model_manager.list_models() + models["local"] = [ + { + "id": f"local://{model.get('id', model.get('name', 'unknown'))}", + "name": model.get('name', 'Unknown'), + "description": model.get('description', ''), + "provider": "local" + } + for model in local_models + ] + except Exception: + pass + + # HuggingFace models (popular ones) + try: + hf_models = [ + { + "id": "hf://TinyLlama/TinyLlama-1.1B-Chat-v1.0", + "name": "TinyLlama Chat", + "description": "Small conversational model", + "provider": "huggingface" + }, + { + "id": "hf://microsoft/DialoGPT-small", + "name": "DialoGPT Small", + "description": "Conversational AI model", + "provider": "huggingface" + } + ] + models["huggingface"] = hf_models + except Exception: + pass + + return models + + +# Global instance +_api_router = None + + +def get_api_router() -> APIRouter: + """Get the global API router instance.""" + global _api_router + if _api_router is None: + _api_router = APIRouter() + return _api_router diff --git a/src/heidi_cli/api/server.py b/src/heidi_cli/api/server.py new file mode 100644 index 00000000..97f2a662 --- /dev/null +++ b/src/heidi_cli/api/server.py @@ -0,0 +1,244 @@ +""" +Heidi API Server + +FastAPI server that provides unified API access to all Heidi models. +Users can authenticate with Heidi API keys and access models from any provider. +""" + +import time +from typing import Dict, List, Optional, Any +from fastapi import FastAPI, HTTPException, Depends, Security +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +from .key_manager import get_api_key_manager +from .auth import get_authenticator, AuthResult +from .router import get_api_router + + +# Pydantic models for API requests +class ChatMessage(BaseModel): + role: str = Field(..., description="Message role (user, assistant, system)") + content: str = Field(..., description="Message content") + + +class ChatCompletionRequest(BaseModel): + model: str = Field(..., description="Model identifier (e.g., local://my-model, hf://model-name)") + messages: List[ChatMessage] = Field(..., description="List of messages") + temperature: float = Field(1.0, ge=0.0, le=2.0, description="Sampling temperature") + max_tokens: Optional[int] = Field(None, ge=1, description="Maximum tokens to generate") + stream: bool = Field(False, description="Whether to stream the response") + + +class ChatCompletionResponse(BaseModel): + id: str + object: str = "chat.completion" + created: int + model: str + choices: List[Dict[str, Any]] + usage: Dict[str, int] + + +# Initialize FastAPI app +app = FastAPI( + title="Heidi API", + description="Unified API access to all Heidi-managed models", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc" +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Configure appropriately for production + allow_credentials=True, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["*"], +) + +# Security +security = HTTPBearer() + +# Initialize components +key_manager = get_api_key_manager() +authenticator = get_authenticator() +router = get_api_router() + + +async def authenticate_api_key(credentials: HTTPAuthorizationCredentials = Security(security)) -> AuthResult: + """Authenticate the API key.""" + if not credentials: + raise HTTPException( + status_code=401, + detail="API key required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + auth_result = authenticator.authenticate(credentials.credentials) + + if not auth_result.success: + if auth_result.rate_limited: + raise HTTPException( + status_code=429, + detail="Rate limit exceeded", + headers={"Retry-After": "60"}, + ) + else: + raise HTTPException( + status_code=401, + detail=auth_result.error_message or "Invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return auth_result + + +@app.get("/") +async def root(): + """Root endpoint with API information.""" + return { + "service": "Heidi API", + "version": "1.0.0", + "description": "Unified API access to all Heidi-managed models", + "endpoints": { + "chat": "/v1/chat/completions", + "models": "/v1/models", + "health": "/health", + "docs": "/docs" + } + } + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return { + "status": "healthy", + "timestamp": int(time.time()), + "service": "heidi-api" + } + + +@app.get("/v1/models") +async def list_models(auth_result: AuthResult = Depends(authenticate_api_key)): + """List all available models.""" + try: + models = router.list_available_models() + + # Flatten all models into a single list + all_models = [] + for provider, model_list in models.items(): + for model in model_list: + all_models.append(model) + + return { + "object": "list", + "data": all_models + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to list models: {str(e)}" + ) + + +@app.post("/v1/chat/completions") +async def chat_completions( + request: ChatCompletionRequest, + auth_result: AuthResult = Depends(authenticate_api_key) +): + """Create a chat completion.""" + try: + # Convert messages to dict format + messages = [ + {"role": msg.role, "content": msg.content} + for msg in request.messages + ] + + # Route the request + response = await router.route_request( + model=request.model, + messages=messages, + temperature=request.temperature, + max_tokens=request.max_tokens + ) + + return ChatCompletionResponse(**response) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Chat completion failed: {str(e)}" + ) + + +@app.get("/v1/rate-limit") +async def get_rate_limit(auth_result: AuthResult = Depends(authenticate_api_key)): + """Get rate limit information for the current API key.""" + try: + rate_info = authenticator.get_rate_limit_info(auth_result.api_key) + return rate_info + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to get rate limit: {str(e)}" + ) + + +@app.get("/v1/user/info") +async def get_user_info(auth_result: AuthResult = Depends(authenticate_api_key)): + """Get user information.""" + try: + return { + "user_id": auth_result.api_key.user_id, + "key_id": auth_result.api_key.key_id, + "key_name": auth_result.api_key.name, + "permissions": auth_result.api_key.permissions, + "rate_limit": auth_result.api_key.rate_limit, + "usage_count": auth_result.api_key.usage_count, + "created_at": auth_result.api_key.created_at.isoformat(), + "expires_at": auth_result.api_key.expires_at.isoformat() if auth_result.api_key.expires_at else None + } + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to get user info: {str(e)}" + ) + + +# Middleware for logging and analytics +@app.middleware("http") +async def add_headers_and_logging(request, call_next): + """Add custom headers and log requests.""" + start_time = time.time() + + # Add custom headers + response = await call_next(request) + + # Add rate limit headers + response.headers["X-API-Version"] = "1.0.0" + response.headers["X-Service"] = "Heidi API" + + # Log request time + process_time = time.time() - start_time + response.headers["X-Process-Time"] = str(process_time) + + return response + + +if __name__ == "__main__": + import uvicorn + + # Run the server + uvicorn.run( + "server:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) diff --git a/src/heidi_cli/cli.py b/src/heidi_cli/cli.py index 49664abe..59058662 100644 --- a/src/heidi_cli/cli.py +++ b/src/heidi_cli/cli.py @@ -8,6 +8,7 @@ from .shared.config import ConfigLoader from .launcher import start_daemon, stop_process, load_pids from .token_tracking.cli import register_tokens_app +from .api.cli import register_api_app console = Console() app = typer.Typer( @@ -26,6 +27,7 @@ app.add_typer(learning_app, name="learning") app.add_typer(hf_app, name="hf") register_tokens_app(app) +register_api_app(app) @app.command() def doctor():