Skip to content

Commit 0606096

Browse files
feat(providers): add openrouter attribution defaults
1 parent c47c1fa commit 0606096

15 files changed

Lines changed: 610 additions & 25 deletions

File tree

.env.template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@
163163
# OpenRouter (default base URL: https://openrouter.ai/api/v1)
164164
# OPENROUTER_API_KEY=sk-or-...
165165
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
166+
# OPENROUTER_SITE_URL=https://gomodel.enterpilot.io
167+
# OPENROUTER_APP_NAME=GOModel
166168

167169
# Azure OpenAI
168170
# AZURE_API_KEY=...

GETTING_STARTED.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ Provider credentials:
190190
| `GEMINI_BASE_URL` | Gemini (custom endpoint) |
191191
| `OPENROUTER_API_KEY` | OpenRouter (default base URL: `https://openrouter.ai/api/v1`) |
192192
| `OPENROUTER_BASE_URL` | OpenRouter (custom endpoint override) |
193+
| `OPENROUTER_SITE_URL` | OpenRouter attribution URL override (default: `https://gomodel.enterpilot.io`) |
194+
| `OPENROUTER_APP_NAME` | OpenRouter attribution title override (default: `GOModel`) |
193195
| `XAI_API_KEY` | xAI / Grok |
194196
| `XAI_BASE_URL` | xAI (custom endpoint) |
195197
| `GROQ_API_KEY` | Groq |
@@ -224,6 +226,9 @@ Ollama requires no API key. Even with no YAML and no `OLLAMA_BASE_URL` set, an O
224226
**Azure ships with a pinned API version by default.**
225227
If you do not set `AZURE_API_VERSION`, the gateway sends `api-version=2024-10-21`. Override it only when you need a different Azure API version.
226228

229+
**OpenRouter gets GOModel attribution headers by default.**
230+
When the `openrouter` provider is used, the gateway adds `HTTP-Referer` and `X-OpenRouter-Title` unless the request already provides them. Override the defaults with `OPENROUTER_SITE_URL` and `OPENROUTER_APP_NAME`.
231+
227232
**Partial YAML fields leave the rest at defaults.**
228233
YAML is unmarshalled onto the struct that was already populated by built-in defaults. Only fields that appear in the file are written. Omitting `max_backoff` from `resilience.retry` leaves it at `30s`; you do not need to repeat defaults you are happy with.
229234

cmd/gomodel/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"gomodel/internal/providers/groq"
2525
"gomodel/internal/providers/ollama"
2626
"gomodel/internal/providers/openai"
27+
"gomodel/internal/providers/openrouter"
2728
"gomodel/internal/providers/xai"
2829
"gomodel/internal/version"
2930

@@ -119,6 +120,7 @@ func main() {
119120
}
120121

121122
factory.Add(openai.Registration)
123+
factory.Add(openrouter.Registration)
122124
factory.Add(azure.Registration)
123125
factory.Add(anthropic.Registration)
124126
factory.Add(gemini.Registration)

config/config.example.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,14 +148,14 @@ providers:
148148

149149
# Example: OpenRouter
150150
# openrouter:
151-
# type: "openai"
151+
# type: "openrouter"
152152
# base_url: "https://openrouter.ai/api/v1"
153153
# api_key: "${OPENROUTER_API_KEY}"
154154

155155
# Example: Azure OpenAI
156156
# azure:
157157
# type: "azure"
158-
# base_url: "https://your-resource.openai.azure.com/openai/deployments/your-deployment"
158+
# base_url: "${AZURE_API_BASE}"
159159
# api_key: "${AZURE_API_KEY}"
160160
# api_version: "2024-10-21"
161161

