Skip to content

Commit dabe278

Browse files
feat(realtime): add xAI Grok Voice Agent realtime support
xAI's Voice Agent API (wss://api.x.ai/v1/realtime) is OpenAI Realtime API compatible and derives from the base URL exactly like OpenAI's, so factor the shared derivation into providers.OpenAIRealtimeURL and have both OpenAI and xAI use it; Bailian keeps its own (/api-ws/v1/realtime) target. - xai.Provider implements core.RealtimeProvider (+ retains baseURL, kept in sync by SetBaseURL). - providers.OpenAIRealtimeURL: shared https->wss + /realtime?model= derivation, with its table test moved to the providers package. - Compile-time core.RealtimeProvider assertions on OpenAI and xAI. Verified live over the gateway: grok-voice-latest relays session.created -> events -> response.done on both /v1/realtime and /p/xai/v1/realtime (the latter once xai is added to ENABLED_PASSTHROUGH_PROVIDERS, confirming the allowlist gate). Notes: xAI voice models are not in upstream /models discovery (configure via XAI_MODELS) and xAI bills realtime per-minute, so it reports no token usage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e065cf6 commit dabe278

8 files changed

Lines changed: 163 additions & 78 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Full reference: `.env.template` and `config/config.yaml`
114114
- `ENABLE_PASSTHROUGH_ROUTES` (true: Enable provider-native passthrough routes under /p/{provider}/...)
115115
- `ALLOW_PASSTHROUGH_V1_ALIAS` (true: Allow /p/{provider}/v1/... aliases while keeping /p/{provider}/... canonical)
116116
- `ENABLED_PASSTHROUGH_PROVIDERS` (openai,anthropic,openrouter,zai,vllm: Comma-separated list of enabled passthrough providers)
117-
- `REALTIME_ENABLED` (true: Expose the realtime speech-to-speech websocket at `/v1/realtime` and the `/p/{provider}/v1/realtime` upgrade. The gateway is a transparent websocket reverse proxy — it injects provider credentials and relays the provider's realtime event schema verbatim (no translation), so clients connect without provider API keys. Only providers implementing realtime accept sessions; currently OpenAI (`wss://…/v1/realtime`) and Bailian/Qwen-Omni (`wss://dashscope…/api-ws/v1/realtime`). Sessions are gated by the same model-access and budget rules as other model endpoints; usage is tracked per `response.done` event, accepting both the OpenAI singular and Alibaba plural token-detail spellings.)
117+
- `REALTIME_ENABLED` (true: Expose the realtime speech-to-speech websocket at `/v1/realtime` and the `/p/{provider}/v1/realtime` upgrade. The gateway is a transparent websocket reverse proxy — it injects provider credentials and relays the provider's realtime event schema verbatim (no translation), so clients connect without provider API keys. Only providers implementing realtime accept sessions; currently OpenAI and xAI/Grok Voice Agent (both `wss://…/v1/realtime`, OpenAI-realtime-compatible) and Bailian/Qwen-Omni (`wss://dashscope…/api-ws/v1/realtime`). xAI's voice models (e.g. `grok-voice-latest`) are not returned by upstream `/models` discovery, so configure them via `XAI_MODELS`; xAI bills realtime per-minute and reports no token usage. Sessions are gated by the same model-access and budget rules as other model endpoints; usage is tracked per `response.done` event, accepting both the OpenAI singular and Alibaba plural token-detail spellings.)
118118
- **Storage:** `STORAGE_TYPE` (sqlite), `SQLITE_PATH` (data/gomodel.db), `POSTGRES_URL`, `MONGODB_URL`
119119
- **Models:** `MODELS_ENABLED_BY_DEFAULT` (true), `MODEL_OVERRIDES_ENABLED` (true), `KEEP_ONLY_ALIASES_AT_MODELS_ENDPOINT` (false), `CONFIGURED_PROVIDER_MODELS_MODE` (`fallback` or `allowlist`, default `fallback`; `allowlist` skips upstream `/models` for providers with configured lists); persisted overrides restrict/allow selectors with `user_paths`. When alias-only models listing is enabled, `GET /v1/models` returns only model aliases, not full concrete model specs, to operators.
120120
- **Audit logging:** `LOGGING_ENABLED` (false), `LOGGING_LOG_BODIES` (false), `LOGGING_LOG_AUDIO_BODIES` (false: refines `LOGGING_LOG_BODIES` for audio endpoints — base64 audio for both `/v1/audio/speech` output and `/v1/audio/transcriptions` upload (≤8 MB each, else `too_large`) + dashboard playback, plus transcription upload metadata; no effect unless `LOGGING_LOG_BODIES` is on, in which case audio-off records a placeholder), `LOGGING_LOG_HEADERS` (false), `LOGGING_RETENTION_DAYS` (30)

internal/providers/openai/realtime.go

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ package openai
33
import (
44
"context"
55
"net/http"
6-
"net/url"
76
"strings"
87

98
"gomodel/internal/core"
9+
"gomodel/internal/providers"
1010
)
1111

1212
// RealtimeTarget implements core.RealtimeProvider for OpenAI's realtime websocket
@@ -18,7 +18,7 @@ func (p *Provider) RealtimeTarget(_ context.Context, req *core.RealtimeRequest)
1818
return nil, core.NewInvalidRequestError("model is required for realtime sessions", nil)
1919
}
2020

