Skip to content

Commit b573c1b

Browse files
committed
feat: add OpenRouter adapter
Adds OpenRouter as a new AI provider, extending the OpenAI adapter with custom HTTP headers (HTTP-Referer, X-Title) and provider-prefixed model ID normalization. Includes a PHP sync script to generate model constants from the live OpenRouter API catalog.
1 parent 0522279 commit b573c1b

10 files changed

Lines changed: 925 additions & 9 deletions

File tree

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ LLM_KEY_OPENAI=sk-proj-1234567890
33
LLM_KEY_DEEPSEEK=sk-1234567890
44
LLM_KEY_XAI=xai-1234567890
55
LLM_KEY_PERPLEXITY=pplx-1234567890
6-
LLM_KEY_GEMINI=AI1234567890
6+
LLM_KEY_GEMINI=AI1234567890
7+
LLM_KEY_OPENROUTER=sk-or-v1-1234567890

.github/workflows/tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ jobs:
2222
LLM_KEY_XAI: ${{ secrets.LLM_KEY_XAI }}
2323
LLM_KEY_PERPLEXITY: ${{ secrets.LLM_KEY_PERPLEXITY }}
2424
LLM_KEY_GEMINI: ${{ secrets.LLM_KEY_GEMINI }}
25+
LLM_KEY_OPENROUTER: ${{ secrets.LLM_KEY_OPENROUTER }}
2526
run: |
2627
docker compose build
2728
docker compose up -d

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP ve
2121

2222
## Features
2323

24-
- **Multiple AI Providers** - Support for OpenAI, Anthropic, Deepseek, Perplexity, and XAI APIs
24+
- **Multiple AI Providers** - Support for OpenAI, Anthropic, Deepseek, Perplexity, XAI, Gemini, and OpenRouter APIs
2525
- **Flexible Message Types** - Support for text and structured content in messages
2626
- **Conversation Management** - Easy-to-use conversation handling between agents and users
2727
- **Model Selection** - Choose from various AI models (GPT-4, Claude 3, Deepseek Chat, Sonar, Grok, etc.)
@@ -155,6 +155,28 @@ Available XAI Models:
155155
- `MODEL_GROK_3_MINI`: Mini version of Grok model
156156
- `MODEL_GROK_2_IMAGE`: Latest Grok model with image support
157157

158+
#### OpenRouter
159+
160+
```php
161+
use Utopia\Agents\Adapters\OpenRouter;
162+
use Utopia\Agents\Adapters\OpenRouter\Models as OpenRouterModels;
163+
164+
$openrouter = new OpenRouter(
165+
apiKey: 'your-api-key',
166+
model: OpenRouterModels::MODEL_OPENAI_GPT_4O,
167+
maxTokens: 2048,
168+
temperature: 0.7,
169+
httpReferer: 'https://your-app.example',
170+
xTitle: 'Your App Name'
171+
);
172+
```
173+
174+
- Named constants are provided for popular models from major providers (OpenAI, Anthropic, Google, Meta, DeepSeek, Mistral, xAI)
175+
- `Models::MODELS` contains the full model catalog; the adapter defaults to `openai/gpt-4o`
176+
- Arbitrary model IDs like `'openai/gpt-5-nano'` or `'anthropic/claude-sonnet-4'` are also accepted directly
177+
- `httpReferer` and `xTitle` are optional and enable OpenRouter app attribution headers
178+
- To re-sync constants from the live OpenRouter API, run `php scripts/sync-openrouter-models.php`
179+
158180
### Managing Conversations
159181

