diff --git a/.env.example b/.env.example index 6668555cc..eb02101dc 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/cmd/router/main.go b/cmd/router/main.go index 6ebffa46b..9d7f5f9dc 100644 --- a/cmd/router/main.go +++ b/cmd/router/main.go @@ -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, @@ -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). diff --git a/cmd/router/minimax_provider_test.go b/cmd/router/minimax_provider_test.go new file mode 100644 index 000000000..01ffec478 --- /dev/null +++ b/cmd/router/minimax_provider_test.go @@ -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"]) +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 193c85a49..58468f327 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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. | diff --git a/internal/providers/AGENTS.md b/internal/providers/AGENTS.md index 975a21c94..d7566d777 100644 --- a/internal/providers/AGENTS.md +++ b/internal/providers/AGENTS.md @@ -20,7 +20,7 @@ Provider adapters (`internal/providers//`) import `internal/proxy` for the 1. **Create `internal/providers//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. diff --git a/internal/providers/CLAUDE.md b/internal/providers/CLAUDE.md index 77bf611a0..947c1dbd7 100644 --- a/internal/providers/CLAUDE.md +++ b/internal/providers/CLAUDE.md @@ -20,7 +20,7 @@ Provider adapters (`internal/providers//`) import `internal/proxy` for the 1. **Create `internal/providers//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. diff --git a/internal/providers/families_test.go b/internal/providers/families_test.go index ae737d784..a33c3d36d 100644 --- a/internal/providers/families_test.go +++ b/internal/providers/families_test.go @@ -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, diff --git a/internal/providers/openaicompat/client.go b/internal/providers/openaicompat/client.go index fbdbc07d4..d45d03e14 100644 --- a/internal/providers/openaicompat/client.go +++ b/internal/providers/openaicompat/client.go @@ -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. @@ -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 diff --git a/internal/providers/openaicompat/client_test.go b/internal/providers/openaicompat/client_test.go index ec2b0c12a..4e048c922 100644 --- a/internal/providers/openaicompat/client_test.go +++ b/internal/providers/openaicompat/client_test.go @@ -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 diff --git a/internal/providers/provider.go b/internal/providers/provider.go index 2f1d2ffcc..ba8f6115f 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -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. @@ -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 @@ -104,6 +106,7 @@ var ProviderFamilies = map[string]TranslationFamily{ ProviderFireworks: FamilyOpenAICompat, ProviderBedrock: FamilyOpenAICompat, ProviderMakora: FamilyOpenAICompat, + ProviderMiniMax: FamilyOpenAICompat, ProviderTogether: FamilyOpenAICompat, ProviderXAI: FamilyOpenAICompat, ProviderMeta: FamilyOpenAICompat, @@ -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", diff --git a/internal/router/catalog/catalog.go b/internal/router/catalog/catalog.go index b8255d949..417b42800 100644 --- a/internal/router/catalog/catalog.go +++ b/internal/router/catalog/catalog.go @@ -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}}, {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{ diff --git a/internal/router/catalog/catalog_test.go b/internal/router/catalog/catalog_test.go index 36e181d72..7ed25c3fb 100644 --- a/internal/router/catalog/catalog_test.go +++ b/internal/router/catalog/catalog_test.go @@ -33,6 +33,7 @@ func TestCatalog_BindingsReferenceCanonicalProviders(t *testing.T) { providers.ProviderFireworks: {}, providers.ProviderBedrock: {}, providers.ProviderMakora: {}, + providers.ProviderMiniMax: {}, providers.ProviderTogether: {}, providers.ProviderXAI: {}, providers.ProviderMeta: {}, @@ -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 diff --git a/internal/router/rl/mapping_internal_test.go b/internal/router/rl/mapping_internal_test.go index fad12b1e3..874ff87af 100644 --- a/internal/router/rl/mapping_internal_test.go +++ b/internal/router/rl/mapping_internal_test.go @@ -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: {}, diff --git a/internal/translate/session_affinity_coverage_test.go b/internal/translate/session_affinity_coverage_test.go index 463c332d7..38661b632 100644 --- a/internal/translate/session_affinity_coverage_test.go +++ b/internal/translate/session_affinity_coverage_test.go @@ -48,6 +48,7 @@ var expectedSessionAffinityMechanism = map[string]sessionAffinityMechanism{ var defaultMechanismProviders = map[string]struct{}{ providers.ProviderFireworks: {}, providers.ProviderMakora: {}, + providers.ProviderMiniMax: {}, providers.ProviderTogether: {}, providers.ProviderMeta: {}, providers.ProviderWafer: {},