feat(agent): enhance system configuration and connector management

- Updated the system configuration to include new role-level defaults for Light, Vision, and Audio connectors.
- Refactored the resolveSystemConnector function to prioritize per-agent overrides, improving connector resolution logic.
- Enhanced LLMConnector integration across various components to streamline settings retrieval and capabilities management.
- Improved error handling and logging for connector-related operations, ensuring better diagnostics and user feedback.
This commit is contained in:
Max 2026-05-01 10:31:58 +08:00
parent aa9632d67c
commit 9eef569e4b
41 changed files with 1443 additions and 743 deletions

View file

@ -2,7 +2,6 @@ package assistant
import ( import (
"fmt" "fmt"
"log"
"time" "time"
jsoniter "github.com/json-iterator/go" jsoniter "github.com/json-iterator/go"
@ -628,52 +627,42 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
return finalResponse, nil return finalResponse, nil
} }
// GetConnector get the connector object, capabilities, and error with priority: // GetConnector get the connector object, capabilities, and error.
// opts.Connector > ast.Connector > GetRoleBy("default", identity) > GetRole("default") > error // Priority: opts.Connector > ast.Connector (may be "use::<role>") > "default" role > legacy fallback
// Note: opts.Connector may be set by Create hook's applyOptionsAdjustments // Note: opts.Connector may be set by Create hook's applyOptionsAdjustments
// Returns: (connector, capabilities, error)
func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Options) (connector.Connector, *goullm.Capabilities, error) { func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Options) (connector.Connector, *goullm.Capabilities, error) {
connectorID := ast.Connector cid := ast.Connector
if len(opts) > 0 && opts[0] != nil && opts[0].Connector != "" { if len(opts) > 0 && opts[0] != nil && opts[0].Connector != "" {
connectorID = opts[0].Connector cid = opts[0].Connector
} }
// Fallback to unified role resolution via llmprovider (team > user > system) // Extract identity for role-based resolution
if connectorID == "" && llmprovider.Global != nil { var identity llmprovider.Identity
if ctx != nil && ctx.Authorized != nil { if ctx != nil && ctx.Authorized != nil {
if cid, err := llmprovider.Global.GetRoleBy("default", ctx.Authorized); err == nil { identity = ctx.Authorized
connectorID = cid
}
}
if connectorID == "" {
if cid, err := llmprovider.Global.GetRole("default"); err == nil {
connectorID = cid
}
} }
// Unified resolution: explicit connector / use:: prefix / empty → all handled
conn, caps, err := llm.ResolveConnector(cid, identity)
if err == nil {
return conn, caps, nil
} }
// Legacy fallback // Legacy fallback
if connectorID == "" { if defaultConnector != "" {
connectorID = defaultConnector if conn, err := connector.Select(defaultConnector); err == nil {
return conn, llm.GetCapabilitiesFromConn(conn), nil
}
}
if fallback := findCapableConnector(); fallback != "" {
if conn, err := connector.Select(fallback); err == nil {
return conn, llm.GetCapabilitiesFromConn(conn), nil
}
} }
if connectorID == "" {
return nil, nil, fmt.Errorf("connector not specified") return nil, nil, fmt.Errorf("connector not specified")
} }
conn, err := connector.Select(connectorID)
if err != nil && connectorID != defaultConnector && defaultConnector != "" {
log.Printf("[Assistant] connector %q not found, falling back to default %q", connectorID, defaultConnector)
conn, err = connector.Select(defaultConnector)
}
if err != nil {
return nil, nil, err
}
capabilities := llm.GetCapabilitiesFromConn(conn)
return conn, capabilities, nil
}
// Info get the assistant information // Info get the assistant information
func (ast *Assistant) Info(locale ...string) *message.AssistantInfo { func (ast *Assistant) Info(locale ...string) *message.AssistantInfo {
lc := "en" lc := "en"

View file

@ -12,7 +12,6 @@ import (
"github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/i18n"
store "github.com/yaoapp/yao/agent/store/types" store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/data" "github.com/yaoapp/yao/data"
"github.com/yaoapp/yao/llmprovider"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -35,7 +34,13 @@ var systemAgents = []string{
// SystemConfig holds the system agents connector configuration // SystemConfig holds the system agents connector configuration
// This is set from agent.yml system block // This is set from agent.yml system block
type SystemConfig struct { type SystemConfig struct {
Default string // Default connector for all system agents // Role-level defaults (consumed by buildSystemRoles → SetDefaults)
Default string // Default connector for the "default" role
Light string // Default connector for the "light" role
Vision string // Default connector for the "vision" role
Audio string // Default connector for the "audio" role
// Per-agent overrides (consumed by resolveSystemConnector → ast.Connector)
Keyword string // Connector for __yao.keyword agent Keyword string // Connector for __yao.keyword agent
QueryDSL string // Connector for __yao.querydsl agent QueryDSL string // Connector for __yao.querydsl agent
Title string // Connector for __yao.title agent Title string // Connector for __yao.title agent
@ -43,8 +48,6 @@ type SystemConfig struct {
RobotPrompt string // Connector for __yao.robot_prompt agent RobotPrompt string // Connector for __yao.robot_prompt agent
NeedSearch string // Connector for __yao.needsearch agent NeedSearch string // Connector for __yao.needsearch agent
Entity string // Connector for __yao.entity agent Entity string // Connector for __yao.entity agent
Vision string // Connector for vision capabilities
Audio string // Connector for audio/STT capabilities
} }
// systemConfig holds the system agents configuration (global variable like others in load.go) // systemConfig holds the system agents configuration (global variable like others in load.go)
@ -161,10 +164,9 @@ func loadSystemAgent(id, pathPrefix string) (*Assistant, error) {
pkgData["type"] = "assistant" pkgData["type"] = "assistant"
} }
// Resolve connector for this system agent // Override connector only if agent.yml has an explicit per-agent setting
connectorID := resolveSystemConnector(id) if override := resolveSystemConnector(id); override != "" {
if connectorID != "" { pkgData["connector"] = override
pkgData["connector"] = connectorID
} }
// Read prompts.yml from bindata (default prompts) // Read prompts.yml from bindata (default prompts)
@ -208,118 +210,34 @@ func loadSystemAgent(id, pathPrefix string) (*Assistant, error) {
return loadMap(pkgData) return loadMap(pkgData)
} }
// resolveSystemConnector resolves the connector for a system agent // resolveSystemConnector returns an explicit per-agent connector override from agent.yml.
// Priority: specific agent config > system.default > llmprovider role > defaultConnector > fallback // Returns empty string if no override exists, so the connector declared in package.yao
// (e.g. "use::light") is preserved as-is.
func resolveSystemConnector(agentID string) string { func resolveSystemConnector(agentID string) string {
// Try specific agent config first if systemConfig == nil {
if systemConfig != nil { return ""
}
switch agentID { switch agentID {
case "__yao.keyword": case "__yao.keyword":
if systemConfig.Keyword != "" {
return systemConfig.Keyword return systemConfig.Keyword
}
case "__yao.querydsl": case "__yao.querydsl":
if systemConfig.QueryDSL != "" {
return systemConfig.QueryDSL return systemConfig.QueryDSL
}
case "__yao.title": case "__yao.title":
if systemConfig.Title != "" {
return systemConfig.Title return systemConfig.Title
}
case "__yao.prompt": case "__yao.prompt":
if systemConfig.Prompt != "" {
return systemConfig.Prompt return systemConfig.Prompt
}
case "__yao.robot_prompt": case "__yao.robot_prompt":
if systemConfig.RobotPrompt != "" {
return systemConfig.RobotPrompt return systemConfig.RobotPrompt
}
case "__yao.needsearch": case "__yao.needsearch":
if systemConfig.NeedSearch != "" {
return systemConfig.NeedSearch return systemConfig.NeedSearch
}
case "__yao.entity": case "__yao.entity":
if systemConfig.Entity != "" {
return systemConfig.Entity return systemConfig.Entity
}
case "__yao.vision": case "__yao.vision":
if systemConfig.Vision != "" {
return systemConfig.Vision return systemConfig.Vision
}
case "__yao.audio": case "__yao.audio":
if systemConfig.Audio != "" {
return systemConfig.Audio return systemConfig.Audio
} }
} return ""
// Try system default
if systemConfig.Default != "" {
return systemConfig.Default
}
}
// Try unified role resolution: strip __yao. prefix as role name
if llmprovider.Global != nil {
role := strings.TrimPrefix(agentID, "__yao.")
if cid, err := llmprovider.Global.GetRole(role); err == nil && cid != "" {
return cid
}
if cid, err := llmprovider.Global.GetRole("default"); err == nil && cid != "" {
return cid
}
}
// Try global default connector
if defaultConnector != "" {
return defaultConnector
}
// Fallback: find first connector that supports tool calling
return findCapableConnector()
}
// GetVisionConnector returns the connector for vision capabilities.
// Priority: system.vision > system.default > llmprovider GetRole("vision") > defaultConnector > findCapableConnector
func GetVisionConnector() string {
if systemConfig != nil {
if systemConfig.Vision != "" {
return systemConfig.Vision
}
if systemConfig.Default != "" {
return systemConfig.Default
}
}
if llmprovider.Global != nil {
if cid, err := llmprovider.Global.GetRole("vision"); err == nil && cid != "" {
return cid
}
}
if defaultConnector != "" {
return defaultConnector
}
return findCapableConnector()
}
// GetAudioConnector returns the connector for audio/STT capabilities.
// Priority: system.audio > system.default > llmprovider GetRole("audio") > defaultConnector > findCapableConnector
func GetAudioConnector() string {
if systemConfig != nil {
if systemConfig.Audio != "" {
return systemConfig.Audio
}
if systemConfig.Default != "" {
return systemConfig.Default
}
}
if llmprovider.Global != nil {
if cid, err := llmprovider.Global.GetRole("audio"); err == nil && cid != "" {
return cid
}
}
if defaultConnector != "" {
return defaultConnector
}
return findCapableConnector()
} }
// findCapableConnector finds the first connector that supports tool calling // findCapableConnector finds the first connector that supports tool calling

View file

@ -0,0 +1,58 @@
package assistant
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestResolveSystemConnector_NoConfig(t *testing.T) {
saved := systemConfig
systemConfig = nil
defer func() { systemConfig = saved }()
assert.Equal(t, "", resolveSystemConnector("__yao.title"))
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
}
func TestResolveSystemConnector_PerAgentOverride(t *testing.T) {
saved := systemConfig
systemConfig = &SystemConfig{
Title: "openai.gpt-4o",
}
defer func() { systemConfig = saved }()
assert.Equal(t, "openai.gpt-4o", resolveSystemConnector("__yao.title"))
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
}
func TestResolveSystemConnector_RoleLevelOnly(t *testing.T) {
saved := systemConfig
systemConfig = &SystemConfig{
Default: "openai.gpt-4o",
Light: "openai.gpt-4o-mini",
}
defer func() { systemConfig = saved }()
// Role-level keys don't produce per-agent overrides
assert.Equal(t, "", resolveSystemConnector("__yao.title"))
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
}
func TestResolveSystemConnector_UnknownAgent(t *testing.T) {
saved := systemConfig
systemConfig = &SystemConfig{
Default: "openai.gpt-4o",
Title: "openai.gpt-4o",
}
defer func() { systemConfig = saved }()
assert.Equal(t, "", resolveSystemConnector("__yao.nonexistent"))
assert.Equal(t, "", resolveSystemConnector("custom.agent"))
}

View file

@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
gouMCP "github.com/yaoapp/gou/mcp" gouMCP "github.com/yaoapp/gou/mcp"
mcpProcess "github.com/yaoapp/gou/mcp/process" mcpProcess "github.com/yaoapp/gou/mcp/process"
"github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/context"
@ -266,7 +267,13 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op
execOpts.ConnectorType = "openai" execOpts.ConnectorType = "openai"
} }
// Extract standard fields via LLMConnector when available, fallback to Setting()
setting := conn.Setting() setting := conn.Setting()
if lc, ok := conn.(goullm.LLMConnector); ok {
execOpts.ConnectorHost = lc.GetURL()
execOpts.ConnectorKey = lc.GetKey()
execOpts.Model = lc.GetModel()
} else {
if host, ok := setting["host"].(string); ok { if host, ok := setting["host"].(string); ok {
execOpts.ConnectorHost = host execOpts.ConnectorHost = host
} }
@ -276,20 +283,10 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op
if model, ok := setting["model"].(string); ok { if model, ok := setting["model"].(string); ok {
execOpts.Model = model execOpts.Model = model
} }
}
// Extract extra connector options (thinking, max_tokens, temperature, etc.) // Whitelist-filter remaining settings for sandbox proxy options
// These are backend-specific parameters that need to be passed through to the proxy connectorOptions := connector.FilterRequestBodyParams(setting, conn)
connectorOptions := make(map[string]interface{})
for k, v := range setting {
// Skip standard fields that are already handled
switch k {
case "host", "key", "model", "azure", "capabilities":
continue
default:
// Include all other fields as extra options
connectorOptions[k] = v
}
}
if len(connectorOptions) > 0 { if len(connectorOptions) > 0 {
execOpts.ConnectorOptions = connectorOptions execOpts.ConnectorOptions = connectorOptions
ctx.Logger.Debug("Connector options extracted: %v", connectorOptions) ctx.Logger.Debug("Connector options extracted: %v", connectorOptions)

View file

@ -20,12 +20,21 @@ func GetCapabilities(connectorID string) *goullm.Capabilities {
return GetCapabilitiesFromConn(conn) return GetCapabilitiesFromConn(conn)
} }
// GetCapabilitiesFromConn get the capabilities from a connector instance // GetCapabilitiesFromConn get the capabilities from a connector instance.
// Prefers LLMConnector.GetCapabilities() when available, falls back to Setting() parsing.
func GetCapabilitiesFromConn(conn connector.Connector) *goullm.Capabilities { func GetCapabilitiesFromConn(conn connector.Connector) *goullm.Capabilities {
if conn == nil { if conn == nil {
return getDefaultCapabilities() return getDefaultCapabilities()
} }
// Prefer typed LLMConnector interface
if lc, ok := conn.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil {
return caps
}
}
// Fallback to Setting() parsing for non-LLMConnector or nil capabilities
settings := conn.Setting() settings := conn.Setting()
if settings != nil { if settings != nil {
if caps, ok := settings["capabilities"]; ok { if caps, ok := settings["capabilities"]; ok {

View file

@ -17,6 +17,7 @@ import (
"github.com/yaoapp/yao/agent/llm/adapters" "github.com/yaoapp/yao/agent/llm/adapters"
"github.com/yaoapp/yao/agent/llm/providers/base" "github.com/yaoapp/yao/agent/llm/providers/base"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/share"
) )
// Provider Anthropic Messages API provider // Provider Anthropic Messages API provider
@ -201,21 +202,10 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
return nil, fmt.Errorf("failed to build request body: %w", err) return nil, fmt.Errorf("failed to build request body: %w", err)
} }
// Get connector settings // Get connector settings via LLMConnector or fallback
setting := p.Connector.Setting() host, key, version, err := p.resolveHostKeyVersion()
host, ok := setting["host"].(string) if err != nil {
if !ok || host == "" { return nil, err
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
}
version := "2023-06-01"
if v, ok := setting["version"].(string); ok && v != "" {
version = v
} }
// Build URL: host/v1/messages // Build URL: host/v1/messages
@ -227,13 +217,13 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
}) })
} }
// Create HTTP request with Anthropic auth headers // Create HTTP request with auth headers
req := http.New(url). req := http.New(url).
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("x-api-key", key).
SetHeader("anthropic-version", version). SetHeader("anthropic-version", version).
SetHeader("Accept", "text/event-stream"). SetHeader("Accept", "text/event-stream").
SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)") SetHeader("User-Agent", "YaoEngine/"+share.VERSION)
setAnthropicAuthHeaders(req, p.Connector, key)
// Accumulate response data // Accumulate response data
accumulator := &streamAccumulator{ accumulator := &streamAccumulator{
@ -678,31 +668,20 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
return nil, fmt.Errorf("failed to build request body: %w", err) return nil, fmt.Errorf("failed to build request body: %w", err)
} }
// Get connector settings // Get connector settings via LLMConnector or fallback
setting := p.Connector.Setting() host, key, version, err := p.resolveHostKeyVersion()
host, ok := setting["host"].(string) if err != nil {
if !ok || host == "" { return nil, err
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
}
version := "2023-06-01"
if v, ok := setting["version"].(string); ok && v != "" {
version = v
} }
url := buildAPIURL(host, "/messages") url := buildAPIURL(host, "/messages")
// Create HTTP request // Create HTTP request with auth headers
req := http.New(url). req := http.New(url).
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("x-api-key", key).
SetHeader("anthropic-version", version). SetHeader("anthropic-version", version).
SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)") SetHeader("User-Agent", "YaoEngine/"+share.VERSION)
setAnthropicAuthHeaders(req, p.Connector, key)
resp := req.Post(requestBody) resp := req.Post(requestBody)
if resp.Code != 200 { if resp.Code != 200 {
@ -915,6 +894,11 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
} else if mt, ok := setting["max_tokens"].(int); ok && mt > 0 { } else if mt, ok := setting["max_tokens"].(int); ok && mt > 0 {
maxTokens = mt maxTokens = mt
} }
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil && caps.MaxOutputTokens > 0 && maxTokens > caps.MaxOutputTokens {
maxTokens = caps.MaxOutputTokens
}
}
body["max_tokens"] = maxTokens body["max_tokens"] = maxTokens
// Temperature // Temperature
@ -1176,3 +1160,47 @@ func isRetryableError(err error) bool {
return false return false
} }
// resolveHostKeyVersion extracts host, key, and version via LLMConnector or Setting().
// Setting() is called at most once, and only when needed.
func (p *Provider) resolveHostKeyVersion() (host, key, version string, err error) {
setting := p.Connector.Setting()
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
host = lc.GetURL()
key = lc.GetKey()
} else {
host, _ = setting["host"].(string)
key, _ = setting["key"].(string)
}
// Version is Anthropic-specific, not on LLMConnector interface
version = "2023-06-01"
if v, ok := setting["version"].(string); ok && v != "" {
version = v
}
if host == "" {
return "", "", "", fmt.Errorf("no host found in connector settings")
}
if key == "" {
return "", "", "", fmt.Errorf("API key is not set")
}
return host, key, version, nil
}
// setAnthropicAuthHeaders sets auth headers based on LLMConnector.GetAuthMode().
func setAnthropicAuthHeaders(req *http.Request, conn connector.Connector, key string) {
if lc, ok := conn.(goullm.LLMConnector); ok {
switch lc.GetAuthMode() {
case goullm.AuthAPIKey:
req.SetHeader("api-key", key)
return
case goullm.AuthBearer:
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
return
}
}
// Default for Anthropic: x-api-key
req.SetHeader("x-api-key", key)
}

