feat(providers): add streaming reasoning_content and multimodal media support

- openai_compat: extract reasoning_content from streaming SSE deltas,
  benefiting providers like DeepSeek, Mimo, and Kimi that return
  chain-of-thought content in streamed chunks

Media handling refactor (addresses review feedback):
- agent: centralize video/audio inline decisions in resolveMediaRefs
  with provider capability gating via VideoCapable/AudioCapable interfaces
- agent: add mediaInlinePolicy + mediaInlinePolicyFromProvider() to probe
  provider capabilities before encoding media as data URLs
- agent: add size guard via encodeMediaToDataURL (respects MaxMediaSize)
- providers: add VideoCapable and AudioCapable interfaces (same pattern as
  ThinkingCapable/NativeSearchCapable)
- openai_compat: implement SupportsVideo/SupportsAudio for Mimo, Qwen,
  OpenAI (host and providerName detection)
- httpapi: delegate SupportsVideo/SupportsAudio through HTTPProvider
- httpapi: GeminiProvider declares video + audio support
- common: add video_url serialization in SerializeMessages (now reachable
  via the centralized pipeline); fix missing continue after audio branch
- Tests for capability detection, inline policy, oversized media skip,
  and provider-unsupported media fallback to path tags
This commit is contained in:
BeaconCat 2026-05-03 22:44:03 +08:00
parent be67aed4dc
commit aeba8f7142
18 changed files with 625 additions and 55 deletions

View file

@ -248,7 +248,7 @@ func registerSharedTools(
// This keeps subagent vision support working even when the optimized
// sub-turn spawner path is unavailable.
subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize())
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize(), mediaInlinePolicy{})
})
// Set the spawner that links into AgentLoop's turnState

View file

