Skip to content

Commit 6115ae8

Browse files
fix: apply channel header overrides to model-list probes (#2471)
* fix: apply channel header overrides to model-list probes, close #2445 * test(model-fetcher): use canonical X-Api-Key header spelling in override tests --------- Co-authored-by: wangzhengzhuo05 <175673456+wangzhengzhuo05@users.noreply.github.com>
1 parent 9e415c7 commit 6115ae8

3 files changed

Lines changed: 173 additions & 2 deletions

File tree

internal/server/biz/channel_override.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"maps"
8+
"net/http"
89
"strings"
910

1011
"github.com/samber/lo"
@@ -308,3 +309,57 @@ func deepMergeMap(base, override map[string]any) map[string]any {
308309

309310
return result
310311
}
312+
313+
// ApplyModelFetchHeaderOverrides applies the channel's header override operations to an
314+
// HTTP request used for model-list probing.
315+
//
316+
// Model probing runs outside the LLM request pipeline, so there is no RenderContext to
317+
// evaluate against: operations guarded by a Condition and set-values containing a template
318+
// placeholder ("{{") are skipped. Unconditional literal operations use the same semantics as
319+
// the chat/completion path (applyOverrideOperationToHeaders in
320+
// internal/server/orchestrator/override.go): set, delete, rename, copy. The legacy
321+
// "__AXONHUB_CLEAR__" value deletes the header.
322+
func ApplyModelFetchHeaderOverrides(headers http.Header, ops []objects.OverrideOperation) {
323+
if headers == nil || len(ops) == 0 {
324+
return
325+
}
326+
327+
for _, op := range ops {
328+
if op.Condition != "" {
329+
continue
330+
}
331+
332+
switch op.Op {
333+
case objects.OverrideOpSet:
334+
if strings.Contains(op.Value, "{{") {
335+
// A templated value cannot be rendered without an LLM request context;
336+
// sending the raw template would corrupt the probe request.
337+
continue
338+
}
339+
340+
if op.Value == ClearHeaderDirective {
341+
headers.Del(op.Path)
342+
continue
343+
}
344+
345+
headers.Set(op.Path, op.Value)
346+
case objects.OverrideOpDelete:
347+
headers.Del(op.Path)
348+
case objects.OverrideOpRename:
349+
values := headers.Values(op.From)
350+
if len(values) == 0 {
351+
continue
352+
}
353+
354+
headers.Del(op.From)
355+
356+
for _, v := range values {
357+
headers.Add(op.To, v)
358+
}
359+
case objects.OverrideOpCopy:
360+
for _, v := range headers.Values(op.From) {
361+
headers.Add(op.To, v)
362+
}
363+
}
364+
}
365+
}

internal/server/biz/model_fetcher.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
"github.com/looplj/axonhub/internal/ent"
1919
"github.com/looplj/axonhub/internal/ent/channel"
20+
"github.com/looplj/axonhub/internal/objects"
2021
"github.com/looplj/axonhub/llm/httpclient"
2122
"github.com/looplj/axonhub/llm/transformer/anthropic/claudecode"
2223
"github.com/looplj/axonhub/llm/transformer/antigravity"
@@ -390,8 +391,9 @@ func (f *ModelFetcher) FetchModels(ctx context.Context, input FetchModelsInput)
390391
}
391392

392393
var (
393-
apiKey string
394-
proxyConfig *httpclient.ProxyConfig
394+
apiKey string
395+
proxyConfig *httpclient.ProxyConfig
396+
headerOverrideOps []objects.OverrideOperation
395397
)
396398

397399
if input.APIKey != nil && *input.APIKey != "" {
@@ -431,6 +433,15 @@ func (f *ModelFetcher) FetchModels(ctx context.Context, input FetchModelsInput)
431433

432434
if ch.Settings != nil {
433435
proxyConfig = ch.Settings.Proxy
436+
437+
// The new schema field takes precedence; the legacy OverrideHeaders list is
438+
// converted when the new field is absent (same precedence as
439+
// (*Channel).GetHeaderOverrideOperations).
440+
if ch.Settings.HeaderOverrideOperations != nil {
441+
headerOverrideOps = ch.Settings.HeaderOverrideOperations
442+
} else {
443+
headerOverrideOps = objects.HeaderEntriesToOverrideOperations(ch.Settings.OverrideHeaders)
444+
}
434445
}
435446
}
436447

@@ -510,6 +521,13 @@ func (f *ModelFetcher) FetchModels(ctx context.Context, input FetchModelsInput)
510521
req.Headers.Set("Authorization", "Bearer "+apiKey)
511522
}
512523

