Skip to content

Commit d9161c7

Browse files
authored
fix(models): apply oauth excluded model updates (#110)
Merge global OAuth excluded-model config with per-auth exclusions, normalize manual auth-file edits to the canonical excluded_models field, and dispatch the existing post-persist auth hook after management PATCH updates so model registry state refreshes immediately.
1 parent d3b98ed commit d9161c7

4 files changed

Lines changed: 341 additions & 25 deletions

File tree

internal/api/handlers/management/auth_files.go

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@ import (
3838
kiroauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kiro"
3939
qoderauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/qoder"
4040
xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai"
41+
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
4142
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
4243
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
4344
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
4445
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
46+
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
4547
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
4648
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
4749
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
@@ -1319,10 +1321,15 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
13191321
}
13201322
targetAuth.UpdatedAt = time.Now()
13211323

1322-
if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
1324+
updatedAuth, err := h.authManager.Update(ctx, targetAuth)
1325+
if err != nil {
13231326
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
13241327
return
13251328
}
1329+
if errHook := h.notifyAuthFilePersisted(ctx, updatedAuth); errHook != nil {
1330+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to refresh auth: %v", errHook)})
1331+
return
1332+
}
13261333

13271334
c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
13281335
}
@@ -1419,14 +1426,26 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) {
14191426

14201427
targetAuth.UpdatedAt = time.Now()
14211428

1422-
if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
1429+
updatedAuth, err := h.authManager.Update(ctx, targetAuth)
1430+
if err != nil {
14231431
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
14241432
return
14251433
}
1434+
if errHook := h.notifyAuthFilePersisted(ctx, updatedAuth); errHook != nil {
1435+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to refresh auth: %v", errHook)})
1436+
return
1437+
}
14261438

14271439
c.JSON(http.StatusOK, gin.H{"status": "ok"})
14281440
}
14291441

1442+
func (h *Handler) notifyAuthFilePersisted(ctx context.Context, auth *coreauth.Auth) error {
1443+
if h == nil || h.postAuthPersistHook == nil || auth == nil {
1444+
return nil
1445+
}
1446+
return h.postAuthPersistHook(ctx, auth)
1447+
}
1448+
14301449
func decodeAuthFileFieldValue(raw json.RawMessage) (any, error) {
14311450
decoder := json.NewDecoder(bytes.NewReader(raw))
14321451
decoder.UseNumber()
@@ -1563,6 +1582,11 @@ func syncAuthFileMetadataFields(auth *coreauth.Auth, touchedRoots map[string]str
15631582
if _, ok := touchedRoots["disabled"]; ok {
15641583
syncAuthFileDisabledState(auth)
15651584
}
1585+
if _, ok := touchedRoots["excluded_models"]; ok {
1586+
syncAuthFileExcludedModelsAttribute(auth, touchedRoots)
1587+
} else if _, ok := touchedRoots["excluded-models"]; ok {
1588+
syncAuthFileExcludedModelsAttribute(auth, touchedRoots)
1589+
}
15661590
}
15671591

15681592
func syncAuthFileHeaderAttributes(auth *coreauth.Auth) {
@@ -1656,6 +1680,52 @@ func syncAuthFileWebsocketsAttribute(auth *coreauth.Auth) {
16561680
auth.Attributes["websockets"] = strconv.FormatBool(websockets)
16571681
}
16581682

1683+
func syncAuthFileExcludedModelsAttribute(auth *coreauth.Auth, touchedRoots map[string]struct{}) {
1684+
if auth == nil {
1685+
return
1686+
}
1687+
if auth.Attributes == nil {
1688+
auth.Attributes = make(map[string]string)
1689+
}
1690+
sourceKey := "excluded_models"
1691+
if _, okUnderscore := touchedRoots["excluded_models"]; !okUnderscore {
1692+
if _, okHyphen := touchedRoots["excluded-models"]; okHyphen {
1693+
sourceKey = "excluded-models"
1694+
}
1695+
}
1696+
excluded := authFileExcludedModelsValue(auth.Metadata[sourceKey])
1697+
excluded = internalconfig.NormalizeExcludedModels(excluded)
1698+
delete(auth.Metadata, "excluded-models")
1699+
if len(excluded) == 0 {
1700+
delete(auth.Metadata, "excluded_models")
1701+
delete(auth.Attributes, "excluded_models")
1702+
delete(auth.Attributes, "excluded_models_hash")
1703+
return
1704+
}
1705+
auth.Metadata["excluded_models"] = append([]string(nil), excluded...)
1706+
auth.Attributes["excluded_models"] = strings.Join(excluded, ",")
1707+
auth.Attributes["excluded_models_hash"] = diff.ComputeExcludedModelsHash(excluded)
1708+
}
1709+
1710+
func authFileExcludedModelsValue(value any) []string {
1711+
switch typed := value.(type) {
1712+
case []string:
1713+
return append([]string(nil), typed...)
1714+
case []any:
1715+
out := make([]string, 0, len(typed))
1716+
for _, item := range typed {
1717+
if value, ok := item.(string); ok {
1718+
out = append(out, value)
1719+
}
1720+
}
1721+
return out
1722+
case string:
1723+
return strings.Split(typed, ",")
1724+
default:
1725+
return nil
1726+
}
1727+
}
1728+
16591729
func authFileBoolValue(value any) (bool, bool) {
16601730
switch typed := value.(type) {
16611731
case bool:

internal/api/handlers/management/auth_files_patch_fields_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/gin-gonic/gin"
1414
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
15+
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
1516
fileauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
1617
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
1718
)
@@ -166,6 +167,143 @@ func TestPatchAuthFileFields_HeadersEmptyMapIsNoop(t *testing.T) {
166167
}
167168
}
168169

170+
func TestPatchAuthFileFields_SyncsExcludedModelsAttributes(t *testing.T) {
171+
t.Setenv("MANAGEMENT_PASSWORD", "")
172+
gin.SetMode(gin.TestMode)
173+
174+
store := &memoryAuthStore{}
175+
manager := coreauth.NewManager(store, nil, nil)
176+
record := &coreauth.Auth{
177+
ID: "kiro.json",
178+
FileName: "kiro.json",
179+
Provider: "kiro",
180+
Attributes: map[string]string{
181+
"path": "/tmp/kiro.json",
182+
},
183+
Metadata: map[string]any{
184+
"type": "kiro",
185+
},
186+
}
187+
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
188+
t.Fatalf("failed to register auth record: %v", errRegister)
189+
}
190+
191+
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
192+
193+
body := `{"name":"kiro.json","excluded_models":["kiro-auto"," KIRO-AUTO ","custom-undetected"]}`
194+
rec := httptest.NewRecorder()
195+
ctx, _ := gin.CreateTestContext(rec)
196+
req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(body))
197+
req.Header.Set("Content-Type", "application/json")
198+
ctx.Request = req
199+
h.PatchAuthFileFields(ctx)
200+
201+
if rec.Code != http.StatusOK {
202+
t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
203+
}
204+
205+
updated, ok := manager.GetByID("kiro.json")
206+
if !ok || updated == nil {
207+
t.Fatalf("expected auth record to exist after patch")
208+
}
209+
if got := updated.Attributes["excluded_models"]; got != "kiro-auto,custom-undetected" {
210+
t.Fatalf("attrs excluded_models = %q, want %q", got, "kiro-auto,custom-undetected")
211+
}
212+
if got, ok := updated.Metadata["excluded_models"].([]string); !ok || len(got) != 2 || got[0] != "kiro-auto" || got[1] != "custom-undetected" {
213+
t.Fatalf("metadata excluded_models = %#v, want canonical []string", updated.Metadata["excluded_models"])
214+
}
215+
if _, ok := updated.Metadata["excluded-models"]; ok {
216+
t.Fatalf("expected metadata excluded-models alias to be removed")
217+
}
218+
wantHash := diff.ComputeExcludedModelsHash([]string{"kiro-auto", "custom-undetected"})
219+
if got := updated.Attributes["excluded_models_hash"]; got != wantHash {
220+
t.Fatalf("attrs excluded_models_hash = %q, want %q", got, wantHash)
221+
}
222+
}
223+
224+
func TestPatchAuthFileFields_ExcludedModelsAliasReplacesAndClears(t *testing.T) {
225+
t.Setenv("MANAGEMENT_PASSWORD", "")
226+
gin.SetMode(gin.TestMode)
227+
228+
store := &memoryAuthStore{}
229+
manager := coreauth.NewManager(store, nil, nil)
230+
record := &coreauth.Auth{
231+
ID: "alias.json",
232+
FileName: "alias.json",
233+
Provider: "kiro",
234+
Attributes: map[string]string{
235+
"path": "/tmp/alias.json",
236+
"excluded_models": "stale-underscore,stale-hyphen",
237+
"excluded_models_hash": "stale",
238+
},
239+
Metadata: map[string]any{
240+
"type": "kiro",
241+
"excluded_models": []any{"stale-underscore"},
242+
"excluded-models": []any{"stale-hyphen"},
243+
},
244+
}
245+
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
246+
t.Fatalf("failed to register auth record: %v", errRegister)
247+
}
248+
249+
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
250+
251+
body := `{"name":"alias.json","excluded-models":["new-model"]}`
252+
rec := httptest.NewRecorder()
253+
ctx, _ := gin.CreateTestContext(rec)
254+
req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(body))
255+
req.Header.Set("Content-Type", "application/json")
256+
ctx.Request = req
257+
h.PatchAuthFileFields(ctx)
258+
259+
if rec.Code != http.StatusOK {
260+
t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
261+
}
262+
263+
updated, ok := manager.GetByID("alias.json")
264+
if !ok || updated == nil {
265+
t.Fatalf("expected auth record to exist after patch")
266+
}
267+
if got := updated.Attributes["excluded_models"]; got != "new-model" {
268+
t.Fatalf("attrs excluded_models = %q, want %q", got, "new-model")
269+
}
270+
if got, ok := updated.Metadata["excluded_models"].([]string); !ok || len(got) != 1 || got[0] != "new-model" {
271+
t.Fatalf("metadata excluded_models = %#v, want [new-model]", updated.Metadata["excluded_models"])
272+
}
273+
if _, ok := updated.Metadata["excluded-models"]; ok {
274+
t.Fatalf("expected metadata excluded-models alias to be removed")
275+
}
276+
277+
body = `{"name":"alias.json","excluded_models":[]}`
278+
rec = httptest.NewRecorder()
279+
ctx, _ = gin.CreateTestContext(rec)
280+
req = httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(body))
281+
req.Header.Set("Content-Type", "application/json")
282+
ctx.Request = req
283+
h.PatchAuthFileFields(ctx)
284+
285+
if rec.Code != http.StatusOK {
286+
t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
287+
}
288+
289+
updated, ok = manager.GetByID("alias.json")
290+
if !ok || updated == nil {
291+
t.Fatalf("expected auth record to exist after clear")
292+
}
293+
if _, ok := updated.Attributes["excluded_models"]; ok {
294+
t.Fatalf("expected attrs excluded_models to be cleared")
295+
}
296+
if _, ok := updated.Attributes["excluded_models_hash"]; ok {
297+
t.Fatalf("expected attrs excluded_models_hash to be cleared")
298+
}
299+
if _, ok := updated.Metadata["excluded_models"]; ok {
300+
t.Fatalf("expected metadata excluded_models to be cleared")
301+
}
302+
if _, ok := updated.Metadata["excluded-models"]; ok {
303+
t.Fatalf("expected metadata excluded-models alias to be cleared")
304+
}
305+
}
306+
169307
func TestPatchAuthFileFields_WebsocketsFalseIsUpdate(t *testing.T) {
170308
t.Setenv("MANAGEMENT_PASSWORD", "")
171309
gin.SetMode(gin.TestMode)

sdk/cliproxy/service.go

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,15 +1129,7 @@ func (s *Service) tryRegisterPluginModelsForAuth(ctx context.Context, a *coreaut
11291129
activeAuthKind = "apikey"
11301130
}
11311131
}
1132-
activeExcluded := s.oauthExcludedModels(providerKey, activeAuthKind)
1133-
if a == activeAuth && len(activeExcluded) == 0 {
1134-
activeExcluded = excluded
1135-
}
1136-
if activeAuth.Attributes != nil {
1137-
if val, ok := activeAuth.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" {
1138-
activeExcluded = strings.Split(val, ",")
1139-
}
1140-
}
1132+
activeExcluded := s.effectiveAuthExcludedModels(activeAuth, providerKey, activeAuthKind, excluded)
11411133
models := applyExcludedModels(result.Models, activeExcluded)
11421134
models = applyOAuthModelAlias(s.cfg, providerKey, activeAuthKind, models)
11431135
if len(models) > 0 {
@@ -1785,14 +1777,7 @@ func (s *Service) registerModelsForAuth(ctx context.Context, a *coreauth.Auth) {
17851777
if compatDetected {
17861778
provider = "openai-compatibility"
17871779
}
1788-
excluded := s.oauthExcludedModels(provider, authKind)
1789-
// The synthesizer pre-merges per-account and global exclusions into the "excluded_models" attribute.
1790-
// If this attribute is present, it represents the complete list of exclusions and overrides the global config.
1791-
if a.Attributes != nil {
1792-
if val, ok := a.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" {
1793-
excluded = strings.Split(val, ",")
1794-
}
1795-
}
1780+
excluded := s.effectiveAuthExcludedModels(a, provider, authKind, nil)
17961781
if s.tryRegisterPluginModelsForAuth(ctx, a, provider, authKind, excluded) {
17971782
return
17981783
}
@@ -2183,6 +2168,47 @@ func (s *Service) oauthExcludedModels(provider, authKind string) []string {
21832168
return cfg.OAuthExcludedModels[providerKey]
21842169
}
21852170

2171+
func (s *Service) effectiveAuthExcludedModels(auth *coreauth.Auth, provider, authKind string, fallback []string) []string {
2172+
return mergeExcludedModels(
2173+
fallback,
2174+
s.oauthExcludedModels(provider, authKind),
2175+
excludedModelsFromAuthAttributes(auth),
2176+
)
2177+
}
2178+
2179+
func excludedModelsFromAuthAttributes(auth *coreauth.Auth) []string {
2180+
if auth == nil || auth.Attributes == nil {
2181+
return nil
2182+
}
2183+
value := strings.TrimSpace(auth.Attributes["excluded_models"])
2184+
if value == "" {
2185+
return nil
2186+
}
2187+
return strings.Split(value, ",")
2188+
}
2189+
2190+
func mergeExcludedModels(lists ...[]string) []string {
2191+
seen := make(map[string]struct{})
2192+
out := make([]string, 0)
2193+
for _, list := range lists {
2194+
for _, item := range list {
2195+
trimmed := strings.ToLower(strings.TrimSpace(item))
2196+
if trimmed == "" {
2197+
continue
2198+
}
2199+
if _, exists := seen[trimmed]; exists {
2200+
continue
2201+
}
2202+
seen[trimmed] = struct{}{}
2203+
out = append(out, trimmed)
2204+
}
2205+
}
2206+
if len(out) == 0 {
2207+
return nil
2208+
}
2209+
return out
2210+
}
2211+
21862212
func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo {
21872213
if len(models) == 0 || len(excluded) == 0 {
21882214
return models

0 commit comments

Comments
 (0)