View file

@ -140,15 +140,30 @@ func (p *Provider) GetConnectorStringSetting(key string) (string, error) {
// GetModel gets the model name from connector settings // GetModel gets the model name from connector settings
func (p *Provider) GetModel() (string, error) { func (p *Provider) GetModel() (string, error) {
if lc, ok := p.Connector.(llm.LLMConnector); ok {
if m := lc.GetModel(); m != "" {
return m, nil
}
}
return p.GetConnectorStringSetting("model") return p.GetConnectorStringSetting("model")
} }
// GetAPIKey gets the API key from connector settings // GetAPIKey gets the API key from connector settings
func (p *Provider) GetAPIKey() (string, error) { func (p *Provider) GetAPIKey() (string, error) {
if lc, ok := p.Connector.(llm.LLMConnector); ok {
if k := lc.GetKey(); k != "" {
return k, nil
}
}
return p.GetConnectorStringSetting("key") return p.GetConnectorStringSetting("key")
} }
// GetHost gets the host URL from connector settings // GetHost gets the host URL from connector settings
func (p *Provider) GetHost() (string, error) { func (p *Provider) GetHost() (string, error) {
if lc, ok := p.Connector.(llm.LLMConnector); ok {
if u := lc.GetURL(); u != "" {
return u, nil
}
}
return p.GetConnectorStringSetting("host") return p.GetConnectorStringSetting("host")
} }

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/llm/providers/anthropic" "github.com/yaoapp/yao/agent/llm/providers/anthropic"
"github.com/yaoapp/yao/agent/llm/providers/openai" "github.com/yaoapp/yao/agent/llm/providers/openai"
@ -61,10 +62,18 @@ func DetectAPIFormat(conn connector.Connector) string {
return "openai" return "openai"
} }
// Check connector settings for host URL patterns as fallback // Try LLMConnector for typed URL access, fall back to Setting() map
settings := conn.Setting() var host string
if settings != nil { if lc, ok := conn.(goullm.LLMConnector); ok {
if host, ok := settings["host"].(string); ok { host = lc.GetURL()
}
if host == "" {
if settings := conn.Setting(); settings != nil {
host, _ = settings["host"].(string)
}
}
if host != "" {
if contains(host, "anthropic.com") || contains(host, "api.kimi.com/coding") { if contains(host, "anthropic.com") || contains(host, "api.kimi.com/coding") {
return "anthropic" return "anthropic"
} }
@ -72,7 +81,6 @@ func DetectAPIFormat(conn connector.Connector) string {
return "openai" return "openai"
} }
} }
}
// Default to OpenAI-compatible // Default to OpenAI-compatible
return "openai" return "openai"

