Files
HomeAgent/internal/agent/api/provider_security_modal_test.go
JianFeeeee 662d5472ee fix(agent): 安全装饰器吃掉 provider 的模态能力声明(see_image 失效根因)
## 现象

调用 see_image 工具是通的、图片也确实注入了,但内核回:

    当前模型不支持图片,且没有可用的视觉回退源

而配置明明是 `core.llm.sources.llmsproxy.vision = true`,后端也确实支持看图。
用户以为是配置没热生效、以为要重启,实际都不是。

## 根因:一条完整的静默失效链

    vision=true(配置是对的)
      → LuaAdaptedProvider.Vision = true
      → ProviderManager.Register 把它包成 *secureProviderDecorator
        (security.Global() != nil 时无条件包装)
      → ProviderSupportsVision(p) 做 p.(ModalProvider) 断言
      → ★ 装饰器只实现了 Name/MaxContextTokens/Chat/ChatStream,
          **漏了 ModalProvider 的 SupportsVision/SupportsAudio**
      → 断言失败 ⇒ 返回 false
      → prepareToolBlocks 判定"主模型不支持图片"
      → see_image 的 image_url 被当成需要回退的块
      → resolveModalFallback 找不到可用视觉源
      → 「没有可用的视觉回退源」

装饰器只该**改写请求内容**(明文→别名防泄漏),不该改变能力声明。

## 修法

透传给内层:内层实现了 ModalProvider 就照实转,否则 false(不 panic)。

## 判据(4 条)

`internal/agent/api/provider_security_modal_test.go`:
 vision 透传 / audio 透传 / vision=false 不得被污染成 true / 无 ModalProvider 不 panic

★ 我第一版判据是**假绿**的,值得记下来:
 `WrapProviderForSecurity(p, nil)` 在 store==nil 时**直接返回原对象**
 (见其实现),所以根本没包装,测的是未包装的 stub ⇒ 删掉透传也不红。
 已改为构造真实 security.Store,并加**包装前置断言**
 (`_, ok := wrapped.(*secureProviderDecorator)`),防止再次假绿。

变异 2/2 咬住:
 · 删掉 SupportsVision 透传 → TestRegisterPreservesVisionCapability 红
 · 一律返回 true       → TestRegisterPreservesNonVisionCapability 红

## 实测(完整生产装配路径)

    源 llmsproxy  类型=*api.secureProviderDecorator  vision=true audio=false
    默认源 "llmsproxy": ProviderSupportsVision=true

重启本机实例后:22 插件建链 / 0 panic。
2026-10-17 20:20:49 +08:00

