-
-
Notifications
You must be signed in to change notification settings - Fork 65
feat(realtime): add Azure OpenAI and Z.ai/Zhipu GLM realtime providers #396
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
d666f5a
feat(realtime): add Azure OpenAI and Z.ai/Zhipu GLM realtime support
SantiagoDePolonia 7f14201
fix(realtime): pin Z.ai realtime path; strip Azure /openai root
SantiagoDePolonia 0b46228
test(realtime): check url.Parse errors in zai/azure realtime tests
SantiagoDePolonia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package azure | ||
|
|
||
| import ( | ||
| "context" | ||
| "net/http" | ||
| "net/url" | ||
| "strings" | ||
|
|
||
| "gomodel/internal/core" | ||
| ) | ||
|
|
||
| // RealtimeTarget implements core.RealtimeProvider for Azure OpenAI's GPT Realtime | ||
| // API, which uses OpenAI's realtime event schema. Azure differs from OpenAI only | ||
| // in the dial shape: the websocket lives at <resource>/openai/realtime with the | ||
| // deployment and api-version as query parameters, and auth uses the api-key | ||
| // header (not Bearer). The api-key is injected here and must never be logged. | ||
| func (p *Provider) RealtimeTarget(_ context.Context, req *core.RealtimeRequest) (*core.RealtimeTarget, error) { | ||
| if req == nil || strings.TrimSpace(req.Model) == "" { | ||
| return nil, core.NewInvalidRequestError("model is required for realtime sessions", nil) | ||
| } | ||
|
|
||
| endpoint, err := p.realtimeURL(strings.TrimSpace(req.Model)) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| headers := http.Header{} | ||
| if p.apiKey != "" { | ||
| headers.Set("api-key", p.apiKey) | ||
| } | ||
|
|
||
| return &core.RealtimeTarget{URL: endpoint, Headers: headers}, nil | ||
| } | ||
|
|
||
| // realtimeURL builds wss://<resource>/openai/realtime?api-version=…&deployment=… | ||
| // from the configured base URL's resource root. The model selects the Azure | ||
| // deployment. | ||
| func (p *Provider) realtimeURL(deployment string) (string, error) { | ||
| root := resourceRootBaseURL(p.GetBaseURL()) | ||
| u, err := url.Parse(root) | ||
| if err != nil || u.Host == "" { | ||
| return "", core.NewInvalidRequestError("invalid azure realtime base url: "+root, err) | ||
| } | ||
| switch strings.ToLower(u.Scheme) { | ||
| case "https", "wss", "": | ||
| u.Scheme = "wss" | ||
| case "http", "ws": | ||
| u.Scheme = "ws" | ||
| default: | ||
| return "", core.NewInvalidRequestError("unsupported azure realtime base url scheme: "+u.Scheme, nil) | ||
| } | ||
| // Strip any existing /openai[/v1] root so a base already pointing at the | ||
| // OpenAI sub-path doesn't produce /openai/openai/realtime. | ||
| path := strings.TrimRight(u.Path, "/") | ||
| path = strings.TrimSuffix(path, "/openai/v1") | ||
| path = strings.TrimSuffix(path, "/openai") | ||
| u.Path = path + "/openai/realtime" | ||
| q := url.Values{} | ||
| q.Set("api-version", p.apiVersion) | ||
| q.Set("deployment", deployment) | ||
| u.RawQuery = q.Encode() | ||
| return u.String(), nil | ||
| } | ||
|
|
||
| // Compile-time assertion that Azure implements the realtime capability. | ||
| var _ core.RealtimeProvider = (*Provider)(nil) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| package azure | ||
|
|
||
| import ( | ||
| "context" | ||
| "net/url" | ||
| "testing" | ||
|
|
||
| "gomodel/internal/core" | ||
| "gomodel/internal/providers" | ||
| ) | ||
|
|
||
| func TestRealtimeTarget(t *testing.T) { | ||
| const apiKey = "azure-secret-key" | ||
| p := New(providers.ProviderConfig{ | ||
| APIKey: apiKey, | ||
| BaseURL: "https://myres.openai.azure.com/openai/deployments/gpt-realtime", | ||
| APIVersion: "2025-04-01-preview", | ||
| }, providers.ProviderOptions{}).(*Provider) | ||
|
|
||
| target, err := p.RealtimeTarget(context.Background(), &core.RealtimeRequest{Model: "gpt-realtime"}) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
|
|
||
| u, err := url.Parse(target.URL) | ||
| if err != nil { | ||
| t.Fatalf("parse target url: %v", err) | ||
| } | ||
| if u.Scheme != "wss" || u.Host != "myres.openai.azure.com" || u.Path != "/openai/realtime" { | ||
| t.Errorf("endpoint = %q, want wss://myres.openai.azure.com/openai/realtime", target.URL) | ||
| } | ||
| if got := u.Query().Get("deployment"); got != "gpt-realtime" { | ||
| t.Errorf("deployment = %q, want gpt-realtime", got) | ||
| } | ||
| if got := u.Query().Get("api-version"); got != "2025-04-01-preview" { | ||
| t.Errorf("api-version = %q, want 2025-04-01-preview", got) | ||
| } | ||
| // Azure authenticates with the api-key header, not Bearer. | ||
| if got := target.Headers.Get("api-key"); got != apiKey { | ||
| t.Errorf("api-key = %q, want %q", got, apiKey) | ||
| } | ||
| if target.Headers.Get("Authorization") != "" { | ||
| t.Error("Authorization header must not be set for Azure (uses api-key)") | ||
| } | ||
| } | ||
|
|
||
| func TestRealtimeTargetStripsExistingOpenAIPath(t *testing.T) { | ||
| // A base already rooted at /openai must not yield /openai/openai/realtime. | ||
| for _, base := range []string{ | ||
| "https://myres.openai.azure.com/openai", | ||
| "https://myres.openai.azure.com/openai/v1", | ||
| } { | ||
| p := New(providers.ProviderConfig{APIKey: "k", BaseURL: base}, providers.ProviderOptions{}).(*Provider) | ||
| target, err := p.RealtimeTarget(context.Background(), &core.RealtimeRequest{Model: "m"}) | ||
| if err != nil { | ||
| t.Fatalf("base %q: unexpected error: %v", base, err) | ||
| } | ||
| u, err := url.Parse(target.URL) | ||
| if err != nil { | ||
| t.Fatalf("base %q: parse target url: %v", base, err) | ||
| } | ||
| if u.Path != "/openai/realtime" { | ||
| t.Errorf("base %q: path = %q, want /openai/realtime", base, u.Path) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestRealtimeTargetOmitsAuthWhenNoKey(t *testing.T) { | ||
| p := New(providers.ProviderConfig{ | ||
| APIKey: "", | ||
| BaseURL: "https://myres.openai.azure.com", | ||
| }, providers.ProviderOptions{}).(*Provider) | ||
| target, err := p.RealtimeTarget(context.Background(), &core.RealtimeRequest{Model: "m"}) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if _, present := target.Headers["Api-Key"]; present { | ||
| t.Error("api-key header should be absent when no key is configured") | ||
| } | ||
| } | ||
|
|
||
| func TestRealtimeTargetMissingModel(t *testing.T) { | ||
| p := New(providers.ProviderConfig{APIKey: "k", BaseURL: "https://myres.openai.azure.com"}, providers.ProviderOptions{}).(*Provider) | ||
| if _, err := p.RealtimeTarget(context.Background(), &core.RealtimeRequest{Model: " "}); err == nil { | ||
| t.Fatal("expected error for missing model") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package zai | ||
|
|
||
| import ( | ||
| "context" | ||
| "net/http" | ||
| "net/url" | ||
| "strings" | ||
|
|
||
| "gomodel/internal/core" | ||
| ) | ||
|
|
||
| // realtimePath is the fixed GLM-Realtime websocket path. It is the same across | ||
| // regions and is independent of the chat base path (paas/v4, coding/paas/v4, | ||
| // anthropic, …), so only the host and scheme are taken from the base URL. | ||
| const realtimePath = "/api/paas/v4/realtime" | ||
|
|
||
| // RealtimeTarget implements core.RealtimeProvider for Z.ai / Zhipu GLM-Realtime, | ||
| // whose core event schema mirrors OpenAI's Realtime API. The host (region) comes | ||
| // from the configured base URL while the path is pinned to realtimePath so chat | ||
| // base variants like the Coding Plan endpoint still resolve correctly. Bearer | ||
| // auth is injected here and must never be logged. | ||
| func (p *Provider) RealtimeTarget(_ context.Context, req *core.RealtimeRequest) (*core.RealtimeTarget, error) { | ||
| model := "" | ||
| if req != nil { | ||
| model = strings.TrimSpace(req.Model) | ||
| } | ||
| if model == "" { | ||
| return nil, core.NewInvalidRequestError("model is required for realtime sessions", nil) | ||
| } | ||
|
|
||
| endpoint, err := realtimeURL(p.GetBaseURL(), model) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| headers := http.Header{} | ||
| if p.apiKey != "" { | ||
| headers.Set("Authorization", "Bearer "+p.apiKey) | ||
| } | ||
|
|
||
| return &core.RealtimeTarget{URL: endpoint, Headers: headers}, nil | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // realtimeURL maps the configured base URL host to the GLM-Realtime endpoint | ||
| // wss://<host>/api/paas/v4/realtime?model=... preserving the region host and | ||
| // mapping the scheme to ws/wss. | ||
| func realtimeURL(baseURL, model string) (string, error) { | ||
| base := strings.TrimSpace(baseURL) | ||
| if base == "" { | ||
| base = defaultBaseURL | ||
| } | ||
| u, err := url.Parse(base) | ||
| if err != nil || u.Host == "" { | ||
| return "", core.NewInvalidRequestError("invalid realtime base url: "+base, err) | ||
| } | ||
| scheme := "wss" | ||
| if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "ws") { | ||
| scheme = "ws" | ||
| } | ||
| rt := url.URL{Scheme: scheme, Host: u.Host, Path: realtimePath} | ||
| q := url.Values{} | ||
| q.Set("model", model) | ||
| rt.RawQuery = q.Encode() | ||
| return rt.String(), nil | ||
| } | ||
|
|
||
| // Compile-time assertion that Z.ai implements the realtime capability. | ||
| var _ core.RealtimeProvider = (*Provider)(nil) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| package zai | ||
|
|
||
| import ( | ||
| "context" | ||
| "net/url" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "gomodel/internal/core" | ||
| "gomodel/internal/providers" | ||
| ) | ||
|
|
||
| func TestRealtimeTarget(t *testing.T) { | ||
| const apiKey = "zai-secret-key" | ||
| p := New(providers.ProviderConfig{APIKey: apiKey}, providers.ProviderOptions{}).(*Provider) | ||
|
|
||
| target, err := p.RealtimeTarget(context.Background(), &core.RealtimeRequest{Model: "glm-realtime"}) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if !strings.HasPrefix(target.URL, "wss://api.z.ai/api/paas/v4/realtime?") { | ||
| t.Errorf("url = %q, want Z.ai realtime endpoint", target.URL) | ||
| } | ||
| u, err := url.Parse(target.URL) | ||
| if err != nil { | ||
| t.Fatalf("parse target url: %v", err) | ||
| } | ||
| if got := u.Query().Get("model"); got != "glm-realtime" { | ||
| t.Errorf("model query = %q, want %q", got, "glm-realtime") | ||
| } | ||
| if got := target.Headers.Get("Authorization"); got != "Bearer "+apiKey { | ||
| t.Errorf("Authorization = %q, want bearer with key", got) | ||
| } | ||
| } | ||
|
|
||
| func TestRealtimeTargetFollowsSetBaseURL(t *testing.T) { | ||
| // open.bigmodel.cn region must be honored when configured via ZAI_BASE_URL. | ||
| p := New(providers.ProviderConfig{APIKey: "k"}, providers.ProviderOptions{}).(*Provider) | ||
| p.SetBaseURL("https://open.bigmodel.cn/api/paas/v4") | ||
| target, err := p.RealtimeTarget(context.Background(), &core.RealtimeRequest{Model: "glm-realtime"}) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if !strings.HasPrefix(target.URL, "wss://open.bigmodel.cn/api/paas/v4/realtime?") { | ||
| t.Errorf("url = %q, want the configured region host", target.URL) | ||
| } | ||
| } | ||
|
|
||
| func TestRealtimeTargetNormalizesCodingPlanBase(t *testing.T) { | ||
| // The GLM Coding Plan base (/api/coding/paas/v4) must still resolve to the | ||
| // fixed realtime path /api/paas/v4/realtime, not /api/coding/paas/v4/realtime. | ||
| p := New(providers.ProviderConfig{APIKey: "k"}, providers.ProviderOptions{}).(*Provider) | ||
| p.SetBaseURL("https://api.z.ai/api/coding/paas/v4") | ||
| target, err := p.RealtimeTarget(context.Background(), &core.RealtimeRequest{Model: "glm-realtime"}) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| u, err := url.Parse(target.URL) | ||
| if err != nil { | ||
| t.Fatalf("parse target url: %v", err) | ||
| } | ||
| if u.Path != "/api/paas/v4/realtime" { | ||
| t.Errorf("path = %q, want /api/paas/v4/realtime", u.Path) | ||
| } | ||
| } | ||
|
|
||
| func TestRealtimeTargetOmitsAuthWhenNoKey(t *testing.T) { | ||
| p := New(providers.ProviderConfig{APIKey: ""}, providers.ProviderOptions{}).(*Provider) | ||
| target, err := p.RealtimeTarget(context.Background(), &core.RealtimeRequest{Model: "m"}) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if _, present := target.Headers["Authorization"]; present { | ||
| t.Error("Authorization header should be absent when no API key is configured") | ||
| } | ||
| } | ||
|
|
||
| func TestRealtimeTargetMissingModel(t *testing.T) { | ||
| p := New(providers.ProviderConfig{APIKey: "k"}, providers.ProviderOptions{}).(*Provider) | ||
| if _, err := p.RealtimeTarget(context.Background(), &core.RealtimeRequest{Model: " "}); err == nil { | ||
| t.Fatal("expected error for missing model") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
AZURE_API_VERSIONis omitted, this sends the provider default2024-10-21on realtime websocket dials. The PR documentation says Azure realtime requires a realtime-capable API version and that the default may be too old, so existing Azure configs can now fail with an opaque upstream websocket error instead of a clear local configuration error.