21-
endpoint, err := realtimeURL(p.baseURL, req.Model)
21+
endpoint, err := providers.OpenAIRealtimeURL(p.baseURL, req.Model)
2222
if err != nil {
2323
return nil, err
2424
}
@@ -33,32 +33,5 @@ func (p *Provider) RealtimeTarget(_ context.Context, req *core.RealtimeRequest)
3333
return &core.RealtimeTarget{URL: endpoint, Headers: headers}, nil
3434
}
3535

36-
// realtimeURL turns an HTTP(S) base URL such as https://api.openai.com/v1 into
37-
// the realtime websocket URL wss://api.openai.com/v1/realtime?model=... It maps
38-
// the scheme to ws/wss and appends the realtime path and model query parameter.
39-
func realtimeURL(baseURL, model string) (string, error) {
40-
base := strings.TrimSpace(baseURL)
41-
if base == "" {
42-
base = defaultBaseURL
43-
}
44-
u, err := url.Parse(base)
45-
if err != nil {
46-
return "", core.NewInvalidRequestError("invalid realtime base url: "+err.Error(), err)
47-
}
48-
switch strings.ToLower(u.Scheme) {
49-
case "https", "wss":
50-
u.Scheme = "wss"
51-
case "http", "ws":
52-
u.Scheme = "ws"
53-
default:
54-
return "", core.NewInvalidRequestError("unsupported realtime base url scheme: "+u.Scheme, nil)
55-
}
56-
u.Path = strings.TrimRight(u.Path, "/") + "/realtime"
57-
q := u.Query()
58-
q.Set("model", model)
59-
u.RawQuery = q.Encode()
60-
return u.String(), nil
61-
}
62-
6336
// Compile-time assertion that OpenAI implements the realtime capability.
6437
var _ core.RealtimeProvider = (*Provider)(nil)

internal/providers/openai/realtime_test.go

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,56 +2,13 @@ package openai
22

33
import (
44
"context"
5-
"net/url"
65
"strings"
76
"testing"
87

98
"gomodel/internal/core"
109
"gomodel/internal/providers"
1110
)
1211

