Skip to content

Commit 1723b6c

Browse files
lzwjavaclaude
andcommitted
feat: add OpenRouter as a model provider
Wires OpenRouter alongside GitHub Copilot in /provider_model. Chat and model-list calls dispatch by provider; Copilot's 24-min token refresh is gated so OpenRouter sessions don't trigger it. API key comes from OPENROUTER_API_KEY env var if set, otherwise prompted once and saved to config.json. /model skips the Copilot per-model test-ping loop for OpenRouter since each call is billed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f3d36e9 commit 1723b6c

7 files changed

Lines changed: 248 additions & 70 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ A minimal [openclaw](https://github.com/lzwjava/openclaw) implementation, built
1414

1515
## Features
1616

17-
- **Multi-turn conversations** with GitHub Copilot in your terminal.
17+
- **Multi-turn conversations** with GitHub Copilot or OpenRouter in your terminal.
18+
- **Multiple Model Providers**: GitHub Copilot (OAuth) and OpenRouter (API key).
1819
- **Native Tool Calling**: The model can autonomously invoke web search, execute shell commands, and edit files.
1920
- **Multiple Search Providers**: DuckDuckGo (default), Startpage, Bing, and Tavily.
2021
- **GitHub OAuth device flow** authentication.
@@ -41,11 +42,12 @@ pip install -e .
4142
iclaw
4243
```
4344

44-
2. **Authenticate with GitHub** (on first run):
45+
2. **Authenticate** (on first run):
4546
```
4647
/provider_model
4748
```
48-
Select `copilot`, then follow the GitHub device authorization flow. Your token is saved to `~/.config/iclaw/config.json`.
49+
- **Copilot**: select option 1, follow the GitHub device authorization flow. Your token is saved to `~/.config/iclaw/config.json`.
50+
- **OpenRouter**: select option 2. iclaw reads `OPENROUTER_API_KEY` from the environment, or prompts for a key and saves it to `~/.config/iclaw/config.json`.
4951

5052
### CLI Commands
5153
- `/provider_model`: Select and authenticate with the model provider.

iclaw/commands/model.py

Lines changed: 114 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
11
import sys
22
import concurrent.futures
33
from iclaw.github_api import get_models, get_copilot_token
4+
from iclaw.providers import openrouter
45
from iclaw.commands.auth import handle_login_command
56
from iclaw.commands.test_models import test_model
7+
from iclaw.config import load_openrouter_api_key, save_openrouter_api_key
8+
9+
10+
def _prompt_openrouter_key():
11+
env_key = load_openrouter_api_key()
12+
if env_key:
13+
print("Using OPENROUTER_API_KEY from environment.\n")
14+
return env_key
15+
key = input("Enter OpenRouter API key (stored in config): ").strip()
16+
if not key:
17+
print("API key cannot be empty.\n")
18+
return None
19+
save_openrouter_api_key(key)
20+
return key
621

722

823
def handle_model_provider_command(config_path, current_provider):
9-
PROVIDERS = ["copilot", "others"]
24+
PROVIDERS = ["copilot", "openrouter"]
1025
print(f"\nCurrent model provider: {current_provider}")
1126
print("Available model providers:")
1227
for i, p in enumerate(PROVIDERS, 1):
@@ -17,31 +32,39 @@ def handle_model_provider_command(config_path, current_provider):
1732
if not choice:
1833
return current_provider, None
1934

20-
if choice.isdigit():
21-
n = int(choice)
22-
if 1 <= n <= len(PROVIDERS):
23-
provider = PROVIDERS[n - 1]
24-
if provider == "copilot":
25-
github_token = handle_login_command(config_path)
26-
if github_token:
27-
try:
28-
copilot_token = get_copilot_token(github_token)
29-
print("Connected to GitHub Copilot.\n")
30-
return provider, copilot_token
31-
except Exception as e:
32-
print(f"Error: {e}", file=sys.stderr)
33-
else:
34-
print(f"{provider} not implemented yet.\n")
35-
else:
36-
print("Invalid selection.\n")
37-
return current_provider, None
35+
if not choice.isdigit():
36+
print("Invalid selection.\n")
37+
return current_provider, None
3838

39+
n = int(choice)
40+
if not (1 <= n <= len(PROVIDERS)):
41+
print("Invalid selection.\n")
42+
return current_provider, None
3943

40-
def handle_model_command(copilot_token, current_model):
41-
if not copilot_token:
42-
print("Not authenticated with any model provider.\n", file=sys.stderr)
43-
return current_model
44+
provider = PROVIDERS[n - 1]
45+
if provider == "copilot":
46+
github_token = handle_login_command(config_path)
47+
if not github_token:
48+
return current_provider, None
49+
try:
50+
token = get_copilot_token(github_token)
51+
print("Connected to GitHub Copilot.\n")
52+
return provider, token
53+
except Exception as e:
54+
print(f"Error: {e}", file=sys.stderr)
55+
return current_provider, None
4456

57+
if provider == "openrouter":
58+
key = _prompt_openrouter_key()
59+
if not key:
60+
return current_provider, None
61+
print("OpenRouter configured.\n")
62+
return provider, key
63+
64+
return current_provider, None
65+
66+
67+
def _handle_copilot_model(copilot_token, current_model):
4568
try:
4669
model_data = get_models(copilot_token)
4770
except Exception as e:
@@ -57,49 +80,95 @@ def handle_model_command(copilot_token, current_model):
5780
working_models = []
5881
completed = 0
5982
for future in concurrent.futures.as_completed(futures):
60-
model_id, works = future.result()
83+
_model_id, works = future.result()
6184
if works:
6285
working_models.append(futures[future])
6386
completed += 1
6487
pct = int(completed * 100 / total)
6588
print(f"\r{pct}% ({completed}/{total})", end="", flush=True)
6689
print()
6790

68-
groups = {}
69-
for m in working_models:
70-
owner = m.get("owned_by", "unknown")
71-
groups.setdefault(owner, []).append(m["id"])
91+
return _select_from_models(working_models, current_model, group_key="owned_by")
7292

73-
flat_models = [m["id"] for m in working_models]
93+
94+
def _handle_openrouter_model(api_key, current_model):
95+
try:
96+
model_data = openrouter.get_models(api_key)
97+
except Exception as e:
98+
print(f"Error fetching models: {e}\n", file=sys.stderr)
99+
return current_model
100+
return _select_from_models(model_data, current_model, group_key=None)
101+
102+
103+
def _select_from_models(models, current_model, group_key):
104+
if not models:
105+
print("No models available.\n")
106+
return current_model
107+
108+
flat_ids = [m["id"] for m in models]
74109
print(f"\nCurrent model: {current_model}")
75110
print("Available models:")
76111

77112
idx = 1
78113
model_index = {}
79-
for owner, ids in groups.items():
80-
print(f" [{owner}]")
81-
for mid in ids:
114+
115+
if group_key:
116+
groups = {}
117+
for m in models:
118+
groups.setdefault(m.get(group_key, "unknown"), []).append(m["id"])
119+
for owner, ids in groups.items():
120+
print(f" [{owner}]")
121+
for mid in ids:
122+
marker = "*" if mid == current_model else " "
123+
print(f" {marker} {idx}. {mid}")
124+
model_index[idx] = mid
125+
idx += 1
126+
else:
127+
for mid in flat_ids:
82128
marker = "*" if mid == current_model else " "
83129
print(f" {marker} {idx}. {mid}")
84130
model_index[idx] = mid
85131
idx += 1
86132

87133
try:
88134
choice = input("Select model (number or name, Enter to keep current): ").strip()
89-
if choice:
90-
if choice.isdigit():
91-
n = int(choice)
92-
if n in model_index:
93-
print(f"Model set to: {model_index[n]}\n")
94-
return model_index[n]
95-
else:
96-
print("Invalid selection.\n")
97-
elif choice in flat_models:
98-
print(f"Model set to: {choice}\n")
99-
return choice
100-
else:
101-
print(f"Unknown model '{choice}'. Keeping {current_model}\n")
135+
if not choice:
136+
return current_model
137+
if choice.isdigit():
138+
n = int(choice)
139+
if n in model_index:
140+
print(f"Model set to: {model_index[n]}\n")
141+
return model_index[n]
142+
print("Invalid selection.\n")
143+
return current_model
144+
if choice in flat_ids:
145+
print(f"Model set to: {choice}\n")
146+
return choice
147+
print(f"Unknown model '{choice}'. Keeping {current_model}\n")
102148
except (EOFError, KeyboardInterrupt):
103149
print()
150+
return current_model
151+
152+
153+
def handle_model_command(provider_or_token, token_or_model, current_model=None):
154+
"""Dispatch model selection by provider.
104155
156+
Two call styles are accepted:
157+
handle_model_command(provider, token, current_model) — new, preferred
158+
handle_model_command(copilot_token, current_model) — legacy Copilot-only
159+
"""
160+
if current_model is None:
161+
copilot_token, current_model = provider_or_token, token_or_model
162+
provider, token = "copilot", copilot_token
163+
else:
164+
provider, token = provider_or_token, token_or_model
165+
166+
if not token:
167+
print("Not authenticated with any model provider.\n", file=sys.stderr)
168+
return current_model
169+
if provider == "copilot":
170+
return _handle_copilot_model(token, current_model)
171+
if provider == "openrouter":
172+
return _handle_openrouter_model(token, current_model)
173+
print(f"Unknown provider: {provider}\n", file=sys.stderr)
105174
return current_model

iclaw/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ def load_github_token():
2525
return _load_config().get("github_token")
2626

2727

28+
def load_openrouter_api_key():
29+
return os.environ.get("OPENROUTER_API_KEY") or _load_config().get(
30+
"openrouter_api_key"
31+
)
32+
33+
34+
def save_openrouter_api_key(api_key):
35+
config = _load_config()
36+
config["openrouter_api_key"] = api_key
37+
_save_config(config)
38+
39+
2840
def load_session_settings() -> dict:
2941
config = _load_config()
3042
return {

0 commit comments

Comments
 (0)