# Kollabor Engine - Profiles API Specification

> REST API for managing LLM profiles in the kollabor-engine server.
>
> **Status**: Spec complete, implementation pending
> **Router**: `packages/kollabor-engine/src/kollabor_engine/routes/profiles.py`
> **Manager**: `kollabor_ai.profile_manager.ProfileManager`

---

## Overview

Profiles define LLM connection settings (provider, model, API keys, etc.). They are stored in `~/.kollabor-cli/config.json` under `kollabor.llm.profiles` and managed via `ProfileManager` from the `kollabor_ai` package.

**Key concepts:**
- **Built-in profiles**: `default`, `fast`, `claude`, `openai` - cannot be deleted
- **Active profile**: The currently selected profile (tracked in `kollabor.llm.active_profile`)
- **Env var resolution**: Profile fields can be overridden by environment variables

---

## Authentication

All endpoints require Bearer token authentication (except `/health`, `/version`, `/`):

```bash
TOKEN=$(cat ~/.kollabor-cli/engine.token)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/profiles
```

Auth middleware is defined in `server.py`:
- Skips auth for health/version endpoints
- Validates token via `auth.validate_token()`
- Returns 401 if missing or invalid

---

## Profile Schema

### LLMProfile Structure

```python
@dataclass
class LLMProfile:
    name: str                    # Profile identifier (required)
    provider: str                # Provider type (required)
    model: str                   # Model name (required)
    api_key: str = ""            # API key (optional, resolved from env)
    base_url: str = ""           # Custom endpoint URL (optional)
    temperature: float = 0.7     # Sampling temperature (0.0-2.0)
    max_tokens: int | None = None  # Max tokens to generate
    timeout: int = 0             # Request timeout in ms (0 = no timeout)
    top_p: float | None = None   # Nucleus sampling (0.0-1.0)
    streaming: bool = True       # Enable streaming responses
    supports_tools: bool = True  # Enable tool/function calling
    description: str = ""        # Human-readable description
    extra_headers: dict = {}     # Additional HTTP headers
```

### Provider Types

- `anthropic` - Anthropic Claude API
- `openai` - OpenAI API (and compatible)
- `azure_openai` - Azure OpenAI
- `gemini` - Google Gemini
- `openai_responses` - OpenAI with responses API
- `openrouter` - OpenRouter
- `custom` - Custom OpenAI-compatible endpoint

### Required vs Optional Fields

**Required:**
- `name` - Profile identifier (used as key in config)
- `provider` - Provider type
- `model` - Model name/identifier

**Optional:**
- `api_key` - Can be resolved from `KOLLABOR_{NAME}_API_KEY` env var
- `base_url` - Can be resolved from `KOLLABOR_{NAME}_BASE_URL` env var
- `temperature` - Default 0.7, override with `KOLLABOR_{NAME}_TEMPERATURE`
- `max_tokens` - Default None (API default), override with `KOLLABOR_{NAME}_MAX_TOKENS`
- `timeout` - Default 0 (no timeout), override with `KOLLABOR_{NAME}_TIMEOUT`
- `top_p` - Default None, override with `KOLLABOR_{NAME}_TOP_P`
- `streaming` - Default True, override with `KOLLABOR_{NAME}_STREAMING`
- `supports_tools` - Default True, override with `KOLLABOR_{NAME}_SUPPORTS_TOOLS`
- `description` - Human-readable description
- `extra_headers` - Dict of extra HTTP headers

### Env Var Resolution Pattern

Profile fields are resolved from environment variables using this pattern:

```
KOLLABOR_{PROFILE_NAME}_{FIELD}

# Examples:
KOLLABOR_MYCLAUDE_API_KEY=sk-ant-...
KOLLABOR_MYCLAUDE_MODEL=claude-sonnet-4-6
KOLLABOR_MYCLAUDE_BASE_URL=https://api.anthropic.com
```

Profile name is normalized: non-alphanumeric chars become underscores, then uppercased.

```
my-local-llm → KOLLABOR_MY_LOCAL_LLM_API_KEY
my.profile   → KOLLABOR_MY_PROFILE_API_KEY
```

