fix(vision): 服务端自己取回图片 URL 并内联为 data URI,修自部署下取不到图(F3,优先级「高」)
- 原缺陷:vision 适配器把 http(s) URL **原样转给上游 provider**,而 upload 返回的是同源局域网地址 ⇒ 云 provider 根本取不到 ⇒ **B1(大图绕开 WS 4096 字节上限)在自部署形态下等于没做** - 修法:① `imageURL` → `localizeRef`:**彻底不再直传 URL**;同源 upload 产物走**本地读取**, 其余 http(s) 由服务端自己取回 → 一律内联为 `data:` URI 再交 provider(OpenAI 与 Anthropic 双协议都能收图) - 顺手修「类型瞎猜」:新增 `vision/mime.go`,**字节嗅探是唯一裁决**——判得出白名单光栅图就用字节结果; 字节是文本(HTML/PROXY 错误页、SVG)→ `ErrNotImage`;判不定但来源明确声明 `image/*` 才采信声明。 错误信息只含类型名,**不含响应体/URL**(不打印图片内容) - 防护:取回复用既有 http/https 白名单 + 50MiB 上限,新增 **30s 取回超时**; 本地读取路径 `localUploadPath` 严格认定 `<host>/api/v1/files/<name>`(无 query/fragment、单段路径)+ 32 位小写十六进制名 + `filepath.Dir` 包含性第二道 ⇒ `../`、`%2e%2e%2f`、反斜杠等编码变体在触碰 FS 前即拒 - 生产接线(主会话补):`cmd/cyrene/vision.go` 传 `vision.WithUploadsDir(<DataDir>/uploads)` - 复核:build/vet 绿;新增 51 条断言(含 httptest 真取回 → 断言上游收到 data URI 且不含源 URL、 同源 upload 用不可达 host 证明走本地、HTML 谎报 image/png 被字节否决、越界名 19 例); **3 条变异反证**(恢复 URL 直传 8 红 / 文本静默回退 png 红 / 去掉穿越检查 12 红)均已还原 - 未做(已登记):历史里 URL 形态图片每轮重新取回(缓解=持久化时内联/缓存); Anthropic 只认 png/jpeg/gif/webp 而白名单含 bmp;`fetchURL` 无 IP 过滤(链路本地 169.254.169.254 可达)→ 需收紧
This commit is contained in:
1 parent
88d7dd0026
commit
983bc49218
11 files changed
+971
-55
No files matched your search
@@ -40,11 +40,12 @@ type OCRAdapter interface {
|
||||
// 模型 + OCR 模型」):Describe 用 model(LLM_VISION_MODEL),Extract 用
|
||||
// ocrModel(LLM_OCR_MODEL),未配 OCR 模型时回落到同一个模型。
|
||||
type OpenAICompatibleAdapter struct {
|
||||
cfg config.LLMConfig
|
||||
model string
|
||||
ocrModel string
|
||||
client *http.Client
|
||||
log *slog.Logger
|
||||
cfg config.LLMConfig
|
||||
model string
|
||||
ocrModel string
|
||||
client *http.Client
|
||||
log *slog.Logger
|
||||
uploadsDir string
|
||||
}
|
||||
|
||||
// OpenAIOption 用于定制 OpenAICompatibleAdapter。
|
||||
@@ -87,6 +88,18 @@ func WithOCRModel(m string) OpenAIOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithUploadsDir 配置本机上传目录(<DataDir>/uploads,即网关静态路由
|
||||
// /api/v1/files/ 的根),使指向本服务的上传 URL 直接走本地文件读取,
|
||||
// 而不是绕一圈网络回自己的 HTTP 路由。留空时上传 URL 按普通 URL 取回,
|
||||
// 行为与配置前一致。
|
||||
func WithUploadsDir(dir string) OpenAIOption {
|
||||
return func(a *OpenAICompatibleAdapter) {
|
||||
if d := strings.TrimSpace(dir); d != "" {
|
||||
a.uploadsDir = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewOpenAICompatible 用 cfg 构造一个适配器,未覆盖时使用
|
||||
// cfg.Model。
|
||||
func NewOpenAICompatible(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAICompatibleAdapter {
|
||||
@@ -163,19 +176,40 @@ func (a *OpenAICompatibleAdapter) ask(ctx context.Context, model string, img Ima
|
||||
return a.decode(resp.Body)
|
||||
}
|
||||
|
||||
// imageURL 把 ImageRef 转换为线上格式期望的 image_url 取值:http(s)
|
||||
// URL 原样透传,其余一律转为 base64 data: URI。
|
||||
// imageURL 返回线上 image_url 的取值。无论来源是 http(s) URL、本地路径
|
||||
// 还是 base64,送上游的字节一律由服务端自己取回并内联为 data: URI:
|
||||
// 把同源(局域网)URL 原样透传给云 provider,对方根本取不到,
|
||||
// 自部署形态下视觉链路会整条不通(B1 上传大图的实际意义也就落空)。
|
||||
// URL 取回沿用 Resolve→fetchURL 的既有守卫(http/https 白名单 + 50MiB
|
||||
// 体积上限),并加取回超时(imageFetchTimeout)。
|
||||
func (a *OpenAICompatibleAdapter) imageURL(ctx context.Context, img ImageRef) (string, error) {
|
||||
if img.Source == SourceURL {
|
||||
return img.Data, nil
|
||||
ref := a.localizeRef(img)
|
||||
if ref.Source == SourceURL {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, imageFetchTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
mime, data, err := Resolve(ctx, img, a.client)
|
||||
mime, data, err := Resolve(ctx, ref, a.client)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return DataURI(mime, data), nil
|
||||
}
|
||||
|
||||
// localizeRef 把指向本服务上传路由的 URL 改写成 uploads 目录下的本地路径。
|
||||
// uploadsDir 未配置、或 URL 不是本服务的上传形状时原样返回(此时仍会按
|
||||
// 普通 URL 取回,因此行为不退化);文件缺失等读取错误由 Resolve 如实报告。
|
||||
func (a *OpenAICompatibleAdapter) localizeRef(img ImageRef) ImageRef {
|
||||
if img.Source != SourceURL || a.uploadsDir == "" {
|
||||
return img
|
||||
}
|
||||
path, ok := localUploadPath(img.Data, a.uploadsDir)
|
||||
if !ok {
|
||||
return img
|
||||
}
|
||||
return ImageRef{Source: SourcePath, Data: path}
|
||||
}
|
||||
|
||||
// openAIVisionRequest 是发往 /chat/completions 的 JSON 请求体。
|
||||
type openAIVisionRequest struct {
|
||||
Model string `json:"model"`
|
||||
|
||||
Reference in new issue
Block a user