config/config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func clearProviderEnvVars(t *testing.T) {
1515
"GEMINI_API_KEY", "GEMINI_BASE_URL",
1616
"XAI_API_KEY", "XAI_BASE_URL",
1717
"GROQ_API_KEY", "GROQ_BASE_URL",
18-
"OPENROUTER_API_KEY", "OPENROUTER_BASE_URL",
18+
"OPENROUTER_API_KEY", "OPENROUTER_BASE_URL", "OPENROUTER_SITE_URL", "OPENROUTER_APP_NAME",
1919
"AZURE_API_KEY", "AZURE_API_BASE", "AZURE_API_VERSION",
2020
"OLLAMA_API_KEY", "OLLAMA_BASE_URL",
2121
} {

docs/advanced/configuration.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ Set these to automatically register providers. No YAML configuration required.
136136

137137
Most providers can use a custom base URL via `<PROVIDER>_BASE_URL` (for example `OPENAI_BASE_URL`). OpenRouter defaults to `https://openrouter.ai/api/v1` and can be overridden with `OPENROUTER_BASE_URL`. Azure uses `AZURE_API_BASE` for its deployment base URL and accepts an optional `AZURE_API_VERSION` override; otherwise it defaults to `2024-10-21`.
138138

139+
For OpenRouter, GOModel also sends default attribution headers unless the request already sets them. Override those defaults with `OPENROUTER_SITE_URL` and `OPENROUTER_APP_NAME`.
140+
139141
### 2. `.env` File
140142

141143
GOModel automatically loads a `.env` file from the working directory at startup. This is convenient for local development.

internal/providers/azure/azure.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package azure
22

33
import (
4+
"context"
45
"net/http"
56
"net/url"
7+
"strconv"
68

79
"gomodel/internal/core"
810
"gomodel/internal/llmclient"
@@ -45,6 +47,92 @@ func NewWithHTTPClient(apiKey string, httpClient *http.Client, hooks llmclient.H
4547
return p
4648
}
4749

50+
func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) {
51+
var resp core.ModelsResponse
52+
if err := p.Do(ctx, llmclient.Request{
53+
Method: http.MethodGet,
54+
Endpoint: "/openai/models",
55+
}, &resp); err != nil {
56+
return nil, err
57+
}
58+
return &resp, nil
59+
}
60+
61+
func (p *Provider) CreateBatch(ctx context.Context, req *core.BatchRequest) (*core.BatchResponse, error) {
62+
if req == nil {
63+
return nil, core.NewInvalidRequestError("batch request is required", nil)
64+
}
65+
var resp core.BatchResponse
66+
if err := p.Do(ctx, llmclient.Request{
67+
Method: http.MethodPost,
68+
Endpoint: "/openai/batches",
69+
Body: req,
70+
}, &resp); err != nil {
71+
return nil, err
72+
}
73+
if resp.ProviderBatchID == "" {
74+
resp.ProviderBatchID = resp.ID
75+
}
76+
return &resp, nil
77+
}
78+
79+
func (p *Provider) GetBatch(ctx context.Context, id string) (*core.BatchResponse, error) {
80+
var resp core.BatchResponse
81+
if err := p.Do(ctx, llmclient.Request{
82+
Method: http.MethodGet,
83+
Endpoint: "/openai/batches/" + url.PathEscape(id),
84+
}, &resp); err != nil {
85+
return nil, err
86+
}
87+
if resp.ProviderBatchID == "" {
88+
resp.ProviderBatchID = resp.ID
89+
}
90+
return &resp, nil
91+
}
92+
93+
func (p *Provider) ListBatches(ctx context.Context, limit int, after string) (*core.BatchListResponse, error) {
94+
values := url.Values{}
95+
if limit > 0 {
96+
values.Set("limit", strconv.Itoa(limit))
97+
}
98+
if after != "" {
99+
values.Set("after", after)
100+
}
101+
102+
endpoint := "/openai/batches"
103+
if encoded := values.Encode(); encoded != "" {
104+
endpoint += "?" + encoded
105+
}
106+
107+
var resp core.BatchListResponse
108+
if err := p.Do(ctx, llmclient.Request{
109+
Method: http.MethodGet,
110+
Endpoint: endpoint,
111+
}, &resp); err != nil {
112+
return nil, err
113+
}
114+
for i := range resp.Data {
115+
if resp.Data[i].ProviderBatchID == "" {
116+
resp.Data[i].ProviderBatchID = resp.Data[i].ID
117+
}
118+
}
119+
return &resp, nil
120+
}
121+
122+
func (p *Provider) CancelBatch(ctx context.Context, id string) (*core.BatchResponse, error) {
123+
var resp core.BatchResponse
124+
if err := p.Do(ctx, llmclient.Request{
125+
Method: http.MethodPost,
126+
Endpoint: "/openai/batches/" + url.PathEscape(id) + "/cancel",
127+
}, &resp); err != nil {
128+
return nil, err
129+
}
130+
if resp.ProviderBatchID == "" {
131+
resp.ProviderBatchID = resp.ID
132+
}
133+
return &resp, nil
134+
}
135+
48136
func (p *Provider) SetAPIVersion(version string) {
49137
if version == "" {
50138
return

internal/providers/azure/azure_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,143 @@ func TestSetAPIVersion_OverridesDefault(t *testing.T) {
9999
t.Fatalf("api-version = %q, want 2025-04-01-preview", gotAPIVersion)
100100
}
101101
}
102+
103+
func TestListModels_UsesAzureOpenAIPath(t *testing.T) {
104+
var gotPath string
105+
var gotAPIVersion string
106+
107+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
108+
gotPath = r.URL.Path
109+
gotAPIVersion = r.URL.Query().Get("api-version")
110+
w.Header().Set("Content-Type", "application/json")
111+
_, _ = w.Write([]byte(`{"object":"list","data":[]}`))
112+
}))
113+
defer server.Close()
114+
115+
provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{})
116+
provider.SetBaseURL(server.URL)
117+
118+
_, err := provider.ListModels(context.Background())
119+
if err != nil {
120+
t.Fatalf("unexpected error: %v", err)
121+
}
122+
if gotPath != "/openai/models" {
123+
t.Fatalf("path = %q, want /openai/models", gotPath)
124+
}
125+
if gotAPIVersion != defaultAPIVersion {
126+
t.Fatalf("api-version = %q, want %q", gotAPIVersion, defaultAPIVersion)
127+
}
128+
}
129+
130+
func TestBatchEndpoints_UseAzureOpenAIPaths(t *testing.T) {
131+
tests := []struct {
132+
name string
133+
call func(*Provider) error
134+
wantPath string
135+
wantMethod string
136+
responseBody string
137+
}{
138+
{
139+
name: "create",
140+
call: func(p *Provider) error {
141+
_, err := p.CreateBatch(context.Background(), &core.BatchRequest{
142+
InputFileID: "file-123",
143+
Endpoint: "/v1/chat/completions",
144+
CompletionWindow: "24h",
145+
})
146+
return err
147+
},
148+
wantPath: "/openai/batches",
149+
wantMethod: http.MethodPost,
150+
responseBody: `{
151+
"id":"batch_123",
152+
"object":"batch",
153+
"endpoint":"/v1/chat/completions",
154+
"status":"validating",
155+
"created_at":1677652288,
156+
"request_counts":{"total":1,"completed":0,"failed":0}
157+
}`,
158+
},
159+
{
160+
name: "get",
161+
call: func(p *Provider) error {
162+
_, err := p.GetBatch(context.Background(), "batch_123")
163+
return err
164+
},
165+
wantPath: "/openai/batches/batch_123",
166+
wantMethod: http.MethodGet,
167+
responseBody: `{
168+
"id":"batch_123",
169+
"object":"batch",
170+
"endpoint":"/v1/chat/completions",
171+
"status":"validating",
172+
"created_at":1677652288,
173+
"request_counts":{"total":1,"completed":0,"failed":0}
174+
}`,
175+
},
176+
{
177+
name: "list",
178+
call: func(p *Provider) error {
179+
_, err := p.ListBatches(context.Background(), 10, "batch_122")
180+
return err
181+
},
182+
wantPath: "/openai/batches",
183+
wantMethod: http.MethodGet,
184+
responseBody: `{
185+
"object":"list",
186+
"data":[],
187+
"has_more":false
188+
}`,
189+
},
190+
{
191+
name: "cancel",
192+
call: func(p *Provider) error {
193+
_, err := p.CancelBatch(context.Background(), "batch_123")
194+
return err
195+
},
196+
wantPath: "/openai/batches/batch_123/cancel",
197+
wantMethod: http.MethodPost,
198+
responseBody: `{
199+
"id":"batch_123",
200+
"object":"batch",
201+
"endpoint":"/v1/chat/completions",
202+
"status":"cancelling",
203+
"created_at":1677652288,
204+
"request_counts":{"total":1,"completed":0,"failed":0}
205+
}`,
206+
},
207+
}
208+
209+
for _, tt := range tests {
210+
t.Run(tt.name, func(t *testing.T) {
211+
var gotPath string
212+
var gotMethod string
213+
var gotAPIVersion string
214+
215+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
216+
gotPath = r.URL.Path
217+
gotMethod = r.Method
218+
gotAPIVersion = r.URL.Query().Get("api-version")
219+
w.Header().Set("Content-Type", "application/json")
220+
_, _ = w.Write([]byte(tt.responseBody))
221+
}))
222+
defer server.Close()
223+
224+
provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{})
225+
provider.SetBaseURL(server.URL)
226+
227+
if err := tt.call(provider); err != nil {
228+
t.Fatalf("unexpected error: %v", err)
229+
}
230+
if gotPath != tt.wantPath {
231+
t.Fatalf("path = %q, want %q", gotPath, tt.wantPath)
232+
}
233+
if gotMethod != tt.wantMethod {
234+
t.Fatalf("method = %q, want %q", gotMethod, tt.wantMethod)
235+
}
236+
if gotAPIVersion != defaultAPIVersion {
237+
t.Fatalf("api-version = %q, want %q", gotAPIVersion, defaultAPIVersion)
238+
}
239+
})
240+
}
241+
}

