feat(llm): enhance connector handling and model configuration
- Integrated logging for connector resolution failures, providing clearer diagnostics during fallback scenarios. - Simplified connector ID extraction in various components, ensuring accurate handling of model identifiers. - Updated model configuration to support new parameters and capabilities, improving overall provider management. - Enhanced OpenAPI settings to reflect changes in model options and connector behavior, ensuring better alignment with upstream API requirements.
This commit is contained in:
parent
fa30898dee
commit
8c34aa12ef
14 changed files with 2000 additions and 120 deletions
|
|
@ -7,6 +7,7 @@ import (
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
goullm "github.com/yaoapp/gou/llm"
|
goullm "github.com/yaoapp/gou/llm"
|
||||||
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/yao/agent/assistant/handlers"
|
"github.com/yaoapp/yao/agent/assistant/handlers"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
|
@ -647,15 +648,16 @@ func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Option
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return conn, caps, nil
|
return conn, caps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy fallback
|
// Legacy fallback
|
||||||
if defaultConnector != "" {
|
if defaultConnector != "" {
|
||||||
if conn, err := connector.Select(defaultConnector); err == nil {
|
if conn, err := connector.Select(defaultConnector); err == nil {
|
||||||
|
log.Warn("[LLM] Connector %s resolve failed, fallback to %s", cid, defaultConnector)
|
||||||
return conn, llm.GetCapabilitiesFromConn(conn), nil
|
return conn, llm.GetCapabilitiesFromConn(conn), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if fallback := findCapableConnector(); fallback != "" {
|
if fallback := findCapableConnector(); fallback != "" {
|
||||||
if conn, err := connector.Select(fallback); err == nil {
|
if conn, err := connector.Select(fallback); err == nil {
|
||||||
|
log.Warn("[LLM] Connector %s resolve failed, fallback to %s (auto-detected)", cid, fallback)
|
||||||
return conn, llm.GetCapabilitiesFromConn(conn), nil
|
return conn, llm.GetCapabilitiesFromConn(conn), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector"
|
|
||||||
"github.com/yaoapp/gou/store"
|
"github.com/yaoapp/gou/store"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
)
|
)
|
||||||
|
|
@ -63,9 +62,7 @@ func GetGRPCAgentRequest(parent context.Context, input GRPCAgentInput) ([]Messag
|
||||||
}
|
}
|
||||||
|
|
||||||
if connectorID := getStringOpt(rawOpts, "connector"); connectorID != "" {
|
if connectorID := getStringOpt(rawOpts, "connector"); connectorID != "" {
|
||||||
if _, err := connector.Select(connectorID); err == nil {
|
opts.Connector = connectorID
|
||||||
opts.Connector = connectorID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Interrupt = NewInterruptController()
|
ctx.Interrupt = NewInterruptController()
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/gou/connector"
|
|
||||||
"github.com/yaoapp/gou/store"
|
"github.com/yaoapp/gou/store"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||||
)
|
)
|
||||||
|
|
@ -70,19 +69,9 @@ func GetCompletionRequest(c *gin.Context, cache store.Store) (*CompletionRequest
|
||||||
Mode: GetMode(c, completionReq),
|
Mode: GetMode(c, completionReq),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract custom connector from model field
|
// Pass model as connector ID; downstream ResolveConnector handles validation + lazy loading
|
||||||
// If model is a valid connector ID, set it to opts.Connector
|
if completionReq != nil && completionReq.Model != "" && !strings.Contains(completionReq.Model, "-yao_") {
|
||||||
// Otherwise, keep the standard OpenAI-compatible behavior (model as assistant ID)
|
opts.Connector = completionReq.Model
|
||||||
if completionReq != nil && completionReq.Model != "" {
|
|
||||||
// Check if model is a valid connector (not containing "-yao_" which indicates assistant ID format)
|
|
||||||
if !strings.Contains(completionReq.Model, "-yao_") {
|
|
||||||
// Try to validate if it's a real connector
|
|
||||||
if _, err := connector.Select(completionReq.Model); err == nil {
|
|
||||||
// It's a valid connector, use it
|
|
||||||
opts.Connector = completionReq.Model
|
|
||||||
}
|
|
||||||
// If not a valid connector, ignore it (keep opts.Connector empty to use assistant's default)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize interrupt controller
|
// Initialize interrupt controller
|
||||||
|
|
|
||||||
|
|
@ -926,9 +926,13 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
|
||||||
body["tool_choice"] = convertToolChoice(options.ToolChoice)
|
body["tool_choice"] = convertToolChoice(options.ToolChoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thinking configuration from connector settings
|
// Merge connector-level body params (thinking, etc.)
|
||||||
if thinking, exists := setting["thinking"]; exists && thinking != nil {
|
// filtered through the SupportedParams / default whitelist.
|
||||||
body["thinking"] = thinking
|
connParams := connector.FilterRequestBodyParams(setting, p.Connector)
|
||||||
|
for k, v := range connParams {
|
||||||
|
if _, exists := body[k]; !exists {
|
||||||
|
body[k] = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return body, nil
|
return body, nil
|
||||||
|
|
|
||||||
|
|
@ -493,16 +493,18 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
|
||||||
accumulator.role = delta.Role
|
accumulator.role = delta.Role
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle reasoning content (DeepSeek R1)
|
reasoningText := delta.ReasoningContent
|
||||||
if delta.ReasoningContent != "" {
|
if reasoningText == "" {
|
||||||
// Start thinking message if not active
|
reasoningText = delta.Reasoning
|
||||||
|
}
|
||||||
|
if reasoningText != "" {
|
||||||
if !messageTracker.active || messageTracker.messageType != message.ChunkThinking {
|
if !messageTracker.active || messageTracker.messageType != message.ChunkThinking {
|
||||||
messageTracker.startMessage(message.ChunkThinking, handler)
|
messageTracker.startMessage(message.ChunkThinking, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
accumulator.reasoningContent += delta.ReasoningContent
|
accumulator.reasoningContent += reasoningText
|
||||||
if handler != nil {
|
if handler != nil {
|
||||||
handler(message.ChunkThinking, []byte(delta.ReasoningContent))
|
handler(message.ChunkThinking, []byte(reasoningText))
|
||||||
messageTracker.incrementChunk()
|
messageTracker.incrementChunk()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -995,7 +997,7 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
|
||||||
Model: fullResp.Model,
|
Model: fullResp.Model,
|
||||||
Role: string(choice.Message.Role),
|
Role: string(choice.Message.Role),
|
||||||
Content: content,
|
Content: content,
|
||||||
ReasoningContent: choice.Message.ReasoningContent,
|
ReasoningContent: reasoningOrFallback(choice.Message.ReasoningContent, choice.Message.Reasoning),
|
||||||
ToolCalls: choice.Message.ToolCalls,
|
ToolCalls: choice.Message.ToolCalls,
|
||||||
FinishReason: choice.FinishReason,
|
FinishReason: choice.FinishReason,
|
||||||
Usage: fullResp.Usage,
|
Usage: fullResp.Usage,
|
||||||
|
|
@ -1029,12 +1031,6 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
|
||||||
return nil, fmt.Errorf("model is not set in connector")
|
return nil, fmt.Errorf("model is not set in connector")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get thinking setting from connector (for models that support reasoning/thinking mode)
|
|
||||||
var thinkingSetting interface{}
|
|
||||||
if thinking, exists := setting["thinking"]; exists {
|
|
||||||
thinkingSetting = thinking
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert messages to API format
|
// Convert messages to API format
|
||||||
apiMessages := make([]map[string]interface{}, 0, len(messages))
|
apiMessages := make([]map[string]interface{}, 0, len(messages))
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
|
|
@ -1199,9 +1195,14 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
|
||||||
body["audio"] = options.Audio
|
body["audio"] = options.Audio
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add thinking parameter for models that support reasoning/thinking mode
|
// Merge connector-level body params (thinking, reasoning, enable_thinking, etc.)
|
||||||
if thinkingSetting != nil {
|
// filtered through the SupportedParams / default whitelist.
|
||||||
body["thinking"] = thinkingSetting
|
// CompletionOptions (per-call) take precedence over connector defaults.
|
||||||
|
connParams := connector.FilterRequestBodyParams(setting, p.Connector)
|
||||||
|
for k, v := range connParams {
|
||||||
|
if _, exists := body[k]; !exists {
|
||||||
|
body[k] = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return body, nil
|
return body, nil
|
||||||
|
|
@ -1320,3 +1321,10 @@ func setAuthHeaders(req *http.Request, conn connector.Connector, key string) {
|
||||||
}
|
}
|
||||||
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
|
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reasoningOrFallback(primary, fallback string) string {
|
||||||
|
if primary != "" {
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ type Delta struct {
|
||||||
type DeltaContent struct {
|
type DeltaContent struct {
|
||||||
Role string `json:"role,omitempty"`
|
Role string `json:"role,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek R1 reasoning
|
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek direct API
|
||||||
|
Reasoning string `json:"reasoning,omitempty"` // OpenRouter
|
||||||
ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
|
||||||
Refusal string `json:"refusal,omitempty"`
|
Refusal string `json:"refusal,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +61,8 @@ type CompletionResponseFull struct {
|
||||||
Message struct {
|
Message struct {
|
||||||
Role context.MessageRole `json:"role"`
|
Role context.MessageRole `json:"role"`
|
||||||
Content interface{} `json:"content,omitempty"` // string or array
|
Content interface{} `json:"content,omitempty"` // string or array
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek R1 reasoning
|
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek direct API
|
||||||
|
Reasoning string `json:"reasoning,omitempty"` // OpenRouter
|
||||||
ToolCalls []context.ToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []context.ToolCall `json:"tool_calls,omitempty"`
|
||||||
Refusal *string `json:"refusal,omitempty"`
|
Refusal *string `json:"refusal,omitempty"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ func ResolveConnector(connectorID string, identity llmprovider.Identity) (connec
|
||||||
|
|
||||||
func selectWithCapabilities(connectorID string) (connector.Connector, *goullm.Capabilities, error) {
|
func selectWithCapabilities(connectorID string) (connector.Connector, *goullm.Capabilities, error) {
|
||||||
conn, err := connector.Select(connectorID)
|
conn, err := connector.Select(connectorID)
|
||||||
|
if err != nil && llmprovider.Global != nil {
|
||||||
|
conn, err = llmprovider.Global.GetModel(connectorID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,13 +125,11 @@ func buildProviderConfig(conn connector.Connector) (providerID string, cfg map[s
|
||||||
// Adding "interleaved" is safe for non-thinking models (no-op if absent).
|
// Adding "interleaved" is safe for non-thinking models (no-op if absent).
|
||||||
modelCfg["interleaved"] = map[string]any{"field": "reasoning_content"}
|
modelCfg["interleaved"] = map[string]any{"field": "reasoning_content"}
|
||||||
|
|
||||||
// Pass through thinking configuration from the Yao connector so OpenCode
|
// Forward connector-level request body params (thinking, reasoning, etc.)
|
||||||
// sends it to the upstream API. DeepSeek defaults thinking to "enabled";
|
// to OpenCode model options so they reach the upstream API.
|
||||||
// without explicitly sending {"thinking":{"type":"disabled"}}, the API
|
connParams := connector.FilterRequestBodyParams(setting, conn)
|
||||||
// returns reasoning_content that OpenCode (AI SDK bug) fails to replay.
|
if len(connParams) > 0 {
|
||||||
modelOpts := buildModelOptions(setting)
|
modelCfg["options"] = connParams
|
||||||
if len(modelOpts) > 0 {
|
|
||||||
modelCfg["options"] = modelOpts
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if lc, ok := conn.(goullm.LLMConnector); ok {
|
if lc, ok := conn.(goullm.LLMConnector); ok {
|
||||||
|
|
@ -158,21 +156,6 @@ func buildProviderConfig(conn connector.Connector) (providerID string, cfg map[s
|
||||||
}, "custom/" + modelName
|
}, "custom/" + modelName
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildModelOptions extracts connector-level model options (thinking, etc.)
|
|
||||||
// and maps them to the OpenCode model options format.
|
|
||||||
func buildModelOptions(setting map[string]any) map[string]any {
|
|
||||||
opts := map[string]any{}
|
|
||||||
|
|
||||||
// Forward thinking configuration as-is (e.g. {"type":"disabled"}).
|
|
||||||
// DeepSeek V4 models default thinking to "enabled"; the only way to
|
|
||||||
// suppress reasoning_content is to explicitly send {"type":"disabled"}.
|
|
||||||
if thinking, ok := setting["thinking"]; ok && thinking != nil {
|
|
||||||
opts["thinking"] = thinking
|
|
||||||
}
|
|
||||||
|
|
||||||
return opts
|
|
||||||
}
|
|
||||||
|
|
||||||
// isNativeOpenAI returns true if host points to official OpenAI API,
|
// isNativeOpenAI returns true if host points to official OpenAI API,
|
||||||
// where OpenCode already knows the correct base URL.
|
// where OpenCode already knows the correct base URL.
|
||||||
func isNativeOpenAI(host string) bool {
|
func isNativeOpenAI(host string) bool {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package llmprovider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
@ -30,6 +31,20 @@ func GetPresets() []ProviderPreset {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPresetsForLocale returns presets filtered by locale.
|
||||||
|
// Presets with empty Locale are always included (global).
|
||||||
|
// Presets with a non-empty Locale are included only when it matches.
|
||||||
|
func GetPresetsForLocale(locale string) []ProviderPreset {
|
||||||
|
norm := strings.ToLower(locale)
|
||||||
|
var out []ProviderPreset
|
||||||
|
for _, p := range presets {
|
||||||
|
if p.Locale == "" || strings.ToLower(p.Locale) == norm {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// GetPreset returns the preset for the given key, or nil if not found.
|
// GetPreset returns the preset for the given key, or nil if not found.
|
||||||
func GetPreset(key string) *ProviderPreset {
|
func GetPreset(key string) *ProviderPreset {
|
||||||
for i := range presets {
|
for i := range presets {
|
||||||
|
|
@ -40,3 +55,15 @@ func GetPreset(key string) *ProviderPreset {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterPreset adds or updates a dynamic preset in the global list.
|
||||||
|
// New entries are prepended so they appear first; existing entries are updated in place.
|
||||||
|
func RegisterPreset(p ProviderPreset) {
|
||||||
|
for i := range presets {
|
||||||
|
if presets[i].Key == p.Key {
|
||||||
|
presets[i] = p
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
presets = append([]ProviderPreset{p}, presets...)
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -166,21 +166,41 @@ func marshalModelDSL(p *Provider, m *ModelInfo) ([]byte, error) {
|
||||||
caps["max_output_tokens"] = m.MaxOutputTokens
|
caps["max_output_tokens"] = m.MaxOutputTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apiModel := m.ID
|
||||||
|
if m.Model != "" {
|
||||||
|
apiModel = m.Model
|
||||||
|
}
|
||||||
opts := map[string]interface{}{
|
opts := map[string]interface{}{
|
||||||
"host": p.APIURL,
|
"host": p.APIURL,
|
||||||
"key": p.APIKey,
|
"key": p.APIKey,
|
||||||
"model": m.ID,
|
"model": apiModel,
|
||||||
}
|
}
|
||||||
if len(caps) > 0 {
|
if len(caps) > 0 {
|
||||||
opts["capabilities"] = caps
|
opts["capabilities"] = caps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reserved := map[string]bool{"host": true, "key": true, "model": true, "capabilities": true, "_connector_type": true}
|
||||||
|
extraBody := map[string]interface{}{}
|
||||||
|
for k, v := range m.Options {
|
||||||
|
if !reserved[k] {
|
||||||
|
extraBody[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(extraBody) > 0 {
|
||||||
|
opts["extra_body"] = extraBody
|
||||||
|
}
|
||||||
|
|
||||||
|
connType := p.Type
|
||||||
|
if ct, ok := m.Options["_connector_type"].(string); ok && ct != "" {
|
||||||
|
connType = ct
|
||||||
|
}
|
||||||
|
|
||||||
name := m.Name
|
name := m.Name
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = m.ID
|
name = m.ID
|
||||||
}
|
}
|
||||||
dsl := map[string]interface{}{
|
dsl := map[string]interface{}{
|
||||||
"type": p.Type,
|
"type": connType,
|
||||||
"name": name,
|
"name": name,
|
||||||
"label": name,
|
"label": name,
|
||||||
"options": opts,
|
"options": opts,
|
||||||
|
|
@ -202,6 +222,11 @@ func unregisterConnector(p *Provider) error {
|
||||||
if cid == "" {
|
if cid == "" {
|
||||||
cid = connectorID(p)
|
cid = connectorID(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, m := range p.Models {
|
||||||
|
_ = connector.Unregister(cid + ":" + m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
return connector.Unregister(cid)
|
return connector.Unregister(cid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,14 @@ type Provider struct {
|
||||||
// ModelInfo describes a single model within a provider.
|
// ModelInfo describes a single model within a provider.
|
||||||
// Fields align with the frontend ModelInfo interface.
|
// Fields align with the frontend ModelInfo interface.
|
||||||
type ModelInfo struct {
|
type ModelInfo struct {
|
||||||
ID string `json:"id" yaml:"id"`
|
ID string `json:"id" yaml:"id"`
|
||||||
Name string `json:"name" yaml:"name"`
|
Model string `json:"model,omitempty" yaml:"model,omitempty"`
|
||||||
Capabilities []string `json:"capabilities" yaml:"capabilities"`
|
Name string `json:"name" yaml:"name"`
|
||||||
Enabled bool `json:"enabled" yaml:"enabled"`
|
Capabilities []string `json:"capabilities" yaml:"capabilities"`
|
||||||
MaxInputTokens int `json:"max_input_tokens,omitempty" yaml:"max_input_tokens,omitempty"`
|
Enabled bool `json:"enabled" yaml:"enabled"`
|
||||||
MaxOutputTokens int `json:"max_output_tokens,omitempty" yaml:"max_output_tokens,omitempty"`
|
MaxInputTokens int `json:"max_input_tokens,omitempty" yaml:"max_input_tokens,omitempty"`
|
||||||
|
MaxOutputTokens int `json:"max_output_tokens,omitempty" yaml:"max_output_tokens,omitempty"`
|
||||||
|
Options map[string]interface{} `json:"options,omitempty" yaml:"options,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProviderOwner identifies who owns a provider.
|
// ProviderOwner identifies who owns a provider.
|
||||||
|
|
@ -69,6 +71,7 @@ type ProviderFilter struct {
|
||||||
type ProviderPreset struct {
|
type ProviderPreset struct {
|
||||||
Key string `json:"key" yaml:"key"`
|
Key string `json:"key" yaml:"key"`
|
||||||
Name string `json:"name" yaml:"name"`
|
Name string `json:"name" yaml:"name"`
|
||||||
|
Locale string `json:"locale,omitempty" yaml:"locale,omitempty"`
|
||||||
Type string `json:"type" yaml:"type"`
|
Type string `json:"type" yaml:"type"`
|
||||||
APIURL string `json:"api_url" yaml:"api_url"`
|
APIURL string `json:"api_url" yaml:"api_url"`
|
||||||
RequireKey bool `json:"require_key" yaml:"require_key"`
|
RequireKey bool `json:"require_key" yaml:"require_key"`
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ package setting
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
@ -61,6 +63,9 @@ func enrichProvider(p *llmprovider.Provider) map[string]interface{} {
|
||||||
if preset := llmprovider.GetPreset(p.PresetKey); preset != nil {
|
if preset := llmprovider.GetPreset(p.PresetKey); preset != nil {
|
||||||
m["is_cloud"] = preset.IsCloud
|
m["is_cloud"] = preset.IsCloud
|
||||||
m["url_editable"] = preset.URLEditable
|
m["url_editable"] = preset.URLEditable
|
||||||
|
} else if p.PresetKey == "yaoagents" {
|
||||||
|
m["is_cloud"] = true
|
||||||
|
m["url_editable"] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,6 +117,218 @@ func llmValidateKey(providerType, apiURL, apiKey string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cloud preset helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
cloudModelCache []map[string]interface{}
|
||||||
|
cloudModelCacheAt time.Time
|
||||||
|
cloudModelCacheURL string
|
||||||
|
cloudModelCacheMu sync.Mutex
|
||||||
|
cloudModelCacheTTL = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildCloudPreset(info *oauthTypes.AuthorizedInfo) {
|
||||||
|
var saved map[string]interface{}
|
||||||
|
if setting.Global != nil {
|
||||||
|
saved, _ = setting.Global.GetMerged(info.UserID, info.TeamID, cloudNS)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := resolveCloudAPIURL(saved)
|
||||||
|
preset := llmprovider.ProviderPreset{
|
||||||
|
Key: "yaoagents",
|
||||||
|
Name: "Yao Agents",
|
||||||
|
Type: "openai",
|
||||||
|
APIURL: apiURL,
|
||||||
|
RequireKey: false,
|
||||||
|
IsCloud: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
status, _ := saved["status"].(string)
|
||||||
|
if status == "connected" {
|
||||||
|
if encKey, _ := saved["api_key"].(string); encKey != "" {
|
||||||
|
raw := fetchCloudModels(apiURL, cloudDecrypt(encKey))
|
||||||
|
if len(raw) > 0 {
|
||||||
|
rawJSON, _ := json.Marshal(raw)
|
||||||
|
var models []llmprovider.ModelInfo
|
||||||
|
if err := json.Unmarshal(rawJSON, &models); err == nil {
|
||||||
|
for i := range models {
|
||||||
|
models[i].Enabled = true
|
||||||
|
}
|
||||||
|
preset.DefaultModels = models
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
llmprovider.RegisterPreset(preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCloudAPIURL(saved map[string]interface{}) string {
|
||||||
|
if saved != nil {
|
||||||
|
if v, ok := saved["api_url"].(string); ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
def := cloudDefaultRegion()
|
||||||
|
return def.APIURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCloudModels(apiURL, apiKey string) []map[string]interface{} {
|
||||||
|
cloudModelCacheMu.Lock()
|
||||||
|
if cloudModelCache != nil && cloudModelCacheURL == apiURL && time.Since(cloudModelCacheAt) < cloudModelCacheTTL {
|
||||||
|
cached := cloudModelCache
|
||||||
|
cloudModelCacheMu.Unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cloudModelCacheMu.Unlock()
|
||||||
|
|
||||||
|
url := apiURL
|
||||||
|
if strings.HasSuffix(url, "/") {
|
||||||
|
url += "v1/models"
|
||||||
|
} else {
|
||||||
|
url += "/v1/models"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Data []map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
models := make([]map[string]interface{}, 0, len(result.Data))
|
||||||
|
for _, item := range result.Data {
|
||||||
|
m := mapCloudModel(item)
|
||||||
|
if m != nil {
|
||||||
|
models = append(models, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloudModelCacheMu.Lock()
|
||||||
|
cloudModelCache = models
|
||||||
|
cloudModelCacheAt = time.Now()
|
||||||
|
cloudModelCacheURL = apiURL
|
||||||
|
cloudModelCacheMu.Unlock()
|
||||||
|
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCloudModel(item map[string]interface{}) map[string]interface{} {
|
||||||
|
id, _ := item["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
name := id
|
||||||
|
if label, ok := item["label"].(string); ok && label != "" {
|
||||||
|
name = strings.TrimPrefix(label, "Yao Agents / ")
|
||||||
|
name = strings.TrimPrefix(name, "Yao Agents /")
|
||||||
|
}
|
||||||
|
|
||||||
|
caps := make([]string, 0)
|
||||||
|
mode, _ := item["mode"].(string)
|
||||||
|
switch mode {
|
||||||
|
case "embedding":
|
||||||
|
caps = append(caps, "embedding")
|
||||||
|
case "audio_transcription", "audio_speech":
|
||||||
|
caps = append(caps, "audio")
|
||||||
|
case "image_generation":
|
||||||
|
caps = append(caps, "image_generation")
|
||||||
|
default:
|
||||||
|
if getBool(item, "supports_streaming") {
|
||||||
|
caps = append(caps, "streaming")
|
||||||
|
}
|
||||||
|
if getBool(item, "supports_function_calling") {
|
||||||
|
caps = append(caps, "tool_calls")
|
||||||
|
}
|
||||||
|
if getBool(item, "supports_vision") {
|
||||||
|
caps = append(caps, "vision")
|
||||||
|
}
|
||||||
|
if getBool(item, "supports_response_schema") {
|
||||||
|
caps = append(caps, "json")
|
||||||
|
}
|
||||||
|
if getBool(item, "supports_reasoning") {
|
||||||
|
caps = append(caps, "reasoning")
|
||||||
|
}
|
||||||
|
if getBool(item, "supports_audio_input") {
|
||||||
|
caps = append(caps, "audio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"capabilities": caps,
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := getNumber(item, "max_input_tokens"); ok && v > 0 {
|
||||||
|
m["max_input_tokens"] = int(v)
|
||||||
|
}
|
||||||
|
if v, ok := getNumber(item, "max_output_tokens"); ok && v > 0 {
|
||||||
|
m["max_output_tokens"] = int(v)
|
||||||
|
}
|
||||||
|
opts := map[string]interface{}{}
|
||||||
|
if dp, ok := item["params"].(map[string]interface{}); ok {
|
||||||
|
for k, v := range dp {
|
||||||
|
opts[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at, ok := item["api_type"].(string); ok && at != "" {
|
||||||
|
opts["_connector_type"] = at
|
||||||
|
}
|
||||||
|
if len(opts) > 0 {
|
||||||
|
m["options"] = opts
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBool(m map[string]interface{}, key string) bool {
|
||||||
|
if m == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, ok := m[key].(bool)
|
||||||
|
return ok && v
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNumber(m map[string]interface{}, key string) (float64, bool) {
|
||||||
|
if m == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
switch v := m[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return v, true
|
||||||
|
case json.Number:
|
||||||
|
f, err := v.Float64()
|
||||||
|
return f, err == nil
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Handlers
|
// Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -124,9 +341,10 @@ func handleLLMTest(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var input struct {
|
var input struct {
|
||||||
APIURL string `json:"api_url"`
|
APIURL string `json:"api_url"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
RequireKey *bool `json:"require_key"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
respondError(c, http.StatusBadRequest, "invalid request body")
|
respondError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
|
@ -136,6 +354,13 @@ func handleLLMTest(c *gin.Context) {
|
||||||
respondError(c, http.StatusBadRequest, "api_url is required")
|
respondError(c, http.StatusBadRequest, "api_url is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if input.APIKey == "" && (input.RequireKey == nil || *input.RequireKey) {
|
||||||
|
response.RespondWithSuccess(c, http.StatusOK, llmprovider.ProviderTestResult{
|
||||||
|
Success: false,
|
||||||
|
Message: "API Key is required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
url := llmModelsURL(input.APIURL)
|
url := llmModelsURL(input.APIURL)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
@ -216,7 +441,15 @@ func handleLLMGet(c *gin.Context) {
|
||||||
roles = make(map[string]interface{})
|
roles = make(map[string]interface{})
|
||||||
}
|
}
|
||||||
|
|
||||||
presetList := llmprovider.GetPresets()
|
buildCloudPreset(info)
|
||||||
|
|
||||||
|
locale := c.Query("locale")
|
||||||
|
var presetList []llmprovider.ProviderPreset
|
||||||
|
if locale != "" {
|
||||||
|
presetList = llmprovider.GetPresetsForLocale(locale)
|
||||||
|
} else {
|
||||||
|
presetList = llmprovider.GetPresets()
|
||||||
|
}
|
||||||
presetIface := make([]interface{}, len(presetList))
|
presetIface := make([]interface{}, len(presetList))
|
||||||
for i, p := range presetList {
|
for i, p := range presetList {
|
||||||
raw, _ := json.Marshal(p)
|
raw, _ := json.Marshal(p)
|
||||||
|
|
@ -259,6 +492,7 @@ func handleLLMRoles(c *gin.Context) {
|
||||||
|
|
||||||
llmEnsureEncKey()
|
llmEnsureEncKey()
|
||||||
|
|
||||||
|
var staleRoles []string
|
||||||
for roleName, target := range body {
|
for roleName, target := range body {
|
||||||
targetMap, ok := target.(map[string]interface{})
|
targetMap, ok := target.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -275,16 +509,16 @@ func handleLLMRoles(c *gin.Context) {
|
||||||
|
|
||||||
p, err := llmprovider.Global.Get(providerKey)
|
p, err := llmprovider.Global.Get(providerKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(c, http.StatusBadRequest, fmt.Sprintf("provider \"%s\" not found", providerKey))
|
staleRoles = append(staleRoles, roleName)
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
if !p.Enabled {
|
if !p.Enabled {
|
||||||
respondError(c, http.StatusBadRequest, fmt.Sprintf("provider \"%s\" is not enabled", providerKey))
|
staleRoles = append(staleRoles, roleName)
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
if err := llmCheckOwnership(p, info); err != nil {
|
if err := llmCheckOwnership(p, info); err != nil {
|
||||||
respondError(c, http.StatusBadRequest, fmt.Sprintf("provider \"%s\" not found", providerKey))
|
staleRoles = append(staleRoles, roleName)
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
modelFound := false
|
modelFound := false
|
||||||
|
|
@ -295,10 +529,16 @@ func handleLLMRoles(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !modelFound {
|
if !modelFound {
|
||||||
respondError(c, http.StatusBadRequest, fmt.Sprintf("model \"%s\" not found in provider \"%s\"", modelID, providerKey))
|
staleRoles = append(staleRoles, roleName)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, role := range staleRoles {
|
||||||
|
delete(body, role)
|
||||||
|
}
|
||||||
|
if _, ok := body["default"]; !ok {
|
||||||
|
respondError(c, http.StatusBadRequest, "\"default\" role: the assigned provider no longer exists, please re-select")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if setting.Global == nil {
|
if setting.Global == nil {
|
||||||
respondError(c, http.StatusInternalServerError, "setting registry not initialized")
|
respondError(c, http.StatusInternalServerError, "setting registry not initialized")
|
||||||
|
|
@ -344,6 +584,10 @@ func handleLLMProviderCreate(c *gin.Context) {
|
||||||
|
|
||||||
if presetKey != "" {
|
if presetKey != "" {
|
||||||
preset := llmprovider.GetPreset(presetKey)
|
preset := llmprovider.GetPreset(presetKey)
|
||||||
|
if preset == nil && presetKey == "yaoagents" {
|
||||||
|
buildCloudPreset(info)
|
||||||
|
preset = llmprovider.GetPreset(presetKey)
|
||||||
|
}
|
||||||
if preset == nil {
|
if preset == nil {
|
||||||
respondError(c, http.StatusBadRequest, fmt.Sprintf("unknown preset: %s", presetKey))
|
respondError(c, http.StatusBadRequest, fmt.Sprintf("unknown preset: %s", presetKey))
|
||||||
return
|
return
|
||||||
|
|
@ -376,6 +620,7 @@ func handleLLMProviderCreate(c *gin.Context) {
|
||||||
}
|
}
|
||||||
for _, m := range preset.DefaultModels {
|
for _, m := range preset.DefaultModels {
|
||||||
if idSet[m.ID] {
|
if idSet[m.ID] {
|
||||||
|
m.Enabled = true
|
||||||
provider.Models = append(provider.Models, m)
|
provider.Models = append(provider.Models, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -383,6 +628,16 @@ func handleLLMProviderCreate(c *gin.Context) {
|
||||||
provider.Models = make([]llmprovider.ModelInfo, len(preset.DefaultModels))
|
provider.Models = make([]llmprovider.ModelInfo, len(preset.DefaultModels))
|
||||||
copy(provider.Models, preset.DefaultModels)
|
copy(provider.Models, preset.DefaultModels)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if preset.IsCloud && provider.APIKey == "" {
|
||||||
|
var saved map[string]interface{}
|
||||||
|
if setting.Global != nil {
|
||||||
|
saved, _ = setting.Global.GetMerged(info.UserID, info.TeamID, cloudNS)
|
||||||
|
}
|
||||||
|
if encKey, _ := saved["api_key"].(string); encKey != "" {
|
||||||
|
provider.APIKey = cloudDecrypt(encKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
provider.IsCustom = true
|
provider.IsCustom = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ func TestLLMGetPageData(t *testing.T) {
|
||||||
|
|
||||||
presets, ok := body["preset_providers"].([]interface{})
|
presets, ok := body["preset_providers"].([]interface{})
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, 5, len(presets), "should have 5 presets")
|
assert.GreaterOrEqual(t, len(presets), 5, "should have at least 5 presets")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMGetUnauthenticated(t *testing.T) {
|
func TestLLMGetUnauthenticated(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue