mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
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:
+22
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ func ParseConfigBytes(data []byte) (*Config, error) {
|
||||
cfg.SanitizeInteractionsKeys()
|
||||
cfg.SanitizeVertexCompatKeys()
|
||||
cfg.SanitizeCodexKeys()
|
||||
cfg.SanitizeXAIKeys()
|
||||
cfg.SanitizeCodexHeaderDefaults()
|
||||
cfg.SanitizeClaudeHeaderDefaults()
|
||||
cfg.SanitizeClaudeKeys()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user