feat(llm): 每个 LLM 配置支持自定义会话头
新增 session_header_key:非空时每次 LLM 请求带上该 HTTP 头,头值为当前 运行的 session id(chat 会话=conv-<id>,worker=exp<x>-worker-i<intent> 等)。用于某些按 session-id 头做提示缓存/粘性路由的网关。 实现:在已有的 quotaAwareTransport 里从请求 context 读取 session id (transcript.WithSessionID 挂上的)并注入,无需改 norma;同一共享 provider 也能按会话发出不同头值。DB 加 session_header_key 列(带旧库迁移),前端 LLM 配置弹窗新增输入框。
This commit is contained in:
1 parent
b513b656aa
commit
4319fe413f
10 files changed
+166
-36
No files matched your search
@@ -0,0 +1,66 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/norma/transcript"
|
||||
)
|
||||
|
||||
// fakeRT records the request it saw and returns a minimal 200 response.
|
||||
type fakeRT struct{ seen *http.Request }
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
f.seen = req
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newReq(ctx context.Context) *http.Request {
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.example.com/v1/messages", strings.NewReader("{}"))
|
||||
return req
|
||||
}
|
||||
|
||||
func TestSessionHeaderInjectedFromContext(t *testing.T) {
|
||||
base := &fakeRT{}
|
||||
rt := quotaAwareTransport{base: base, sessionHeaderKey: "x-session-id"}
|
||||
ctx := transcript.WithSessionID(context.Background(), "conv-42")
|
||||
if _, err := rt.RoundTrip(newReq(ctx)); err != nil {
|
||||
t.Fatalf("RoundTrip: %v", err)
|
||||
}
|
||||
if got := base.seen.Header.Get("x-session-id"); got != "conv-42" {
|
||||
t.Fatalf("x-session-id = %q, want conv-42", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHeaderSkippedWhenKeyEmpty(t *testing.T) {
|
||||
base := &fakeRT{}
|
||||
rt := quotaAwareTransport{base: base} // no key configured
|
||||
ctx := transcript.WithSessionID(context.Background(), "conv-42")
|
||||
if _, err := rt.RoundTrip(newReq(ctx)); err != nil {
|
||||
t.Fatalf("RoundTrip: %v", err)
|
||||
}
|
||||
// The header name is whatever the user would have set; with no key, nothing
|
||||
// session-related is added. Assert the common key stays absent.
|
||||
if got := base.seen.Header.Get("x-session-id"); got != "" {
|
||||
t.Fatalf("unexpected session header %q with empty key", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHeaderSkippedWhenNoSessionID(t *testing.T) {
|
||||
base := &fakeRT{}
|
||||
rt := quotaAwareTransport{base: base, sessionHeaderKey: "x-session-id"}
|
||||
// Context carries no session id (transcript persistence off).
|
||||
if _, err := rt.RoundTrip(newReq(context.Background())); err != nil {
|
||||
t.Fatalf("RoundTrip: %v", err)
|
||||
}
|
||||
if got := base.seen.Header.Get("x-session-id"); got != "" {
|
||||
t.Fatalf("x-session-id = %q, want empty when no session id on context", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user