# OpenRouter Provider Support

## Overview

Add OpenRouter as a third provider option alongside LM Studio and HuggingFace Inference API. OpenRouter provides access to multiple LLM providers through a unified API.

## Background

Swival uses LiteLLM for all LLM calls, which already supports OpenRouter. The integration requires minimal code changes—mostly extending the existing provider pattern.

## Implementation Plan

### 1. Update Argument Parser (`swival/agent.py`)

**Location:** `build_parser()` function, around line 575

**Change:** Add `"openrouter"` to the provider choices.

```python
parser.add_argument(
    "--provider",
    choices=["lmstudio", "huggingface", "openrouter"],  # was: ["lmstudio", "huggingface"]
    default="lmstudio",
    help="LLM provider: lmstudio (local), huggingface (HF API), openrouter (multi-provider API).",
)
```

---

### 2. Add Provider Configuration Logic (`swival/agent.py`)

**Location:** `_run_main()` function, around line 851 (after the huggingface block)

**Change:** Add new `elif` branch for OpenRouter:

```python
elif args.provider == "openrouter":
    if not args.model:
        parser.error("--model is required when --provider is openrouter")
    api_base = args.base_url  # LiteLLM knows OpenRouter's default; only set if user provides --base-url
    model_id = args.model
    current_context = args.max_context_tokens
    api_key = args.api_key or os.environ.get("OPENROUTER_API_KEY")
    if not api_key:
        parser.error(
            "--api-key or OPENROUTER_API_KEY env var required for openrouter provider"
        )
```

**Notes:**
- OpenRouter always requires a model identifier (no auto-discovery)
- No default `api_base` — LiteLLM handles routing when using `openrouter/` prefix
- Supports `--base-url` override for custom endpoints
- Reads API key from `--api-key` flag or `OPENROUTER_API_KEY` environment variable

---

### 3. Update LiteLLM Call (`swival/agent.py`)

**Location:** `call_llm()` function, around line 460 (after the huggingface block)

**Change:** Add OpenRouter case:

```python
elif provider == "openrouter":
    bare_id = model_id.removeprefix("openrouter/")
    model_str = f"openrouter/{bare_id}"
    kwargs = {"api_key": api_key}
    if base_url:
        kwargs["api_base"] = base_url
```

**Notes:**
- LiteLLM expects the model to be prefixed with `openrouter/`
- Strips any existing prefix to avoid double-prefixing
- Passes API key directly (required for authentication)
- Optional `api_base` for custom endpoints (only when user provides `--base-url`)

---

### 4. Update Documentation

#### 4.1 `docs.md/providers.md`

**Location:** After the "HuggingFace Inference API" section

**Add new section:**

```markdown
## OpenRouter

For access to multiple LLM providers through a unified API.

### Basic usage

```sh
export OPENROUTER_API_KEY=sk_or_your_token_here
swival --provider openrouter --model z-ai/glm-5 "task"
```

The `--model` flag is required. Use the model identifier from OpenRouter's catalog (e.g., `z-ai/glm-5`, `anthropic/claude-3-5-sonnet`, `z-ai/glm-5`).

Authentication comes from `OPENROUTER_API_KEY` in the environment or `--api-key` on the command line (which takes precedence).

### Custom base URL

For custom OpenRouter-compatible endpoints:

```sh
swival --provider openrouter \
    --model z-ai/glm-5 \
    --base-url https://custom.openrouter.endpoint \
    --api-key sk_or_key \
    "task"
```

### Context size

OpenRouter models vary widely in context size (8K to 200K+ tokens). Swival defaults to whatever `--max-context-tokens` is set to; use it to match your model's actual limit:

```sh
swival --provider openrouter --model z-ai/glm-5 \
    --max-context-tokens 131072 "task"
```

### How the LiteLLM call works

For OpenRouter, Swival prefixes the model with `openrouter/` (stripping any existing prefix first) and passes the API key directly. If `--base-url` is set, it's passed as `api_base` to LiteLLM.
```

**Location:** "Future providers" section (near end of document)

**Update:** Change or remove the "Future providers" paragraph since OpenRouter now provides access to multiple providers. Consider revising to:

```markdown
## Future providers

Since Swival uses LiteLLM for the actual API call, adding new providers is
straightforward — it's mostly a matter of building the right model string and
passing the right credentials. The provider-specific logic in `call_llm()` is
about 10 lines per provider.

OpenRouter already provides access to dozens of models from different providers
(OpenAI, Anthropic, Meta, Google, etc.) through a single API, so it's a good
option if you need flexibility without configuring multiple credentials.
```

#### 4.2 `README.md`

**Location:** Quickstart section, after the HuggingFace example

**Add:**

```markdown
### OpenRouter

```sh
export OPENROUTER_API_KEY=sk_or_...
uv tool install swival
swival "Refactor the error handling in src/api.py" \
    --provider openrouter --model z-ai/glm-5
```
```

**Location:** Introduction paragraph (second paragraph)