View file

@ -16,6 +16,7 @@ import (
"github.com/yaoapp/yao/agent/llm/adapters" "github.com/yaoapp/yao/agent/llm/adapters"
"github.com/yaoapp/yao/agent/llm/providers/base" "github.com/yaoapp/yao/agent/llm/providers/base"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/share"
"github.com/yaoapp/yao/utils/jsonschema" "github.com/yaoapp/yao/utils/jsonschema"
) )
@ -385,16 +386,10 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
return nil, fmt.Errorf("failed to build request body: %w", err) return nil, fmt.Errorf("failed to build request body: %w", err)
} }
// Get connector settings // Get connector settings via LLMConnector or fallback
setting := p.Connector.Setting() host, key, err := p.resolveHostKey()
host, ok := setting["host"].(string) if err != nil {
if !ok || host == "" { return nil, err
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
} }
// Build URL // Build URL
@ -409,9 +404,9 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
// Create HTTP request with proxy support // Create HTTP request with proxy support
req := http.New(url). req := http.New(url).
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("Authorization", fmt.Sprintf("Bearer %s", key)).
SetHeader("Accept", "text/event-stream"). SetHeader("Accept", "text/event-stream").
SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)") SetHeader("User-Agent", "YaoEngine/"+share.VERSION)
setAuthHeaders(req, p.Connector, key)
// Accumulate response data // Accumulate response data
accumulator := &streamAccumulator{ accumulator := &streamAccumulator{
@ -922,16 +917,10 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
return nil, fmt.Errorf("failed to build request body: %w", err) return nil, fmt.Errorf("failed to build request body: %w", err)
} }
// Get connector settings // Get connector settings via LLMConnector or fallback
setting := p.Connector.Setting() host, key, err := p.resolveHostKey()
host, ok := setting["host"].(string) if err != nil {
if !ok || host == "" { return nil, err
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
} }
// Build URL // Build URL
@ -940,8 +929,8 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
// Create HTTP request with proxy support // Create HTTP request with proxy support
req := http.New(url). req := http.New(url).
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetHeader("Authorization", fmt.Sprintf("Bearer %s", key)). SetHeader("User-Agent", "YaoEngine/"+share.VERSION)
SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)") setAuthHeaders(req, p.Connector, key)
// Make request // Make request
resp := req.Post(requestBody) resp := req.Post(requestBody)
@ -1120,11 +1109,19 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
// Use max_completion_tokens (modern API parameter for GPT-5+) // Use max_completion_tokens (modern API parameter for GPT-5+)
// GPT-5 models only support max_completion_tokens (not max_tokens) // GPT-5 models only support max_completion_tokens (not max_tokens)
if options.MaxCompletionTokens != nil || options.MaxTokens != nil {
maxTokens := 0
if options.MaxCompletionTokens != nil { if options.MaxCompletionTokens != nil {
body["max_completion_tokens"] = *options.MaxCompletionTokens maxTokens = *options.MaxCompletionTokens
} else if options.MaxTokens != nil { } else {
// Fallback: convert MaxTokens to max_completion_tokens for compatibility maxTokens = *options.MaxTokens
body["max_completion_tokens"] = *options.MaxTokens }
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil && caps.MaxOutputTokens > 0 && maxTokens > caps.MaxOutputTokens {
maxTokens = caps.MaxOutputTokens
}
}
body["max_completion_tokens"] = maxTokens
} }
if options.TopP != nil { if options.TopP != nil {
@ -1289,3 +1286,37 @@ func isRetryableError(err error) bool {
return false return false
} }
// resolveHostKey extracts host and key via LLMConnector or Setting() fallback.
func (p *Provider) resolveHostKey() (host, key string, err error) {
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
host = lc.GetURL()
key = lc.GetKey()
} else {
setting := p.Connector.Setting()
host, _ = setting["host"].(string)
key, _ = setting["key"].(string)
}
if host == "" {
return "", "", fmt.Errorf("no host found in connector settings")
}
if key == "" {
return "", "", fmt.Errorf("API key is not set")
}
return host, key, nil
}
// setAuthHeaders sets authentication headers based on LLMConnector.GetAuthMode().
func setAuthHeaders(req *http.Request, conn connector.Connector, key string) {
if lc, ok := conn.(goullm.LLMConnector); ok {
switch lc.GetAuthMode() {
case goullm.AuthAPIKey:
req.SetHeader("api-key", key)
return
case goullm.AuthXAPIKey:
req.SetHeader("x-api-key", key)
return
}
}
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
}

91
agent/llm/resolve.go Normal file
View file

@ -0,0 +1,91 @@
package llm
import (
"fmt"
"strings"
"github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/yao/llmprovider"
)
// RolePrefix marks a Connector field value as a role reference (e.g. "use::light").
const RolePrefix = "use::"
// ResolveConnector resolves an LLM connector using a unified priority chain.
//
// connectorID may be:
// - explicit connector ID (e.g. "openai.gpt-4o") — resolved directly
// - role reference with prefix (e.g. "use::light") — resolved via llmprovider roles
// - empty string — falls back to the "default" role
//
// Priority for role-based resolution:
// 1. GetRoleBy(role, identity) — user/team scoped setting
// 2. GetRole(role) — system-level default for that role
// 3. GetRoleBy("default", identity) — fallback to "default" role (user/team)
// 4. GetRole("default") — fallback to "default" role (system)
// 5. error — caller decides whether to apply legacy fallback
func ResolveConnector(connectorID string, identity llmprovider.Identity) (connector.Connector, *goullm.Capabilities, error) {
// Parse use:: prefix to extract role
role := ""
if strings.HasPrefix(connectorID, RolePrefix) {
role = strings.TrimPrefix(connectorID, RolePrefix)
connectorID = ""
}
// Explicit connector ID takes highest priority
if connectorID != "" {
return selectWithCapabilities(connectorID)
}
// Empty connector with no role → treat as "default"
if role == "" {
role = "default"
}
if llmprovider.Global == nil {
return nil, nil, fmt.Errorf("llmprovider not initialized and no explicit connector specified")
}
// Resolve by the specified role (e.g. "light", "vision")
if role != "default" {
if identity != nil {
if cid, err := llmprovider.Global.GetRoleBy(role, identity); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
}
if cid, err := llmprovider.Global.GetRole(role); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
}
// Fallback to "default" role
if identity != nil {
if cid, err := llmprovider.Global.GetRoleBy("default", identity); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
}
if cid, err := llmprovider.Global.GetRole("default"); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
return nil, nil, fmt.Errorf("no connector resolved for role %q", role)
}
func selectWithCapabilities(connectorID string) (connector.Connector, *goullm.Capabilities, error) {
conn, err := connector.Select(connectorID)
if err != nil {
return nil, nil, err
}
caps := GetCapabilitiesFromConn(conn)
return conn, caps, nil
}

171
agent/llm/resolve_test.go Normal file
View file

@ -0,0 +1,171 @@
package llm_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/llmprovider"
"github.com/yaoapp/yao/setting"
"github.com/yaoapp/yao/test"
)
func TestMain(m *testing.M) {
test.Prepare(nil, config.Conf)
defer test.Clean()
os.Exit(m.Run())
}
type mockIdentity struct {
UserID string
TeamID string
}
func (m *mockIdentity) GetUserID() string { return m.UserID }
func (m *mockIdentity) GetTeamID() string { return m.TeamID }
func setupResolveTest(t *testing.T) string {
t.Helper()
test.Prepare(t, config.Conf)
err := setting.Init()
require.NoError(t, err)
err = llmprovider.Init()
require.NoError(t, err)
connIDs := connector.AIConnectors
if len(connIDs) == 0 {
t.Skip("no AI connectors available in test env")
}
cid := connIDs[0].Value
t.Cleanup(func() {
s, _ := store.Get("__yao.store")
if s != nil {
s.Del("llmprovider:*")
}
c, _ := store.Get("__yao.cache")
if c != nil {
c.Del("llmprovider:*")
}
test.Clean()
})
return cid
}
// --- use:: prefix tests ---
func TestResolveConnector_UseLight(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
"light": cid,
})
require.NoError(t, err)
conn, caps, err := llm.ResolveConnector("use::light", nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_UseDefault(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
})
require.NoError(t, err)
conn, caps, err := llm.ResolveConnector("use::default", nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_UseLightWithIdentity(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
"light": cid,
})
require.NoError(t, err)
conn, caps, err := llm.ResolveConnector("use::light", &mockIdentity{UserID: "u1", TeamID: "t1"})
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_UseLightNoProvider(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
saved := llmprovider.Global
llmprovider.Global = nil
defer func() { llmprovider.Global = saved }()
_, _, err := llm.ResolveConnector("use::light", nil)
assert.Error(t, err)
}
// --- Explicit connector tests ---
func TestResolveConnector_ExplicitID(t *testing.T) {
cid := setupResolveTest(t)
conn, caps, err := llm.ResolveConnector(cid, nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_ExplicitIDPriority(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
"light": cid,
})
require.NoError(t, err)
// Explicit connector ID is NOT a use:: prefix, so it takes priority
conn, caps, err := llm.ResolveConnector(cid, &mockIdentity{UserID: "u1"})
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_InvalidID(t *testing.T) {
setupResolveTest(t)
_, _, err := llm.ResolveConnector("nonexistent-connector-xyz", nil)
assert.Error(t, err)
}
// --- Empty connector fallback ---
func TestResolveConnector_EmptyFallbackDefault(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
})
require.NoError(t, err)
// Empty string → treated as use::default
conn, caps, err := llm.ResolveConnector("", nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}

