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 catalog/live/fetchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ var Registry = map[string]FetchFunc{
"zai_payg": FetchZAI,
"zai_coding": FetchZAICoding,
"concentrate": FetchConcentrate,
"opengateway": FetchOpenGateway,
"agnes": FetchAgnes,
"longcat": FetchLongCat,
"canopywave": FetchCanopyWave,
Expand Down
120 changes: 120 additions & 0 deletions catalog/live/fetchers_opengateway.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package live

import (
"context"
"fmt"
"net/http"
"strconv"
"strings"

"github.com/GrayCodeAI/eyrie/catalog/opengateway"
)

// opengatewayModel is the subset of the public GET /v1/models OpenGateway object
// we consume. The gateway returns pricing inline (effective_pricing is the rate
// actually billed to you; pricing is the underlying provider rate), so no
// authenticated per-model fetch is required.
type opengatewayModel struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Aliases []string `json:"aliases"`
ContextWindow int `json:"context_window"`
Pricing struct {
Prompt string `json:"prompt"`
Completion string `json:"completion"`
InputCacheRead string `json:"input_cache_read"`
} `json:"pricing"`
EffectivePricing struct {
Prompt string `json:"prompt"`
Completion string `json:"completion"`
InputCacheRead string `json:"input_cache_read"`
} `json:"effective_pricing"`
}

// FetchOpenGateway lists models from the public OpenGateway model catalog.
// No API key is required to list models or read pricing (pricing is public on
// GET /v1/models); OPENGATEWAY_API_KEY is only needed for inference requests.
func FetchOpenGateway(env map[string]string) ([]Entry, error) {
apiKey := strings.TrimSpace(env["OPENGATEWAY_API_KEY"])
baseURL := strings.TrimRight(envOr(env, "OPENGATEWAY_BASE_URL", opengateway.DefaultBaseURL), "/")

ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
if err != nil {
return nil, fmt.Errorf("live: opengateway: create request: %w", err)
}
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "eyrie-model-catalog/1.0")

resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("live: opengateway: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("live: opengateway model fetch failed (%d)", resp.StatusCode)
}

var payload struct {
Data []opengatewayModel `json:"data"`
}
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
return nil, fmt.Errorf("live: opengateway: decode: %w", err)
}
return opengatewayEntries(payload.Data), nil
}

// opengatewayEntries maps the OpenGateway model objects to eyrie Entries.
func opengatewayEntries(models []opengatewayModel) []Entry {
var entries []Entry
for _, m := range models {
id := strings.TrimSpace(m.ID)
if id == "" {
continue
}
e := Entry{
ID: id,
DisplayName: strings.TrimSpace(m.Name),
Description: strings.TrimSpace(m.Description),
OwnedBy: "gitlawb",
ContextWindow: m.ContextWindow,
}

// Prefer the gateway-billed rate (effective_pricing); fall back to provider pricing.
// OpenGateway prices are given per token (e.g. 0.000000522 USD/token); convert to per-1M.
e.InputPricePer1M = ratePerToken(m.EffectivePricing.Prompt, m.Pricing.Prompt) * 1_000_000
e.OutputPricePer1M = ratePerToken(m.EffectivePricing.Completion, m.Pricing.Completion) * 1_000_000
e.CachedReadPricePer1M = ratePerToken(m.EffectivePricing.InputCacheRead, m.Pricing.InputCacheRead) * 1_000_000

// Every model is OpenAI chat-completions compatible (the gateway normalizes
// tool calling and reasoning params across providers).
e.Features = append(e.Features, "function_calling")
if strings.Contains(id, "kimi") || strings.Contains(id, "glm") || strings.Contains(id, "nemotron") || strings.Contains(id, "qwen") {
e.ThinkingEnabled = true
e.Features = append(e.Features, "thinking:enabled")
}
_ = m.Aliases
entries = append(entries, e)
}
return entries
}

