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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ ZAI_API_KEY=
STEP_API_KEY=
OPENGATEWAY_API_KEY=
FIREWORKS_API_KEY=

REFLECTION_API_KEY=
# Default model overrides
OPENAI_MODEL=gpt-4o
ANTHROPIC_MODEL=claude-sonnet-4-5
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ When starting any new work (feature, fix, refactor, chore), always create a feat

## Design Principles

- **Model-agnostic** — single interface for the 28 provider gateways in
- **Model-agnostic** — single interface for the 29 provider gateways in
`catalog/registry/providers.go` (see README "Supported Providers")
- **Host-neutral engine** — Relay owns provider routing, transport, caching,
retry/fallback, and normalized telemetry; hosts own product UX and semantics
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ ANTHROPIC_API_KEY=sk-... go run ./examples/basic/

## Supported Providers

28 provider gateways in `catalog/registry/providers.go` (reflect `/config` uses the same list), listed in registry `SortOrder`. `catalog/registry/docs_test.go` fails when this table, the count, or `.env.example` drift from the registry.
29 provider gateways in `catalog/registry/providers.go` (reflect `/config` uses the same list), listed in registry `SortOrder`. `catalog/registry/docs_test.go` fails when this table, the count, or `.env.example` drift from the registry.

| Provider | ID | Env variable |
|---|---|---|
Expand Down Expand Up @@ -242,6 +242,7 @@ ANTHROPIC_API_KEY=sk-... go run ./examples/basic/
| **StepFun** | `stepfun` | `STEP_API_KEY` (+ region `global` / `cn`) |
| **OpenGateway** | `opengateway` | `OPENGATEWAY_API_KEY` |
| **Fireworks AI** | `fireworks` | `FIREWORKS_API_KEY` |
| **Reflection AI** | `reflection` | `REFLECTION_API_KEY` |

Runtime auto-detection uses a separate priority order (`config.APIProviderDetectionOrder`) when no deployment is pinned.

Expand Down
1 change: 1 addition & 0 deletions catalog/live/fetchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ var Registry = map[string]FetchFunc{
"groq": FetchGroq,
"clinepass": FetchClinePass,
"stepfun": FetchStepFun,
"reflection": FetchReflection,
}

// Fetch runs a registered live fetcher.
Expand Down
19 changes: 19 additions & 0 deletions catalog/live/fetchers_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -1212,3 +1212,22 @@ func enrichStepFunEntry(e Entry) Entry {
}
return e
}

