feat(external-llm): add external LLM integration (fc-004)

Implements external LLM routing via opencode CLI for:
- GitHub Copilot (gpt-5.2, claude-sonnet-4.5, claude-haiku-4.5, o3, gemini-3-pro)
- Z.AI (glm-4.7 for code generation)
- OpenCode native (big-pickle)

Components:
- mcp/llm-router/invoke.py: Main router with task-based model selection
- mcp/llm-router/delegate.py: Agent delegation helper (respects external mode)
- mcp/llm-router/toggle.py: Enable/disable external-only mode
- mcp/llm-router/providers/: CLI wrappers for opencode and gemini

Features:
- Persistent toggle via state/external-mode.json
- Task routing: reasoning -> gpt-5.2, code-gen -> glm-4.7, long-context -> gemini
- Claude tier mapping: opus -> gpt-5.2, sonnet -> claude-sonnet-4.5, haiku -> claude-haiku-4.5
- Session-start hook announces when external mode is active
- Natural language toggle support via component registry

Plan: gleaming-routing-mercury

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OpenCode Test
2026-01-08 13:34:35 -08:00
parent 7dcb8af1bb
commit df6cf94dae
14 changed files with 1831 additions and 5 deletions

View File

@@ -0,0 +1,903 @@
# External LLM Integration Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Enable agents to use external LLMs (Copilot, Z.AI, Gemini) via CLI tools with a session toggle.
**Architecture:** Python router reads model-policy.json, invokes appropriate CLI (opencode/gemini), returns response. State file controls Claude vs external mode. PA exposes toggle commands.
**Tech Stack:** Python 3, subprocess, JSON state files, opencode CLI, gemini CLI
---
## Task 1: Create External Mode State File
**Files:**
- Create: `~/.claude/state/external-mode.json`
**Step 1: Create state file**
```bash
cat > ~/.claude/state/external-mode.json << 'EOF'
{
"enabled": false,
"activated_at": null,
"reason": null
}
EOF
```
**Step 2: Verify file**
Run: `cat ~/.claude/state/external-mode.json | jq .`
Expected: Valid JSON with `enabled: false`
**Step 3: Commit**
```bash
git -C ~/.claude add state/external-mode.json
git -C ~/.claude commit -m "feat(external-llm): add external-mode state file"
```
---
## Task 2: Extend Model Policy
**Files:**
- Modify: `~/.claude/state/model-policy.json`
**Step 1: Read current file**
Run: `cat ~/.claude/state/model-policy.json | jq .`
**Step 2: Add external_models section**
Add to model-policy.json (after `skill_delegation` section):
```json
"external_models": {
"copilot/gpt-5.2": {
"cli": "opencode",
"cli_args": ["--provider", "copilot", "--model", "gpt-5.2"],
"use_cases": ["reasoning", "fallback"],
"tier": "opus-equivalent"
},
"copilot/sonnet-4.5": {
"cli": "opencode",
"cli_args": ["--provider", "copilot", "--model", "sonnet-4.5"],
"use_cases": ["general", "fallback"],
"tier": "sonnet-equivalent"
},
"copilot/haiku-4.5": {
"cli": "opencode",
"cli_args": ["--provider", "copilot", "--model", "haiku-4.5"],
"use_cases": ["simple"],
"tier": "haiku-equivalent"
},
"zai/glm-4.7": {
"cli": "opencode",
"cli_args": ["--provider", "zai", "--model", "glm-4.7"],
"use_cases": ["code-generation"],
"tier": "sonnet-equivalent"
},
"gemini/gemini-3-pro": {
"cli": "gemini",
"cli_args": ["-m", "gemini-3-pro"],
"use_cases": ["long-context"],
"tier": "opus-equivalent"
}
},
"claude_to_external_map": {
"opus": "copilot/gpt-5.2",
"sonnet": "copilot/sonnet-4.5",
"haiku": "copilot/haiku-4.5"
},
"task_routing": {
"reasoning": "copilot/gpt-5.2",
"code-generation": "zai/glm-4.7",
"long-context": "gemini/gemini-3-pro",
"default": "copilot/sonnet-4.5"
}
```
**Step 3: Validate JSON**
Run: `cat ~/.claude/state/model-policy.json | jq .`
Expected: Valid JSON, no errors
**Step 4: Commit**
```bash
git -C ~/.claude add state/model-policy.json
git -C ~/.claude commit -m "feat(external-llm): add external model definitions to policy"
```
---
## Task 3: Create Router Directory Structure
**Files:**
- Create: `~/.claude/mcp/llm-router/`
- Create: `~/.claude/mcp/llm-router/providers/`
- Create: `~/.claude/mcp/llm-router/providers/__init__.py`
**Step 1: Create directories**
```bash
mkdir -p ~/.claude/mcp/llm-router/providers
```
**Step 2: Create __init__.py**
```bash
touch ~/.claude/mcp/llm-router/providers/__init__.py
```
**Step 3: Verify structure**
Run: `ls -la ~/.claude/mcp/llm-router/`
Expected: `providers/` directory exists
**Step 4: Commit**
```bash
git -C ~/.claude add mcp/llm-router/
git -C ~/.claude commit -m "feat(external-llm): create llm-router directory structure"
```
---
## Task 4: Create OpenCode Provider
**Files:**
- Create: `~/.claude/mcp/llm-router/providers/opencode.py`
**Step 1: Write provider**
```python
#!/usr/bin/env python3
"""OpenCode CLI wrapper for Copilot, Z.AI, and other providers."""
import subprocess
from typing import List
def invoke(cli_args: List[str], prompt: str, timeout: int = 300) -> str:
"""
Invoke opencode CLI with given args and prompt.
Args:
cli_args: Provider/model args like ["--provider", "copilot", "--model", "gpt-5.2"]
prompt: The prompt text
timeout: Timeout in seconds (default 5 minutes)
Returns:
Model response as string
Raises:
RuntimeError: If opencode CLI fails
TimeoutError: If request exceeds timeout
"""
cmd = ["opencode", "--print"] + cli_args + ["-p", prompt]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout
)
except subprocess.TimeoutExpired:
raise TimeoutError(f"opencode timed out after {timeout}s")
if result.returncode != 0:
raise RuntimeError(f"opencode failed (exit {result.returncode}): {result.stderr}")
return result.stdout.strip()
if __name__ == "__main__":
# Quick test
import sys
if len(sys.argv) > 1:
response = invoke(["--provider", "copilot", "--model", "gpt-5.2"], sys.argv[1])
print(response)
else:
print("Usage: opencode.py 'prompt'")
```
**Step 2: Make executable**
```bash
chmod +x ~/.claude/mcp/llm-router/providers/opencode.py
```
**Step 3: Verify syntax**
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/providers/opencode.py`
Expected: No output (success)
**Step 4: Commit**
```bash
git -C ~/.claude add mcp/llm-router/providers/opencode.py
git -C ~/.claude commit -m "feat(external-llm): add opencode provider wrapper"
```
---
## Task 5: Create Gemini Provider
**Files:**
- Create: `~/.claude/mcp/llm-router/providers/gemini.py`
**Step 1: Write provider**
```python
#!/usr/bin/env python3
"""Gemini CLI wrapper for Google models."""
import subprocess
from typing import List
def invoke(cli_args: List[str], prompt: str, timeout: int = 300) -> str:
"""
Invoke gemini CLI with given args and prompt.
Args:
cli_args: Model args like ["-m", "gemini-3-pro"]
prompt: The prompt text
timeout: Timeout in seconds (default 5 minutes)
Returns:
Model response as string
Raises:
RuntimeError: If gemini CLI fails
TimeoutError: If request exceeds timeout
"""
cmd = ["gemini"] + cli_args + ["-p", prompt]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout
)
except subprocess.TimeoutExpired:
raise TimeoutError(f"gemini timed out after {timeout}s")
if result.returncode != 0:
raise RuntimeError(f"gemini failed (exit {result.returncode}): {result.stderr}")
return result.stdout.strip()
if __name__ == "__main__":
# Quick test
import sys
if len(sys.argv) > 1:
response = invoke(["-m", "gemini-3-pro"], sys.argv[1])
print(response)
else:
print("Usage: gemini.py 'prompt'")
```
**Step 2: Make executable**
```bash
chmod +x ~/.claude/mcp/llm-router/providers/gemini.py
```
**Step 3: Verify syntax**
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/providers/gemini.py`
Expected: No output (success)
**Step 4: Commit**
```bash
git -C ~/.claude add mcp/llm-router/providers/gemini.py
git -C ~/.claude commit -m "feat(external-llm): add gemini provider wrapper"
```
---
## Task 6: Create Main Router (invoke.py)
**Files:**
- Create: `~/.claude/mcp/llm-router/invoke.py`
**Step 1: Write router**
```python
#!/usr/bin/env python3
"""
Invoke external LLM via configured provider.
Usage:
invoke.py --model copilot/gpt-5.2 -p "prompt"
invoke.py --task reasoning -p "prompt"
invoke.py --task code-generation -p "prompt" --json
Model selection priority:
1. Explicit --model flag
2. Task-based routing (--task flag)
3. Default from policy
"""
import argparse
import json
import sys
from pathlib import Path
STATE_DIR = Path.home() / ".claude/state"
ROUTER_DIR = Path(__file__).parent
def load_policy() -> dict:
"""Load model policy from state file."""
policy_file = STATE_DIR / "model-policy.json"
with open(policy_file) as f:
return json.load(f)
def resolve_model(args: argparse.Namespace, policy: dict) -> str:
"""Determine which model to use based on args and policy."""
if args.model:
return args.model
if args.task and args.task in policy.get("task_routing", {}):
return policy["task_routing"][args.task]
return policy.get("task_routing", {}).get("default", "copilot/sonnet-4.5")
def invoke(model: str, prompt: str, policy: dict) -> str:
"""Invoke the appropriate provider for the given model."""
external_models = policy.get("external_models", {})
if model not in external_models:
raise ValueError(f"Unknown model: {model}. Available: {list(external_models.keys())}")
model_config = external_models[model]
cli = model_config["cli"]
cli_args = model_config.get("cli_args", [])
# Import and invoke appropriate provider
if cli == "opencode":
sys.path.insert(0, str(ROUTER_DIR))
from providers.opencode import invoke as opencode_invoke
return opencode_invoke(cli_args, prompt)
elif cli == "gemini":
sys.path.insert(0, str(ROUTER_DIR))
from providers.gemini import invoke as gemini_invoke
return gemini_invoke(cli_args, prompt)
else:
raise ValueError(f"Unknown CLI: {cli}")
def main():
parser = argparse.ArgumentParser(
description="Invoke external LLM via configured provider"
)
parser.add_argument(
"-p", "--prompt",
required=True,
help="Prompt text"
)
parser.add_argument(
"--model",
help="Explicit model (e.g., copilot/gpt-5.2)"
)
parser.add_argument(
"--task",
choices=["reasoning", "code-generation", "long-context", "general"],
help="Task type for automatic model routing"
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON with model info"
)
parser.add_argument(
"--timeout",
type=int,
default=300,
help="Timeout in seconds (default: 300)"
)
args = parser.parse_args()
try:
policy = load_policy()
model = resolve_model(args, policy)
result = invoke(model, args.prompt, policy)
if args.json:
output = {
"model": model,
"response": result,
"success": True
}
print(json.dumps(output, indent=2))
else:
print(result)
except Exception as e:
if args.json:
output = {
"model": args.model or "unknown",
"error": str(e),
"success": False
}
print(json.dumps(output, indent=2))
sys.exit(1)
else:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
```
**Step 2: Make executable**
```bash
chmod +x ~/.claude/mcp/llm-router/invoke.py
```
**Step 3: Verify syntax**
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/invoke.py`
Expected: No output (success)
**Step 4: Commit**
```bash
git -C ~/.claude add mcp/llm-router/invoke.py
git -C ~/.claude commit -m "feat(external-llm): add main router invoke.py"
```
---
## Task 7: Create Delegation Helper
**Files:**
- Create: `~/.claude/mcp/llm-router/delegate.py`
**Step 1: Write delegation helper**
```python
#!/usr/bin/env python3
"""
Agent delegation helper. Routes to external or Claude based on mode.
Usage:
delegate.py --tier sonnet -p "prompt"
delegate.py --tier opus -p "complex reasoning task" --json
"""
import argparse
import json
import subprocess
import sys
from pathlib import Path
STATE_DIR = Path.home() / ".claude/state"
ROUTER_DIR = Path(__file__).parent
def is_external_mode() -> bool:
"""Check if external-only mode is enabled."""
mode_file = STATE_DIR / "external-mode.json"
if mode_file.exists():
with open(mode_file) as f:
data = json.load(f)
return data.get("enabled", False)
return False
def get_external_model(tier: str) -> str:
"""Get the external model equivalent for a Claude tier."""
policy_file = STATE_DIR / "model-policy.json"
with open(policy_file) as f:
policy = json.load(f)
mapping = policy.get("claude_to_external_map", {})
if tier not in mapping:
raise ValueError(f"No external mapping for tier: {tier}")
return mapping[tier]
def delegate(tier: str, prompt: str, use_json: bool = False) -> str:
"""
Delegate to appropriate model based on mode.
Args:
tier: Claude tier (opus, sonnet, haiku)
prompt: The prompt text
use_json: Return JSON output
Returns:
Model response as string
"""
if is_external_mode():
# Use external model
model = get_external_model(tier)
invoke_script = ROUTER_DIR / "invoke.py"
cmd = [sys.executable, str(invoke_script), "--model", model, "-p", prompt]
if use_json:
cmd.append("--json")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"External invoke failed: {result.stderr}")
return result.stdout.strip()
else:
# Use Claude
cmd = ["claude", "--print", "--model", tier, prompt]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Claude failed: {result.stderr}")
response = result.stdout.strip()
if use_json:
return json.dumps({
"model": f"claude/{tier}",
"response": response,
"success": True
}, indent=2)
return response
def main():
parser = argparse.ArgumentParser(
description="Delegate to Claude or external model based on mode"
)
parser.add_argument(
"--tier",
required=True,
choices=["opus", "sonnet", "haiku"],
help="Claude tier (maps to external equivalent when in external mode)"
)
parser.add_argument(
"-p", "--prompt",
required=True,
help="Prompt text"
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON"
)
args = parser.parse_args()
try:
result = delegate(args.tier, args.prompt, args.json)
print(result)
except Exception as e:
if args.json:
print(json.dumps({"error": str(e), "success": False}, indent=2))
sys.exit(1)
else:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
```
**Step 2: Make executable**
```bash
chmod +x ~/.claude/mcp/llm-router/delegate.py
```
**Step 3: Verify syntax**
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/delegate.py`
Expected: No output (success)
**Step 4: Commit**
```bash
git -C ~/.claude add mcp/llm-router/delegate.py
git -C ~/.claude commit -m "feat(external-llm): add delegation helper"
```
---
## Task 8: Create Toggle Script
**Files:**
- Create: `~/.claude/mcp/llm-router/toggle.py`
**Step 1: Write toggle script**
```python
#!/usr/bin/env python3
"""
Toggle external-only mode.
Usage:
toggle.py on [--reason "user requested"]
toggle.py off
toggle.py status
"""
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
STATE_FILE = Path.home() / ".claude/state/external-mode.json"
def load_state() -> dict:
"""Load current state."""
if STATE_FILE.exists():
with open(STATE_FILE) as f:
return json.load(f)
return {"enabled": False, "activated_at": None, "reason": None}
def save_state(state: dict):
"""Save state to file."""
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
def enable(reason: str = None):
"""Enable external-only mode."""
state = {
"enabled": True,
"activated_at": datetime.now().isoformat(),
"reason": reason or "user-requested"
}
save_state(state)
print("External-only mode ENABLED")
print(f" Activated: {state['activated_at']}")
print(f" Reason: {state['reason']}")
print("\nAll agent requests will now use external LLMs.")
print("Run 'toggle.py off' or '/pa --external off' to disable.")
def disable():
"""Disable external-only mode."""
state = {
"enabled": False,
"activated_at": None,
"reason": None
}
save_state(state)
print("External-only mode DISABLED")
print("\nAll agent requests will now use Claude.")
def status():
"""Show current mode status."""
state = load_state()
if state.get("enabled"):
print("External-only mode: ENABLED")
print(f" Activated: {state.get('activated_at', 'unknown')}")
print(f" Reason: {state.get('reason', 'unknown')}")
else:
print("External-only mode: DISABLED")
print(" Using Claude for all requests.")
def main():
parser = argparse.ArgumentParser(description="Toggle external-only mode")
subparsers = parser.add_subparsers(dest="command", required=True)
# on command
on_parser = subparsers.add_parser("on", help="Enable external-only mode")
on_parser.add_argument("--reason", help="Reason for enabling")
# off command
subparsers.add_parser("off", help="Disable external-only mode")
# status command
subparsers.add_parser("status", help="Show current mode")
args = parser.parse_args()
if args.command == "on":
enable(args.reason)
elif args.command == "off":
disable()
elif args.command == "status":
status()
if __name__ == "__main__":
main()
```
**Step 2: Make executable**
```bash
chmod +x ~/.claude/mcp/llm-router/toggle.py
```
**Step 3: Test toggle**
Run: `~/.claude/mcp/llm-router/toggle.py status`
Expected: Shows "External-only mode: DISABLED"
**Step 4: Commit**
```bash
git -C ~/.claude add mcp/llm-router/toggle.py
git -C ~/.claude commit -m "feat(external-llm): add toggle script"
```
---
## Task 9: Update Session Start Hook
**Files:**
- Modify: `~/.claude/hooks/scripts/session-start.sh`
**Step 1: Read current hook**
Run: `cat ~/.claude/hooks/scripts/session-start.sh`
**Step 2: Add external mode check**
Add before the final output section:
```bash
# Check external mode
if [ -f ~/.claude/state/external-mode.json ]; then
EXTERNAL_ENABLED=$(jq -r '.enabled // false' ~/.claude/state/external-mode.json)
if [ "$EXTERNAL_ENABLED" = "true" ]; then
echo "external-mode:enabled"
fi
fi
```
**Step 3: Verify hook**
Run: `bash -n ~/.claude/hooks/scripts/session-start.sh`
Expected: No output (success)
**Step 4: Commit**
```bash
git -C ~/.claude add hooks/scripts/session-start.sh
git -C ~/.claude commit -m "feat(external-llm): announce external mode in session-start"
```
---
## Task 10: Add Component Registry Triggers
**Files:**
- Modify: `~/.claude/state/component-registry.json`
**Step 1: Read current registry**
Run: `cat ~/.claude/state/component-registry.json | jq '.skills'`
**Step 2: Add external-mode skill entry**
Add to skills array:
```json
{
"id": "external-mode-toggle",
"name": "External Mode Toggle",
"description": "Toggle between Claude and external LLMs",
"path": "mcp/llm-router/toggle.py",
"triggers": [
"use external",
"switch to external",
"external models",
"external only",
"use copilot",
"use opencode",
"back to claude",
"use claude again",
"disable external",
"external mode"
]
}
```
**Step 3: Validate JSON**
Run: `cat ~/.claude/state/component-registry.json | jq .`
Expected: Valid JSON
**Step 4: Commit**
```bash
git -C ~/.claude add state/component-registry.json
git -C ~/.claude commit -m "feat(external-llm): add external-mode triggers to registry"
```
---
## Task 11: Integration Test
**Step 1: Test toggle**
```bash
~/.claude/mcp/llm-router/toggle.py status
~/.claude/mcp/llm-router/toggle.py on --reason "testing"
~/.claude/mcp/llm-router/toggle.py status
~/.claude/mcp/llm-router/toggle.py off
```
Expected: Status changes correctly
**Step 2: Test router (mock - will fail without actual CLIs)**
```bash
~/.claude/mcp/llm-router/invoke.py --model copilot/gpt-5.2 -p "Say hello" --json 2>&1 || echo "Expected: CLI not found (normal if opencode not installed)"
```
**Step 3: Test delegation helper status check**
```bash
python3 -c "
import sys
sys.path.insert(0, '$HOME/.claude/mcp/llm-router')
from delegate import is_external_mode
print(f'External mode: {is_external_mode()}')
"
```
Expected: "External mode: False"
**Step 4: Final commit**
```bash
git -C ~/.claude commit --allow-empty -m "feat(external-llm): integration complete - gleaming-routing-mercury"
```
---
## Summary
After completing all tasks:
| Component | Status |
|-----------|--------|
| `state/external-mode.json` | Created |
| `state/model-policy.json` | Extended |
| `mcp/llm-router/invoke.py` | Created |
| `mcp/llm-router/delegate.py` | Created |
| `mcp/llm-router/toggle.py` | Created |
| `mcp/llm-router/providers/opencode.py` | Created |
| `mcp/llm-router/providers/gemini.py` | Created |
| `hooks/scripts/session-start.sh` | Updated |
| `state/component-registry.json` | Updated |
**To use:**
```bash
# Enable external mode
~/.claude/mcp/llm-router/toggle.py on
# Or via PA
/pa --external on
/pa switch to external models
# Invoke directly
~/.claude/mcp/llm-router/invoke.py --task reasoning -p "Explain quantum computing"
# Delegate (respects mode)
~/.claude/mcp/llm-router/delegate.py --tier sonnet -p "Check disk space"
```

