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/compaction"
|
||||||
"github.com/Autumn-27/norma/llm"
|
"github.com/Autumn-27/norma/llm"
|
||||||
acperm "github.com/Autumn-27/norma/permission"
|
acperm "github.com/Autumn-27/norma/permission"
|
||||||
|
"github.com/Autumn-27/norma/transcript"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config describes the LLM backend resolved from the environment.
|
// Config describes the LLM backend resolved from the environment.
|
||||||
@@ -67,6 +68,12 @@ type Config struct {
|
|||||||
// OpenAI 推理模型(o 系列/GPT-5)只认后者,收到 max_tokens 会直接报
|
// OpenAI 推理模型(o 系列/GPT-5)只认后者,收到 max_tokens 会直接报
|
||||||
// unsupported_parameter;而多数兼容网关只认前者,故不做自动推断,交由用户按端点选。
|
// unsupported_parameter;而多数兼容网关只认前者,故不做自动推断,交由用户按端点选。
|
||||||
MaxTokensField string
|
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
|
// 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 +
|
// limiter lives on the single provider instance — so planner + all workers +
|
||||||
// main agent (which share this provider) are bounded by one shared rate limit.
|
// main agent (which share this provider) are bounded by one shared rate limit.
|
||||||
func (c Config) NewProvider() (llm.Provider, error) {
|
func (c Config) NewProvider() (llm.Provider, error) {
|
||||||
client, err := quotaAwareHTTPClient(c.Proxy)
|
client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -292,9 +299,24 @@ func IsQuotaExhaustedMessage(message string) bool {
|
|||||||
// retry loop treats every 429 as transient; normalizing only that response to
|
// retry loop treats every 429 as transient; normalizing only that response to
|
||||||
// 402 lets a task router fail over immediately while retaining the original
|
// 402 lets a task router fail over immediately while retaining the original
|
||||||
// response body for provider-specific classification and audit logs.
|
// 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) {
|
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
|
// 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
|
// 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
|
// 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)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
|
func quotaAwareHTTPClient(proxy, sessionHeaderKey string) (*http.Client, error) {
|
||||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
proxy = strings.TrimSpace(proxy)
|
proxy = strings.TrimSpace(proxy)
|
||||||
if proxy == "" {
|
if proxy == "" {
|
||||||
@@ -366,7 +388,7 @@ func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
|
|||||||
}
|
}
|
||||||
transport.Proxy = http.ProxyURL(proxyURL)
|
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
|
// 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()
|
defer srv.Close()
|
||||||
|
|
||||||
client, err := quotaAwareHTTPClient("")
|
client, err := quotaAwareHTTPClient("", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("client: %v", err)
|
t.Fatalf("client: %v", err)
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ func TestRoundTripCaptures429BodyAlongsideQuotaRewrite(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client, err := quotaAwareHTTPClient("")
|
client, err := quotaAwareHTTPClient("", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("client: %v", err)
|
t.Fatalf("client: %v", err)
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ func TestRoundTripWithoutCaptureIsUnchanged(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client, err := quotaAwareHTTPClient("")
|
client, err := quotaAwareHTTPClient("", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("client: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-10
@@ -55,19 +55,25 @@ type LLMProfile struct {
|
|||||||
// reasoning tokens plus visible output. Ignored by anthropic and
|
// reasoning tokens plus visible output. Ignored by anthropic and
|
||||||
// openai-responses, which name the field themselves.
|
// openai-responses, which name the field themselves.
|
||||||
MaxTokensField string `json:"max_tokens_field"`
|
MaxTokensField string `json:"max_tokens_field"`
|
||||||
|
// SessionHeaderKey, when non-empty, names a custom HTTP header sent on every
|
||||||
|
// request built from this profile; its value is the current run's session id
|
||||||
|
// (chat conversation / worker intent). For gateways that key prompt caching
|
||||||
|
// or sticky routing off a session-id header. "" = not sent. Maps to
|
||||||
|
// agent.Config.SessionHeaderKey.
|
||||||
|
SessionHeaderKey string `json:"session_header_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// profileCols is the read column list (hint variant, no api key) shared by the
|
// profileCols is the read column list (hint variant, no api key) shared by the
|
||||||
// list query; profileColsKey is the same with api_key for the single-row loads.
|
// list query; profileColsKey is the same with api_key for the single-row loads.
|
||||||
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,'')`
|
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,'')`
|
||||||
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,'')`
|
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,'')`
|
||||||
|
|
||||||
// scanProfile reads one row in the profileCols / profileColsKey column order. The
|
// scanProfile reads one row in the profileCols / profileColsKey column order. The
|
||||||
// 7th column lands in APIKeyHint or APIKey depending on which list the caller used.
|
// 7th column lands in APIKeyHint or APIKey depending on which list the caller used.
|
||||||
func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile) error {
|
func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile) error {
|
||||||
return sc.Scan(&p.ID, &p.Name, &p.Format, &p.BaseURL, &p.Proxy, &p.Model, into,
|
return sc.Scan(&p.ID, &p.Name, &p.Format, &p.BaseURL, &p.Proxy, &p.Model, into,
|
||||||
&p.RatePerSecond, &p.RatePerMinute, &p.ContextWindowK, &p.ReasoningEffort, &p.IsDefault, &p.Priority, &p.PoolExclude, &p.ThinkingType, &p.Streaming,
|
&p.RatePerSecond, &p.RatePerMinute, &p.ContextWindowK, &p.ReasoningEffort, &p.IsDefault, &p.Priority, &p.PoolExclude, &p.ThinkingType, &p.Streaming,
|
||||||
&p.MaxTokens, &p.MaxTokensField)
|
&p.MaxTokens, &p.MaxTokensField, &p.SessionHeaderKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) ListProfiles() ([]*LLMProfile, error) {
|
func (d *DB) ListProfiles() ([]*LLMProfile, error) {
|
||||||
@@ -140,18 +146,18 @@ func (d *DB) SaveProfile(p *LLMProfile) (int64, error) {
|
|||||||
}
|
}
|
||||||
if p.ID == 0 {
|
if p.ID == 0 {
|
||||||
var id int64
|
var id int64
|
||||||
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming,max_tokens,max_tokens_field)
|
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming,max_tokens,max_tokens_field,session_header_key)
|
||||||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING id`,
|
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NULLIF($18,'')) RETURNING id`,
|
||||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField).Scan(&id)
|
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey).Scan(&id)
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
if p.APIKey == "" {
|
if p.APIKey == "" {
|
||||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15 WHERE id=$16`,
|
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15,session_header_key=NULLIF($16,'') WHERE id=$17`,
|
||||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.ID)
|
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey, p.ID)
|
||||||
return p.ID, err
|
return p.ID, err
|
||||||
}
|
}
|
||||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17 WHERE id=$18`,
|
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17,session_header_key=NULLIF($18,'') WHERE id=$19`,
|
||||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.ID)
|
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey, p.ID)
|
||||||
return p.ID, err
|
return p.ID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,9 @@ CREATE TABLE IF NOT EXISTS llm_profiles (
|
|||||||
-- 发 max_tokens 会被 unsupported_parameter 拒绝。
|
-- 发 max_tokens 会被 unsupported_parameter 拒绝。
|
||||||
-- anthropic(max_tokens 必填)与 openai-responses(max_output_tokens)自带字段名,不受此值影响。
|
-- anthropic(max_tokens 必填)与 openai-responses(max_output_tokens)自带字段名,不受此值影响。
|
||||||
max_tokens_field TEXT NOT NULL DEFAULT '',
|
max_tokens_field TEXT NOT NULL DEFAULT '',
|
||||||
|
-- 自定义会话头:非空时每次请求带一个该名字的 HTTP 头,头值=当前运行的 session id
|
||||||
|
-- (chat 会话/worker 意图)。用于某些按 session-id 头做提示缓存/粘性路由的网关。''=不发送。
|
||||||
|
session_header_key TEXT NOT NULL DEFAULT '',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
@@ -350,6 +353,8 @@ ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_field_check
|
|||||||
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_max_tokens_check;
|
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_max_tokens_check;
|
||||||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_check
|
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_check
|
||||||
CHECK (max_tokens >= 0);
|
CHECK (max_tokens >= 0);
|
||||||
|
-- 自定义会话头名;补旧库。默认 '' = 不发送,旧配置行为不变。
|
||||||
|
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS session_header_key TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
-- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。
|
-- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。
|
||||||
-- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填,
|
-- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填,
|
||||||
|
|||||||
+22
-18
@@ -587,28 +587,32 @@ type LLMProfileDTO struct {
|
|||||||
// (''=max_tokens | 'max_completion_tokens',仅 openai 格式有意义)。
|
// (''=max_tokens | 'max_completion_tokens',仅 openai 格式有意义)。
|
||||||
MaxTokens int `json:"max_tokens"`
|
MaxTokens int `json:"max_tokens"`
|
||||||
MaxTokensField string `json:"max_tokens_field"`
|
MaxTokensField string `json:"max_tokens_field"`
|
||||||
|
// 自定义会话头名:非空时每次请求带该 HTTP 头,头值=当前会话/意图的 session id。
|
||||||
|
// ''=不发送。用于按 session-id 头做提示缓存/粘性路由的网关。
|
||||||
|
SessionHeaderKey string `json:"session_header_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
|
func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
|
||||||
return LLMProfileDTO{
|
return LLMProfileDTO{
|
||||||
ID: i64s(p.ID),
|
ID: i64s(p.ID),
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Format: p.Format,
|
Format: p.Format,
|
||||||
BaseURL: p.BaseURL,
|
BaseURL: p.BaseURL,
|
||||||
Proxy: p.Proxy,
|
Proxy: p.Proxy,
|
||||||
Model: p.Model,
|
Model: p.Model,
|
||||||
APIKeyHint: p.APIKeyHint,
|
APIKeyHint: p.APIKeyHint,
|
||||||
RatePerSecond: p.RatePerSecond,
|
RatePerSecond: p.RatePerSecond,
|
||||||
RatePerMinute: p.RatePerMinute,
|
RatePerMinute: p.RatePerMinute,
|
||||||
ContextWindowK: p.ContextWindowK,
|
ContextWindowK: p.ContextWindowK,
|
||||||
ThinkingType: p.ThinkingType,
|
ThinkingType: p.ThinkingType,
|
||||||
ReasoningEffort: p.ReasoningEffort,
|
ReasoningEffort: p.ReasoningEffort,
|
||||||
IsDefault: p.IsDefault,
|
IsDefault: p.IsDefault,
|
||||||
Priority: p.Priority,
|
Priority: p.Priority,
|
||||||
PoolExclude: p.PoolExclude,
|
PoolExclude: p.PoolExclude,
|
||||||
Streaming: p.Streaming,
|
Streaming: p.Streaming,
|
||||||
MaxTokens: p.MaxTokens,
|
MaxTokens: p.MaxTokens,
|
||||||
MaxTokensField: p.MaxTokensField,
|
MaxTokensField: p.MaxTokensField,
|
||||||
|
SessionHeaderKey: p.SessionHeaderKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -313,6 +313,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
|
|||||||
cfg.ReasoningEffort = p.ReasoningEffort
|
cfg.ReasoningEffort = p.ReasoningEffort
|
||||||
cfg.Stream = p.Streaming
|
cfg.Stream = p.Streaming
|
||||||
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
||||||
|
cfg.SessionHeaderKey = p.SessionHeaderKey
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey == "" {
|
||||||
return cfg, false
|
return cfg, false
|
||||||
}
|
}
|
||||||
@@ -336,11 +337,13 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
|||||||
streaming := true // 旧库/新建默认流式
|
streaming := true // 旧库/新建默认流式
|
||||||
var maxTokens int
|
var maxTokens int
|
||||||
var maxTokensField string
|
var maxTokensField string
|
||||||
|
var sessionHeaderKey string
|
||||||
if profs, _ := s.m.pg.ListProfiles(); profs != nil {
|
if profs, _ := s.m.pg.ListProfiles(); profs != nil {
|
||||||
for _, p := range profs {
|
for _, p := range profs {
|
||||||
if p.Name == "default" {
|
if p.Name == "default" {
|
||||||
id, priority, poolExclude, streaming = p.ID, p.Priority, p.PoolExclude, p.Streaming
|
id, priority, poolExclude, streaming = p.ID, p.Priority, p.PoolExclude, p.Streaming
|
||||||
maxTokens, maxTokensField = p.MaxTokens, p.MaxTokensField
|
maxTokens, maxTokensField = p.MaxTokens, p.MaxTokensField
|
||||||
|
sessionHeaderKey = p.SessionHeaderKey
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,7 +353,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
|||||||
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
|
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
|
||||||
ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
|
ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
|
||||||
Priority: priority, PoolExclude: poolExclude, Streaming: streaming,
|
Priority: priority, PoolExclude: poolExclude, Streaming: streaming,
|
||||||
MaxTokens: maxTokens, MaxTokensField: maxTokensField,
|
MaxTokens: maxTokens, MaxTokensField: maxTokensField, SessionHeaderKey: sessionHeaderKey,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -508,6 +511,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
|||||||
cfg.ReasoningEffort = p.ReasoningEffort
|
cfg.ReasoningEffort = p.ReasoningEffort
|
||||||
cfg.Stream = p.Streaming
|
cfg.Stream = p.Streaming
|
||||||
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
||||||
|
cfg.SessionHeaderKey = p.SessionHeaderKey
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey == "" {
|
||||||
return cfg, false
|
return cfg, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ function ProfileSheet({
|
|||||||
const [streaming, setStreaming] = React.useState(true); // true=流式(默认);false=非流式
|
const [streaming, setStreaming] = React.useState(true); // true=流式(默认);false=非流式
|
||||||
const [maxTokens, setMaxTokens] = React.useState("0"); // 单次回复输出上限;0=不发送
|
const [maxTokens, setMaxTokens] = React.useState("0"); // 单次回复输出上限;0=不发送
|
||||||
const [maxTokensField, setMaxTokensField] = React.useState(NONE); // 上限用哪个字段名;NONE=max_tokens
|
const [maxTokensField, setMaxTokensField] = React.useState(NONE); // 上限用哪个字段名;NONE=max_tokens
|
||||||
|
const [sessionHeaderKey, setSessionHeaderKey] = React.useState(""); // 自定义会话头名;空=不发送
|
||||||
const [testing, setTesting] = React.useState(false);
|
const [testing, setTesting] = React.useState(false);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
const [models, setModels] = React.useState<string[]>([]);
|
const [models, setModels] = React.useState<string[]>([]);
|
||||||
@@ -351,6 +352,7 @@ function ProfileSheet({
|
|||||||
setStreaming(profile?.streaming ?? true);
|
setStreaming(profile?.streaming ?? true);
|
||||||
setMaxTokens(String(profile?.max_tokens ?? 0));
|
setMaxTokens(String(profile?.max_tokens ?? 0));
|
||||||
setMaxTokensField(fromStore(profile?.max_tokens_field));
|
setMaxTokensField(fromStore(profile?.max_tokens_field));
|
||||||
|
setSessionHeaderKey(profile?.session_header_key ?? "");
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
setKeyHint(profile?.api_key_hint ?? "");
|
setKeyHint(profile?.api_key_hint ?? "");
|
||||||
setModels([]);
|
setModels([]);
|
||||||
@@ -437,6 +439,7 @@ function ProfileSheet({
|
|||||||
// 字段名开关只对 openai(Chat Completions) 有意义,其它格式一律回落到默认;
|
// 字段名开关只对 openai(Chat Completions) 有意义,其它格式一律回落到默认;
|
||||||
// 后端也会再做一次同样的归一化,这里只是别让 UI 送出自相矛盾的值。
|
// 后端也会再做一次同样的归一化,这里只是别让 UI 送出自相矛盾的值。
|
||||||
max_tokens_field: format === "openai" ? toStore(maxTokensField) : "",
|
max_tokens_field: format === "openai" ? toStore(maxTokensField) : "",
|
||||||
|
session_header_key: sessionHeaderKey.trim(),
|
||||||
});
|
});
|
||||||
if (isNew) toast.success(`已新建:${name.trim()}(在卡片上「设为激活」以启用)`);
|
if (isNew) toast.success(`已新建:${name.trim()}(在卡片上「设为激活」以启用)`);
|
||||||
else toast.success(profile?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
|
else toast.success(profile?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
|
||||||
@@ -570,6 +573,22 @@ function ProfileSheet({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="p-session-header">自定义会话头(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="p-session-header"
|
||||||
|
className="font-mono"
|
||||||
|
placeholder="如 x-session-id(留空=不发送)"
|
||||||
|
value={sessionHeaderKey}
|
||||||
|
onChange={(e) => setSessionHeaderKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
填写头名后,每次请求都会带上这个 HTTP 头,头值自动填为{" "}
|
||||||
|
<b>当前会话的 session id</b>(chat 会话如 conv-12、worker 如 exp3-worker-i87)。用于按 session-id
|
||||||
|
头做提示缓存 / 粘性路由的网关;同一会话多轮稳定、不同会话互不相同。留空则不发送。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="p-api-key">API Key</Label>
|
<Label htmlFor="p-api-key">API Key</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -723,6 +723,7 @@ export const api = {
|
|||||||
streaming?: boolean; // true(默认)=流式 | false=非流式
|
streaming?: boolean; // true(默认)=流式 | false=非流式
|
||||||
max_tokens?: number; // 单次回复输出上限;0=不发送,由服务端默认值决定
|
max_tokens?: number; // 单次回复输出上限;0=不发送,由服务端默认值决定
|
||||||
max_tokens_field?: string; // ""=max_tokens(默认) | "max_completion_tokens"(仅 openai 格式)
|
max_tokens_field?: string; // ""=max_tokens(默认) | "max_completion_tokens"(仅 openai 格式)
|
||||||
|
session_header_key?: string; // 非空=每次请求带该 HTTP 头,头值=当前会话 session id;""=不发送
|
||||||
}) => post<{ id: number }>("/llm/profiles", p),
|
}) => post<{ id: number }>("/llm/profiles", p),
|
||||||
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
|
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
|
||||||
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }),
|
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }),
|
||||||
|
|||||||
@@ -913,6 +913,9 @@ export interface LLMProfile {
|
|||||||
// 上限用哪个请求字段名,仅 format="openai" 有意义:
|
// 上限用哪个请求字段名,仅 format="openai" 有意义:
|
||||||
// ""=max_tokens(默认) | "max_completion_tokens"(OpenAI 推理模型只认它)
|
// ""=max_tokens(默认) | "max_completion_tokens"(OpenAI 推理模型只认它)
|
||||||
max_tokens_field?: string;
|
max_tokens_field?: string;
|
||||||
|
// 自定义会话头名:非空时每次请求带该 HTTP 头,头值=当前会话/意图的 session id。
|
||||||
|
// ""=不发送。用于按 session-id 头做提示缓存/粘性路由的网关。
|
||||||
|
session_header_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- LLM 轮询(故障转移)----
|
// ---- LLM 轮询(故障转移)----
|
||||||
|
|||||||
Reference in new issue
Block a user