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
14 changes: 9 additions & 5 deletions docs/advanced/audio-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ Requests route **by model** through the same registry used for chat and
embeddings, so `model` selection, `provider` hints, virtual models, per-key model
access rules ([user paths](/features/user-path)), and budgets all apply. Audio is
served by OpenAI and the OpenAI-compatible providers (OpenRouter, Azure OpenAI,
vLLM, Oracle, MiniMax, Z.ai); a provider that doesn't support audio returns a
clear error rather than mis-routing.
vLLM, Oracle, Z.ai), plus providers whose **native audio APIs GoModel translates**
behind the same endpoints: [Xiaomi MiMo](/providers/xiaomi) (TTS and ASR via chat
completions), [Cohere](/providers/cohere) (transcription), and
[MiniMax](/providers/minimax) (TTS via its native `t2a_v2` API). A provider that
doesn't support audio returns a clear error rather than mis-routing.

## Supported endpoints

Expand Down Expand Up @@ -74,9 +77,10 @@ through the full inference orchestrator**. Compared with `/v1/chat/completions`:
- **No usage/cost metering** — audio is not token-priced, so it is not recorded in
usage tracking. Requests are still authorized, budget-checked, and written to the
[audit log](/advanced/admin-endpoints) under their `/v1/audio/*` path.
- **OpenAI request shape only** — requests are forwarded in OpenAI's audio format to
OpenAI-compatible upstreams. Providers with a different native audio contract are
not yet adapted behind this endpoint.
- **OpenAI request shape in, provider dialect out** — clients always send OpenAI's
audio format. OpenAI-compatible upstreams receive it unchanged; Xiaomi MiMo,
Cohere, and MiniMax requests are translated to each provider's native audio
contract. Providers beyond those are not adapted behind this endpoint.
- **Realtime voice-to-voice** (the WebSocket realtime API) is not supported.

For a provider whose native audio API differs from OpenAI's, use the
Expand Down
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@
"providers/xai",
"providers/bailian",
"providers/xiaomi",
"providers/minimax",
"providers/opencode-go",
"providers/vllm",
"providers/multiple-ollama",
Expand Down
85 changes: 85 additions & 0 deletions docs/providers/minimax.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
title: "MiniMax"
description: "Configure MiniMax in GoModel: chat models, temperature handling, and native text-to-speech through the standard audio endpoint."
icon: "waveform"
keywords: ["MiniMax", "text-to-speech", "TTS", "t2a_v2", "provider setup"]
---

MiniMax speaks an OpenAI-compatible chat API, so chat models work out of the
box. Text-to-speech, however, uses MiniMax's own `t2a_v2` API — GoModel
translates the standard `/v1/audio/speech` endpoint into that dialect for you.

## Configure

```bash
MINIMAX_API_KEY=...
```

Or in `config.yaml`:

```yaml
providers:
minimax:
type: minimax
base_url: "https://api.minimax.io/v1"
api_key: "${MINIMAX_API_KEY}"
```

`MINIMAX_BASE_URL` overrides the endpoint (default
`https://api.minimax.io/v1`); accounts on the China platform should set it to
`https://api.minimaxi.com/v1`.

## Temperature

MiniMax requires `temperature` in `(0.0, 1.0]` and rejects zero. GoModel clamps
a zero or negative temperature to `1.0` so OpenAI-style requests that pin
`temperature: 0` keep working.

## Text-to-speech

`POST /v1/audio/speech` is translated to MiniMax's synchronous
[`t2a_v2`](https://platform.minimax.io/docs/api-reference/speech-t2a-v2)
API and the hex-encoded audio is decoded back to binary:

```bash
curl https://your-gateway/v1/audio/speech \
-H "Authorization: Bearer $GOMODEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "speech-2.6-hd",
"input": "Hello from GoModel.",
"voice": "English_expressive_narrator",
"response_format": "mp3"
}' \
--output speech.mp3
```

- `voice` takes a **MiniMax voice ID** (for example
`English_expressive_narrator`), not an OpenAI voice name like `alloy`.
- `response_format` supports `mp3` (default), `wav`, `flac`, and `pcm`.
- `speed` supports `0.5`–`2.0` (default `1.0`).

Speech models are usually not returned by MiniMax's `/models` listing, so add
them to the configured model list to make them routable:

```bash
MINIMAX_MODELS=speech-2.6-hd,speech-2.6-turbo
```

MiniMax reports failures as HTTP 200 with a native status code; GoModel maps
the common ones to real errors (invalid parameters and blocked content → 400,
authentication → 401, insufficient balance → 402, rate limits → 429) instead of
relaying them as opaque gateway errors.

## Not supported by MiniMax

All of these return `invalid_request_error` rather than silently dropping the
option:

- Speech `instructions` (pick a voice ID that matches the style you want).
- Speech `response_format` values other than `mp3`/`wav`/`flac`/`pcm` and
`speed` outside `0.5`–`2.0`.
- Speech-to-text — MiniMax has no transcription API, so
`/v1/audio/transcriptions` is rejected.
- Realtime voice-to-voice — MiniMax's conversational realtime schema is not
OpenAI-compatible, so it is not exposed at `/v1/realtime`.
2 changes: 1 addition & 1 deletion docs/providers/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ support, not every individual model capability exposed by an upstream provider.
| Z.ai | `ZAI_API_KEY` (`ZAI_BASE_URL` optional) | `glm-5.1` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | — |
| xAI (Grok) | `XAI_API_KEY` | `grok-4.5` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | [xAI (Grok)](/providers/xai) |
| Alibaba Cloud Model Studio (Bailian) | `BAILIAN_API_KEY` (`BAILIAN_BASE_URL` optional) | `qwen3-max` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | [Alibaba Cloud Model Studio](/providers/bailian) |
| MiniMax | `MINIMAX_API_KEY` (`MINIMAX_BASE_URL` optional) | `MiniMax-M3` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | |
| MiniMax | `MINIMAX_API_KEY` (`MINIMAX_BASE_URL` optional) | `MiniMax-M3` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [MiniMax](/providers/minimax) |
| Xiaomi MiMo | `XIAOMI_API_KEY` (`XIAOMI_BASE_URL` optional) | `mimo-v2.5-pro` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | [Xiaomi MiMo](/providers/xiaomi) |
| OpenCode Go | `OPENCODE_GO_API_KEY` (`OPENCODE_GO_BASE_URL` optional) | `glm-5.1` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | [OpenCode Go](/providers/opencode-go) |
| Kimi Code | `KIMICODE_API_KEY` | `kimi-for-coding` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [Kimi Code](/providers/kimicode) |
Expand Down
32 changes: 27 additions & 5 deletions internal/providers/minimax/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
Expand Down Expand Up @@ -117,11 +118,7 @@ func (p *Provider) CreateSpeech(ctx context.Context, req *core.AudioSpeechReques
return nil, core.NewProviderError("minimax", http.StatusBadGateway, "failed to parse speech response", err)
}
if response.BaseResponse.StatusCode != 0 {
message := "minimax speech request failed"
if statusMessage := strings.TrimSpace(response.BaseResponse.StatusMsg); statusMessage != "" {
message += ": " + statusMessage
}
return nil, core.NewProviderError("minimax", http.StatusBadGateway, message, nil)
return nil, speechStatusError(response.BaseResponse.StatusCode, response.BaseResponse.StatusMsg)
}
if response.Data == nil || strings.TrimSpace(response.Data.Audio) == "" {
return nil, core.NewProviderError("minimax", http.StatusBadGateway, "speech response contains no audio", nil)
Expand All @@ -140,6 +137,31 @@ func (p *Provider) CreateSpeech(ctx context.Context, req *core.AudioSpeechReques
}, nil
}

// speechStatusError maps a MiniMax base_resp status code to a gateway error.
// MiniMax reports failures as HTTP 200 with a non-zero base_resp.status_code,
// so caller mistakes (invalid parameters, auth, balance, rate limits) must be
// surfaced with their real meaning rather than a blanket 502.
func speechStatusError(statusCode int, statusMessage string) error {
message := fmt.Sprintf("minimax speech request failed (status %d)", statusCode)
if statusMessage = strings.TrimSpace(statusMessage); statusMessage != "" {
message += ": " + statusMessage
}
switch statusCode {
case 1002, 1039, 2045, 2056: // rate limit / token limit / rate growth limit / usage limit
return core.NewRateLimitError("minimax", message)
case 1004, 2049: // not authorized / invalid API key
return core.NewAuthenticationError("minimax", message)
case 1008: // insufficient balance
return core.NewProviderError("minimax", http.StatusPaymentRequired, message, nil)
case 1026, 1042, 2013, 20132: // sensitive input / invisible characters / invalid params / invalid voice_id
gatewayErr := core.NewInvalidRequestError(message, nil)
gatewayErr.Provider = "minimax"
return gatewayErr
default:
return core.NewProviderError("minimax", http.StatusBadGateway, message, nil)
}
}

func speechFormat(responseFormat string) (string, error) {
format := strings.ToLower(strings.TrimSpace(responseFormat))
if format == "" {
Expand Down
75 changes: 61 additions & 14 deletions internal/providers/minimax/audio_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package minimax
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"

Expand Down Expand Up @@ -138,21 +140,66 @@ func TestCreateSpeech_ValidatesNativeConstraints(t *testing.T) {
}
}

func TestCreateSpeech_ReturnsNativeStatusError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":null,"base_resp":{"status_code":1004,"status_msg":"invalid voice"}}`))
}))
defer server.Close()
func TestCreateSpeech_MapsNativeStatusCodes(t *testing.T) {
tests := []struct {
name string
nativeStatus int
statusMsg string
wantHTTPStatus int
wantType core.ErrorType
}{
{name: "rate limit", nativeStatus: 1002, statusMsg: "rate limit triggered", wantHTTPStatus: http.StatusTooManyRequests, wantType: core.ErrorTypeRateLimit},
{name: "tpm limit", nativeStatus: 1039, statusMsg: "token limit", wantHTTPStatus: http.StatusTooManyRequests, wantType: core.ErrorTypeRateLimit},
{name: "rate growth limit", nativeStatus: 2045, statusMsg: "rate growth limit", wantHTTPStatus: http.StatusTooManyRequests, wantType: core.ErrorTypeRateLimit},
{name: "usage limit", nativeStatus: 2056, statusMsg: "usage limit exceeded", wantHTTPStatus: http.StatusTooManyRequests, wantType: core.ErrorTypeRateLimit},
{name: "auth failed", nativeStatus: 1004, statusMsg: "not authorized", wantHTTPStatus: http.StatusUnauthorized, wantType: core.ErrorTypeAuthentication},
{name: "invalid api key", nativeStatus: 2049, statusMsg: "invalid API Key", wantHTTPStatus: http.StatusUnauthorized, wantType: core.ErrorTypeAuthentication},
{name: "insufficient balance", nativeStatus: 1008, statusMsg: "insufficient balance", wantHTTPStatus: http.StatusPaymentRequired, wantType: core.ErrorTypeProvider},
{name: "sensitive input", nativeStatus: 1026, statusMsg: "sensitive content", wantHTTPStatus: http.StatusBadRequest, wantType: core.ErrorTypeInvalidRequest},
{name: "invisible characters", nativeStatus: 1042, statusMsg: "invisible character ratio limit", wantHTTPStatus: http.StatusBadRequest, wantType: core.ErrorTypeInvalidRequest},
{name: "invalid params", nativeStatus: 2013, statusMsg: "invalid params", wantHTTPStatus: http.StatusBadRequest, wantType: core.ErrorTypeInvalidRequest},
{name: "invalid voice", nativeStatus: 20132, statusMsg: "invalid samples or voice_id", wantHTTPStatus: http.StatusBadRequest, wantType: core.ErrorTypeInvalidRequest},
{name: "unknown code", nativeStatus: 1000, statusMsg: "unknown error", wantHTTPStatus: http.StatusBadGateway, wantType: core.ErrorTypeProvider},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{})
_, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{
Model: "speech-2.8-hd",
Input: "hello",
Voice: "voice-id",
})
if err == nil || !strings.Contains(err.Error(), "invalid voice") {
t.Fatalf("CreateSpeech() error = %v, want native status message", err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
body, _ := json.Marshal(map[string]any{
"data": nil,
"base_resp": map[string]any{"status_code": tt.nativeStatus, "status_msg": tt.statusMsg},
})
_, _ = w.Write(body)
}))
defer server.Close()

provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{})
_, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{
Model: "speech-2.8-hd",
Input: "hello",
Voice: "voice-id",
})
var gatewayErr *core.GatewayError
if !errors.As(err, &gatewayErr) {
t.Fatalf("CreateSpeech() error = %v, want *core.GatewayError", err)
}
if gatewayErr.StatusCode != tt.wantHTTPStatus {
t.Fatalf("status = %d, want %d", gatewayErr.StatusCode, tt.wantHTTPStatus)
}
if gatewayErr.Type != tt.wantType {
t.Fatalf("type = %q, want %q", gatewayErr.Type, tt.wantType)
}
if !strings.Contains(gatewayErr.Message, tt.statusMsg) {
t.Fatalf("message = %q, want substring %q", gatewayErr.Message, tt.statusMsg)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if !strings.Contains(gatewayErr.Message, strconv.Itoa(tt.nativeStatus)) {
t.Fatalf("message = %q, want native status %d", gatewayErr.Message, tt.nativeStatus)
}
if gatewayErr.Provider != "minimax" {
t.Fatalf("provider = %q, want minimax", gatewayErr.Provider)
}
})
}
}

Expand Down