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.
This commit is contained in:
Luis Pater
2026-07-14 04:40:12 +08:00
parent a5577cc67d
commit 423f3d5ff7
35 changed files with 820 additions and 51 deletions
+22 -1
View File
@@ -265,6 +265,27 @@ nonstream-keepalive-interval: 0
# - "*-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 @@ nonstream-keepalive-interval: 0
# 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.
@@ -583,6 +583,10 @@ func proxyURLFromAPIKeyConfig(cfg *config.Config, auth *coreauth.Auth) string {
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 ""
}
@@ -76,6 +76,10 @@ func TestAPICallTransportAPIKeyAuthFallsBackToConfigProxyURL(t *testing.T) {
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",
@@ -116,6 +120,14 @@ func TestAPICallTransportAPIKeyAuthFallsBackToConfigProxyURL(t *testing.T) {
},
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",
auth: &coreauth.Auth{
@@ -73,6 +73,14 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa
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)
@@ -19,6 +19,34 @@ func TestSetConfigAPIKeyExcludedAll(t *testing.T) {
}
}
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{{
@@ -23,6 +23,11 @@ type codexKeyWithAuthIndex struct {
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"`
@@ -194,6 +199,35 @@ func (h *Handler) codexKeysWithAuthIndex() []codexKeyWithAuthIndex {
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,
}
}
return out
}
func (h *Handler) vertexCompatKeysWithAuthIndex() []vertexCompatKeyWithAuthIndex {
if h == nil {
return nil
@@ -1250,6 +1250,184 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) {
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
@@ -139,6 +139,33 @@ func TestDeleteVertexCompatKey_DeletesOnlyMatchingBaseURL(t *testing.T) {
}
}
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()
@@ -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")
}
}
+9 -2
View File
@@ -887,6 +887,11 @@ func (s *Server) registerManagementRoutes() {
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 @@ func (s *Server) UpdateClients(cfg *config.Config) {
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 @@ func (s *Server) UpdateClients(cfg *config.Config) {
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,
)
+34 -6
View File
@@ -125,6 +125,9 @@ type Config struct {
// 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 @@ type Config struct {
// 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) GetAlias() string { return m.Alias }
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 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// 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 @@ func (cfg *Config) SanitizeOpenAICompatibility() {
// 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 @@ func (cfg *Config) SanitizeCodexKeys() {
}
out = append(out, e)
}
cfg.CodexKey = out
return out
}
// SanitizeClaudeKeys normalizes headers for Claude credentials.
+9 -1
View File
@@ -13,6 +13,11 @@ func TestModelDisplayNameConfigDecoding(t *testing.T) {
- 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 @@ openai-compatibility:
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
@@ -61,6 +66,9 @@ openai-compatibility:
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)
}
+1
View File
@@ -82,6 +82,7 @@ func ParseConfigBytes(data []byte) (*Config, error) {
cfg.SanitizeInteractionsKeys()
cfg.SanitizeVertexCompatKeys()
cfg.SanitizeCodexKeys()
cfg.SanitizeXAIKeys()
cfg.SanitizeCodexHeaderDefaults()
cfg.SanitizeClaudeHeaderDefaults()
cfg.SanitizeClaudeKeys()
+67
View File
@@ -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)
}
}
@@ -20,6 +20,19 @@ import (
"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)
+5
View File
@@ -306,6 +306,11 @@ func (c *Client) GetCodexKeys() ([]map[string]any, error) {
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")
+5
View File
@@ -20,6 +20,7 @@ type keysTabModel struct {
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 @@ type keysDataMsg struct {
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 @@ func (m keysTabModel) fetchKeys() tea.Msg {
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 @@ func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) {
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 @@ func (m keysTabModel) renderContent() string {
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 {
+11 -6
View File
@@ -56,8 +56,8 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
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 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
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 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
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 @@ func (w *Watcher) loadFileClients(cfg *config.Config) int {
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 @@ func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int, int) {
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 @@ func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int, int) {
openAICompatCount += len(compatConfig.APIKeyEntries)
}
}
return geminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
return geminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, xaiAPIKeyCount, openAICompatCount
}
func (w *Watcher) persistConfigAsync() {
+44
View File
@@ -273,6 +273,50 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
}
}
// 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...)
}
+58 -1
View File
@@ -153,6 +153,61 @@ func TestBuildConfigChangeDetails_ModelPrefixes(t *testing.T) {
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 @@ func TestBuildConfigChangeDetails_CountBranches(t *testing.T) {
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 @@ func TestBuildConfigChangeDetails_CountBranches(t *testing.T) {
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")
}
+1 -1
View File
@@ -66,7 +66,7 @@ func ComputeCodexModelsHash(models []config.CodexModel) string {
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)
+8
View File
@@ -171,6 +171,14 @@ func TestComputeModelHashesIncludeDisplayName(t *testing.T) {
}
}
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"})
+5 -1
View File
@@ -83,7 +83,11 @@ func SummarizeCodexModels(models []config.CodexModel) CodexModelsSummary {
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{
+32 -21
View File
@@ -13,7 +13,7 @@ import (
)
// 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 @@ func (s *ConfigSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth,
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 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea
// 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
}
+59 -3
View File
@@ -339,6 +339,59 @@ func TestConfigSynthesizer_CodexKeys(t *testing.T) {
}
}
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 @@ func TestConfigSynthesizer_AllProviders(t *testing.T) {
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 @@ func TestConfigSynthesizer_AllProviders(t *testing.T) {
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 @@ func TestConfigSynthesizer_AllProviders(t *testing.T) {
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)
+5 -4
View File
@@ -69,15 +69,16 @@ func TestBuildAPIKeyClientsCounts(t *testing.T) {
{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)
}
}
@@ -129,6 +129,7 @@ func TestAPIKeyModelAlias_MultipleProviders(t *testing.T) {
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 @@ func TestAPIKeyModelAlias_MultipleProviders(t *testing.T) {
_, _ = 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 @@ func TestAPIKeyModelAlias_MultipleProviders(t *testing.T) {
{"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 {
+25
View File
@@ -1297,6 +1297,10 @@ func (m *Manager) resolveAPIKeyModelAliasWithResult(auth *Auth, requestedModel s
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)
@@ -2013,6 +2017,10 @@ func (m *Manager) rebuildAPIKeyModelAliasLocked(cfg *internalconfig.Config) {
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":
if entry := resolveVertexAPIKeyConfig(cfg, auth); entry != nil {
compileAPIKeyModelAliasForModels(byAlias, entry.Models)
@@ -3200,6 +3208,8 @@ func (m *Manager) applyAPIKeyModelAlias(auth *Auth, requestedModel string) strin
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 @@ func resolveCodexAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalc
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
@@ -3325,6 +3342,14 @@ func resolveUpstreamModelForCodexAPIKey(cfg *internalconfig.Config, auth *Auth,
return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))
}
func resolveUpstreamModelForXAIAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {
entry := resolveXAIAPIKeyConfig(cfg, auth)
if entry == nil {
return ""
}
return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models))
}
func resolveUpstreamModelForVertexAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string {
entry := resolveVertexAPIKeyConfig(cfg, auth)
if entry == nil {
@@ -581,6 +581,15 @@ func setupAPIKeyForceMappingManager(t *testing.T, provider, upstreamModel, alias
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 @@ func TestManagerExecute_APIKeyAliasForceMappingRewritesResponse(t *testing.T) {
}{
{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 @@ func TestManagerExecuteStream_APIKeyAliasForceMappingRewritesResponse(t *testing
}{
{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"},
}
+2
View File
@@ -361,6 +361,8 @@ func (a *Auth) indexSeed() string {
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"
}
@@ -48,6 +48,15 @@ func TestBuildConfigModelsDisplayName(t *testing.T) {
}}})[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 {
+2 -1
View File
@@ -29,7 +29,7 @@ func NewAPIKeyClientProvider() APIKeyClientProvider {
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 @@ func (p *apiKeyClientProvider) Load(ctx context.Context, cfg *config.Config) (*A
VertexCompatKeyCount: vertexCompatCount,
ClaudeKeyCount: claudeCount,
CodexKeyCount: codexCount,
XAIKeyCount: xaiCount,
OpenAICompatCount: openAICompat,
}, nil
}
+32 -3
View File
@@ -2027,6 +2027,14 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut
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) resolveConfigVertexCompatKey(auth *coreauth.Auth) *config.Vert
}
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 @@ func (s *Service) resolveConfigCodexKey(auth *coreauth.Auth) *config.CodexKey {
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) {
@@ -2604,6 +2626,13 @@ func buildClaudeConfigModels(entry *config.ClaudeKey) []*ModelInfo {
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 {
if entry == nil {
return nil
+3
View File
@@ -65,6 +65,9 @@ type APIKeyClientResult struct {
// 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
}
+2
View File
@@ -21,6 +21,8 @@ type PayloadModelRule = internalconfig.PayloadModelRule
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