From 5f8495049a2ea088d2093579908fa3e8c7cd411f Mon Sep 17 00:00:00 2001 From: rayyan Date: Mon, 26 Oct 2026 23:14:11 -0300 Subject: [PATCH] feat(gateway): add FreeLLMAPI and OmniRoute provider endpoints --- NOTICE.md | 30 +++ docs/PROVIDER-ENDPOINTS.md | 73 +++++++ internal/gateway/api/keys_custom_routes.go | 37 +++- .../gateway/api/keys_custom_routes_test.go | 42 ++++ internal/gateway/catalog/catalog_test.go | 31 +++ internal/gateway/catalog/directory.go | 1 + .../gateway/catalog/directory_adapters.go | 43 +++- internal/gateway/catalog/directory_test.go | 2 +- internal/gateway/catalog/providers.NOTICE.md | 11 +- internal/gateway/catalog/providers.json | 198 ++++++++++++++++++ internal/gateway/provider/freellmapi.go | 101 +++++++++ internal/gateway/provider/freellmapi_test.go | 146 +++++++++++++ internal/gateway/provider/registry.go | 36 +++- 13 files changed, 729 insertions(+), 22 deletions(-) create mode 100644 docs/PROVIDER-ENDPOINTS.md create mode 100644 internal/gateway/provider/freellmapi.go create mode 100644 internal/gateway/provider/freellmapi_test.go diff --git a/NOTICE.md b/NOTICE.md index 84d8db1..1090b9d 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -17,6 +17,36 @@ from FreeLLMAPI. Ported files carry a comment naming the specific FreeLLMAPI source they follow. FreeLLMAPI's own curated provider list draws on the `awesome-freellm-apis` catalogue, which Prowl's directory also incorporates. +## OmniRoute + +- Project: https://github.com/cheaper-inference/OmniRoute +- Source snapshot: e0552a26f4f049220131412a70fe272163c31215 +- License: MIT +- Copyright (c) 2026 diegosouzapw + +The additional bearer-key OpenAI-compatible endpoint metadata in +`internal/gateway/catalog/providers.json` follows OmniRoute's provider registry. +Prowl uses its existing Go adapters, not OmniRoute's OAuth, browser-cookie, +wallet, or provider-specific execution implementations. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## LiteLLM - Project: https://github.com/BerriAI/litellm diff --git a/docs/PROVIDER-ENDPOINTS.md b/docs/PROVIDER-ENDPOINTS.md new file mode 100644 index 0000000..a105550 --- /dev/null +++ b/docs/PROVIDER-ENDPOINTS.md @@ -0,0 +1,73 @@ +# Additional provider endpoints + +These registrations follow reviewed API-key configurations from: + +- [FreeLLMAPI](https://github.com/tashfeenahmed/freellmapi/tree/22b7ec74a09892d3efdb9107ec3e74c764dd71a1/server/src/providers) +- [OmniRoute](https://github.com/cheaper-inference/OmniRoute/tree/e0552a26f4f049220131412a70fe272163c31215/open-sse/config/providers/registry) + +The URLs below are API roots, not full chat-completion URLs. Prowl appends +`/chat/completions` when routing chat. No model roster, pricing, free-tier +eligibility, or quota is inferred from an upstream registration. Models must +be configured separately; adding or importing a key does not enroll models. +These endpoints have local mock coverage, not live credential/inference +verification for every provider. + +## FreeLLMAPI + +| Platform | API root | Behavior | +|---|---|---| +| beatapi | `https://api.beatapi.io/v1` | Credential storage/validation only; decisions API, no chat | +| blaze | `https://api.blazeapi.org/paid/v1` | Chat; `/usage` credential probe; this is also the upstream Free-plan root | +| blockrun | `https://api.blockrun.ai/v1` | Chat; `/account` probe; not the separate wallet/x402 service | +| dreamprompting | `https://dreamprompting.com/api/v1` | Chat; checks upstream route-prefix and `:free` identity aliases; buffered streaming | +| inferbase | `https://api.inferbase.ai/v1` | Chat; probes `https://api.inferbase.ai/api/v1/inference/credits`; zero balance is valid; buffered streaming | +| logfare | `https://logfare.ai/v1` | Chat; exact model identity | +| parallel | `https://api.parallel.ai/v1` | Credential storage/validation only; keyed REST search, no chat or anonymous MCP | +| waterfall | `https://api.getwaterfall.org/v1` | Chat; exact model identity | + +DreamPrompting and Inferbase buffer a normal completion into a stream to avoid +incorrectly rejecting legitimate identity aliases or leaking provider-specific +routing/billing receipt frames. This delays the first token. The other new +chat adapters reject substitutions and missing identities before forwarding +content. HTTP errors retain the shared provider status/backoff handling. + +Parallel validates with an empty `POST /search`, `x-api-key` authentication, +and the documented authenticated `422` response; it never performs a real +search. Other probes are read-only GETs. Provider outages, invalid response +shapes, and rate limits remain inconclusive rather than proving key validity. + +## OmniRoute + +Only static HTTPS, bearer-key, OpenAI-compatible chat configurations are +included. Browser cookies, OAuth logins, native Messages/Responses-only APIs, +local services, wallet authentication, and provider-specific executors are not +ported by copying a URL. Existing handwritten Prowl adapters take precedence. + +| Platform | API root | Import variable | +|---|---|---| +| synthetic | `https://api.synthetic.new/openai/v1` | `SYNTHETIC_API_KEY` | +| deepinfra | `https://api.deepinfra.com/v1/openai` | `DEEPINFRA_API_KEY` | +| fireworks | `https://api.fireworks.ai/inference/v1` | `FIREWORKS_API_KEY` | +| featherless-ai | `https://api.featherless.ai/v1` | `FEATHERLESS_API_KEY` | +| hyperbolic | `https://api.hyperbolic.xyz/v1` | `HYPERBOLIC_API_KEY` | +| lambda-ai | `https://api.lambda.ai/v1` | `LAMBDA_API_KEY` | +| moonshot | `https://api.moonshot.ai/v1` | `MOONSHOT_API_KEY` | +| minimax | `https://api.minimax.io/v1` | `MINIMAX_API_KEY` | +| poe | `https://api.poe.com/v1` | `POE_API_KEY` | +| inception | `https://api.inceptionlabs.ai/v1` | `INCEPTION_API_KEY` | +| morph | `https://api.morphllm.com/v1` | `MORPH_API_KEY` | +| upstage | `https://api.upstage.ai/v1` | `UPSTAGE_API_KEY` | +| inference-net | `https://api.inference.net/v1` | `INFERENCE_NET_API_KEY` | +| arcee-ai | `https://api.arcee.ai/api/v1` | `ARCEE_API_KEY` | +| aimlapi | `https://api.aimlapi.com/v1` | `AIMLAPI_API_KEY` | +| nanogpt | `https://nano-gpt.com/api/v1` | `NANOGPT_API_KEY` | +| llmgateway | `https://api.llmgateway.io/v1` | `LLMGATEWAY_API_KEY` | +| publicai | `https://api.publicai.co/v1` | `PUBLICAI_API_KEY` | + +The provider directory includes these registrations and their source links. +The key importer recognizes catalog-declared environment variables and +registered platform IDs in API-key credential pools without needing another +hardcoded mapping for each catalog addition. Existing aliases still win. + +No private keys, account exports, local endpoint definitions, or gateway state +belong in this repository or a pull request. diff --git a/internal/gateway/api/keys_custom_routes.go b/internal/gateway/api/keys_custom_routes.go index 1835fdb..72eefd8 100644 --- a/internal/gateway/api/keys_custom_routes.go +++ b/internal/gateway/api/keys_custom_routes.go @@ -19,6 +19,8 @@ import ( "time" "github.com/neur0map/prowl/internal/gateway" + "github.com/neur0map/prowl/internal/gateway/catalog" + "github.com/neur0map/prowl/internal/gateway/provider" ) // registerKeysCustomRoutes mounts the custom-endpoint, discovery and import @@ -1769,6 +1771,9 @@ type keysCustomKeyPair struct { // keysCustomPrefixMap maps a *_ env prefix to a platform (key-parser.ts:40-116). var keysCustomPrefixMap = map[string]string{ + "BEATAPI_": "beatapi", "BLAZE_": "blaze", "BLAZEAPI_": "blaze", "BLOCKRUN_": "blockrun", + "DREAMPROMPTING_": "dreamprompting", "DREAM_PROMPTING_": "dreamprompting", + "INFERBASE_": "inferbase", "LOGFARE_": "logfare", "PARALLEL_": "parallel", "WATERFALL_": "waterfall", "GOOGLE_": "google", "GEMINI_": "google", "GROQ_": "groq", "CEREBRAS_": "cerebras", "SAIL_": "sail", "SAILRESEARCH_": "sail", "SAIL_RESEARCH_": "sail", "ELECTRONHUB_": "electronhub", "ELECTRON_HUB_": "electronhub", @@ -1800,6 +1805,9 @@ var keysCustomPrefixMap = map[string]string{ // keysCustomAuthJSONMap maps an opencode auth.json provider name to a platform // (key-parser.ts:118-181). var keysCustomAuthJSONMap = map[string]string{ + "beatapi": "beatapi", "blaze": "blaze", "blazeapi": "blaze", "blockrun": "blockrun", + "dreamprompting": "dreamprompting", "dream-prompting": "dreamprompting", + "inferbase": "inferbase", "logfare": "logfare", "parallel": "parallel", "waterfall": "waterfall", "gemini": "google", "google": "google", "groq": "groq", "sail": "sail", "sail-research": "sail", "sailresearch": "sail", "electronhub": "electronhub", "electron-hub": "electronhub", "experiential": "experiential", @@ -1846,6 +1854,20 @@ func keysCustomDetectPlatform(prefix string) *string { if p, ok := keysCustomPrefixMap[prefix]; ok { return &p } + // Catalog additions should not need another handwritten importer mapping. + entries, _ := catalog.All() + for _, entry := range entries { + if entry.Env == "" || strings.TrimSuffix(entry.Env, "API_KEY") != prefix { + continue + } + platform, ok := catalog.AdapterPlatform(entry.ID) + if !ok { + platform = entry.ID + } + if provider.Known(platform) { + return &platform + } + } return nil } @@ -2092,9 +2114,9 @@ func keysCustomParseAuthJSON(content string) keysCustomParseResult { if json.Unmarshal([]byte(content), &obj) != nil || obj.CredentialPool == nil { return res } - for provider, creds := range obj.CredentialPool { + for providerID, creds := range obj.CredentialPool { for _, row := range creds { - label := provider + label := providerID if l, ok := row["label"].(string); ok && l != "" { label = l } else if id, ok := row["id"].(string); ok && id != "" { @@ -2102,18 +2124,21 @@ func keysCustomParseAuthJSON(content string) keysCustomParseResult { } if at, ok := row["auth_type"]; ok { if ats, _ := at.(string); ats != "api_key" { - res.skipped = append(res.skipped, fmt.Sprintf("%s/%s: auth_type is %v", provider, label, at)) + res.skipped = append(res.skipped, fmt.Sprintf("%s/%s: auth_type is %v", providerID, label, at)) continue } } token, _ := row["access_token"].(string) if strings.TrimSpace(token) == "" { - res.skipped = append(res.skipped, fmt.Sprintf("%s/%s: no access_token", provider, label)) + res.skipped = append(res.skipped, fmt.Sprintf("%s/%s: no access_token", providerID, label)) continue } - platform, ok := keysCustomAuthJSONMap[provider] + platform, ok := keysCustomAuthJSONMap[providerID] + if !ok && providerID != "custom" && provider.Known(providerID) { + platform, ok = providerID, true + } if !ok { - res.skipped = append(res.skipped, fmt.Sprintf("%s/%s: no platform mapping", provider, label)) + res.skipped = append(res.skipped, fmt.Sprintf("%s/%s: no platform mapping", providerID, label)) continue } p := platform diff --git a/internal/gateway/api/keys_custom_routes_test.go b/internal/gateway/api/keys_custom_routes_test.go index db72a86..55ad1a4 100644 --- a/internal/gateway/api/keys_custom_routes_test.go +++ b/internal/gateway/api/keys_custom_routes_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "database/sql" "encoding/json" "fmt" @@ -294,6 +295,47 @@ func TestProbeAllNonChatCatalogMakesNoChatRequest(t *testing.T) { "the operator must be told the endpoint serves no chat model") } +func TestImportSelectedFreeLLMAPIPlatforms(t *testing.T) { + t.Parallel() + s := testServer(t, Options{}) + token := session(t, s) + platforms := []string{"beatapi", "blaze", "blockrun", "dreamprompting", "inferbase", "logfare", "parallel", "waterfall"} + keys := make([]map[string]string, 0, len(platforms)) + for _, platform := range platforms { + keys = append(keys, map[string]string{"platform": platform, "keyName": platform, "keyValue": "test-secret-" + platform}) + } + payload, err := json.Marshal(map[string]any{"keys": keys}) + require.NoError(t, err) + resp, body := do(t, s, http.MethodPost, "/api/keys/import-selected", string(payload), authed(token)) + require.Equal(t, http.StatusOK, resp.StatusCode, body) + out := decodeMap(t, body) + require.EqualValues(t, len(platforms), out["imported"]) + require.Empty(t, out["errors"].([]any)) + rows, err := s.engine.Vault().List(context.Background()) + require.NoError(t, err) + require.Len(t, rows, len(platforms)) + for _, platform := range platforms { + require.NotContains(t, body, "test-secret-"+platform) + } +} + +func TestImportDetectsAddedProviders(t *testing.T) { + for prefix, platform := range map[string]string{ + "BEATAPI_": "beatapi", "BLAZEAPI_": "blaze", "BLOCKRUN_": "blockrun", + "DREAM_PROMPTING_": "dreamprompting", "INFERBASE_": "inferbase", + "LOGFARE_": "logfare", "PARALLEL_": "parallel", "WATERFALL_": "waterfall", + "FEATHERLESS_": "featherless-ai", "INFERENCE_NET_": "inference-net", "DEEPINFRA_": "deepinfra", + } { + got := keysCustomDetectPlatform(prefix) + require.NotNil(t, got, prefix) + require.Equal(t, platform, *got) + } + require.Nil(t, keysCustomDetectPlatform("NOT_A_PROVIDER_")) + parsed := keysCustomParseAuthJSON(`{"credential_pool":{"deepinfra":[{"auth_type":"api_key","access_token":"test-only"}]}}`) + require.Len(t, parsed.keys, 1) + require.Equal(t, "deepinfra", *parsed.keys[0].platform) +} + // TestImportSelectedCreatesModelsIdempotently: importing a custom endpoint with // a model list creates exactly those models, and a second identical import adds // nothing and - critically - leaves every models.id unchanged, because diff --git a/internal/gateway/catalog/catalog_test.go b/internal/gateway/catalog/catalog_test.go index 4ae257a..59caf4a 100644 --- a/internal/gateway/catalog/catalog_test.go +++ b/internal/gateway/catalog/catalog_test.go @@ -39,6 +39,37 @@ func TestEndpointsAreUniqueAndValid(t *testing.T) { } } +func TestOmniRouteEndpointsAreDiscoverable(t *testing.T) { + providers, err := All() + require.NoError(t, err) + directory, err := Directory() + require.NoError(t, err) + byID := map[string]DirectoryEntry{} + for _, entry := range directory { + byID[entry.ID] = entry + } + count := 0 + for _, p := range providers { + if p.Source != "omniroute" { + continue + } + count++ + require.True(t, p.Routable(), p.ID) + require.False(t, p.Free(), "no unverified free-tier claims") + require.Empty(t, p.Models, "do not automatically enroll upstream model rosters") + require.NotContains(t, p.BaseURL, "/chat/completions", "store the root, not the full verb") + entry, ok := byID[p.ID] + require.True(t, ok, p.ID) + require.True(t, entry.Routable, p.ID) + require.Contains(t, entry.Sources, "omniroute") + require.NotEmpty(t, entry.DocsURL+entry.APIKeyURL) + } + require.Equal(t, 18, count) + for _, id := range []string{"beatapi", "parallel"} { + require.False(t, byID[id].Routable, id) + } +} + // TestRoutabilityIsHonest checks the classification the dashboard relies on: // an endpoint that does not speak OpenAI chat completions, or that still has // an unfilled placeholder, must not be offered as one-click. diff --git a/internal/gateway/catalog/directory.go b/internal/gateway/catalog/directory.go index 745d2bd..9480e63 100644 --- a/internal/gateway/catalog/directory.go +++ b/internal/gateway/catalog/directory.go @@ -72,6 +72,7 @@ var loadDirectory = sync.OnceValues(func() (directoryAsset, error) { // The upstream lists omit providers this gateway can route to, so they // are merged in and the whole list ordered here, where it is done once. asset.Providers = sortDirectory(withProwlDirectoryProviders(asset.Providers)) + asset.Sources["omniroute"] = "https://github.com/cheaper-inference/OmniRoute" if len(asset.Providers) == 0 { return directoryAsset{}, fmt.Errorf("provider directory is empty") } diff --git a/internal/gateway/catalog/directory_adapters.go b/internal/gateway/catalog/directory_adapters.go index 5882015..6ff7be6 100644 --- a/internal/gateway/catalog/directory_adapters.go +++ b/internal/gateway/catalog/directory_adapters.go @@ -17,6 +17,14 @@ package catalog // prowlDirectoryProviders contains entries the upstream source lists omit. // Most have a Prowl adapter; listed-only exceptions say so explicitly. var prowlDirectoryProviders = []DirectoryEntry{ + {ID: "beatapi", Name: "BeatAPI", Class: ClassPaid, Friction: "registration", Note: "Credential management only: decisions API, not chat completions."}, + {ID: "blaze", Name: "BlazeAPI", Class: ClassPaid, Friction: "registration", Note: "Uses /paid/v1 for all plans; model pricing is not inferred."}, + {ID: "blockrun", Name: "BlockRun", Class: ClassPaid, Friction: "registration", Note: "API-key endpoint, not wallet/x402; select model pricing explicitly."}, + {ID: "dreamprompting", Name: "DreamPrompting", Class: ClassPaid, Friction: "registration", Note: "Aliased model identities are checked; streaming is buffered."}, + {ID: "inferbase", Name: "Inferbase", Class: ClassPaid, Friction: "registration", Note: "Authenticated credits probe; streaming is buffered to avoid provider-only receipt frames."}, + {ID: "logfare", Name: "Logfare", Class: ClassPaid, Friction: "registration"}, + {ID: "parallel", Name: "Parallel", Class: ClassPaid, Friction: "registration", Note: "Credential management only: keyed REST search, not chat or anonymous MCP."}, + {ID: "waterfall", Name: "Waterfall", Class: ClassPaid, Friction: "registration"}, { ID: "sail", Name: "Sail Research", Class: ClassCredits, Friction: "card", APIKeyURL: "https://app.sailresearch.com", @@ -146,10 +154,43 @@ func withProwlDirectoryProviders(entries []DirectoryEntry) []DirectoryEntry { if _, ok := seen[extra.ID]; ok { continue } - extra.Routable = extra.ID != "sail" + extra.Routable = extra.ID != "sail" && extra.ID != "beatapi" && extra.ID != "parallel" extra.Probe = ProbeNone extra.Sources = []string{"prowl"} + switch extra.ID { + case "beatapi", "blaze", "blockrun", "dreamprompting", "inferbase", "logfare", "parallel", "waterfall": + extra.DocsURL = "https://github.com/tashfeenahmed/freellmapi/blob/22b7ec74a09892d3efdb9107ec3e74c764dd71a1/server/src/providers/" + extra.ID + ".ts" + extra.Sources = []string{"freellmapi", "prowl"} + } merged = append(merged, extra) } + // Reviewed catalog additions also belong in discovery, without duplicating + // endpoint metadata or leaving an existing listed-only row unroutable. + providers, _ := All() + for _, p := range providers { + if p.Source != "omniroute" { + continue + } + found := false + for i := range merged { + if merged[i].ID != p.ID { + continue + } + merged[i].Routable = p.Routable() + merged[i].Env = p.Env + if merged[i].DocsURL == "" { + merged[i].DocsURL = "https://github.com/cheaper-inference/OmniRoute/tree/e0552a26f4f049220131412a70fe272163c31215/open-sse/config/providers/registry/" + p.ID + } + merged[i].Sources = append(append([]string(nil), merged[i].Sources...), p.Source) + found = true + break + } + if !found { + merged = append(merged, DirectoryEntry{ID: p.ID, Name: p.Name, Class: ClassPaid, + Friction: p.Friction, Env: p.Env, Routable: p.Routable(), Probe: ProbeNone, + DocsURL: "https://github.com/cheaper-inference/OmniRoute/tree/e0552a26f4f049220131412a70fe272163c31215/open-sse/config/providers/registry/" + p.ID, + Sources: []string{p.Source}, Note: "OpenAI-compatible API-key endpoint; no free models or prices are assumed."}) + } + } return merged } diff --git a/internal/gateway/catalog/directory_test.go b/internal/gateway/catalog/directory_test.go index 39ff71b..68f8146 100644 --- a/internal/gateway/catalog/directory_test.go +++ b/internal/gateway/catalog/directory_test.go @@ -27,7 +27,7 @@ func TestDirectoryListsProwlProviders(t *testing.T) { require.NotEmpty(t, got.Name, "%s must be presentable", extra.ID) require.NotEmpty(t, got.Class, "%s must declare a class so it sorts", extra.ID) require.NotEmpty(t, got.Friction, "%s must say what signing up costs", extra.ID) - require.Equal(t, extra.ID != "sail", got.Routable, "%s routability is dishonest", extra.ID) + require.Equal(t, extra.ID != "sail" && extra.ID != "beatapi" && extra.ID != "parallel", got.Routable, "%s routability is dishonest", extra.ID) if !got.Routable { require.NotEmpty(t, got.Note, "%s must explain why it is listed only", extra.ID) } diff --git a/internal/gateway/catalog/providers.NOTICE.md b/internal/gateway/catalog/providers.NOTICE.md index 2a1a73f..b26229c 100644 --- a/internal/gateway/catalog/providers.NOTICE.md +++ b/internal/gateway/catalog/providers.NOTICE.md @@ -2,8 +2,8 @@ `providers.json` starts from generated upstream data, then receives a reviewed compatibility pass for endpoint, authentication, and retirement changes. The -upstream merge combines two MIT-licensed sources and deduplicates them by API -endpoint. +upstream merge combines free-API and LiteLLM data, then adds reviewed OmniRoute +registrations. Entries are deduplicated by API endpoint. ## Sources @@ -11,8 +11,9 @@ endpoint. |---|---|---| | [open-free-llm-api/awesome-freellm-apis](https://github.com/open-free-llm-api/awesome-freellm-apis) | Free-tier providers: base URLs, API-key signup links, card/verification requirements, max context, modalities, and the curated best-free-model lists | MIT | | [BerriAI/litellm](https://github.com/BerriAI/litellm) | Additional provider endpoints and their conventional API-key environment variable names | MIT | +| [cheaper-inference/OmniRoute](https://github.com/cheaper-inference/OmniRoute) | 18 reviewed static bearer-key OpenAI-compatible chat endpoints; see `docs/PROVIDER-ENDPOINTS.md` for the source snapshot and scope | MIT | -Both upstreams are MIT licensed. Their copyright notices are reproduced in +These upstreams are MIT licensed. Their copyright notices are reproduced in `NOTICE.md` at the repository root. ## What the generation does @@ -50,9 +51,9 @@ probe and the documented `codestral-2508` model is registered. ## Current contents -- 45 providers, one per unique endpoint +- 63 providers, one per unique endpoint - 26 with a standing free tier and at least one currently advertised free model -- 32 routable as OpenAI-compatible endpoints today +- 50 classified as OpenAI-compatible endpoints (18 additional OmniRoute entries are upstream-reviewed, not live credential-tested here) - 13 listed but not generically routable: Cloudflare Workers AI, Google Gemini, GitHub Models, Cohere, AI21 Labs, Glhf.chat, Anyscale, Empower, Galadriel, GigaChat, GitHub Models (legacy), Manus, and Soniox diff --git a/internal/gateway/catalog/providers.json b/internal/gateway/catalog/providers.json index 4606db6..f847161 100644 --- a/internal/gateway/catalog/providers.json +++ b/internal/gateway/catalog/providers.json @@ -1410,5 +1410,203 @@ "compat": "openai", "requires_vars": [], "friction": "registration" + }, + { + "id": "synthetic", + "name": "Synthetic", + "base_url": "https://api.synthetic.new/openai/v1", + "env": "SYNTHETIC_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "deepinfra", + "name": "DeepInfra", + "base_url": "https://api.deepinfra.com/v1/openai", + "env": "DEEPINFRA_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "fireworks", + "name": "Fireworks AI", + "base_url": "https://api.fireworks.ai/inference/v1", + "env": "FIREWORKS_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "featherless-ai", + "name": "Featherless AI", + "base_url": "https://api.featherless.ai/v1", + "env": "FEATHERLESS_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "hyperbolic", + "name": "Hyperbolic", + "base_url": "https://api.hyperbolic.xyz/v1", + "env": "HYPERBOLIC_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "lambda-ai", + "name": "Lambda AI", + "base_url": "https://api.lambda.ai/v1", + "env": "LAMBDA_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "moonshot", + "name": "Moonshot AI", + "base_url": "https://api.moonshot.ai/v1", + "env": "MOONSHOT_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "minimax", + "name": "MiniMax", + "base_url": "https://api.minimax.io/v1", + "env": "MINIMAX_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "poe", + "name": "Poe API", + "base_url": "https://api.poe.com/v1", + "env": "POE_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "inception", + "name": "Inception Labs", + "base_url": "https://api.inceptionlabs.ai/v1", + "env": "INCEPTION_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "morph", + "name": "Morph", + "base_url": "https://api.morphllm.com/v1", + "env": "MORPH_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "upstage", + "name": "Upstage", + "base_url": "https://api.upstage.ai/v1", + "env": "UPSTAGE_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "inference-net", + "name": "Inference.net", + "base_url": "https://api.inference.net/v1", + "env": "INFERENCE_NET_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "arcee-ai", + "name": "Arcee AI", + "base_url": "https://api.arcee.ai/api/v1", + "env": "ARCEE_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "aimlapi", + "name": "AI/ML API", + "base_url": "https://api.aimlapi.com/v1", + "env": "AIMLAPI_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "nanogpt", + "name": "NanoGPT", + "base_url": "https://nano-gpt.com/api/v1", + "env": "NANOGPT_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "llmgateway", + "name": "LLM Gateway", + "base_url": "https://api.llmgateway.io/v1", + "env": "LLMGATEWAY_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" + }, + { + "id": "publicai", + "name": "PublicAI", + "base_url": "https://api.publicai.co/v1", + "env": "PUBLICAI_API_KEY", + "tier": "paid-or-credits", + "source": "omniroute", + "compat": "openai", + "requires_vars": [], + "friction": "registration" } ] diff --git a/internal/gateway/provider/freellmapi.go b/internal/gateway/provider/freellmapi.go new file mode 100644 index 0000000..05df774 --- /dev/null +++ b/internal/gateway/provider/freellmapi.go @@ -0,0 +1,101 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "strings" +) + +// Endpoint and wire deviations follow FreeLLMAPI's server/src/providers adapters. +func registerFreeLLMAPIEndpoints(r *Registry) { + for _, cfg := range []providerCfg{ + {platform: "beatapi", name: "BeatAPI", baseURL: "https://api.beatapi.io/v1", noChat: true}, + {platform: "blaze", name: "BlazeAPI", baseURL: "https://api.blazeapi.org/paid/v1", validateURL: "https://api.blazeapi.org/paid/v1/usage"}, + {platform: "blockrun", name: "BlockRun", baseURL: "https://api.blockrun.ai/v1", validateURL: "https://api.blockrun.ai/v1/account"}, + {platform: "dreamprompting", name: "DreamPrompting", baseURL: "https://dreamprompting.com/api/v1", noStream: true, checkResponse: dreamPromptingCheck}, + {platform: "inferbase", name: "Inferbase", baseURL: "https://api.inferbase.ai/v1", validateURL: "https://api.inferbase.ai/api/v1/inference/credits", noStream: true}, + {platform: "logfare", name: "Logfare", baseURL: "https://logfare.ai/v1"}, + {platform: "parallel", name: "Parallel", baseURL: "https://api.parallel.ai/v1", noChat: true}, + {platform: "waterfall", name: "Waterfall", baseURL: "https://api.getwaterfall.org/v1"}, + } { + cfg.validate = freeLLMAPIValidate + if !cfg.noChat && cfg.checkResponse == nil { + cfg.checkResponse = exactModelCheck + cfg.verifyModelIdentity = true + } + // shortcut: buffer DreamPrompting's aliased identities and Inferbase's + // receipt frames; add native streaming when their frame handling is needed. + r.register(cfg) + } +} + +func exactModelCheck(requested string, resp *ChatResponse) error { + if resp.Model == "" || resp.Model != requested { + return &HTTPError{Status: 502, Message: "provider returned a different or missing model identity"} + } + return nil +} + +func dreamPromptingCheck(requested string, resp *ChatResponse) error { + stripped := strings.TrimSuffix(requested, ":free") + _, unprefixed, hasPrefix := strings.Cut(stripped, "/") + if requested == "auto" || (resp.Model != "" && (resp.Model == requested || resp.Model == stripped || (hasPrefix && resp.Model == unprefixed))) { + return nil + } + return &HTTPError{Status: 502, Message: "DreamPrompting returned a different or missing model identity"} +} + +func freeLLMAPIValidate(ctx context.Context, c *compat, apiKey string) KeyValidationResult { + endpoint := c.cfg.validateURL + if endpoint == "" { + endpoint = c.BaseURL() + "/models" + } + method, headers := http.MethodGet, c.authMap(apiKey) + var payload []byte + if c.Platform() == "parallel" { + // Only keyed REST search is supported, not anonymous MCP search. + if apiKey == "no-key" || strings.TrimSpace(apiKey) == "" { + return Inconclusive("Parallel requires an API key; anonymous MCP is not implemented") + } + endpoint, method, payload = c.BaseURL()+"/search", http.MethodPost, []byte("{}") + headers = map[string]string{"x-api-key": apiKey, "Content-Type": "application/json"} + } + status, body, _, err := c.httpCall(ctx, method, endpoint, headers, payload, c.cfg.validateTimeout) + if err != nil { + return Inconclusive(err.Error()) + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return classifyValidation(c.Name(), status, body) + } + if c.Platform() == "parallel" { + if status == http.StatusUnprocessableEntity { + return Valid() + } + return Inconclusive("Parallel empty search did not return the expected 422") + } + if status < 200 || status >= 300 { + return Inconclusive(c.Name() + " credential probe failed") + } + if c.Platform() == "beatapi" { + var models struct { + Data []json.RawMessage `json:"data"` + Error json.RawMessage `json:"error"` + } + if json.Unmarshal(body, &models) != nil || models.Data == nil || (len(models.Error) != 0 && string(models.Error) != "null") { + return Inconclusive("BeatAPI returned an invalid model list") + } + } + if c.Platform() == "inferbase" { + var credits struct { + Balance *float64 `json:"balance_micros"` + } + if json.Unmarshal(body, &credits) != nil || credits.Balance == nil { + return Inconclusive("Inferbase returned an invalid credits response") + } + } + if c.endpointIgnoresCredentials(ctx, method, endpoint) { + return Inconclusive(c.Name() + " credential probe also accepts an invalid key") + } + return Valid() +} diff --git a/internal/gateway/provider/freellmapi_test.go b/internal/gateway/provider/freellmapi_test.go new file mode 100644 index 0000000..b22213a --- /dev/null +++ b/internal/gateway/provider/freellmapi_test.go @@ -0,0 +1,146 @@ +package provider + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFreeLLMAPIStreamChecksBeforeContent(t *testing.T) { + for _, frame := range []string{ + `{"model":"wrong","choices":[{"delta":{"content":"do not leak"}}]}`, + `{"choices":[{"delta":{"content":"do not leak"}}]}`, + `{"error":{"message":"failed"}}`, + `not json`, + } { + stream := newSSEStream(&http.Response{Body: io.NopCloser(strings.NewReader("data: " + frame + "\n\n"))}, func() {}, true, "wanted") + stream.checkResponse = exactModelCheck + chunk, err := stream.Recv() + if err == nil || chunk != nil { + t.Fatalf("unsafe frame was forwarded: %s", frame) + } + _ = stream.Close() + } + stream := newSSEStream(&http.Response{Body: io.NopCloser(strings.NewReader("data: {\"model\":\"wanted\",\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: {\"choices\":[],\"usage\":{}}\n\ndata: [DONE]\n\n"))}, func() {}, true, "wanted") + stream.checkResponse = exactModelCheck + defer stream.Close() + for i := 0; i < 2; i++ { + if _, err := stream.Recv(); err != nil { + t.Fatal(err) + } + } + if _, err := stream.Recv(); err != io.EOF { + t.Fatalf("end = %v", err) + } +} + +func TestFreeLLMAPIEndpoints(t *testing.T) { + r := NewRegistry() + for platform, base := range map[string]string{ + "beatapi": "https://api.beatapi.io/v1", + "blaze": "https://api.blazeapi.org/paid/v1", + "blockrun": "https://api.blockrun.ai/v1", + "dreamprompting": "https://dreamprompting.com/api/v1", + "inferbase": "https://api.inferbase.ai/v1", + "logfare": "https://logfare.ai/v1", + "parallel": "https://api.parallel.ai/v1", + "waterfall": "https://api.getwaterfall.org/v1", + } { + t.Run(platform, func(t *testing.T) { + p, ok := r.Resolve(platform, "") + if !ok || p.BaseURL() != base || !Known(platform) { + t.Fatalf("endpoint %s not registered at %s", platform, base) + } + if platform == "beatapi" || platform == "parallel" { + if _, err := p.ChatCompletion(context.Background(), "test", &ChatRequest{}); !errors.Is(err, ErrChatUnsupported) { + t.Fatalf("non-chat provider accepted chat: %v", err) + } + if _, err := p.StreamChatCompletion(context.Background(), "test", &ChatRequest{}); !errors.Is(err, ErrChatUnsupported) { + t.Fatalf("non-chat provider accepted streaming chat: %v", err) + } + } + }) + } +} + +func TestFreeLLMAPIModelIdentity(t *testing.T) { + for _, tc := range []struct { + requested, returned string + valid bool + }{ + {"groq/openai/gpt-oss-20b:free", "openai/gpt-oss-20b", true}, + {"codestral/codestral-latest", "codestral-latest", true}, + {"route/model:free", "route/model", true}, + {"route/model", "route/model", true}, + {"auto", "selected", true}, + {"route/model", "other", false}, + {"route/model", "", false}, + } { + if err := dreamPromptingCheck(tc.requested, &ChatResponse{Model: tc.returned}); (err == nil) != tc.valid { + t.Errorf("DreamPrompting %s -> %s: %v", tc.requested, tc.returned, err) + } + } + for _, model := range []string{"", "other", "auto"} { + if exactModelCheck("named", &ChatResponse{Model: model}) == nil { + t.Errorf("accepted substitution %q", model) + } + } + if err := exactModelCheck("named", &ChatResponse{Model: "named"}); err != nil { + t.Fatal(err) + } +} + +func TestFreeLLMAPIValidation(t *testing.T) { + for _, tc := range []struct { + platform, path, body string + status int + want KeyValidationStatus + }{ + {"beatapi", "/models", `{"data":[]}`, 200, KeyValid}, + {"beatapi", "/models", `{}`, 200, KeyInconclusive}, + {"beatapi", "/models", `{"data":[],"error":{}}`, 200, KeyInconclusive}, + {"inferbase", "/credits", `{"balance_micros":0}`, 200, KeyValid}, + {"inferbase", "/credits", `{}`, 200, KeyInconclusive}, + {"blaze", "/usage", `{}`, 200, KeyValid}, + {"blockrun", "/account", `{}`, 401, KeyInvalid}, + {"waterfall", "/models", `{}`, 503, KeyInconclusive}, + {"parallel", "/search", `{}`, 422, KeyValid}, + {"parallel", "/search", `{}`, 401, KeyInvalid}, + {"parallel", "/search", `{}`, 500, KeyInconclusive}, + } { + t.Run(tc.platform+tc.body+http.StatusText(tc.status), func(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != tc.path { + t.Errorf("path = %s, want %s", req.URL.Path, tc.path) + } + if tc.platform == "parallel" { + body, _ := io.ReadAll(req.Body) + if req.Method != "POST" || string(body) != "{}" || req.Header.Get("x-api-key") != "test" || req.Header.Get("Authorization") != "" { + t.Errorf("invalid Parallel search probe") + } + } else { + if req.Header.Get("Authorization") == "Bearer "+sentinelInvalidKey { + w.WriteHeader(401) + return + } + if req.Header.Get("Authorization") != "Bearer test" { + t.Error("missing bearer") + } + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer up.Close() + c := compatOf(t, NewRegistry(), tc.platform) + c.cfg.baseURL = up.URL + c.cfg.validateURL = up.URL + tc.path + if got := c.ValidateKey(context.Background(), "test"); got.Status != tc.want { + t.Fatalf("validation = %+v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/gateway/provider/registry.go b/internal/gateway/provider/registry.go index 126ac0a..3e6eacf 100644 --- a/internal/gateway/provider/registry.go +++ b/internal/gateway/provider/registry.go @@ -280,7 +280,11 @@ func (c *compat) StreamChatCompletion(ctx context.Context, apiKey string, req *C cancel() return nil, httpErrorFrom(resp.StatusCode, resp.Header, b) } - return newSSEStream(resp, cancel, c.cfg.verifyModelIdentity, req.Model), nil + stream := newSSEStream(resp, cancel, c.cfg.verifyModelIdentity, req.Model) + if c.cfg.verifyModelIdentity { + stream.checkResponse = c.cfg.checkResponse + } + return stream, nil } // doStream issues the streaming POST with a first-byte watchdog: the per- @@ -596,14 +600,15 @@ func jsonString(s string) json.RawMessage { // ── Streaming implementations ──────────────────────────────────────────────── type sseStream struct { - resp *http.Response - scan *bufio.Scanner - cancel context.CancelFunc - verifyModel bool - reqModel string - sawFinish bool - sawModel bool - closed bool + resp *http.Response + scan *bufio.Scanner + cancel context.CancelFunc + verifyModel bool + checkResponse func(string, *ChatResponse) error + reqModel string + sawFinish bool + sawModel bool + closed bool } func newSSEStream(resp *http.Response, cancel context.CancelFunc, verify bool, reqModel string) *sseStream { @@ -640,8 +645,20 @@ func (s *sseStream) Recv() (*ChatChunk, error) { } var chunk ChatChunk if err := json.Unmarshal([]byte(data), &chunk); err != nil { + if s.checkResponse != nil { + return nil, &HTTPError{Status: 502, Message: "provider returned a malformed stream event"} + } continue } + if s.checkResponse != nil && chunk.Choices == nil { + return nil, &HTTPError{Status: 502, Message: "provider returned an upstream stream error or malformed event"} + } + if s.checkResponse != nil && (chunk.Model != "" || len(chunk.Choices) > 0 || !s.sawModel) { + if err := s.checkResponse(s.reqModel, &ChatResponse{Model: chunk.Model}); err != nil { + return nil, err + } + s.sawModel = true + } chunk.Raw = append([]byte(nil), data...) for _, ch := range chunk.Choices { if ch.FinishReason != nil && *ch.FinishReason != "" { @@ -894,6 +911,7 @@ func Known(platform string) bool { // URLs, timeouts, headers, keyless flags and validate overrides are all taken // from that file (cited inline where the value would be hard to re-derive). func registerAll(r *Registry) { + registerFreeLLMAPIEndpoints(r) plain := func(platform, name, baseURL string) { r.register(providerCfg{platform: platform, name: name, baseURL: baseURL}) }