**Update:** Change "Swival connects to LM Studio and HuggingFace Inference API" to "Swival connects to LM Studio, HuggingFace Inference API, or OpenRouter".

#### 4.3 `CLAUDE.md`

**Location:** "What This Is" paragraph and CLI usage section

**Update:** Add OpenRouter to the list of supported providers. For example, if the file mentions:

```
Swival connects to LM Studio and HuggingFace Inference API...
```

Change to:

```
Swival connects to LM Studio, HuggingFace Inference API, and OpenRouter...
```

Also update any CLI examples that show `--provider` options to include `openrouter`.

---

### 5. Testing

#### Unit Tests (`tests/test_provider.py`)

Add a test case for OpenRouter provider that mocks `litellm.completion`:

```python
def test_openrouter_provider():
    """Verify OpenRouter provider builds correct model string and kwargs."""
    from swival.agent import call_llm

    with patch("litellm.completion") as mock_completion:
        mock_completion.return_value = Mock(
            choices=[Mock(message=Mock(), finish_reason="stop")]
        )

        call_llm(
            base_url=None,
            model_id="z-ai/glm-5",
            messages=[{"role": "user", "content": "test"}],
            max_output_tokens=1024,
            temperature=0.5,
            top_p=1.0,
            seed=None,
            tools=None,
            verbose=False,
            provider="openrouter",
            api_key="sk_or_test_key",
        )

        # Verify model string is prefixed correctly
        call_args = mock_completion.call_args
        assert call_args.kwargs["model"] == "z-ai/glm-5"
        assert call_args.kwargs["api_key"] == "sk_or_test_key"
        assert "api_base" not in call_args.kwargs  # Not set when no --base-url
```

Slot additional cases into the existing test classes rather than adding standalone functions:

- **`TestModelNormalization`**: add a case where `model_id="z-ai/glm-5"` is passed — the `openrouter/` prefix should be stripped before re-adding it, so the final model string is still `"z-ai/glm-5"` (no double prefix).
- **`TestCLIValidation`**: add a case for `--provider openrouter` without `--model` — should call `parser.error()`.
- **`TestAPIKeyResolution`**: add a case verifying `OPENROUTER_API_KEY` env var is used when `--api-key` is not passed, and that `--api-key` takes precedence when both are set.

#### Manual Testing Checklist

- [ ] Basic OpenRouter call works:
  ```sh
  export OPENROUTER_API_KEY=sk_or_...
  swival --provider openrouter --model z-ai/glm-5 "What is 2+2?"
  ```

- [ ] `--api-key` flag overrides environment variable:
  ```sh
  swival --provider openrouter --model z-ai/glm-5 --api-key sk_or_... "task"
  ```

- [ ] Missing model error:
  ```sh
  swival --provider openrouter "task"  # should error
  ```

- [ ] Missing API key error:
  ```sh
  unset OPENROUTER_API_KEY
  swival --provider openrouter --model z-ai/glm-5 "task"  # should error
  ```

- [ ] Custom base URL works:
  ```sh
  swival --provider openrouter --model z-ai/glm-5 --base-url https://custom.endpoint "task"
  ```

- [ ] Context token limiting works with `--max-context-tokens`

- [ ] REPL mode works with OpenRouter:
  ```sh
  swival --repl --provider openrouter --model z-ai/glm-5
  ```

- [ ] Report generation works with OpenRouter provider

#### Edge Cases

- Model identifier with and without `openrouter/` prefix
- Invalid API key (should produce clear error)
- Context window exceeded (should trigger compaction/retry logic)
- Rate limit errors from OpenRouter (should surface clearly to user)

---

### 6. Optional Enhancements (Future)

- **Auto-discovery of available models:** Query OpenRouter's `/api/v1/models` endpoint to list available models
- **Default model:** Could add a `--model` default for OpenRouter (e.g., a popular open model)
- **Rate limit handling:** OpenRouter has rate limits; could add retry logic with backoff

---

## Files to Modify

| File | Changes |
|------|---------|
| `swival/agent.py` | Lines ~575, ~851, ~460 (parser, config, call_llm) |
| `docs.md/providers.md` | Add OpenRouter section; update "Future providers" |
| `README.md` | Update intro and add OpenRouter quickstart |
| `CLAUDE.md` | Update provider list in "What This Is" and CLI examples |
| `tests/test_provider.py` | Add unit tests for OpenRouter provider |

## Estimated Effort

- **Code changes:** ~20 lines across 3 locations in `agent.py`
- **Unit tests:** ~30-40 lines in `test_provider.py`
- **Documentation:** ~4 files (providers.md, README.md, CLAUDE.md, plus inline examples)
- **Testing:** 30-60 minutes manual testing + run existing test suite
- **Total:** 2-3 hours including testing and documentation

## Dependencies

No new dependencies required. LiteLLM already supports OpenRouter.

## References

- OpenRouter API docs: https://openrouter.ai/docs
- LiteLLM OpenRouter support: https://docs.litellm.ai/docs/providers/openrouter