Env vars take **priority** over values stored in config.json.

---

## Endpoints

### GET /profiles

List all available profiles.

**Response:**
```json
{
  "profiles": [
    {
      "name": "default",
      "provider": "custom",
      "model": "qwen/qwen3-4b",
      "base_url": "http://localhost:1234/v1",
      "temperature": 0.7,
      "max_tokens": null,
      "timeout": 0,
      "description": "Local LLM for general use",
      "streaming": true,
      "supports_tools": true
    },
    {
      "name": "claude",
      "provider": "anthropic",
      "model": "claude-sonnet-4-20250514",
      "temperature": 0.7,
      "max_tokens": 4096,
      "timeout": 0,
      "description": "Anthropic Claude for complex tasks",
      "streaming": true,
      "supports_tools": true
    }
  ],
  "active": "default",
  "count": 2
}
```

**Implementation:**
```python
@router.get("")
async def list_profiles():
    pm = ProfileManager(config_dir=str(Path.home() / ".kollabor-cli"))
    profiles = pm.list_profiles()

    return {
        "profiles": [p.to_dict() for p in profiles],
        "active": pm.active_profile_name,
        "count": len(profiles)
    }
```

**Notes:**
- Uses `ProfileManager.list_profiles()` to get all profiles
- `to_dict()` on each profile resolves env var overrides
- Returns active profile name from `pm.active_profile_name`

---

### GET /profiles/{name}

Get detailed info for a specific profile.

**Path Parameters:**
- `name` - Profile name (URL-encoded if needed)

**Response (200):**
```json
{
  "name": "claude",
  "provider": "anthropic",
  "model": "claude-sonnet-4-20250514",
  "temperature": 0.7,
  "max_tokens": 4096,
  "timeout": 0,
  "description": "Anthropic Claude for complex tasks",
  "base_url": "",
  "top_p": null,
  "streaming": true,
  "supports_tools": true,
  "extra_headers": {},
  "env_var_hints": {
    "model": {
      "name": "KOLLABOR_CLAUDE_MODEL",
      "is_set": false
    },
    "api_key": {
      "name": "KOLLABOR_CLAUDE_API_KEY",
      "is_set": true
    },
    "base_url": {
      "name": "KOLLABOR_CLAUDE_BASE_URL",
      "is_set": false
    }
  },
  "is_active": true
}
```

**Response (404):**
```json
{
  "detail": "Profile 'unknown-profile' not found"
}
```

**Implementation:**
```python
@router.get("/{name}")
async def get_profile(name: str):
    pm = ProfileManager(config_dir=str(Path.home() / ".kollabor-cli"))
    profile = pm.get_profile(name)

    if not profile:
        raise HTTPException(status_code=404, detail=f"Profile '{name}' not found")

    result = profile.to_dict()
    result["env_var_hints"] = profile.get_env_var_hints()
    result["is_active"] = pm.is_active(name)

    return result
```

**Notes:**
- Uses `ProfileManager.get_profile(name)`
- Includes env var hints showing which env vars are available and set
- Includes `is_active` flag indicating if this is the active profile

---

### POST /profiles

Create a new profile.

**Request Body:**
```json
{
  "name": "gpt4",
  "provider": "openai",
  "model": "gpt-4-turbo",
  "api_key": "sk-openai-...",
  "base_url": "https://api.openai.com/v1",
  "temperature": 0.7,
  "max_tokens": 4096,
  "description": "OpenAI GPT-4 for complex tasks",
  "timeout": 30000,
  "streaming": true,
  "supports_tools": true
}
```

**Response (201):**
```json
{
  "name": "gpt4",
  "provider": "openai",
  "model": "gpt-4-turbo",
  "temperature": 0.7,
  "max_tokens": 4096,
  "timeout": 30000,
  "description": "OpenAI GPT-4 for complex tasks",
  "streaming": true,
  "supports_tools": true,
  "created": true
}
```

**Response (400):**
```json
{
  "detail": "Profile 'gpt4' already exists"
}
```

**Response (400):**
```json
{
  "detail": "Missing required field: provider"
}
```