13-
func TestRealtimeURL(t *testing.T) {
14-
tests := []struct {
15-
name string
16-
baseURL string
17-
model string
18-
wantBase string // scheme://host/path before query
19-
wantErr bool
20-
}{
21-
{name: "default https to wss", baseURL: "https://api.openai.com/v1", model: "gpt-realtime", wantBase: "wss://api.openai.com/v1/realtime"},
22-
{name: "empty base falls back to default", baseURL: "", model: "gpt-realtime", wantBase: "wss://api.openai.com/v1/realtime"},
23-
{name: "trailing slash normalized", baseURL: "https://api.openai.com/v1/", model: "gpt-realtime", wantBase: "wss://api.openai.com/v1/realtime"},
24-
{name: "http maps to ws", baseURL: "http://localhost:9000/v1", model: "m", wantBase: "ws://localhost:9000/v1/realtime"},
25-
{name: "wss preserved", baseURL: "wss://example.com/v1", model: "m", wantBase: "wss://example.com/v1/realtime"},
26-
{name: "unsupported scheme", baseURL: "ftp://example.com/v1", model: "m", wantErr: true},
27-
}
28-
for _, tt := range tests {
29-
t.Run(tt.name, func(t *testing.T) {
30-
got, err := realtimeURL(tt.baseURL, tt.model)
31-
if tt.wantErr {
32-
if err == nil {
33-
t.Fatalf("expected error, got %q", got)
34-
}
35-
return
36-
}
37-
if err != nil {
38-
t.Fatalf("unexpected error: %v", err)
39-
}
40-
u, parseErr := url.Parse(got)
41-
if parseErr != nil {
42-
t.Fatalf("result is not a valid URL: %v", parseErr)
43-
}
44-
base := u.Scheme + "://" + u.Host + u.Path
45-
if base != tt.wantBase {
46-
t.Errorf("base = %q, want %q", base, tt.wantBase)
47-
}
48-
if u.Query().Get("model") != tt.model {
49-
t.Errorf("model query = %q, want %q", u.Query().Get("model"), tt.model)
50-
}
51-
})
52-
}
53-
}
54-
5512
func TestRealtimeTarget(t *testing.T) {
5613
const apiKey = "sk-secret-key"
5714
p, ok := New(providers.ProviderConfig{APIKey: apiKey}, providers.ProviderOptions{}).(*Provider)

internal/providers/realtime_url.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package providers
2+
3+
import (
4+
"net/url"
5+
"strings"
6+
7+
"gomodel/internal/core"
8+
)
9+
10+
// OpenAIRealtimeURL derives an OpenAI-style realtime websocket URL from an
11+
// HTTP(S) base URL: https://host/v1 -> wss://host/v1/realtime?model=... It maps
12+
// the scheme to ws/wss and appends the realtime path and model query parameter.
13+
//
14+
// It is shared by providers whose realtime endpoint mirrors OpenAI's exact shape
15+
// (OpenAI, xAI). Providers whose realtime endpoint differs (e.g. Bailian's
16+
// /api-ws/v1/realtime) build their own target instead.
17+
func OpenAIRealtimeURL(baseURL, model string) (string, error) {
18+
base := strings.TrimSpace(baseURL)
19+
if base == "" {
20+
return "", core.NewInvalidRequestError("realtime base url is required", nil)
21+
}
22+
u, err := url.Parse(base)
23+
if err != nil {
24+
return "", core.NewInvalidRequestError("invalid realtime base url: "+err.Error(), err)
25+
}
26+
switch strings.ToLower(u.Scheme) {
27+
case "https", "wss":
28+
u.Scheme = "wss"
29+
case "http", "ws":
30+
u.Scheme = "ws"
31+
default:
32+
return "", core.NewInvalidRequestError("unsupported realtime base url scheme: "+u.Scheme, nil)
33+
}
34+
u.Path = strings.TrimRight(u.Path, "/") + "/realtime"
35+
q := u.Query()
36+
q.Set("model", model)
37+
u.RawQuery = q.Encode()
38+
return u.String(), nil
39+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package providers
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
)
7+
8+
func TestOpenAIRealtimeURL(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
baseURL string
12+
model string
13+
wantBase string // scheme://host/path before query
14+
wantErr bool
15+
}{
16+
{name: "openai https to wss", baseURL: "https://api.openai.com/v1", model: "gpt-realtime", wantBase: "wss://api.openai.com/v1/realtime"},
17+
{name: "xai https to wss", baseURL: "https://api.x.ai/v1", model: "grok-voice-latest", wantBase: "wss://api.x.ai/v1/realtime"},
18+
{name: "trailing slash normalized", baseURL: "https://api.openai.com/v1/", model: "m", wantBase: "wss://api.openai.com/v1/realtime"},
19+
{name: "http maps to ws", baseURL: "http://localhost:9000/v1", model: "m", wantBase: "ws://localhost:9000/v1/realtime"},
20+
{name: "wss preserved", baseURL: "wss://example.com/v1", model: "m", wantBase: "wss://example.com/v1/realtime"},
21+
{name: "empty base", baseURL: "", model: "m", wantErr: true},
22+
{name: "unsupported scheme", baseURL: "ftp://example.com/v1", model: "m", wantErr: true},
23+
}
24+
for _, tt := range tests {
25+
t.Run(tt.name, func(t *testing.T) {
26+
got, err := OpenAIRealtimeURL(tt.baseURL, tt.model)
27+
if tt.wantErr {
28+
if err == nil {
29+
t.Fatalf("expected error, got %q", got)
30+
}
31+
return
32+
}
33+
if err != nil {
34+
t.Fatalf("unexpected error: %v", err)
35+
}
36+
u, parseErr := url.Parse(got)
37+
if parseErr != nil {
38+
t.Fatalf("result is not a valid URL: %v", parseErr)
39+
}
40+
if base := u.Scheme + "://" + u.Host + u.Path; base != tt.wantBase {
41+
t.Errorf("base = %q, want %q", base, tt.wantBase)
42+
}
43+
if u.Query().Get("model") != tt.model {
44+
t.Errorf("model query = %q, want %q", u.Query().Get("model"), tt.model)
45+
}
46+
})
47+
}
48+
}

