Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ POSTGRES_SSLMODE=require # set to `disable` for local Docker
# XAI_API_KEY=xai-...
# XAI_BASE_URL=https://api.x.ai/v1

# MiniMax uses the global endpoint by default. Set MINIMAX_REGION=cn for the
# mainland-China endpoint; MINIMAX_BASE_URL overrides either regional default.
# MINIMAX_API_KEY=...
# MINIMAX_REGION=global
# MINIMAX_BASE_URL=https://api.minimax.io/v1

# META_API_KEY=...
# META_BASE_URL=https://api.meta.ai/v1

Expand Down
12 changes: 11 additions & 1 deletion cmd/router/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,16 @@ func main() {
})
}

{
minimaxRegion := config.GetOr("MINIMAX_REGION", "global")
minimaxBaseURL := config.GetOr("MINIMAX_BASE_URL", openaiCompatProvider.MiniMaxBaseURL(minimaxRegion))
registerDeploymentKeyedProvider(providerMap, envKeyedProviders, logger,
providers.ProviderMiniMax, "MiniMax", "MINIMAX_API_KEY", minimaxBaseURL, byokOnly,
func(key, baseURL string) providers.Client {
return openaiCompatProvider.NewClientWithModelIDMap(key, baseURL, upstreamIDsForProvider(providers.ProviderMiniMax))
})
}

