diff --git a/catalog/live/fetchers.go b/catalog/live/fetchers.go index 929fa0e..9cde3af 100644 --- a/catalog/live/fetchers.go +++ b/catalog/live/fetchers.go @@ -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, diff --git a/catalog/live/fetchers_opengateway.go b/catalog/live/fetchers_opengateway.go new file mode 100644 index 0000000..6e6b0af --- /dev/null +++ b/catalog/live/fetchers_opengateway.go @@ -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 +} diff --git a/catalog/live/fetchers_opengateway_test.go b/catalog/live/fetchers_opengateway_test.go new file mode 100644 index 0000000..baf4270 --- /dev/null +++ b/catalog/live/fetchers_opengateway_test.go @@ -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") + } +} diff --git a/catalog/opengateway/opengateway.go b/catalog/opengateway/opengateway.go new file mode 100644 index 0000000..1682c23 --- /dev/null +++ b/catalog/opengateway/opengateway.go @@ -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" diff --git a/catalog/provider_live_parity_test.go b/catalog/provider_live_parity_test.go index 2c7198f..d5588c6 100644 --- a/catalog/provider_live_parity_test.go +++ b/catalog/provider_live_parity_test.go @@ -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) { diff --git a/catalog/registry/provider_spec_test.go b/catalog/registry/provider_spec_test.go index 84ed9d3..01d8482 100644 --- a/catalog/registry/provider_spec_test.go +++ b/catalog/registry/provider_spec_test.go @@ -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) } } @@ -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)) } } @@ -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() diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index cbbeb65..c01a439 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -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", diff --git a/catalog/v1_defaults.go b/catalog/v1_defaults.go index 663aa04..d0a6830 100644 --- a/catalog/v1_defaults.go +++ b/catalog/v1_defaults.go @@ -16,6 +16,7 @@ func defaultProviders() map[string]Provider { "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"}, @@ -51,6 +52,7 @@ func defaultDeployments() map[string]Deployment { "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), diff --git a/client/adapters/compat.go b/client/adapters/compat.go index d439fbf..3d9c8fa 100644 --- a/client/adapters/compat.go +++ b/client/adapters/compat.go @@ -55,6 +55,10 @@ var ( CanopyWaveCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", } + OpenGatewayCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + SupportsUsageInStreaming: true, + } OllamaCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", } diff --git a/client/compat.go b/client/compat.go index 1229bab..b0de4e8 100644 --- a/client/compat.go +++ b/client/compat.go @@ -7,24 +7,25 @@ type OpenAICompatConfig = adapters.OpenAICompatConfig // Per-provider compat configs. var ( - OpenAICompat = adapters.OpenAICompat - GrokCompat = adapters.GrokCompat - OpenRouterCompat = adapters.OpenRouterCompat - GeminiCompat = adapters.GeminiCompat - ZAICompat = adapters.ZAICompat - CanopyWaveCompat = adapters.CanopyWaveCompat - OllamaCompat = adapters.OllamaCompat - OpenCodeGoCompat = adapters.OpenCodeGoCompat - PoolsideCompat = adapters.PoolsideCompat - GroqCompat = adapters.GroqCompat - ClinePassCompat = adapters.ClinePassCompat - KimiCompat = adapters.KimiCompat - XiaomiCompat = adapters.XiaomiCompat - AzureCompat = adapters.AzureCompat - BedrockCompat = adapters.BedrockCompat - VertexCompat = adapters.VertexCompat - DeepSeekCompat = adapters.DeepSeekCompat - AgnesCompat = adapters.AgnesCompat - LongCatCompat = adapters.LongCatCompat - MiniMaxCompat = adapters.MiniMaxCompat + OpenAICompat = adapters.OpenAICompat + GrokCompat = adapters.GrokCompat + OpenRouterCompat = adapters.OpenRouterCompat + GeminiCompat = adapters.GeminiCompat + ZAICompat = adapters.ZAICompat + CanopyWaveCompat = adapters.CanopyWaveCompat + OpenGatewayCompat = adapters.OpenGatewayCompat + OllamaCompat = adapters.OllamaCompat + OpenCodeGoCompat = adapters.OpenCodeGoCompat + PoolsideCompat = adapters.PoolsideCompat + GroqCompat = adapters.GroqCompat + ClinePassCompat = adapters.ClinePassCompat + KimiCompat = adapters.KimiCompat + XiaomiCompat = adapters.XiaomiCompat + AzureCompat = adapters.AzureCompat + BedrockCompat = adapters.BedrockCompat + VertexCompat = adapters.VertexCompat + DeepSeekCompat = adapters.DeepSeekCompat + AgnesCompat = adapters.AgnesCompat + LongCatCompat = adapters.LongCatCompat + MiniMaxCompat = adapters.MiniMaxCompat ) diff --git a/config/config_test.go b/config/config_test.go index e9dcf6c..68ac60e 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -87,8 +87,8 @@ func TestNormalizeOllamaOpenAIBaseURL(t *testing.T) { func TestProviderDetectionOrder(t *testing.T) { t.Parallel() - if len(APIProviderDetectionOrder) != 24 { - t.Errorf("expected 24 providers in detection order, got %d", len(APIProviderDetectionOrder)) + if len(APIProviderDetectionOrder) != 25 { + t.Errorf("expected 25 providers in detection order, got %d", len(APIProviderDetectionOrder)) } if APIProviderDetectionOrder[0] != ProviderAnthropic { t.Error("expected anthropic first in detection order") diff --git a/config/profiles.go b/config/profiles.go index 3c477ee..b151942 100644 --- a/config/profiles.go +++ b/config/profiles.go @@ -15,6 +15,7 @@ const ( ProviderZAIPayg APIProvider = "zai_payg" ProviderOpenRouter APIProvider = "openrouter" ProviderConcentrate APIProvider = "concentrate" + ProviderOpenGateway APIProvider = "opengateway" ProviderAgnes APIProvider = "agnes" ProviderGrok APIProvider = "grok" ProviderGemini APIProvider = "gemini" @@ -115,6 +116,13 @@ var ( BaseURLEnv: []string{"CONCENTRATE_BASE_URL"}, APIKeys: []APIKeyDef{{Env: "CONCENTRATE_API_KEY", Source: "concentrate"}, {Env: "OPENAI_API_KEY", Source: "openai"}}, } + OpenGatewayRuntimeProfile = RuntimeProviderProfile{ + Mode: "openai", DefaultBaseURL: DefaultOpenGatewayOpenAIBaseURL, + DetectionEnv: []string{"OPENGATEWAY_API_KEY"}, + ModelEnv: []string{"OPENGATEWAY_MODEL", "OPENAI_MODEL"}, + BaseURLEnv: []string{"OPENGATEWAY_BASE_URL"}, + APIKeys: []APIKeyDef{{Env: "OPENGATEWAY_API_KEY", Source: "opengateway"}, {Env: "OPENAI_API_KEY", Source: "openai"}}, + } ZAIPaygRuntimeProfile = RuntimeProviderProfile{ Mode: "openai", DefaultBaseURL: DefaultZAIOpenAIBaseURL, DetectionEnv: []string{"ZAI_API_KEY"}, @@ -226,7 +234,7 @@ var ( var APIProviderDetectionOrder = []APIProvider{ ProviderAnthropic, ProviderConcentrate, ProviderOpenRouter, ProviderGrok, ProviderGemini, ProviderVertex, ProviderBedrock, ProviderZAICoding, ProviderZAIPayg, ProviderCanopyWave, ProviderDeepSeek, ProviderPoolside, ProviderGroq, ProviderClinePass, ProviderAzure, ProviderOpenAI, ProviderOpenCodeGo, - ProviderKimi, ProviderXiaomiMimoPayg, ProviderXiaomiMimoTokenPlan, ProviderMiniMaxTokenPlan, ProviderMiniMaxPayg, ProviderOllama, ProviderStepFun, + ProviderKimi, ProviderXiaomiMimoPayg, ProviderXiaomiMimoTokenPlan, ProviderMiniMaxTokenPlan, ProviderMiniMaxPayg, ProviderOllama, ProviderStepFun, ProviderOpenGateway, } // ProviderModelEnvKeys maps each provider to its model env var keys. @@ -255,6 +263,7 @@ var ProviderModelEnvKeys = map[APIProvider][]string{ ProviderMiniMaxTokenPlan: {"MINIMAX_TOKEN_PLAN_MODEL", "MINIMAX_MODEL", "OPENAI_MODEL"}, ProviderMiniMaxPayg: {"MINIMAX_PAYG_MODEL", "MINIMAX_MODEL", "OPENAI_MODEL"}, ProviderStepFun: StepFunRuntimeProfile.ModelEnv, + ProviderOpenGateway: OpenGatewayRuntimeProfile.ModelEnv, } const ( @@ -264,12 +273,13 @@ const ( // OpenAICompatibleRuntimeProfileOrder is the detection order for runtime profiles. var OpenAICompatibleRuntimeProfileOrder = []string{ - "concentrate", "agnes", "longcat", "openrouter", "grok", "gemini", "anthropic", "zai_coding", "zai_payg", "canopywave", "deepseek", "poolside", "groq", "clinepass", "openai", "opencodego", "kimi", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", "minimax_token_plan", "minimax_payg", "stepfun", + "concentrate", "agnes", "longcat", "openrouter", "grok", "gemini", "anthropic", "zai_coding", "zai_payg", "canopywave", "deepseek", "poolside", "groq", "clinepass", "openai", "opencodego", "kimi", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", "minimax_token_plan", "minimax_payg", "stepfun", "opengateway", } // OpenAICompatibleRuntimeProfiles maps profile key to its runtime profile. var OpenAICompatibleRuntimeProfiles = map[string]RuntimeProviderProfile{ "concentrate": ConcentrateRuntimeProfile, + "opengateway": OpenGatewayRuntimeProfile, "agnes": OpenAIRuntimeProfile, "longcat": OpenAIRuntimeProfile, "anthropic": AnthropicRuntimeProfile, @@ -305,6 +315,7 @@ var RuntimeProviderProfiles = map[string]RuntimeProviderProfile{ "bedrock": BedrockRuntimeProfile, "openrouter": OpenRouterRuntimeProfile, "concentrate": ConcentrateRuntimeProfile, + "opengateway": OpenGatewayRuntimeProfile, "zai_payg": ZAIPaygRuntimeProfile, "zai_coding": ZAICodingRuntimeProfile, "canopywave": CanopyWaveRuntimeProfile, diff --git a/config/provider_env.go b/config/provider_env.go index 7e388d8..459e37c 100644 --- a/config/provider_env.go +++ b/config/provider_env.go @@ -73,6 +73,9 @@ type ProviderConfig struct { ConcentrateAPIKey string `json:"concentrate_api_key,omitempty"` ConcentrateBaseURL string `json:"concentrate_base_url,omitempty"` ConcentrateModel string `json:"concentrate_model,omitempty"` + OpenGatewayAPIKey string `json:"opengateway_api_key,omitempty"` + OpenGatewayBaseURL string `json:"opengateway_base_url,omitempty"` + OpenGatewayModel string `json:"opengateway_model,omitempty"` AgnesAPIKey string `json:"agnes_api_key,omitempty"` AgnesBaseURL string `json:"agnes_base_url,omitempty"` AgnesModel string `json:"agnes_model,omitempty"` @@ -583,6 +586,7 @@ func ClearProviderRuntimeEnv() { "VERTEX_ACCESS_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN", "VERTEX_PROJECT_ID", "VERTEX_REGION", "VERTEX_MODEL", "OPENROUTER_API_KEY", "OPENROUTER_MODEL", "OPENROUTER_BASE_URL", "CONCENTRATE_API_KEY", "CONCENTRATE_MODEL", "CONCENTRATE_BASE_URL", + "OPENGATEWAY_API_KEY", "OPENGATEWAY_MODEL", "OPENGATEWAY_BASE_URL", "CANOPYWAVE_API_KEY", "CANOPYWAVE_MODEL", "CANOPYWAVE_BASE_URL", "DEEPSEEK_API_KEY", "DEEPSEEK_MODEL", "DEEPSEEK_BASE_URL", "ZAI_API_KEY", "ZAI_CODING_API_KEY", "ZAI_MODEL", "ZAI_BASE_URL", "ZAI_CODING_BASE_URL", "ZAI_API_BASE", @@ -742,6 +746,14 @@ func ApplyProviderEnv(provider string, config *ProviderConfig, activeModel strin m = catalog.GetProviderDefaultModel("concentrate", cat) } collectOpenAICompatibleProvider(env, "CONCENTRATE", apiKey, m, base, overwrite) + case ProviderOpenGateway: + apiKey := AsNonEmptyString(config.OpenGatewayAPIKey) + base := firstNonEmpty(config.OpenGatewayBaseURL, DefaultOpenGatewayOpenAIBaseURL) + m := activeModel + if m == "" { + m = catalog.GetProviderDefaultModel("opengateway", cat) + } + collectOpenAICompatibleProvider(env, "OPENGATEWAY", apiKey, m, base, overwrite) case ProviderDeepSeek: apiKey := AsNonEmptyString(config.DeepSeekAPIKey) base := firstNonEmpty(config.DeepSeekBaseURL, "https://api.deepseek.com/v1") diff --git a/config/providers.go b/config/providers.go index 7fb86eb..3ce4cc5 100644 --- a/config/providers.go +++ b/config/providers.go @@ -24,6 +24,7 @@ const ( DefaultOpenAIBaseURL = "https://api.openai.com/v1" DefaultOpenRouterOpenAIBaseURL = "https://openrouter.ai/api/v1" DefaultConcentrateOpenAIBaseURL = "https://api.concentrate.ai/v1" + DefaultOpenGatewayOpenAIBaseURL = "https://opengateway.gitlawb.com/v1" DefaultCanopyWaveOpenAIBaseURL = "https://inference.canopywave.io/v1" DefaultZAIOpenAIBaseURL = "https://api.z.ai/api/paas/v4" DefaultZAICodingOpenAIBaseURL = "https://api.z.ai/api/coding/paas/v4" diff --git a/setup/deployment.go b/setup/deployment.go index 212eddd..8400498 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -279,6 +279,12 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultCanopyWaveOpenAIBaseURL), &client.CanopyWaveCompat), true + case "opengateway-payg": + apiKey := FirstNonEmpty(deployment.APIKey, lookup("OPENGATEWAY_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenGatewayOpenAIBaseURL), &client.OpenGatewayCompat), true case "deepseek-direct": apiKey := FirstNonEmpty(deployment.APIKey, lookup("DEEPSEEK_API_KEY")) if apiKey == "" { @@ -469,6 +475,8 @@ func DefaultDeploymentForProvider(provider string) string { return "minimax_payg-direct" case config.ProviderConcentrate: return "concentrate-payg" + case config.ProviderOpenGateway: + return "opengateway-payg" default: return "" } @@ -492,6 +500,8 @@ func LegacyDeploymentConfig(cfg *config.ProviderConfig, provider string) config. return config.DeploymentConfig{APIKey: cfg.OpenRouterAPIKey, BaseURL: cfg.OpenRouterBaseURL} case config.ProviderCanopyWave: return config.DeploymentConfig{APIKey: cfg.CanopyWaveAPIKey, BaseURL: cfg.CanopyWaveBaseURL} + case config.ProviderOpenGateway: + return config.DeploymentConfig{APIKey: cfg.OpenGatewayAPIKey, BaseURL: cfg.OpenGatewayBaseURL} case config.ProviderPoolside: return config.DeploymentConfig{APIKey: cfg.PoolsideAPIKey, BaseURL: cfg.PoolsideBaseURL} case config.ProviderDeepSeek: diff --git a/setup/deployment_test.go b/setup/deployment_test.go index 5a1b7be..60be72d 100644 --- a/setup/deployment_test.go +++ b/setup/deployment_test.go @@ -331,6 +331,7 @@ func TestDefaultDeploymentForProvider(t *testing.T) { {config.ProviderGemini, "gemini-direct"}, {config.ProviderOpenRouter, "openrouter"}, {config.ProviderConcentrate, "concentrate-payg"}, + {config.ProviderOpenGateway, "opengateway-payg"}, {config.ProviderCanopyWave, "canopywave"}, {config.ProviderDeepSeek, "deepseek-direct"}, {config.ProviderZAIPayg, "zai_payg-direct"}, @@ -532,6 +533,24 @@ func TestProviderForDeployment_ConcentratePayAsYouGoRequiresKey(t *testing.T) { } } +func TestProviderForDeployment_OpenGateway(t *testing.T) { + p, ok := ProviderForDeployment("opengateway-payg", config.DeploymentConfig{APIKey: "ogw_live_test"}) + if !ok { + t.Fatal("expected opengateway-payg to be configured") + } + if p.Name() != "openai" { + t.Fatalf("provider name = %q, want openai", p.Name()) + } +} + +func TestProviderForDeployment_OpenGatewayRequiresKey(t *testing.T) { + t.Setenv("OPENGATEWAY_API_KEY", "") + + if _, ok := ProviderForDeployment("opengateway-payg", config.DeploymentConfig{}); ok { + t.Fatal("expected opengateway-payg to be unavailable without key") + } +} + func TestProviderForDeployment_CanopyWave(t *testing.T) { p, ok := ProviderForDeployment("canopywave", config.DeploymentConfig{APIKey: "test-key"}) if !ok {