// FetchReflection lists models from the Reflection AI OpenAI-compatible API.
func FetchReflection(env map[string]string) ([]Entry, error) {
entries, err := fetchOpenAICompatModels(
context.Background(),
envOr(env, "REFLECTION_BASE_URL", "https://api.reflection.ai/openai/v1"),
env["REFLECTION_API_KEY"], "Bearer",
)
if err != nil {
return nil, err
}
for i := range entries {
entries[i].ThinkingEnabled = true
entries[i].Features = appendUnique(entries[i].Features, "tools")
entries[i].Features = appendUnique(entries[i].Features, "structured_output")
entries[i].Features = appendUnique(entries[i].Features, "thinking:enabled")
}
return entries, nil
}
4 changes: 2 additions & 2 deletions catalog/provider_live_parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import (
func TestAllProviders_LiveFetchParity(t *testing.T) {
t.Parallel()
specs := registry.All()
if len(specs) != 28 {
t.Fatalf("expected 28 providers, got %d", len(specs))
if len(specs) != 29 {
t.Fatalf("expected 29 providers, got %d", len(specs))
}
for _, spec := range specs {
t.Run(spec.ProviderID, func(t *testing.T) {
Expand Down
6 changes: 3 additions & 3 deletions catalog/registry/provider_spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import (

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

Expand Down Expand Up @@ -39,7 +39,7 @@ func TestProviderSpecs_AgnesOpenAIOnlyLongCatOpenAIPrimary(t *testing.T) {
func TestLiveFetcherKeys_AllProviders(t *testing.T) {
t.Parallel()
keys := registry.LiveFetcherKeys()
if len(keys) != 28 {
if len(keys) != 29 {
t.Fatalf("expected 28 live fetcher keys, got %d", len(keys))
}
}
Expand Down
9 changes: 9 additions & 0 deletions catalog/registry/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,15 @@ func providerSpecs() []ProviderSpec {
ProtocolID: "openai-chat-completions", AdapterID: "fireworks", RuntimeProfileKey: "fireworks",
DNSHost: "api.fireworks.ai",
},
{
ProviderID: "reflection", DisplayName: "Reflection AI", DeploymentID: "reflection-direct", SortOrder: 29, ChatPreference: 29,
RequiresKey: true, CredentialEnv: "REFLECTION_API_KEY",
BaseURLEnv: []string{"REFLECTION_BASE_URL"},
ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.reflection.ai/openai/v1",
LiveFetcherKey: "reflection", LiveCatalogKey: "reflection",
ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "reflection",
DNSHost: "api.reflection.ai",
},

// ── Niche ─────────────────────────────────────────────────────────
{
Expand Down
4 changes: 2 additions & 2 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ func TestNormalizeOllamaOpenAIBaseURL(t *testing.T) {

func TestProviderDetectionOrder(t *testing.T) {
t.Parallel()
if len(APIProviderDetectionOrder) != 28 {
t.Errorf("expected 28 providers in detection order, got %d", len(APIProviderDetectionOrder))
if len(APIProviderDetectionOrder) != 29 {
t.Errorf("expected 29 providers in detection order, got %d", len(APIProviderDetectionOrder))
}
if APIProviderDetectionOrder[0] != ProviderAnthropic {
t.Error("expected anthropic first in detection order")
Expand Down
15 changes: 13 additions & 2 deletions config/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
ProviderGroq APIProvider = "groq"
ProviderClinePass APIProvider = "clinepass"
ProviderStepFun APIProvider = "stepfun"
ProviderReflection APIProvider = "reflection"
)

// RuntimeProviderProfile defines how a provider is detected and configured at runtime.
Expand Down Expand Up @@ -251,13 +252,20 @@ var (
BaseURLEnv: []string{"STEP_BASE_URL"},
APIKeys: []APIKeyDef{{Env: "STEP_API_KEY", Source: "stepfun"}},
}
ReflectionRuntimeProfile = RuntimeProviderProfile{
Mode: "openai", DefaultBaseURL: DefaultReflectionOpenAIBaseURL,
DetectionEnv: []string{"REFLECTION_API_KEY"},
ModelEnv: []string{"REFLECTION_MODEL", "OPENAI_MODEL"},
BaseURLEnv: []string{"REFLECTION_BASE_URL"},
APIKeys: []APIKeyDef{{Env: "REFLECTION_API_KEY", Source: "reflection"}},
}
)

// APIProviderDetectionOrder is the priority order for provider detection.
var APIProviderDetectionOrder = []APIProvider{
ProviderAnthropic, ProviderConcentrate, ProviderAgnes, ProviderLongCat, ProviderFireworks, ProviderOpenRouter, ProviderGrok, ProviderGemini,
ProviderVertex, ProviderBedrock, ProviderZAICoding, ProviderZAIPayg, ProviderCanopyWave, ProviderDeepSeek, ProviderPoolside, ProviderGroq, ProviderClinePass, ProviderAzure, ProviderOpenAI, ProviderOpenCodeGo,
ProviderKimi, ProviderXiaomiMimoPayg, ProviderXiaomiMimoTokenPlan, ProviderMiniMaxTokenPlan, ProviderMiniMaxPayg, ProviderOllama, ProviderStepFun, ProviderOpenGateway,
ProviderKimi, ProviderXiaomiMimoPayg, ProviderXiaomiMimoTokenPlan, ProviderMiniMaxTokenPlan, ProviderMiniMaxPayg, ProviderOllama, ProviderStepFun, ProviderOpenGateway, ProviderReflection,
}

// ProviderModelEnvKeys maps each provider to its model env var keys.
Expand Down Expand Up @@ -288,6 +296,7 @@ var ProviderModelEnvKeys = map[APIProvider][]string{
ProviderStepFun: StepFunRuntimeProfile.ModelEnv,
ProviderOpenGateway: OpenGatewayRuntimeProfile.ModelEnv,
ProviderFireworks: FireworksRuntimeProfile.ModelEnv,
ProviderReflection: ReflectionRuntimeProfile.ModelEnv,
}

const (
Expand All @@ -297,7 +306,7 @@ const (

// OpenAICompatibleRuntimeProfileOrder is the detection order for runtime profiles.
var OpenAICompatibleRuntimeProfileOrder = []string{
"concentrate", "agnes", "longcat", "fireworks", "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",
"concentrate", "agnes", "longcat", "fireworks", "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", "reflection",
}

// OpenAICompatibleRuntimeProfiles maps profile key to its runtime profile.
Expand Down Expand Up @@ -326,6 +335,7 @@ var OpenAICompatibleRuntimeProfiles = map[string]RuntimeProviderProfile{
"minimax_token_plan": MiniMaxTokenPlanRuntimeProfile,
"minimax_payg": MiniMaxPaygRuntimeProfile,
"stepfun": StepFunRuntimeProfile,
"reflection": ReflectionRuntimeProfile,
}

// RuntimeProviderProfiles maps provider/profile keys to runtime detection profiles.
Expand Down Expand Up @@ -357,6 +367,7 @@ var RuntimeProviderProfiles = map[string]RuntimeProviderProfile{
"minimax_token_plan": MiniMaxTokenPlanRuntimeProfile,
"minimax_payg": MiniMaxPaygRuntimeProfile,
"stepfun": StepFunRuntimeProfile,
"reflection": ReflectionRuntimeProfile,
"ollama": OllamaRuntimeProfile,
}

Expand Down
17 changes: 17 additions & 0 deletions config/provider_env.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ type ProviderConfig struct {
StepFunAPIKey string `json:"stepfun_api_key,omitempty"`
StepFunBaseURL string `json:"stepfun_base_url,omitempty"`
StepFunModel string `json:"stepfun_model,omitempty"`
ReflectionAPIKey string `json:"reflection_api_key,omitempty"`
ReflectionBaseURL string `json:"reflection_base_url,omitempty"`
ReflectionModel string `json:"reflection_model,omitempty"`
MiniMaxModel string `json:"minimax_model,omitempty"`
AnthropicModel string `json:"anthropic_model,omitempty"`
OpenAIModel string `json:"openai_model,omitempty"`
Expand Down Expand Up @@ -256,6 +259,11 @@ var providerFields = map[string]providerFieldMap{
Models: func(c *ProviderConfig) []string { return []string{c.StepFunModel} },
BaseURL: func(c *ProviderConfig) string { return c.StepFunBaseURL },
},
ProviderReflection: {
APIKeys: func(c *ProviderConfig) []string { return []string{c.ReflectionAPIKey} },
Models: func(c *ProviderConfig) []string { return []string{c.ReflectionModel} },
BaseURL: func(c *ProviderConfig) string { return c.ReflectionBaseURL },
},
ProviderOpenGateway: {
APIKeys: func(c *ProviderConfig) []string { return []string{c.OpenGatewayAPIKey} },
Models: func(c *ProviderConfig) []string { return []string{c.OpenGatewayModel} },
Expand Down Expand Up @@ -639,6 +647,7 @@ func ClearProviderRuntimeEnv() {
"CLINE_API_KEY", "CLINE_MODEL", "CLINE_API_BASE",
"MOONSHOT_API_KEY", "MOONSHOT_MODEL", "MOONSHOT_BASE_URL",
"STEP_API_KEY", "STEP_MODEL", "STEP_BASE_URL",
"REFLECTION_API_KEY", "REFLECTION_MODEL", "REFLECTION_BASE_URL",
"XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
"XIAOMI_MIMO_TOKEN_PLAN_REGION", "XIAOMI_MODEL", "XIAOMI_BASE_URL",
"XIAOMI_MIMO_PAYG_BASE_URL", "XIAOMI_MIMO_TOKEN_PLAN_BASE_URL",
Expand Down Expand Up @@ -899,6 +908,14 @@ func ApplyProviderEnv(provider string, config *ProviderConfig, activeModel strin
m = catalog.GetProviderDefaultModel("stepfun", cat)
}
collectOpenAICompatibleProvider(env, "STEP", apiKey, m, base, overwrite)
case ProviderReflection:
apiKey := AsNonEmptyString(config.ReflectionAPIKey)
base := firstNonEmpty(config.ReflectionBaseURL, DefaultReflectionOpenAIBaseURL)
m := activeModel
if m == "" {
m = catalog.GetProviderDefaultModel("reflection", cat)
}
collectOpenAICompatibleProvider(env, "REFLECTION", apiKey, m, base, overwrite)
}
return env
}
Expand Down
6 changes: 6 additions & 0 deletions config/provider_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ var providerCredentialFields = []providerCredentialField{
value: func(c *ProviderConfig) string { return c.FireworksAPIKey },
clear: func(c *ProviderConfig) { c.FireworksAPIKey = "" },
},
{
label: "reflection_api_key", env: "REFLECTION_API_KEY",
value: func(c *ProviderConfig) string { return c.ReflectionAPIKey },
clear: func(c *ProviderConfig) { c.ReflectionAPIKey = "" },
},
}

// ProviderConfigContainsSecrets reports whether provider state contains
Expand Down Expand Up @@ -306,6 +311,7 @@ var providerBaseURLEnv = map[string]func(*ProviderConfig) string{
"STEP_BASE_URL": func(c *ProviderConfig) string { return c.StepFunBaseURL },
"AGNES_BASE_URL": func(c *ProviderConfig) string { return c.AgnesBaseURL },
"FIREWORKS_BASE_URL": func(c *ProviderConfig) string { return c.FireworksBaseURL },
"REFLECTION_BASE_URL": func(c *ProviderConfig) string { return c.ReflectionBaseURL },
"CANOPYWAVE_BASE_URL": func(c *ProviderConfig) string { return c.CanopyWaveBaseURL },
"POOLSIDE_BASE_URL": func(c *ProviderConfig) string { return c.PoolsideBaseURL },
"GROQ_BASE_URL": func(c *ProviderConfig) string { return c.GroqBaseURL },
Expand Down
1 change: 1 addition & 0 deletions config/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const (
DefaultLongCatOpenAIBaseURL = "https://api.longcat.chat/openai/v1"
DefaultLongCatAnthropicBaseURL = "https://api.longcat.chat/anthropic"
DefaultFireworksOpenAIBaseURL = "https://api.fireworks.ai/inference/v1"
DefaultReflectionOpenAIBaseURL = "https://api.reflection.ai/openai/v1"
)

// ProviderTransport is the transport type for provider requests.
Expand Down
12 changes: 11 additions & 1 deletion provider/adapters/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ var (
MaxTokensField: "max_tokens",
SupportsUsageInStreaming: true,
}
// ReflectionCompat: OpenAI-compatible; requires reasoning_content passback
// in multi-turn conversations (HTTP 400 otherwise).
ReflectionCompat = OpenAICompatConfig{
SupportsStore: false,
SupportsDeveloperRole: true,
SupportsReasoningEffort: true,
SupportsUsageInStreaming: true,
MaxTokensField: "max_completion_tokens",
RequiresReasoningPassback: true,
}
StepFunCompat = OpenAICompatConfig{
MaxTokensField: "max_tokens",
}
Expand All @@ -150,7 +160,7 @@ func init() {
"longcat": &LongCatCompat,
"minimax_token_plan": &MiniMaxCompat, "minimax_payg": &MiniMaxCompat,
"stepfun": &StepFunCompat, "concentrate": &ConcentrateCompat,
"agnes": &AgnesCompat,
"agnes": &AgnesCompat, "reflection": &ReflectionCompat,
}
for id, compat := range compatible {
if provider, ok := OpenAICompatibleProviders[id]; ok {
Expand Down
6 changes: 1 addition & 5 deletions provider/adapters/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"io"
"log/slog"
"net/http"
"strings"

"github.com/GrayCodeAI/relay/llm"
"github.com/GrayCodeAI/relay/provider/core"
Expand Down Expand Up @@ -204,9 +203,6 @@ func buildRequestBase(messages []core.RelayMessage, opts core.ChatOptions, strea
"content": tr.Content,
"tool_call_id": tr.ToolUseID,
}
if tr.IsError {
toolMsg["is_error"] = true
}
msgs = append(msgs, toolMsg)
}
continue
Expand All @@ -215,7 +211,7 @@ func buildRequestBase(messages []core.RelayMessage, opts core.ChatOptions, strea
// Forward the prior assistant turn's reasoning_content back to providers
// that require it (DeepSeek tool-call round-trips), as an assistant field.
if compat != nil && compat.RequiresReasoningPassback &&
m.Role == "assistant" && strings.TrimSpace(m.Thinking) != "" {
m.Role == "assistant" && m.Thinking != "" {
msg["reasoning_content"] = m.Thinking
}
// Handle ContentParts (multi-modal): takes precedence over Content/Images
Expand Down
6 changes: 6 additions & 0 deletions setup/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c
return nil, false
}
return adapters.NewAgnesClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultAgnesOpenAIBaseURL), &adapters.AgnesCompat), true
case "reflection-direct":
apiKey := FirstNonEmpty(deployment.APIKey, lookup("REFLECTION_API_KEY"))
if apiKey == "" {
return nil, false
}
return adapters.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultReflectionOpenAIBaseURL), &adapters.ReflectionCompat), true
case "longcat-direct":
apiKey := FirstNonEmpty(deployment.APIKey, lookup("LONGCAT_API_KEY"))
if apiKey == "" {
Expand Down
Loading