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
30 changes: 30 additions & 0 deletions NOTICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions docs/PROVIDER-ENDPOINTS.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 31 additions & 6 deletions internal/gateway/api/keys_custom_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -2092,28 +2114,31 @@ 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 != "" {
label = id
}
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
Expand Down
42 changes: 42 additions & 0 deletions internal/gateway/api/keys_custom_routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions internal/gateway/catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions internal/gateway/catalog/directory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
43 changes: 42 additions & 1 deletion internal/gateway/catalog/directory_adapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion internal/gateway/catalog/directory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading