feat(config): add support for model prefixes and prefix normalization

Refactor model management to include an optional `prefix` field for model credentials, enabling better namespace handling. Update affected configuration files, APIs, and handlers to support prefix normalization and routing. Remove unused OpenAI compatibility provider logic to simplify processing.
This commit is contained in:
Luis Pater
2025-12-17 01:07:26 +08:00
parent fcecbc7d46
commit 52b6306388
10 changed files with 210 additions and 87 deletions

View File

@@ -230,13 +230,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
envManagementSecret := envAdminPasswordSet && envAdminPassword != ""
// Create server instance
providerNames := make([]string, 0, len(cfg.OpenAICompatibility))
for _, p := range cfg.OpenAICompatibility {
providerNames = append(providerNames, p.Name)
}
s := &Server{
engine: engine,
handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager, providerNames),
handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),
cfg: cfg,
accessManager: accessManager,
requestLogger: requestLogger,
@@ -919,12 +915,6 @@ func (s *Server) UpdateClients(cfg *config.Config) {
// Save YAML snapshot for next comparison
s.oldConfigYaml, _ = yaml.Marshal(cfg)
providerNames := make([]string, 0, len(cfg.OpenAICompatibility))
for _, p := range cfg.OpenAICompatibility {
providerNames = append(providerNames, p.Name)
}
s.handlers.OpenAICompatProviders = providerNames
s.handlers.UpdateClients(&cfg.SDKConfig)
if !cfg.RemoteManagement.DisableControlPanel {

View File

@@ -187,6 +187,9 @@ type ClaudeKey struct {
// APIKey is the authentication key for accessing Claude API services.
APIKey string `yaml:"api-key" json:"api-key"`
// Prefix optionally namespaces models for this credential (e.g., "teamA/claude-sonnet-4").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL is the base URL for the Claude API endpoint.
// If empty, the default Claude API URL will be used.
BaseURL string `yaml:"base-url" json:"base-url"`
@@ -219,6 +222,9 @@ type CodexKey struct {
// APIKey is the authentication key for accessing Codex API services.
APIKey string `yaml:"api-key" json:"api-key"`
// Prefix optionally namespaces models for this credential (e.g., "teamA/gpt-5-codex").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL is the base URL for the Codex API endpoint.
// If empty, the default Codex API URL will be used.
BaseURL string `yaml:"base-url" json:"base-url"`
@@ -239,6 +245,9 @@ type GeminiKey struct {
// APIKey is the authentication key for accessing Gemini API services.
APIKey string `yaml:"api-key" json:"api-key"`
// Prefix optionally namespaces models for this credential (e.g., "teamA/gemini-3-pro-preview").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL optionally overrides the Gemini API endpoint.
BaseURL string `yaml:"base-url,omitempty" json:"base-url,omitempty"`
@@ -258,6 +267,9 @@ type OpenAICompatibility struct {
// Name is the identifier for this OpenAI compatibility configuration.
Name string `yaml:"name" json:"name"`
// Prefix optionally namespaces model aliases for this provider (e.g., "teamA/kimi-k2").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL is the base URL for the external OpenAI-compatible API endpoint.
BaseURL string `yaml:"base-url" json:"base-url"`
@@ -422,6 +434,7 @@ func (cfg *Config) SanitizeOpenAICompatibility() {
for i := range cfg.OpenAICompatibility {
e := cfg.OpenAICompatibility[i]
e.Name = strings.TrimSpace(e.Name)
e.Prefix = normalizeModelPrefix(e.Prefix)
e.BaseURL = strings.TrimSpace(e.BaseURL)
e.Headers = NormalizeHeaders(e.Headers)
if e.BaseURL == "" {
@@ -442,6 +455,7 @@ func (cfg *Config) SanitizeCodexKeys() {
out := make([]CodexKey, 0, len(cfg.CodexKey))
for i := range cfg.CodexKey {
e := cfg.CodexKey[i]
e.Prefix = normalizeModelPrefix(e.Prefix)
e.BaseURL = strings.TrimSpace(e.BaseURL)
e.Headers = NormalizeHeaders(e.Headers)
e.ExcludedModels = NormalizeExcludedModels(e.ExcludedModels)
@@ -460,6 +474,7 @@ func (cfg *Config) SanitizeClaudeKeys() {
}
for i := range cfg.ClaudeKey {
entry := &cfg.ClaudeKey[i]
entry.Prefix = normalizeModelPrefix(entry.Prefix)
entry.Headers = NormalizeHeaders(entry.Headers)
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
}
@@ -479,6 +494,7 @@ func (cfg *Config) SanitizeGeminiKeys() {
if entry.APIKey == "" {
continue
}
entry.Prefix = normalizeModelPrefix(entry.Prefix)
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
entry.Headers = NormalizeHeaders(entry.Headers)
@@ -492,6 +508,18 @@ func (cfg *Config) SanitizeGeminiKeys() {
cfg.GeminiKey = out
}
func normalizeModelPrefix(prefix string) string {
trimmed := strings.TrimSpace(prefix)
trimmed = strings.Trim(trimmed, "/")
if trimmed == "" {
return ""
}
if strings.Contains(trimmed, "/") {
return ""
}
return trimmed
}
func syncInlineAccessProvider(cfg *Config) {
if cfg == nil {
return

View File

@@ -13,6 +13,9 @@ type VertexCompatKey struct {
// Maps to the x-goog-api-key header.
APIKey string `yaml:"api-key" json:"api-key"`
// Prefix optionally namespaces model aliases for this credential (e.g., "teamA/vertex-pro").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL is the base URL for the Vertex-compatible API endpoint.
// The executor will append "/v1/publishers/google/models/{model}:action" to this.
// Example: "https://zenmux.ai/api" becomes "https://zenmux.ai/api/v1/publishers/google/models/..."
@@ -53,6 +56,7 @@ func (cfg *Config) SanitizeVertexCompatKeys() {
if entry.APIKey == "" {
continue
}
entry.Prefix = normalizeModelPrefix(entry.Prefix)
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
if entry.BaseURL == "" {
// BaseURL is required for Vertex API key entries

View File

@@ -183,7 +183,7 @@ func (w *Watcher) Start(ctx context.Context) error {
go w.processEvents(ctx)
// Perform an initial full reload based on current config and auth dir
w.reloadClients(true, nil)
w.reloadClients(true, nil, false)
return nil
}
@@ -276,7 +276,7 @@ func (w *Watcher) DispatchRuntimeAuthUpdate(update AuthUpdate) bool {
return true
}
func (w *Watcher) refreshAuthState() {
func (w *Watcher) refreshAuthState(force bool) {
auths := w.SnapshotCoreAuths()
w.clientsMutex.Lock()
if len(w.runtimeAuths) > 0 {
@@ -286,12 +286,12 @@ func (w *Watcher) refreshAuthState() {
}
}
}
updates := w.prepareAuthUpdatesLocked(auths)
updates := w.prepareAuthUpdatesLocked(auths, force)
w.clientsMutex.Unlock()
w.dispatchAuthUpdates(updates)
}
func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth) []AuthUpdate {
func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth, force bool) []AuthUpdate {
newState := make(map[string]*coreauth.Auth, len(auths))
for _, auth := range auths {
if auth == nil || auth.ID == "" {
@@ -318,7 +318,7 @@ func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth) []AuthUpdate
for id, auth := range newState {
if existing, ok := w.currentAuths[id]; !ok {
updates = append(updates, AuthUpdate{Action: AuthUpdateActionAdd, ID: id, Auth: auth.Clone()})
} else if !authEqual(existing, auth) {
} else if force || !authEqual(existing, auth) {
updates = append(updates, AuthUpdate{Action: AuthUpdateActionModify, ID: id, Auth: auth.Clone()})
}
}
@@ -949,15 +949,16 @@ func (w *Watcher) reloadConfig() bool {
}
authDirChanged := oldConfig == nil || oldConfig.AuthDir != newConfig.AuthDir
forceAuthRefresh := oldConfig != nil && oldConfig.ForceModelPrefix != newConfig.ForceModelPrefix
log.Infof("config successfully reloaded, triggering client reload")
// Reload clients with new config
w.reloadClients(authDirChanged, affectedOAuthProviders)
w.reloadClients(authDirChanged, affectedOAuthProviders, forceAuthRefresh)
return true
}
// reloadClients performs a full scan and reload of all clients.
func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string) {
func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string, forceAuthRefresh bool) {
log.Debugf("starting full client load process")
w.clientsMutex.RLock()
@@ -1048,7 +1049,7 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
w.reloadCallback(cfg)
}
w.refreshAuthState()
w.refreshAuthState(forceAuthRefresh)
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)",
totalNewClients,
@@ -1099,7 +1100,7 @@ func (w *Watcher) addOrUpdateClient(path string) {
w.clientsMutex.Unlock() // Unlock before the callback
w.refreshAuthState()
w.refreshAuthState(false)
if w.reloadCallback != nil {
log.Debugf("triggering server update callback after add/update")
@@ -1118,7 +1119,7 @@ func (w *Watcher) removeClient(path string) {
w.clientsMutex.Unlock() // Release the lock before the callback
w.refreshAuthState()
w.refreshAuthState(false)
if w.reloadCallback != nil {
log.Debugf("triggering server update callback after removal")
@@ -1147,6 +1148,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if key == "" {
continue
}
prefix := strings.TrimSpace(entry.Prefix)
base := strings.TrimSpace(entry.BaseURL)
proxyURL := strings.TrimSpace(entry.ProxyURL)
id, token := idGen.next("gemini:apikey", key, base)
@@ -1162,6 +1164,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id,
Provider: "gemini",
Label: "gemini-apikey",
Prefix: prefix,
Status: coreauth.StatusActive,
ProxyURL: proxyURL,
Attributes: attrs,
@@ -1179,6 +1182,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if key == "" {
continue
}
prefix := strings.TrimSpace(ck.Prefix)
base := strings.TrimSpace(ck.BaseURL)
id, token := idGen.next("claude:apikey", key, base)
attrs := map[string]string{
@@ -1197,6 +1201,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id,
Provider: "claude",
Label: "claude-apikey",
Prefix: prefix,
Status: coreauth.StatusActive,
ProxyURL: proxyURL,
Attributes: attrs,
@@ -1213,6 +1218,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if key == "" {
continue
}
prefix := strings.TrimSpace(ck.Prefix)
id, token := idGen.next("codex:apikey", key, ck.BaseURL)
attrs := map[string]string{
"source": fmt.Sprintf("config:codex[%s]", token),
@@ -1227,6 +1233,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id,
Provider: "codex",
Label: "codex-apikey",
Prefix: prefix,
Status: coreauth.StatusActive,
ProxyURL: proxyURL,
Attributes: attrs,
@@ -1238,6 +1245,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
}
for i := range cfg.OpenAICompatibility {
compat := &cfg.OpenAICompatibility[i]
prefix := strings.TrimSpace(compat.Prefix)
providerName := strings.ToLower(strings.TrimSpace(compat.Name))
if providerName == "" {
providerName = "openai-compatibility"
@@ -1269,6 +1277,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id,
Provider: providerName,
Label: compat.Name,
Prefix: prefix,
Status: coreauth.StatusActive,
ProxyURL: proxyURL,
Attributes: attrs,
@@ -1295,6 +1304,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id,
Provider: providerName,
Label: compat.Name,
Prefix: prefix,
Status: coreauth.StatusActive,
Attributes: attrs,
CreatedAt: now,
@@ -1312,6 +1322,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
base := strings.TrimSpace(compat.BaseURL)
key := strings.TrimSpace(compat.APIKey)
prefix := strings.TrimSpace(compat.Prefix)
proxyURL := strings.TrimSpace(compat.ProxyURL)
idKind := fmt.Sprintf("vertex:apikey:%s", base)
id, token := idGen.next(idKind, key, base, proxyURL)
@@ -1331,6 +1342,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id,
Provider: providerName,
Label: "vertex-apikey",
Prefix: prefix,
Status: coreauth.StatusActive,
ProxyURL: proxyURL,
Attributes: attrs,
@@ -1383,10 +1395,20 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
proxyURL = p
}
prefix := ""
if rawPrefix, ok := metadata["prefix"].(string); ok {
trimmed := strings.TrimSpace(rawPrefix)
trimmed = strings.Trim(trimmed, "/")
if trimmed != "" && !strings.Contains(trimmed, "/") {
prefix = trimmed
}
}
a := &coreauth.Auth{
ID: id,
Provider: provider,
Label: label,
Prefix: prefix,
Status: coreauth.StatusActive,
Attributes: map[string]string{
"source": full,
@@ -1473,6 +1495,7 @@ func synthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]an
Attributes: attrs,
Metadata: metadataCopy,
ProxyURL: primary.ProxyURL,
Prefix: primary.Prefix,
CreatedAt: now,
UpdatedAt: now,
Runtime: geminicli.NewVirtualCredential(projectID, shared),
@@ -1742,6 +1765,9 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if oldCfg.WebsocketAuth != newCfg.WebsocketAuth {
changes = append(changes, fmt.Sprintf("ws-auth: %t -> %t", oldCfg.WebsocketAuth, newCfg.WebsocketAuth))
}
if oldCfg.ForceModelPrefix != newCfg.ForceModelPrefix {
changes = append(changes, fmt.Sprintf("force-model-prefix: %t -> %t", oldCfg.ForceModelPrefix, newCfg.ForceModelPrefix))
}
// Quota-exceeded behavior
if oldCfg.QuotaExceeded.SwitchProject != newCfg.QuotaExceeded.SwitchProject {