internal/providers/xai/realtime.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package xai
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"strings"
7+
8+
"gomodel/internal/core"
9+
"gomodel/internal/providers"
10+
)
11+
12+
// RealtimeTarget implements core.RealtimeProvider for xAI's Voice Agent API
13+
// (wss://api.x.ai/v1/realtime), which is largely OpenAI Realtime API compatible.
14+
// The endpoint shares OpenAI's shape, so the dial URL is derived from the base
15+
// URL the same way. Bearer auth is injected here and must never be logged.
16+
func (p *Provider) RealtimeTarget(_ context.Context, req *core.RealtimeRequest) (*core.RealtimeTarget, error) {
17+
if req == nil || strings.TrimSpace(req.Model) == "" {
18+
return nil, core.NewInvalidRequestError("model is required for realtime sessions", nil)
19+
}
20+
21+
endpoint, err := providers.OpenAIRealtimeURL(p.baseURL, req.Model)
22+
if err != nil {
23+
return nil, err
24+
}
25+
26+
headers := http.Header{}
27+
if p.apiKey != "" {
28+
headers.Set("Authorization", "Bearer "+p.apiKey)
29+
}
30+
31+
return &core.RealtimeTarget{URL: endpoint, Headers: headers}, nil
32+
}
33+
34+
// Compile-time assertion that xAI implements the realtime capability.
35+
var _ core.RealtimeProvider = (*Provider)(nil)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package xai
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"gomodel/internal/core"
9+
"gomodel/internal/providers"
10+
)
11+
12+
func TestRealtimeTarget(t *testing.T) {
13+
const apiKey = "xai-secret-key"
14+
p := New(providers.ProviderConfig{APIKey: apiKey}, providers.ProviderOptions{}).(*Provider)
15+
16+
target, err := p.RealtimeTarget(context.Background(), &core.RealtimeRequest{Model: "grok-voice-latest"})
17+
if err != nil {
18+
t.Fatalf("unexpected error: %v", err)
19+
}
20+
if !strings.HasPrefix(target.URL, "wss://api.x.ai/v1/realtime?") {
21+
t.Errorf("url = %q, want xAI realtime endpoint", target.URL)
22+
}
23+
if got := target.Headers.Get("Authorization"); got != "Bearer "+apiKey {
24+
t.Errorf("Authorization = %q, want bearer with key", got)
25+
}
26+
27+
if _, err := p.RealtimeTarget(context.Background(), &core.RealtimeRequest{Model: " "}); err == nil {
28+
t.Fatal("expected error for missing model")
29+
}
30+
}

internal/providers/xai/xai.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,18 @@ const (
3232

3333
// Provider implements the core.Provider interface for xAI
3434
type Provider struct {
35-
client *llmclient.Client
36-
apiKey string
35+
client *llmclient.Client
36+
apiKey string
37+
baseURL string // retained to build the realtime websocket dial target
3738
}
3839

3940
// New creates a new xAI provider.
4041
func New(providerCfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider {
41-
p := &Provider{apiKey: providerCfg.APIKey}
42+
baseURL := providers.ResolveBaseURL(providerCfg.BaseURL, defaultBaseURL)
43+
p := &Provider{apiKey: providerCfg.APIKey, baseURL: baseURL}
4244
clientCfg := llmclient.Config{
4345
ProviderName: "xai",
44-
BaseURL: providers.ResolveBaseURL(providerCfg.BaseURL, defaultBaseURL),
46+
BaseURL: baseURL,
4547
Retry: opts.Resilience.Retry,
4648
Hooks: opts.Hooks,
4749
CircuitBreaker: opts.Resilience.CircuitBreaker,
@@ -56,7 +58,7 @@ func NewWithHTTPClient(apiKey string, httpClient *http.Client, hooks llmclient.H
5658
if httpClient == nil {
5759
httpClient = http.DefaultClient
5860
}
59-
p := &Provider{apiKey: apiKey}
61+
p := &Provider{apiKey: apiKey, baseURL: defaultBaseURL}
6062
cfg := llmclient.DefaultConfig("xai", defaultBaseURL)
6163
cfg.Hooks = hooks
6264
p.client = llmclient.NewWithHTTPClient(httpClient, cfg, p.setHeaders)
@@ -66,6 +68,7 @@ func NewWithHTTPClient(apiKey string, httpClient *http.Client, hooks llmclient.H
6668
// SetBaseURL allows configuring a custom base URL for the provider
6769
func (p *Provider) SetBaseURL(url string) {
6870
p.client.SetBaseURL(url)
71+
p.baseURL = url
6972
}
7073

7174
// setHeaders sets the required headers for xAI API requests

0 commit comments

Comments
 (0)