// ratePerToken parses a price string like "0.000000522" (USD per token).
func ratePerToken(effective, fallback string) float64 {
s := strings.TrimSpace(effective)
if s == "" {
s = strings.TrimSpace(fallback)
}
if s == "" {
return 0
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return v
}
128 changes: 128 additions & 0 deletions catalog/live/fetchers_opengateway_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package live

import (
"net/http"
"net/http/httptest"
"testing"
)

func TestFetchOpenGateway_ParsesPricingAndContext(t *testing.T) {
t.Parallel()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/models" {
http.NotFound(w, r)
return
}
// No auth required for the public listing.
if r.Header.Get("Authorization") != "" {
t.Errorf("did not expect Authorization header on public /models, got %q", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data": [
{
"id": "auto",
"name": "Auto (smart routing)",
"description": "picks the cheapest capable model",
"context_window": null,
"aliases": ["gitlawb/auto"],
"pricing": null,
"effective_pricing": null
},
{
"id": "xiaomi/mimo-v2.5-pro",
"name": "MiMo V2.5-Pro",
"description": "general large language model",
"aliases": [],
"context_window": 262144,
"pricing": {"prompt": "0.000000435", "completion": "0.00000087", "input_cache_read": "0.0000000036"},
"effective_pricing": {"prompt": "0.000000522", "completion": "0.000001044", "input_cache_read": "0.00000000432"}
},
{
"id": "nvidia/nemotron-3-ultra-550b-a55b:free",
"name": "Nemotron 3 UltraFREE",
"description": "frontier reasoning MoE",
"aliases": [],
"context_window": 131072,
"pricing": {"prompt": "0", "completion": "0", "input_cache_read": "0"},
"effective_pricing": {"prompt": "0", "completion": "0", "input_cache_read": "0"}
}
]
}`))
}))
defer server.Close()

entries, err := FetchOpenGateway(map[string]string{"OPENGATEWAY_BASE_URL": server.URL})
if err != nil {
t.Fatalf("FetchOpenGateway: %v", err)
}
if len(entries) != 3 {
t.Fatalf("entries = %d, want 3", len(entries))
}

want := map[string]struct {
in, out, cached float64
ctx int
}{
"auto": {0, 0, 0, 0},
"xiaomi/mimo-v2.5-pro": {0.522, 1.044, 0.00432, 262144},
"nvidia/nemotron-3-ultra-550b-a55b:free": {0, 0, 0, 131072},
}
for _, e := range entries {
w, ok := want[e.ID]
if !ok {
t.Errorf("unexpected entry %q", e.ID)
continue
}
if e.ContextWindow != w.ctx {
t.Errorf("%s context_window = %d, want %d", e.ID, e.ContextWindow, w.ctx)
}
if e.InputPricePer1M != w.in {
t.Errorf("%s input = %v, want %v", e.ID, e.InputPricePer1M, w.in)
}
if e.OutputPricePer1M != w.out {
t.Errorf("%s output = %v, want %v", e.ID, e.OutputPricePer1M, w.out)
}
if e.CachedReadPricePer1M != w.cached {
t.Errorf("%s cached_read = %v, want %v", e.ID, e.CachedReadPricePer1M, w.cached)
}
}
}

func TestFetchOpenGateway_EmptyCatalog(t *testing.T) {
t.Parallel()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer server.Close()

entries, err := FetchOpenGateway(map[string]string{"OPENGATEWAY_BASE_URL": server.URL})
if err != nil {
t.Fatalf("FetchOpenGateway: unexpected error on empty catalog: %v", err)
}
if len(entries) != 0 {
t.Fatalf("entries = %d, want 0 on empty catalog", len(entries))
}
}

func TestFetchOpenGateway_SendsAuthWhenKeyProvided(t *testing.T) {
t.Parallel()

var seenAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"auto","name":"Auto","effective_pricing":{},"pricing":{}}]}`))
}))
defer server.Close()

if _, err := FetchOpenGateway(map[string]string{"OPENGATEWAY_BASE_URL": server.URL, "OPENGATEWAY_API_KEY": "ogw_live_test"}); err != nil {
t.Fatalf("FetchOpenGateway: %v", err)
}
if seenAuth != "Bearer ogw_live_test" {
t.Errorf("Authorization header = %q, want %q", seenAuth, "Bearer ogw_live_test")
}
}
8 changes: 8 additions & 0 deletions catalog/opengateway/opengateway.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Package opengateway holds shared constants for the OpenGateway inference gateway
// (https://gitlawb.com/opengateway), an OpenAI-compatible endpoint that routes
// requests across providers (MiMo, Gemini, MiniMax, Qwen, Kimi, GLM, etc.) and
// returns the live model catalog with inline pricing from GET /v1/models.
package opengateway

