Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 25 additions & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,31 @@ plugins:
# catalog: true
# catalog_url: https://models.dev/api.json
# timeout: 15s
# Custom compatible backends let operators add API-compatible providers without code.
# Use kind to select the generic factory; id remains the runtime route backend instance.
# backend_prefix must be unique and cannot use standard connector prefixes such as nvidia/openrouter/anthropic.
# api_key_env_var_root reads ROOT, ROOT_2, ROOT_3, ... using the standard static key convention.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
# - id: provider123
# kind: custom-openai-legacy-compatible
# enabled: false
# config:
# backend_prefix: provider123
# base_url: https://api.provider123.example/v1
# api_key_env_var_root: PROVIDER123_API_KEY
# - id: provider123-responses
# kind: custom-openai-responses-compatible
# enabled: false
# config:
# backend_prefix: provider123-responses
# base_url: https://api.provider123.example/v1
# api_key_env_var_root: PROVIDER123_RESPONSES_API_KEY
# - id: provider-anthropic
# kind: custom-anthropic-compatible
# enabled: false
# config:
# backend_prefix: provider-anthropic
# base_url: https://api.provider-anthropic.example
# api_key_env_var_root: PROVIDER_ANTHROPIC_API_KEY
features:
- id: submit-noop
enabled: true
Expand Down
85 changes: 85 additions & 0 deletions docs/custom-compatible-backends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Custom Compatible Backends

Custom compatible backends let operators add API-compatible providers without writing a Go plugin. Use them when a provider exposes an OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages compatible API but the standard distribution does not ship a dedicated connector.

## Factory kinds

Use the existing backend `kind` field to select the factory and `id` as the runtime route backend instance:

| Factory kind | Upstream API |
| --- | --- |
| `custom-openai-legacy-compatible` | OpenAI-compatible `/chat/completions` |
| `custom-openai-responses-compatible` | OpenAI-compatible `/responses` |
| `custom-anthropic-compatible` | Anthropic-compatible `/v1/messages` |

Each enabled custom backend requires a unique `config.backend_prefix`. The prefix is used for backend inventory and prefixed routing. It must not contain `/` or `:`, must not duplicate another enabled custom backend, and must not use a reserved standard backend prefix such as `nvidia`, `openrouter`, `anthropic`, `openai-legacy`, or `openai-responses`.

## API keys

Custom backends follow the existing static API key convention:

- `api_key`, `api_keys`, and `credentials` in YAML are explicit operator credentials.
- `credentials` take precedence when present because they preserve credential IDs and remote account metadata.
- If YAML credentials are omitted, `api_key_env_var_root` supplies environment fallback keys.
- Numbered environment keys use the standard convention: `ROOT`, then `ROOT_2`, `ROOT_3`, and so on.

For example, `api_key_env_var_root: PROVIDER123_API_KEY` reads `PROVIDER123_API_KEY`, `PROVIDER123_API_KEY_2`, `PROVIDER123_API_KEY_3`, etc.

## Model inventory

By default, custom backends probe the provider for models and register them in the central model registry under `backend_prefix`:

- OpenAI-compatible backends call `<base_url>/models` with bearer authentication.
- Anthropic-compatible backends call `<base_url>/v1/models` with `x-api-key` and `anthropic-version` headers.

Operators can override remote discovery with static `models:` config using the same inline or file inventory shape as other backends.

## Example: New OpenAI-compatible provider

```yaml
plugins:
backends:
- id: provider123
kind: custom-openai-legacy-compatible
enabled: true
config:
backend_prefix: provider123
base_url: https://api.provider123.example/v1
api_key_env_var_root: PROVIDER123_API_KEY
```

With this config, route selectors can target discovered models from the `provider123` backend prefix after model inventory refresh.

## Example: Static inventory override

```yaml
plugins:
backends:
- id: provider123
kind: custom-openai-responses-compatible
enabled: true
config:
backend_prefix: provider123
base_url: https://api.provider123.example/v1
api_key_env_var_root: PROVIDER123_API_KEY
models:
source: inline
items:
- canonical_id: provider123/deepseek-chat
native_id: deepseek-chat
display_name: Provider123 DeepSeek Chat
```