**Implementation:**
```python
class CreateProfileRequest(BaseModel):
    name: str
    provider: str
    model: str
    api_key: str = ""
    base_url: str = ""
    temperature: float = 0.7
    max_tokens: Optional[int] = None
    description: str = ""
    timeout: int = 0
    top_p: Optional[float] = None
    streaming: bool = True
    supports_tools: bool = True
    extra_headers: Dict[str, str] = {}

@router.post("")
async def create_profile(body: CreateProfileRequest):
    pm = ProfileManager(config_dir=str(Path.home() / ".kollabor-cli"))

    # Check for existing profile
    if pm.get_profile(body.name):
        raise HTTPException(status_code=400, detail=f"Profile '{body.name}' already exists")

    # Create profile
    profile = LLMProfile(
        name=body.name,
        provider=body.provider,
        model=body.model,
        api_key=body.api_key,
        base_url=body.base_url,
        temperature=body.temperature,
        max_tokens=body.max_tokens,
        description=body.description,
        timeout=body.timeout,
        top_p=body.top_p,
        streaming=body.streaming,
        supports_tools=body.supports_tools,
        extra_headers=body.extra_headers,
    )

    pm.add_profile(profile)
    pm.save_profile_values_to_config(profile, local=False)

    result = profile.to_dict()
    result["created"] = True

    return JSONResponse(content=result, status_code=201)
```

**curl:**
```bash
curl -X POST http://127.0.0.1:8765/profiles \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "gpt4",
    "provider": "openai",
    "model": "gpt-4-turbo",
    "api_key": "sk-..."
  }'
```

**Notes:**
- Uses `ProfileManager.add_profile()` which returns False if name exists
- Uses `save_profile_values_to_config(profile, local=False)` to persist
- Provider field is REQUIRED (enforced by `LLMProfile.from_dict()`)

---

### PUT /profiles/{name}

Update an existing profile. Can also rename via the `new_name` field.

**Path Parameters:**
- `name` - Current profile name to update

**Request Body:**
```json
{
  "new_name": "gpt4-turbo",
  "provider": "openai",
  "model": "gpt-4-turbo-preview",
  "api_key": "sk-new-key-...",
  "base_url": "https://api.openai.com/v1",
  "temperature": 0.5,
  "max_tokens": 8192,
  "description": "Updated GPT-4 config",
  "timeout": 60000,
  "streaming": false,
  "supports_tools": true
}
```

**Response (200):**
```json
{
  "name": "gpt4-turbo",
  "provider": "openai",
  "model": "gpt-4-turbo-preview",
  "temperature": 0.5,
  "max_tokens": 8192,
  "timeout": 60000,
  "description": "Updated GPT-4 config",
  "streaming": false,
  "supports_tools": true,
  "updated": true
}
```

**Response (404):**
```json
{
  "detail": "Profile 'unknown' not found"
}
```

**Response (400):**
```json
{
  "detail": "Cannot rename: profile 'gpt4-turbo' already exists"
}
```

**Implementation:**
```python
class UpdateProfileRequest(BaseModel):
    new_name: Optional[str] = None
    provider: Optional[str] = None
    model: Optional[str] = None
    api_key: Optional[str] = None
    base_url: Optional[str] = None
    temperature: Optional[float] = None
    max_tokens: Optional[int] = None
    description: Optional[str] = None
    timeout: Optional[int] = None
    top_p: Optional[float] = None
    streaming: Optional[bool] = None
    supports_tools: Optional[bool] = None
    extra_headers: Optional[Dict[str, str]] = None

@router.put("/{name}")
async def update_profile(name: str, body: UpdateProfileRequest):
    pm = ProfileManager(config_dir=str(Path.home() / ".kollabor-cli"))

    # Get existing profile
    profile = pm.get_profile(name)
    if not profile:
        raise HTTPException(status_code=404, detail=f"Profile '{name}' not found")

    # Build kwargs for fields that were provided
    kwargs = {}
    if body.provider is not None:
        kwargs["provider"] = body.provider
    if body.model is not None:
        kwargs["model"] = body.model
    if body.api_key is not None:
        kwargs["api_key"] = body.api_key
    if body.base_url is not None:
        kwargs["base_url"] = body.base_url
    if body.temperature is not None:
        kwargs["temperature"] = body.temperature
    if body.max_tokens is not None:
        kwargs["max_tokens"] = body.max_tokens
    if body.description is not None:
        kwargs["description"] = body.description
    if body.supports_tools is not None:
        kwargs["supports_tools"] = body.supports_tools
    if body.timeout is not None:
        kwargs["timeout"] = body.timeout

    # Handle rename
    if body.new_name:
        kwargs["new_name"] = body.new_name

    # Update (include save_to_config)
    kwargs["save_to_config"] = True
    ok = pm.update_profile(name, **kwargs)

    if not ok:
        # Likely a rename collision
        if body.new_name:
            raise HTTPException(
                status_code=400,
                detail=f"Cannot rename: profile '{body.new_name}' already exists"
            )
        raise HTTPException(status_code=400, detail="Update failed")

    # Get updated profile (name may have changed)
    updated_name = body.new_name or name
    updated_profile = pm.get_profile(updated_name)

    result = updated_profile.to_dict()
    result["updated"] = True

    return result
```

