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
+26
-4
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/Autumn-27/norma/compaction"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
acperm "github.com/Autumn-27/norma/permission"
|
||||
"github.com/Autumn-27/norma/transcript"
|
||||
)
|
||||
|
||||
// Config describes the LLM backend resolved from the environment.
|
||||
@@ -67,6 +68,12 @@ type Config struct {
|
||||
// OpenAI 推理模型(o 系列/GPT-5)只认后者,收到 max_tokens 会直接报
|
||||
// unsupported_parameter;而多数兼容网关只认前者,故不做自动推断,交由用户按端点选。
|
||||
MaxTokensField string
|
||||
// SessionHeaderKey,非空时,让每次 LLM 请求带上一个自定义 HTTP 头,头名为该值、
|
||||
// 头值为【当前会话的 session id】(chat 会话=conv-<id>,worker=exp<x>-worker-i<intent>
|
||||
// 等,见 WorkerSessionID)。用于某些按 session-id 头做提示缓存/粘性路由的网关。
|
||||
// 空 = 不发送。值由 transcript.WithSessionID 挂在请求 context 上,由 RoundTripper
|
||||
// 读取填入,因此同一共享 provider 也能按会话发出不同的头值。
|
||||
SessionHeaderKey string
|
||||
}
|
||||
|
||||
// compaction window resolution bounds (in K tokens). Below the floor the
|
||||
@@ -224,7 +231,7 @@ func (c Config) Provider() string {
|
||||
// limiter lives on the single provider instance — so planner + all workers +
|
||||
// main agent (which share this provider) are bounded by one shared rate limit.
|
||||
func (c Config) NewProvider() (llm.Provider, error) {
|
||||
client, err := quotaAwareHTTPClient(c.Proxy)
|
||||
client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -292,9 +299,24 @@ func IsQuotaExhaustedMessage(message string) bool {
|
||||
// retry loop treats every 429 as transient; normalizing only that response to
|
||||
// 402 lets a task router fail over immediately while retaining the original
|
||||
// response body for provider-specific classification and audit logs.
|
||||
type quotaAwareTransport struct{ base http.RoundTripper }
|
||||
type quotaAwareTransport struct {
|
||||
base http.RoundTripper
|
||||
// sessionHeaderKey, when non-empty, is the HTTP header name each request
|
||||
// carries; its value is the session id read from the request context. Empty
|
||||
// disables it. See Config.SessionHeaderKey.
|
||||
sessionHeaderKey string
|
||||
}
|
||||
|
||||
func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Custom session-id header: name is user-configured, value is THIS run's
|
||||
// session id (norma stashes it on the context via transcript.WithSessionID).
|
||||
// Stable across a session's turns and distinct across sessions — exactly what
|
||||
// a session-keyed prompt cache wants. Skipped when no session id is present.
|
||||
if t.sessionHeaderKey != "" {
|
||||
if sid := transcript.SessionIDFrom(req.Context()); sid != "" {
|
||||
req.Header.Set(t.sessionHeaderKey, sid)
|
||||
}
|
||||
}
|
||||
// When LLM recording is on, the Recorder puts a Capture on the context so the
|
||||
// raw wire bodies can be persisted. This is the only layer that still sees
|
||||
// them: norma builds the request body internally and decodes the SSE response
|
||||
@@ -347,7 +369,7 @@ func requestBodySnapshot(req *http.Request) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
|
||||
func quotaAwareHTTPClient(proxy, sessionHeaderKey string) (*http.Client, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if proxy == "" {
|
||||
@@ -366,7 +388,7 @@ func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
return &http.Client{Transport: quotaAwareTransport{base: transport}}, nil
|
||||
return &http.Client{Transport: quotaAwareTransport{base: transport, sessionHeaderKey: strings.TrimSpace(sessionHeaderKey)}}, nil
|
||||
}
|
||||
|
||||
// logTestConnection prints the raw HTTP status code(s) and response body of a
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestRoundTripCapturesRawBodies(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("")
|
||||
client, err := quotaAwareHTTPClient("", "")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestRoundTripCaptures429BodyAlongsideQuotaRewrite(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("")
|
||||
client, err := quotaAwareHTTPClient("", "")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestRoundTripWithoutCaptureIsUnchanged(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("")
|
||||
client, err := quotaAwareHTTPClient("", "")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
|
||||
@@ -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