## Example: Anthropic-compatible provider

```yaml
plugins:
backends:
- id: provider-anthropic
kind: custom-anthropic-compatible
enabled: true
config:
backend_prefix: provider-anthropic
base_url: https://api.provider-anthropic.example
api_key_env_var_root: PROVIDER_ANTHROPIC_API_KEY
```
2 changes: 1 addition & 1 deletion docs/plugin-authoring.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Plugin authoring guide

This guide explains how to write feature and protocol plugins that preserve the Go proxy architecture. For the complete stage map, see `docs/extension-points.md`. For operator configuration and examples, see `README.md` and `config/config.yaml`. For the **no-key local stub** maintainer workflow (`check-config`, routes, inventory, serve), see [`docs/dogfood-local.md`](dogfood-local.md).
This guide explains how to write feature and protocol plugins that preserve the Go proxy architecture. For the complete stage map, see `docs/extension-points.md`. For operator configuration and examples, see `README.md` and `config/config.yaml`. For YAML-only OpenAI/Anthropic-compatible provider wiring, see [`docs/custom-compatible-backends.md`](custom-compatible-backends.md). For the **no-key local stub** maintainer workflow (`check-config`, routes, inventory, serve), see [`docs/dogfood-local.md`](dogfood-local.md).

## Plugin types

Expand Down
3 changes: 3 additions & 0 deletions internal/infra/runtimebundle/bootstrap_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ func BuildBootstrap(ctx context.Context, in BuildBootstrapInput) (BootstrapResul
if err := routing.ValidateModelAliasesConfig(cfg); err != nil {
return out, err
}
if err := pluginreg.ValidateCustomCompatibleBackendPrefixes(cfg.Plugins.Backends); err != nil {
return out, fmt.Errorf("runtimebundle: %w", err)
}

traceRes, err := tracing.Init(ctx, cfg)
if err != nil {
Expand Down
35 changes: 35 additions & 0 deletions internal/infra/runtimebundle/bootstrap_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package runtimebundle_test
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"

"github.com/matdev83/go-llm-interactive-proxy/internal/infra/runtimebundle"
Expand Down Expand Up @@ -87,3 +89,36 @@ func TestBuildBootstrap_unspecifiedMode(t *testing.T) {
t.Fatal("expected error")
}
}

func TestBuildBootstrap_inspectRejectsInvalidCustomBackendPrefix(t *testing.T) {
t.Parallel()
base, err := os.ReadFile(testConfigPath(t))
if err != nil {
t.Fatal(err)
}
customBackend := ` - id: nvidia-copy
kind: custom-openai-legacy-compatible
enabled: true
config:
backend_prefix: nvidia
base_url: http://127.0.0.1:9/v1
`
text := strings.Replace(string(base), " features:\n", customBackend+" features:\n", 1)
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
t.Fatal(err)
}

_, err = runtimebundle.BuildBootstrap(context.Background(), runtimebundle.BuildBootstrapInput{
ConfigPath: path,
Mode: runtimebundle.BootstrapInspect,
Mandatory: lipsdk.StandardDistributionRequirements(),
LogWriter: io.Discard,
})
if err == nil {
t.Fatal("expected custom backend prefix validation error")
}
if !strings.Contains(err.Error(), "custom backend prefix") || !strings.Contains(err.Error(), "reserved") {
t.Fatalf("error = %v, want custom backend prefix reserved", err)
}
}
3 changes: 3 additions & 0 deletions internal/infra/runtimebundle/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ func Build(cfg *config.Config, bus *hooks.Bus, log *slog.Logger, opts *BuildOpti
if log == nil {
return nil, fmt.Errorf("runtimebundle: nil logger")
}
if err := pluginreg.ValidateCustomCompatibleBackendPrefixes(cfg.Plugins.Backends); err != nil {
return nil, fmt.Errorf("runtimebundle: %w", err)
}
authEvents, err := buildAuthEventDispatcher(cfg, log, opts)
if err != nil {
return nil, err
Expand Down
60 changes: 60 additions & 0 deletions internal/infra/runtimebundle/dual_backend_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package runtimebundle_test

import (
"strings"
"testing"

"github.com/matdev83/go-llm-interactive-proxy/internal/core/config"
Expand Down Expand Up @@ -50,3 +51,62 @@ func TestBuild_twoInstancesSameFactoryKind(t *testing.T) {
t.Fatal("missing instance openai-fallback")
}
}

func TestBuild_customBackendsRejectDuplicatePrefixBeforeModelRegistry(t *testing.T) {
t.Parallel()
reg := pluginreg.NewRegistry()
if err := pluginreg.InstallStandardBundleOn(reg, pluginreg.UpstreamAPIKeys{}); err != nil {
t.Fatal(err)
}
var node yaml.Node
if err := yaml.Unmarshal([]byte("backend_prefix: provider123\nbase_url: http://127.0.0.1:9/v1\n"), &node); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
Routing: config.RoutingConfig{MaxAttempts: 3},
Plugins: config.PluginsConfig{Backends: []config.PluginConfig{
{Kind: pluginreg.CustomOpenAILegacyCompatibleID, ID: "provider-chat", Enabled: true, Config: node},
{Kind: pluginreg.CustomOpenAIResponsesCompatibleID, ID: "provider-responses", Enabled: true, Config: node},
}},
Continuity: config.ContinuityConfig{InMemory: true},
}
if err := config.Validate(cfg); err != nil {
t.Fatal(err)
}
_, err := runtimebundle.Build(cfg, hooks.New(hooks.Config{}), testkit.DiscardLogger(), &runtimebundle.BuildOptions{PluginRegistry: reg})
if err == nil {
t.Fatal("expected duplicate custom backend prefix error")
}
if !strings.Contains(err.Error(), "custom backend prefix") || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("error = %v, want custom backend prefix duplicate", err)
}
}