524+
if req.Headers == nil {
525+
req.Headers = make(http.Header)
526+
}
527+
// Channel header overrides win over the standard auth headers, matching the
528+
// chat/completion path.
529+
ApplyModelFetchHeaderOverrides(req.Headers, headerOverrideOps)
530+
513531
httpClient := f.httpClient
514532
if proxyConfig != nil {
515533
httpClient = f.httpClient.WithProxy(proxyConfig)

internal/server/biz/model_fetcher_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,3 +1134,101 @@ func TestFetchModelsCommandCodeRejectsHTTP(t *testing.T) {
11341134
}
11351135
require.Zero(t, calls.Load())
11361136
}
1137+
1138+
func TestFetchModelsAppliesHeaderOverrideOperationsFromSettings(t *testing.T) {
1139+
var gotAPIKey, gotAuth string
1140+
1141+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1142+
gotAPIKey = r.Header.Get("X-Api-Key")
1143+
gotAuth = r.Header.Get("Authorization")
1144+
w.Header().Set("Content-Type", "application/json")
1145+
_, _ = w.Write([]byte(`{"data":[{"id":"override-model"}]}`))
1146+
}))
1147+
defer server.Close()
1148+
1149+
client := enttest.NewEntClient(t, "sqlite3", "file:fetch_models_header_override?mode=memory&_fk=0")
1150+
defer client.Close()
1151+
1152+
ctx := authz.WithSystemBypass(context.Background(), "test")
1153+
ch, err := client.Channel.Create().
1154+
SetName("header-override").
1155+
SetType(channel.TypeOpenai).
1156+
SetBaseURL(server.URL).
1157+
SetCredentials(objects.ChannelCredentials{APIKey: "stored-secret"}).
1158+
SetSupportedModels([]string{"override-model"}).
1159+
SetDefaultTestModel("override-model").
1160+
SetSettings(&objects.ChannelSettings{
1161+
HeaderOverrideOperations: []objects.OverrideOperation{
1162+
{Op: objects.OverrideOpSet, Path: "x-api-key", Value: "override-secret"},
1163+
{Op: objects.OverrideOpDelete, Path: "Authorization"},
1164+
},
1165+
}).
1166+
Save(ctx)
1167+
require.NoError(t, err)
1168+
1169+
fetcher := NewModelFetcher(
1170+
httpclient.NewHttpClientWithClient(server.Client()),
1171+
&ChannelService{AbstractService: &AbstractService{db: client}},
1172+
)
1173+
1174+
result, err := fetcher.FetchModels(ctx, FetchModelsInput{
1175+
ChannelType: channel.TypeOpenai.String(),
1176+
BaseURL: server.URL + "/",
1177+
ChannelID: &ch.ID,
1178+
})
1179+
require.NoError(t, err)
1180+
require.Nil(t, result.Error)
1181+
require.Len(t, result.Models, 1)
1182+
require.Equal(t, "override-model", result.Models[0].ID)
1183+
1184+
assert.Equal(t, "override-secret", gotAPIKey,
1185+
"the channel's header override must be applied to the /models probe request")
1186+
assert.Empty(t, gotAuth,
1187+
"a delete override must remove the standard Authorization header from the probe")
1188+
}
1189+
1190+
func TestFetchModelsAppliesLegacyOverrideHeaders(t *testing.T) {
1191+
var gotAPIKey string
1192+
1193+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1194+
gotAPIKey = r.Header.Get("X-Api-Key")
1195+
w.Header().Set("Content-Type", "application/json")
1196+
_, _ = w.Write([]byte(`{"data":[{"id":"legacy-model"}]}`))
1197+
}))
1198+
defer server.Close()
1199+
1200+
client := enttest.NewEntClient(t, "sqlite3", "file:fetch_models_header_override_legacy?mode=memory&_fk=0")
1201+
defer client.Close()
1202+
1203+
ctx := authz.WithSystemBypass(context.Background(), "test")
1204+
ch, err := client.Channel.Create().
1205+
SetName("legacy-header-override").
1206+
SetType(channel.TypeOpenai).
1207+
SetBaseURL(server.URL).
1208+
SetCredentials(objects.ChannelCredentials{APIKey: "stored-secret"}).
1209+
SetSupportedModels([]string{"legacy-model"}).
1210+
SetDefaultTestModel("legacy-model").
1211+
SetSettings(&objects.ChannelSettings{
1212+
OverrideHeaders: []objects.HeaderEntry{{Key: "x-api-key", Value: "legacy-secret"}},
1213+
}).
1214+
Save(ctx)
1215+
require.NoError(t, err)
1216+
1217+
fetcher := NewModelFetcher(
1218+
httpclient.NewHttpClientWithClient(server.Client()),
1219+
&ChannelService{AbstractService: &AbstractService{db: client}},
1220+
)
1221+
1222+
result, err := fetcher.FetchModels(ctx, FetchModelsInput{
1223+
ChannelType: channel.TypeOpenai.String(),
1224+
BaseURL: server.URL + "/",
1225+
ChannelID: &ch.ID,
1226+
})
1227+
require.NoError(t, err)
1228+
require.Nil(t, result.Error)
1229+
require.Len(t, result.Models, 1)
1230+
require.Equal(t, "legacy-model", result.Models[0].ID)
1231+
1232+
assert.Equal(t, "legacy-secret", gotAPIKey,
1233+
"the deprecated OverrideHeaders list must still be applied to the probe request")
1234+
}

0 commit comments

Comments
 (0)