Skip to content

Commit 06064fd

Browse files
authored
Merge pull request #27 from utopia-php/feat/openrouter-adapter
feat: add OpenRouter adapter
2 parents 0522279 + eafd745 commit 06064fd

File tree

11 files changed

+1008
-20
lines changed

11 files changed

+1008
-20
lines changed

.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: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,60 @@
11
name: "Tests"
22

3-
on: [ pull_request ]
3+
on: [pull_request]
44
jobs:
5-
lint:
6-
name: Tests
5+
unit:
6+
name: Unit Tests
77
runs-on: ubuntu-latest
8+
steps:
9+
- name: Checkout repository
10+
uses: actions/checkout@v3
11+
with:
12+
fetch-depth: 2
813

14+
- run: git checkout HEAD^2
15+
16+
- name: Build and start services
17+
run: |
18+
docker compose up -d
19+
sleep 10
20+
21+
- name: Run Unit Tests
22+
run: |
23+
docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml \
24+
tests/Agents/AgentTest.php \
25+
tests/Agents/SchemaTest.php \
26+
tests/Agents/Messages \
27+
tests/Agents/Roles \
28+
tests/Agents/Schema
29+
30+
conversation:
31+
name: "${{ matrix.provider }} Tests"
32+
runs-on: ubuntu-latest
33+
strategy:
34+
fail-fast: false
35+
matrix:
36+
include:
37+
- provider: OpenAI
38+
test: tests/Agents/Conversation/ConversationOpenAITest.php
39+
env_key: LLM_KEY_OPENAI
40+
- provider: Anthropic
41+
test: tests/Agents/Conversation/ConversationAnthropicTest.php
42+
env_key: LLM_KEY_ANTHROPIC
43+
- provider: Deepseek
44+
test: tests/Agents/Conversation/ConversationDeepseekTest.php
45+
env_key: LLM_KEY_DEEPSEEK
46+
- provider: XAI
47+
test: tests/Agents/Conversation/ConversationXAITest.php
48+
env_key: LLM_KEY_XAI
49+
- provider: Perplexity
50+
test: tests/Agents/Conversation/ConversationPerplexityTest.php
51+
env_key: LLM_KEY_PERPLEXITY
52+
- provider: Gemini
53+
test: tests/Agents/Conversation/ConversationGeminiTest.php
54+
env_key: LLM_KEY_GEMINI
55+
- provider: OpenRouter
56+
test: tests/Agents/Conversation/ConversationOpenRouterTest.php
57+
env_key: LLM_KEY_OPENROUTER
958
steps:
1059
- name: Checkout repository
1160
uses: actions/checkout@v3
@@ -14,18 +63,45 @@ jobs:
1463

1564
- run: git checkout HEAD^2
1665

17-
- name: Build
66+
- name: Setup PHP
67+
uses: shivammathur/setup-php@v2
68+
with:
69+
php-version: "8.3"
70+
71+
- name: Install dependencies
72+
run: composer install --no-interaction --prefer-dist
73+
74+
- name: Run ${{ matrix.provider }} Tests
1875
env:
1976
LLM_KEY_ANTHROPIC: ${{ secrets.LLM_KEY_ANTHROPIC }}
2077
LLM_KEY_OPENAI: ${{ secrets.LLM_KEY_OPENAI }}
2178
LLM_KEY_DEEPSEEK: ${{ secrets.LLM_KEY_DEEPSEEK }}
2279
LLM_KEY_XAI: ${{ secrets.LLM_KEY_XAI }}
2380
LLM_KEY_PERPLEXITY: ${{ secrets.LLM_KEY_PERPLEXITY }}
2481
LLM_KEY_GEMINI: ${{ secrets.LLM_KEY_GEMINI }}
25-
run: |
26-
docker compose build
27-
docker compose up -d
28-
sleep 10
82+
LLM_KEY_OPENROUTER: ${{ secrets.LLM_KEY_OPENROUTER }}
83+
run: vendor/bin/phpunit --configuration phpunit.xml ${{ matrix.test }}
2984

30-
- name: Run Tests
31-
run: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests
85+
diffcheck:
86+
name: DiffCheck Tests
87+
runs-on: ubuntu-latest
88+
steps:
89+
- name: Checkout repository
90+
uses: actions/checkout@v3
91+
with:
92+
fetch-depth: 2
93+
94+
- run: git checkout HEAD^2
95+
96+
- name: Setup PHP
97+
uses: shivammathur/setup-php@v2
98+
with:
99+
php-version: "8.3"
100+
101+
- name: Install dependencies
102+
run: composer install --no-interaction --prefer-dist
103+
104+
- name: Run DiffCheck Tests
105+
env:
106+
LLM_KEY_OPENAI: ${{ secrets.LLM_KEY_OPENAI }}
107+
run: vendor/bin/phpunit --configuration phpunit.xml tests/Agents/DiffCheck

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:

pint.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,8 @@
22
"rules": {
33
"php_unit_method_casing": false,
44
"new_with_parentheses": false
5-
}
5+
},
6+
"notPath": [
7+
"src/Agents/Adapters/OpenRouter/Models.php"
8+
]
69
}

scripts/sync-openrouter-models.php

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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+
$safeId = str_replace("'", "\\'", $id);
159+
$lines[] = " public const {$name} = '{$safeId}';";
160+
}
161+
162+
$lines[] = '';
163+
// No DEFAULT_MODEL — the default is set in OpenRouter::__construct()
164+
165+
// Full MODELS array as plain strings
166+
$lines[] = '';
167+
$lines[] = ' /**';
168+
$lines[] = ' * Full model catalog. Use model IDs directly or via named constants above.';
169+
$lines[] = ' *';
170+
$lines[] = ' * @var list<string>';
171+
$lines[] = ' */';
172+
$lines[] = ' public const MODELS = [';
173+
foreach ($modelIds as $id) {
174+
$safeId = str_replace("'", "\\'", $id);
175+
$lines[] = " '{$safeId}',";
176+
}
177+
$lines[] = ' ];';
178+
$lines[] = '}';
179+
$lines[] = '';
180+
181+
$dir = dirname($outputPath);
182+
if (! is_dir($dir)) {
183+
mkdir($dir, 0755, true);
184+
}
185+
186+
file_put_contents($outputPath, implode("\n", $lines));
187+
188+
echo "Wrote {$curatedCount} named constants + {$totalCount} model IDs to {$outputPath}\n";

0 commit comments

Comments
 (0)