feat(llm): 模型配置新增输出上限,支持 max_completion_tokens 字段名
llm_profiles 加两列:max_tokens(单次回复输出上限,0=不发送该字段,由服务端 默认值决定)与 max_tokens_field(上限发哪个键)。后者仅对 format=openai 生效: 空(默认)= max_tokens;'max_completion_tokens' = 新键。OpenAI 推理模型 (o 系列/GPT-5)拒收 max_tokens、只认新键,而多数兼容网关反过来只认旧键, 故做成显式开关而非自动推断。anthropic 与 openai-responses 自带字段名, 存下来只会误导,保存时一并清空。 顺带补上一处从未接通的链路:planner/worker/chat/mainagent/goals 五个 agentcore.Options 都没设过 MaxTokens,恒为 0——openai 系因 omitempty 压根 不发上限,anthropic 走 norma 的 8192 兜底。光加配置项而不接这条线,用户在 后台填了数字也不会生效,故本次一并接通,取值与 streaming 一样按 profile 每轮解析,故障转移切换后下一轮即生效。 task_llm.go 里 nonStreaming() 的来源优先级(agent 绑定 → 任务链激活 profile → 全局)抽成共用的 activeCfg(),避免 maxTokens() 再抄一份同样的三级回退。 norma 升到 v0.3.3(该版本提供 Config.MaxTokensField)。go.mod 只动 norma 这 一行:仓库此前未 tidy 过,跑 go mod tidy 会顺带升 x/crypto、x/net 等一批无关 依赖,不混进本次改动。 迁移:新列走 ADD COLUMN IF NOT EXISTS + 默认 0/'',旧库升级后行为完全不变; CHECK 约束限定取值,每次启动先删后建保持幂等。
This commit is contained in:
1 parent
338e77c064
commit
7cc467c159
20 files changed
+364
-33
No files matched your search
+22
-10
@@ -44,18 +44,30 @@ type LLMProfile struct {
|
||||
// Provider.Complete). Non-streaming sidesteps flaky gateway SSE at the cost of
|
||||
// live in-run progress. Maps to agent.Config.Stream.
|
||||
Streaming bool `json:"streaming"`
|
||||
// MaxTokens caps a single reply's output in tokens. 0 = send no cap and let
|
||||
// the endpoint's own default apply (the historical behaviour). Unlike
|
||||
// ContextWindowK — the model's total capacity, used locally to size compaction
|
||||
// — this value travels with every request.
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
// MaxTokensField picks the request key carrying MaxTokens, for format
|
||||
// "openai" only: "" = max_tokens (default); "max_completion_tokens" = the
|
||||
// newer key, which OpenAI's reasoning models require and whose budget covers
|
||||
// reasoning tokens plus visible output. Ignored by anthropic and
|
||||
// openai-responses, which name the field themselves.
|
||||
MaxTokensField string `json:"max_tokens_field"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)`
|
||||
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)`
|
||||
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 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,'')`
|
||||
|
||||
// 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.
|
||||
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,
|
||||
&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)
|
||||
}
|
||||
|
||||
func (d *DB) ListProfiles() ([]*LLMProfile, error) {
|
||||
@@ -128,18 +140,18 @@ func (d *DB) SaveProfile(p *LLMProfile) (int64, error) {
|
||||
}
|
||||
if p.ID == 0 {
|
||||
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)
|
||||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15) 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).Scan(&id)
|
||||
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)
|
||||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) 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)
|
||||
return id, err
|
||||
}
|
||||
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 WHERE id=$14`,
|
||||
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.ID)
|
||||
_, 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`,
|
||||
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)
|
||||
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 WHERE id=$16`,
|
||||
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.ID)
|
||||
_, 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`,
|
||||
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)
|
||||
return p.ID, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestProfileMaxTokensRoundTrip pins the output-cap columns through the whole
|
||||
// read/write surface: both column lists (list vs keyed loads) must carry them,
|
||||
// and an update must not drop them. A miscounted column list silently shifts
|
||||
// every later Scan target, so this is the test that catches it.
|
||||
func TestProfileMaxTokensRoundTrip(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
// Close via Cleanup, registered FIRST so it runs LAST: cleanups are LIFO, and a
|
||||
// plain `defer d.Close()` would fire before them — the row-deleting cleanups
|
||||
// would then run against a closed pool and silently leave test rows behind.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
id, err := d.SaveProfile(&LLMProfile{
|
||||
Name: "t-maxtok", Format: "openai", Model: "m", APIKey: "k",
|
||||
MaxTokens: 8192, MaxTokensField: "max_completion_tokens",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
|
||||
|
||||
// Keyed single-row load (profileColsKey).
|
||||
p, err := d.ProfileByID(id)
|
||||
if err != nil || p == nil {
|
||||
t.Fatalf("ProfileByID: %v, p=%v", err, p)
|
||||
}
|
||||
if p.MaxTokens != 8192 || p.MaxTokensField != "max_completion_tokens" {
|
||||
t.Fatalf("keyed load: max_tokens=%d field=%q", p.MaxTokens, p.MaxTokensField)
|
||||
}
|
||||
|
||||
// List load (profileCols — the hint variant, a separate column list).
|
||||
ps, err := d.ListProfiles()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found *LLMProfile
|
||||
for _, x := range ps {
|
||||
if x.ID == id {
|
||||
found = x
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("profile missing from ListProfiles")
|
||||
}
|
||||
if found.MaxTokens != 8192 || found.MaxTokensField != "max_completion_tokens" {
|
||||
t.Fatalf("list load: max_tokens=%d field=%q", found.MaxTokens, found.MaxTokensField)
|
||||
}
|
||||
|
||||
// Update with a blank key takes the "keep existing key" UPDATE branch, which
|
||||
// has its own column list and is the easiest one to forget.
|
||||
p.APIKey = ""
|
||||
p.MaxTokens = 4096
|
||||
p.MaxTokensField = ""
|
||||
if _, err := d.SaveProfile(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := d.ProfileByID(id)
|
||||
if err != nil || after == nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if after.MaxTokens != 4096 || after.MaxTokensField != "" {
|
||||
t.Fatalf("after update: max_tokens=%d field=%q", after.MaxTokens, after.MaxTokensField)
|
||||
}
|
||||
if after.APIKey != "k" {
|
||||
t.Fatalf("blank key on update must keep the stored one, got %q", after.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// A profile saved without touching the new fields must read back as "no cap,
|
||||
// classic field name" — the pre-feature behaviour old rows also get.
|
||||
func TestProfileMaxTokensDefaults(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
// Close via Cleanup, registered FIRST so it runs LAST: cleanups are LIFO, and a
|
||||
// plain `defer d.Close()` would fire before them — the row-deleting cleanups
|
||||
// would then run against a closed pool and silently leave test rows behind.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
id, err := d.SaveProfile(&LLMProfile{Name: "t-maxtok-default", Format: "openai", Model: "m", APIKey: "k"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
|
||||
|
||||
p, err := d.ProfileByID(id)
|
||||
if err != nil || p == nil {
|
||||
t.Fatalf("ProfileByID: %v", err)
|
||||
}
|
||||
if p.MaxTokens != 0 || p.MaxTokensField != "" {
|
||||
t.Fatalf("defaults: max_tokens=%d field=%q, want 0 and \"\"", p.MaxTokens, p.MaxTokensField)
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,15 @@ CREATE TABLE IF NOT EXISTS llm_profiles (
|
||||
pool_exclude BOOLEAN NOT NULL DEFAULT false,
|
||||
-- streaming=true(默认)走流式 SSE;false 走真·非流式(stream:false,一次性 JSON)。
|
||||
streaming BOOLEAN NOT NULL DEFAULT true,
|
||||
-- 单次回复的输出上限(token)。0=不发送该字段,由服务端默认值决定——保持既有行为。
|
||||
-- 与 context_window_k(模型总容量,仅本地用于压缩阈值)是两回事:本值会随请求发出。
|
||||
max_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
-- 输出上限用哪个请求字段名,仅对 format='openai' 生效:
|
||||
-- '' = max_tokens(默认,兼容绝大多数网关)
|
||||
-- 'max_completion_tokens' = 新字段;OpenAI 推理模型(o 系列/GPT-5)只认它,
|
||||
-- 发 max_tokens 会被 unsupported_parameter 拒绝。
|
||||
-- anthropic(max_tokens 必填)与 openai-responses(max_output_tokens)自带字段名,不受此值影响。
|
||||
max_tokens_field TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -330,6 +339,18 @@ ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_format_check;
|
||||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_format_check
|
||||
CHECK (format IN ('openai','anthropic','openai-responses'));
|
||||
|
||||
-- 输出上限及其字段名;补旧库。默认 0 / '' = 不发送上限、沿用 max_tokens 字段名,
|
||||
-- 旧配置行为完全不变。
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS max_tokens INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS max_tokens_field TEXT NOT NULL DEFAULT '';
|
||||
-- 同 format:先删再建,保证每次启动幂等。
|
||||
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_max_tokens_field_check;
|
||||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_field_check
|
||||
CHECK (max_tokens_field IN ('','max_completion_tokens'));
|
||||
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_max_tokens_check;
|
||||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_check
|
||||
CHECK (max_tokens >= 0);
|
||||
|
||||
-- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。
|
||||
-- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填,
|
||||
-- 否则每次启动都会把用户后来手动设的组合覆盖回去。旧 reasoning_effort 语义:
|
||||
|
||||
Reference in new issue
Block a user