View File

@@ -0,0 +1,375 @@
# Plan: External LLM Integration
## Summary
Integrate external LLMs (via subscription-based access) into the agent system for cost optimization, specialized capabilities, and redundancy. Uses `opencode` CLI for Copilot/Z.AI models and `gemini` CLI for Google models.
## Motivation
- **Cost optimization** — Use cheaper models for simple tasks
- **Specialized capabilities** — Access models with unique strengths (GPT-5.2 for reasoning, GLM 4.7 for code)
- **Redundancy** — Fallback when Claude is unavailable
## Design Decisions
| Decision | Choice |
|----------|--------|
| Provider type | Cloud APIs via subscription (not local) |
| Providers | GitHub Copilot, Z.AI, Google Gemini |
| CLIs | `opencode` (Copilot, Z.AI), `gemini` (Google) |
| Integration | Task-specific routing + agent-level assignment |
| Toggle | State file (persists across sessions) |
| Toggle scope | All agents switch when enabled |
## Task Routing
| Task Type | Model |
|-----------|-------|
| Reasoning chains | copilot/gpt-5.2 |
| Code generation | zai/glm-4.7 |
| Long context | gemini/gemini-3-pro |
| General/fallback | copilot/sonnet-4.5 |
## Claude-to-External Mapping
| Claude Tier | External Equivalent |
|-------------|---------------------|
| opus | copilot/gpt-5.2 |
| sonnet | copilot/sonnet-4.5 |
| haiku | copilot/haiku-4.5 |
## Files to Create
### `~/.claude/state/external-mode.json`
```json
{
"enabled": false,
"activated_at": null,
"reason": null
}
```
### `~/.claude/mcp/llm-router/invoke.py`
Main entry point for invoking external LLMs.
```python
#!/usr/bin/env python3
"""
Invoke external LLM via configured provider.
Usage:
invoke.py --model copilot/gpt-5.2 -p "prompt"
invoke.py --task reasoning -p "prompt"
"""
import argparse
import json
import subprocess
from pathlib import Path
STATE_DIR = Path.home() / ".claude/state"
def load_policy():
with open(STATE_DIR / "model-policy.json") as f:
return json.load(f)
def resolve_model(args, policy):
if args.model:
return args.model
if args.task and args.task in policy["task_routing"]:
return policy["task_routing"][args.task]
return policy["task_routing"]["default"]
def invoke(model: str, prompt: str, policy: dict) -> str:
model_config = policy["external_models"][model]
cli = model_config["cli"]
cli_args = model_config["cli_args"]
if cli == "opencode":
from providers.opencode import invoke as opencode_invoke
return opencode_invoke(cli_args, prompt)
elif cli == "gemini":
from providers.gemini import invoke as gemini_invoke
return gemini_invoke(cli_args, prompt)
else:
raise ValueError(f"Unknown CLI: {cli}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("-p", "--prompt", required=True, help="Prompt text")
parser.add_argument("--model", help="Explicit model (e.g., copilot/gpt-5.2)")
parser.add_argument("--task", help="Task type for routing")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
policy = load_policy()
model = resolve_model(args, policy)
result = invoke(args.prompt, model, policy)
if args.json:
print(json.dumps({"model": model, "response": result}))
else:
print(result)
if __name__ == "__main__":
main()
```
### `~/.claude/mcp/llm-router/providers/opencode.py`
```python
#!/usr/bin/env python3
"""OpenCode CLI wrapper."""
import subprocess
def invoke(cli_args: list, prompt: str) -> str:
cmd = ["opencode", "--print"] + cli_args + ["-p", prompt]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300
)
if result.returncode != 0:
raise RuntimeError(f"opencode failed: {result.stderr}")
return result.stdout.strip()
```
### `~/.claude/mcp/llm-router/providers/gemini.py`
```python
#!/usr/bin/env python3
"""Gemini CLI wrapper."""
import subprocess
def invoke(cli_args: list, prompt: str) -> str:
cmd = ["gemini"] + cli_args + ["-p", prompt]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300
)
if result.returncode != 0:
raise RuntimeError(f"gemini failed: {result.stderr}")
return result.stdout.strip()
```
### `~/.claude/mcp/llm-router/delegate.py`
```python
#!/usr/bin/env python3
"""
Agent delegation helper. Routes to external or Claude based on mode.
Usage:
delegate.py --tier sonnet -p "prompt"
"""
import argparse
import json
import subprocess
from pathlib import Path
STATE_DIR = Path.home() / ".claude/state"
def is_external_mode():
mode_file = STATE_DIR / "external-mode.json"
if mode_file.exists():
with open(mode_file) as f:
return json.load(f).get("enabled", False)
return False
def delegate(tier: str, prompt: str) -> str:
if is_external_mode():
policy = json.loads((STATE_DIR / "model-policy.json").read_text())
model = policy["claude_to_external_map"][tier]
result = subprocess.run(
[str(Path.home() / ".claude/mcp/llm-router/invoke.py"),
"--model", model, "-p", prompt],
capture_output=True, text=True
)
return result.stdout
else:
result = subprocess.run(
["claude", "--print", "--model", tier, prompt],
capture_output=True, text=True
)
return result.stdout
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--tier", required=True, choices=["opus", "sonnet", "haiku"])
parser.add_argument("-p", "--prompt", required=True)
args = parser.parse_args()
print(delegate(args.tier, args.prompt))
if __name__ == "__main__":
main()
```
## Files to Modify
### `~/.claude/state/model-policy.json`
Add sections:
```json
{
"external_models": {
"copilot/gpt-5.2": {
"cli": "opencode",
"cli_args": ["--provider", "copilot", "--model", "gpt-5.2"],
"use_cases": ["reasoning", "fallback"],
"tier": "opus-equivalent"
},
"copilot/sonnet-4.5": {
"cli": "opencode",
"cli_args": ["--provider", "copilot", "--model", "sonnet-4.5"],
"use_cases": ["general", "fallback"],
"tier": "sonnet-equivalent"
},
"copilot/haiku-4.5": {
"cli": "opencode",
"cli_args": ["--provider", "copilot", "--model", "haiku-4.5"],
"use_cases": ["simple"],
"tier": "haiku-equivalent"
},
"zai/glm-4.7": {
"cli": "opencode",
"cli_args": ["--provider", "zai", "--model", "glm-4.7"],
"use_cases": ["code-generation"],
"tier": "sonnet-equivalent"
},
"gemini/gemini-3-pro": {
"cli": "gemini",
"cli_args": ["-m", "gemini-3-pro"],
"use_cases": ["long-context"],
"tier": "opus-equivalent"
}
},
"claude_to_external_map": {
"opus": "copilot/gpt-5.2",
"sonnet": "copilot/sonnet-4.5",
"haiku": "copilot/haiku-4.5"
},
"task_routing": {
"reasoning": "copilot/gpt-5.2",
"code-generation": "zai/glm-4.7",
"long-context": "gemini/gemini-3-pro",
"default": "copilot/sonnet-4.5"
}
}
```
### `~/.claude/state/component-registry.json`
Add trigger for external mode:
```json
{
"id": "external-mode-toggle",
"type": "skill",
"triggers": [
"use external", "switch to external", "external models",
"stop using claude", "external only", "use copilot",
"back to claude", "use claude again", "disable external"
],
"action": "toggle-external-mode"
}
```
### `~/.claude/hooks/scripts/session-start.sh`
Add external mode announcement:
```bash
# Check external mode
if [ -f ~/.claude/state/external-mode.json ]; then
if [ "$(jq -r '.enabled' ~/.claude/state/external-mode.json)" = "true" ]; then
echo "external-mode:enabled"
fi
fi
```
## Toggle Interface
### Command-based
```
/pa --external on # Enable external-only mode
/pa --external off # Disable, return to Claude
/pa --external status # Show current mode
```
### Natural language
| User says | Action |
|-----------|--------|
| "switch to external models" | Enable |
| "use copilot for everything" | Enable |
| "go back to claude" | Disable |
| "are we using external?" | Status |
| "use external for this" | One-shot (no persist) |
### Visual indicator
When external mode active, PA prefixes responses:
```
🔌 [External: copilot/gpt-5.2]
<response>
```
## Implementation Order
1. Create `external-mode.json` state file
2. Extend `model-policy.json` with external models
3. Create `llm-router/` directory and scripts
4. Add provider wrappers (opencode, gemini)
5. Create delegation helper
6. Update PA with toggle commands
7. Add component-registry triggers
8. Update session-start hook
9. Test each provider
## Validation
```bash
# Test router directly
~/.claude/mcp/llm-router/invoke.py --model copilot/gpt-5.2 -p "Say hello"
# Test delegation
~/.claude/mcp/llm-router/delegate.py --tier sonnet -p "What is 2+2?"
# Test toggle
/pa --external on
/pa what time is it?
/pa --external off
```
## Rollback
If issues arise:
1. Set `external-mode.json``enabled: false`
2. All operations revert to Claude immediately
## Future Considerations
- Auto-fallback to external when Claude rate-limited
- Cost tracking per external model
- Response quality comparison metrics
- Additional providers (Mistral, local Ollama)

View File

@@ -121,6 +121,13 @@
"implemented": "2026-01-07",
"category": "enhancement",
"notes": "This plan - meta!"
},
"gleaming-routing-mercury": {
"title": "External LLM integration",
"status": "pending",
"created": "2026-01-08",
"category": "feature",
"notes": "fc-004 - Cloud API integration via opencode/gemini CLIs with session toggle"
}
}
}