From 423f3d5ff77a5678defa6a581ad5356543012eb7 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 13 Jul 2026 20:40:12 +0000 Subject: [PATCH] feat(xai): add xAI API key support with comprehensive handling and tests - Introduced `xai-api-key` to configuration for supporting xAI provider integration. - Added API key operations: creation, updates, deletion, and sanitization. - Implemented backend enhancements for credential-specific routing, response filtering with model aliasing, and WebSocket handling for xAI services. - Included extensive test coverage to validate configuration parsing, CRUD operations, and the new functionalities. - Updated related modules to handle `xai-api-key`, including the watcher, management handlers, and configuration sanitizers. --- config.example.yaml | 23 ++++++++++++++++++++++- internal/api/server.go | 11 +++++++++-- internal/config/config.go | 40 ++++++++++++++++++++++++++++++++++------ internal/config/model_display_name_test.go | 10 +++++++++- internal/config/parse.go | 1 + internal/config/xai_api_key_test.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/tui/client.go | 5 +++++ internal/tui/keys_tab.go | 5 +++++ internal/watcher/clients.go | 17 +++++++++++------ internal/watcher/watcher_test.go | 9 +++++---- sdk/cliproxy/config_model_display_name_test.go | 9 +++++++++ sdk/cliproxy/providers.go | 3 ++- sdk/cliproxy/service.go | 35 ++++++++++++++++++++++++++++++++--- sdk/cliproxy/types.go | 3 +++ sdk/config/config.go | 2 ++ internal/runtime/executor/xai_websockets_executor_test.go | 13 +++++++++++++ internal/watcher/diff/config_diff.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ internal/watcher/diff/config_diff_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- internal/watcher/diff/model_hash.go | 2 +- internal/watcher/diff/model_hash_test.go | 8 ++++++++ internal/watcher/diff/models_summary.go | 6 +++++- internal/watcher/synthesizer/config.go | 53 ++++++++++++++++++++++++++++++++--------------------- internal/watcher/synthesizer/config_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- sdk/cliproxy/auth/api_key_model_alias_test.go | 3 +++ sdk/cliproxy/auth/conductor.go | 25 +++++++++++++++++++++++++ sdk/cliproxy/auth/conductor_force_mapping_test.go | 11 +++++++++++ sdk/cliproxy/auth/types.go | 2 ++ internal/api/handlers/management/api_tools.go | 4 ++++ internal/api/handlers/management/api_tools_test.go | 12 ++++++++++++ internal/api/handlers/management/config_apikey_disable.go | 8 ++++++++ internal/api/handlers/management/config_apikey_disable_test.go | 28 ++++++++++++++++++++++++++++ internal/api/handlers/management/config_auth_index.go | 34 ++++++++++++++++++++++++++++++++++ internal/api/handlers/management/config_lists.go | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/api/handlers/management/config_lists_delete_keys_test.go | 27 +++++++++++++++++++++++++++ internal/api/handlers/management/config_xai_key_test.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 35 file(s) changed, 820 insertion(s)(+), 51 deletion(s)(-) diff --git a/config.example.yaml b/config.example.yaml --- a/config.example.yaml +++ b/config.example.yaml @@ -265,6 +265,27 @@ # - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini) # - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low) +# xAI API keys +# Uses the native xAI executor, including its Responses namespace-tool handling. +# xai-api-key: +# - api-key: "xai-..." +# prefix: "xai" # optional: require calls like "xai/grok-4.5" to target this credential +# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling +# base-url: "https://api.x.ai/v1" # xAI-compatible Responses API endpoint +# websockets: true # optional: use the xAI upstream websocket transport for downstream websocket requests +# headers: +# X-Custom-Header: "custom-value" +# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override +# # proxy-url: "direct" # optional: explicit direct connect for this credential +# models: +# - name: "grok-4.5" # upstream model name +# alias: "grok-latest" # client alias mapped to the upstream model +# display-name: "Grok Latest" # optional catalog display name +# force-mapping: true # optional: rewrite response model fields back to the alias +# excluded-models: +# - "grok-4.1" # exclude specific models (exact match) +# - "grok-3-*" # wildcard matching prefix + # Claude API keys # claude-api-key: # - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url @@ -384,7 +405,7 @@ # Global OAuth model name aliases (per channel) # These aliases rename model IDs for both model listing and request routing. # Supported channels: vertex, aistudio, antigravity, claude, codex, kimi, xai. -# NOTE: Aliases do not apply to gemini-api-key, interactions-api-key, codex-api-key, claude-api-key, openai-compatibility, or vertex-api-key. +# NOTE: Aliases do not apply to gemini-api-key, interactions-api-key, codex-api-key, xai-api-key, claude-api-key, openai-compatibility, or vertex-api-key. # NOTE: Because aliases affect the merged /v1 model list and merged request routing, overlapping # client-visible names can become ambiguous across providers. For strict backend pinning, use # unique aliases/prefixes or avoid overlapping names. diff --git a/internal/api/server.go b/internal/api/server.go --- a/internal/api/server.go +++ b/internal/api/server.go @@ -887,6 +887,11 @@ mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey) mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey) + mgmt.GET("/xai-api-key", s.mgmt.GetXAIKeys) + mgmt.PUT("/xai-api-key", s.mgmt.PutXAIKeys) + mgmt.PATCH("/xai-api-key", s.mgmt.PatchXAIKey) + mgmt.DELETE("/xai-api-key", s.mgmt.DeleteXAIKey) + mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat) mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat) mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat) @@ -1922,6 +1927,7 @@ interactionsAPIKeyCount := len(cfg.InteractionsKey) claudeAPIKeyCount := len(cfg.ClaudeKey) codexAPIKeyCount := len(cfg.CodexKey) + xaiAPIKeyCount := len(cfg.XAIKey) vertexAICompatCount := len(cfg.VertexCompatAPIKey) openAICompatCount := 0 for i := range cfg.OpenAICompatibility { @@ -1932,14 +1938,15 @@ openAICompatCount += len(entry.APIKeyEntries) } - total := authEntries + geminiAPIKeyCount + interactionsAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + vertexAICompatCount + openAICompatCount - fmt.Printf("server clients and configuration updated: %d clients (%d auth entries + %d Gemini API keys + %d Interactions API keys + %d Claude API keys + %d Codex keys + %d Vertex-compat + %d OpenAI-compat)\n", + total := authEntries + geminiAPIKeyCount + interactionsAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + xaiAPIKeyCount + vertexAICompatCount + openAICompatCount + fmt.Printf("server clients and configuration updated: %d clients (%d auth entries + %d Gemini API keys + %d Interactions API keys + %d Claude API keys + %d Codex keys + %d xAI keys + %d Vertex-compat + %d OpenAI-compat)\n", total, authEntries, geminiAPIKeyCount, interactionsAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, + xaiAPIKeyCount, vertexAICompatCount, openAICompatCount, ) diff --git a/internal/config/config.go b/internal/config/config.go --- a/internal/config/config.go +++ b/internal/config/config.go @@ -125,6 +125,9 @@ // Codex defines a list of Codex API key configurations as specified in the YAML configuration file. CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"` + // XAIKey defines xAI API key configurations using the same structure as Codex API keys. + XAIKey []XAIKey `yaml:"xai-api-key" json:"xai-api-key"` + // Codex configures provider-wide Codex request behavior. Codex CodexConfig `yaml:"codex" json:"codex"` @@ -162,7 +165,7 @@ // vertex, aistudio, antigravity, claude, codex, kimi, xai. // // NOTE: This does not apply to existing per-credential model alias features under: - // gemini-api-key, interactions-api-key, codex-api-key, claude-api-key, openai-compatibility, and vertex-api-key. + // gemini-api-key, interactions-api-key, codex-api-key, xai-api-key, claude-api-key, openai-compatibility, and vertex-api-key. OAuthModelAlias map[string][]OAuthModelAlias `yaml:"oauth-model-alias,omitempty" json:"oauth-model-alias,omitempty"` // Payload defines default and override rules for provider payload parameters. @@ -561,6 +564,12 @@ func (m CodexModel) GetDisplayName() string { return m.DisplayName } func (m CodexModel) GetForceMapping() bool { return m.ForceMapping } +// XAIKey uses the Codex API key structure for native xAI execution. +type XAIKey = CodexKey + +// XAIModel uses the Codex model mapping structure for xAI models. +type XAIModel = CodexModel + // GeminiKey represents the configuration for a Gemini API key, // including optional overrides for upstream base URL, proxy routing, and headers. type GeminiKey struct { @@ -815,6 +824,9 @@ // Sanitize Codex keys: drop entries without base-url cfg.SanitizeCodexKeys() + // Sanitize xAI keys: drop entries without base-url + cfg.SanitizeXAIKeys() + // Sanitize Codex header defaults. cfg.SanitizeCodexHeaderDefaults() @@ -1009,12 +1021,28 @@ // SanitizeCodexKeys removes Codex API key entries missing a BaseURL. // It trims whitespace and preserves order for remaining entries. func (cfg *Config) SanitizeCodexKeys() { - if cfg == nil || len(cfg.CodexKey) == 0 { + if cfg == nil { return } - out := make([]CodexKey, 0, len(cfg.CodexKey)) - for i := range cfg.CodexKey { - e := cfg.CodexKey[i] + cfg.CodexKey = sanitizeCodexKeyEntries(cfg.CodexKey) +} + +// SanitizeXAIKeys removes xAI API key entries missing a BaseURL. +// It applies the same normalization rules as codex-api-key. +func (cfg *Config) SanitizeXAIKeys() { + if cfg == nil { + return + } + cfg.XAIKey = sanitizeCodexKeyEntries(cfg.XAIKey) +} + +func sanitizeCodexKeyEntries(entries []CodexKey) []CodexKey { + if len(entries) == 0 { + return entries + } + out := make([]CodexKey, 0, len(entries)) + for i := range entries { + e := entries[i] e.Prefix = normalizeModelPrefix(e.Prefix) e.BaseURL = strings.TrimSpace(e.BaseURL) e.Headers = NormalizeHeaders(e.Headers) @@ -1024,7 +1052,7 @@ } out = append(out, e) } - cfg.CodexKey = out + return out } // SanitizeClaudeKeys normalizes headers for Claude credentials. diff --git a/internal/config/model_display_name_test.go b/internal/config/model_display_name_test.go --- a/internal/config/model_display_name_test.go +++ b/internal/config/model_display_name_test.go @@ -13,6 +13,11 @@ - name: codex-upstream alias: codex-alias display-name: Codex Name +xai-api-key: + - models: + - name: xai-upstream + alias: xai-alias + display-name: xAI Name claude-api-key: - models: - name: claude-upstream @@ -34,7 +39,7 @@ alias: compat-alias display-name: Compatibility Name ` - const jsonConfig = `{"codex-api-key":[{"models":[{"name":"codex-upstream","alias":"codex-alias","display-name":"Codex Name"}]}],"claude-api-key":[{"models":[{"name":"claude-upstream","alias":"claude-alias","display-name":"Claude Name"}]}],"gemini-api-key":[{"models":[{"name":"gemini-upstream","alias":"gemini-alias","display-name":"Gemini Name"}]}],"vertex-api-key":[{"models":[{"name":"vertex-upstream","alias":"vertex-alias","display-name":"Vertex Name"}]}],"openai-compatibility":[{"models":[{"name":"compat-upstream","alias":"compat-alias","display-name":"Compatibility Name"}]}]}` + const jsonConfig = `{"codex-api-key":[{"models":[{"name":"codex-upstream","alias":"codex-alias","display-name":"Codex Name"}]}],"xai-api-key":[{"models":[{"name":"xai-upstream","alias":"xai-alias","display-name":"xAI Name"}]}],"claude-api-key":[{"models":[{"name":"claude-upstream","alias":"claude-alias","display-name":"Claude Name"}]}],"gemini-api-key":[{"models":[{"name":"gemini-upstream","alias":"gemini-alias","display-name":"Gemini Name"}]}],"vertex-api-key":[{"models":[{"name":"vertex-upstream","alias":"vertex-alias","display-name":"Vertex Name"}]}],"openai-compatibility":[{"models":[{"name":"compat-upstream","alias":"compat-alias","display-name":"Compatibility Name"}]}]}` for _, tt := range []struct { name string @@ -60,6 +65,9 @@ } if got := cfg.CodexKey[0].Models[0].DisplayName; got != "Codex Name" { t.Fatalf("Codex display name = %q", got) + } + if got := cfg.XAIKey[0].Models[0].DisplayName; got != "xAI Name" { + t.Fatalf("xAI display name = %q", got) } if got := cfg.ClaudeKey[0].Models[0].DisplayName; got != "Claude Name" { t.Fatalf("Claude display name = %q", got) diff --git a/internal/config/parse.go b/internal/config/parse.go --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -82,6 +82,7 @@ cfg.SanitizeInteractionsKeys() cfg.SanitizeVertexCompatKeys() cfg.SanitizeCodexKeys() + cfg.SanitizeXAIKeys() cfg.SanitizeCodexHeaderDefaults() cfg.SanitizeClaudeHeaderDefaults() cfg.SanitizeClaudeKeys() diff --git a/internal/config/xai_api_key_test.go b/internal/config/xai_api_key_test.go new file mode 100644 --- /dev/null +++ b/internal/config/xai_api_key_test.go @@ -0,0 +1,67 @@ +package config + +import "testing" + +func TestParseConfigBytesXAIAPIKeyMatchesCodexShape(t *testing.T) { + cfg, errParse := ParseConfigBytes([]byte(`xai-api-key: + - api-key: " xai-key " + priority: 3 + prefix: " team-xai " + base-url: " https://api.x.ai/v1 " + websockets: true + proxy-url: " http://proxy.local " + headers: + X-Custom: value + models: + - name: grok-4.5 + alias: grok-latest + display-name: Grok Latest + force-mapping: true + excluded-models: + - " grok-3-* " + disable-cooling: true + - api-key: dropped + base-url: " " +`)) + if errParse != nil { + t.Fatalf("ParseConfigBytes() error = %v", errParse) + } + if len(cfg.XAIKey) != 1 { + t.Fatalf("xai-api-key count = %d, want 1", len(cfg.XAIKey)) + } + entry := cfg.XAIKey[0] + if entry.APIKey != " xai-key " { + t.Fatalf("api-key = %q, want original Codex-compatible value", entry.APIKey) + } + if entry.Priority != 3 { + t.Fatalf("priority = %d, want 3", entry.Priority) + } + if entry.Prefix != "team-xai" { + t.Fatalf("prefix = %q, want team-xai", entry.Prefix) + } + if entry.BaseURL != "https://api.x.ai/v1" { + t.Fatalf("base-url = %q, want https://api.x.ai/v1", entry.BaseURL) + } + if !entry.Websockets { + t.Fatal("websockets = false, want true") + } + if entry.ProxyURL != " http://proxy.local " { + t.Fatalf("proxy-url = %q, want original Codex-compatible value", entry.ProxyURL) + } + if !entry.DisableCooling { + t.Fatal("disable-cooling = false, want true") + } + if entry.Headers["X-Custom"] != "value" { + t.Fatalf("X-Custom header = %q, want value", entry.Headers["X-Custom"]) + } + if len(entry.Models) != 1 { + t.Fatalf("model count = %d, want 1", len(entry.Models)) + } + model := entry.Models[0] + if model.Name != "grok-4.5" || model.Alias != "grok-latest" || model.DisplayName != "Grok Latest" || !model.ForceMapping { + t.Fatalf("unexpected model mapping: %+v", model) + } + if len(entry.ExcludedModels) != 1 || entry.ExcludedModels[0] != "grok-3-*" { + t.Fatalf("excluded-models = %#v, want [grok-3-*]", entry.ExcludedModels) + } +} diff --git a/internal/tui/client.go b/internal/tui/client.go --- a/internal/tui/client.go +++ b/internal/tui/client.go @@ -306,6 +306,11 @@ return c.getWrappedKeyList("/v0/management/codex-api-key", "codex-api-key") } +// GetXAIKeys fetches xAI API keys. +func (c *Client) GetXAIKeys() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/xai-api-key", "xai-api-key") +} + // GetVertexKeys fetches Vertex API keys. func (c *Client) GetVertexKeys() ([]map[string]any, error) { return c.getWrappedKeyList("/v0/management/vertex-api-key", "vertex-api-key") diff --git a/internal/tui/keys_tab.go b/internal/tui/keys_tab.go --- a/internal/tui/keys_tab.go +++ b/internal/tui/keys_tab.go @@ -20,6 +20,7 @@ interactions []map[string]any claude []map[string]any codex []map[string]any + xai []map[string]any vertex []map[string]any openai []map[string]any err error @@ -43,6 +44,7 @@ interactions []map[string]any claude []map[string]any codex []map[string]any + xai []map[string]any vertex []map[string]any openai []map[string]any err error @@ -80,6 +82,7 @@ result.interactions, _ = m.client.GetInteractionsKeys() result.claude, _ = m.client.GetClaudeKeys() result.codex, _ = m.client.GetCodexKeys() + result.xai, _ = m.client.GetXAIKeys() result.vertex, _ = m.client.GetVertexKeys() result.openai, _ = m.client.GetOpenAICompat() return result @@ -100,6 +103,7 @@ m.interactions = msg.interactions m.claude = msg.claude m.codex = msg.codex + m.xai = msg.xai m.vertex = msg.vertex m.openai = msg.openai if m.cursor >= len(m.keys) { @@ -347,6 +351,7 @@ renderProviderKeys(&sb, "Interactions API Keys", m.interactions) renderProviderKeys(&sb, "Claude API Keys", m.claude) renderProviderKeys(&sb, "Codex API Keys", m.codex) + renderProviderKeys(&sb, "xAI API Keys", m.xai) renderProviderKeys(&sb, "Vertex API Keys", m.vertex) if len(m.openai) > 0 { diff --git a/internal/watcher/clients.go b/internal/watcher/clients.go --- a/internal/watcher/clients.go +++ b/internal/watcher/clients.go @@ -56,8 +56,8 @@ w.clientsMutex.Unlock() } - geminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := BuildAPIKeyClients(cfg) - totalAPIKeyClients := geminiAPIKeyCount + vertexCompatAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount + geminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, xaiAPIKeyCount, openAICompatCount := BuildAPIKeyClients(cfg) + totalAPIKeyClients := geminiAPIKeyCount + vertexCompatAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + xaiAPIKeyCount + openAICompatCount log.Debugf("loaded %d API key clients", totalAPIKeyClients) var authFileCount int @@ -136,7 +136,7 @@ w.authRescanMu.Unlock() } - totalNewClients := authFileCount + geminiAPIKeyCount + vertexCompatAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount + totalNewClients := authFileCount + geminiAPIKeyCount + vertexCompatAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + xaiAPIKeyCount + openAICompatCount if w.reloadCallback != nil { log.Debugf("triggering server update callback before auth refresh") @@ -146,13 +146,14 @@ w.refreshAuthState(forceAuthRefresh) redisqueue.NotifyUsageRefresh() - log.Infof("full client load complete - %d clients (%d auth files + %d Gemini API keys + %d Vertex API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)", + log.Infof("full client load complete - %d clients (%d auth files + %d Gemini API keys + %d Vertex API keys + %d Claude API keys + %d Codex keys + %d xAI keys + %d OpenAI-compat)", totalNewClients, authFileCount, geminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, + xaiAPIKeyCount, openAICompatCount, ) } @@ -374,11 +375,12 @@ return authFileCount } -func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int, int) { +func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int, int, int) { geminiAPIKeyCount := 0 vertexCompatAPIKeyCount := 0 claudeAPIKeyCount := 0 codexAPIKeyCount := 0 + xaiAPIKeyCount := 0 openAICompatCount := 0 if len(cfg.GeminiKey) > 0 { @@ -396,6 +398,9 @@ if len(cfg.CodexKey) > 0 { codexAPIKeyCount += len(cfg.CodexKey) } + if len(cfg.XAIKey) > 0 { + xaiAPIKeyCount += len(cfg.XAIKey) + } if len(cfg.OpenAICompatibility) > 0 { for _, compatConfig := range cfg.OpenAICompatibility { if compatConfig.Disabled { @@ -404,7 +409,7 @@ openAICompatCount += len(compatConfig.APIKeyEntries) } } - return geminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount + return geminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, xaiAPIKeyCount, openAICompatCount } func (w *Watcher) persistConfigAsync() { diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -69,15 +69,16 @@ {APIKey: "v1"}, }, ClaudeKey: []config.ClaudeKey{{APIKey: "c1"}}, - CodexKey: []config.CodexKey{{APIKey: "x1"}, {APIKey: "x2"}}, + CodexKey: []config.CodexKey{{APIKey: "c1"}, {APIKey: "c2"}}, + XAIKey: []config.XAIKey{{APIKey: "x1"}}, OpenAICompatibility: []config.OpenAICompatibility{ {APIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: "o1"}, {APIKey: "o2"}}}, }, } - gemini, vertex, claude, codex, compat := BuildAPIKeyClients(cfg) - if gemini != 3 || vertex != 1 || claude != 1 || codex != 2 || compat != 2 { - t.Fatalf("unexpected counts: %d %d %d %d %d", gemini, vertex, claude, codex, compat) + gemini, vertex, claude, codex, xai, compat := BuildAPIKeyClients(cfg) + if gemini != 3 || vertex != 1 || claude != 1 || codex != 2 || xai != 1 || compat != 2 { + t.Fatalf("unexpected counts: %d %d %d %d %d %d", gemini, vertex, claude, codex, xai, compat) } } diff --git a/sdk/cliproxy/config_model_display_name_test.go b/sdk/cliproxy/config_model_display_name_test.go --- a/sdk/cliproxy/config_model_display_name_test.go +++ b/sdk/cliproxy/config_model_display_name_test.go @@ -48,6 +48,15 @@ }}})[0] }, }, + { + name: "xai", + want: "xAI Catalog Name", + got: func() *ModelInfo { + return buildXAIConfigModels(&config.XAIKey{Models: []config.XAIModel{{ + Name: "grok-4.5", Alias: "grok-latest", DisplayName: "xAI Catalog Name", + }}})[0] + }, + }, } for _, tt := range tests { diff --git a/sdk/cliproxy/providers.go b/sdk/cliproxy/providers.go --- a/sdk/cliproxy/providers.go +++ b/sdk/cliproxy/providers.go @@ -29,7 +29,7 @@ type apiKeyClientProvider struct{} func (p *apiKeyClientProvider) Load(ctx context.Context, cfg *config.Config) (*APIKeyClientResult, error) { - geminiCount, vertexCompatCount, claudeCount, codexCount, openAICompat := watcher.BuildAPIKeyClients(cfg) + geminiCount, vertexCompatCount, claudeCount, codexCount, xaiCount, openAICompat := watcher.BuildAPIKeyClients(cfg) if ctx != nil { select { case <-ctx.Done(): @@ -42,6 +42,7 @@ VertexCompatKeyCount: vertexCompatCount, ClaudeKeyCount: claudeCount, CodexKeyCount: codexCount, + XAIKeyCount: xaiCount, OpenAICompatCount: openAICompat, }, nil } diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -2027,6 +2027,14 @@ models = applyExcludedModels(models, excluded) case "xai": models = registry.GetXAIModels() + if entry := s.resolveConfigXAIKey(a); entry != nil { + if len(entry.Models) > 0 { + models = buildXAIConfigModels(entry) + } + if authKind == "apikey" { + excluded = entry.ExcludedModels + } + } models = applyExcludedModels(models, excluded) default: // Handle OpenAI-compatibility providers by name using config @@ -2310,7 +2318,21 @@ } func (s *Service) resolveConfigCodexKey(auth *coreauth.Auth) *config.CodexKey { - if auth == nil || s.cfg == nil { + if s == nil || s.cfg == nil { + return nil + } + return resolveConfigCodexStyleKey(auth, s.cfg.CodexKey) +} + +func (s *Service) resolveConfigXAIKey(auth *coreauth.Auth) *config.XAIKey { + if s == nil || s.cfg == nil { + return nil + } + return resolveConfigCodexStyleKey(auth, s.cfg.XAIKey) +} + +func resolveConfigCodexStyleKey(auth *coreauth.Auth, entries []config.CodexKey) *config.CodexKey { + if auth == nil { return nil } var attrKey, attrBase string @@ -2318,8 +2340,8 @@ attrKey = strings.TrimSpace(auth.Attributes["api_key"]) attrBase = strings.TrimSpace(auth.Attributes["base_url"]) } - for i := range s.cfg.CodexKey { - entry := &s.cfg.CodexKey[i] + for i := range entries { + entry := &entries[i] cfgKey := strings.TrimSpace(entry.APIKey) cfgBase := strings.TrimSpace(entry.BaseURL) if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { @@ -2602,6 +2624,13 @@ return nil } return buildConfigModels(entry.Models, "anthropic", "claude") +} + +func buildXAIConfigModels(entry *config.XAIKey) []*ModelInfo { + if entry == nil { + return nil + } + return buildConfigModels(entry.Models, "xai", "xai") } func buildCodexConfigModels(entry *config.CodexKey) []*ModelInfo { diff --git a/sdk/cliproxy/types.go b/sdk/cliproxy/types.go --- a/sdk/cliproxy/types.go +++ b/sdk/cliproxy/types.go @@ -65,6 +65,9 @@ // CodexKeyCount is the number of Codex API keys loaded CodexKeyCount int + // XAIKeyCount is the number of xAI API keys loaded + XAIKeyCount int + // OpenAICompatCount is the number of OpenAI compatibility API keys loaded OpenAICompatCount int } diff --git a/sdk/config/config.go b/sdk/config/config.go --- a/sdk/config/config.go +++ b/sdk/config/config.go @@ -21,6 +21,8 @@ type GeminiKey = internalconfig.GeminiKey type CodexKey = internalconfig.CodexKey +type XAIKey = internalconfig.XAIKey +type XAIModel = internalconfig.XAIModel type ClaudeKey = internalconfig.ClaudeKey type VertexCompatKey = internalconfig.VertexCompatKey type VertexCompatModel = internalconfig.VertexCompatModel diff --git a/internal/runtime/executor/xai_websockets_executor_test.go b/internal/runtime/executor/xai_websockets_executor_test.go --- a/internal/runtime/executor/xai_websockets_executor_test.go +++ b/internal/runtime/executor/xai_websockets_executor_test.go @@ -20,6 +20,19 @@ "github.com/tidwall/gjson" ) +func TestXAIWebsocketsEnabledForConfigAPIKey(t *testing.T) { + auth := &cliproxyauth.Auth{ + Provider: "xai", + Attributes: map[string]string{ + "api_key": "xai-key", + "websockets": "true", + }, + } + if !xaiWebsocketsEnabled(auth) { + t.Fatal("xaiWebsocketsEnabled() = false, want true") + } +} + func TestXAIWebsocketsExecuteStreamSendsResponseCreateWithPreviousResponseID(t *testing.T) { upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} capturedPayload := make(chan []byte, 1) diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -273,6 +273,50 @@ } } + // xAI keys (do not print key material) + if len(oldCfg.XAIKey) != len(newCfg.XAIKey) { + changes = append(changes, fmt.Sprintf("xai-api-key count: %d -> %d", len(oldCfg.XAIKey), len(newCfg.XAIKey))) + } else { + for i := range oldCfg.XAIKey { + o := oldCfg.XAIKey[i] + n := newCfg.XAIKey[i] + if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) { + changes = append(changes, fmt.Sprintf("xai[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL))) + } + if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) { + changes = append(changes, fmt.Sprintf("xai[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL))) + } + if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) { + changes = append(changes, fmt.Sprintf("xai[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix))) + } + if o.Priority != n.Priority { + changes = append(changes, fmt.Sprintf("xai[%d].priority: %d -> %d", i, o.Priority, n.Priority)) + } + if o.Websockets != n.Websockets { + changes = append(changes, fmt.Sprintf("xai[%d].websockets: %t -> %t", i, o.Websockets, n.Websockets)) + } + if o.DisableCooling != n.DisableCooling { + changes = append(changes, fmt.Sprintf("xai[%d].disable-cooling: %t -> %t", i, o.DisableCooling, n.DisableCooling)) + } + if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { + changes = append(changes, fmt.Sprintf("xai[%d].api-key: updated", i)) + } + if !equalStringMap(o.Headers, n.Headers) { + changes = append(changes, fmt.Sprintf("xai[%d].headers: updated", i)) + } + oldModels := SummarizeCodexModels(o.Models) + newModels := SummarizeCodexModels(n.Models) + if oldModels.hash != newModels.hash { + changes = append(changes, fmt.Sprintf("xai[%d].models: updated (%d -> %d entries)", i, oldModels.count, newModels.count)) + } + oldExcluded := SummarizeExcludedModels(o.ExcludedModels) + newExcluded := SummarizeExcludedModels(n.ExcludedModels) + if oldExcluded.hash != newExcluded.hash { + changes = append(changes, fmt.Sprintf("xai[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count)) + } + } + } + if entries, _ := DiffOAuthExcludedModelChanges(oldCfg.OAuthExcludedModels, newCfg.OAuthExcludedModels); len(entries) > 0 { changes = append(changes, entries...) } diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go --- a/internal/watcher/diff/config_diff_test.go +++ b/internal/watcher/diff/config_diff_test.go @@ -153,6 +153,61 @@ expectContains(t, changes, "vertex[0].prefix: old-v -> new-v") } +func TestBuildConfigChangeDetails_XAIKeys(t *testing.T) { + oldCfg := &config.Config{XAIKey: []config.XAIKey{{ + APIKey: "old-key", + Priority: 1, + Prefix: "old", + BaseURL: "https://old.example.com/v1", + ProxyURL: "http://old-proxy", + Websockets: false, + DisableCooling: false, + Headers: map[string]string{"X-Test": "old"}, + Models: []config.XAIModel{{Name: "grok-old", Alias: "grok"}}, + ExcludedModels: []string{"grok-hidden"}, + }}} + newCfg := &config.Config{XAIKey: []config.XAIKey{{ + APIKey: "new-key", + Priority: 2, + Prefix: "new", + BaseURL: "https://new.example.com/v1", + ProxyURL: "http://new-proxy", + Websockets: true, + DisableCooling: true, + Headers: map[string]string{"X-Test": "new"}, + Models: []config.XAIModel{{Name: "grok-new", Alias: "grok"}}, + ExcludedModels: []string{"grok-other"}, + }}} + + changes := BuildConfigChangeDetails(oldCfg, newCfg) + expectContains(t, changes, "xai[0].base-url: https://old.example.com/v1 -> https://new.example.com/v1") + expectContains(t, changes, "xai[0].proxy-url: http://old-proxy -> http://new-proxy") + expectContains(t, changes, "xai[0].prefix: old -> new") + expectContains(t, changes, "xai[0].priority: 1 -> 2") + expectContains(t, changes, "xai[0].websockets: false -> true") + expectContains(t, changes, "xai[0].disable-cooling: false -> true") + expectContains(t, changes, "xai[0].api-key: updated") + expectContains(t, changes, "xai[0].headers: updated") + expectContains(t, changes, "xai[0].models: updated (1 -> 1 entries)") + expectContains(t, changes, "xai[0].excluded-models: updated (1 -> 1 entries)") +} + +func TestBuildConfigChangeDetails_XAIForceMappingOnly(t *testing.T) { + oldCfg := &config.Config{XAIKey: []config.XAIKey{{ + APIKey: "xai-key", + BaseURL: "https://api.x.ai/v1", + Models: []config.XAIModel{{Name: "grok-4.5", Alias: "grok-latest"}}, + }}} + newCfg := &config.Config{XAIKey: []config.XAIKey{{ + APIKey: "xai-key", + BaseURL: "https://api.x.ai/v1", + Models: []config.XAIModel{{Name: "grok-4.5", Alias: "grok-latest", ForceMapping: true}}, + }}} + + changes := BuildConfigChangeDetails(oldCfg, newCfg) + expectContains(t, changes, "xai[0].models: updated (1 -> 1 entries)") +} + func TestBuildConfigChangeDetails_NilSafe(t *testing.T) { if details := BuildConfigChangeDetails(nil, &config.Config{}); len(details) != 0 { t.Fatalf("expected empty change list when old nil, got %v", details) @@ -477,7 +532,8 @@ newCfg := &config.Config{ GeminiKey: []config.GeminiKey{{APIKey: "g"}}, ClaudeKey: []config.ClaudeKey{{APIKey: "c"}}, - CodexKey: []config.CodexKey{{APIKey: "x"}}, + CodexKey: []config.CodexKey{{APIKey: "c"}}, + XAIKey: []config.XAIKey{{APIKey: "x"}}, VertexCompatAPIKey: []config.VertexCompatKey{ {APIKey: "v", BaseURL: "http://v"}, }, @@ -487,6 +543,7 @@ expectContains(t, changes, "gemini-api-key count: 0 -> 1") expectContains(t, changes, "claude-api-key count: 0 -> 1") expectContains(t, changes, "codex-api-key count: 0 -> 1") + expectContains(t, changes, "xai-api-key count: 0 -> 1") expectContains(t, changes, "vertex-api-key count: 0 -> 1") } diff --git a/internal/watcher/diff/model_hash.go b/internal/watcher/diff/model_hash.go --- a/internal/watcher/diff/model_hash.go +++ b/internal/watcher/diff/model_hash.go @@ -66,7 +66,7 @@ if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping)) } }) return hashJoined(keys) diff --git a/internal/watcher/diff/model_hash_test.go b/internal/watcher/diff/model_hash_test.go --- a/internal/watcher/diff/model_hash_test.go +++ b/internal/watcher/diff/model_hash_test.go @@ -171,6 +171,14 @@ } } +func TestComputeCodexModelsHashIncludesForceMapping(t *testing.T) { + withoutForceMapping := ComputeCodexModelsHash([]config.CodexModel{{Name: "m", Alias: "a"}}) + withForceMapping := ComputeCodexModelsHash([]config.CodexModel{{Name: "m", Alias: "a", ForceMapping: true}}) + if withoutForceMapping == "" || withoutForceMapping == withForceMapping { + t.Fatalf("force-mapping must change model hash: %q / %q", withoutForceMapping, withForceMapping) + } +} + func TestComputeExcludedModelsHash_Normalizes(t *testing.T) { hash1 := ComputeExcludedModelsHash([]string{" A ", "b", "a"}) hash2 := ComputeExcludedModelsHash([]string{"a", " b", "A"}) diff --git a/internal/watcher/diff/models_summary.go b/internal/watcher/diff/models_summary.go --- a/internal/watcher/diff/models_summary.go +++ b/internal/watcher/diff/models_summary.go @@ -83,7 +83,11 @@ if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) + forceMapping := "false" + if model.ForceMapping { + forceMapping = "true" + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|force-mapping=" + forceMapping) } }) return CodexModelsSummary{ diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -13,7 +13,7 @@ ) // ConfigSynthesizer generates Auth entries from configuration API keys. -// It handles Gemini, Interactions, Claude, Codex, OpenAI-compat, and Vertex-compat providers. +// It handles Gemini, Interactions, Claude, Codex, xAI, OpenAI-compat, and Vertex-compat providers. type ConfigSynthesizer struct{} // NewConfigSynthesizer creates a new ConfigSynthesizer instance. @@ -36,6 +36,8 @@ out = append(out, s.synthesizeClaudeKeys(ctx)...) // Codex API Keys out = append(out, s.synthesizeCodexKeys(ctx)...) + // xAI API Keys + out = append(out, s.synthesizeXAIKeys(ctx)...) // OpenAI-compat out = append(out, s.synthesizeOpenAICompat(ctx)...) // Vertex-compat @@ -170,54 +172,63 @@ // synthesizeCodexKeys creates Auth entries for Codex API keys. func (s *ConfigSynthesizer) synthesizeCodexKeys(ctx *SynthesisContext) []*coreauth.Auth { + return s.synthesizeCodexStyleKeys(ctx, ctx.Config.CodexKey, "codex") +} + +// synthesizeXAIKeys creates Auth entries for xAI API keys. +func (s *ConfigSynthesizer) synthesizeXAIKeys(ctx *SynthesisContext) []*coreauth.Auth { + return s.synthesizeCodexStyleKeys(ctx, ctx.Config.XAIKey, "xai") +} + +func (s *ConfigSynthesizer) synthesizeCodexStyleKeys(ctx *SynthesisContext, entries []config.CodexKey, provider string) []*coreauth.Auth { cfg := ctx.Config now := ctx.Now idGen := ctx.IDGenerator - out := make([]*coreauth.Auth, 0, len(cfg.CodexKey)) - for i := range cfg.CodexKey { - ck := cfg.CodexKey[i] - key := strings.TrimSpace(ck.APIKey) + out := make([]*coreauth.Auth, 0, len(entries)) + for i := range entries { + entry := entries[i] + key := strings.TrimSpace(entry.APIKey) if key == "" { continue } - prefix := strings.TrimSpace(ck.Prefix) - id, token := idGen.Next("codex:apikey", key, ck.BaseURL) + prefix := strings.TrimSpace(entry.Prefix) + baseURL := strings.TrimSpace(entry.BaseURL) + id, token := idGen.Next(provider+":apikey", key, baseURL) attrs := map[string]string{ - "source": fmt.Sprintf("config:codex[%s]", token), + "source": fmt.Sprintf("config:%s[%s]", provider, token), "api_key": key, } metadata := map[string]any{} - if ck.DisableCooling { + if entry.DisableCooling { metadata["disable_cooling"] = true } - if ck.Priority != 0 { - attrs["priority"] = strconv.Itoa(ck.Priority) + if entry.Priority != 0 { + attrs["priority"] = strconv.Itoa(entry.Priority) } - if ck.BaseURL != "" { - attrs["base_url"] = ck.BaseURL + if baseURL != "" { + attrs["base_url"] = baseURL } - if ck.Websockets { + if entry.Websockets { attrs["websockets"] = "true" } - if hash := diff.ComputeCodexModelsHash(ck.Models); hash != "" { + if hash := diff.ComputeCodexModelsHash(entry.Models); hash != "" { attrs["models_hash"] = hash } - addConfigHeadersToAttrs(ck.Headers, attrs) - proxyURL := strings.TrimSpace(ck.ProxyURL) + addConfigHeadersToAttrs(entry.Headers, attrs) a := &coreauth.Auth{ ID: id, - Provider: "codex", - Label: "codex-apikey", + Provider: provider, + Label: provider + "-apikey", Prefix: prefix, Status: coreauth.StatusActive, - ProxyURL: proxyURL, + ProxyURL: strings.TrimSpace(entry.ProxyURL), Attributes: attrs, Metadata: metadata, CreatedAt: now, UpdatedAt: now, } - ApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey") + ApplyAuthExcludedModelsMeta(a, cfg, entry.ExcludedModels, "apikey") if len(a.Metadata) == 0 { a.Metadata = nil } diff --git a/internal/watcher/synthesizer/config_test.go b/internal/watcher/synthesizer/config_test.go --- a/internal/watcher/synthesizer/config_test.go +++ b/internal/watcher/synthesizer/config_test.go @@ -339,6 +339,59 @@ } } +func TestConfigSynthesizer_XAIKeys(t *testing.T) { + synth := NewConfigSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{ + XAIKey: []config.XAIKey{{ + APIKey: "xai-key-123", + Prefix: "grok", + BaseURL: "https://api.x.ai/v1", + ProxyURL: "http://proxy.local", + Websockets: true, + DisableCooling: true, + Headers: map[string]string{"X-Custom": "value"}, + Models: []config.XAIModel{{Name: "grok-4.5", Alias: "grok-latest"}}, + }}, + }, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, errSynthesize := synth.Synthesize(ctx) + if errSynthesize != nil { + t.Fatalf("Synthesize() error = %v", errSynthesize) + } + if len(auths) != 1 { + t.Fatalf("auth count = %d, want 1", len(auths)) + } + auth := auths[0] + if auth.Provider != "xai" { + t.Fatalf("provider = %q, want xai", auth.Provider) + } + if auth.Label != "xai-apikey" { + t.Fatalf("label = %q, want xai-apikey", auth.Label) + } + if auth.Attributes["websockets"] != "true" { + t.Fatalf("websockets = %q, want true", auth.Attributes["websockets"]) + } + if auth.Attributes["base_url"] != "https://api.x.ai/v1" { + t.Fatalf("base_url = %q, want https://api.x.ai/v1", auth.Attributes["base_url"]) + } + if auth.Attributes["header:X-Custom"] != "value" { + t.Fatalf("custom header = %q, want value", auth.Attributes["header:X-Custom"]) + } + if auth.Attributes["models_hash"] == "" { + t.Fatal("models_hash is empty") + } + if auth.ProxyURL != "http://proxy.local" { + t.Fatalf("proxy URL = %q, want http://proxy.local", auth.ProxyURL) + } + if disabled, ok := auth.Metadata["disable_cooling"].(bool); !ok || !disabled { + t.Fatalf("disable_cooling = %#v, want true", auth.Metadata["disable_cooling"]) + } +} + func TestConfigSynthesizer_CodexKeys_SkipsEmptyAndHeaders(t *testing.T) { synth := NewConfigSynthesizer() ctx := &SynthesisContext{ @@ -703,6 +756,9 @@ CodexKey: []config.CodexKey{ {APIKey: "codex-key"}, }, + XAIKey: []config.XAIKey{ + {APIKey: "xai-key"}, + }, OpenAICompatibility: []config.OpenAICompatibility{ {Name: "compat", BaseURL: "https://compat.api"}, }, @@ -718,8 +774,8 @@ if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(auths) != 5 { - t.Fatalf("expected 5 auths, got %d", len(auths)) + if len(auths) != 6 { + t.Fatalf("expected 6 auths, got %d", len(auths)) } providers := make(map[string]bool) @@ -727,7 +783,7 @@ providers[a.Provider] = true } - expected := []string{"gemini", "claude", "codex", "openai-compatible-compat", "vertex"} + expected := []string{"gemini", "claude", "codex", "xai", "openai-compatible-compat", "vertex"} for _, p := range expected { if !providers[p] { t.Errorf("expected provider %s not found", p) diff --git a/sdk/cliproxy/auth/api_key_model_alias_test.go b/sdk/cliproxy/auth/api_key_model_alias_test.go --- a/sdk/cliproxy/auth/api_key_model_alias_test.go +++ b/sdk/cliproxy/auth/api_key_model_alias_test.go @@ -129,6 +129,7 @@ GeminiKey: []internalconfig.GeminiKey{{APIKey: "gemini-key", Models: []internalconfig.GeminiModel{{Name: "gemini-2.5-pro", Alias: "gp"}}}}, ClaudeKey: []internalconfig.ClaudeKey{{APIKey: "claude-key", Models: []internalconfig.ClaudeModel{{Name: "claude-sonnet-4", Alias: "cs4"}}}}, CodexKey: []internalconfig.CodexKey{{APIKey: "codex-key", Models: []internalconfig.CodexModel{{Name: "o3", Alias: "o"}}}}, + XAIKey: []internalconfig.XAIKey{{APIKey: "xai-key", Models: []internalconfig.XAIModel{{Name: "grok-4.5", Alias: "grok-latest"}}}}, } mgr := NewManager(nil, nil, nil) @@ -138,6 +139,7 @@ _, _ = mgr.Register(ctx, &Auth{ID: "gemini-auth", Provider: "gemini", Attributes: map[string]string{"api_key": "gemini-key"}}) _, _ = mgr.Register(ctx, &Auth{ID: "claude-auth", Provider: "claude", Attributes: map[string]string{"api_key": "claude-key"}}) _, _ = mgr.Register(ctx, &Auth{ID: "codex-auth", Provider: "codex", Attributes: map[string]string{"api_key": "codex-key"}}) + _, _ = mgr.Register(ctx, &Auth{ID: "xai-auth", Provider: "xai", Attributes: map[string]string{"api_key": "xai-key"}}) tests := []struct { authID, input, want string @@ -145,6 +147,7 @@ {"gemini-auth", "gp", "gemini-2.5-pro"}, {"claude-auth", "cs4", "claude-sonnet-4"}, {"codex-auth", "o", "o3"}, + {"xai-auth", "grok-latest", "grok-4.5"}, } for _, tt := range tests { diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1297,6 +1297,10 @@ if entry := resolveCodexAPIKeyConfig(cfg, auth); entry != nil { models = asModelAliasEntries(entry.Models) } + case "xai": + if entry := resolveXAIAPIKeyConfig(cfg, auth); entry != nil { + models = asModelAliasEntries(entry.Models) + } case "vertex": if entry := resolveVertexAPIKeyConfig(cfg, auth); entry != nil { models = asModelAliasEntries(entry.Models) @@ -2011,6 +2015,10 @@ } case "codex": if entry := resolveCodexAPIKeyConfig(cfg, auth); entry != nil { + compileAPIKeyModelAliasForModels(byAlias, entry.Models) + } + case "xai": + if entry := resolveXAIAPIKeyConfig(cfg, auth); entry != nil { compileAPIKeyModelAliasForModels(byAlias, entry.Models) } case "vertex": @@ -3200,6 +3208,8 @@ upstreamModel = resolveUpstreamModelForClaudeAPIKey(cfg, auth, requestedModel) case "codex": upstreamModel = resolveUpstreamModelForCodexAPIKey(cfg, auth, requestedModel) + case "xai": + upstreamModel = resolveUpstreamModelForXAIAPIKey(cfg, auth, requestedModel) case "vertex": upstreamModel = resolveUpstreamModelForVertexAPIKey(cfg, auth, requestedModel) default: @@ -3286,6 +3296,13 @@ return resolveAPIKeyConfig(cfg.CodexKey, auth) } +func resolveXAIAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.XAIKey { + if cfg == nil { + return nil + } + return resolveAPIKeyConfig(cfg.XAIKey, auth) +} + func resolveVertexAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.VertexCompatKey { if cfg == nil { return nil @@ -3319,6 +3336,14 @@ func resolveUpstreamModelForCodexAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string { entry := resolveCodexAPIKeyConfig(cfg, auth) + if entry == nil { + return "" + } + return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models)) +} + +func resolveUpstreamModelForXAIAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string { + entry := resolveXAIAPIKeyConfig(cfg, auth) if entry == nil { return "" } diff --git a/sdk/cliproxy/auth/conductor_force_mapping_test.go b/sdk/cliproxy/auth/conductor_force_mapping_test.go --- a/sdk/cliproxy/auth/conductor_force_mapping_test.go +++ b/sdk/cliproxy/auth/conductor_force_mapping_test.go @@ -581,6 +581,15 @@ ForceMapping: true, }}, }} + case "xai": + cfg.XAIKey = []internalconfig.XAIKey{{ + APIKey: apiKey, + Models: []internalconfig.XAIModel{{ + Name: upstreamModel, + Alias: aliasModel, + ForceMapping: true, + }}, + }} case "vertex": cfg.VertexCompatAPIKey = []internalconfig.VertexCompatKey{{ APIKey: apiKey, @@ -635,6 +644,7 @@ }{ {provider: "claude", upstreamModel: "glm-5.2", aliasModel: "claude-sonnet-latest"}, {provider: "codex", upstreamModel: "gpt-5.5", aliasModel: "claude-sonnet-4-5"}, + {provider: "xai", upstreamModel: "grok-4.5", aliasModel: "grok-latest"}, {provider: "vertex", upstreamModel: "gemini-3-pro", aliasModel: "claude-opus-4-5"}, {provider: "openai-compatibility", upstreamModel: "deepseek-v3.1", aliasModel: "claude-opus-4.66"}, } @@ -665,6 +675,7 @@ }{ {provider: "claude", upstreamModel: "glm-5.2", aliasModel: "claude-sonnet-latest"}, {provider: "codex", upstreamModel: "gpt-5.5", aliasModel: "claude-sonnet-4-5"}, + {provider: "xai", upstreamModel: "grok-4.5", aliasModel: "grok-latest"}, {provider: "vertex", upstreamModel: "gemini-3-pro", aliasModel: "claude-opus-4-5"}, {provider: "openai-compatibility", upstreamModel: "deepseek-v3.1", aliasModel: "claude-opus-4.66"}, } diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -361,6 +361,8 @@ apiPrefix = "interactions-api-key" case strings.EqualFold(provider, "codex"): apiPrefix = "codex-api-key" + case strings.EqualFold(provider, "xai"): + apiPrefix = "xai-api-key" case strings.EqualFold(provider, "claude"): apiPrefix = "claude-api-key" } diff --git a/internal/api/handlers/management/api_tools.go b/internal/api/handlers/management/api_tools.go --- a/internal/api/handlers/management/api_tools.go +++ b/internal/api/handlers/management/api_tools.go @@ -583,6 +583,10 @@ if entry := resolveAPIKeyConfig(cfg.CodexKey, auth); entry != nil { return strings.TrimSpace(entry.ProxyURL) } + case "xai": + if entry := resolveAPIKeyConfig(cfg.XAIKey, auth); entry != nil { + return strings.TrimSpace(entry.ProxyURL) + } } return "" } diff --git a/internal/api/handlers/management/api_tools_test.go b/internal/api/handlers/management/api_tools_test.go --- a/internal/api/handlers/management/api_tools_test.go +++ b/internal/api/handlers/management/api_tools_test.go @@ -76,6 +76,10 @@ APIKey: "codex-key", ProxyURL: "http://codex-proxy.example.com:8080", }}, + XAIKey: []config.XAIKey{{ + APIKey: "xai-key", + ProxyURL: "http://xai-proxy.example.com:8080", + }}, OpenAICompatibility: []config.OpenAICompatibility{{ Name: "bohe", BaseURL: "https://bohe.example.com", @@ -115,6 +119,14 @@ Attributes: map[string]string{"api_key": "codex-key"}, }, wantProxy: "http://codex-proxy.example.com:8080", + }, + { + name: "xai", + auth: &coreauth.Auth{ + Provider: "xai", + Attributes: map[string]string{"api_key": "xai-key"}, + }, + wantProxy: "http://xai-proxy.example.com:8080", }, { name: "openai-compatibility", diff --git a/internal/api/handlers/management/config_apikey_disable.go b/internal/api/handlers/management/config_apikey_disable.go --- a/internal/api/handlers/management/config_apikey_disable.go +++ b/internal/api/handlers/management/config_apikey_disable.go @@ -73,6 +73,14 @@ return true, nil } } + for i := range cfg.XAIKey { + entry := &cfg.XAIKey[i] + id, _ := idGen.Next("xai:apikey", entry.APIKey, entry.BaseURL) + if id == authID { + entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) + return true, nil + } + } for i := range cfg.VertexCompatAPIKey { entry := &cfg.VertexCompatAPIKey[i] id, _ := idGen.Next("vertex:apikey", entry.APIKey, entry.BaseURL, entry.ProxyURL) diff --git a/internal/api/handlers/management/config_apikey_disable_test.go b/internal/api/handlers/management/config_apikey_disable_test.go --- a/internal/api/handlers/management/config_apikey_disable_test.go +++ b/internal/api/handlers/management/config_apikey_disable_test.go @@ -19,6 +19,34 @@ } } +func TestToggleConfigAPIKeyExcludedAll_XAI(t *testing.T) { + cfg := &config.Config{ + XAIKey: []config.XAIKey{{ + APIKey: "xai-test", + BaseURL: "https://api.x.ai/v1", + }}, + } + idGen := synthesizer.NewStableIDGenerator() + authID, _ := idGen.Next("xai:apikey", "xai-test", "https://api.x.ai/v1") + auth := &coreauth.Auth{ + ID: authID, + Provider: "xai", + Attributes: map[string]string{ + "api_key": "xai-test", + "base_url": "https://api.x.ai/v1", + "source": "config:xai[abc]", + }, + } + + handled, errToggle := toggleConfigAPIKeyExcludedAll(cfg, auth, true) + if errToggle != nil || !handled { + t.Fatalf("toggle disable: handled=%v err=%v", handled, errToggle) + } + if len(cfg.XAIKey[0].ExcludedModels) != 1 || cfg.XAIKey[0].ExcludedModels[0] != "*" { + t.Fatalf("excluded-models = %#v, want [*]", cfg.XAIKey[0].ExcludedModels) + } +} + func TestToggleConfigAPIKeyExcludedAll_Codex(t *testing.T) { cfg := &config.Config{ CodexKey: []config.CodexKey{{ diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go --- a/internal/api/handlers/management/config_auth_index.go +++ b/internal/api/handlers/management/config_auth_index.go @@ -23,6 +23,11 @@ AuthIndex string `json:"auth-index,omitempty"` } +type xaiKeyWithAuthIndex struct { + config.XAIKey + AuthIndex string `json:"auth-index,omitempty"` +} + type vertexCompatKeyWithAuthIndex struct { config.VertexCompatKey AuthIndex string `json:"auth-index,omitempty"` @@ -188,6 +193,35 @@ } out[i] = codexKeyWithAuthIndex{ CodexKey: entry, + AuthIndex: authIndex, + } + } + return out +} + +func (h *Handler) xaiKeysWithAuthIndex() []xaiKeyWithAuthIndex { + if h == nil { + return nil + } + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { + return nil + } + + idGen := synthesizer.NewStableIDGenerator() + out := make([]xaiKeyWithAuthIndex, len(h.cfg.XAIKey)) + for i := range h.cfg.XAIKey { + entry := h.cfg.XAIKey[i] + authIndex := "" + if key := strings.TrimSpace(entry.APIKey); key != "" { + id, _ := idGen.Next("xai:apikey", key, entry.BaseURL) + authIndex = liveIndexByID[id] + } + out[i] = xaiKeyWithAuthIndex{ + XAIKey: entry, AuthIndex: authIndex, } } diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -1250,6 +1250,184 @@ c.JSON(400, gin.H{"error": "missing api-key or index"}) } +// xai-api-key: []XAIKey +func (h *Handler) GetXAIKeys(c *gin.Context) { + c.JSON(200, gin.H{"xai-api-key": h.xaiKeysWithAuthIndex()}) +} + +func (h *Handler) PutXAIKeys(c *gin.Context) { + data, errRead := c.GetRawData() + if errRead != nil { + c.JSON(400, gin.H{"error": "failed to read body"}) + return + } + var arr []config.XAIKey + if errUnmarshal := json.Unmarshal(data, &arr); errUnmarshal != nil { + var obj struct { + Items []config.XAIKey `json:"items"` + } + if errObject := json.Unmarshal(data, &obj); errObject != nil || len(obj.Items) == 0 { + c.JSON(400, gin.H{"error": "invalid body"}) + return + } + arr = obj.Items + } + filtered := make([]config.XAIKey, 0, len(arr)) + for i := range arr { + entry := arr[i] + normalizeCodexKey(&entry) + if entry.BaseURL == "" { + continue + } + filtered = append(filtered, entry) + } + h.mu.Lock() + defer h.mu.Unlock() + h.cfg.XAIKey = filtered + h.cfg.SanitizeXAIKeys() + h.persistLocked(c) +} + +func (h *Handler) PatchXAIKey(c *gin.Context) { + type xaiKeyPatch struct { + APIKey *string `json:"api-key"` + Priority *int `json:"priority"` + Prefix *string `json:"prefix"` + BaseURL *string `json:"base-url"` + Websockets *bool `json:"websockets"` + ProxyURL *string `json:"proxy-url"` + Models *[]config.XAIModel `json:"models"` + Headers *map[string]string `json:"headers"` + ExcludedModels *[]string `json:"excluded-models"` + DisableCooling *bool `json:"disable-cooling"` + } + var body struct { + Index *int `json:"index"` + Match *string `json:"match"` + Value *xaiKeyPatch `json:"value"` + } + if errBind := c.ShouldBindJSON(&body); errBind != nil || body.Value == nil { + c.JSON(400, gin.H{"error": "invalid body"}) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + targetIndex := -1 + if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.XAIKey) { + targetIndex = *body.Index + } + if targetIndex == -1 && body.Match != nil { + match := strings.TrimSpace(*body.Match) + for i := range h.cfg.XAIKey { + if h.cfg.XAIKey[i].APIKey == match { + targetIndex = i + break + } + } + } + if targetIndex == -1 { + c.JSON(404, gin.H{"error": "item not found"}) + return + } + + entry := h.cfg.XAIKey[targetIndex] + if body.Value.APIKey != nil { + entry.APIKey = strings.TrimSpace(*body.Value.APIKey) + } + if body.Value.Priority != nil { + entry.Priority = *body.Value.Priority + } + if body.Value.Prefix != nil { + entry.Prefix = strings.TrimSpace(*body.Value.Prefix) + } + if body.Value.BaseURL != nil { + trimmed := strings.TrimSpace(*body.Value.BaseURL) + if trimmed == "" { + h.cfg.XAIKey = append(h.cfg.XAIKey[:targetIndex], h.cfg.XAIKey[targetIndex+1:]...) + h.cfg.SanitizeXAIKeys() + h.persistLocked(c) + return + } + entry.BaseURL = trimmed + } + if body.Value.Websockets != nil { + entry.Websockets = *body.Value.Websockets + } + if body.Value.ProxyURL != nil { + entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL) + } + if body.Value.Models != nil { + entry.Models = append([]config.XAIModel(nil), (*body.Value.Models)...) + } + if body.Value.Headers != nil { + entry.Headers = config.NormalizeHeaders(*body.Value.Headers) + } + if body.Value.ExcludedModels != nil { + entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels) + } + if body.Value.DisableCooling != nil { + entry.DisableCooling = *body.Value.DisableCooling + } + normalizeCodexKey(&entry) + h.cfg.XAIKey[targetIndex] = entry + h.cfg.SanitizeXAIKeys() + h.persistLocked(c) +} + +func (h *Handler) DeleteXAIKey(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() + if val := strings.TrimSpace(c.Query("api-key")); val != "" { + if baseRaw, okBase := c.GetQuery("base-url"); okBase { + base := strings.TrimSpace(baseRaw) + out := make([]config.XAIKey, 0, len(h.cfg.XAIKey)) + for _, entry := range h.cfg.XAIKey { + if strings.TrimSpace(entry.APIKey) == val && strings.TrimSpace(entry.BaseURL) == base { + continue + } + out = append(out, entry) + } + h.cfg.XAIKey = out + h.cfg.SanitizeXAIKeys() + h.persistLocked(c) + return + } + + matchIndex := -1 + matchCount := 0 + for i := range h.cfg.XAIKey { + if strings.TrimSpace(h.cfg.XAIKey[i].APIKey) == val { + matchCount++ + if matchIndex == -1 { + matchIndex = i + } + } + } + if matchCount > 1 { + c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"}) + return + } + if matchIndex != -1 { + h.cfg.XAIKey = append(h.cfg.XAIKey[:matchIndex], h.cfg.XAIKey[matchIndex+1:]...) + } + h.cfg.SanitizeXAIKeys() + h.persistLocked(c) + return + } + if idxStr := c.Query("index"); idxStr != "" { + var idx int + _, errScan := fmt.Sscanf(idxStr, "%d", &idx) + if errScan == nil && idx >= 0 && idx < len(h.cfg.XAIKey) { + h.cfg.XAIKey = append(h.cfg.XAIKey[:idx], h.cfg.XAIKey[idx+1:]...) + h.cfg.SanitizeXAIKeys() + h.persistLocked(c) + return + } + } + c.JSON(400, gin.H{"error": "missing api-key or index"}) +} + func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) { if entry == nil { return diff --git a/internal/api/handlers/management/config_lists_delete_keys_test.go b/internal/api/handlers/management/config_lists_delete_keys_test.go --- a/internal/api/handlers/management/config_lists_delete_keys_test.go +++ b/internal/api/handlers/management/config_lists_delete_keys_test.go @@ -139,6 +139,33 @@ } } +func TestDeleteXAIKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) { + t.Parallel() + + h := &Handler{ + cfg: &config.Config{ + XAIKey: []config.XAIKey{ + {APIKey: "shared-key", BaseURL: "https://a.example.com"}, + {APIKey: "shared-key", BaseURL: "https://b.example.com"}, + }, + }, + configFilePath: writeTestConfigFile(t), + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/xai-api-key?api-key=shared-key", nil) + + h.DeleteXAIKey(c) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if got := len(h.cfg.XAIKey); got != 2 { + t.Fatalf("xAI keys len = %d, want 2", got) + } +} + func TestDeleteCodexKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) { t.Parallel() diff --git a/internal/api/handlers/management/config_xai_key_test.go b/internal/api/handlers/management/config_xai_key_test.go new file mode 100644 --- /dev/null +++ b/internal/api/handlers/management/config_xai_key_test.go @@ -0,0 +1,52 @@ +package management + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestPatchXAIKeyUpdatesExecutionFields(t *testing.T) { + h := &Handler{ + cfg: &config.Config{XAIKey: []config.XAIKey{{ + APIKey: "xai-key", + Priority: 1, + BaseURL: "https://api.x.ai/v1", + Websockets: true, + DisableCooling: false, + }}}, + configFilePath: writeTestConfigFile(t), + } + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPatch, "/v0/management/xai-api-key", strings.NewReader(`{ + "index": 0, + "value": { + "priority": 7, + "websockets": false, + "disable-cooling": true + } + }`)) + ctx.Request.Header.Set("Content-Type", "application/json") + + h.PatchXAIKey(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + entry := h.cfg.XAIKey[0] + if entry.Priority != 7 { + t.Fatalf("priority = %d, want 7", entry.Priority) + } + if entry.Websockets { + t.Fatal("websockets = true, want false") + } + if !entry.DisableCooling { + t.Fatal("disable-cooling = false, want true") + } +} -- tangled.sh