func TestBuild_customBackendsRejectReservedStandardPrefix(t *testing.T) {
t.Parallel()
reg := pluginreg.NewRegistry()
if err := pluginreg.InstallStandardBundleOn(reg, pluginreg.UpstreamAPIKeys{}); err != nil {
t.Fatal(err)
}
var node yaml.Node
if err := yaml.Unmarshal([]byte("backend_prefix: nvidia\nbase_url: http://127.0.0.1:9/v1\n"), &node); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
Routing: config.RoutingConfig{MaxAttempts: 3},
Plugins: config.PluginsConfig{Backends: []config.PluginConfig{
{Kind: pluginreg.CustomOpenAILegacyCompatibleID, ID: "nvidia-copy", Enabled: true, Config: node},
}},
Continuity: config.ContinuityConfig{InMemory: true},
}
if err := config.Validate(cfg); err != nil {
t.Fatal(err)
}
_, err := runtimebundle.Build(cfg, hooks.New(hooks.Config{}), testkit.DiscardLogger(), &runtimebundle.BuildOptions{PluginRegistry: reg})
if err == nil {
t.Fatal("expected reserved custom backend prefix error")
}
if !strings.Contains(err.Error(), "custom backend prefix") || !strings.Contains(err.Error(), "reserved") {
t.Fatalf("error = %v, want custom backend prefix reserved", err)
}
}
55 changes: 53 additions & 2 deletions internal/pluginreg/backend_prefix_inventory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ func TestStandardBackends_exposeInventoryPrefixes(t *testing.T) {
if len(be.BackendPrefixes) == 0 {
t.Fatalf("BuildBackend(%q) BackendPrefixes is empty", id)
}
if !slices.Contains(be.BackendPrefixes, id) {
t.Fatalf("BuildBackend(%q) BackendPrefixes = %#v, want factory id %q", id, be.BackendPrefixes, id)
wantPrefix := standardBackendWantPrefix(id)
if !slices.Contains(be.BackendPrefixes, wantPrefix) {
t.Fatalf("BuildBackend(%q) BackendPrefixes = %#v, want prefix %q", id, be.BackendPrefixes, wantPrefix)
}
for _, prefix := range be.BackendPrefixes {
prefix = strings.TrimSpace(prefix)
Expand All @@ -44,6 +45,37 @@ func TestStandardBackends_exposeInventoryPrefixes(t *testing.T) {
}
}

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

for _, id := range standardBackendFactoryIDs(t) {
if IsCustomCompatibleBackendKind(id) {
continue
}
t.Run(id, func(t *testing.T) {
t.Parallel()

var node yaml.Node
if err := yaml.Unmarshal([]byte(standardBackendBuildYAML(id)), &node); err != nil {
t.Fatal(err)
}
reg := NewRegistry()
if err := InstallStandardBackendsOn(reg, UpstreamAPIKeys{}); err != nil {
t.Fatal(err)
}
be, err := reg.BuildBackend(id, node, nil)
if err != nil {
t.Fatalf("BuildBackend(%q) error = %v", id, err)
}
for _, prefix := range be.BackendPrefixes {
if _, ok := reservedStandardBackendPrefixes[prefix]; !ok {
t.Fatalf("standard backend %q exposes prefix %q not reserved for custom connectors", id, prefix)
}
}
})
}
}

func standardBackendFactoryIDs(t *testing.T) []string {
t.Helper()
be := StandardBackendBundle(UpstreamAPIKeys{})
Expand All @@ -65,7 +97,26 @@ func standardBackendBuildYAML(id string) string {
return "responses_api: disabled\n"
case "bedrock":
return "region: us-east-1\n"
case CustomOpenAILegacyCompatibleID:
return "backend_prefix: custom-openai-legacy\nbase_url: http://127.0.0.1:9/v1\n"
case CustomOpenAIResponsesCompatibleID:
return "backend_prefix: custom-openai-responses\nbase_url: http://127.0.0.1:9/v1\n"
case CustomAnthropicCompatibleID:
return "backend_prefix: custom-anthropic\nbase_url: http://127.0.0.1:9\n"
default:
return ""
}
}

func standardBackendWantPrefix(id string) string {
switch id {
case CustomOpenAILegacyCompatibleID:
return "custom-openai-legacy"
case CustomOpenAIResponsesCompatibleID:
return "custom-openai-responses"
case CustomAnthropicCompatibleID:
return "custom-anthropic"
default:
return id
}
}
24 changes: 24 additions & 0 deletions internal/pluginreg/backends_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,30 @@ func backendAnthropic(n yaml.Node, upstream *http.Client, keys UpstreamAPIKeys)
return applyConfiguredModelInventory(anthropic.New(cfg), y.Models)
}

func backendCustomAnthropicCompatible(n yaml.Node, upstream *http.Client) (execbackend.Backend, error) {
y, err := decodeCustomCompatibleBackendYAML(n)
if err != nil {
return execbackend.Backend{}, fmt.Errorf("%s backend config: %w", CustomAnthropicCompatibleID, err)
}
prefix := strings.TrimSpace(y.BackendPrefix)
if err := validateCustomBackendPrefix(prefix); err != nil {
return execbackend.Backend{}, err
}
base := strings.TrimSpace(y.BaseURL)
ek := resolveCustomCompatibleAPIKeys(y)
cfg := anthropic.Config{
BaseURL: base,
BackendPrefix: prefix,
APIKeys: ek,
Credentials: hostedCredentials(y.Credentials),
HTTPClient: resolveUpstreamHTTP(upstream),
}
if len(ek) > 0 {
cfg.APIKey = ek[0]
}
return applyConfiguredModelInventory(anthropic.New(cfg), y.Models)
}

func backendGemini(n yaml.Node, upstream *http.Client, keys UpstreamAPIKeys) (execbackend.Backend, error) {
var y openAIStyleYAML
if err := config.DecodeYAMLNode(n, &y); err != nil {
Expand Down
Loading