**curl:**
```bash
curl -X PUT http://127.0.0.1:8765/profiles/gpt4 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "new_name": "gpt4-turbo",
    "model": "gpt-4-turbo-preview",
    "temperature": 0.5
  }'
```

**Notes:**
- Only updates fields that are provided (partial update)
- `new_name` is for renaming - if omitted, keeps existing name
- Uses `ProfileManager.update_profile()` with `save_to_config=True`
- Cannot rename to an existing profile name

---

### DELETE /profiles/{name}

Delete a profile. Cannot delete built-in profiles or the active profile.

**Path Parameters:**
- `name` - Profile name to delete

**Response (200):**
```json
{
  "deleted": true,
  "name": "my-profile"
}
```

**Response (404):**
```json
{
  "detail": "Profile 'unknown' not found"
}
```

**Response (400):**
```json
{
  "detail": "Cannot delete built-in profile: default"
}
```

**Response (400):**
```json
{
  "detail": "Cannot delete active profile: claude"
}
```

**Implementation:**
```python
@router.delete("/{name}")
async def delete_profile(name: str):
    pm = ProfileManager(config_dir=str(Path.home() / ".kollabor-cli"))

    ok = pm.delete_profile(name)

    if not ok:
        # Determine reason
        if name in pm.DEFAULT_PROFILES:
            raise HTTPException(
                status_code=400,
                detail=f"Cannot delete built-in profile: {name}"
            )
        if name == pm.active_profile_name:
            raise HTTPException(
                status_code=400,
                detail=f"Cannot delete active profile: {name}"
            )
        raise HTTPException(status_code=404, detail=f"Profile '{name}' not found")

    return {"deleted": True, "name": name}
```

**curl:**
```bash
curl -X DELETE http://127.0.0.1:8765/profiles/my-profile \
  -H "Authorization: Bearer $TOKEN"
```

**Notes:**
- `ProfileManager.delete_profile()` handles built-in and active profile checks
- Removes profile from memory AND from config.json
- Built-in profiles: `default`, `fast`, `claude`, `openai`

---

### POST /profiles/{name}/test

Test a profile's API key and connectivity. Makes a minimal API call to verify credentials.

**Path Parameters:**
- `name` - Profile name to test

**Response (200):**
```json
{
  "success": true,
  "profile": "claude",
  "provider": "anthropic",
  "message": "API key is valid",
  "model": "claude-sonnet-4-20250514",
  "latency_ms": 234
}
```

**Response (200 - with warning):**
```json
{
  "success": true,
  "profile": "my-profile",
  "provider": "custom",
  "message": "Connected (model validation skipped)",
  "warning": "Custom provider does not support model verification"
}
```

**Response (401):**
```json
{
  "success": false,
  "profile": "claude",
  "error": "authentication_failed",
  "message": "Invalid API key"
}
```

**Response (404):**
```json
{
  "detail": "Profile 'unknown' not found"
}
```

**Response (500):**
```json
{
  "success": false,
  "profile": "my-profile",
  "error": "connection_failed",
  "message": "Connection refused: localhost:9999"
}
```

**Implementation:**
```python
import time
from kollabor_ai import APIService

@router.post("/{name}/test")
async def test_profile(name: str):
    pm = ProfileManager(config_dir=str(Path.home() / ".kollabor-cli"))
    profile = pm.get_profile(name)

    if not profile:
        raise HTTPException(status_code=404, detail=f"Profile '{name}' not found")

    start = time.monotonic()

    try:
        # Create API service for this profile
        api_service = APIService(profile=profile)

        # Make a minimal test call
        # For Anthropic: we can use get_model() or a very short completion
        # For OpenAI: list models
        # For custom: just try to connect

        provider = profile.get_provider()

        if provider == "anthropic":
            # Use a minimal message for testing
            test_result = await api_service.call_llm(
                conversation_history=[{"role": "user", "content": "Hi"}],
                max_tokens=1,
            )
            message = "API key is valid"

        elif provider == "openai":
            # List models to verify connection
            import httpx
            headers = {"Authorization": f"Bearer {profile.get_api_key()}"}
            base_url = profile.get_endpoint() or "https://api.openai.com/v1"
            async with httpx.AsyncClient(timeout=10.0) as client:
                resp = await client.get(f"{base_url}/models", headers=headers)
                resp.raise_for_status()
            message = "API key is valid"

        else:
            # For custom/other providers, just verify the URL is reachable
            import httpx
            base_url = profile.get_endpoint() or "http://localhost:1234/v1"
            async with httpx.AsyncClient(timeout=5.0) as client:
                resp = await client.get(base_url)
            message = "Connected (model validation skipped)"
            warning = "Custom provider does not support model verification"

        latency = (time.monotonic() - start) * 1000

        result = {
            "success": True,
            "profile": name,
            "provider": provider,
            "message": message,
            "model": profile.get_model(),
            "latency_ms": round(latency, 1),
        }

        if warning:
            result["warning"] = warning

        return result

    except httpx.HTTPStatusError as e:
        if e.response.status_code == 401:
            return {
                "success": False,
                "profile": name,
                "error": "authentication_failed",
                "message": "Invalid API key",
            }
        if e.response.status_code == 429:
            return {
                "success": False,
                "profile": name,
                "error": "rate_limited",
                "message": "Rate limited - try again later",
            }
        return {
            "success": False,
            "profile": name,
            "error": "api_error",
            "message": str(e),
        }

    except (httpx.ConnectError, OSError) as e:
        return {
            "success": False,
            "profile": name,
            "error": "connection_failed",
            "message": f"Connection failed: {e}",
        }

    except Exception as e:
        return {
            "success": False,
            "profile": name,
            "error": "internal_error",
            "message": str(e),
        }
```

**curl:**
```bash
curl -X POST http://127.0.0.1:8765/profiles/claude/test \
  -H "Authorization: Bearer $TOKEN"
```

**Notes:**
- Provider-specific testing logic:
  - `anthropic`: Sends minimal message, expects response
  - `openai`: Calls `/models` endpoint
  - `custom`: Just checks URL reachability
- Returns latency in milliseconds
- Catches auth errors (401), rate limits (429), connection failures

---

## Storage

Profiles are stored in `~/.kollabor-cli/config.json`:

```json
{
  "kollabor": {
    "llm": {
      "active_profile": "default",
      "default_profile": "default",
      "profiles": {
        "default": {
          "provider": "custom",
          "base_url": "http://localhost:1234/v1",
          "model": "qwen/qwen3-4b",
          "temperature": 0.7,
          "description": "Local LLM for general use"
        },
        "claude": {
          "provider": "anthropic",
          "model": "claude-sonnet-4-20250514",
          "temperature": 0.7,
          "max_tokens": 4096,
          "description": "Anthropic Claude for complex tasks"
        }
      }
    }
  }
}
```

**Key paths:**
- Config directory: `Path.home() / ".kollabor-cli"`
- Config file: `config_dir / "config.json"`
- Profiles key: `kollabor.llm.profiles`

