feat(translate): handle canonical.ImageBlock → Anthropic image content

Picks the most efficient source shape available:

  URL set        → {"type":"url", "url":...}
  DataBase64 set → {"type":"base64", "media_type":..., "data":...}
  Path set       → read file → base64 → {"type":"base64", ...}

MIME defaults to application/octet-stream when ImageBlock.MediaType is
empty (some servers accept that, some don't — operator's responsibility
to fill it in upstream).

File read failure → emits a "[image %q unavailable]" text block in its
place so the message still goes through with provenance preserved.

Verified live against Kimi K2.6 via Fabric channel: alice uploads
blue 32x32 PNG → Fabric plugin downloads + emits Media in inbound →
host turncore builds canonical.Message with ImageBlock → translator
reads file + base64s → Kimi responds "Blue".
This commit is contained in:
h z
2026-05-31 21:03:04 +01:00
parent 94f33a003a
commit e5ca677113

View File

@@ -8,8 +8,10 @@
package translate package translate
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"git.hangman-lab.top/hzhang/Plexum-sdk-go/canonical" "git.hangman-lab.top/hzhang/Plexum-sdk-go/canonical"
@@ -74,6 +76,40 @@ func CanonicalToAnthropic(req canonical.TurnRequest, modelID string, defaultMaxT
return out, nil return out, nil
} }
// imageSource converts a canonical.ImageBlock into the
// Anthropic-shaped {"type":..., ...} source map. Picks the most
// efficient source mode available; returns nil if all sources are
// empty or the path is unreadable.
//
// URL set → {"type":"url", "url":...}
// DataBase64 set → {"type":"base64", "media_type":..., "data":...}
// Path set → read file → base64 → {"type":"base64", ...}
func imageSource(v *canonical.ImageBlock) map[string]any {
media := v.MediaType
if media == "" {
media = "application/octet-stream"
}
if v.URL != "" {
return map[string]any{"type": "url", "url": v.URL}
}
if v.DataBase64 != "" {
return map[string]any{
"type": "base64", "media_type": media, "data": v.DataBase64,
}
}
if v.Path == "" {
return nil
}
raw, err := os.ReadFile(v.Path)
if err != nil {
return nil
}
return map[string]any{
"type": "base64", "media_type": media,
"data": base64.StdEncoding.EncodeToString(raw),
}
}
func roleToAnthropic(r canonical.Role) string { func roleToAnthropic(r canonical.Role) string {
switch r { switch r {
case canonical.RoleUser: case canonical.RoleUser:
@@ -129,6 +165,16 @@ func blockToAnthropic(b canonical.Block) anthropic.ContentBlock {
return anthropic.ContentBlock{ return anthropic.ContentBlock{
"type": "thinking", "thinking": v.Thinking, "signature": v.Signature, "type": "thinking", "thinking": v.Thinking, "signature": v.Signature,
} }
case *canonical.ImageBlock:
// Three source shapes possible; emit whatever the canonical
// block carries. Anthropic accepts base64 + url + (vendor-
// specific) file shape. Path → read + base64 (default).
src := imageSource(v)
if src == nil {
return anthropic.ContentBlock{"type": "text",
"text": fmt.Sprintf("[image %q unavailable]", v.Path)}
}
return anthropic.ContentBlock{"type": "image", "source": src}
default: default:
// Best-effort generic: marshal then unmarshal into a map. // Best-effort generic: marshal then unmarshal into a map.
raw, err := json.Marshal(b) raw, err := json.Marshal(b)