View file

@ -228,14 +228,16 @@ func initAssistant() error {
if agentDSL.System != nil { if agentDSL.System != nil {
assistant.SetSystemConfig(&assistant.SystemConfig{ assistant.SetSystemConfig(&assistant.SystemConfig{
Default: agentDSL.System.Default, Default: agentDSL.System.Default,
Light: agentDSL.System.Light,
Vision: agentDSL.System.Vision,
Audio: agentDSL.System.Audio,
Keyword: agentDSL.System.Keyword, Keyword: agentDSL.System.Keyword,
QueryDSL: agentDSL.System.QueryDSL, QueryDSL: agentDSL.System.QueryDSL,
Title: agentDSL.System.Title, Title: agentDSL.System.Title,
Prompt: agentDSL.System.Prompt, Prompt: agentDSL.System.Prompt,
RobotPrompt: agentDSL.System.RobotPrompt,
NeedSearch: agentDSL.System.NeedSearch, NeedSearch: agentDSL.System.NeedSearch,
Entity: agentDSL.System.Entity, Entity: agentDSL.System.Entity,
Vision: agentDSL.System.Vision,
Audio: agentDSL.System.Audio,
}) })
} }
@ -479,7 +481,8 @@ func defaultAssistant() (*assistant.Assistant, error) {
} }
// buildSystemRoles converts the System config block into a role→connectorID map // buildSystemRoles converts the System config block into a role→connectorID map
// for llmprovider.SetDefaults. // for llmprovider.SetDefaults. Only role-level keys are written here; per-agent
// overrides (keyword, title, querydsl, etc.) are consumed by resolveSystemConnector.
func buildSystemRoles(sys *types.System) map[string]string { func buildSystemRoles(sys *types.System) map[string]string {
roles := make(map[string]string) roles := make(map[string]string)
add := func(role, cid string) { add := func(role, cid string) {
@ -488,13 +491,7 @@ func buildSystemRoles(sys *types.System) map[string]string {
} }
} }
add("default", sys.Default) add("default", sys.Default)
add("keyword", sys.Keyword) add("light", sys.Light)
add("querydsl", sys.QueryDSL)
add("title", sys.Title)
add("prompt", sys.Prompt)
add("robot_prompt", sys.RobotPrompt)
add("needsearch", sys.NeedSearch)
add("entity", sys.Entity)
add("vision", sys.Vision) add("vision", sys.Vision)
add("audio", sys.Audio) add("audio", sys.Audio)
return roles return roles
@ -506,6 +503,9 @@ func buildSystemRoles(sys *types.System) map[string]string {
func resolveEnvStrings(setting *types.DSL) { func resolveEnvStrings(setting *types.DSL) {
if setting.System != nil { if setting.System != nil {
setting.System.Default = helper.EnvString(setting.System.Default) setting.System.Default = helper.EnvString(setting.System.Default)
setting.System.Light = helper.EnvString(setting.System.Light)
setting.System.Vision = helper.EnvString(setting.System.Vision)
setting.System.Audio = helper.EnvString(setting.System.Audio)
setting.System.Keyword = helper.EnvString(setting.System.Keyword) setting.System.Keyword = helper.EnvString(setting.System.Keyword)
setting.System.QueryDSL = helper.EnvString(setting.System.QueryDSL) setting.System.QueryDSL = helper.EnvString(setting.System.QueryDSL)
setting.System.Title = helper.EnvString(setting.System.Title) setting.System.Title = helper.EnvString(setting.System.Title)
@ -513,8 +513,6 @@ func resolveEnvStrings(setting *types.DSL) {
setting.System.RobotPrompt = helper.EnvString(setting.System.RobotPrompt) setting.System.RobotPrompt = helper.EnvString(setting.System.RobotPrompt)
setting.System.NeedSearch = helper.EnvString(setting.System.NeedSearch) setting.System.NeedSearch = helper.EnvString(setting.System.NeedSearch)
setting.System.Entity = helper.EnvString(setting.System.Entity) setting.System.Entity = helper.EnvString(setting.System.Entity)
setting.System.Vision = helper.EnvString(setting.System.Vision)
setting.System.Audio = helper.EnvString(setting.System.Audio)
} }
if setting.Uses != nil { if setting.Uses != nil {

View file

@ -10,6 +10,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/gou/store" "github.com/yaoapp/gou/store"
"github.com/yaoapp/kun/str" "github.com/yaoapp/kun/str"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
@ -140,9 +141,22 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
if req.Connector != nil { if req.Connector != nil {
setting := req.Connector.Setting() setting := req.Connector.Setting()
host, _ := setting["host"].(string)
key, _ := setting["key"].(string) var host, key, model string
model, _ := setting["model"].(string) if lc, ok := req.Connector.(goullm.LLMConnector); ok {
host = lc.GetURL()
key = lc.GetKey()
model = lc.GetModel()
}
if host == "" {
host, _ = setting["host"].(string)
}
if key == "" {
key, _ = setting["key"].(string)
}
if model == "" {
model, _ = setting["model"].(string)
}
roleConnectors := getRoleConnectors(req) roleConnectors := getRoleConnectors(req)
getConn := func(id string) connector.Connector { getConn := func(id string) connector.Connector {
@ -204,6 +218,17 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
} }
} }
if lc, ok := req.Connector.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil {
if caps.MaxOutputTokens > 0 {
env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = fmt.Sprintf("%d", caps.MaxOutputTokens)
}
if caps.MaxInputTokens > 0 {
env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = fmt.Sprintf("%d", caps.MaxInputTokens)
}
}
}
if thinking, ok := setting["thinking"].(map[string]interface{}); ok { if thinking, ok := setting["thinking"].(map[string]interface{}); ok {
thinkType, _ := thinking["type"].(string) thinkType, _ := thinking["type"].(string)
switch thinkType { switch thinkType {
@ -416,6 +441,11 @@ func connectorHost(c connector.Connector) string {
if c == nil { if c == nil {
return "" return ""
} }
if lc, ok := c.(goullm.LLMConnector); ok {
if u := lc.GetURL(); u != "" {
return u
}
}
host, _ := c.Setting()["host"].(string) host, _ := c.Setting()["host"].(string)
return host return host
} }

View file

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/kun/log" "github.com/yaoapp/kun/log"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
@ -187,6 +188,8 @@ type a2oConnectorConfig struct {
Backend string `json:"backend"` Backend string `json:"backend"`
Model string `json:"model"` Model string `json:"model"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
AuthMode string `json:"auth_mode,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Options map[string]interface{} `json:"options,omitempty"` Options map[string]interface{} `json:"options,omitempty"`
Routes map[string]*a2oConnectorConfig `json:"routes,omitempty"` Routes map[string]*a2oConnectorConfig `json:"routes,omitempty"`
} }
@ -199,6 +202,18 @@ func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig {
cfg := &a2oConnectorConfig{} cfg := &a2oConnectorConfig{}
// Extract standard fields via LLMConnector methods when available
if lc, ok := conn.(goullm.LLMConnector); ok {
if url := lc.GetURL(); url != "" {
cfg.Backend = connector.BuildAPIURL(url, "/chat/completions")
}
cfg.Model = lc.GetModel()
cfg.APIKey = lc.GetKey()
cfg.AuthMode = string(lc.GetAuthMode())
if caps := lc.GetCapabilities(); caps != nil && caps.MaxOutputTokens > 0 {
cfg.MaxOutputTokens = caps.MaxOutputTokens
}
} else {
if host, ok := settings["host"].(string); ok && host != "" { if host, ok := settings["host"].(string); ok && host != "" {
cfg.Backend = connector.BuildAPIURL(host, "/chat/completions") cfg.Backend = connector.BuildAPIURL(host, "/chat/completions")
} else if proxy, ok := settings["proxy"].(string); ok && proxy != "" { } else if proxy, ok := settings["proxy"].(string); ok && proxy != "" {
@ -210,16 +225,10 @@ func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig {
if key, ok := settings["key"].(string); ok && key != "" { if key, ok := settings["key"].(string); ok && key != "" {
cfg.APIKey = key cfg.APIKey = key
} }
}
extra := make(map[string]interface{}) // Whitelist-filter remaining settings for the options field
for k, v := range settings { extra := connector.FilterRequestBodyParams(settings, conn)
switch k {
case "host", "model", "key", "proxy", "type":
continue
default:
extra[k] = v
}
}
if len(extra) > 0 { if len(extra) > 0 {
cfg.Options = extra cfg.Options = extra
} }

View file

@ -8,6 +8,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/gou/store" "github.com/yaoapp/gou/store"
"github.com/yaoapp/kun/str" "github.com/yaoapp/kun/str"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
@ -400,6 +401,11 @@ func connectorHost(c connector.Connector) string {
if c == nil { if c == nil {
return "" return ""
} }
if lc, ok := c.(goullm.LLMConnector); ok {
if u := lc.GetURL(); u != "" {
return strings.TrimSpace(u)
}
}
host, _ := c.Setting()["host"].(string) host, _ := c.Setting()["host"].(string)
return strings.TrimSpace(host) return strings.TrimSpace(host)
} }

View file

@ -5,6 +5,7 @@ import (
"strings" "strings"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/yao/agent/sandbox/v2/types" "github.com/yaoapp/yao/agent/sandbox/v2/types"
) )
@ -78,8 +79,18 @@ func buildOpenCodeConfig(req *types.PrepareRequest, mcpServers []types.MCPServer
// For custom hosts (OpenAI-compatible proxies), we pass the bare host URL. // For custom hosts (OpenAI-compatible proxies), we pass the bare host URL.
func buildProviderConfig(conn connector.Connector) (providerID string, cfg map[string]any, model string) { func buildProviderConfig(conn connector.Connector) (providerID string, cfg map[string]any, model string) {
setting := conn.Setting() setting := conn.Setting()
host, _ := setting["host"].(string)
modelName, _ := setting["model"].(string) var host, modelName string
if lc, ok := conn.(goullm.LLMConnector); ok {
host = lc.GetURL()
modelName = lc.GetModel()
}
if host == "" {
host, _ = setting["host"].(string)
}
if modelName == "" {
modelName, _ = setting["model"].(string)
}
opts := map[string]any{ opts := map[string]any{
"apiKey": "{env:YAO_PROVIDER_KEY}", "apiKey": "{env:YAO_PROVIDER_KEY}",
@ -123,6 +134,21 @@ func buildProviderConfig(conn connector.Connector) (providerID string, cfg map[s
modelCfg["options"] = modelOpts modelCfg["options"] = modelOpts
} }
if lc, ok := conn.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil {
limit := map[string]any{}
if caps.MaxInputTokens > 0 {
limit["context"] = caps.MaxInputTokens
}
if caps.MaxOutputTokens > 0 {
limit["output"] = caps.MaxOutputTokens
}
if len(limit) > 0 {
modelCfg["limit"] = limit
}
}
}
return "custom", map[string]any{ return "custom", map[string]any{
"npm": "@ai-sdk/openai-compatible", "npm": "@ai-sdk/openai-compatible",
"options": opts, "options": opts,
@ -309,6 +335,21 @@ func buildRoleProviderConfig(conn connector.Connector, envKeyPrefix string, moda
modelCfg["modalities"] = modalities modelCfg["modalities"] = modalities
} }
if lc, ok := conn.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil {
limit := map[string]any{}
if caps.MaxInputTokens > 0 {
limit["context"] = caps.MaxInputTokens
}
if caps.MaxOutputTokens > 0 {
limit["output"] = caps.MaxOutputTokens
}
if len(limit) > 0 {
modelCfg["limit"] = limit
}
}
}
if conn.Is(connector.ANTHROPIC) { if conn.Is(connector.ANTHROPIC) {
if host != "" { if host != "" {
opts["baseURL"] = host opts["baseURL"] = host

View file

@ -212,6 +212,7 @@ type AssistantInfo struct {
Avatar string `json:"avatar,omitempty"` Avatar string `json:"avatar,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Connector string `json:"connector,omitempty"` Connector string `json:"connector,omitempty"`
ConnectorRaw string `json:"connector_raw,omitempty"`
ConnectorOptions *ConnectorOptions `json:"connector_options,omitempty"` ConnectorOptions *ConnectorOptions `json:"connector_options,omitempty"`
Modes []string `json:"modes,omitempty"` Modes []string `json:"modes,omitempty"`
DefaultMode string `json:"default_mode,omitempty"` DefaultMode string `json:"default_mode,omitempty"`
@ -432,7 +433,7 @@ type AssistantModel struct {
Type string `json:"type,omitempty"` // Assistant Type, default is assistant Type string `json:"type,omitempty"` // Assistant Type, default is assistant
Name string `json:"name,omitempty"` // Assistant Name Name string `json:"name,omitempty"` // Assistant Name
Avatar string `json:"avatar,omitempty"` // Assistant Avatar Avatar string `json:"avatar,omitempty"` // Assistant Avatar
Connector string `json:"connector"` // AI Connector (default connector) Connector string `json:"connector"` // AI Connector (default connector, or "use::<role>" for role-based resolution)
ConnectorOptions *ConnectorOptions `json:"connector_options,omitempty"` // Connector selection options for user to choose from ConnectorOptions *ConnectorOptions `json:"connector_options,omitempty"` // Connector selection options for user to choose from
Path string `json:"path,omitempty"` // Assistant Path Path string `json:"path,omitempty"` // Assistant Path
BuiltIn bool `json:"built_in,omitempty"` // Whether this is a built-in assistant BuiltIn bool `json:"built_in,omitempty"` // Whether this is a built-in assistant

View file

@ -93,7 +93,13 @@ func (u *Uses) GetPhaseAgent(phase string) string {
// System configures connectors for system agents // System configures connectors for system agents
// =============================== // ===============================
type System struct { type System struct {
Default string `json:"default,omitempty" yaml:"default,omitempty"` // Default connector for all system agents // Role-level defaults (written to llmprovider via SetDefaults)
Default string `json:"default,omitempty" yaml:"default,omitempty"` // Default connector for the "default" role
Light string `json:"light,omitempty" yaml:"light,omitempty"` // Default connector for the "light" role (titles, keywords, summaries)
Vision string `json:"vision,omitempty" yaml:"vision,omitempty"` // Default connector for the "vision" role
Audio string `json:"audio,omitempty" yaml:"audio,omitempty"` // Default connector for the "audio" role
// Per-agent overrides (optional, highest priority — bypasses role resolution)
Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Connector for __yao.keyword agent Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Connector for __yao.keyword agent
QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // Connector for __yao.querydsl agent QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // Connector for __yao.querydsl agent
Title string `json:"title,omitempty" yaml:"title,omitempty"` // Connector for __yao.title agent Title string `json:"title,omitempty" yaml:"title,omitempty"` // Connector for __yao.title agent
@ -101,8 +107,6 @@ type System struct {
RobotPrompt string `json:"robot_prompt,omitempty" yaml:"robot_prompt,omitempty"` // Connector for __yao.robot_prompt agent RobotPrompt string `json:"robot_prompt,omitempty" yaml:"robot_prompt,omitempty"` // Connector for __yao.robot_prompt agent
NeedSearch string `json:"needsearch,omitempty" yaml:"needsearch,omitempty"` // Connector for __yao.needsearch agent NeedSearch string `json:"needsearch,omitempty" yaml:"needsearch,omitempty"` // Connector for __yao.needsearch agent
Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` // Connector for __yao.entity agent Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` // Connector for __yao.entity agent
Vision string `json:"vision,omitempty" yaml:"vision,omitempty"` // Connector for vision capabilities
Audio string `json:"audio,omitempty" yaml:"audio,omitempty"` // Connector for audio/STT capabilities
} }
// Mention Structure // Mention Structure

File diff suppressed because it is too large Load diff

View file

@ -310,12 +310,19 @@ func ownerMatch(po, want *ProviderOwner) bool {
return po.Type == "user" && po.UserID == want.UserID return po.Type == "user" && po.UserID == want.UserID
} }
// capabilitiesFromConn extracts *llm.Capabilities from a connector's settings. // capabilitiesFromConn extracts *llm.Capabilities from a connector.
// Prefers LLMConnector.GetCapabilities() when available.
func capabilitiesFromConn(conn connector.Connector) *goullm.Capabilities { func capabilitiesFromConn(conn connector.Connector) *goullm.Capabilities {
if conn == nil { if conn == nil {
return defaultCaps() return defaultCaps()
} }
if lc, ok := conn.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil {
return caps
}
}
settings := conn.Setting() settings := conn.Setting()
if settings != nil { if settings != nil {
if caps, ok := settings["capabilities"]; ok { if caps, ok := settings["capabilities"]; ok {

View file

@ -18,14 +18,20 @@
default_models: default_models:
- id: gpt-4o - id: gpt-4o
name: GPT-4o name: GPT-4o
max_input_tokens: 128000
max_output_tokens: 16384
capabilities: [vision, tool_calls, streaming, json] capabilities: [vision, tool_calls, streaming, json]
enabled: true enabled: true
- id: gpt-4o-mini - id: gpt-4o-mini
name: GPT-4o Mini name: GPT-4o Mini
max_input_tokens: 128000
max_output_tokens: 16384
capabilities: [tool_calls, streaming, json] capabilities: [tool_calls, streaming, json]
enabled: true enabled: true
- id: o3-mini - id: o3-mini
name: o3-mini name: o3-mini
max_input_tokens: 200000
max_output_tokens: 100000
capabilities: [tool_calls, streaming, reasoning] capabilities: [tool_calls, streaming, reasoning]
enabled: false enabled: false
@ -37,10 +43,14 @@
default_models: default_models:
- id: claude-sonnet-4-20250514 - id: claude-sonnet-4-20250514
name: Claude Sonnet 4 name: Claude Sonnet 4
max_input_tokens: 200000
max_output_tokens: 16000
capabilities: [vision, tool_calls, streaming, reasoning] capabilities: [vision, tool_calls, streaming, reasoning]
enabled: true enabled: true
- id: claude-haiku-3-5-20241022 - id: claude-haiku-3-5-20241022
name: Claude Haiku 3.5 name: Claude Haiku 3.5
max_input_tokens: 200000
max_output_tokens: 8192
capabilities: [tool_calls, streaming] capabilities: [tool_calls, streaming]
enabled: true enabled: true

View file

@ -141,9 +141,14 @@ func (r *Registry) extractConnectorID(target interface{}) string {
p, err := r.Get(rt.Provider, true) p, err := r.Get(rt.Provider, true)
if err != nil { if err != nil {
// Builtin providers: Key == ConnectorID if rt.Model != "" {
return rt.Provider + ":" + rt.Model
}
return rt.Provider return rt.Provider
} }
if rt.Model != "" {
return p.ConnectorID + ":" + rt.Model
}
return p.ConnectorID return p.ConnectorID
} }

View file

@ -76,7 +76,7 @@ func TestGetRole(t *testing.T) {
cid, err := r.GetRole("default") cid, err := r.GetRole("default")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, p.ConnectorID, cid) assert.Equal(t, p.ConnectorID+":gpt-4o", cid)
} }
func TestGetRoleByUser(t *testing.T) { func TestGetRoleByUser(t *testing.T) {
@ -101,11 +101,11 @@ func TestGetRoleByUser(t *testing.T) {
cid, err := r.GetRoleByUser("default", "u1") cid, err := r.GetRoleByUser("default", "u1")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, userP.ConnectorID, cid, "user scope should override system") assert.Equal(t, userP.ConnectorID+":gpt-4o", cid, "user scope should override system")
cidSys, err := r.GetRole("default") cidSys, err := r.GetRole("default")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, sysP.ConnectorID, cidSys, "system scope should still return system provider") assert.Equal(t, sysP.ConnectorID+":gpt-4o", cidSys, "system scope should still return system provider")
} }
func TestGetRoleByTeam(t *testing.T) { func TestGetRoleByTeam(t *testing.T) {
@ -130,7 +130,45 @@ func TestGetRoleByTeam(t *testing.T) {
cid, err := r.GetRoleByTeam("default", "t1") cid, err := r.GetRoleByTeam("default", "t1")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, teamP.ConnectorID, cid, "team scope should override system") assert.Equal(t, teamP.ConnectorID+":gpt-4o", cid, "team scope should override system")
}
func TestGetRoleIncludesModel(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "model-inc-prov")
err := r.SetDefaults(map[string]string{"default": p.Key})
require.NoError(t, err)
cid, err := r.GetRole("default")
require.NoError(t, err)
assert.Contains(t, cid, ":", "connector ID should contain ':' separator for model")
assert.Equal(t, p.ConnectorID+":gpt-4o", cid, "should include model suffix")
}
func TestGetRoleNoModel(t *testing.T) {
r := setupRegistryWithSetting(t)
noModelProvider := llmprovider.Provider{
Key: "nomodel-prov",
Name: "No Model Provider",
Type: "openai",
APIURL: "https://api.openai.com",
APIKey: "sk-test",
Enabled: true,
Models: []llmprovider.ModelInfo{},
Owner: llmprovider.ProviderOwner{Type: "system"},
}
created, err := r.Create(&noModelProvider)
require.NoError(t, err)
err = r.SetDefaults(map[string]string{"default": created.Key})
require.NoError(t, err)
cid, err := r.GetRole("default")
require.NoError(t, err)
assert.Equal(t, created.ConnectorID, cid, "should return base connector ID without model when no models defined")
assert.NotContains(t, cid, ":", "should not contain model separator")
} }
func TestGetRoleNotConfigured(t *testing.T) { func TestGetRoleNotConfigured(t *testing.T) {
@ -195,7 +233,7 @@ func TestProcessGetRole(t *testing.T) {
proc := process.New("llmprovider.getrole", "default") proc := process.New("llmprovider.getrole", "default")
result, err := proc.Exec() result, err := proc.Exec()
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, p.ConnectorID, result) assert.Equal(t, p.ConnectorID+":gpt-4o", result)
} }
func TestProcessListRoles(t *testing.T) { func TestProcessListRoles(t *testing.T) {

View file

@ -245,7 +245,7 @@ func TestGetRoleBy_TeamPriority(t *testing.T) {
info := &oauthTypes.AuthorizedInfo{UserID: "u1", TeamID: "grb-t1"} info := &oauthTypes.AuthorizedInfo{UserID: "u1", TeamID: "grb-t1"}
cid, err := r.GetRoleBy("default", info) cid, err := r.GetRoleBy("default", info)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, teamP.ConnectorID, cid, "should resolve via team scope when TeamID is set") assert.Equal(t, teamP.ConnectorID+":gpt-4o", cid, "should resolve via team scope when TeamID is set")
} }
func TestGetRoleBy_UserFallback(t *testing.T) { func TestGetRoleBy_UserFallback(t *testing.T) {
@ -272,7 +272,7 @@ func TestGetRoleBy_UserFallback(t *testing.T) {
info := &oauthTypes.AuthorizedInfo{UserID: "grbu-u1"} info := &oauthTypes.AuthorizedInfo{UserID: "grbu-u1"}
cid, err := r.GetRoleBy("default", info) cid, err := r.GetRoleBy("default", info)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, userP.ConnectorID, cid, "should resolve via user scope when no TeamID") assert.Equal(t, userP.ConnectorID+":gpt-4o", cid, "should resolve via user scope when no TeamID")
} }
func TestListRolesBy(t *testing.T) { func TestListRolesBy(t *testing.T) {

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
) )
// connectorID builds the runtime ID for registering into connector.Connectors. // connectorID builds the runtime ID for registering into connector.Connectors.
@ -51,6 +52,12 @@ func marshalDSL(p *Provider) ([]byte, error) {
"label": p.Name, "label": p.Name,
"options": opts, "options": opts,
} }
// Propagate auth_mode for providers that use non-Bearer authentication
if p.PresetKey == "azure" {
dsl["auth_mode"] = "api-key"
}
return json.Marshal(dsl) return json.Marshal(dsl)
} }
@ -140,7 +147,7 @@ func ensureModelConnector(p *Provider, m *ModelInfo) error {
// marshalModelDSL builds a connector DSL for a specific model within a provider. // marshalModelDSL builds a connector DSL for a specific model within a provider.
func marshalModelDSL(p *Provider, m *ModelInfo) ([]byte, error) { func marshalModelDSL(p *Provider, m *ModelInfo) ([]byte, error) {
caps := make(map[string]bool) caps := make(map[string]interface{})
for _, c := range m.Capabilities { for _, c := range m.Capabilities {
caps[c] = true caps[c] = true
} }
@ -152,6 +159,12 @@ func marshalModelDSL(p *Provider, m *ModelInfo) ([]byte, error) {
caps["temperature_adjustable"] = true caps["temperature_adjustable"] = true
} }
} }
if m.MaxInputTokens > 0 {
caps["max_input_tokens"] = m.MaxInputTokens
}
if m.MaxOutputTokens > 0 {
caps["max_output_tokens"] = m.MaxOutputTokens
}
opts := map[string]interface{}{ opts := map[string]interface{}{
"host": p.APIURL, "host": p.APIURL,
@ -172,6 +185,11 @@ func marshalModelDSL(p *Provider, m *ModelInfo) ([]byte, error) {
"label": name, "label": name,
"options": opts, "options": opts,
} }
if p.PresetKey == "azure" {
dsl["auth_mode"] = "api-key"
}
return json.Marshal(dsl) return json.Marshal(dsl)
} }
@ -228,13 +246,34 @@ func providerFromConnector(id string, conn connector.Connector) Provider {
} }
typ := connectorType(conn) typ := connectorType(conn)
apiURL, _ := setting["host"].(string)
apiKey, _ := setting["key"].(string) var apiURL, apiKey, model string
model, _ := setting["model"].(string) if lc, ok := conn.(goullm.LLMConnector); ok {
apiURL = lc.GetURL()
apiKey = lc.GetKey()
model = lc.GetModel()
}
if apiURL == "" {
apiURL, _ = setting["host"].(string)
}
if apiKey == "" {
apiKey, _ = setting["key"].(string)
}
if model == "" {
model, _ = setting["model"].(string)
}
var models []ModelInfo var models []ModelInfo
if model != "" { if model != "" {
caps := capabilitiesFromSetting(setting) var caps []string
if lc, ok := conn.(goullm.LLMConnector); ok {
if c := lc.GetCapabilities(); c != nil {
caps = capabilitiesFromCapabilities(c)
}
}
if len(caps) == 0 {
caps = capabilitiesFromSetting(setting)
}
models = []ModelInfo{{ models = []ModelInfo{{
ID: model, ID: model,
Name: model, Name: model,
@ -258,6 +297,48 @@ func providerFromConnector(id string, conn connector.Connector) Provider {
} }
} }
// capabilitiesFromCapabilities converts a typed Capabilities struct to a string slice.
func capabilitiesFromCapabilities(c *goullm.Capabilities) []string {
var out []string
if c.Streaming {
out = append(out, "streaming")
}
if c.ToolCalls {
out = append(out, "tool_calls")
}
if c.TemperatureAdjustable {
out = append(out, "temperature_adjustable")
}
if c.Vision != nil {
switch v := c.Vision.(type) {
case bool:
if v {
out = append(out, "vision")
}
case string:
if v != "" {
out = append(out, "vision")
}
}
}
if c.Audio {
out = append(out, "audio")
}
if c.STT {
out = append(out, "stt")
}
if c.Reasoning {
out = append(out, "reasoning")
}
if c.JSON {
out = append(out, "json")
}
if c.Multimodal {
out = append(out, "multimodal")
}
return out
}
func connectorType(conn connector.Connector) string { func connectorType(conn connector.Connector) string {
switch { switch {
case conn.Is(6): // OPENAI case conn.Is(6): // OPENAI

View file

@ -33,6 +33,8 @@ type ModelInfo struct {
Name string `json:"name" yaml:"name"` Name string `json:"name" yaml:"name"`
Capabilities []string `json:"capabilities" yaml:"capabilities"` Capabilities []string `json:"capabilities" yaml:"capabilities"`
Enabled bool `json:"enabled" yaml:"enabled"` Enabled bool `json:"enabled" yaml:"enabled"`
MaxInputTokens int `json:"max_input_tokens,omitempty" yaml:"max_input_tokens,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty" yaml:"max_output_tokens,omitempty"`
} }
// ProviderOwner identifies who owns a provider. // ProviderOwner identifies who owns a provider.

View file

@ -31,7 +31,7 @@ type OpenAI struct {
baseURL string baseURL string
organization string organization string
maxToken int maxToken int
azure bool // Azure Credentials, "true" or "false" or "" authMode string
} }
// New create a new OpenAI instance by connector id // New create a new OpenAI instance by connector id
@ -95,9 +95,9 @@ func NewOpenAI(setting map[string]interface{}) (*OpenAI, error) {
maxToken = v maxToken = v
} }
azure := false authMode := ""
if v, ok := setting["azure"].(string); ok { if v, ok := setting["auth_mode"].(string); ok {
azure = v == "true" || v == "1" authMode = v
} }
return &OpenAI{ return &OpenAI{
@ -107,7 +107,7 @@ func NewOpenAI(setting map[string]interface{}) (*OpenAI, error) {
baseURL: baseURL, baseURL: baseURL,
organization: organization, organization: organization,
maxToken: maxToken, maxToken: maxToken,
azure: azure, authMode: authMode,
}, nil }, nil
} }
@ -269,7 +269,7 @@ func (openai OpenAI) AudioTranscriptionsFile(filePath string, option map[string]
} }
req := http.New(url) req := http.New(url)
if openai.azure { if openai.authMode == "api-key" {
req.WithHeader(map[string][]string{ req.WithHeader(map[string][]string{
"Content-Type": {"multipart/form-data"}, "Content-Type": {"multipart/form-data"},
"api-key": {openai.key}, "api-key": {openai.key},
@ -414,7 +414,7 @@ func (openai OpenAI) post(path string, payload map[string]interface{}) (interfac
payload["model"] = openai.model payload["model"] = openai.model
req := http.New(url) req := http.New(url)
if openai.azure { if openai.authMode == "api-key" {
req.WithHeader(map[string][]string{ req.WithHeader(map[string][]string{
"Content-Type": {"application/json; charset=utf-8"}, "Content-Type": {"application/json; charset=utf-8"},
"api-key": {openai.key}, "api-key": {openai.key},
@ -438,7 +438,7 @@ func (openai OpenAI) postWithoutModel(path string, payload map[string]interface{
url := fmt.Sprintf("%s%s", openai.host, path) url := fmt.Sprintf("%s%s", openai.host, path)
req := http.New(url) req := http.New(url)
if openai.azure { if openai.authMode == "api-key" {
req.WithHeader(map[string][]string{"api-key": {openai.key}}) req.WithHeader(map[string][]string{"api-key": {openai.key}})
} else { } else {
req.WithHeader(map[string][]string{"Authorization": {fmt.Sprintf("Bearer %s", openai.key)}}) req.WithHeader(map[string][]string{"Authorization": {fmt.Sprintf("Bearer %s", openai.key)}})
@ -461,7 +461,7 @@ func (openai OpenAI) postFile(path string, files map[string][]byte, option map[s
req := http.New(url) req := http.New(url)
if openai.azure { if openai.authMode == "api-key" {
req.WithHeader(map[string][]string{ req.WithHeader(map[string][]string{
"Content-Type": {"multipart/form-data"}, "Content-Type": {"multipart/form-data"},
"api-key": {openai.key}, "api-key": {openai.key},
@ -496,7 +496,7 @@ func (openai OpenAI) postFileWithoutModel(path string, files map[string][]byte,
key := fmt.Sprintf("Bearer %s", openai.key) key := fmt.Sprintf("Bearer %s", openai.key)
req := http.New(url).WithHeader(map[string][]string{"Authorization": {key}}) req := http.New(url).WithHeader(map[string][]string{"Authorization": {key}})
if openai.azure { if openai.authMode == "api-key" {
req.WithHeader(map[string][]string{"api-key": {openai.key}}) req.WithHeader(map[string][]string{"api-key": {openai.key}})
} else { } else {
req.WithHeader(map[string][]string{"Authorization": {fmt.Sprintf("Bearer %s", openai.key)}}) req.WithHeader(map[string][]string{"Authorization": {fmt.Sprintf("Bearer %s", openai.key)}})
@ -528,7 +528,7 @@ func (openai OpenAI) stream(ctx context.Context, path string, payload map[string
} }
req := http.New(url) req := http.New(url)
if openai.azure { if openai.authMode == "api-key" {
req.WithHeader(map[string][]string{ req.WithHeader(map[string][]string{
"Content-Type": {"application/json; charset=utf-8"}, "Content-Type": {"application/json; charset=utf-8"},
"api-key": {openai.key}, "api-key": {openai.key},

View file

@ -175,7 +175,7 @@ func ListAssistants(c *gin.Context) {
// Convert sandbox to boolean and filter built-in sensitive fields // Convert sandbox to boolean and filter built-in sensitive fields
resp := map[string]interface{}{ resp := map[string]interface{}{
"data": AssistantsToResponse(result.Data), "data": AssistantsToResponse(result.Data, authInfo),
"total": result.Total, "total": result.Total,
"page": result.Page, "page": result.Page,
"pagesize": result.PageSize, "pagesize": result.PageSize,
@ -274,7 +274,7 @@ func GetAssistant(c *gin.Context) {
// Convert sandbox to boolean and filter built-in sensitive fields // Convert sandbox to boolean and filter built-in sensitive fields
hasSandbox := assistant.Sandbox != nil hasSandbox := assistant.Sandbox != nil
FilterBuiltInAssistant(assistant) FilterBuiltInAssistant(assistant)
resp := AssistantToResponse(assistant, hasSandbox) resp := AssistantToResponse(assistant, hasSandbox, authInfo)
// Return the result with standard response format // Return the result with standard response format
response.RespondWithSuccess(c, response.StatusOK, resp) response.RespondWithSuccess(c, response.StatusOK, resp)
@ -602,7 +602,11 @@ func GetAssistantInfo(c *gin.Context) {
return return
} }
response.RespondWithSuccess(c, response.StatusOK, ast.GetInfo(locale)) info := ast.GetInfo(locale)
resolved, raw := resolveConnectorForResponse(info.Connector, authInfo)
info.Connector = resolved
info.ConnectorRaw = raw
response.RespondWithSuccess(c, response.StatusOK, info)
} }
// checkAssistantPermission checks if the user has permission to access the assistant // checkAssistantPermission checks if the user has permission to access the assistant

View file

@ -2,11 +2,13 @@ package agent
import ( import (
"encoding/json" "encoding/json"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yaoapp/gou/model" "github.com/yaoapp/gou/model"
"github.com/yaoapp/xun/dbal/query" "github.com/yaoapp/xun/dbal/query"
agenttypes "github.com/yaoapp/yao/agent/store/types" agenttypes "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/llmprovider"
"github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/oauth/types"
) )
@ -160,10 +162,49 @@ func FilterBuiltInAssistant(assistant *agenttypes.AssistantModel) {
} }
} }
// resolveConnectorForResponse resolves a "use::" prefixed connector value
// to the actual connector ID for API responses.
// Returns (resolvedID, rawValue). rawValue is non-empty only when the original was a use:: prefix.
func resolveConnectorForResponse(connectorValue string, identity llmprovider.Identity) (string, string) {
if !strings.HasPrefix(connectorValue, "use::") {
return connectorValue, ""
}
role := strings.TrimPrefix(connectorValue, "use::")
if role == "" {
role = "default"
}
if identity != nil && llmprovider.Global != nil {
if cid, err := llmprovider.Global.GetRoleBy(role, identity); err == nil && cid != "" {
return cid, connectorValue
}
}
if llmprovider.Global != nil {
if cid, err := llmprovider.Global.GetRole(role); err == nil && cid != "" {
return cid, connectorValue
}
}
return connectorValue, connectorValue
}
// applyConnectorResolve resolves the connector field in a response map.
func applyConnectorResolve(result map[string]interface{}, identity llmprovider.Identity) {
connVal, ok := result["connector"].(string)
if !ok || connVal == "" {
return
}
resolved, raw := resolveConnectorForResponse(connVal, identity)
result["connector"] = resolved
if raw != "" {
result["connector_raw"] = raw
}
}
// AssistantToResponse converts an AssistantModel to a response map, // AssistantToResponse converts an AssistantModel to a response map,
// replacing the sandbox JSON object with a boolean indicating whether sandbox is configured. // replacing the sandbox JSON object with a boolean indicating whether sandbox is configured.
// hasSandbox must be captured before FilterBuiltInAssistant clears the Sandbox field. // hasSandbox must be captured before FilterBuiltInAssistant clears the Sandbox field.
func AssistantToResponse(assistant *agenttypes.AssistantModel, hasSandbox bool) map[string]interface{} { func AssistantToResponse(assistant *agenttypes.AssistantModel, hasSandbox bool, identity llmprovider.Identity) map[string]interface{} {
if assistant == nil { if assistant == nil {
return nil return nil
} }
@ -182,13 +223,14 @@ func AssistantToResponse(assistant *agenttypes.AssistantModel, hasSandbox bool)
if assistant.ComputerFilter != nil { if assistant.ComputerFilter != nil {
result["computer_filter"] = assistant.ComputerFilter result["computer_filter"] = assistant.ComputerFilter
} }
applyConnectorResolve(result, identity)
return result return result
} }
// AssistantsToResponse converts a slice of AssistantModel to response maps, // AssistantsToResponse converts a slice of AssistantModel to response maps,
// replacing sandbox with a boolean for each assistant. // replacing sandbox with a boolean for each assistant.
// Captures sandbox state before filtering, then applies FilterBuiltInAssistant. // Captures sandbox state before filtering, then applies FilterBuiltInAssistant.
func AssistantsToResponse(assistants []*agenttypes.AssistantModel) []map[string]interface{} { func AssistantsToResponse(assistants []*agenttypes.AssistantModel, identity llmprovider.Identity) []map[string]interface{} {
if assistants == nil { if assistants == nil {
return nil return nil
} }
@ -197,7 +239,7 @@ func AssistantsToResponse(assistants []*agenttypes.AssistantModel) []map[string]
for _, a := range assistants { for _, a := range assistants {
hasSandbox := a.Sandbox != nil || a.IsSandbox hasSandbox := a.Sandbox != nil || a.IsSandbox
FilterBuiltInAssistant(a) FilterBuiltInAssistant(a)
result = append(result, AssistantToResponse(a, hasSandbox)) result = append(result, AssistantToResponse(a, hasSandbox, identity))
} }
return result return result
} }

View file

@ -0,0 +1,80 @@
package agent
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/llmprovider"
)
func TestResolveConnectorForResponse_ExplicitID(t *testing.T) {
resolved, raw := resolveConnectorForResponse("some-connector-id", nil)
assert.Equal(t, "some-connector-id", resolved)
assert.Empty(t, raw, "non use:: prefix should not set raw value")
}
func TestResolveConnectorForResponse_ExplicitIDWithModel(t *testing.T) {
resolved, raw := resolveConnectorForResponse("t123.openai:gpt-4o", nil)
assert.Equal(t, "t123.openai:gpt-4o", resolved)
assert.Empty(t, raw)
}
func TestResolveConnectorForResponse_EmptyString(t *testing.T) {
resolved, raw := resolveConnectorForResponse("", nil)
assert.Empty(t, resolved)
assert.Empty(t, raw)
}
func TestResolveConnectorForResponse_NilGlobal(t *testing.T) {
orig := llmprovider.Global
llmprovider.Global = nil
defer func() { llmprovider.Global = orig }()
resolved, raw := resolveConnectorForResponse("use::default", nil)
assert.Equal(t, "use::default", resolved, "should return original when Global is nil")
assert.Equal(t, "use::default", raw)
}
func TestResolveConnectorForResponse_UseUnresolvable(t *testing.T) {
orig := llmprovider.Global
llmprovider.Global = nil
defer func() { llmprovider.Global = orig }()
resolved, raw := resolveConnectorForResponse("use::light", nil)
assert.Equal(t, "use::light", resolved, "unresolvable role returns original")
assert.Equal(t, "use::light", raw)
}
func TestResolveConnectorForResponse_UseEmptyRole(t *testing.T) {
orig := llmprovider.Global
llmprovider.Global = nil
defer func() { llmprovider.Global = orig }()
resolved, raw := resolveConnectorForResponse("use::", nil)
assert.Equal(t, "use::", resolved, "empty role with nil Global returns original")
assert.Equal(t, "use::", raw)
}
func TestApplyConnectorResolve_NoConnector(t *testing.T) {
result := map[string]interface{}{"name": "test"}
applyConnectorResolve(result, nil)
assert.Nil(t, result["connector_raw"], "should not add connector_raw when no connector")
}
func TestApplyConnectorResolve_ExplicitConnector(t *testing.T) {
result := map[string]interface{}{"connector": "t123.openai:gpt-4o"}
applyConnectorResolve(result, nil)
assert.Equal(t, "t123.openai:gpt-4o", result["connector"])
assert.Nil(t, result["connector_raw"], "should not add connector_raw for explicit IDs")
}
func TestApplyConnectorResolve_UsePrefix(t *testing.T) {
orig := llmprovider.Global
llmprovider.Global = nil
defer func() { llmprovider.Global = orig }()
result := map[string]interface{}{"connector": "use::default"}
applyConnectorResolve(result, nil)
assert.Equal(t, "use::default", result["connector"], "unresolvable returns original")
assert.Equal(t, "use::default", result["connector_raw"])
}

View file

@ -9,6 +9,7 @@ import (
"github.com/yaoapp/xun/dbal/query" "github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/yao/agent/assistant" "github.com/yaoapp/yao/agent/assistant"
storetypes "github.com/yaoapp/yao/agent/store/types" storetypes "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/llmprovider"
"github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/authorized"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response" "github.com/yaoapp/yao/openapi/response"
@ -378,6 +379,7 @@ func GetMessages(c *gin.Context) {
// Collect unique assistant IDs from messages and fetch their info // Collect unique assistant IDs from messages and fetch their info
assistantIDs := collectAssistantIDs(messages) assistantIDs := collectAssistantIDs(messages)
assistants := assistant.GetInfoByIDs(assistantIDs, locale) assistants := assistant.GetInfoByIDs(assistantIDs, locale)
resolveAssistantInfoConnectors(assistants, authInfo)
response.RespondWithSuccess(c, response.StatusOK, gin.H{ response.RespondWithSuccess(c, response.StatusOK, gin.H{
"chat_id": chatID, "chat_id": chatID,
@ -598,3 +600,32 @@ func checkChatPermission(chatStore storetypes.ChatStore, authInfo *oauthtypes.Au
return false, nil return false, nil
} }
// resolveAssistantInfoConnectors resolves use:: prefixed connectors in a map of AssistantInfo.
func resolveAssistantInfoConnectors(infos map[string]*storetypes.AssistantInfo, identity llmprovider.Identity) {
for _, info := range infos {
if info == nil || !strings.HasPrefix(info.Connector, "use::") {
continue
}
role := strings.TrimPrefix(info.Connector, "use::")
if role == "" {
role = "default"
}
raw := info.Connector
if identity != nil && llmprovider.Global != nil {
if cid, err := llmprovider.Global.GetRoleBy(role, identity); err == nil && cid != "" {
info.ConnectorRaw = raw
info.Connector = cid
continue
}
}
if llmprovider.Global != nil {
if cid, err := llmprovider.Global.GetRole(role); err == nil && cid != "" {
info.ConnectorRaw = raw
info.Connector = cid
continue
}
}
info.ConnectorRaw = raw
}
}

View file

@ -1,7 +1,6 @@
package llm package llm
import ( import (
"fmt"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -46,10 +45,7 @@ func listProviders(c *gin.Context) {
} }
} }
fmt.Printf("[llm/providers] filtersParam=%q\n", filtersParam)
info := authorized.GetInfo(c) info := authorized.GetInfo(c)
fmt.Printf("[llm/providers] identity: UserID=%q TeamID=%q\n", info.GetUserID(), info.GetTeamID())
var opts []connector.Option var opts []connector.Option
if llmprovider.Global != nil { if llmprovider.Global != nil {
@ -57,10 +53,6 @@ func listProviders(c *gin.Context) {
} else { } else {
opts = connector.AIConnectors opts = connector.AIConnectors
} }
fmt.Printf("[llm/providers] ListModelsBy returned %d options\n", len(opts))
for i, o := range opts {
fmt.Printf("[llm/providers] [%d] label=%q value=%q\n", i, o.Label, o.Value)
}
for _, opt := range opts { for _, opt := range opts {
var conn connector.Connector var conn connector.Connector
@ -71,19 +63,16 @@ func listProviders(c *gin.Context) {
conn, err = connector.Select(opt.Value) conn, err = connector.Select(opt.Value)
} }
if err != nil { if err != nil {
fmt.Printf("[llm/providers] GetModel(%q) FAILED: %v\n", opt.Value, err)
continue continue
} }
connType := connectorType(conn) connType := connectorType(conn)
if connType != "openai" && connType != "anthropic" { if connType != "openai" && connType != "anthropic" {
fmt.Printf("[llm/providers] SKIP %q: type=%q (not openai/anthropic)\n", opt.Value, connType)
continue continue
} }
capabilities := getCapabilitiesFromConn(conn) capabilities := getCapabilitiesFromConn(conn)
if len(filters) > 0 && !matchesFilters(capabilities, filters) { if len(filters) > 0 && !matchesFilters(capabilities, filters) {
fmt.Printf("[llm/providers] SKIP %q: caps filter %v not matched (streaming=%v)\n", opt.Value, filters, capabilities["streaming"])
continue continue
} }
@ -96,7 +85,6 @@ func listProviders(c *gin.Context) {
}) })
} }
fmt.Printf("[llm/providers] returning %d providers\n", len(allProviders))
response.RespondWithSuccess(c, response.StatusOK, allProviders) response.RespondWithSuccess(c, response.StatusOK, allProviders)
} }

View file

@ -2,7 +2,7 @@
"name": "Entity Extractor", "name": "Entity Extractor",
"description": "Extract entities and relationships", "description": "Extract entities and relationships",
"type": "worker", "type": "worker",
"connector": "use::light",
"uses": { "search": "disabled" }, "uses": { "search": "disabled" },
"options": { "max_tokens": 2000 } "options": { "max_tokens": 2000 }
} }

View file

@ -2,6 +2,7 @@
"name": "Fetch Helper", "name": "Fetch Helper",
"description": "Fetch and extract content from URLs", "description": "Fetch and extract content from URLs",
"type": "worker", "type": "worker",
"connector": "use::default",
"automated": true, "automated": true,
"public": true, "public": true,
"uses": { "search": "disabled" }, "uses": { "search": "disabled" },

View file

@ -2,6 +2,7 @@
"name": "Keyword Extractor", "name": "Keyword Extractor",
"description": "Extract search keywords", "description": "Extract search keywords",
"type": "worker", "type": "worker",
"connector": "use::light",
"uses": { "search": "disabled" }, "uses": { "search": "disabled" },
"options": { "max_tokens": 500 } "options": { "max_tokens": 500 }
} }

View file

@ -2,6 +2,7 @@
"name": "Reference Checker", "name": "Reference Checker",
"description": "Check if references are needed", "description": "Check if references are needed",
"type": "worker", "type": "worker",
"connector": "use::light",
"uses": { "search": "disabled" }, "uses": { "search": "disabled" },
"options": { "max_tokens": 200 } "options": { "max_tokens": 200 }
} }

View file

@ -2,6 +2,7 @@
"name": "Prompt Optimizer", "name": "Prompt Optimizer",
"description": "Optimize prompts for better results", "description": "Optimize prompts for better results",
"type": "worker", "type": "worker",
"connector": "use::light",
"uses": { "search": "disabled" }, "uses": { "search": "disabled" },
"options": {} "options": {}
} }

View file

@ -2,6 +2,7 @@
"name": "Query Builder", "name": "Query Builder",
"description": "Build database queries", "description": "Build database queries",
"type": "worker", "type": "worker",
"connector": "use::default",
"automated": true, "automated": true,
"public": true, "public": true,
"uses": { "search": "disabled" }, "uses": { "search": "disabled" },

View file

@ -2,6 +2,7 @@
"name": "Robot Prompt Generator", "name": "Robot Prompt Generator",
"description": "Generate system prompts for autonomous robots", "description": "Generate system prompts for autonomous robots",
"type": "worker", "type": "worker",
"connector": "use::default",
"uses": { "search": "disabled" }, "uses": { "search": "disabled" },
"options": {} "options": {}
} }

View file

@ -2,6 +2,7 @@
"name": "Title Generator", "name": "Title Generator",
"description": "Generate conversation titles", "description": "Generate conversation titles",
"type": "worker", "type": "worker",
"connector": "use::light",
"uses": { "search": "disabled" }, "uses": { "search": "disabled" },
"options": {} "options": {}
} }

View file

@ -2,6 +2,7 @@
"name": "Vision Helper", "name": "Vision Helper",
"description": "Analyze images when the main model doesn't support vision", "description": "Analyze images when the main model doesn't support vision",
"type": "worker", "type": "worker",
"connector": "use::vision",
"uses": { "search": "disabled" }, "uses": { "search": "disabled" },
"options": {} "options": {}
} }