// DefaultBaseURL is the OpenGateway API root.
const DefaultBaseURL = "https://opengateway.gitlawb.com/v1"
4 changes: 2 additions & 2 deletions catalog/provider_live_parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import (
func TestAllProviders_LiveFetchParity(t *testing.T) {
t.Parallel()
specs := registry.All()
if len(specs) != 26 {
t.Fatalf("expected 26 providers, got %d", len(specs))
if len(specs) != 27 {
t.Fatalf("expected 27 providers, got %d", len(specs))
}
for _, spec := range specs {
t.Run(spec.ProviderID, func(t *testing.T) {
Expand Down
31 changes: 27 additions & 4 deletions catalog/registry/provider_spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import (

func TestAllProviders_Count(t *testing.T) {
t.Parallel()
if n := len(registry.All()); n != 26 {
t.Fatalf("expected 26 providers, got %d", n)
if n := len(registry.All()); n != 27 {
t.Fatalf("expected 27 providers, got %d", n)
}
}

Expand Down Expand Up @@ -42,8 +42,8 @@ func TestProviderSpecs_AgnesOpenAIOnlyLongCatOpenAIPrimary(t *testing.T) {
func TestLiveFetcherKeys_AllProviders(t *testing.T) {
t.Parallel()
keys := registry.LiveFetcherKeys()
if len(keys) != 26 {
t.Fatalf("expected 26 live fetcher keys, got %d", len(keys))
if len(keys) != 27 {
t.Fatalf("expected 27 live fetcher keys, got %d", len(keys))
}
}

Expand Down Expand Up @@ -81,6 +81,29 @@ func TestConcentrateUsesResponsesAPI(t *testing.T) {
}
}

func TestOpenGatewaySpec(t *testing.T) {
t.Parallel()
spec, ok := registry.SpecByProviderID("opengateway")
if !ok {
t.Fatal("missing OpenGateway provider spec")
}
if spec.ProtocolID != "openai-chat-completions" {
t.Fatalf("protocol = %q, want openai-chat-completions", spec.ProtocolID)
}
if spec.AdapterID != "openai" {
t.Fatalf("adapter = %q, want openai", spec.AdapterID)
}
if !spec.PublicModelCatalog {
t.Fatal("OpenGateway model catalog must be public")
}
if !spec.RequiresKey {
t.Fatal("OpenGateway should require a key for inference (OPENGATEWAY_API_KEY)")
}
if spec.LiveFetcherKey != "opengateway" {
t.Fatalf("fetcher = %q", spec.LiveFetcherKey)
}
}

func TestProviderRuntimePolicy_Metadata(t *testing.T) {
t.Parallel()

Expand Down
9 changes: 9 additions & 0 deletions catalog/registry/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,15 @@ func providerSpecs() []ProviderSpec {
PublicModelCatalog: true,
ProtocolID: "openai-responses", AdapterID: "concentrate-responses", RuntimeProfileKey: "concentrate",
},
{
ProviderID: "opengateway", DisplayName: "OpenGateway (Pay-as-you-go)", DeploymentID: "opengateway-payg", SortOrder: 27, ChatPreference: 28,
RequiresKey: true, CredentialEnv: "OPENGATEWAY_API_KEY",
BaseURLEnv: []string{"OPENGATEWAY_BASE_URL"},
ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://opengateway.gitlawb.com/v1",
LiveFetcherKey: "opengateway", LiveCatalogKey: "opengateway",
PublicModelCatalog: true,
ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "opengateway",
},
{
ProviderID: "stepfun", DisplayName: "StepFun", DeploymentID: "stepfun-direct", SortOrder: 26, ChatPreference: 27,
RequiresKey: true, CredentialEnv: "STEP_API_KEY",
Expand Down
2 changes: 2 additions & 0 deletions catalog/v1_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"xai": {ID: "xai", Name: "xAI"},
"openrouter": {ID: "openrouter", Name: "OpenRouter"},
"concentrate": {ID: "concentrate", Name: "Concentrate AI (Pay-as-you-go)"},
"opengateway": {ID: "opengateway", Name: "OpenGateway (Pay-as-you-go)"},
"canopywave": {ID: "canopywave", Name: "CanopyWave"},
"zai_payg": {ID: "zai_payg", Name: "Z.AI Pay-as-you-go"},
"zai_coding": {ID: "zai_coding", Name: "Z.AI Coding Plan"},
Expand Down Expand Up @@ -51,6 +52,7 @@
"grok-direct": deployment("grok-direct", "Grok", "xai", "openai-chat-completions", "grok", NativeModelIDCatalogKnown),
"openrouter": deployment("openrouter", "OpenRouter", "openrouter", "openai-chat-completions", "openrouter", NativeModelIDDiscovered),
"concentrate-payg": deployment("concentrate-payg", "Concentrate AI (Pay-as-you-go)", "concentrate", "openai-responses", "concentrate-responses", NativeModelIDDiscovered),
"opengateway-payg": deployment("opengateway-payg", "OpenGateway (Pay-as-you-go)", "opengateway", "openai-chat-completions", "openai", NativeModelIDDiscovered),
"zai_payg-direct": deployment("zai_payg-direct", "Z.AI Pay-as-you-go", "zai_payg", "openai-chat-completions", "zai_payg", NativeModelIDCatalogKnown),
"zai_coding-direct": deployment("zai_coding-direct", "Z.AI Coding Plan", "zai_coding", "openai-chat-completions", "zai_coding", NativeModelIDCatalogKnown),
"canopywave": deployment("canopywave", "CanopyWave", "canopywave", "openai-chat-completions", "canopywave", NativeModelIDDiscovered),
Expand Down Expand Up @@ -124,7 +126,7 @@
}

// DefaultOfferingTemplates returns offering templates for Azure deployments (model mappings required).
func DefaultOfferingTemplates(generatedAt time.Time) []ModelOfferingTemplate {

Check failure on line 129 in catalog/v1_defaults.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: DefaultOfferingTemplates
var out []ModelOfferingTemplate
for _, model := range seedOpenAIModels {
modelID := "openai/" + model.ID
Expand Down
4 changes: 4 additions & 0 deletions client/adapters/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ var (
CanopyWaveCompat = OpenAICompatConfig{
MaxTokensField: "max_tokens",
}
OpenGatewayCompat = OpenAICompatConfig{
MaxTokensField: "max_tokens",
SupportsUsageInStreaming: true,
}
OllamaCompat = OpenAICompatConfig{
MaxTokensField: "max_tokens",
}
Expand Down
Loading
Loading