internal/providers/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ var knownProviderEnvs = []struct {
3636
{"gemini", "gemini", "GEMINI_API_KEY", "GEMINI_BASE_URL", "", "", false},
3737
{"xai", "xai", "XAI_API_KEY", "XAI_BASE_URL", "", "", false},
3838
{"groq", "groq", "GROQ_API_KEY", "GROQ_BASE_URL", "", "", false},
39-
{"openrouter", "openai", "OPENROUTER_API_KEY", "OPENROUTER_BASE_URL", "", openRouterDefaultBaseURL, false},
39+
{"openrouter", "openrouter", "OPENROUTER_API_KEY", "OPENROUTER_BASE_URL", "", openRouterDefaultBaseURL, false},
4040
{"azure", "azure", "AZURE_API_KEY", "AZURE_API_BASE", "AZURE_API_VERSION", "", true},
4141
{"ollama", "ollama", "OLLAMA_API_KEY", "OLLAMA_BASE_URL", "", "", false},
4242
}

internal/providers/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,8 @@ func TestApplyProviderEnvVars_DiscoversOpenRouterFromAPIKey(t *testing.T) {
298298
if p.APIKey != "sk-openrouter" {
299299
t.Errorf("APIKey = %q, want sk-openrouter", p.APIKey)
300300
}
301-
if p.Type != "openai" {
302-
t.Errorf("Type = %q, want openai", p.Type)
301+
if p.Type != "openrouter" {
302+
t.Errorf("Type = %q, want openrouter", p.Type)
303303
}
304304
if p.BaseURL != "https://openrouter.ai/api/v1" {
305305
t.Errorf("BaseURL = %q, want https://openrouter.ai/api/v1", p.BaseURL)

0 commit comments

Comments
 (0)