Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/llm.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ A gateway preset is a built-in, ready-made custom provider for a well-known Open
| `api-route` | `https://global.api-route.com/v1` | `gpt-6.1-sol` | `API_ROUTE_API_KEY` | `frontier` |
| `cheaperinference` | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini` | `CHEAPER_INFERENCE_API_KEY` | `frontier` |
| `llm-buzz` | `https://api.llm.buzz/api/v1` | `openai/gpt-5.4-mini` | `LLM_BUZZ_API_KEY` | `frontier` |
| `opper` | `https://api.opper.ai/v3/compat` | `gpt-6.1-terra` | `OPPER_API_KEY` | `frontier` |
| `requesty` | `https://router.requesty.ai/v1` (EU: `https://router.eu.requesty.ai/v1`) | `openai/gpt-4o-mini` | `REQUESTY_API_KEY` | `frontier` |

When `llm.provider` names `X`, gortex resolves it in this order: a true built-in provider `X` → your custom provider `X` (global `providers.json`, the opt-in repo-local one, or inline `llm.custom.X`) → the preset `X`. Your own entry always wins over a same-named preset, as a whole — no preset field leaks into it. `GORTEX_LLM_MODEL` / `GORTEX_LLM_EFFORT` apply on top of whichever entry is resolved.
Expand Down
10 changes: 10 additions & 0 deletions internal/llm/presets.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ var gatewayPresets = map[string]CustomProvider{
SchemaMode: "json_schema",
PromptProfile: "frontier",
},
// Opper (https://docs.opper.ai) is an EU-hosted OpenAI-compatible
// gateway at /v3/compat (no /v1); model ids are bare pool names, and a
// "provider/model" id such as "azure/gpt-5.5" pins one route.
"opper": {
BaseURL: "https://api.opper.ai/v3/compat",
Model: "gpt-6.1-terra",
APIKeyEnv: "OPPER_API_KEY",
SchemaMode: "json_schema",
PromptProfile: "frontier",
},
// Requesty (https://docs.requesty.ai) fronts many vendors behind one
// key; model ids carry a vendor prefix. Regional routers:
// https://router.eu.requesty.ai/v1 (and .us / .ap).
Expand Down
6 changes: 6 additions & 0 deletions internal/llm/presets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ func TestConfig_ActiveCustom_PresetResolution(t *testing.T) {
t.Errorf("llm-buzz preset: %+v ok=%v profile=%v", cp, ok, ProfileFor(buzz))
}

opper := Config{Provider: "opper"}
cp, ok = opper.ActiveCustom()
if !ok || cp.BaseURL != "https://api.opper.ai/v3/compat" || cp.Model != "gpt-6.1-terra" || cp.APIKeyEnv != "OPPER_API_KEY" || cp.SchemaMode != "json_schema" || ProfileFor(opper) != ProfileFrontier {
t.Errorf("opper preset: %+v ok=%v profile=%v", cp, ok, ProfileFor(opper))
}

// A user custom provider of the same name always wins, wholesale —
// no preset field leaks into it.
c.Custom = map[string]CustomProvider{"testgw": {BaseURL: "https://mine/v1", Model: "my-model"}}
Expand Down
34 changes: 34 additions & 0 deletions internal/llm/provider/provider_custom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ func TestNew_CustomProviderDispatches(t *testing.T) {
}
}

// TestNew_CustomBaseURLPathIsKept proves a base URL whose path is not
// /v1 (Opper's /v3/compat) keeps that path when /chat/completions is
// appended.
func TestNew_CustomBaseURLPathIsKept(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = io.WriteString(w, `{"choices":[{"message":{"content":"ok"}}]}`)
}))
defer srv.Close()

t.Setenv("MY_GATEWAY_KEY", "secret")
cfg := llm.Config{
Provider: "mygw",
Custom: map[string]llm.CustomProvider{
"mygw": {BaseURL: srv.URL + "/v3/compat", Model: "some-model", APIKeyEnv: "MY_GATEWAY_KEY"},
},
}
p, err := New(cfg)
if err != nil {
t.Fatal(err)
}
defer p.Close()

if _, err := p.Complete(context.Background(), llm.CompletionRequest{
Messages: []llm.Message{{Role: llm.RoleUser, Content: "hi"}},
}); err != nil {
t.Fatal(err)
}
if gotPath != "/v3/compat/chat/completions" {
t.Errorf("path=%q want /v3/compat/chat/completions", gotPath)
}
}

func TestNew_CustomKeylessOmitsAuth(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading