Files
Agent-System-Backend/internal/runtime/model.go
T
weichenandCursor 24bbb7b777
ci / vet-and-test (pull_request) Successful in 1m1s
Keep a newly seeded model key ahead of a stale secret
Why: The active secret_keys row overrides settings plaintext, so a replaced chat key kept returning 401. Migration also mutated the settings map in place, so clearing a matching plaintext never reached the database.
Evidence: go test ./internal/settings/ ./internal/runtime/ -count=1 (SELF, worktree).
Impact: System settings writes adopt a changed secret; conflicting plaintext is kept and logged; 401 errors name the key layer and an 8-hex fingerprint.
Refs: fix/model-key-secret-overlay
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 10:33:14 +00:00

166 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package runtime
import (
"strings"
"agent-system-backend/internal/vision"
core "agent-system-core"
)
// SecretLookup 由组合根注入。layer 是 owner 或 system。没有独立密钥时 ok 为 false,调用方用设置里的旧字段。
var SecretLookup func(owner, service string) (value, layer string, ok bool)
// LookupSecret 给组合根以外的调用方使用。owner 为空时用 local。
func LookupSecret(owner, service, fallback string) string {
value, _ := overlayKey(owner, service, fallback)
return value
}
func overlayKey(owner, service, fallback string) (string, string) {
if SecretLookup == nil {
return fallback, "settings"
}
if owner == "" {
owner = "local"
}
if v, layer, ok := SecretLookup(owner, service); ok && strings.TrimSpace(v) != "" {
if layer == "" {
layer = "owner"
}
return v, layer
}
return fallback, "settings"
}
// 模型路由解析(4.7 / 4.9 覆盖链):设置层 defaults → system → user → project,
// 画像 ModelRoute 为会话内最高层覆盖(kind 为空回落设置层主模型)。
// 端点与密钥按 kind 独立配置,轻量档空则回落主档。
// ContextInjection 是一条条件注入,由 runmgr 从存储读出,组装时再转换成 ctxasm 的形状。
type ContextInjection struct {
Keys []string
Secondary string
Logic string
Content string
Constant bool
ScanDepth int
BudgetChars int
}
// ResolvedModel 是一次路由解析的结果。
type ResolvedModel struct {
Kind string // chat | lightweight
Model string
Base string
Key string
KeySource string // owner | system | settings
}
// ResolveChatModel 解析主对话模型(画像 route > 设置层)。
func ResolveChatModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
m := eff.GetString("models.chat.model", "")
if prof.Model.Chat != "" {
m = prof.Model.Chat
}
key, source := overlayKey(prof.Owner, "models.chat", eff.GetString("models.chat.apiKey", ""))
return ResolvedModel{Kind: "chat", Model: m,
Base: eff.GetString("models.chat.baseUrl", ""), Key: key, KeySource: source}
}
// ResolveVisionModel 在当前轮含图片时核对视觉清单。清单内的模型保持原选择;
// 不在清单内则这一轮改用 models.vision.model,端点和密钥仍沿用主模型。
// 没有图片时与 ResolveChatModel 相同。
func ResolveVisionModel(prof core.AgentProfile, eff core.EffectiveSettings, hasImages bool) ResolvedModel {
chat := ResolveChatModel(prof, eff)
if !hasImages || modelCapable(chat.Model, VisionCapable(eff)) {
return chat
}
name := strings.TrimSpace(eff.GetString("models.vision.model", ""))
if name == "" {
name = vision.DefaultVisionModel
}
if name == chat.Model {
return chat
}
chat.Model = name
return chat
}
// VisionCapable 解析 models.vision.capable。空字符串用内置默认清单。
func VisionCapable(eff core.EffectiveSettings) []string {
raw := strings.TrimSpace(eff.GetString("models.vision.capable", ""))
if raw == "" {
raw = vision.DefaultVisionCapable
}
return splitModelList(raw)
}
func modelCapable(name string, capable []string) bool {
name = strings.TrimSpace(name)
if name == "" {
return false
}
for _, item := range capable {
if item == name {
return true
}
}
return false
}
func splitModelList(raw string) []string {
var out []string
seen := map[string]bool{}
for _, part := range strings.Split(raw, ",") {
name := strings.TrimSpace(part)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
return out
}
// ChatFallbacks 解析 models.chat.fallbacks。默认空,不改变现有模型选择。
func ChatFallbacks(eff core.EffectiveSettings) []string {
return splitModelList(eff.GetString("models.chat.fallbacks", ""))
}
// ChatFallbacksFor 在图片轮只保留视觉清单内的回退模型,避免退到不能看图的模型。
func ChatFallbacksFor(eff core.EffectiveSettings, hasImages bool) []string {
all := ChatFallbacks(eff)
if !hasImages {
return all
}
capable := VisionCapable(eff)
var out []string
for _, name := range all {
if modelCapable(name, capable) {
out = append(out, name)
}
}
return out
}
// ResolveLightweightModel 解析轻量模型:画像 route > 设置层轻量档 > 回落主档。
func ResolveLightweightModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
m := eff.GetString("models.lightweight.model", "")
base := eff.GetString("models.lightweight.baseUrl", "")
key, source := overlayKey(prof.Owner, "models.lightweight", eff.GetString("models.lightweight.apiKey", ""))
if prof.Model.Lightweight != "" {
m = prof.Model.Lightweight
}
if m == "" || base == "" {
// 回落主档(模型与端点分别回落,允许轻量仅覆盖模型名)。
chat := ResolveChatModel(prof, eff)
if m == "" {
m = chat.Model
}
if base == "" {
base, key, source = chat.Base, chat.Key, chat.KeySource
}
}
return ResolvedModel{Kind: "lightweight", Model: m, Base: base, Key: key, KeySource: source}
}