{
xaiBaseURL := config.GetOr("XAI_BASE_URL", openaiCompatProvider.XAIBaseURL)
registerDeploymentKeyedProvider(providerMap, envKeyedProviders, logger,
Expand Down Expand Up @@ -1933,7 +1943,7 @@ func envVarHint(provider string) string {
// key (respecting byokOnly), constructs its client via newClient, registers
// it in providerMap, and logs its BYOK/keyed/passthrough state. Shared by the
// providers whose registration collapses to "resolve key -> build client ->
// three-way log switch" (Fireworks, Makora, Together, Bedrock, Google);
// three-way log switch" (Fireworks, Makora, MiniMax, Together, Bedrock, Google);
// OpenRouter and Anthropic/OpenAI have genuinely different gating
// logic and stay bespoke. extraLogAttrs are appended only to the
// deployment-keyed log line (e.g. Bedrock's region).
Expand Down
16 changes: 16 additions & 0 deletions cmd/router/minimax_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package main

import (
"testing"

"github.com/stretchr/testify/assert"

"workweave/router/internal/providers"
)

func TestUpstreamIDsForProvider_MiniMax(t *testing.T) {
ids := upstreamIDsForProvider(providers.ProviderMiniMax)

assert.Equal(t, "MiniMax-M3", ids["minimax/minimax-m3"])
assert.Equal(t, "MiniMax-M2.7", ids["minimax/minimax-m2.7"])
}
3 changes: 3 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Claude Code keep using the user's logged-in plan.
| `OPENAI_API_KEY` | *(none)* | Enables the OpenAI provider (Chat Completions API). |
| `OPENAI_BASE_URL` | `https://api.openai.com` | Override for OpenAI (e.g. Azure OpenAI). |
| `ROUTER_CODEX_BASE_URL` | `https://chatgpt.com/backend-api/codex` | Local-testing override for the ChatGPT subscription Responses backend; leave unset in production. |
| `MINIMAX_API_KEY` | *(none)* | Enables the native MiniMax provider through its OpenAI-compatible API. |
| `MINIMAX_REGION` | `global` | Set to `cn` (or `china`) to use the mainland-China endpoint. |
| `MINIMAX_BASE_URL` | regional default | Override the MiniMax endpoint; defaults to `https://api.minimax.io/v1` globally or `https://api.minimaxi.com/v1` for mainland China. |
| `GOOGLE_API_KEY` | *(none)* | Enables Gemini via its OpenAI-compatible endpoint. |
| `GOOGLE_BASE_URL` | `https://generativelanguage.googleapis.com/v1beta/openai` | Override for Gemini. |
| `ANTHROPIC_GATEWAY_BASE_URL` | *(none)* | Base URL of an Anthropic-compatible gateway; `/v1/messages` is appended to it. |
Expand Down
2 changes: 1 addition & 1 deletion internal/providers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Provider adapters (`internal/providers/<name>/`) import `internal/proxy` for the
1. **Create `internal/providers/<name>/client.go`** with a `Client` struct + `NewClient(...)` constructor taking credentials (typically API key string + base URL). For OpenAI-compatible upstreams (vLLM, Together, customer endpoints), **prefer adding a sibling `*BaseURL` constant in [`openaicompat`](openaicompat) over a new adapter** — the openaicompat client already covers OpenRouter + Fireworks under their own provider keys.
2. **Implement `Proxy` and `Passthrough`.** The adapter translates the prepared request body to the provider's wire format, sends it with a pooled `http.Client` (build it with `httputil.NewClient(httputil.NewTransport(...))` so redirects are refused and the managed-mode destination policy applies, and use `httputil.StreamBody`), streams the response back. Adapters call `proxy.OnUpstreamMeta` when they observe usage/header data. Do not leak provider-specific types across the package boundary.
3. **Add compile-time check:** `var _ providers.Client = (*Client)(nil)`.
4. **Add a canonical name constant** to [`provider.go`](provider.go) (the `Provider*` block) + register the matching env-var name in `APIKeyEnvVars` **and** a `ProviderFamilies` entry (see the "THREE-map edit" comment above the `Provider*` block in `provider.go`). Today's wired keys: `"anthropic"`, `"openai"`, `"google"`, `"openrouter"`, `"fireworks"`, `"bedrock"`, `"makora"`, `"together"`, `"xai"`, `"wafer"`, `"wafer_anthropic"`. The composition root reads `APIKeyEnvVars`, so the admin `/config` view can't drift from actual wiring. Skipping the `ProviderFamilies` entry is the failure mode that silently 502s at request time (`ErrProviderNotConfigured`) instead of at boot — `families_test.go`'s `TestEveryProviderHasFamilyAndEnvVar` exists to catch it, but only if the new constant is also added to `ProviderFamilies`/`APIKeyEnvVars` in the first place (a constant that's never added to either map is invisible to `AllProviders()` and won't be caught by that test).
4. **Add a canonical name constant** to [`provider.go`](provider.go) (the `Provider*` block) + register the matching env-var name in `APIKeyEnvVars` **and** a `ProviderFamilies` entry (see the "THREE-map edit" comment above the `Provider*` block in `provider.go`). Today's wired keys: `"anthropic"`, `"openai"`, `"google"`, `"openrouter"`, `"fireworks"`, `"bedrock"`, `"makora"`, `"minimax"`, `"together"`, `"xai"`, `"wafer"`, `"wafer_anthropic"`. The composition root reads `APIKeyEnvVars`, so the admin `/config` view can't drift from actual wiring. Skipping the `ProviderFamilies` entry is the failure mode that silently 502s at request time (`ErrProviderNotConfigured`) instead of at boot — `families_test.go`'s `TestEveryProviderHasFamilyAndEnvVar` exists to catch it, but only if the new constant is also added to `ProviderFamilies`/`APIKeyEnvVars` in the first place (a constant that's never added to either map is invisible to `AllProviders()` and won't be caught by that test).
5. **Check the non-family-based dispatch switches.** Most cross-format dispatch in `internal/proxy/service.go` and `internal/proxy/credentials.go` now keys off `providers.FamilyFor` (the `TranslationFamily` enum), so a new provider reusing an existing family needs no edit there. Two switches still key off literal `Provider*` constants and are **not** driven by `ProviderFamilies`, so review whether the new provider needs its own case:
- [`internal/translate/emit_openai.go`](../translate/emit_openai.go)'s `applySessionAffinity` — picks the upstream prompt-cache stickiness mechanism (header vs. body field vs. none) per provider. Unlisted OpenAI-compat providers fall through to the generic `x-session-affinity` header default, which is usually correct — but if the new provider's caching semantics differ (like OpenAI's body-field or Bedrock's none), add a case. Covered by `internal/translate/session_affinity_test.go`.
- [`internal/router/rl/mapping.go`](../router/rl/mapping.go)'s `rosterIDFor` — maps a catalog model to the RL policy artifact's roster slug. Unlisted providers fall through to the bare model ID (best-effort; the sidecar drops candidates it doesn't recognize), so a missing case degrades RL coverage rather than 502ing — still worth a case if the provider needs an OpenRouter-style vendor prefix.
Expand Down
2 changes: 1 addition & 1 deletion internal/providers/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Provider adapters (`internal/providers/<name>/`) import `internal/proxy` for the
1. **Create `internal/providers/<name>/client.go`** with a `Client` struct + `NewClient(...)` constructor taking credentials (typically API key string + base URL). For OpenAI-compatible upstreams (vLLM, Together, customer endpoints), **prefer adding a sibling `*BaseURL` constant in [`openaicompat`](openaicompat) over a new adapter** — the openaicompat client already covers OpenRouter + Fireworks under their own provider keys.
2. **Implement `Proxy` and `Passthrough`.** The adapter translates the prepared request body to the provider's wire format, sends it with a pooled `http.Client` (build it with `httputil.NewClient(httputil.NewTransport(...))` so redirects are refused and the managed-mode destination policy applies, and use `httputil.StreamBody`), streams the response back. Adapters call `proxy.OnUpstreamMeta` when they observe usage/header data. Do not leak provider-specific types across the package boundary.
3. **Add compile-time check:** `var _ providers.Client = (*Client)(nil)`.
4. **Add a canonical name constant** to [`provider.go`](provider.go) (the `Provider*` block) + register the matching env-var name in `APIKeyEnvVars` **and** a `ProviderFamilies` entry (see the "THREE-map edit" comment above the `Provider*` block in `provider.go`). Today's wired keys: `"anthropic"`, `"openai"`, `"google"`, `"openrouter"`, `"fireworks"`, `"bedrock"`, `"makora"`, `"together"`, `"xai"`, `"wafer"`, `"wafer_anthropic"`. The composition root reads `APIKeyEnvVars`, so the admin `/config` view can't drift from actual wiring. Skipping the `ProviderFamilies` entry is the failure mode that silently 502s at request time (`ErrProviderNotConfigured`) instead of at boot — `families_test.go`'s `TestEveryProviderHasFamilyAndEnvVar` exists to catch it, but only if the new constant is also added to `ProviderFamilies`/`APIKeyEnvVars` in the first place (a constant that's never added to either map is invisible to `AllProviders()` and won't be caught by that test).
4. **Add a canonical name constant** to [`provider.go`](provider.go) (the `Provider*` block) + register the matching env-var name in `APIKeyEnvVars` **and** a `ProviderFamilies` entry (see the "THREE-map edit" comment above the `Provider*` block in `provider.go`). Today's wired keys: `"anthropic"`, `"openai"`, `"google"`, `"openrouter"`, `"fireworks"`, `"bedrock"`, `"makora"`, `"minimax"`, `"together"`, `"xai"`, `"wafer"`, `"wafer_anthropic"`. The composition root reads `APIKeyEnvVars`, so the admin `/config` view can't drift from actual wiring. Skipping the `ProviderFamilies` entry is the failure mode that silently 502s at request time (`ErrProviderNotConfigured`) instead of at boot — `families_test.go`'s `TestEveryProviderHasFamilyAndEnvVar` exists to catch it, but only if the new constant is also added to `ProviderFamilies`/`APIKeyEnvVars` in the first place (a constant that's never added to either map is invisible to `AllProviders()` and won't be caught by that test).
5. **Check the non-family-based dispatch switches.** Most cross-format dispatch in `internal/proxy/service.go` and `internal/proxy/credentials.go` now keys off `providers.FamilyFor` (the `TranslationFamily` enum), so a new provider reusing an existing family needs no edit there. Two switches still key off literal `Provider*` constants and are **not** driven by `ProviderFamilies`, so review whether the new provider needs its own case:
- [`internal/translate/emit_openai.go`](../translate/emit_openai.go)'s `applySessionAffinity` — picks the upstream prompt-cache stickiness mechanism (header vs. body field vs. none) per provider. Unlisted OpenAI-compat providers fall through to the generic `x-session-affinity` header default, which is usually correct — but if the new provider's caching semantics differ (like OpenAI's body-field or Bedrock's none), add a case. Covered by `internal/translate/session_affinity_test.go`.
- [`internal/router/rl/mapping.go`](../router/rl/mapping.go)'s `rosterIDFor` — maps a catalog model to the RL policy artifact's roster slug. Unlisted providers fall through to the bare model ID (best-effort; the sidecar drops candidates it doesn't recognize), so a missing case degrades RL coverage rather than 502ing — still worth a case if the provider needs an OpenRouter-style vendor prefix.
Expand Down
1 change: 1 addition & 0 deletions internal/providers/families_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func TestFamilyForKnownProviders(t *testing.T) {
providers.ProviderFireworks: providers.FamilyOpenAICompat,
providers.ProviderBedrock: providers.FamilyOpenAICompat,
providers.ProviderMakora: providers.FamilyOpenAICompat,
providers.ProviderMiniMax: providers.FamilyOpenAICompat,
providers.ProviderTogether: providers.FamilyOpenAICompat,
providers.ProviderXAI: providers.FamilyOpenAICompat,
providers.ProviderMeta: providers.FamilyOpenAICompat,
Expand Down
15 changes: 15 additions & 0 deletions internal/providers/openaicompat/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const (
// providers; pair with NewClientWithModelIDMap to rewrite slugs to Makora's
// upstream IDs.
MakoraBaseURL = "https://inference.makora.com/v1"
// MiniMaxGlobalBaseURL is MiniMax's global OpenAI-compatible endpoint.
MiniMaxGlobalBaseURL = "https://api.minimax.io/v1"
// MiniMaxCNBaseURL is MiniMax's mainland-China OpenAI-compatible endpoint.
MiniMaxCNBaseURL = "https://api.minimaxi.com/v1"
// TogetherBaseURL serves the OSS pool (DeepSeek, GLM, MiniMax, Qwen, Kimi)
// and is fastest on artificialanalysis.ai for several routed models; pair
// with NewClientWithModelIDMap to rewrite slugs to Together's "Org/Model" IDs.
Expand All @@ -40,6 +44,17 @@ const (
WaferBaseURL = "https://pass.wafer.ai/v1"
)

// MiniMaxBaseURL returns the regional MiniMax OpenAI-compatible endpoint.
// The global endpoint is the safe default for unset or unrecognized regions.
func MiniMaxBaseURL(region string) string {
switch strings.ToLower(strings.TrimSpace(region)) {
case "cn", "china":
return MiniMaxCNBaseURL
default:
return MiniMaxGlobalBaseURL
}
}

// grokResponseHeaderTimeout is the time-to-first-byte guard for Grok models;
// Snowflake Cortex prefill can push first-byte past the default 30s. Streaming
// inactivity stays bounded by StreamBody's idle watchdog. Tunable via
Expand Down
15 changes: 15 additions & 0 deletions internal/providers/openaicompat/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ import (
"github.com/stretchr/testify/require"
)

func TestMiniMaxBaseURL(t *testing.T) {
tests := map[string]string{
"": openaicompat.MiniMaxGlobalBaseURL,
"global": openaicompat.MiniMaxGlobalBaseURL,
"cn": openaicompat.MiniMaxCNBaseURL,
"China": openaicompat.MiniMaxCNBaseURL,
}

for region, want := range tests {
t.Run(region, func(t *testing.T) {
assert.Equal(t, want, openaicompat.MiniMaxBaseURL(region))
})
}
}

func TestProxy_ForwardsToChatCompletionsUnderVersionedBaseURL(t *testing.T) {
var (
gotPath string
Expand Down
6 changes: 5 additions & 1 deletion internal/providers/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const (
ProviderFireworks = "fireworks"
ProviderBedrock = "bedrock"
ProviderMakora = "makora"
ProviderMiniMax = "minimax"
ProviderTogether = "together"
ProviderXAI = "xai"
// ProviderMeta is Meta's Model API (api.meta.ai), OpenAI-compatible Chat Completions surface.
Expand Down Expand Up @@ -88,7 +89,8 @@ const (
FamilyAnthropic
// FamilyOpenAICompat speaks the OpenAI Chat Completions wire format
// (OpenAI itself plus every OpenAI-compatible upstream: OpenRouter,
// Fireworks, Bedrock's OpenAI-compat surface, Makora, Together, XAI, Wafer).
// Fireworks, Bedrock's OpenAI-compat surface, Makora, MiniMax, Together,
// XAI, Wafer).
FamilyOpenAICompat
// FamilyGemini speaks the Google Generative Language (Gemini) wire format.
FamilyGemini
Expand All @@ -104,6 +106,7 @@ var ProviderFamilies = map[string]TranslationFamily{
ProviderFireworks: FamilyOpenAICompat,
ProviderBedrock: FamilyOpenAICompat,
ProviderMakora: FamilyOpenAICompat,
ProviderMiniMax: FamilyOpenAICompat,
ProviderTogether: FamilyOpenAICompat,
ProviderXAI: FamilyOpenAICompat,
ProviderMeta: FamilyOpenAICompat,
Expand Down Expand Up @@ -184,6 +187,7 @@ var APIKeyEnvVars = map[string]string{
ProviderFireworks: "FIREWORKS_API_KEY",
ProviderBedrock: "AWS_BEARER_TOKEN_BEDROCK",
ProviderMakora: "MAKORA_API_KEY",
ProviderMiniMax: "MINIMAX_API_KEY",
ProviderTogether: "TOGETHER_API_KEY",
ProviderXAI: "XAI_API_KEY",
ProviderMeta: "META_API_KEY",
Expand Down
9 changes: 7 additions & 2 deletions internal/router/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,15 +558,20 @@ var Models = []Model{
// at the identical $0.30/$1.20 list price.
{Provider: providers.ProviderTogether, UpstreamID: "MiniMaxAI/MiniMax-M2.7",
Price: Pricing{InputUSDPer1M: 0.300, OutputUSDPer1M: 1.200, CacheReadMultiplier: 0.06 / 0.300}},
{Provider: providers.ProviderMiniMax, UpstreamID: "MiniMax-M2.7",
Price: Pricing{InputUSDPer1M: 0.300, OutputUSDPer1M: 1.200, CacheWriteMultiplier: 0.375 / 0.300, CacheReadMultiplier: 0.06 / 0.300}},
{Provider: providers.ProviderFireworks, UpstreamID: "accounts/fireworks/models/minimax-m2p7",
Price: Pricing{InputUSDPer1M: 0.300, OutputUSDPer1M: 1.200}},
{Provider: providers.ProviderOpenRouter, Price: Pricing{InputUSDPer1M: 0.279, OutputUSDPer1M: 1.200, CacheReadMultiplier: 0.10}},
}},
// Fireworks serves 512k context — the model's headline 1M is not what the
// endpoint exposes. Unlike m2.7 it accepts images, so ImageInput stays default.
// The direct endpoint serves 1M context, but fallback endpoints can be limited
// to 512K, so the model-level window stays conservative. Unlike m2.7 it accepts
// images, so ImageInput stays default.
{ID: "minimax/minimax-m3", Tier: TierHigh, ContextWindow: 512_000, AgenticUse: AgenticLow, Providers: []ProviderBinding{
{Provider: providers.ProviderFireworks, UpstreamID: "accounts/fireworks/models/minimax-m3",
Price: Pricing{InputUSDPer1M: 0.300, OutputUSDPer1M: 1.200, CacheReadMultiplier: 0.20}},
{Provider: providers.ProviderMiniMax, UpstreamID: "MiniMax-M3",
Price: Pricing{InputUSDPer1M: 0.600, OutputUSDPer1M: 2.400, CacheReadMultiplier: 0.12 / 0.600}},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

M3 uses long-context prices

High Severity

The native MiniMax M3 binding is priced at $0.60/$2.40 with a $0.12 cache-read rate, which is MiniMax's long-context tier for inputs over 512K. The model-level window stays at 512_000 and the MiniMax binding does not override ContextWindow, so routed turns never enter that tier. Official standard rates at or below 512K are $0.30/$1.20 with $0.06 cache reads. Catalog prices feed billing and planner EV, so MiniMax M3 usage is charged and scored at twice the actual cost.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 380b2b5. Configure here.

{Provider: providers.ProviderOpenRouter, Price: Pricing{InputUSDPer1M: 0.300, OutputUSDPer1M: 1.200, CacheReadMultiplier: 0.10}},
}},
{ID: "z-ai/glm-5", Tier: TierHigh, ContextWindow: 202_752, ImageInput: ImageInputUnsupported, Providers: []ProviderBinding{
Expand Down
25 changes: 25 additions & 0 deletions internal/router/catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func TestCatalog_BindingsReferenceCanonicalProviders(t *testing.T) {
providers.ProviderFireworks: {},
providers.ProviderBedrock: {},
providers.ProviderMakora: {},
providers.ProviderMiniMax: {},
providers.ProviderTogether: {},
providers.ProviderXAI: {},
providers.ProviderMeta: {},
Expand Down Expand Up @@ -144,6 +145,30 @@ func TestResolveBinding_GemmaUsesNativeGoogleUpstreamID(t *testing.T) {
assert.Equal(t, "gemma-4-26b-a4b-it", b.UpstreamID)
}

func TestResolveBinding_MiniMaxUsesNativeModelIDs(t *testing.T) {
cases := []struct {
model string
upstreamID string
inputPrice float64
outputPrice float64
}{
{model: "minimax/minimax-m3", upstreamID: "MiniMax-M3", inputPrice: 0.600, outputPrice: 2.400},
{model: "minimax/minimax-m2.7", upstreamID: "MiniMax-M2.7", inputPrice: 0.300, outputPrice: 1.200},
}

for _, tc := range cases {
t.Run(tc.model, func(t *testing.T) {
binding, ok := ResolveBinding(tc.model, map[string]struct{}{providers.ProviderMiniMax: {}})
require.True(t, ok)
assert.Equal(t, providers.ProviderMiniMax, binding.Provider)
assert.Equal(t, tc.upstreamID, binding.UpstreamID)
assert.Equal(t, tc.inputPrice, binding.Price.InputUSDPer1M)
assert.Equal(t, tc.outputPrice, binding.Price.OutputUSDPer1M)
assert.Equal(t, 0.20, binding.Price.EffectiveCacheReadMultiplier())
})
}
}

func TestGPT56ProCatalogRowsAreDirectOpenAIRoutable(t *testing.T) {
cases := []struct {
model string
Expand Down
1 change: 1 addition & 0 deletions internal/router/rl/mapping_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ var defaultRosterPrefixProviders = map[string]struct{}{
providers.ProviderFireworks: {},
providers.ProviderBedrock: {},
providers.ProviderMakora: {},
providers.ProviderMiniMax: {},
providers.ProviderTogether: {},
// Muse Spark is not in the trained roster yet; bare ID is best-effort.
providers.ProviderMeta: {},
Expand Down
Loading