@ -31,16 +31,44 @@ var (
filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`)
)
// mediaInlinePolicy controls which media types are encoded as inline data URLs.
type mediaInlinePolicy struct {
video bool
audio bool
}
// mediaInlinePolicyFromProvider probes the provider's capability interfaces
// to build an inline policy. Providers that do not implement VideoCapable or
// AudioCapable default to false (path tags only).
func mediaInlinePolicyFromProvider(p providers.LLMProvider) mediaInlinePolicy {
var policy mediaInlinePolicy
if vc, ok := p.(providers.VideoCapable); ok {
policy.video = vc.SupportsVideo()
}
if ac, ok := p.(providers.AudioCapable); ok {
policy.audio = ac.SupportsAudio()
}
return policy
}
// resolveMediaRefs resolves media:// refs in messages.
// For user messages: images get path tags only ([image:/path]) so the LLM
// can decide whether to view them via load_image or operate on the file.
// Video and audio refs are encoded as inline data URLs (with size guard)
// only when the active provider declares support via the policy parameter.
// For tool messages: images are base64-encoded and appended as a synthetic
// user message only after the contiguous tool-message block ends, so we don't
// break the tool-results-must-immediately-follow-assistant constraint that
// LLM APIs enforce.
// Non-image files always get path tags regardless of role.
// LLM APIs enforce. Video and audio in tool messages follow the same pattern
// when the provider supports them.
// Non-image/video/audio files always get path tags regardless of role.
// Returns a new slice; original messages are not mutated.
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
func resolveMediaRefs(
messages []providers.Message,
store media.MediaStore,
maxSize int,
policy mediaInlinePolicy,
) []providers.Message {
if store == nil {
return messages
}
@ -104,11 +132,24 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
mime := detectMIME(localPath, meta)
pathTags = append(pathTags, buildPathTag(mime, localPath))
if m.Role == "tool" && strings.HasPrefix(mime, "image/") {
dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
isImage := strings.HasPrefix(mime, "image/")
isVideo := strings.HasPrefix(mime, "video/")
isAudio := strings.HasPrefix(mime, "audio/")
shouldInline := (isVideo && policy.video) || (isAudio && policy.audio)
if m.Role == "tool" && (isImage || shouldInline) {
// Tool-role media: encode and defer as synthetic user message
dataURL := encodeMediaToDataURL(localPath, mime, info, maxSize)
if dataURL != "" {
pendingToolImages = append(pendingToolImages, dataURL)
}
} else if shouldInline {
// User/assistant-role video & audio: encode inline as data URL
// only when the active provider declares support
dataURL := encodeMediaToDataURL(localPath, mime, info, maxSize)
if dataURL != "" {
resolved = append(resolved, dataURL)
}
}
}
@ -132,9 +173,9 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
return result
}
// encodeImageToDataURL base64-encodes an image file into a data URL.
// encodeMediaToDataURL base64-encodes a media file (image, video, audio) into a data URL.
// Returns empty string if the file exceeds maxSize or encoding fails.
func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string {
func encodeMediaToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string {
if info.Size() > int64(maxSize) {
logger.WarnCF("agent", "Media file too large, skipping", map[string]any{
"path": localPath,

View file

@ -4688,7 +4688,7 @@ func TestResolveMediaRefs_ImageInjectsPathTag(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "describe this", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media))
@ -4721,7 +4721,7 @@ func TestResolveMediaRefs_ToolRoleImageAppendedAsUserMessage(t *testing.T) {
messages := []providers.Message{
{Role: "tool", Content: "Image loaded", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
// Tool message should have path tag but no base64
if len(result[0].Media) != 0 {
@ -4768,7 +4768,7 @@ func TestResolveMediaRefs_MultiToolCallPreservesOrdering(t *testing.T) {
{Role: "tool", Content: "Image loaded [image: photo]", Media: []string{imgRef}},
{Role: "tool", Content: "file contents here"},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
// assistant, tool#1, tool#2 must remain contiguous — no user in between
if result[0].Role != "assistant" {
@ -4810,7 +4810,7 @@ func TestResolveMediaRefs_OversizedImageSkipsBase64KeepsPathTag(t *testing.T) {
{Role: "user", Content: "hi", Media: []string{ref}},
}
// Use a tiny limit (1KB) so the file is oversized
result := resolveMediaRefs(messages, store, 1024)
result := resolveMediaRefs(messages, store, 1024, mediaInlinePolicy{})
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media))
@ -4835,7 +4835,7 @@ func TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "hi", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media entries, got %d", len(result[0].Media))
@ -4850,7 +4850,7 @@ func TestResolveMediaRefs_PassesThroughNonMediaRefs(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}},
}
result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize, mediaInlinePolicy{})
if len(result[0].Media) != 1 || result[0].Media[0] != "https://example.com/img.png" {
t.Fatalf("expected passthrough of non-media:// URL, got %v", result[0].Media)
@ -4875,7 +4875,7 @@ func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) {
}
originalRef := original[0].Media[0]
resolveMediaRefs(original, store, config.DefaultMaxMediaSize)
resolveMediaRefs(original, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
if original[0].Media[0] != originalRef {
t.Fatal("resolveMediaRefs mutated original message slice")
@ -4895,7 +4895,7 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "hi", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media))
@ -4919,7 +4919,7 @@ func TestResolveMediaRefs_PDFInjectsFilePath(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "report.pdf [file]", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (non-image), got %d", len(result[0].Media))
@ -4930,29 +4930,56 @@ func TestResolveMediaRefs_PDFInjectsFilePath(t *testing.T) {
}
}
func TestResolveMediaRefs_AudioInjectsAudioPath(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
oggPath := filepath.Join(dir, "voice.ogg")
os.WriteFile(oggPath, []byte("fake audio"), 0o644)
ref, _ := store.Store(oggPath, media.MediaMeta{ContentType: "audio/ogg"}, "test")
messages := []providers.Message{
{Role: "user", Content: "voice.ogg [audio]", Media: []string{ref}},
func TestResolveMediaRefs_AudioVideoInlinesWithCapability(t *testing.T) {
cases := []struct {
name string
filename string
fakeData string
contentType string
tag string // "audio" or "video"
dataPrefix string
policy mediaInlinePolicy
}{
{
name: "audio/ogg", filename: "voice.ogg", fakeData: "fake audio",
contentType: "audio/ogg", tag: "audio", dataPrefix: "data:audio/ogg;base64,",
policy: mediaInlinePolicy{audio: true},
},
{
name: "video/mp4", filename: "clip.mp4", fakeData: "fake video",
contentType: "video/mp4", tag: "video", dataPrefix: "data:video/mp4;base64,",
policy: mediaInlinePolicy{video: true},
},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media, got %d", len(result[0].Media))
}
expected := "voice.ogg [audio:" + oggPath + "]"
if result[0].Content != expected {
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
filePath := filepath.Join(dir, tc.filename)
os.WriteFile(filePath, []byte(tc.fakeData), 0o644)
ref, _ := store.Store(filePath, media.MediaMeta{ContentType: tc.contentType}, "test")
messages := []providers.Message{
{Role: "user", Content: tc.filename + " [" + tc.tag + "]", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, tc.policy)
if len(result[0].Media) != 1 {
t.Fatalf("expected 1 media (inline data URL), got %d", len(result[0].Media))
}
if !strings.HasPrefix(result[0].Media[0], tc.dataPrefix) {
t.Fatalf("expected %s data URL, got %q", tc.contentType, result[0].Media[0])
}
expected := tc.filename + " [" + tc.tag + ":" + filePath + "]"
if result[0].Content != expected {
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
}
})
}
}
func TestResolveMediaRefs_VideoInjectsVideoPath(t *testing.T) {
func TestResolveMediaRefs_VideoNotInlinedWithoutCapability(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
@ -4963,10 +4990,11 @@ func TestResolveMediaRefs_VideoInjectsVideoPath(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "clip.mp4 [video]", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
// Empty policy: provider does not support video
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media, got %d", len(result[0].Media))
t.Fatalf("expected 0 media (provider does not support video), got %d", len(result[0].Media))
}
expected := "clip.mp4 [video:" + mp4Path + "]"
if result[0].Content != expected {
@ -4974,6 +5002,29 @@ func TestResolveMediaRefs_VideoInjectsVideoPath(t *testing.T) {
}
}
func TestResolveMediaRefs_OversizedVideoSkipsInline(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
mp4Path := filepath.Join(dir, "big.mp4")
os.WriteFile(mp4Path, []byte("fake video content"), 0o644)
ref, _ := store.Store(mp4Path, media.MediaMeta{ContentType: "video/mp4"}, "test")
messages := []providers.Message{
{Role: "user", Content: "big.mp4 [video]", Media: []string{ref}},
}
// Use a tiny limit (1 byte) so the file is oversized
result := resolveMediaRefs(messages, store, 1, mediaInlinePolicy{video: true})
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media))
}
expected := "big.mp4 [video:" + mp4Path + "]"
if result[0].Content != expected {
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
}
}
func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
@ -4985,7 +5036,7 @@ func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "here is my data", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
expected := "here is my data [file:" + csvPath + "]"
if result[0].Content != expected {
@ -5077,7 +5128,7 @@ func TestResolveMediaRefs_JSONContentPrependsPathTag(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: jsonContent, Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
want := "[image:" + pngPath + "]\n" + jsonContent
if result[0].Content != want {
@ -5097,7 +5148,7 @@ func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
expected := "[file:" + docPath + "]"
if result[0].Content != expected {
@ -5126,7 +5177,7 @@ func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "check these [file]", Media: []string{imgRef, fileRef}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, mediaInlinePolicy{})
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (all types use path tags), got %d", len(result[0].Media))

View file

@ -31,7 +31,8 @@ func (p *Pipeline) CallLLM(
// PreLLM: resolve media refs (except on iteration 1 where user media is already resolved)
if iteration > 1 {
exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize)
policy := mediaInlinePolicyFromProvider(ts.agent.Provider)
exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize, policy)
}
// PreLLM: graceful terminal handling
@ -292,7 +293,7 @@ func (p *Pipeline) CallLLM(
if isNetworkError && retry < maxRetries {
backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second
al.emitEvent(
EventKindLLMRetry,
runtimeevents.KindAgentLLMRetry,
ts.eventMeta("runTurn", "turn.llm.retry"),
LLMRetryPayload{
Attempt: retry + 1,

View file

@ -16,6 +16,7 @@ import (
func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution, error) {
cfg := p.Cfg
maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize()
policy := mediaInlinePolicyFromProvider(ts.agent.Provider)
var history []providers.Message
var summary string
@ -35,7 +36,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize, policy)
if !ts.opts.NoHistory {
toolDefs := ts.agent.Tools.ToProviderDefs()
@ -64,7 +65,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize, policy)
}
}

View file

@ -122,7 +122,8 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
// Inject pending steering messages
if len(pendingMessages) > 0 {
resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize)
policy := mediaInlinePolicyFromProvider(ts.agent.Provider)
resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize, policy)
totalContentLen := 0
for i, pm := range pendingMessages {
messages = append(messages, resolvedPending[i])
@ -371,7 +372,8 @@ func (al *AgentLoop) askSideQuestion(
)
maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize()
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
policy := mediaInlinePolicyFromProvider(agent.Provider)
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize, policy)
activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages)
selectedModelName := sideQuestionModelName(agent, usedLight)

View file

@ -84,6 +84,11 @@ func migrateLegacyAgentDefaultsModel(m map[string]any) {
func loadConfig(data []byte) (*Config, error) {
cfg := DefaultConfig()
// Sanitize deprecated fields before strict unknown-field validation.
// This handles configs written by older versions or frontends that still
// use removed fields (e.g. session.dm_scope → session.dimensions).
data, _ = sanitizeDeprecatedFields(data)
// Pre-scan the JSON to check how many model_list entries the user provided.
// Go's JSON decoder reuses existing slice backing-array elements rather than
// zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
@ -498,3 +503,77 @@ func mergeModelListsWithMap(mainML []any, secML map[string]any) error {
return nil
}
// sanitizeDeprecatedFields removes known deprecated fields from raw config
// JSON so that the strict unknown-field validator does not reject them.
// When possible it migrates deprecated values into their replacements.
//
// Known deprecated fields:
// - session.dm_scope → session.dimensions (removed in ca9652e1)
// - channels → channel_list (renamed in V2→V3 migration)
// - bindings (removed in V2→V3 migration)
// - providers (removed in V0→V1 migration, replaced by model_list)
func sanitizeDeprecatedFields(data []byte) ([]byte, error) {
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return data, err
}
changed := false
// session.dm_scope → session.dimensions
if session, ok := m["session"].(map[string]any); ok {
if dmScope, hasDM := session["dm_scope"]; hasDM {
if _, hasDims := session["dimensions"]; !hasDims {
if scope, ok := dmScope.(string); ok {
session["dimensions"] = dmScopeToDimensions(scope)
}
}
delete(session, "dm_scope")
changed = true
}
}
// channels → channel_list (V2 legacy)
if channels, hasChannels := m["channels"]; hasChannels {
if _, hasChannelList := m["channel_list"]; !hasChannelList {
m["channel_list"] = channels
}
delete(m, "channels")
changed = true
}
// bindings (removed in V2→V3, handled by applyLegacyBindingsMigration)
if _, hasBindings := m["bindings"]; hasBindings {
delete(m, "bindings")
changed = true
}
// providers (V0 legacy, replaced by model_list)
if _, hasProviders := m["providers"]; hasProviders {
delete(m, "providers")
changed = true
}
if !changed {
return data, nil
}
return json.Marshal(m)
}
// dmScopeToDimensions converts a legacy dm_scope value to the new
// session dimensions slice.
func dmScopeToDimensions(scope string) []string {
switch scope {
case "per-channel-peer":
return []string{"chat", "sender"}
case "per-channel":
return []string{"chat"}
case "per-peer":
return []string{"sender"}
case "global":
return []string{}
default:
return []string{"chat"}
}
}

View file

@ -6,6 +6,7 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
@ -395,3 +396,142 @@ func TestMigrateV1ToV3_AlreadyNestedFormat(t *testing.T) {
// Should NOT have nested settings inside settings
require.NotContains(t, settings, "settings")
}
func TestSanitizeDeprecatedFields_MigratesDMScope(t *testing.T) {
tests := []struct {
name string
input string
wantDims []string
}{
{"per-channel-peer", `{"session":{"dm_scope":"per-channel-peer"}}`, []string{"chat", "sender"}},
{"per-channel", `{"session":{"dm_scope":"per-channel"}}`, []string{"chat"}},
{"per-peer", `{"session":{"dm_scope":"per-peer"}}`, []string{"sender"}},
{"global", `{"session":{"dm_scope":"global"}}`, []string{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := sanitizeDeprecatedFields([]byte(tt.input))
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(out, &m))
session := m["session"].(map[string]any)
require.NotContains(t, session, "dm_scope", "dm_scope should be removed")
dims, ok := session["dimensions"].([]any)
require.True(t, ok, "dimensions should be a slice")
got := make([]string, len(dims))
for i, d := range dims {
got[i] = d.(string)
}
require.Equal(t, tt.wantDims, got)
})
}
}
func TestSanitizeDeprecatedFields_PreservesExistingDimensions(t *testing.T) {
input := `{"session":{"dm_scope":"global","dimensions":["chat","sender"]}}`
out, err := sanitizeDeprecatedFields([]byte(input))
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(out, &m))
session := m["session"].(map[string]any)
require.NotContains(t, session, "dm_scope")
dims := session["dimensions"].([]any)
require.Equal(t, 2, len(dims), "existing dimensions should be preserved")
}
func TestSanitizeDeprecatedFields_NoSessionUnchanged(t *testing.T) {
input := `{"version":3,"gateway":{"host":"localhost"}}`
out, err := sanitizeDeprecatedFields([]byte(input))
require.NoError(t, err)
require.JSONEq(t, input, string(out))
}
func TestSanitizeDeprecatedFields_MigratesChannelsToChannelList(t *testing.T) {
input := `{"version":3,"channels":{"telegram":{"type":"telegram","enabled":true}}}`
out, err := sanitizeDeprecatedFields([]byte(input))
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(out, &m))
require.NotContains(t, m, "channels", "channels should be removed")
require.Contains(t, m, "channel_list", "channel_list should be present")
}
func TestSanitizeDeprecatedFields_ChannelsDoesNotOverwriteExistingChannelList(t *testing.T) {
input := `{"version":3,"channels":{"old":{"type":"old"}},"channel_list":{"telegram":{"type":"telegram"}}}`
out, err := sanitizeDeprecatedFields([]byte(input))
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(out, &m))
require.NotContains(t, m, "channels")
cl := m["channel_list"].(map[string]any)
require.Contains(t, cl, "telegram", "existing channel_list should be preserved")
require.NotContains(t, cl, "old", "old channels should not overwrite channel_list")
}
func TestSanitizeDeprecatedFields_RemovesBindings(t *testing.T) {
input := `{"version":3,"bindings":[{"agent":"main"}]}`
out, err := sanitizeDeprecatedFields([]byte(input))
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(out, &m))
require.NotContains(t, m, "bindings")
}
func TestSanitizeDeprecatedFields_RemovesProviders(t *testing.T) {
input := `{"version":3,"providers":{"openai":{"api_key":"sk-test"}}}`
out, err := sanitizeDeprecatedFields([]byte(input))
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(out, &m))
require.NotContains(t, m, "providers")
}
func TestLoadConfig_WithLegacyDMScope(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
raw := `{
"version": 3,
"session": {
"dm_scope": "per-channel-peer"
},
"model_list": []
}`
require.NoError(t, os.WriteFile(configPath, []byte(raw), 0o600))
cfg, err := LoadConfig(configPath)
require.NoError(t, err, "LoadConfig should not fail with legacy dm_scope")
require.Equal(t, []string{"chat", "sender"}, cfg.Session.Dimensions)
}
func TestLoadConfig_WithLegacyChannelsField(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
raw := `{
"version": 3,
"channels": {
"telegram": {
"type": "telegram",
"enabled": true,
"settings": {"token": "test-token"}
}
},
"model_list": []
}`
require.NoError(t, os.WriteFile(configPath, []byte(raw), 0o600))
cfg, err := LoadConfig(configPath)
require.NoError(t, err, "LoadConfig should not fail with legacy channels field")
require.NotNil(t, cfg)
}

View file

@ -135,6 +135,17 @@ func SerializeMessages(messages []Message) []any {
"format": format,
},
})
continue
}
if strings.HasPrefix(mediaURL, "data:video/") {
parts = append(parts, map[string]any{
"type": "video_url",
"video_url": map[string]any{
"url": mediaURL,
},
})
continue
}
}

View file

@ -129,6 +129,41 @@ func TestSerializeMessages_WithAudioMedia(t *testing.T) {
}
}
func TestSerializeMessages_WithVideoMedia(t *testing.T) {
messages := []Message{
{Role: "user", Content: "describe this video", Media: []string{"data:video/mp4;base64,AAAAAA"}},
}
result := SerializeMessages(messages)
data, _ := json.Marshal(result)
var msgs []map[string]any
json.Unmarshal(data, &msgs)
content, ok := msgs[0]["content"].([]any)
if !ok {
t.Fatalf("expected array content for media message, got %T", msgs[0]["content"])
}
if len(content) != 2 {
t.Fatalf("expected 2 content parts, got %d", len(content))
}
videoPart, ok := content[1].(map[string]any)
if !ok {
t.Fatalf("expected video content part to be an object, got %T", content[1])
}
if videoPart["type"] != "video_url" {
t.Fatalf("video part type = %v, want video_url", videoPart["type"])
}
videoURL, ok := videoPart["video_url"].(map[string]any)
if !ok {
t.Fatalf("expected video_url object, got %T", videoPart["video_url"])
}
if videoURL["url"] != "data:video/mp4;base64,AAAAAA" {
t.Fatalf("video url = %v, want data:video/mp4;base64,AAAAAA", videoURL["url"])
}
}
func TestSerializeMessages_MediaWithToolCallID(t *testing.T) {
messages := []Message{
{Role: "tool", Content: "result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"},

View file

@ -63,6 +63,14 @@ func (p *GeminiProvider) SupportsThinking() bool {
return true
}
func (p *GeminiProvider) SupportsVideo() bool {
return true
}
func (p *GeminiProvider) SupportsAudio() bool {
return true
}
func (p *GeminiProvider) Chat(
ctx context.Context,
messages []Message,

View file

@ -78,6 +78,14 @@ func (p *HTTPProvider) SupportsNativeSearch() bool {
return p.delegate.SupportsNativeSearch()
}
func (p *HTTPProvider) SupportsVideo() bool {
return p.delegate.SupportsVideo()
}
func (p *HTTPProvider) SupportsAudio() bool {
return p.delegate.SupportsAudio()
}
func (p *HTTPProvider) SetProviderName(providerName string) {
if p == nil || p.delegate == nil {
return

View file

@ -419,6 +419,7 @@ func parseStreamResponse(
onChunk func(accumulated string),
) (*LLMResponse, error) {
var textContent strings.Builder
var reasoningContent strings.Builder
var finishReason string
var usage *UsageInfo
@ -451,8 +452,9 @@ func parseStreamResponse(
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ToolCalls []struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function *struct {
@ -480,6 +482,11 @@ func parseStreamResponse(
choice := chunk.Choices[0]
// Accumulate reasoning content (DeepSeek, Mimo, Kimi, etc.)
if choice.Delta.ReasoningContent != "" {
reasoningContent.WriteString(choice.Delta.ReasoningContent)
}
// Accumulate text content
if choice.Delta.Content != "" {
textContent.WriteString(choice.Delta.Content)
@ -544,10 +551,11 @@ func parseStreamResponse(
}
return &LLMResponse{
Content: textContent.String(),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: usage,
Content: textContent.String(),
ReasoningContent: reasoningContent.String(),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: usage,
}, nil
}
@ -587,6 +595,34 @@ func (p *Provider) SupportsNativeSearch() bool {
return isNativeSearchHost(p.apiBase)
}
// SupportsVideo implements providers.VideoCapable.
func (p *Provider) SupportsVideo() bool {
switch p.providerName {
case "mimo", "qwen", "qwen-portal", "qwen-intl", "qwen-international",
"dashscope-intl", "qwen-us", "dashscope-us":
return true
}
return isMimoHost(p.apiBase)
}
// SupportsAudio implements providers.AudioCapable.
func (p *Provider) SupportsAudio() bool {
switch p.providerName {
case "mimo", "openai", "qwen", "qwen-portal", "qwen-intl", "qwen-international",
"dashscope-intl", "qwen-us", "dashscope-us":
return true
}
return isNativeOpenAIOrAzureEndpoint(p.apiBase) || isMimoHost(p.apiBase)
}
func isMimoHost(apiBase string) bool {
u, err := url.Parse(apiBase)
if err != nil {
return false
}
return u.Hostname() == "api.xiaomimimo.com"
}
// isNativeOpenAIOrAzureEndpoint reports whether the given API base points to
// OpenAI's own API or an Azure OpenAI deployment.
func isNativeOpenAIOrAzureEndpoint(apiBase string) bool {

View file

@ -1494,6 +1494,45 @@ func TestIsNativeSearchHost(t *testing.T) {
}
}
func TestSupportsVideo_Mimo(t *testing.T) {
p := NewProvider("key", "https://api.xiaomimimo.com/v1", "")
if !p.SupportsVideo() {
t.Fatal("Mimo provider should support video")
}
}
func TestSupportsVideo_DeepSeek(t *testing.T) {
p := NewProvider("key", "https://api.deepseek.com/v1", "")
if p.SupportsVideo() {
t.Fatal("DeepSeek provider should not support video")
}
}
func TestSupportsAudio_OpenAI(t *testing.T) {
p := NewProvider("key", "https://api.openai.com/v1", "")
if !p.SupportsAudio() {
t.Fatal("OpenAI provider should support audio")
}
}
func TestSupportsAudio_DeepSeek(t *testing.T) {
p := NewProvider("key", "https://api.deepseek.com/v1", "")
if p.SupportsAudio() {
t.Fatal("DeepSeek provider should not support audio")
}
}
func TestSupportsVideo_QwenByProviderName(t *testing.T) {
p := NewProvider("key", "https://dashscope.aliyuncs.com/compatible-mode/v1", "",
WithProviderName("qwen"))
if !p.SupportsVideo() {
t.Fatal("Qwen provider should support video")
}
if !p.SupportsAudio() {
t.Fatal("Qwen provider should support audio")
}
}
func TestSupportsNativeSearch_OpenAI(t *testing.T) {
p := NewProvider("key", "https://api.openai.com/v1", "")
if !p.SupportsNativeSearch() {
@ -1656,6 +1695,39 @@ func TestProviderChat_NativeSearchIgnoredOnNonOpenAI(t *testing.T) {
}
}
func TestProviderChatStream_ParsesReasoningContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintln(w, `data: {"choices":[{"delta":{"reasoning_content":"Let me think"},"finish_reason":null}]}`)
fmt.Fprintln(w)
fmt.Fprintln(w, `data: {"choices":[{"delta":{"reasoning_content":"... 1+1=2"},"finish_reason":null}]}`)
fmt.Fprintln(w)
fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"The answer is 2"},"finish_reason":"stop"}]}`)
fmt.Fprintln(w)
fmt.Fprintln(w, "data: [DONE]")
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
out, err := p.ChatStream(
t.Context(),
[]Message{{Role: "user", Content: "1+1=?"}},
nil,
"mimo-v2.5",
nil,
nil,
)
if err != nil {
t.Fatalf("ChatStream() error = %v", err)
}
if out.ReasoningContent != "Let me think... 1+1=2" {
t.Fatalf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think... 1+1=2")
}
if out.Content != "The answer is 2" {
t.Fatalf("Content = %q, want %q", out.Content, "The answer is 2")
}
}
func TestSerializeMessages_StripsSystemParts(t *testing.T) {
messages := []protocoltypes.Message{
{

View file

@ -68,6 +68,21 @@ type NativeSearchCapable interface {
SupportsNativeSearch() bool
}
// VideoCapable is an optional interface for providers that support inline
// video content (e.g. Mimo, Qwen). When a provider does not implement this
// interface, video media refs are resolved to path tags only.
type VideoCapable interface {
SupportsVideo() bool
}
// AudioCapable is an optional interface for providers that support inline
// audio content (e.g. OpenAI GPT-4o-audio, Mimo, Qwen). When a provider
// does not implement this interface, audio media refs are resolved to path
// tags only.
type AudioCapable interface {
SupportsAudio() bool
}
// FailoverReason classifies why an LLM request failed for fallback decisions.
type FailoverReason string

View file

@ -167,6 +167,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
return
}
migrateDeprecatedSessionFields(base)
// Convert merged map back to Config struct
merged, err := json.Marshal(base)
@ -386,6 +387,40 @@ func mergeMap(dst, src map[string]any) {
}
}
// migrateDeprecatedSessionFields converts deprecated session fields in a raw
// config map. Currently handles session.dm_scope → session.dimensions.
func migrateDeprecatedSessionFields(m map[string]any) {
session, ok := m["session"].(map[string]any)
if !ok {
return
}
dmScope, hasDM := session["dm_scope"]
if !hasDM {
return
}
if _, hasDims := session["dimensions"]; !hasDims {
if scope, ok := dmScope.(string); ok {
session["dimensions"] = dmScopeToDimensions(scope)
}
}
delete(session, "dm_scope")
}
func dmScopeToDimensions(scope string) []string {
switch scope {
case "per-channel-peer":
return []string{"chat", "sender"}
case "per-channel":
return []string{"chat"}
case "per-peer":
return []string{"sender"}
case "global":
return []string{}
default:
return []string{"chat"}
}
}
func asMapField(value map[string]any, key string) (map[string]any, bool) {
raw, exists := value[key]
if !exists {

View file

@ -30,6 +30,7 @@ import {
EMPTY_LAUNCHER_FORM,
type LauncherForm,
buildFormFromConfig,
dmScopeToDimensions,
parseCIDRText,
parseIntField,
parseMultilineList,
@ -256,7 +257,7 @@ export function ConfigPage() {
},
},
session: {
dm_scope: dmScope,
dimensions: dmScopeToDimensions(dmScope),
},
tools: {
cron: {

View file

@ -66,6 +66,38 @@ export const DM_SCOPE_OPTIONS = [
},
] as const
/**
* Convert a legacy dm_scope value to the new dimensions array.
*/
export function dmScopeToDimensions(scope: string): string[] {
switch (scope) {
case "per-channel-peer":
return ["chat", "sender"]
case "per-channel":
return ["chat"]
case "per-peer":
return ["sender"]
case "global":
return []
default:
return ["chat"]
}
}
/**
* Convert a dimensions array back to a legacy dm_scope value for display.
*/
export function dimensionsToDmScope(dimensions: unknown): string {
if (!Array.isArray(dimensions)) return "per-channel-peer"
const dims = dimensions.filter((d): d is string => typeof d === "string")
const hasChat = dims.includes("chat")
const hasSender = dims.includes("sender")
if (hasChat && hasSender) return "per-channel-peer"
if (hasChat && !hasSender) return "per-channel"
if (!hasChat && hasSender) return "per-peer"
return "global"
}
export const EMPTY_FORM: CoreConfigForm = {
workspace: "",
restrictToWorkspace: true,
@ -211,7 +243,9 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
defaults.summarize_token_percent,
EMPTY_FORM.summarizeTokenPercent,
),
dmScope: asString(session.dm_scope) || EMPTY_FORM.dmScope,
dmScope: session.dimensions !== undefined
? dimensionsToDmScope(session.dimensions)
: (asString(session.dm_scope) || EMPTY_FORM.dmScope),
heartbeatEnabled:
heartbeat.enabled === undefined
? EMPTY_FORM.heartbeatEnabled