160182
```php

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ services:
1515
- LLM_KEY_XAI=${LLM_KEY_XAI:-}
1616
- LLM_KEY_PERPLEXITY=${LLM_KEY_PERPLEXITY:-}
1717
- LLM_KEY_GEMINI=${LLM_KEY_GEMINI:-}
18+
- LLM_KEY_OPENROUTER=${LLM_KEY_OPENROUTER:-}
1819
depends_on:
1920
- ollama
2021
networks:

scripts/sync-openrouter-models.php

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
/**
5+
* Fetches the OpenRouter model catalog and generates Models.php
6+
*
7+
* Usage: php scripts/sync-openrouter-models.php [output-path]
8+
*
9+
* Only models from curated providers get named constants.
10+
* The full catalog is available via the MODELS array.
11+
*/
12+
$endpoint = getenv('OPENROUTER_MODELS_ENDPOINT') ?: 'https://openrouter.ai/api/v1/models';
13+
$defaultOutput = __DIR__.'/../src/Agents/Adapters/OpenRouter/Models.php';
14+
$outputPath = $argv[1] ?? $defaultOutput;
15+
16+
// Providers whose models get named class constants
17+
$curatedProviders = [
18+
'anthropic',
19+
'openai',
20+
'google',
21+
'meta-llama',
22+
'deepseek',
23+
'mistralai',
24+
'x-ai',
25+
];
26+
27+
// Skip model IDs matching these patterns (old/niche variants)
28+
$skipPatterns = [
29+
'/:extended$/', // extended-context variants
30+
'/:free$/', // free-tier duplicates
31+
'/:beta$/', // beta tags
32+
'/-\d{4}-\d{2}-\d{2}/', // date-pinned snapshots (e.g. gpt-4o-2024-08-06)
33+
'/-\d{4}$/', // short date pins (e.g. gpt-4-0314)
34+
'/-\d{4}-preview/', // date preview variants (e.g. gpt-4-1106-preview)
35+
'/gpt-3\.5/', // legacy GPT-3.5 models
36+
'/gpt-4-turbo/', // legacy GPT-4 turbo
37+
'/-preview$/', // generic preview suffixes
38+
];
39+
40+
$headers = ['Accept: application/json'];
41+
42+
$apiKey = getenv('OPENROUTER_API_KEY') ?: getenv('LLM_KEY_OPENROUTER');
43+
if ($apiKey) {
44+
$headers[] = "Authorization: Bearer {$apiKey}";
45+
}
46+
47+
$context = stream_context_create([
48+
'http' => [
49+
'header' => implode("\r\n", $headers),
50+
'timeout' => 30,
51+
],
52+
]);
53+
54+
$response = file_get_contents($endpoint, false, $context);
55+
if ($response === false) {
56+
fwrite(STDERR, "Failed to fetch OpenRouter models from {$endpoint}\n");
57+
exit(1);
58+
}
59+
60+
$payload = json_decode($response, true);
61+
if (! is_array($payload) || ! isset($payload['data']) || ! is_array($payload['data'])) {
62+
fwrite(STDERR, "OpenRouter models response did not include a data array\n");
63+
exit(1);
64+
}
65+
66+
$models = array_filter($payload['data'], fn ($m) => is_array($m) && isset($m['id']) && is_string($m['id']) && $m['id'] !== '');
67+
$models = array_values($models);
68+
usort($models, fn ($a, $b) => strcmp($a['id'], $b['id']));
69+
70+
if (count($models) === 0) {
71+
fwrite(STDERR, "OpenRouter models response was empty\n");
72+
exit(1);
73+
}
74+
75+
$modelIds = array_map(fn ($m) => $m['id'], $models);
76+
77+
/**
78+
* Convert a model ID to a PHP constant name (MODEL_PROVIDER_NAME).
79+
*/
80+
function toConstantName(string $id): string
81+
{
82+
$name = strtoupper($id);
83+
$name = preg_replace('/[^A-Z0-9]+/', '_', $name);
84+
$name = preg_replace('/_+/', '_', $name);
85+
$name = trim($name, '_');
86+
87+
if ($name === '') {
88+
return 'MODEL_UNKNOWN';
89+
}
90+
91+
return "MODEL_{$name}";
92+
}
93+
94+
// Build curated constants (named) and the full ID list
95+
$curatedConstants = []; // name => id
96+
$usedNames = [];
97+
98+
foreach ($modelIds as $id) {
99+
$provider = explode('/', $id, 2)[0];
100+
101+
if (! in_array($provider, $curatedProviders, true)) {
102+
continue;
103+
}
104+
105+
// Skip date-pinned snapshots, free/beta/extended variants
106+
$dominated = false;
107+
foreach ($skipPatterns as $pattern) {
108+
if (preg_match($pattern, $id)) {
109+
$dominated = true;
110+
break;
111+
}
112+
}
113+
if ($dominated) {
114+
continue;
115+
}
116+
117+
$name = toConstantName($id);
118+
119+
if (isset($usedNames[$name])) {
120+
$name .= '_'.strtoupper(substr(sha1($id), 0, 8));
121+
}
122+
123+
$usedNames[$name] = true;
124+
$curatedConstants[$name] = $id;
125+
}
126+
127+
// Generate PHP
128+
$now = gmdate('Y-m-d\TH:i:s\Z');
129+
$totalCount = count($modelIds);
130+
$curatedCount = count($curatedConstants);
131+
132+
$lines = [];
133+
$lines[] = '<?php';
134+
$lines[] = '';
135+
$lines[] = 'namespace Utopia\Agents\Adapters\OpenRouter;';
136+
$lines[] = '';
137+
$lines[] = '/**';
138+
$lines[] = ' * Generated by scripts/sync-openrouter-models.php — do not edit by hand.';
139+
$lines[] = " * Source: {$endpoint}";
140+
$lines[] = " * Synced at: {$now}";
141+
$lines[] = " * Named constants: {$curatedCount} (curated providers)";
142+
$lines[] = " * Total models: {$totalCount}";
143+
$lines[] = ' */';
144+
$lines[] = 'final class Models';
145+
$lines[] = '{';
146+
147+
// Named constants grouped by provider
148+
$currentProvider = '';
149+
foreach ($curatedConstants as $name => $id) {
150+
$provider = explode('/', $id, 2)[0];
151+
if ($provider !== $currentProvider) {
152+
if ($currentProvider !== '') {
153+
$lines[] = '';
154+
}
155+
$lines[] = " // {$provider}";
156+
$currentProvider = $provider;
157+
}
158+
$lines[] = " public const {$name} = '{$id}';";
159+
}
160+
161+
$lines[] = '';
162+
// No DEFAULT_MODEL — the default is set in OpenRouter::__construct()
163+
164+
// Full MODELS array as plain strings
165+
$lines[] = '';
166+
$lines[] = ' /**';
167+
$lines[] = ' * Full model catalog. Use model IDs directly or via named constants above.';
168+
$lines[] = ' *';
169+
$lines[] = ' * @var list<string>';
170+
$lines[] = ' */';
171+
$lines[] = ' public const MODELS = [';
172+
foreach ($modelIds as $id) {
173+
$lines[] = " '{$id}',";
174+
}
175+
$lines[] = ' ];';
176+
$lines[] = '}';
177+
$lines[] = '';
178+
179+
$dir = dirname($outputPath);
180+
if (! is_dir($dir)) {
181+
mkdir($dir, 0755, true);
182+
}
183+
184+
file_put_contents($outputPath, implode("\n", $lines));
185+
186+
echo "Wrote {$curatedCount} named constants + {$totalCount} model IDs to {$outputPath}\n";

src/Agents/Adapters/OpenAI.php

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,7 @@ public function send(array $messages, ?callable $listener = null): Message
109109
throw new \Exception('Agent not set');
110110
}
111111

112-
$client = new Client();
113-
$client
114-
->setTimeout($this->timeout)
115-
->addHeader('authorization', 'Bearer '.$this->apiKey)
116-
->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON);
112+
$client = $this->createClient();
117113

118114
$formattedMessages = [];
119115
foreach ($messages as $message) {
@@ -304,7 +300,7 @@ public function getModels(): array
304300
*/
305301
protected function usesMaxCompletionTokens(): bool
306302
{
307-
return in_array($this->model, [
303+
return in_array($this->normalizeModelForCompatibilityChecks(), [
308304
self::MODEL_GPT_5_NANO,
309305
self::MODEL_O4_MINI,
310306
self::MODEL_O3,
@@ -317,7 +313,7 @@ protected function usesMaxCompletionTokens(): bool
317313
*/
318314
protected function usesDefaultTemperatureOnly(): bool
319315
{
320-
$usesDefaultTemperatureOnly = in_array($this->model, [
316+
$usesDefaultTemperatureOnly = in_array($this->normalizeModelForCompatibilityChecks(), [
321317
self::MODEL_GPT_5_NANO,
322318
], true);
323319

@@ -333,6 +329,28 @@ protected function usesDefaultTemperatureOnly(): bool
333329
return $usesDefaultTemperatureOnly;
334330
}
335331

332+
/**
333+
* Create a configured HTTP client for API requests.
334+
*/
335+
protected function createClient(): Client
336+
{
337+
$client = new Client();
338+
$client
339+
->setTimeout($this->timeout)
340+
->addHeader('authorization', 'Bearer '.$this->apiKey)
341+
->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON);
342+
343+
return $client;
344+
}
345+
346+
/**
347+
* Normalize the current model name for provider compatibility checks.
348+
*/
349+
protected function normalizeModelForCompatibilityChecks(): string
350+
{
351+
return $this->model;
352+
}
353+
336354
/**
337355
* Get current model
338356
*/

0 commit comments

Comments
 (0)