ci / vet-and-test (pull_request) Successful in 1m1s
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>
166 lines
5.0 KiB
Go
166 lines
5.0 KiB
Go
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}
|
||
}
|