feat(model,capacity,vision,io): per-persona 路由 + 本地三级(默认关) + L3 分类 + OCR 独立模型 + image-crop 工具(裁决 03.4.1/03.4.3/14.4.1/14.4.4/14.4.5)
- 03.4.1:per-persona 路由载体选 models.json(组内新增 personaModels/personaProviders + 校验 + PersonaRoute()), Router/RuntimeLLM 快照绑定、loop 流式路径按该 turn 的 persona 路由;主步用路由结果、降级步钉自己的模型 - 03.4.3:本地第三级降级链(LLM_LOCAL_*,Configured() 含 Enabled ⇒ **默认关**),warn 标注来源 - 14.4.4:capacity 接入 L3(分类器接口 + TTL 缓存 + 严格 JSON 解析),CAPACITY_L3_ENABLED **默认 false** - 14.4.1:语义修正 —— group.multimodal 才是「主模型能否收图」(env 路径不再臆断),LLM_VISION_MODEL 只配视觉工具; vision/OCR 可各用不同模型;runtime 不再因有图抢路由(vision.Router 是唯一分层权威) - 14.4.5:新增 image-crop 工具(工具数 5→6,策略默认 owner-only),schema 支持按区域裁切 - 组合根:wire_runtime 读 group.Multimodal;capacity 装配拆出 wire_capacity.go(守 500 行) - 复核:`go build`/`go vet`/各包 `go test -count=1` 全绿;**6 条变异反证**先红后还原(含作者自查出的 2 条假阳性变异) - 未落:按 provider 的 thinking 开关(需 openai_encode.go/openai.go 文件面)、persona awakening(需 loader 文件面)
This commit is contained in:
1 parent
63e5b70e3d
commit
fa33c79e11
24 files changed
+1925
-106
No files matched your search
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件拥有唤醒式能力(capacity)决策层的装配:判定器构造、工具集 JSON 解析、
|
||||||
|
// L3 缓存 TTL 解析与 capacity_awaken 事件出口。它从 wire_runtime.go 拆出
|
||||||
|
// (AGENTS §10 文件 ≤500 行;wire.go 拆分建议同样适用于本文件)。
|
||||||
|
|
||||||
|
// buildCapacity constructs the awakened-capacity decider when enabled, or nil
|
||||||
|
// (which keeps the loop's default all-tools behavior). It is opt-in via
|
||||||
|
// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and
|
||||||
|
// falls back to built-in defaults on a parse error.
|
||||||
|
//
|
||||||
|
// The model-assisted L3 layer (裁决 14.4.4) is a second, independent switch
|
||||||
|
// (CAPACITY_L3_ENABLED, default false): even with capacity on, L3 stays silent
|
||||||
|
// unless it was explicitly asked for, and its verdict cache TTL
|
||||||
|
// (CAPACITY_L3_CACHE_TTL, default 5m) is parsed here because the config loader
|
||||||
|
// only carries scalars.
|
||||||
|
func (a *app) buildCapacity() *capacity.Decider {
|
||||||
|
if !a.cfg.Capacity.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sets, err := parseCapacityToolSets(a.cfg.Capacity.ToolSetsJSON)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err)
|
||||||
|
sets = nil
|
||||||
|
}
|
||||||
|
cfg := capacity.Config{
|
||||||
|
Enabled: true,
|
||||||
|
MinConfidence: a.cfg.Capacity.MinConfidence,
|
||||||
|
ToolSets: sets,
|
||||||
|
}
|
||||||
|
if a.cfg.Capacity.L3Enabled {
|
||||||
|
cfg.L3Enabled = true
|
||||||
|
cfg.L3Classifier = capacity.NewLLMClassifier(a.modelAdapter, a.log)
|
||||||
|
cfg.L3CacheTTL = parseL3CacheTTL(a.cfg.Capacity.L3CacheTTL, a.log)
|
||||||
|
}
|
||||||
|
return capacity.New(cfg, capacity.WithLogger(a.log))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseL3CacheTTL parses the CAPACITY_L3_CACHE_TTL duration string. An empty
|
||||||
|
// value selects the code default (5m, the landing suggestion of 裁决 14.4.4); an
|
||||||
|
// unparsable value is reported and falls back to the same default rather than
|
||||||
|
// silently disabling the cache.
|
||||||
|
func parseL3CacheTTL(raw string, log *slog.Logger) time.Duration {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return capacity.DefaultL3CacheTTL
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(raw)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithSource(log, "cmd/cyrene").Warn("capacity L3 cache ttl unparsable, using the default",
|
||||||
|
"value", raw, "default", capacity.DefaultL3CacheTTL.String(), "error", err)
|
||||||
|
return capacity.DefaultL3CacheTTL
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of
|
||||||
|
// state→allowed tool name list. A blank string yields nil (built-in defaults); a
|
||||||
|
// null value for a state means unrestricted (all tools).
|
||||||
|
func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var m map[string][]string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("capacity tool sets: %w", err)
|
||||||
|
}
|
||||||
|
out := make(map[capacity.State][]string, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out[capacity.State(k)] = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken
|
||||||
|
// transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken`
|
||||||
|
// frame. The loop only emits on a real state transition (never every round), and
|
||||||
|
// the event carries the originating session id so it routes to that session, not
|
||||||
|
// globally. The payload is the frontend AwakenPayload {fromState,toState,tool}.
|
||||||
|
func (a *app) onCapacity(ev capacity.Event) {
|
||||||
|
logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event",
|
||||||
|
"type", ev.Type, "from", ev.FromState, "to", ev.ToState,
|
||||||
|
"tool", ev.Tool, "at", ev.At, "session", ev.SessionID)
|
||||||
|
if a.capacitySink == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.capacitySink.BroadcastCapacityAwaken(ev.SessionID, string(ev.FromState), string(ev.ToState), ev.Tool)
|
||||||
|
}
|
||||||
+13
-56
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,7 +14,6 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||||
@@ -191,8 +189,14 @@ func (a *app) wireRuntime() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vision routing: the loop rewrites an image-bearing user message per the
|
// Vision routing: the loop rewrites an image-bearing user message per the
|
||||||
// vision Router — a vision-capable main model sees the image directly,
|
// vision Router — a main model that really accepts image input sees the image
|
||||||
// otherwise the vision/OCR tool's text description replaces it (design/06 §0).
|
// directly, otherwise the vision/OCR tool's text description replaces it
|
||||||
|
// (design/06 §0)。
|
||||||
|
//
|
||||||
|
// SupportsVision means "主模型能否收图"(裁决 14.4.1 修语义,C-7), read from the
|
||||||
|
// active models.json group's `multimodal` declaration — NOT from
|
||||||
|
// LLM_VISION_MODEL, which configures the vision *tool's* model. It is read per
|
||||||
|
// call so a panel save/reload takes effect on the next turn.
|
||||||
visionRouter := vision.NewRouter(
|
visionRouter := vision.NewRouter(
|
||||||
vision.WithVisionTool(visionToolName),
|
vision.WithVisionTool(visionToolName),
|
||||||
vision.WithOCRTool(ocrToolName),
|
vision.WithOCRTool(ocrToolName),
|
||||||
@@ -200,7 +204,8 @@ func (a *app) wireRuntime() error {
|
|||||||
visionResolver := vision.NewMessageResolver(
|
visionResolver := vision.NewMessageResolver(
|
||||||
visionRouter,
|
visionRouter,
|
||||||
func() vision.ModelInfo {
|
func() vision.ModelInfo {
|
||||||
return vision.ModelInfo{Name: a.cfg.LLM.VisionModel, SupportsVision: a.cfg.LLM.VisionModel != ""}
|
group := a.llm.Group()
|
||||||
|
return vision.ModelInfo{Name: group.Model, SupportsVision: group.Multimodal}
|
||||||
},
|
},
|
||||||
a.dispatcher.Invoke,
|
a.dispatcher.Invoke,
|
||||||
)
|
)
|
||||||
@@ -374,40 +379,8 @@ func (a *app) approvalActor(ctx context.Context) string {
|
|||||||
return pid
|
return pid
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildCapacity constructs the awakened-capacity decider when enabled, or nil
|
// buildCapacity / onCapacity / capacity JSON+TTL parsing live in
|
||||||
// (which keeps the loop's default all-tools behavior). It is opt-in via
|
// wire_capacity.go (AGENTS §10 文件 ≤500 行).
|
||||||
// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and
|
|
||||||
// falls back to built-in defaults on a parse error.
|
|
||||||
func (a *app) buildCapacity() *capacity.Decider {
|
|
||||||
if !a.cfg.Capacity.Enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sets, err := parseCapacityToolSets(a.cfg.Capacity.ToolSetsJSON)
|
|
||||||
if err != nil {
|
|
||||||
a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err)
|
|
||||||
sets = nil
|
|
||||||
}
|
|
||||||
return capacity.New(capacity.Config{
|
|
||||||
Enabled: true,
|
|
||||||
MinConfidence: a.cfg.Capacity.MinConfidence,
|
|
||||||
ToolSets: sets,
|
|
||||||
}, capacity.WithLogger(a.log))
|
|
||||||
}
|
|
||||||
|
|
||||||
// onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken
|
|
||||||
// transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken`
|
|
||||||
// frame. The loop only emits on a real state transition (never every round), and
|
|
||||||
// the event carries the originating session id so it routes to that session, not
|
|
||||||
// globally. The payload is the frontend AwakenPayload {fromState,toState,tool}.
|
|
||||||
func (a *app) onCapacity(ev capacity.Event) {
|
|
||||||
logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event",
|
|
||||||
"type", ev.Type, "from", ev.FromState, "to", ev.ToState,
|
|
||||||
"tool", ev.Tool, "at", ev.At, "session", ev.SessionID)
|
|
||||||
if a.capacitySink == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.capacitySink.BroadcastCapacityAwaken(ev.SessionID, string(ev.FromState), string(ev.ToState), ev.Tool)
|
|
||||||
}
|
|
||||||
|
|
||||||
// onToolAudit is the loop's OnAudit sink: it forwards a tool call denied by the
|
// onToolAudit is the loop's OnAudit sink: it forwards a tool call denied by the
|
||||||
// design/11 §3 security gate in a group/stranger environment into the gateway
|
// design/11 §3 security gate in a group/stranger environment into the gateway
|
||||||
@@ -424,23 +397,7 @@ func (a *app) onToolAudit(ev loop.AuditEvent) {
|
|||||||
a.gw.AuditEvent(ev.Who, ev.What, ev.Result)
|
a.gw.AuditEvent(ev.Who, ev.What, ev.Result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of
|
// parseCapacityToolSets / parseL3CacheTTL live in wire_capacity.go.
|
||||||
// state→allowed tool name list. A blank string yields nil (built-in defaults); a
|
|
||||||
// null value for a state means unrestricted (all tools).
|
|
||||||
func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) {
|
|
||||||
if strings.TrimSpace(raw) == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var m map[string][]string
|
|
||||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
||||||
return nil, fmt.Errorf("capacity tool sets: %w", err)
|
|
||||||
}
|
|
||||||
out := make(map[capacity.State][]string, len(m))
|
|
||||||
for k, v := range m {
|
|
||||||
out[capacity.State(k)] = v
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// dbCfgForEmbed returns the DB config used by the vector index, which opens the
|
// dbCfgForEmbed returns the DB config used by the vector index, which opens the
|
||||||
// same SQLite file as the primary pool.
|
// same SQLite file as the primary pool.
|
||||||
|
|||||||
@@ -88,10 +88,23 @@ type LLMConfig struct {
|
|||||||
Model string `env:"LLM_MODEL"`
|
Model string `env:"LLM_MODEL"`
|
||||||
Fallback string `env:"LLM_FALLBACK_MODEL"`
|
Fallback string `env:"LLM_FALLBACK_MODEL"`
|
||||||
|
|
||||||
// VisionModel is the dedicated model used for image/vision requests. When
|
// VisionModel is the dedicated model behind the vision tool (image
|
||||||
// set, the model router routes image content to it (see model.Router.WithVisionModel).
|
// understanding). It is the tool's model, not the main model's ability: the
|
||||||
|
// per-persona/per-request routing decision belongs to the vision Router, and
|
||||||
|
// the main model's own image input capability is declared by the active
|
||||||
|
// models.json group's multimodal flag (裁决 14.4.1/14.4.5)。
|
||||||
VisionModel string `env:"LLM_VISION_MODEL"`
|
VisionModel string `env:"LLM_VISION_MODEL"`
|
||||||
|
|
||||||
|
// OCRModel is the dedicated model behind the OCR tool. Empty reuses
|
||||||
|
// VisionModel, so OCR keeps working without a second endpoint (裁决 14.4.1
|
||||||
|
// 的「独立视觉模型 + OCR 模型」)。
|
||||||
|
OCRModel string `env:"LLM_OCR_MODEL"`
|
||||||
|
|
||||||
|
// Local is the optional third degradation level (裁决 03.4.3): primary →
|
||||||
|
// fallback → local. It defaults to off because the current machine cannot run
|
||||||
|
// a local model comfortably; when off the chain keeps its two-level shape.
|
||||||
|
Local LocalLLMConfig
|
||||||
|
|
||||||
// FallbackMaxTokens caps the last-resort fallback model in a group's chain
|
// FallbackMaxTokens caps the last-resort fallback model in a group's chain
|
||||||
// (primary → fallback). It is the final safety budget: it only applies when
|
// (primary → fallback). It is the final safety budget: it only applies when
|
||||||
// the chain actually has a fallback step, that group sets no per-group
|
// the chain actually has a fallback step, that group sets no per-group
|
||||||
@@ -107,6 +120,33 @@ type LLMConfig struct {
|
|||||||
AnthropicFallback string `env:"ANTHROPIC_FALLBACK_MODEL"`
|
AnthropicFallback string `env:"ANTHROPIC_FALLBACK_MODEL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalLLMConfig configures the optional local model rung — the third level of
|
||||||
|
// the primary → fallback → local degradation chain (裁决 03.4.3). It is opt-in
|
||||||
|
// and defaults to off: a machine that cannot run a local model comfortably must
|
||||||
|
// not silently pay for one. It becomes an active chain step only when Enabled is
|
||||||
|
// true and both BaseURL and Model are set; anything less is reported and skipped
|
||||||
|
// rather than producing a half-configured step.
|
||||||
|
type LocalLLMConfig struct {
|
||||||
|
// Enabled turns the local rung on. Default false (project red line: 本地模型可开关,默认关).
|
||||||
|
Enabled bool `env:"LLM_LOCAL_ENABLED" default:"false"`
|
||||||
|
// Provider is the local endpoint's wire protocol: "openai" (llama.cpp /
|
||||||
|
// ollama / vLLM compatible endpoints) or "anthropic". Empty means openai.
|
||||||
|
Provider string `env:"LLM_LOCAL_PROVIDER" default:"openai"`
|
||||||
|
// BaseURL is the local endpoint's base URL (e.g. http://127.0.0.1:8080/v1).
|
||||||
|
BaseURL string `env:"LLM_LOCAL_BASE_URL"`
|
||||||
|
// APIKey is the local endpoint's key; empty is normal for a local server.
|
||||||
|
APIKey string `env:"LLM_LOCAL_API_KEY"`
|
||||||
|
// Model is the local model name. Without it the rung stays off.
|
||||||
|
Model string `env:"LLM_LOCAL_MODEL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured reports whether the local rung has enough configuration to act as a
|
||||||
|
// chain step. Enabled alone is not enough: a missing endpoint or model must not
|
||||||
|
// become a step that can only fail.
|
||||||
|
func (l LocalLLMConfig) Configured() bool {
|
||||||
|
return l.Enabled && strings.TrimSpace(l.BaseURL) != "" && strings.TrimSpace(l.Model) != ""
|
||||||
|
}
|
||||||
|
|
||||||
// EmbeddingConfig holds the optional semantic embedding API settings. When
|
// EmbeddingConfig holds the optional semantic embedding API settings. When
|
||||||
// unset, the runtime falls back to built-in n-gram/FTS vectors.
|
// unset, the runtime falls back to built-in n-gram/FTS vectors.
|
||||||
type EmbeddingConfig struct {
|
type EmbeddingConfig struct {
|
||||||
@@ -246,6 +286,15 @@ type CapacityConfig struct {
|
|||||||
// reflection-based loader only supports scalars and []string), matching the
|
// reflection-based loader only supports scalars and []string), matching the
|
||||||
// MCP config pattern.
|
// MCP config pattern.
|
||||||
ToolSetsJSON string `env:"CAPACITY_TOOL_SETS"`
|
ToolSetsJSON string `env:"CAPACITY_TOOL_SETS"`
|
||||||
|
// L3Enabled turns on the model-assisted intent layer (design/14 §2.2 L3;
|
||||||
|
// 裁决 14.4.4, keys chosen as the landing suggestion the owner authorized).
|
||||||
|
// It costs one extra model call for an undecided turn, so it defaults to off:
|
||||||
|
// off means the layer never runs and L1/L2/L4 behave exactly as before.
|
||||||
|
L3Enabled bool `env:"CAPACITY_L3_ENABLED" default:"false"`
|
||||||
|
// L3CacheTTL is how long a model-assisted classification of the same turn text
|
||||||
|
// is reused (a Go duration string such as "5m"). Zero or unparsable disables
|
||||||
|
// reuse; empty selects the code default of 5m.
|
||||||
|
L3CacheTTL string `env:"CAPACITY_L3_CACHE_TTL" default:"5m"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCPServer describes a single MCP server: a stdio subprocess (Command/Args) or
|
// MCPServer describes a single MCP server: a stdio subprocess (Command/Args) or
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ type Group struct {
|
|||||||
// last-resort fallback step then falls back to LLM_FALLBACK_MAX_TOKENS, and
|
// last-resort fallback step then falls back to LLM_FALLBACK_MAX_TOKENS, and
|
||||||
// beyond that the adapter default applies.
|
// beyond that the adapter default applies.
|
||||||
MaxTokens int `json:"maxTokens,omitempty"`
|
MaxTokens int `json:"maxTokens,omitempty"`
|
||||||
|
// PersonaModels binds an individual persona to its own model name within this
|
||||||
|
// group (裁决 03.4.1: the "role" of per-role routing is the persona a turn
|
||||||
|
// answers as, not the LLM message role). The carrier is deliberately
|
||||||
|
// models.json rather than configs/personas/*.yaml, so the panel can edit the
|
||||||
|
// binding together with the rest of the provider configuration and no persona
|
||||||
|
// schema change is needed. An unknown persona id falls back to the group's
|
||||||
|
// default model, so a removed persona never breaks routing.
|
||||||
|
PersonaModels map[string]string `json:"personaModels,omitempty"`
|
||||||
|
// PersonaProviders binds an individual persona to another vendor's protocol
|
||||||
|
// (裁决 03.4.1 的 per-persona provider). Pair it with PersonaModels; an entry
|
||||||
|
// without a provider keeps this group's protocol.
|
||||||
|
PersonaProviders map[string]string `json:"personaProviders,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrInvalidGroup is returned when a group fails schema validation.
|
// ErrInvalidGroup is returned when a group fails schema validation.
|
||||||
@@ -75,9 +87,49 @@ func (g Group) Validate() error {
|
|||||||
if g.MaxTokens < 0 {
|
if g.MaxTokens < 0 {
|
||||||
return fmt.Errorf("maxTokens must be >= 0, got %d", g.MaxTokens)
|
return fmt.Errorf("maxTokens must be >= 0, got %d", g.MaxTokens)
|
||||||
}
|
}
|
||||||
|
if err := validatePersonaMap("personaModels", g.PersonaModels, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validatePersonaMap("personaProviders", g.PersonaProviders, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validatePersonaMap checks a persona-keyed binding map: every key must be a
|
||||||
|
// non-empty persona id and every value a non-empty value. When providers is true
|
||||||
|
// the values must additionally be a known protocol, so a typo in a per-persona
|
||||||
|
// provider fails the group loudly instead of silently routing to the default.
|
||||||
|
func validatePersonaMap(field string, m map[string]string, providers bool) error {
|
||||||
|
for persona, value := range m {
|
||||||
|
if strings.TrimSpace(persona) == "" {
|
||||||
|
return fmt.Errorf("%s: persona id must not be empty", field)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fmt.Errorf("%s: value for persona %q must not be empty", field, persona)
|
||||||
|
}
|
||||||
|
if providers && value != ProviderOpenAI && value != ProviderAnthropic {
|
||||||
|
return fmt.Errorf("%s: provider %q for persona %q must be openai or anthropic", field, value, persona)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersonaRoute returns the per-persona model/provider binding, if one is
|
||||||
|
// configured for persona. An empty model (or an unknown persona) is reported as
|
||||||
|
// ok=false so the caller keeps the group's default binding.
|
||||||
|
func (g Group) PersonaRoute(persona string) (model, provider string, ok bool) {
|
||||||
|
persona = strings.TrimSpace(persona)
|
||||||
|
if persona == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
model = strings.TrimSpace(g.PersonaModels[persona])
|
||||||
|
if model == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return model, strings.TrimSpace(g.PersonaProviders[persona]), true
|
||||||
|
}
|
||||||
|
|
||||||
// ToLLMConfig maps a group to the flat LLMConfig consumed by the adapter
|
// ToLLMConfig maps a group to the flat LLMConfig consumed by the adapter
|
||||||
// factory. The group's provider selects which config block is populated.
|
// factory. The group's provider selects which config block is populated.
|
||||||
func (g Group) ToLLMConfig() config.LLMConfig {
|
func (g Group) ToLLMConfig() config.LLMConfig {
|
||||||
@@ -161,6 +213,14 @@ func loadFile(path string) ([]Group, error) {
|
|||||||
// GroupFromEnv synthesizes a single group from the active env LLM block: the
|
// GroupFromEnv synthesizes a single group from the active env LLM block: the
|
||||||
// anthropic block when LLM_PROVIDER=anthropic, otherwise the flat OpenAI-compatible
|
// anthropic block when LLM_PROVIDER=anthropic, otherwise the flat OpenAI-compatible
|
||||||
// block. It is the .env fallback used when models.json is absent or invalid.
|
// block. It is the .env fallback used when models.json is absent or invalid.
|
||||||
|
//
|
||||||
|
// Multimodal stays false here: the flat env block has no way to declare whether
|
||||||
|
// the main model accepts image input, and claiming a capability the endpoint may
|
||||||
|
// not have is worse than routing the image through the vision/OCR tool (which is
|
||||||
|
// exactly what the tool exists for). The declaration lives on the models.json
|
||||||
|
// group's `multimodal` flag, which the provider panel writes (design/06 §0 首条;
|
||||||
|
// 裁决 14.4.1). LLM_VISION_MODEL configures the vision *tool's* model, not the
|
||||||
|
// main model's ability.
|
||||||
func GroupFromEnv(env config.LLMConfig) []Group {
|
func GroupFromEnv(env config.LLMConfig) []Group {
|
||||||
if env.Provider == ProviderAnthropic {
|
if env.Provider == ProviderAnthropic {
|
||||||
return []Group{{
|
return []Group{{
|
||||||
@@ -170,7 +230,6 @@ func GroupFromEnv(env config.LLMConfig) []Group {
|
|||||||
APIKey: env.AnthropicAPIKey,
|
APIKey: env.AnthropicAPIKey,
|
||||||
Model: env.AnthropicModel,
|
Model: env.AnthropicModel,
|
||||||
Fallback: env.AnthropicFallback,
|
Fallback: env.AnthropicFallback,
|
||||||
Multimodal: env.VisionModel != "",
|
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
return []Group{{
|
return []Group{{
|
||||||
@@ -180,7 +239,6 @@ func GroupFromEnv(env config.LLMConfig) []Group {
|
|||||||
APIKey: env.APIKey,
|
APIKey: env.APIKey,
|
||||||
Model: env.Model,
|
Model: env.Model,
|
||||||
Fallback: env.Fallback,
|
Fallback: env.Fallback,
|
||||||
Multimodal: env.VisionModel != "",
|
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,17 @@ type Config struct {
|
|||||||
// Awakening carries the persona-level override (design §3). Zero = no
|
// Awakening carries the persona-level override (design §3). Zero = no
|
||||||
// override.
|
// override.
|
||||||
Awakening Awakening
|
Awakening Awakening
|
||||||
|
// L3Enabled turns on the model-assisted intent layer (design/14 §2.2 L3;
|
||||||
|
// 裁决 14.4.4). It is off by default: the layer costs one model call per
|
||||||
|
// undecided turn, so it must be asked for explicitly. When false the
|
||||||
|
// classifier is never consulted, whatever L3Classifier holds.
|
||||||
|
L3Enabled bool
|
||||||
|
// L3Classifier is the model-assisted classifier the L3 layer consults when
|
||||||
|
// L3Enabled is true. Nil keeps the layer silent.
|
||||||
|
L3Classifier IntentClassifier
|
||||||
|
// L3CacheTTL is how long a verdict for identical turn text is reused (裁决
|
||||||
|
// 14.4.4 的 L3 输出缓存). Zero or negative disables reuse.
|
||||||
|
L3CacheTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input is everything Decide needs to classify one round (design §4).
|
// Input is everything Decide needs to classify one round (design §4).
|
||||||
@@ -140,10 +151,13 @@ type Input struct {
|
|||||||
type Option func(*Decider)
|
type Option func(*Decider)
|
||||||
|
|
||||||
// Decider is the awakened-capacity decision engine. It is immutable after New
|
// Decider is the awakened-capacity decision engine. It is immutable after New
|
||||||
// and safe for concurrent Decide calls.
|
// and safe for concurrent Decide calls (the L3 cache is mutex-guarded).
|
||||||
type Decider struct {
|
type Decider struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
// l3cache is the model-assisted intent layer's classifier + verdict cache.
|
||||||
|
// Nil when the layer is off, which is the default (裁决 14.4.4).
|
||||||
|
l3cache *l3Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourcedLog returns a logger tagged with the module source (project rule §10),
|
// sourcedLog returns a logger tagged with the module source (project rule §10),
|
||||||
@@ -175,6 +189,9 @@ func New(cfg Config, opts ...Option) *Decider {
|
|||||||
}
|
}
|
||||||
d := &Decider{cfg: cfg}
|
d := &Decider{cfg: cfg}
|
||||||
d.log = sourcedLog(nil)
|
d.log = sourcedLog(nil)
|
||||||
|
if cfg.L3Enabled {
|
||||||
|
d.l3cache = newL3Cache(cfg.L3Classifier, cfg.L3CacheTTL)
|
||||||
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
if opt != nil {
|
if opt != nil {
|
||||||
opt(d)
|
opt(d)
|
||||||
@@ -196,7 +213,7 @@ func (d *Decider) Decide(ctx context.Context, in Input) Decision {
|
|||||||
if !d.cfg.Enabled {
|
if !d.cfg.Enabled {
|
||||||
return Decision{State: in.CurrentState, AllowedTools: append([]string(nil), in.AllToolNames...)}
|
return Decision{State: in.CurrentState, AllowedTools: append([]string(nil), in.AllToolNames...)}
|
||||||
}
|
}
|
||||||
state, conf, tool := d.intent(in)
|
state, conf, tool := d.intent(ctx, in)
|
||||||
j := judgmentFor(in, state, conf, tool)
|
j := judgmentFor(in, state, conf, tool)
|
||||||
state, conf = d.applyAwakening(state, conf, in)
|
state, conf = d.applyAwakening(state, conf, in)
|
||||||
allowed := d.allowedTools(state, in.AllToolNames)
|
allowed := d.allowedTools(state, in.AllToolNames)
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package capacity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// l3MaxTokens caps the classifier's reply: it is a tiny JSON object, and an
|
||||||
|
// uncapped reply would burn budget on prose the parser then rejects.
|
||||||
|
const l3MaxTokens = 128
|
||||||
|
|
||||||
|
// l3SystemPrompt instructs the model to answer with a strict JSON judgment only.
|
||||||
|
// It deliberately repeats "JSON only": a prose answer is an error, not a verdict.
|
||||||
|
const l3SystemPrompt = `你是意图分类器。判断用户这一轮是在「办事」(有明确任务动作/对象),还是在「陪伴/闲聊」。
|
||||||
|
只输出一个 JSON 对象,不要任何解释或代码块标记,格式:
|
||||||
|
{"isTask": true|false, "taskKind": "complex"|"code"|"query"|"ops", "confidence": 0.0-1.0}
|
||||||
|
taskKind 只在 isTask 为 true 时有意义;拿不准时把 confidence 给低(<0.6)。`
|
||||||
|
|
||||||
|
// l3WireJudgment is the wire shape the classifier parses. It is separate from
|
||||||
|
// Judgment so an unexpected field can never leak into the decision engine.
|
||||||
|
type l3WireJudgment struct {
|
||||||
|
IsTask bool `json:"isTask"`
|
||||||
|
TaskKind string `json:"taskKind"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMClassifier is the model-assisted intent classifier behind the L3 layer
|
||||||
|
// (design/14 §2.2 L3; 裁决 14.4.4). It asks the adapter for a strict JSON
|
||||||
|
// judgment and returns it; an unparsable or empty reply is an error, never a
|
||||||
|
// guess, so the caller keeps its conservative default. It is safe for concurrent
|
||||||
|
// use.
|
||||||
|
type LLMClassifier struct {
|
||||||
|
adapter model.Adapter
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLLMClassifier builds the classifier over the runtime's LLM stack. A nil
|
||||||
|
// adapter is allowed and makes Classify report ErrNotConfigured.
|
||||||
|
func NewLLMClassifier(adapter model.Adapter, log *slog.Logger) *LLMClassifier {
|
||||||
|
if log == nil {
|
||||||
|
log = slog.New(slog.NewTextHandler(discard{}, nil))
|
||||||
|
}
|
||||||
|
return &LLMClassifier{adapter: adapter, log: logger.WithSource(log, "runtime/capacity")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotConfigured is returned when the classifier has no LLM stack to ask.
|
||||||
|
var ErrNotConfigured = fmt.Errorf("capacity: L3 classifier has no model configured")
|
||||||
|
|
||||||
|
// Classify asks the model for one judgment of text. It returns the raw verdict;
|
||||||
|
// callers apply the confidence threshold (see Decider.l3).
|
||||||
|
func (c *LLMClassifier) Classify(ctx context.Context, text string) (Judgment, error) {
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return Judgment{}, ErrEmptyTurn
|
||||||
|
}
|
||||||
|
if c.adapter == nil {
|
||||||
|
return Judgment{}, ErrNotConfigured
|
||||||
|
}
|
||||||
|
resp, err := c.adapter.Chat(ctx, model.Request{
|
||||||
|
Messages: []model.Message{
|
||||||
|
model.TextMessage(model.RoleSystem, l3SystemPrompt),
|
||||||
|
model.TextMessage(model.RoleUser, text),
|
||||||
|
},
|
||||||
|
MaxTokens: l3MaxTokens,
|
||||||
|
Purpose: "capacity-l3",
|
||||||
|
}, model.Options{})
|
||||||
|
if err != nil {
|
||||||
|
return Judgment{}, fmt.Errorf("capacity: L3 classify: %w", err)
|
||||||
|
}
|
||||||
|
wire, err := parseL3Judgment(resp.Content)
|
||||||
|
if err != nil {
|
||||||
|
return Judgment{}, err
|
||||||
|
}
|
||||||
|
c.log.Debug("capacity L3 verdict",
|
||||||
|
"is_task", wire.IsTask, "task_kind", strings.TrimSpace(wire.TaskKind),
|
||||||
|
"confidence", wire.Confidence)
|
||||||
|
return Judgment{
|
||||||
|
IsTask: wire.IsTask,
|
||||||
|
TaskKind: strings.TrimSpace(wire.TaskKind),
|
||||||
|
Confidence: clampUnit(wire.Confidence),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseL3Judgment extracts the JSON object from a model reply. A reply wrapped in
|
||||||
|
// a ```json fence is tolerated (models add one routinely), but prose around the
|
||||||
|
// object is not: the object must be the whole answer.
|
||||||
|
func parseL3Judgment(content string) (l3WireJudgment, error) {
|
||||||
|
raw := stripCodeFence(strings.TrimSpace(content))
|
||||||
|
if raw == "" {
|
||||||
|
return l3WireJudgment{}, fmt.Errorf("capacity: L3 classify: %w: empty reply", model.ErrBadRequest)
|
||||||
|
}
|
||||||
|
var wire l3WireJudgment
|
||||||
|
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
|
||||||
|
return l3WireJudgment{}, fmt.Errorf("capacity: L3 classify: %w: parse reply: %v", model.ErrBadRequest, err)
|
||||||
|
}
|
||||||
|
return wire, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripCodeFence removes a single ```/```json fence around the payload when the
|
||||||
|
// whole reply is fenced.
|
||||||
|
func stripCodeFence(s string) string {
|
||||||
|
if !strings.HasPrefix(s, "```") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
body := strings.TrimPrefix(s, "```")
|
||||||
|
if i := strings.IndexByte(body, '\n'); i >= 0 {
|
||||||
|
body = body[i+1:]
|
||||||
|
}
|
||||||
|
body = strings.TrimSuffix(body, "```")
|
||||||
|
return strings.TrimSpace(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampUnit keeps a model-reported confidence inside [0,1], so a hallucinated
|
||||||
|
// 7.5 can never outrank the threshold arithmetic.
|
||||||
|
func clampUnit(v float64) float64 {
|
||||||
|
switch {
|
||||||
|
case v < 0:
|
||||||
|
return 0
|
||||||
|
case v > 1:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package capacity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IntentClassifier is the model-assisted intent classifier behind the L3 layer
|
||||||
|
// (design/14 §2.2 L3; 裁决 14.4.4). It is an interface so the decision engine
|
||||||
|
// stays testable without a model: the wiring supplies the LLM-backed
|
||||||
|
// implementation (NewLLMClassifier), a test supplies a stub.
|
||||||
|
//
|
||||||
|
// Classify returns the raw judgment for one turn's text — {isTask, taskKind,
|
||||||
|
// confidence} plus nothing else. An error means "no verdict": the caller keeps
|
||||||
|
// the conservative default rather than guessing.
|
||||||
|
type IntentClassifier interface {
|
||||||
|
Classify(ctx context.Context, text string) (Judgment, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxL3CacheEntries bounds the L3 verdict cache so a long-lived process cannot
|
||||||
|
// grow it without limit. Past the bound, expired entries are dropped first and
|
||||||
|
// the cache is cleared if every entry is still fresh.
|
||||||
|
const maxL3CacheEntries = 256
|
||||||
|
|
||||||
|
// ErrEmptyTurn is returned when the L3 classifier is asked to judge empty turn
|
||||||
|
// text. It is a sentinel so callers (and tests) can test the category without
|
||||||
|
// matching on the message text.
|
||||||
|
var ErrEmptyTurn = errors.New("capacity: empty turn text for L3 classification")
|
||||||
|
|
||||||
|
// DefaultL3CacheTTL is the default reuse window for an L3 verdict — the landing
|
||||||
|
// suggestion of 裁决 14.4.4 (5m): long enough that a repeated phrase inside one
|
||||||
|
// conversation costs one model call, short enough that a changed habit re-learns.
|
||||||
|
const DefaultL3CacheTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
// l3Cache memoizes classifier verdicts per turn text for a bounded TTL, so the
|
||||||
|
// same phrase does not cost a model call on every round. A zero TTL disables
|
||||||
|
// reuse. The classifier call happens outside the lock: two concurrent turns must
|
||||||
|
// not serialize on each other's model call.
|
||||||
|
type l3Cache struct {
|
||||||
|
classifier IntentClassifier
|
||||||
|
ttl time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]l3Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// l3Entry is one cached verdict with the time it was produced.
|
||||||
|
type l3Entry struct {
|
||||||
|
judgment Judgment
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// newL3Cache builds the cache. A nil classifier yields nil, which the decider
|
||||||
|
// treats as "L3 not available".
|
||||||
|
func newL3Cache(classifier IntentClassifier, ttl time.Duration) *l3Cache {
|
||||||
|
if classifier == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &l3Cache{classifier: classifier, ttl: ttl, entries: map[string]l3Entry{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// classify returns the verdict for text, reusing a fresh cached one when
|
||||||
|
// possible. The bool reports whether a verdict was produced; an error is returned
|
||||||
|
// to the caller for reporting instead of being swallowed here.
|
||||||
|
func (c *l3Cache) classify(ctx context.Context, text string) (Judgment, error) {
|
||||||
|
key := l3CacheKey(text)
|
||||||
|
if key == "" {
|
||||||
|
return Judgment{}, ErrEmptyTurn
|
||||||
|
}
|
||||||
|
if j, ok := c.lookup(key, time.Now()); ok {
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
j, err := c.classifier.Classify(ctx, text)
|
||||||
|
if err != nil {
|
||||||
|
return Judgment{}, err
|
||||||
|
}
|
||||||
|
c.store(key, j, time.Now())
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup returns a fresh cached verdict, if any.
|
||||||
|
func (c *l3Cache) lookup(key string, now time.Time) (Judgment, bool) {
|
||||||
|
if c.ttl <= 0 {
|
||||||
|
return Judgment{}, false
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
e, ok := c.entries[key]
|
||||||
|
if !ok {
|
||||||
|
return Judgment{}, false
|
||||||
|
}
|
||||||
|
if now.Sub(e.at) > c.ttl {
|
||||||
|
return Judgment{}, false
|
||||||
|
}
|
||||||
|
return e.judgment, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// store records a verdict, evicting expired entries (and, if the bound is still
|
||||||
|
// exceeded, the whole map) so the cache cannot grow without limit.
|
||||||
|
func (c *l3Cache) store(key string, j Judgment, now time.Time) {
|
||||||
|
if c.ttl <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if len(c.entries) >= maxL3CacheEntries {
|
||||||
|
c.evictExpired(now)
|
||||||
|
}
|
||||||
|
c.entries[key] = l3Entry{judgment: j, at: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictExpired drops entries past their TTL; when none expired, it clears the
|
||||||
|
// map so a burst of distinct turns cannot pin the memory.
|
||||||
|
func (c *l3Cache) evictExpired(now time.Time) {
|
||||||
|
dropped := 0
|
||||||
|
for k, e := range c.entries {
|
||||||
|
if now.Sub(e.at) > c.ttl {
|
||||||
|
delete(c.entries, k)
|
||||||
|
dropped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dropped == 0 {
|
||||||
|
c.entries = map[string]l3Entry{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// l3CacheKey normalizes a turn's text into the cache key. Empty text (nothing to
|
||||||
|
// classify) yields "".
|
||||||
|
func l3CacheKey(text string) string {
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package capacity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubClassifier is a deterministic IntentClassifier: it records every text it
|
||||||
|
// was asked about so a test can pin whether L3 ran at all (裁决 14.4.4 的
|
||||||
|
// CAPACITY_L3_ENABLED 默认关).
|
||||||
|
type stubClassifier struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seen []string
|
||||||
|
verdict Judgment
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubClassifier) Classify(_ context.Context, text string) (Judgment, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.calls++
|
||||||
|
s.seen = append(s.seen, text)
|
||||||
|
if s.err != nil {
|
||||||
|
return Judgment{}, s.err
|
||||||
|
}
|
||||||
|
return s.verdict, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubClassifier) callCount() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func l3Config(c IntentClassifier, ttl time.Duration) Config {
|
||||||
|
cfg := activeConfig()
|
||||||
|
cfg.L3Enabled = true
|
||||||
|
cfg.L3Classifier = c
|
||||||
|
cfg.L3CacheTTL = ttl
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecide_L3Disabled_ClassifierNeverRuns pins the default-off red line: with
|
||||||
|
// CAPACITY_L3_ENABLED false the classifier must never be consulted, even when one
|
||||||
|
// is configured, so L1/L2/L4 behavior is unchanged.
|
||||||
|
func TestDecide_L3Disabled_ClassifierNeverRuns(t *testing.T) {
|
||||||
|
stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}}
|
||||||
|
cfg := activeConfig()
|
||||||
|
cfg.L3Classifier = stub // configured but not enabled
|
||||||
|
cfg.L3CacheTTL = time.Minute
|
||||||
|
|
||||||
|
// A turn no heuristic can decide: L1 needs a task verb + object, L2 needs a
|
||||||
|
// leaning window. "那件事后来如何" carries no signal at all.
|
||||||
|
dec := New(cfg).Decide(context.Background(), in("那件事后来如何"))
|
||||||
|
if stub.callCount() != 0 {
|
||||||
|
t.Fatalf("classifier calls = %d, want 0 while L3 is disabled", stub.callCount())
|
||||||
|
}
|
||||||
|
if dec.State != StateQuiet {
|
||||||
|
t.Fatalf("state = %s, want quiet (conservative default)", dec.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecide_L3AwakensUndecidedTurn pins that an enabled L3 layer turns an
|
||||||
|
// otherwise undecided turn into a wake with the classifier's own confidence, and
|
||||||
|
// advertises the awake tool set.
|
||||||
|
func TestDecide_L3AwakensUndecidedTurn(t *testing.T) {
|
||||||
|
stub := &stubClassifier{verdict: Judgment{IsTask: true, TaskKind: "query", Confidence: 0.82}}
|
||||||
|
dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何"))
|
||||||
|
|
||||||
|
if stub.callCount() != 1 {
|
||||||
|
t.Fatalf("classifier calls = %d, want 1", stub.callCount())
|
||||||
|
}
|
||||||
|
if dec.State != StateAwake {
|
||||||
|
t.Fatalf("state = %s, want awake (L3 verdict)", dec.State)
|
||||||
|
}
|
||||||
|
if dec.Confidence != 0.82 {
|
||||||
|
t.Fatalf("confidence = %v, want the classifier's 0.82", dec.Confidence)
|
||||||
|
}
|
||||||
|
if dec.Judgment.IsTask != true {
|
||||||
|
t.Errorf("judgment = %+v, want the classifier's isTask=true written back for L4", dec.Judgment)
|
||||||
|
}
|
||||||
|
if !contains(dec.AllowedTools, "web-search") {
|
||||||
|
t.Errorf("allowed = %v, want the awake set including web-search", dec.AllowedTools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecide_L3ComplexKindWakesFull pins the quantified gate's upper branch: a
|
||||||
|
// "complex" task kind maps to the full state rather than awake.
|
||||||
|
func TestDecide_L3ComplexKindWakesFull(t *testing.T) {
|
||||||
|
stub := &stubClassifier{verdict: Judgment{IsTask: true, TaskKind: taskKindComplex, Confidence: 0.9}}
|
||||||
|
dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何"))
|
||||||
|
if dec.State != StateFull {
|
||||||
|
t.Fatalf("state = %s, want full for a complex task kind", dec.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecide_L3LowConfidenceIsUndecided pins the conservative gate: a verdict the
|
||||||
|
// model is unsure about must not wake anything, and must not be written back as a
|
||||||
|
// task either (the L4 write-back feeds the learning loop, so a hesitant verdict
|
||||||
|
// recorded as a task would teach the wrong habit).
|
||||||
|
func TestDecide_L3LowConfidenceIsUndecided(t *testing.T) {
|
||||||
|
stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.3}}
|
||||||
|
dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何"))
|
||||||
|
if stub.callCount() != 1 {
|
||||||
|
t.Fatalf("classifier calls = %d, want 1", stub.callCount())
|
||||||
|
}
|
||||||
|
if dec.State != StateQuiet {
|
||||||
|
t.Fatalf("state = %s, want quiet (below-threshold verdict is undecided)", dec.State)
|
||||||
|
}
|
||||||
|
if dec.Judgment.IsTask {
|
||||||
|
t.Fatalf("judgment = %+v, want isTask=false (a below-threshold verdict is not a task)", dec.Judgment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecide_L3ClassifierErrorKeepsConservativeDefault pins that a model failure
|
||||||
|
// is reported as "no verdict" rather than crashing or waking tools.
|
||||||
|
func TestDecide_L3ClassifierErrorKeepsConservativeDefault(t *testing.T) {
|
||||||
|
stub := &stubClassifier{err: errors.New("upstream down")}
|
||||||
|
dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何"))
|
||||||
|
if dec.State != StateQuiet {
|
||||||
|
t.Fatalf("state = %s, want quiet after a classifier error", dec.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecide_L3CacheReusesVerdict pins the L3 output cache (裁决 14.4.4): the same
|
||||||
|
// turn text costs one model call inside the TTL, and a different turn still costs
|
||||||
|
// its own call.
|
||||||
|
func TestDecide_L3CacheReusesVerdict(t *testing.T) {
|
||||||
|
stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}}
|
||||||
|
d := New(l3Config(stub, time.Minute))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if dec := d.Decide(ctx, in("那件事后来如何")); dec.State != StateAwake {
|
||||||
|
t.Fatalf("call %d state = %s, want awake", i, dec.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := stub.callCount(); got != 1 {
|
||||||
|
t.Fatalf("classifier calls = %d, want 1 (cached verdict reused)", got)
|
||||||
|
}
|
||||||
|
d.Decide(ctx, in("那件事后来如何?"))
|
||||||
|
if got := stub.callCount(); got != 2 {
|
||||||
|
t.Fatalf("classifier calls = %d, want 2 (different text is not cached)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestL3Cache_ExpiredEntryIsReclassified pins the TTL bound itself.
|
||||||
|
func TestL3Cache_ExpiredEntryIsReclassified(t *testing.T) {
|
||||||
|
stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}}
|
||||||
|
c := newL3Cache(stub, time.Minute)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := c.classify(ctx, "那件事后来如何"); err != nil {
|
||||||
|
t.Fatalf("classify: %v", err)
|
||||||
|
}
|
||||||
|
// Backdate the stored entry past the TTL.
|
||||||
|
c.mu.Lock()
|
||||||
|
for k, e := range c.entries {
|
||||||
|
e.at = time.Now().Add(-2 * time.Minute)
|
||||||
|
c.entries[k] = e
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if _, err := c.classify(ctx, "那件事后来如何"); err != nil {
|
||||||
|
t.Fatalf("classify: %v", err)
|
||||||
|
}
|
||||||
|
if got := stub.callCount(); got != 2 {
|
||||||
|
t.Fatalf("classifier calls = %d, want 2 (expired entry reclassified)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestL3Cache_EmptyTextIsNotClassified pins that an empty turn is a sentinel
|
||||||
|
// error rather than a model call.
|
||||||
|
func TestL3Cache_EmptyTextIsNotClassified(t *testing.T) {
|
||||||
|
stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}}
|
||||||
|
c := newL3Cache(stub, time.Minute)
|
||||||
|
if _, err := c.classify(context.Background(), " "); !errors.Is(err, ErrEmptyTurn) {
|
||||||
|
t.Fatalf("err = %v, want ErrEmptyTurn", err)
|
||||||
|
}
|
||||||
|
if stub.callCount() != 0 {
|
||||||
|
t.Fatalf("classifier calls = %d, want 0", stub.callCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseL3Judgment pins the wire parsing: a fenced JSON reply is accepted, a
|
||||||
|
// prose reply is an error, and a hallucinated confidence is clamped.
|
||||||
|
func TestParseL3Judgment(t *testing.T) {
|
||||||
|
ok, err := parseL3Judgment("```json\n{\"isTask\":true,\"taskKind\":\"query\",\"confidence\":0.7}\n```")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseL3Judgment: %v", err)
|
||||||
|
}
|
||||||
|
if !ok.IsTask || ok.TaskKind != "query" || ok.Confidence != 0.7 {
|
||||||
|
t.Fatalf("parsed = %+v, want isTask=true taskKind=query confidence=0.7", ok)
|
||||||
|
}
|
||||||
|
if _, err := parseL3Judgment("我觉得这是一个任务"); err == nil {
|
||||||
|
t.Fatal("prose reply parsed without error, want failure")
|
||||||
|
}
|
||||||
|
if got := clampUnit(7.5); got != 1 {
|
||||||
|
t.Errorf("clampUnit(7.5) = %v, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
|
// signals.go holds the intent layer vocabulary and the L1/L2/L3 layers; the
|
||||||
|
// package doc lives in capacity.go.
|
||||||
package capacity
|
package capacity
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// defaultQuietTools is the companion-safe face (design §1): terminal + file edit
|
// defaultQuietTools is the companion-safe face (design §1): terminal + file edit
|
||||||
// + memory/emotion companion. It is a function building the slice fresh so no
|
// + memory/emotion companion. It is a function building the slice fresh so no
|
||||||
@@ -16,12 +21,13 @@ func defaultAwakeTools() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// intent classifies the round. It runs L1 on the current turn, falls to L2 on
|
// intent classifies the round. It runs L1 on the current turn, falls to L2 on
|
||||||
// the recent context trend, and folds in the L4 learned-habit verdict (design §2.2)
|
// the recent context trend, then to the model-assisted L3 layer when it is
|
||||||
// at each layer: L4 may correct a decided result (boost / quiet→awake) or fill in
|
// enabled, and folds in the L4 learned-habit verdict (design §2.2) at each layer:
|
||||||
// when L1/L2 are undecided. With no matching habit L4 is silent, so a turn with no
|
// L4 may correct a decided result (boost / quiet→awake) or fill in when L1/L2/L3
|
||||||
// recall behaves exactly as before; otherwise the result stays conservative (低置信
|
// are undecided. With no matching habit L4 is silent, so a turn with no recall
|
||||||
// still rests via applyAwakening).
|
// behaves exactly as before; otherwise the result stays conservative (低置信 still
|
||||||
func (d *Decider) intent(in Input) (State, float64, string) {
|
// rests via applyAwakening).
|
||||||
|
func (d *Decider) intent(ctx context.Context, in Input) (State, float64, string) {
|
||||||
if len(in.Messages) == 0 {
|
if len(in.Messages) == 0 {
|
||||||
return "", 0, ""
|
return "", 0, ""
|
||||||
}
|
}
|
||||||
@@ -33,12 +39,44 @@ func (d *Decider) intent(in Input) (State, float64, string) {
|
|||||||
if s, c, t, ok := l2(in.Messages); ok {
|
if s, c, t, ok := l2(in.Messages); ok {
|
||||||
return d.mergeL4(s, c, t, l4s, l4c, l4t, l4ok)
|
return d.mergeL4(s, c, t, l4s, l4c, l4t, l4ok)
|
||||||
}
|
}
|
||||||
|
if s, c, t, ok := d.l3(ctx, current); ok {
|
||||||
|
return d.mergeL4(s, c, t, l4s, l4c, l4t, l4ok)
|
||||||
|
}
|
||||||
if l4ok {
|
if l4ok {
|
||||||
return l4s, l4c, l4t
|
return l4s, l4c, l4t
|
||||||
}
|
}
|
||||||
return "", 0.3, "" // 拿不准 → conservative fallback
|
return "", 0.3, "" // 拿不准 → conservative fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// l3 runs the model-assisted intent layer (design/14 §2.2 L3; 裁决 14.4.4): the
|
||||||
|
// configured classifier judges the current turn and its verdict is trusted only
|
||||||
|
// at or above the wake threshold — a low-confidence verdict is treated as
|
||||||
|
// undecided, so a hesitant model can never wake tools on its own. A classifier
|
||||||
|
// error is reported and treated the same way. With the layer off this returns
|
||||||
|
// ok=false immediately, leaving L1/L2/L4 behavior byte-identical.
|
||||||
|
func (d *Decider) l3(ctx context.Context, current string) (State, float64, string, bool) {
|
||||||
|
if d.l3cache == nil {
|
||||||
|
return "", 0, "", false
|
||||||
|
}
|
||||||
|
j, err := d.l3cache.classify(ctx, current)
|
||||||
|
if err != nil {
|
||||||
|
d.log.Warn("capacity L3 classifier unavailable, keeping the conservative default", "error", err)
|
||||||
|
return "", 0, "", false
|
||||||
|
}
|
||||||
|
if j.Confidence < d.cfg.MinConfidence {
|
||||||
|
d.log.Debug("capacity L3 verdict below threshold, treated as undecided",
|
||||||
|
"confidence", j.Confidence, "threshold", d.cfg.MinConfidence, "is_task", j.IsTask)
|
||||||
|
return "", 0, "", false
|
||||||
|
}
|
||||||
|
if !j.IsTask {
|
||||||
|
return StateQuiet, j.Confidence, "", true
|
||||||
|
}
|
||||||
|
if j.TaskKind == taskKindComplex {
|
||||||
|
return StateFull, j.Confidence, inferTool(current), true
|
||||||
|
}
|
||||||
|
return StateAwake, j.Confidence, inferTool(current), true
|
||||||
|
}
|
||||||
|
|
||||||
// l1 is the fast heuristic on the current turn (design §2.2 L1): a strong
|
// l1 is the fast heuristic on the current turn (design §2.2 L1): a strong
|
||||||
// action signal wakes (awake, or full for complex work); a clear companion
|
// action signal wakes (awake, or full for complex work); a clear companion
|
||||||
// signal (weak + no task object) stays quiet; otherwise it is undecided and the
|
// signal (weak + no task object) stays quiet; otherwise it is undecided and the
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import (
|
|||||||
// RunStep 一致(前导指令消息、工具 schema、模型路由)。agent
|
// RunStep 一致(前导指令消息、工具 schema、模型路由)。agent
|
||||||
// 与推理旋钮从 ctx 解析,因此切换了角色的会话
|
// 与推理旋钮从 ctx 解析,因此切换了角色的会话
|
||||||
// 在其下一步就拿到新角色的提示词与预算(design/22 §4.2)。
|
// 在其下一步就拿到新角色的提示词与预算(design/22 §4.2)。
|
||||||
|
//
|
||||||
|
// 模型路由按该 turn 的角色(persona)解析(裁决 03.4.1)。agent.buildRequest
|
||||||
|
// (非流式路径,internal/runtime/agent/request.go)仍只按消息 role 路由——
|
||||||
|
// 那条路径的 persona 接线不在本批次文件面内,已在回报中登记。
|
||||||
func (l *Loop) buildRequest(ctx context.Context, messages []model.Message, ag *agent.Agent, knobs LoopKnobs) model.Request {
|
func (l *Loop) buildRequest(ctx context.Context, messages []model.Message, ag *agent.Agent, knobs LoopKnobs) model.Request {
|
||||||
req := model.Request{
|
req := model.Request{
|
||||||
Model: ag.Model(),
|
Model: ag.Model(),
|
||||||
@@ -25,6 +29,15 @@ func (l *Loop) buildRequest(ctx context.Context, messages []model.Message, ag *a
|
|||||||
ReasoningEffort: knobs.ReasoningEffort,
|
ReasoningEffort: knobs.ReasoningEffort,
|
||||||
}
|
}
|
||||||
if ag.Router() != nil {
|
if ag.Router() != nil {
|
||||||
|
// 按 turn 的角色(persona)路由(裁决 03.4.1:per-role 的「角色」= 人格,
|
||||||
|
// 不是 LLM 消息 role)。路由器的 persona 感知是可选的扩展接口
|
||||||
|
// (model.PersonaRouter),因此只按消息 role 路由的注入方行为不变。
|
||||||
|
if pr, ok := ag.Router().(model.PersonaRouter); ok {
|
||||||
|
if m, _ := pr.RouteRequestForPersona(l.personaFor(ctx), req); m != "" {
|
||||||
|
req.Model = m
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
if m, _ := ag.Router().RouteRequest(req); m != "" {
|
if m, _ := ag.Router().RouteRequest(req); m != "" {
|
||||||
req.Model = m
|
req.Model = m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
)
|
)
|
||||||
@@ -48,15 +49,67 @@ func NewChatChain(cfg config.LLMConfig) (*Chain, error) {
|
|||||||
// builds, so a provider-level reasoning decision (a thinking budget raised to fit
|
// builds, so a provider-level reasoning decision (a thinking budget raised to fit
|
||||||
// max_tokens, a reasoning_effort value downgraded or ignored) is reported with
|
// max_tokens, a reasoning_effort value downgraded or ignored) is reported with
|
||||||
// its module source. A nil logger leaves the adapters' discard default in place.
|
// its module source. A nil logger leaves the adapters' discard default in place.
|
||||||
|
//
|
||||||
|
// It is also the only builder that appends the optional local third rung
|
||||||
|
// (裁决 03.4.3, primary → fallback → local), because that rung may speak a
|
||||||
|
// different protocol than the active group and therefore needs the raw logger to
|
||||||
|
// build its own adapter. It is appended only when the local rung is enabled AND
|
||||||
|
// fully configured; otherwise the chain keeps its two-level shape and the reason
|
||||||
|
// is reported.
|
||||||
func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) {
|
func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) {
|
||||||
|
var chain *Chain
|
||||||
|
var err error
|
||||||
switch cfg.Provider {
|
switch cfg.Provider {
|
||||||
case ProviderOpenAI, "":
|
case ProviderOpenAI, "":
|
||||||
return newOpenAIChain(cfg, WithOpenAILogger(log))
|
chain, err = newOpenAIChain(cfg, WithOpenAILogger(log))
|
||||||
case ProviderAnthropic:
|
case ProviderAnthropic:
|
||||||
return newAnthropicChain(cfg, WithAnthropicLogger(log))
|
chain, err = newAnthropicChain(cfg, WithAnthropicLogger(log))
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider)
|
return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider)
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if step, ok := localChainStep(cfg, log); ok {
|
||||||
|
chain.steps = append(chain.steps, step)
|
||||||
|
}
|
||||||
|
return chain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// localChainStep builds the local third degradation rung as a chain step. It
|
||||||
|
// returns ok=false (and, when the rung was asked for but is incomplete, a WARN
|
||||||
|
// naming the missing field) when the rung is off, unconfigured or its provider is
|
||||||
|
// unknown — a half-configured step would only ever fail, which is worse than not
|
||||||
|
// having it.
|
||||||
|
func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) {
|
||||||
|
local := cfg.Local
|
||||||
|
// Configured() already requires Enabled, so the rung is off by default and a
|
||||||
|
// disabled rung can never become a chain step. The warning is emitted only
|
||||||
|
// when the rung was actually asked for but is incomplete.
|
||||||
|
if !local.Configured() {
|
||||||
|
if local.Enabled {
|
||||||
|
warn(log, "local model rung enabled but not configured; skipping it",
|
||||||
|
"base_url_set", strings.TrimSpace(local.BaseURL) != "",
|
||||||
|
"model_set", strings.TrimSpace(local.Model) != "")
|
||||||
|
}
|
||||||
|
return Step{}, false
|
||||||
|
}
|
||||||
|
localCfg := config.LLMConfig{
|
||||||
|
Provider: local.Provider,
|
||||||
|
BaseURL: local.BaseURL,
|
||||||
|
APIKey: local.APIKey,
|
||||||
|
Model: local.Model,
|
||||||
|
}
|
||||||
|
switch local.Provider {
|
||||||
|
case ProviderAnthropic:
|
||||||
|
return Step{Model: local.Model, Adapter: NewAnthropicChat(localCfg, WithAnthropicLogger(log))}, true
|
||||||
|
case ProviderOpenAI, "":
|
||||||
|
return Step{Model: local.Model, Adapter: NewOpenAIChat(localCfg, WithOpenAILogger(log))}, true
|
||||||
|
default:
|
||||||
|
warn(log, "local model rung skipped: unknown provider",
|
||||||
|
"provider", local.Provider, "field", "LLM_LOCAL_PROVIDER")
|
||||||
|
return Step{}, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newOpenAIChain builds a fallback chain from the OpenAI-compatible config block.
|
// newOpenAIChain builds a fallback chain from the OpenAI-compatible config block.
|
||||||
@@ -94,3 +147,14 @@ func DefaultModelForProvider(cfg config.LLMConfig) string {
|
|||||||
}
|
}
|
||||||
return cfg.Model
|
return cfg.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warn reports through the injected logger, falling back to a discard logger that
|
||||||
|
// still carries the module source so an unsourced record can never be emitted
|
||||||
|
// (project rule §10). It exists because a chain may be built without a logger
|
||||||
|
// (NewChatChain) while a skipped rung must still be reported.
|
||||||
|
func warn(log *slog.Logger, msg string, args ...any) {
|
||||||
|
if log == nil {
|
||||||
|
log = discardLogger("model")
|
||||||
|
}
|
||||||
|
log.Warn(msg, args...)
|
||||||
|
}
|
||||||
@@ -44,6 +44,19 @@ func (c *Chain) WithTokenBudget(groupMaxTokens, fallbackMaxTokens int) *Chain {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stepModel picks the model name for step idx of the chain. The primary step
|
||||||
|
// prefers the request's routed model — the per-persona / per-request decision the
|
||||||
|
// Router produced — over the group's configured model, so a routing decision is
|
||||||
|
// never silently discarded. Every fallback step pins its own configured model, so
|
||||||
|
// a degradation never re-sends the primary model name to a different endpoint
|
||||||
|
// (裁决 03.4.1: per-persona 路由必须真的落到请求上).
|
||||||
|
func stepModel(req Request, idx int, configured string) string {
|
||||||
|
if idx == 0 && req.Model != "" {
|
||||||
|
return req.Model
|
||||||
|
}
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
|
||||||
// applyBudget resolves the effective MaxTokens for step idx, honoring the
|
// applyBudget resolves the effective MaxTokens for step idx, honoring the
|
||||||
// precedence: an explicit Request.MaxTokens > the group's MaxTokens > the
|
// precedence: an explicit Request.MaxTokens > the group's MaxTokens > the
|
||||||
// last-resort LLM_FALLBACK_MAX_TOKENS > the adapter default. The last-resort
|
// last-resort LLM_FALLBACK_MAX_TOKENS > the adapter default. The last-resort
|
||||||
@@ -85,8 +98,8 @@ func (c *Chain) Chat(ctx context.Context, req Request, opts Options) (Response,
|
|||||||
var lastErr error
|
var lastErr error
|
||||||
for i, s := range c.steps {
|
for i, s := range c.steps {
|
||||||
stepReq := c.applyBudget(req, i)
|
stepReq := c.applyBudget(req, i)
|
||||||
if s.Model != "" {
|
if m := stepModel(req, i, s.Model); m != "" {
|
||||||
stepReq.Model = s.Model
|
stepReq.Model = m
|
||||||
}
|
}
|
||||||
resp, err := s.Adapter.Chat(ctx, stepReq, opts)
|
resp, err := s.Adapter.Chat(ctx, stepReq, opts)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -108,8 +121,8 @@ func (c *Chain) ChatStream(ctx context.Context, req Request, onEvent func(Stream
|
|||||||
var lastErr error
|
var lastErr error
|
||||||
for i, s := range c.steps {
|
for i, s := range c.steps {
|
||||||
stepReq := c.applyBudget(req, i)
|
stepReq := c.applyBudget(req, i)
|
||||||
if s.Model != "" {
|
if m := stepModel(req, i, s.Model); m != "" {
|
||||||
stepReq.Model = s.Model
|
stepReq.Model = m
|
||||||
}
|
}
|
||||||
delivered := false
|
delivered := false
|
||||||
wrapped := func(ev StreamEvent) {
|
wrapped := func(ev StreamEvent) {
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordingServer is an OpenAI-compatible /chat/completions endpoint that records
|
||||||
|
// the model name of every request and either fails or succeeds. It backs the
|
||||||
|
// local-rung tests (裁决 03.4.3): the point of the rung is that a request reaches
|
||||||
|
// the local endpoint with the local model name, and never when the rung is off.
|
||||||
|
type recordingServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
models []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingServer) modelsSeen() []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return append([]string(nil), s.models...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRecordingServer(t *testing.T, fail bool) (*httptest.Server, *recordingServer) {
|
||||||
|
t.Helper()
|
||||||
|
rec := &recordingServer{}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
rec.mu.Lock()
|
||||||
|
rec.models = append(rec.models, body.Model)
|
||||||
|
rec.mu.Unlock()
|
||||||
|
if fail {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = io.WriteString(w, `{"error":"down"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, okCompletion)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewChatChainWithLog_LocalRungAppended verifies the third degradation level
|
||||||
|
// (primary → fallback → local) only joins the chain when it is enabled AND fully
|
||||||
|
// configured.
|
||||||
|
func TestNewChatChainWithLog_LocalRungAppended(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
local config.LocalLLMConfig
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"off", config.LocalLLMConfig{BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 2},
|
||||||
|
{"enabled but no model", config.LocalLLMConfig{Enabled: true, BaseURL: "http://127.0.0.1:1/v1"}, 2},
|
||||||
|
{"enabled but no base url", config.LocalLLMConfig{Enabled: true, Model: "local"}, 2},
|
||||||
|
{"enabled and configured", config.LocalLLMConfig{Enabled: true, BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 3},
|
||||||
|
{"enabled with unknown provider", config.LocalLLMConfig{Enabled: true, Provider: "gemini", BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 2},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
c := c
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||||||
|
Provider: ProviderOpenAI, BaseURL: "http://127.0.0.1:1/v1", Model: "main", Fallback: "fb",
|
||||||
|
Local: c.local,
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewChatChainWithLog: %v", err)
|
||||||
|
}
|
||||||
|
if len(chain.steps) != c.want {
|
||||||
|
t.Fatalf("steps = %d, want %d", len(chain.steps), c.want)
|
||||||
|
}
|
||||||
|
if c.want == 3 && chain.steps[2].Model != "local" {
|
||||||
|
t.Errorf("steps[2].Model = %q, want local", chain.steps[2].Model)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChain_Chat_LocalRungServesAfterFallback verifies the local rung really takes
|
||||||
|
// over the request after the primary and fallback steps fail, with the local model
|
||||||
|
// name on the wire and the local endpoint's own base URL/API key (not the group's).
|
||||||
|
func TestChain_Chat_LocalRungServesAfterFallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mainSrv, _ := newRecordingServer(t, true)
|
||||||
|
localSrv, localRec := newRecordingServer(t, false)
|
||||||
|
|
||||||
|
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||||||
|
Provider: ProviderOpenAI, BaseURL: mainSrv.URL, APIKey: "sk-main", Model: "main", Fallback: "fb",
|
||||||
|
Local: config.LocalLLMConfig{Enabled: true, BaseURL: localSrv.URL, APIKey: "sk-local", Model: "local-model"},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewChatChainWithLog: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := chain.Chat(context.Background(),
|
||||||
|
Request{Messages: []Message{TextMessage(RoleUser, "hi")}, Purpose: "chat"}, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chain.Chat: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Content != "Hello!" {
|
||||||
|
t.Errorf("resp.Content = %q, want Hello!", resp.Content)
|
||||||
|
}
|
||||||
|
seen := localRec.modelsSeen()
|
||||||
|
if len(seen) != 1 || seen[0] != "local-model" {
|
||||||
|
t.Fatalf("local endpoint models = %v, want [local-model]", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChain_Chat_LocalRungOffNeverCalled pins the default-off red line: with the
|
||||||
|
// rung disabled the local endpoint must never be touched, even when every other
|
||||||
|
// step fails.
|
||||||
|
func TestChain_Chat_LocalRungOffNeverCalled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mainSrv, _ := newRecordingServer(t, true)
|
||||||
|
localSrv, localRec := newRecordingServer(t, false)
|
||||||
|
|
||||||
|
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||||||
|
Provider: ProviderOpenAI, BaseURL: mainSrv.URL, Model: "main", Fallback: "fb",
|
||||||
|
Local: config.LocalLLMConfig{BaseURL: localSrv.URL, Model: "local-model"},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewChatChainWithLog: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := chain.Chat(context.Background(),
|
||||||
|
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); !errors.Is(err, ErrUpstream) {
|
||||||
|
t.Fatalf("err = %v, want ErrUpstream", err)
|
||||||
|
}
|
||||||
|
if seen := localRec.modelsSeen(); len(seen) != 0 {
|
||||||
|
t.Fatalf("local endpoint called %v, want never called while the rung is off", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChain_Chat_PrimaryStepKeepsRoutedModel pins that the chain does not discard
|
||||||
|
// the router's decision: the primary step sends the request's routed model, and
|
||||||
|
// the fallback step still pins its own configured model (裁决 03.4.1).
|
||||||
|
func TestChain_Chat_PrimaryStepKeepsRoutedModel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// One endpoint serves both steps (the factory builds every step against the
|
||||||
|
// group's base URL): it fails for anything but the fallback model, so the
|
||||||
|
// recorded model names show which model each step actually sent.
|
||||||
|
rec := &recordingServer{}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
rec.mu.Lock()
|
||||||
|
rec.models = append(rec.models, body.Model)
|
||||||
|
rec.mu.Unlock()
|
||||||
|
if body.Model != "group-fb" {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = io.WriteString(w, `{"error":"down"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, okCompletion)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||||||
|
Provider: ProviderOpenAI, BaseURL: srv.URL, Model: "group-main", Fallback: "group-fb",
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewChatChainWithLog: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := chain.Chat(context.Background(), Request{
|
||||||
|
Model: "persona-main",
|
||||||
|
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||||
|
}, Options{}); err != nil {
|
||||||
|
t.Fatalf("Chain.Chat: %v", err)
|
||||||
|
}
|
||||||
|
seen := rec.modelsSeen()
|
||||||
|
if len(seen) != 2 || seen[0] != "persona-main" || seen[1] != "group-fb" {
|
||||||
|
t.Fatalf("models seen = %v, want [persona-main group-fb] (routed model first, step model on fallback)", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRouter_RouteRequestForPersona pins 裁决 03.4.1: the "role" of per-role
|
||||||
|
// routing is the persona a turn answers as, and a persona without a binding keeps
|
||||||
|
// the message-role behavior.
|
||||||
|
func TestRouter_RouteRequestForPersona(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := NewRouter("default",
|
||||||
|
WithRoleModel(RoleUser, "user-model"),
|
||||||
|
WithPersonaModel("cyrene", "persona-model"),
|
||||||
|
WithPersonaProvider("cyrene", ProviderAnthropic),
|
||||||
|
WithDefaultProvider(ProviderOpenAI),
|
||||||
|
)
|
||||||
|
req := Request{Messages: []Message{TextMessage(RoleUser, "hi")}}
|
||||||
|
|
||||||
|
if got, _ := r.RouteRequestForPersona("cyrene", req); got != "persona-model" {
|
||||||
|
t.Errorf("RouteRequestForPersona(cyrene) = %q, want persona-model", got)
|
||||||
|
}
|
||||||
|
if got, _ := r.RouteRequestForPersona("other", req); got != "user-model" {
|
||||||
|
t.Errorf("RouteRequestForPersona(other) = %q, want user-model (role fallback)", got)
|
||||||
|
}
|
||||||
|
if got, _ := r.RouteRequestForPersona("", req); got != "user-model" {
|
||||||
|
t.Errorf("RouteRequestForPersona(\"\") = %q, want user-model", got)
|
||||||
|
}
|
||||||
|
if got := r.RouteForPersona("cyrene"); got != "persona-model" {
|
||||||
|
t.Errorf("RouteForPersona(cyrene) = %q, want persona-model", got)
|
||||||
|
}
|
||||||
|
if got := r.RouteProviderForPersona("cyrene"); got != ProviderAnthropic {
|
||||||
|
t.Errorf("RouteProviderForPersona(cyrene) = %q, want anthropic", got)
|
||||||
|
}
|
||||||
|
if got := r.RouteProviderForPersona("other"); got != ProviderOpenAI {
|
||||||
|
t.Errorf("RouteProviderForPersona(other) = %q, want the default provider openai", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch pins two edges: a
|
||||||
|
// blank persona/model cannot create a binding, and a persona-routed image request
|
||||||
|
// is not silently re-routed to a vision model (that decision belongs to
|
||||||
|
// vision.Router, 裁决 14.4.1/14.4.5).
|
||||||
|
func TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := NewRouter("default",
|
||||||
|
WithPersonaModel(" ", "x"),
|
||||||
|
WithPersonaModel("cyrene", ""),
|
||||||
|
WithVisionModel("vision-model"),
|
||||||
|
)
|
||||||
|
if got := r.RouteForPersona("cyrene"); got != "default" {
|
||||||
|
t.Errorf("RouteForPersona(cyrene) = %q, want default (no binding stored)", got)
|
||||||
|
}
|
||||||
|
img := Request{Messages: []Message{ImageMessage(RoleUser, "", "data:image/png;base64,abc")}}
|
||||||
|
got, needsVision := r.RouteRequestForPersona("cyrene", img)
|
||||||
|
if !needsVision {
|
||||||
|
t.Error("needsVision = false, want true for an image-bearing request")
|
||||||
|
}
|
||||||
|
if got == "vision-model" {
|
||||||
|
t.Errorf("RouteRequestForPersona = %q, want the persona/role model, not the vision model", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRuntimeLLM_PersonaRoutesFromModelsJSON pins the carrier: a per-persona
|
||||||
|
// binding written on the models.json group reaches the router through the hot
|
||||||
|
// snapshot, and a reload picks up a changed binding (裁决 03.4.1 载体 = models.json)。
|
||||||
|
func TestRuntimeLLM_PersonaRoutesFromModelsJSON(t *testing.T) {
|
||||||
|
path := writeRTFile(t, []runtimeconfig.Group{{
|
||||||
|
ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model",
|
||||||
|
PersonaModels: map[string]string{"cyrene": "persona-model"},
|
||||||
|
PersonaProviders: map[string]string{"cyrene": ProviderAnthropic},
|
||||||
|
}})
|
||||||
|
m := NewRuntimeLLM(path, rtEnv(), nil)
|
||||||
|
|
||||||
|
req := Request{Messages: []Message{TextMessage(RoleUser, "hi")}}
|
||||||
|
got, _ := m.RouteRequestForPersona("cyrene", req)
|
||||||
|
if got != "persona-model" {
|
||||||
|
t.Fatalf("RouteRequestForPersona(cyrene) = %q, want persona-model", got)
|
||||||
|
}
|
||||||
|
if got, _ := m.RouteRequestForPersona("other", req); got != "group-model" {
|
||||||
|
t.Fatalf("RouteRequestForPersona(other) = %q, want group-model", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtimeconfig.SaveModels(path, []runtimeconfig.Group{{
|
||||||
|
ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model",
|
||||||
|
}}); err != nil {
|
||||||
|
t.Fatalf("SaveModels: %v", err)
|
||||||
|
}
|
||||||
|
if err := m.Reload(); err != nil {
|
||||||
|
t.Fatalf("Reload: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := m.RouteRequestForPersona("cyrene", req); got != "group-model" {
|
||||||
|
t.Fatalf("after reload RouteRequestForPersona(cyrene) = %q, want group-model (binding removed)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRuntimeLLM_NoVisionPreemptionOnImage pins that the runtime snapshot no longer
|
||||||
|
// carries the env vision model: a configured LLM_VISION_MODEL must not switch an
|
||||||
|
// image-bearing request to another model, because image routing is owned by
|
||||||
|
// vision.Router (裁决 14.4.1/14.4.5).
|
||||||
|
func TestRuntimeLLM_NoVisionPreemptionOnImage(t *testing.T) {
|
||||||
|
path := writeRTFile(t, []runtimeconfig.Group{{
|
||||||
|
ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model",
|
||||||
|
}})
|
||||||
|
env := rtEnv()
|
||||||
|
env.VisionModel = "vision-model"
|
||||||
|
m := NewRuntimeLLM(path, env, nil)
|
||||||
|
|
||||||
|
req := Request{Messages: []Message{ImageMessage(RoleUser, "", "data:image/png;base64,abc")}}
|
||||||
|
got, needsVision := m.RouteRequest(req)
|
||||||
|
if !needsVision {
|
||||||
|
t.Error("needsVision = false, want true for an image-bearing request")
|
||||||
|
}
|
||||||
|
if got != "group-model" {
|
||||||
|
t.Errorf("RouteRequest = %q, want group-model (no vision pre-emption)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,31 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// Router maps roles to model names. An unknown role resolves to the configured
|
// Router maps roles to model names. An unknown role resolves to the configured
|
||||||
// default model. When a request carries image content and a vision model is
|
// default model. A Router also carries a provider per role (defaulting to the
|
||||||
// configured, the request routes to the vision model. A Router also carries a
|
// global provider) so a multi-vendor deployment can pair a role's model with the
|
||||||
// provider per role (defaulting to the global provider) so a multi-vendor
|
// correct protocol; the provider selection is surfaced via RouteProvider.
|
||||||
// deployment can pair a role's model with the correct protocol; the provider
|
//
|
||||||
// selection is surfaced via RouteProvider.
|
// Per 裁决 03.4.1 the "role" of per-role routing is the **persona** a turn
|
||||||
|
// answers as, not the LLM message role: a persona binding wins over the
|
||||||
|
// message-role binding, and a persona without a binding keeps the previous
|
||||||
|
// behavior. The binding carrier is the active models.json group
|
||||||
|
// (runtimeconfig.Group.PersonaModels / PersonaProviders).
|
||||||
|
//
|
||||||
|
// Image routing is deliberately NOT this router's job any more (裁决
|
||||||
|
// 14.4.1/14.4.5): the single authority for layered image routing is
|
||||||
|
// runtime/multimodal/vision.Router, which the loop calls with the main model's
|
||||||
|
// real image-input capability. A "has image → switch to the vision model" rule
|
||||||
|
// here used to bypass that decision.
|
||||||
type Router struct {
|
type Router struct {
|
||||||
defaultModel string
|
defaultModel string
|
||||||
byRole map[Role]string
|
byRole map[Role]string
|
||||||
visionModel string
|
visionModel string
|
||||||
defaultProvider string
|
defaultProvider string
|
||||||
providerByRole map[Role]string
|
providerByRole map[Role]string
|
||||||
|
personaModel map[string]string
|
||||||
|
personaProvider map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// RouterOption configures a Router.
|
// RouterOption configures a Router.
|
||||||
@@ -22,11 +36,36 @@ func WithRoleModel(role Role, model string) RouterOption {
|
|||||||
return func(r *Router) { r.byRole[role] = model }
|
return func(r *Router) { r.byRole[role] = model }
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithVisionModel configures the dedicated vision model.
|
// WithVisionModel configures the dedicated vision model. It is kept for
|
||||||
|
// backwards compatibility (tests and callers that route a pure image request by
|
||||||
|
// model name) but production no longer sets it: image routing belongs to
|
||||||
|
// vision.Router (裁决 14.4.1/14.4.5).
|
||||||
func WithVisionModel(model string) RouterOption {
|
func WithVisionModel(model string) RouterOption {
|
||||||
return func(r *Router) { r.visionModel = model }
|
return func(r *Router) { r.visionModel = model }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithPersonaModel binds a model to a persona (裁决 03.4.1). A blank persona or
|
||||||
|
// model is ignored so a partially configured group cannot create a binding that
|
||||||
|
// routes a persona to an empty model.
|
||||||
|
func WithPersonaModel(persona, model string) RouterOption {
|
||||||
|
return func(r *Router) {
|
||||||
|
if p := trimKey(persona); p != "" && model != "" {
|
||||||
|
r.personaModel[p] = model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPersonaProvider binds a vendor protocol to a persona, so one persona can
|
||||||
|
// answer through a different vendor than the group default (裁决 03.4.1). Pair it
|
||||||
|
// with WithPersonaModel.
|
||||||
|
func WithPersonaProvider(persona, provider string) RouterOption {
|
||||||
|
return func(r *Router) {
|
||||||
|
if p := trimKey(persona); p != "" && provider != "" {
|
||||||
|
r.personaProvider[p] = provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithDefaultProvider sets the provider returned by RouteProvider for roles with
|
// WithDefaultProvider sets the provider returned by RouteProvider for roles with
|
||||||
// no explicit binding. It defaults to openai.
|
// no explicit binding. It defaults to openai.
|
||||||
func WithDefaultProvider(provider string) RouterOption {
|
func WithDefaultProvider(provider string) RouterOption {
|
||||||
@@ -48,6 +87,8 @@ func NewRouter(defaultModel string, opts ...RouterOption) *Router {
|
|||||||
byRole: map[Role]string{},
|
byRole: map[Role]string{},
|
||||||
defaultProvider: ProviderOpenAI,
|
defaultProvider: ProviderOpenAI,
|
||||||
providerByRole: map[Role]string{},
|
providerByRole: map[Role]string{},
|
||||||
|
personaModel: map[string]string{},
|
||||||
|
personaProvider: map[string]string{},
|
||||||
}
|
}
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
o(r)
|
o(r)
|
||||||
@@ -55,6 +96,10 @@ func NewRouter(defaultModel string, opts ...RouterOption) *Router {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trimKey normalizes a persona id or provider name before it is stored, so
|
||||||
|
// " cyrene" and "cyrene" cannot become two different bindings.
|
||||||
|
func trimKey(k string) string { return strings.TrimSpace(k) }
|
||||||
|
|
||||||
// Route returns the model for a role, falling back to the default model.
|
// Route returns the model for a role, falling back to the default model.
|
||||||
func (r *Router) Route(role Role) string {
|
func (r *Router) Route(role Role) string {
|
||||||
if m, ok := r.byRole[role]; ok && m != "" {
|
if m, ok := r.byRole[role]; ok && m != "" {
|
||||||
@@ -76,10 +121,15 @@ func (r *Router) RouteProvider(role Role) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RouteRequest picks the model for a request and reports whether the request
|
// RouteRequest picks the model for a request and reports whether the request
|
||||||
// needs a vision model. When the request carries image content and a vision
|
// carries image content. When the request carries image content and a vision
|
||||||
// model is configured, the vision model is returned with needsVision set to
|
// model is configured, the vision model is returned with needsVision set to
|
||||||
// true. Otherwise the role of the last message drives routing (user by
|
// true. Otherwise the role of the last message drives routing (user by
|
||||||
// default).
|
// default).
|
||||||
|
//
|
||||||
|
// Deprecated in the production path: the loop routes by persona via
|
||||||
|
// RouteRequestForPersona, and image routing belongs to vision.Router (裁决
|
||||||
|
// 14.4.1/14.4.5). This method stays because it is the ModelRouter contract the
|
||||||
|
// agent package and the existing tests are built on.
|
||||||
func (r *Router) RouteRequest(req Request) (model string, needsVision bool) {
|
func (r *Router) RouteRequest(req Request) (model string, needsVision bool) {
|
||||||
role := RoleUser
|
role := RoleUser
|
||||||
if n := len(req.Messages); n > 0 {
|
if n := len(req.Messages); n > 0 {
|
||||||
@@ -92,6 +142,51 @@ func (r *Router) RouteRequest(req Request) (model string, needsVision bool) {
|
|||||||
return r.Route(role), needsVision
|
return r.Route(role), needsVision
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PersonaRouter is the optional persona-aware routing extension of ModelRouter
|
||||||
|
// (裁决 03.4.1). The loop detects it on the agent's router and prefers it over
|
||||||
|
// RouteRequest, so a per-persona model/provider binding actually reaches the
|
||||||
|
// request. It is a separate interface so a router that only knows message roles
|
||||||
|
// keeps working unchanged.
|
||||||
|
type PersonaRouter interface {
|
||||||
|
RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteForPersona returns the model bound to persona, falling back to the
|
||||||
|
// router's default model. Unlike Route (message role) this is the persona-level
|
||||||
|
// binding: 裁决 03.4.1 defines the "role" of per-role routing as the persona.
|
||||||
|
func (r *Router) RouteForPersona(persona string) string {
|
||||||
|
if m, ok := r.personaModel[trimKey(persona)]; ok && m != "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return r.defaultModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteProviderForPersona returns the provider bound to persona, falling back to
|
||||||
|
// the router's default provider (itself the active group's protocol).
|
||||||
|
func (r *Router) RouteProviderForPersona(persona string) string {
|
||||||
|
if p, ok := r.personaProvider[trimKey(persona)]; ok && p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return r.defaultProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteRequestForPersona routes a request as the given persona: a persona
|
||||||
|
// binding wins over the message-role decision, and a persona without a binding
|
||||||
|
// falls back to RouteRequest so nothing changes for the unconfigured case. It
|
||||||
|
// reports whether the request carries an image, but never switches the model to
|
||||||
|
// the vision model — that decision belongs to vision.Router (裁决 14.4.1/14.4.5).
|
||||||
|
func (r *Router) RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool) {
|
||||||
|
needsVision = HasImage(req.Messages)
|
||||||
|
if m, ok := r.personaModel[trimKey(personaID)]; ok && m != "" {
|
||||||
|
return m, needsVision
|
||||||
|
}
|
||||||
|
role := RoleUser
|
||||||
|
if n := len(req.Messages); n > 0 {
|
||||||
|
role = req.Messages[n-1].Role
|
||||||
|
}
|
||||||
|
return r.Route(role), needsVision
|
||||||
|
}
|
||||||
|
|
||||||
// HasImage reports whether any message carries an image content part.
|
// HasImage reports whether any message carries an image content part.
|
||||||
func HasImage(msgs []Message) bool {
|
func HasImage(msgs []Message) bool {
|
||||||
for _, m := range msgs {
|
for _, m := range msgs {
|
||||||
|
|||||||
@@ -125,6 +125,17 @@ func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) {
|
|||||||
return r.RouteRequest(req)
|
return r.RouteRequest(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RouteRequestForPersona implements PersonaRouter (裁决 03.4.1): the loop routes
|
||||||
|
// a turn as the persona it answers as, and a per-persona binding in the active
|
||||||
|
// models.json group reaches the request through here.
|
||||||
|
func (m *RuntimeLLM) RouteRequestForPersona(personaID string, req Request) (string, bool) {
|
||||||
|
r := m.load().router
|
||||||
|
if r == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return r.RouteRequestForPersona(personaID, req)
|
||||||
|
}
|
||||||
|
|
||||||
// loadInitial builds the first snapshot; a build failure leaves a nil chain so
|
// loadInitial builds the first snapshot; a build failure leaves a nil chain so
|
||||||
// calls report a clear no-model error instead of panicking.
|
// calls report a clear no-model error instead of panicking.
|
||||||
func (m *RuntimeLLM) loadInitial() {
|
func (m *RuntimeLLM) loadInitial() {
|
||||||
@@ -160,11 +171,20 @@ func (m *RuntimeLLM) load() *runtimeChainSnapshot {
|
|||||||
|
|
||||||
// buildSnapshot turns the active group (index 0) into a chain+router. A group
|
// buildSnapshot turns the active group (index 0) into a chain+router. A group
|
||||||
// without a base URL/model yields a nil chain (no model configured), surfaced as
|
// without a base URL/model yields a nil chain (no model configured), surfaced as
|
||||||
// ErrNoModel by the adapter methods. The Router keeps the env vision model so a
|
// ErrNoModel by the adapter methods.
|
||||||
// dedicated vision model survives a reload that only changes the vendor group.
|
//
|
||||||
|
// The env-local rung is copied onto the group's flat config because a models.json
|
||||||
|
// group does not carry it: without that copy the third degradation level
|
||||||
|
// (裁决 03.4.3) would silently disappear as soon as models.json exists.
|
||||||
|
//
|
||||||
|
// The router is seeded with the active group's per-persona bindings
|
||||||
|
// (裁决 03.4.1). It is deliberately NOT given the env vision model: image routing
|
||||||
|
// is owned by vision.Router, and a "has image → switch model" rule here bypassed
|
||||||
|
// that decision (裁决 14.4.1/14.4.5).
|
||||||
func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) (*runtimeChainSnapshot, error) {
|
func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) (*runtimeChainSnapshot, error) {
|
||||||
active := groups[0]
|
active := groups[0]
|
||||||
cfg := active.ToLLMConfig()
|
cfg := active.ToLLMConfig()
|
||||||
|
cfg.Local = env.Local
|
||||||
chain, err := NewChatChainWithLog(cfg, log)
|
chain, err := NewChatChainWithLog(cfg, log)
|
||||||
if err != nil && !errors.Is(err, ErrNoModel) {
|
if err != nil && !errors.Is(err, ErrNoModel) {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -175,9 +195,20 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog
|
|||||||
// last step of a chain that really has a fallback (design/04 §3.1).
|
// last step of a chain that really has a fallback (design/04 §3.1).
|
||||||
chain = chain.WithTokenBudget(active.MaxTokens, env.FallbackMaxTokens)
|
chain = chain.WithTokenBudget(active.MaxTokens, env.FallbackMaxTokens)
|
||||||
}
|
}
|
||||||
router := NewRouter(DefaultModelForProvider(cfg),
|
router := NewRouter(DefaultModelForProvider(cfg), personaRouterOptions(active, cfg)...)
|
||||||
WithVisionModel(env.VisionModel),
|
|
||||||
WithDefaultProvider(cfg.Provider),
|
|
||||||
)
|
|
||||||
return &runtimeChainSnapshot{chain: chain, router: router, group: active, groups: groups}, nil
|
return &runtimeChainSnapshot{chain: chain, router: router, group: active, groups: groups}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// personaRouterOptions turns the active group's per-persona model/provider
|
||||||
|
// bindings into router options (裁决 03.4.1). It is a helper so buildSnapshot
|
||||||
|
// stays inside the 80-line function budget.
|
||||||
|
func personaRouterOptions(active runtimeconfig.Group, cfg config.LLMConfig) []RouterOption {
|
||||||
|
opts := []RouterOption{WithDefaultProvider(cfg.Provider)}
|
||||||
|
for persona, model := range active.PersonaModels {
|
||||||
|
opts = append(opts, WithPersonaModel(persona, model))
|
||||||
|
}
|
||||||
|
for persona, provider := range active.PersonaProviders {
|
||||||
|
opts = append(opts, WithPersonaProvider(persona, provider))
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
@@ -35,9 +35,14 @@ type OCRAdapter interface {
|
|||||||
// endpoint(图像作为内容部件内嵌)同时实现 VisionAdapter 与 OCRAdapter。
|
// endpoint(图像作为内容部件内嵌)同时实现 VisionAdapter 与 OCRAdapter。
|
||||||
// 它是开箱可用的默认实现;真实的
|
// 它是开箱可用的默认实现;真实的
|
||||||
// 云端 vision/OCR 提供方暴露相同协议。
|
// 云端 vision/OCR 提供方暴露相同协议。
|
||||||
|
//
|
||||||
|
// 两个方向可以跑在同一个 endpoint 的两个模型上(裁决 14.4.1 的「独立视觉
|
||||||
|
// 模型 + OCR 模型」):Describe 用 model(LLM_VISION_MODEL),Extract 用
|
||||||
|
// ocrModel(LLM_OCR_MODEL),未配 OCR 模型时回落到同一个模型。
|
||||||
type OpenAICompatibleAdapter struct {
|
type OpenAICompatibleAdapter struct {
|
||||||
cfg config.LLMConfig
|
cfg config.LLMConfig
|
||||||
model string
|
model string
|
||||||
|
ocrModel string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -72,6 +77,16 @@ func WithLogger(l *slog.Logger) OpenAIOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithOCRModel 覆盖 OCR 方向使用的模型(默认取 cfg.OCRModel,再回落
|
||||||
|
// 到 model)。
|
||||||
|
func WithOCRModel(m string) OpenAIOption {
|
||||||
|
return func(a *OpenAICompatibleAdapter) {
|
||||||
|
if m != "" {
|
||||||
|
a.ocrModel = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewOpenAICompatible 用 cfg 构造一个适配器,未覆盖时使用
|
// NewOpenAICompatible 用 cfg 构造一个适配器,未覆盖时使用
|
||||||
// cfg.Model。
|
// cfg.Model。
|
||||||
func NewOpenAICompatible(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAICompatibleAdapter {
|
func NewOpenAICompatible(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAICompatibleAdapter {
|
||||||
@@ -84,6 +99,13 @@ func NewOpenAICompatible(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIComp
|
|||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
o(a)
|
o(a)
|
||||||
}
|
}
|
||||||
|
if a.ocrModel == "" {
|
||||||
|
// LLM_OCR_MODEL 未配置时 OCR 与视觉共用同一个模型(向后兼容)。
|
||||||
|
a.ocrModel = cfg.OCRModel
|
||||||
|
}
|
||||||
|
if a.ocrModel == "" {
|
||||||
|
a.ocrModel = a.model
|
||||||
|
}
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,20 +114,22 @@ func (a *OpenAICompatibleAdapter) Describe(ctx context.Context, img ImageRef, pr
|
|||||||
if prompt == "" {
|
if prompt == "" {
|
||||||
prompt = "Describe this image in detail."
|
prompt = "Describe this image in detail."
|
||||||
}
|
}
|
||||||
return a.ask(ctx, img, prompt)
|
return a.ask(ctx, a.model, img, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract 经同一 endpoint 返回图像中发现的文字。
|
// Extract 经同一 endpoint 返回图像中发现的文字;当配置了独立 OCR 模型
|
||||||
|
// (LLM_OCR_MODEL / WithOCRModel)时用它,否则与视觉模型相同。
|
||||||
func (a *OpenAICompatibleAdapter) Extract(ctx context.Context, img ImageRef) (string, error) {
|
func (a *OpenAICompatibleAdapter) Extract(ctx context.Context, img ImageRef) (string, error) {
|
||||||
return a.ask(ctx, img, "Extract all text visible in this image. Return only the text.")
|
return a.ask(ctx, a.ocrModel, img, "Extract all text visible in this image. Return only the text.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ask 解析图像、构造 chat 请求并返回助手文本。
|
// ask 解析图像、构造 chat 请求并返回助手文本。model 是本次请求使用的模型,
|
||||||
func (a *OpenAICompatibleAdapter) ask(ctx context.Context, img ImageRef, prompt string) (string, error) {
|
// 让两个方向能在同一 endpoint 上跑不同模型。
|
||||||
|
func (a *OpenAICompatibleAdapter) ask(ctx context.Context, model string, img ImageRef, prompt string) (string, error) {
|
||||||
if strings.TrimSpace(a.cfg.BaseURL) == "" {
|
if strings.TrimSpace(a.cfg.BaseURL) == "" {
|
||||||
return "", ErrNotConfigured
|
return "", ErrNotConfigured
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(a.model) == "" {
|
if strings.TrimSpace(model) == "" {
|
||||||
return "", ErrNotConfigured
|
return "", ErrNotConfigured
|
||||||
}
|
}
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
@@ -115,7 +139,7 @@ func (a *OpenAICompatibleAdapter) ask(ctx context.Context, img ImageRef, prompt
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
payload, err := json.Marshal(a.request(imageURL, prompt))
|
payload, err := json.Marshal(a.request(model, imageURL, prompt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("vision: %w: marshal request: %v", ErrUpstream, err)
|
return "", fmt.Errorf("vision: %w: marshal request: %v", ErrUpstream, err)
|
||||||
}
|
}
|
||||||
@@ -187,9 +211,9 @@ type openAIResponseMessage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// request 构造图像 + prompt 的线上请求体。
|
// request 构造图像 + prompt 的线上请求体。
|
||||||
func (a *OpenAICompatibleAdapter) request(imageURL, prompt string) openAIVisionRequest {
|
func (a *OpenAICompatibleAdapter) request(model, imageURL, prompt string) openAIVisionRequest {
|
||||||
return openAIVisionRequest{
|
return openAIVisionRequest{
|
||||||
Model: a.model,
|
Model: model,
|
||||||
Messages: []openAIMessage{
|
Messages: []openAIMessage{
|
||||||
{Role: "user", Content: []openAIPart{
|
{Role: "user", Content: []openAIPart{
|
||||||
{Type: "text", Text: prompt},
|
{Type: "text", Text: prompt},
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// modelRecorder is a /chat/completions endpoint that records the requested model,
|
||||||
|
// so a test can pin which model each direction (Describe / Extract) asked for.
|
||||||
|
type modelRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
models []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *modelRecorder) seen() []string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return append([]string(nil), r.models...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModelRecorderServer(t *testing.T) (*httptest.Server, *modelRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
rec := &modelRecorder{}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
rec.mu.Lock()
|
||||||
|
rec.models = append(rec.models, body.Model)
|
||||||
|
rec.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenAICompatibleAdapter_OCRUsesOwnModel pins the independent OCR model
|
||||||
|
// (裁决 14.4.1 的「独立视觉模型 + OCR 模型」): when LLM_OCR_MODEL is set the OCR
|
||||||
|
// direction sends it, while Describe keeps the vision model.
|
||||||
|
func TestOpenAICompatibleAdapter_OCRUsesOwnModel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, rec := newModelRecorderServer(t)
|
||||||
|
ad := NewOpenAICompatible(config.LLMConfig{
|
||||||
|
BaseURL: srv.URL, Model: "vision-main", OCRModel: "ocr-special",
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, ""); err != nil {
|
||||||
|
t.Fatalf("Describe: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ad.Extract(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}); err != nil {
|
||||||
|
t.Fatalf("Extract: %v", err)
|
||||||
|
}
|
||||||
|
seen := rec.seen()
|
||||||
|
if len(seen) != 2 || seen[0] != "vision-main" || seen[1] != "ocr-special" {
|
||||||
|
t.Fatalf("models seen = %v, want [vision-main ocr-special]", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenAICompatibleAdapter_OCRWithoutOwnModelFallsBack pins the compatibility
|
||||||
|
// edge: without LLM_OCR_MODEL both directions use the vision model.
|
||||||
|
func TestOpenAICompatibleAdapter_OCRWithoutOwnModelFallsBack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, rec := newModelRecorderServer(t)
|
||||||
|
ad := NewOpenAICompatible(config.LLMConfig{BaseURL: srv.URL, Model: "vision-main"})
|
||||||
|
|
||||||
|
if _, err := ad.Extract(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}); err != nil {
|
||||||
|
t.Fatalf("Extract: %v", err)
|
||||||
|
}
|
||||||
|
if seen := rec.seen(); len(seen) != 1 || seen[0] != "vision-main" {
|
||||||
|
t.Fatalf("models seen = %v, want [vision-main]", seen)
|
||||||
|
}
|
||||||
|
// WithOCRModel overrides cfg.OCRModel, so an explicit option still wins.
|
||||||
|
ad2 := NewOpenAICompatible(config.LLMConfig{BaseURL: srv.URL, Model: "vision-main", OCRModel: "cfg-ocr"}, WithOCRModel("opt-ocr"))
|
||||||
|
if _, err := ad2.Extract(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}); err != nil {
|
||||||
|
t.Fatalf("Extract: %v", err)
|
||||||
|
}
|
||||||
|
if seen := rec.seen(); len(seen) != 2 || seen[1] != "opt-ocr" {
|
||||||
|
t.Fatalf("models seen = %v, want the explicit option opt-ocr", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
package vision
|
package vision
|
||||||
|
|
||||||
// Assessment 是模型对自己读图结果的置信度。
|
// Assessment 是模型对自己读图结果的置信度。
|
||||||
|
//
|
||||||
|
// Deprecated placeholder(裁决 14.4.1③/14.4.5②):第二层不再是「结构化自评」,
|
||||||
|
// 而是主模型自己按需调用视觉/OCR 工具(design/06 §0 第 2 层)。因此
|
||||||
|
// SelfAssessment 目前没有任何生产者:主模型要复核读图效果时,直接调用
|
||||||
|
// vision/ocr 工具即可,本包不再要求它吐出一个自评字段。保留该类型只为兼容
|
||||||
|
// 既有调用面;新代码不要依赖它。
|
||||||
type Assessment string
|
type Assessment string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -49,6 +55,11 @@ type Decision struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RouteRequest 携带调用方在模型信息之外给出的路由提示。
|
// RouteRequest 携带调用方在模型信息之外给出的路由提示。
|
||||||
|
//
|
||||||
|
// Deprecated(裁决 14.4.5②):生产没有任何调用方填充这两个提示 —— 图像是转成
|
||||||
|
// 文字还是留给主模型,由主模型的能力声明(models.json multimodal)与它自己
|
||||||
|
// 按需调用视觉/OCR 工具决定,不再靠调用方猜「这张图主要是文字」。字段保留
|
||||||
|
// 只为兼容既有测试与调用面。
|
||||||
type RouteRequest struct {
|
type RouteRequest struct {
|
||||||
// TextPrimary 表示用户指出图像以文字为主,因此
|
// TextPrimary 表示用户指出图像以文字为主,因此
|
||||||
// 优先使用 OCR 工具而非通用视觉模型。
|
// 优先使用 OCR 工具而非通用视觉模型。
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package io
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/draw"
|
||||||
|
_ "image/gif" // register the GIF decoder for image.Decode
|
||||||
|
_ "image/jpeg" // register the JPEG decoder for image.Decode
|
||||||
|
"image/png"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time assertion that cropTool implements sdk.Tool.
|
||||||
|
var _ sdk.Tool = (*cropTool)(nil)
|
||||||
|
|
||||||
|
// Crop output limits. An upstream vision/OCR service downsamples a large image
|
||||||
|
// before it sees it (裁决 14.4.5③), so the way to read a region in full detail is
|
||||||
|
// to crop it and re-read the crop; these caps keep a modelled crop request from
|
||||||
|
// turning into an unbounded allocation.
|
||||||
|
const (
|
||||||
|
// maxCropSourceBytes caps the decoded source image body.
|
||||||
|
maxCropSourceBytes = 50 << 20
|
||||||
|
// maxCropPixels caps the requested region's pixel count (~16 MP).
|
||||||
|
maxCropPixels = 4096 * 4096
|
||||||
|
// cropDataURIMax is the largest crop payload the tool will return inline. A
|
||||||
|
// bigger crop is rejected rather than silently truncated: the model must
|
||||||
|
// narrow the region, not receive a half image it cannot describe.
|
||||||
|
cropDataURIMax = 8 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// cropTool crops an axis-aligned region out of an image and returns it as a
|
||||||
|
// base64 data URI, so the agent can re-read a zoomed region of a large image with
|
||||||
|
// the vision or OCR tool (裁决 14.4.5③:「图片按区域裁切」,供模型自行裁大图分区
|
||||||
|
// 精读). Path inputs go through the file tool's sandbox — this tool never widens
|
||||||
|
// file access; URL inputs use the shared SSRF-guarded client.
|
||||||
|
type cropTool struct {
|
||||||
|
file *fileTool
|
||||||
|
client *http.Client
|
||||||
|
allowedHosts []string
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCropTool(t *tools, file *fileTool) *cropTool {
|
||||||
|
return &cropTool{
|
||||||
|
file: file,
|
||||||
|
client: t.client,
|
||||||
|
allowedHosts: t.allowedHosts,
|
||||||
|
log: t.log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name implements sdk.Tool.
|
||||||
|
func (c *cropTool) Name() string { return "image-crop" }
|
||||||
|
|
||||||
|
// Description implements sdk.Tool. It names the downsampling reason, because the
|
||||||
|
// model decides whether to crop from this text alone.
|
||||||
|
func (c *cropTool) Description() string {
|
||||||
|
return "Crop a rectangular region out of an image and return the crop (PNG data URI) plus its size. " +
|
||||||
|
"Use it to read a large image in parts: an image service downsamples big images, so cropping a region " +
|
||||||
|
"and re-reading the crop recovers the detail. Coordinates are 0-based pixels from the top-left."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameters implements sdk.Tool.
|
||||||
|
func (c *cropTool) Parameters() (map[string]any, error) {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"image": map[string]any{"type": "string", "description": "The image: a local path, an http(s) URL, or a base64/data: URI."},
|
||||||
|
"x": map[string]any{"type": "integer", "description": "Left edge of the region, in pixels (0-based)."},
|
||||||
|
"y": map[string]any{"type": "integer", "description": "Top edge of the region, in pixels (0-based)."},
|
||||||
|
"width": map[string]any{"type": "integer", "description": "Region width in pixels."},
|
||||||
|
"height": map[string]any{"type": "integer", "description": "Region height in pixels."},
|
||||||
|
},
|
||||||
|
"required": []any{"image", "x", "y", "width", "height"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute implements sdk.Tool.
|
||||||
|
func (c *cropTool) Execute(ctx context.Context, args any) (any, error) {
|
||||||
|
m, ok := args.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: args must be an object")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
src := argString(m, "image")
|
||||||
|
if strings.TrimSpace(src) == "" {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: image is required")
|
||||||
|
}
|
||||||
|
region, err := cropRegion(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := c.load(ctx, src)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: decode image: %w", err)
|
||||||
|
}
|
||||||
|
bounds := img.Bounds()
|
||||||
|
if err := checkRegion(region, bounds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cropped := cropToRGBA(img, region)
|
||||||
|
payload, err := encodePNG(cropped)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.log.Debug("io image-crop: cropped region",
|
||||||
|
"source_width", bounds.Dx(), "source_height", bounds.Dy(),
|
||||||
|
"x", region.Min.X, "y", region.Min.Y,
|
||||||
|
"width", region.Dx(), "height", region.Dy(), "bytes", len(payload))
|
||||||
|
return map[string]any{
|
||||||
|
"image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(payload),
|
||||||
|
"format": "image/png",
|
||||||
|
"width": region.Dx(),
|
||||||
|
"height": region.Dy(),
|
||||||
|
"region": map[string]any{"x": region.Min.X, "y": region.Min.Y, "width": region.Dx(), "height": region.Dy()},
|
||||||
|
"source": map[string]any{"width": bounds.Dx(), "height": bounds.Dy()},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cropRegion reads and validates the region arguments. A non-positive width or
|
||||||
|
// height, or a negative offset, is rejected (never clamped: a clamped crop would
|
||||||
|
// silently hand the model a different region than it asked for).
|
||||||
|
func cropRegion(m map[string]any) (image.Rectangle, error) {
|
||||||
|
x, ok := argInt(m, "x")
|
||||||
|
if !ok {
|
||||||
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: x must be an integer")
|
||||||
|
}
|
||||||
|
y, ok := argInt(m, "y")
|
||||||
|
if !ok {
|
||||||
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: y must be an integer")
|
||||||
|
}
|
||||||
|
w, ok := argInt(m, "width")
|
||||||
|
if !ok {
|
||||||
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: width must be an integer")
|
||||||
|
}
|
||||||
|
h, ok := argInt(m, "height")
|
||||||
|
if !ok {
|
||||||
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: height must be an integer")
|
||||||
|
}
|
||||||
|
if x < 0 || y < 0 {
|
||||||
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: x and y must be >= 0, got (%d,%d)", x, y)
|
||||||
|
}
|
||||||
|
if w <= 0 || h <= 0 {
|
||||||
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: width and height must be > 0, got (%d,%d)", w, h)
|
||||||
|
}
|
||||||
|
if int64(w)*int64(h) > maxCropPixels {
|
||||||
|
return image.Rectangle{}, fmt.Errorf("io: image-crop: region of %dx%d exceeds the %d-pixel limit", w, h, maxCropPixels)
|
||||||
|
}
|
||||||
|
return image.Rect(x, y, x+w, y+h), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRegion rejects a region that is not fully inside the image.
|
||||||
|
func checkRegion(r image.Rectangle, bounds image.Rectangle) error {
|
||||||
|
if !r.In(bounds) {
|
||||||
|
return fmt.Errorf("io: image-crop: region %v is outside the image %v", r, bounds)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cropToRGBA copies the region into a fresh RGBA image so the result does not
|
||||||
|
// alias the (possibly huge) source and always re-encodes losslessly.
|
||||||
|
func cropToRGBA(img image.Image, r image.Rectangle) *image.RGBA {
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
|
||||||
|
draw.Draw(dst, dst.Bounds(), img, r.Min, draw.Src)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodePNG encodes the crop, refusing a payload above the inline cap so a huge
|
||||||
|
// crop is reported instead of flooding the tool result.
|
||||||
|
func encodePNG(img image.Image) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: encode crop: %w", err)
|
||||||
|
}
|
||||||
|
if buf.Len() > cropDataURIMax {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: crop is %d bytes, above the %d-byte inline limit; crop a smaller region", buf.Len(), cropDataURIMax)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// load reads the source image: a local path through the file sandbox, an http(s)
|
||||||
|
// URL through the shared SSRF-guarded client, or a base64/data: URI payload.
|
||||||
|
func (c *cropTool) load(ctx context.Context, src string) ([]byte, error) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(src, "data:"):
|
||||||
|
return decodeCropDataURI(src)
|
||||||
|
case strings.HasPrefix(src, "http://"), strings.HasPrefix(src, "https://"):
|
||||||
|
return c.fetch(ctx, src)
|
||||||
|
default:
|
||||||
|
return c.readFile(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCropDataURI decodes a "data:<mime>;base64,<payload>" source.
|
||||||
|
func decodeCropDataURI(src string) ([]byte, error) {
|
||||||
|
comma := strings.IndexByte(src, ',')
|
||||||
|
if comma < 0 || !strings.Contains(src[:comma], ";base64") {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: unsupported data URI (expected base64)")
|
||||||
|
}
|
||||||
|
data, err := base64.StdEncoding.DecodeString(src[comma+1:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: decode data URI: %w", err)
|
||||||
|
}
|
||||||
|
if len(data) > maxCropSourceBytes {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: source exceeds the %d-byte limit", maxCropSourceBytes)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFile reads a path through the file tool's sandbox so cropping can never
|
||||||
|
// reach a file the file tool could not read (裁决 13.4.4:文件沙箱唯一实现处).
|
||||||
|
func (c *cropTool) readFile(p string) ([]byte, error) {
|
||||||
|
if c.file == nil {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: no sandbox configured")
|
||||||
|
}
|
||||||
|
real, err := c.file.resolveExisting(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fh, err := os.Open(real)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: open: %w", err)
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
data, truncated, err := readLimited(fh, maxCropSourceBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: read: %w", err)
|
||||||
|
}
|
||||||
|
if truncated {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: source exceeds the %d-byte limit", maxCropSourceBytes)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch downloads an http(s) source through the shared client, applying the same
|
||||||
|
// SSRF guard as the other network tools.
|
||||||
|
func (c *cropTool) fetch(ctx context.Context, rawURL string) ([]byte, error) {
|
||||||
|
if c.client == nil {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: no http client configured")
|
||||||
|
}
|
||||||
|
if _, err := validateRequestURL(ctx, rawURL, c.allowedHosts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: build request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: fetch: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: fetch: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, truncated, err := readLimited(resp.Body, maxCropSourceBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: read response: %w", err)
|
||||||
|
}
|
||||||
|
if truncated {
|
||||||
|
return nil, fmt.Errorf("io: image-crop: source exceeds the %d-byte limit", maxCropSourceBytes)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package io
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gradientPNG writes an image whose pixel (x,y) encodes its own coordinates in the
|
||||||
|
// red/green channels. That makes a crop self-verifying: if the crop's top-left
|
||||||
|
// pixel is not RGBA{sourceX, sourceY, 0, 255} the wrong region was returned.
|
||||||
|
func gradientPNG(t *testing.T, w, h int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
t.Fatalf("png.Encode: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCropResult pulls the PNG out of a crop tool result and decodes it.
|
||||||
|
func decodeCropResult(t *testing.T, result any) image.Image {
|
||||||
|
t.Helper()
|
||||||
|
m, ok := result.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("result type = %T, want map[string]any", result)
|
||||||
|
}
|
||||||
|
uri, _ := m["image"].(string)
|
||||||
|
if !strings.HasPrefix(uri, "data:image/png;base64,") {
|
||||||
|
t.Fatalf("image field = %q, want a PNG data URI", uri)
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(uri, "data:image/png;base64,"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode data URI: %v", err)
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode crop: %v", err)
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCropToolForTest(t *testing.T, extra ...Option) (*cropTool, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
set := buildTools(t, &config.Config{DataDir: dir}, extra...)
|
||||||
|
tool, ok := findTool(t, set, "image-crop").(*cropTool)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("image-crop tool has type %T, want *cropTool", findTool(t, set, "image-crop"))
|
||||||
|
}
|
||||||
|
return tool, dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCropTool_CropsRequestedRegion pins the region arithmetic: the returned crop
|
||||||
|
// must be exactly the requested region with the requested pixels, not merely the
|
||||||
|
// right size (裁决 14.4.5③).
|
||||||
|
func TestCropTool_CropsRequestedRegion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tool, dir := newCropToolForTest(t)
|
||||||
|
path := filepath.Join(dir, "src.png")
|
||||||
|
if err := os.WriteFile(path, gradientPNG(t, 100, 80), filePerm); err != nil {
|
||||||
|
t.Fatalf("write source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"image": path, "x": 10, "y": 20, "width": 30, "height": 40,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
img := decodeCropResult(t, res)
|
||||||
|
if img.Bounds().Dx() != 30 || img.Bounds().Dy() != 40 {
|
||||||
|
t.Fatalf("crop size = %dx%d, want 30x40", img.Bounds().Dx(), img.Bounds().Dy())
|
||||||
|
}
|
||||||
|
got := color.NRGBAModel.Convert(img.At(0, 0)).(color.NRGBA)
|
||||||
|
if got.R != 10 || got.G != 20 {
|
||||||
|
t.Fatalf("top-left pixel = %v, want the source pixel (10,20)", got)
|
||||||
|
}
|
||||||
|
last := color.NRGBAModel.Convert(img.At(29, 39)).(color.NRGBA)
|
||||||
|
if last.R != 39 || last.G != 59 {
|
||||||
|
t.Fatalf("bottom-right pixel = %v, want the source pixel (39,59)", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCropTool_RejectsRegionOutsideImage pins fail-loud instead of clamping: a
|
||||||
|
// clamp would hand the model a different region than it asked for.
|
||||||
|
func TestCropTool_RejectsRegionOutsideImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tool, dir := newCropToolForTest(t)
|
||||||
|
path := filepath.Join(dir, "src.png")
|
||||||
|
if err := os.WriteFile(path, gradientPNG(t, 50, 50), filePerm); err != nil {
|
||||||
|
t.Fatalf("write source: %v", err)
|
||||||
|
}
|
||||||
|
cases := []map[string]any{
|
||||||
|
{"image": path, "x": 40, "y": 0, "width": 20, "height": 10},
|
||||||
|
{"image": path, "x": 0, "y": 40, "width": 10, "height": 20},
|
||||||
|
{"image": path, "x": -1, "y": 0, "width": 10, "height": 10},
|
||||||
|
{"image": path, "x": 0, "y": 0, "width": 0, "height": 10},
|
||||||
|
{"image": path, "x": 0, "y": 0, "width": 10, "height": -5},
|
||||||
|
}
|
||||||
|
for _, args := range cases {
|
||||||
|
if _, err := tool.Execute(context.Background(), args); err == nil {
|
||||||
|
t.Errorf("args %v accepted, want an out-of-bounds error", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCropTool_RejectsPathOutsideSandbox pins the file sandbox reuse: cropping can
|
||||||
|
// never reach a file the file tool could not read.
|
||||||
|
func TestCropTool_RejectsPathOutsideSandbox(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tool, _ := newCropToolForTest(t)
|
||||||
|
outside := filepath.Join(t.TempDir(), "outside.png")
|
||||||
|
if err := os.WriteFile(outside, gradientPNG(t, 10, 10), filePerm); err != nil {
|
||||||
|
t.Fatalf("write outside image: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"image": outside, "x": 0, "y": 0, "width": 2, "height": 2,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("path outside the data dir accepted, want a sandbox rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCropTool_AcceptsDataURIAndURL pins the other two sources: a data: URI and an
|
||||||
|
// http(s) URL both decode into a crop.
|
||||||
|
func TestCropTool_AcceptsDataURIAndURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
src := gradientPNG(t, 20, 20)
|
||||||
|
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(src)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write(src)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
tool, _ := newCropToolForTest(t, WithAllowedHosts(serverHost(srv)))
|
||||||
|
cases := []string{uri, srv.URL + "/img.png"}
|
||||||
|
for _, source := range cases {
|
||||||
|
res, err := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"image": source, "x": 5, "y": 5, "width": 4, "height": 3,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute(%s): %v", source, err)
|
||||||
|
}
|
||||||
|
if img := decodeCropResult(t, res); img.Bounds().Dx() != 4 || img.Bounds().Dy() != 3 {
|
||||||
|
t.Fatalf("source %s crop size = %dx%d, want 4x3", source, img.Bounds().Dx(), img.Bounds().Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCropTool_RejectsUnsupportedDataURI pins a non-base64 data URI as an error
|
||||||
|
// rather than a silent empty decode.
|
||||||
|
func TestCropTool_RejectsUnsupportedDataURI(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tool, _ := newCropToolForTest(t)
|
||||||
|
if _, err := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"image": "data:image/png,plaintext", "x": 0, "y": 0, "width": 1, "height": 1,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("non-base64 data URI accepted, want an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCropTool_RegisteredInSet pins that the tool ships with the default set and
|
||||||
|
// advertises a usable schema (the agent only sees it if it is registered).
|
||||||
|
func TestCropTool_RegisteredInSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tool, _ := newCropToolForTest(t)
|
||||||
|
if tool.Name() != "image-crop" {
|
||||||
|
t.Errorf("Name = %q, want image-crop", tool.Name())
|
||||||
|
}
|
||||||
|
params, err := tool.Parameters()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parameters: %v", err)
|
||||||
|
}
|
||||||
|
if params["type"] != "object" {
|
||||||
|
t.Errorf("schema type = %v, want object", params["type"])
|
||||||
|
}
|
||||||
|
var _ sdk.Tool = tool
|
||||||
|
// A nil/mis-typed argument set must be an error, not a panic.
|
||||||
|
if _, err := tool.Execute(context.Background(), "not-a-map"); err == nil {
|
||||||
|
t.Error("non-object args accepted, want an error")
|
||||||
|
}
|
||||||
|
if _, err := tool.Execute(context.Background(), map[string]any{}); err == nil {
|
||||||
|
t.Error("missing image accepted, want an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Package io implements the built-in I/O and network tools for Cyrene v2
|
// Package io implements the built-in I/O and network tools for Cyrene v2
|
||||||
// (DevDocs/design/09-插件生态.md). It exposes file, http, web-search, web-fetch
|
// (DevDocs/design/09-插件生态.md). It exposes file, image-crop, http, web-search,
|
||||||
// and knowledge as pkg/plugins/sdk.Tool values, wired to the process-internal
|
// web-fetch and knowledge as pkg/plugins/sdk.Tool values, wired to the
|
||||||
// config, logger and runtime/memory retriever.
|
// process-internal config, logger and runtime/memory retriever.
|
||||||
//
|
//
|
||||||
// Build the full configured tool set with New(cfg, opts...). Tools() and
|
// Build the full configured tool set with New(cfg, opts...). Tools() and
|
||||||
// Register(reg) are convenience wrappers that use platform defaults; production
|
// Register(reg) are convenience wrappers that use platform defaults; production
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
// - file: every path is cleaned and validated to stay inside the configured
|
// - file: every path is cleaned and validated to stay inside the configured
|
||||||
// data directory or an explicit whitelist; ".." traversal and symlink escape
|
// data directory or an explicit whitelist; ".." traversal and symlink escape
|
||||||
// are rejected; reads and writes are size-capped.
|
// are rejected; reads and writes are size-capped.
|
||||||
|
// - image-crop: crops a region out of a large image so the agent can read it in
|
||||||
|
// parts (an image service downsamples big images); path sources go through the
|
||||||
|
// file sandbox, URL sources through the SSRF guard, and the returned data URI
|
||||||
|
// is size-capped.
|
||||||
// - http: GET/POST only, timeouts, response truncation and SSRF protection
|
// - http: GET/POST only, timeouts, response truncation and SSRF protection
|
||||||
// (loopback/private/link-local addresses and localhost are refused unless
|
// (loopback/private/link-local addresses and localhost are refused unless
|
||||||
// the host is on an explicit allowlist).
|
// the host is on an explicit allowlist).
|
||||||
|
|||||||
@@ -202,8 +202,10 @@ func Register(reg sdk.Registrar) error {
|
|||||||
|
|
||||||
// build assembles the tools sharing this set's client, limits and logger.
|
// build assembles the tools sharing this set's client, limits and logger.
|
||||||
func (t *tools) build() []sdk.Tool {
|
func (t *tools) build() []sdk.Tool {
|
||||||
|
file := newFileTool(t)
|
||||||
return []sdk.Tool{
|
return []sdk.Tool{
|
||||||
newFileTool(t),
|
file,
|
||||||
|
newCropTool(t, file),
|
||||||
newHTTPTool(t),
|
newHTTPTool(t),
|
||||||
newWebSearchTool(t),
|
newWebSearchTool(t),
|
||||||
newWebFetchTool(t),
|
newWebFetchTool(t),
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTools_ReturnsAllFiveTools(t *testing.T) {
|
func TestTools_ReturnsEveryTool(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
set, err := New(&config.Config{DataDir: t.TempDir()})
|
set, err := New(&config.Config{DataDir: t.TempDir()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
}
|
}
|
||||||
if len(set) != 5 {
|
if len(set) != 6 {
|
||||||
t.Fatalf("expected 5 tools, got %d", len(set))
|
t.Fatalf("expected 6 tools, got %d", len(set))
|
||||||
}
|
}
|
||||||
want := []string{"file", "http", "web-search", "web-fetch", "knowledge"}
|
want := []string{"file", "image-crop", "http", "web-search", "web-fetch", "knowledge"}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, tool := range set {
|
for _, tool := range set {
|
||||||
seen[tool.Name()] = true
|
seen[tool.Name()] = true
|
||||||
@@ -53,7 +53,7 @@ func TestRegister_RegistersEveryTool(t *testing.T) {
|
|||||||
if err := Register(reg); err != nil {
|
if err := Register(reg); err != nil {
|
||||||
t.Fatalf("Register: %v", err)
|
t.Fatalf("Register: %v", err)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"file", "http", "web-search", "web-fetch", "knowledge"} {
|
for _, name := range []string{"file", "image-crop", "http", "web-search", "web-fetch", "knowledge"} {
|
||||||
c, err := reg.Get(name)
|
c, err := reg.Get(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get(%q): %v", name, err)
|
t.Fatalf("Get(%q): %v", name, err)
|
||||||
|
|||||||
Reference in new issue
Block a user