**ProfileManager methods for storage:**
- `_read_profiles_from_file()` - Read profiles from config
- `save_profile_values_to_config(profile, local=False)` - Save one profile
- `_delete_profile_from_config(name)` - Remove profile from config
- `_save_active_profile_to_config(name)` - Update active profile

---

## Implementation Notes

### Router Setup

Create `packages/kollabor-engine/src/kollabor_engine/routes/profiles.py`:

```python
"""Profile management routes."""

from pathlib import Path
from typing import Dict, List, Optional

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from kollabor_ai import ProfileManager, LLMProfile

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/profiles", tags=["profiles"])
```

Mount in `server.py`:

```python
from .routes.profiles import router as profiles_router

app.include_router(profiles_router)
```

### Config Directory

Always use global config directory for profiles (user-level setting):

```python
config_dir = Path.home() / ".kollabor-cli"
pm = ProfileManager(config_dir=str(config_dir))
```

### Error Handling

Follow patterns from `sessions.py`:
- Use `HTTPException` with appropriate status codes
- Return JSON with `detail` key for errors
- Global exception handler in `server.py` catches unhandled exceptions

### Pydantic Models

Define request models for validation:
- `CreateProfileRequest` - All fields optional except name/provider/model
- `UpdateProfileRequest` - All fields optional (partial update)
- Use `Optional[type] = None` for optional fields

---

## Testing Considerations

### Test Cases

**GET /profiles:**
- Empty profile list
- Multiple profiles
- Active profile indicator

**GET /profiles/{name}:**
- Valid profile name
- Non-existent profile (404)
- Profile with env var overrides
- Active profile flag

**POST /profiles:**
- Valid profile creation
- Duplicate profile name (400)
- Missing required fields (400)
- Profile with all optional fields

**PUT /profiles/{name}:**
- Partial update (only some fields)
- Full update (all fields)
- Rename to new name
- Rename to existing name (400)
- Update non-existent profile (404)

**DELETE /profiles/{name}:**
- Delete user-created profile
- Delete built-in profile (400)
- Delete active profile (400)
- Delete non-existent profile (404)

**POST /profiles/{name}/test:**
- Valid API key (success)
- Invalid API key (auth_failed)
- Connection refused (connection_failed)
- Rate limited (rate_limited)
- Custom provider (warning)

### Integration Tests

Create JSON test spec at `tests/tmux/specs/engine-profiles-api.json`:

```json
{
  "name": "engine-profiles-api",
  "description": "Profiles API endpoints",
  "config": {
    "command": "python -m kollabor_engine.server",
    "app_init_sleep": 2
  },
  "steps": [
    { "action": "start_app" },
    { "action": "shell", "command": "TOKEN=$(cat ~/.kollabor-cli/engine.token)" },
    { "action": "shell", "command": "curl -H \"Authorization: Bearer $TOKEN\" http://127.0.0.1:8765/profiles" },
    { "action": "assert_contains", "pattern": "default" },
    { "action": "shell", "command": "curl -H \"Authorization: Bearer $TOKEN\" http://127.0.0.1:8765/profiles/claude" },
    { "action": "assert_contains", "pattern": "anthropic" }
  ]
}
```

---

## Summary

| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/profiles` | GET | Yes | List all profiles |
| `/profiles/{name}` | GET | Yes | Get profile detail |
| `/profiles` | POST | Yes | Create new profile |
| `/profiles/{name}` | PUT | Yes | Update/rename profile |
| `/profiles/{name}` | DELETE | Yes | Delete profile |
| `/profiles/{name}/test` | POST | Yes | Test API connectivity |

**Implementation checklist:**
- [ ] Create `routes/profiles.py` with router
- [ ] Implement GET /profiles (list)
- [ ] Implement GET /profiles/{name} (detail)
- [ ] Implement POST /profiles (create)
- [ ] Implement PUT /profiles/{name} (update)
- [ ] Implement DELETE /profiles/{name} (delete)
- [ ] Implement POST /profiles/{name}/test (test)
- [ ] Mount router in server.py
- [ ] Add JSON test spec
- [ ] Run tmux tests for verification