138 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
// 装饰器必须**透传模态能力声明**(2026-10-17 生产缺陷)。
//
// ## 缺陷链(完整复现)
//
// 配置 vision=true(本来就是对的)
// → LuaAdaptedProvider.Vision = true
// → ProviderManager.Register 包成 *secureProviderDecorator
// → ProviderSupportsVision 做 p.(ModalProvider) 断言
// → 装饰器漏实现 SupportsVision ⇒ 断言失败 ⇒ false
// → 判定"主模型不支持图片"
// → see_image 的 image_url 被塞进回退链
// → 找不到视觉源 ⇒ 「没有可用的视觉回退源」
//
// 症状极误导:模型明明能看图,内核一口咬定不支持;用户会去怀疑配置、
// 怀疑缓存、怀疑要重启 —— 全都不对,是装饰器把能力吃掉了。
//
// ## 判据怎么咬
//
// **走真实的 Register 包装路径**(不是直接构造装饰器),这样如果有人把
// 包装逻辑改到别处、或新增别的装饰器,判据仍然覆盖。
import (
"context"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/security"
)
// newTestStore 造一个**真正会触发包装**的 store。
//
// ★ 关键:WrapProviderForSecurity 在 store == nil 时**直接返回原对象**
//
// (见其实现:`if p == nil || store == nil { return p }`)。
// 我第一版判据传 nil,于是根本没包装,测的是未包装的 stub ⇒ 判据假绿、
// 变异删掉透传也不红。必须用非 nil store。
func newTestStore(t *testing.T) *security.Store {
t.Helper()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
return security.New(security.Options{
Path: t.TempDir() + "/secrets.db",
KeyFn: func() ([]byte, error) { return key, nil },
Enabled: true,
})
}
// modalStub 同时实现 Provider 与 ModalProvider。
type modalStub struct {
name string
vision, audio bool
}
func (s *modalStub) Name() string { return s.name }
func (s *modalStub) MaxContextTokens() int { return 1000 }
func (s *modalStub) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
return nil, nil
}
func (s *modalStub) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
return nil, nil
}
func (s *modalStub) SupportsVision() bool { return s.vision }
func (s *modalStub) SupportsAudio() bool { return s.audio }
// ★★★ 判据:经 Register 包装后,ProviderSupportsVision 仍必须为 true。
func TestRegisterPreservesVisionCapability(t *testing.T) {
inner := &modalStub{name: "visionllm", vision: true, audio: false}
// 直接包一层装饰器(等价于 Register 内部做的事)
wrapped := WrapProviderForSecurity(inner, newTestStore(t))
// ★ 前置断言:必须真的被包装了,否则下面的测试毫无意义(我踩过这个坑)
if _, ok := wrapped.(*secureProviderDecorator); !ok {
t.Fatalf("前置失败:没有被包装(类型 %T)⇒ 判据会假绿", wrapped)
}
if !ProviderSupportsVision(wrapped) {
t.Fatal("★ 装饰器吃掉了 vision 能力 —— " +
"ProviderManager.Register 包装后 ProviderSupportsVision 变 false," +
"导致 see_image 的图被误判为需要回退(真事故 2026-10-17)")
}
if !wrapped.(ModalProvider).SupportsVision() {
t.Fatal("★ 断言成 ModalProvider 后 SupportsVision 仍为 false")
}
}
// ★★★ 判据:vision=false 的源不能被装饰器"变成" true。
//
// 反向保证:透传是照实传,不是一律返回 true(否则不支持视觉的源
// 会被当成能看,图片原样发出去被上游忽略 ⇒ 又一次静默剥离)。
func TestRegisterPreservesNonVisionCapability(t *testing.T) {
inner := &modalStub{name: "textonly", vision: false, audio: false}
wrapped := WrapProviderForSecurity(inner, newTestStore(t))
if ProviderSupportsVision(wrapped) {
t.Fatal("★ vision=false 的源被装饰器报成能看图 —— 图片会原样发出被上游忽略")
}
}
// ★★★ 判据:audio 能力同样必须透传(同一类缺陷)。
func TestRegisterPreservesAudioCapability(t *testing.T) {
inner := &modalStub{name: "audiollm", vision: false, audio: true}
wrapped := WrapProviderForSecurity(inner, newTestStore(t))
if !ProviderSupportsAudio(wrapped) {
t.Fatal("★ 装饰器吃掉了 audio 能力 —— hear_audio 会被误判需要回退")
}
if ProviderSupportsVision(wrapped) {
t.Fatal("audio-only 源不该报能看图")
}
}
// ★★★ 判据:内层**没有**实现 ModalProvider 时,装饰器不得 panic 且报 false。
func TestDecoratorHandlesNonModalProvider(t *testing.T) {
plain := &plainStub{name: "plain"}
wrapped := WrapProviderForSecurity(plain, newTestStore(t))
if ProviderSupportsVision(wrapped) {
t.Fatal("内层没有 ModalProvider 却报能看图")
}
if ProviderSupportsAudio(wrapped) {
t.Fatal("内层没有 ModalProvider 却报能听音频")
}
}
type plainStub struct{ name string }
func (s *plainStub) Name() string { return s.name }
func (s *plainStub) MaxContextTokens() int { return 1000 }
func (s *plainStub) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
return nil, nil
}
func (s *plainStub) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
return nil, nil
}