Merge pull request #1530 from trheyi/main

feat: LLM audio/connector/key enhancements, Agent role/tool/sandbox improvements, Image tools, and various fixes
This commit is contained in:
Max 2026-05-06 11:09:51 +08:00 committed by GitHub
commit a80fc9a7ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
156 changed files with 13275 additions and 2051 deletions

3
.gitignore vendored
View file

@ -84,3 +84,6 @@ sandbox/v2/PID-KILL-UPGRADE.md
sandbox/v2/*.md sandbox/v2/*.md
POSTGRESQL_COMPAT.md POSTGRESQL_COMPAT.md
openapi/setting/*.md openapi/setting/*.md
agent/docs/design/*.md
tools/README.md
tools/TOOL-REGISTRATION.md

View file

@ -1,61 +0,0 @@
> **DEPRECATED**: This license is no longer in effect. Please refer to the [LICENSE](LICENSE) file for current licensing terms.
# Commercial License for Yao
This document outlines the terms for the commercial license of the **Yao** project. While the Yao project is primarily licensed under the **Apache License, Version 2.0**, certain commercial use cases require a separate commercial license.
## 1. Commercial License Requirements
The following use cases require a commercial license:
1. **Application Hosting Services**
If you use Yao, or any derivative product (such as a forked or modified version of Yao), to provide Yao-based application hosting services (e.g., Software-as-a-Service (SaaS) or Platform-as-a-Service (PaaS)) to users, you must obtain a commercial license. This restriction applies regardless of whether the original Yao code or a modified version is used to host and manage applications on behalf of third-party users for commercial purposes.
**In addition**, if you provide hosting services for applications that are built using Yao (even if they are customized or modified versions of Yao), a commercial license is required.
### Definition: Application Hosting Services
"Application Hosting Services" refers to any service that involves hosting Yao-based applications or web applications created with Yao (including modified versions of Yao) for third-party users. This includes, but is not limited to:
- **Hosting platforms** providing software or services built on top of Yao for third-party users.
- **SaaS or PaaS offerings** where you manage and host applications that are based on or utilize Yao, either in their original or modified form.
- **Managed hosting services** where Yao is used as the underlying technology for applications deployed for external clients.
In these cases, a commercial license is required, whether you are using the original Yao code or a fork/modified version.
2. **AI Web Application Generation Services**
If you provide services that generate AI-driven web applications using Yao, or any derivative product (such as a fork or modified version of Yao), to third-party users, you are required to purchase a commercial license.
### Definition: AI Web Application Generation Services
"AI Web Application Generation Services" refers to any service or functionality that utilizes Yao (or any forked or modified version of Yao) to automate the creation of web applications with AI capabilities. This includes, but is not limited to, providing third-party users with:
- **Automated web application development** driven by AI, where the service generates complete or partial web applications.
- **Customizable web solutions** that are powered by AI and built using Yao as the core technology.
- **On-demand application generation** for specific client needs, using Yao to dynamically build, configure, or deploy applications for users.
In these cases, whether Yao is directly used, forked, or modified, a commercial license is required to operate legally.
## 2. Use Under Apache License 2.0
For all other uses, the **Apache License, Version 2.0** applies. You are free to use, modify, and distribute the Yao project under the terms of Apache 2.0 as long as your usage does not fall within the restricted scenarios outlined above.
## 3. Obtaining a Commercial License
To inquire about or obtain a commercial license, please contact us at:
- **Email**: [friends@iqka.com]
- **Website**: [https://moapi.ai/contact]
Pricing and terms for commercial licenses vary based on usage scenarios, user scale, and other factors.
## 4. Compliance and Auditing
If you have any questions about whether your use case requires a commercial license, please contact us for clarification. We reserve the right to audit usage for compliance and enforce commercial licensing terms where necessary.
## 5. Disclaimer
Failure to comply with these licensing terms may result in a violation of the Yao licensing agreement and could lead to legal action.
---
**Note:** This commercial license is supplementary to the Apache 2.0 license and only applies in specific commercial scenarios outlined above.

View file

@ -1,61 +0,0 @@
> **已废弃**: 本许可证已不再生效。请参考 [LICENSE](LICENSE) 文件获取当前的许可条款。
# Yao 商业许可证
本文件概述了 **Yao** 项目的商业许可证条款。虽然 Yao 项目主要使用 **Apache 许可证 2.0 版** 授权,但某些商业使用场景需要单独的商业许可证。
## 1. 商业许可证要求
以下使用场景需要商业许可证:
1. **应用托管服务**
如果您使用 Yao 或其衍生产品(如 Yao 的分支版本或修改版本)为用户提供基于 Yao 的应用托管服务例如软件即服务SaaS或平台即服务PaaS您必须获得商业许可证。此限制适用于无论是否使用原始 Yao 代码或修改版 Yao 代码,托管和管理应用程序的行为只要是为第三方用户提供的商业目的。
**此外**,如果您提供的托管服务是为使用 Yao 构建的应用程序提供托管服务(即使它们是定制或修改版的 Yao也需要获得商业许可证。
### 定义:应用托管服务
"应用托管服务"指任何涉及托管基于 Yao 的应用程序或使用 Yao 创建的 WEB 应用程序(包括 Yao 的修改版本)的服务,服务对象为第三方用户。包括但不限于:
- **托管平台** 提供基于 Yao 的软件或服务给第三方用户。
- **SaaS 或 PaaS 服务**,在这些服务中,您管理并托管基于或利用 Yao 的应用程序,可能是原版或修改版。
- **托管服务**,其中 Yao 被用作为客户外部部署应用程序的基础技术。
在这些情况下,无论是使用原始 Yao 代码还是修改版 Yao都需要获得商业许可证。
2. **AI WEB 应用生成服务**
如果您提供利用 Yao 或其衍生产品(如 Yao 的分支版本或修改版本)为第三方用户生成 AI 驱动的 WEB 应用程序的服务,您需要购买商业许可证。
### 定义AI WEB 应用生成服务
"AI WEB 应用生成服务"指任何利用 Yao或任何分支版本或修改版本的 Yao自动化创建具有 AI 功能的 WEB 应用程序的服务或功能。包括但不限于,为第三方用户提供以下服务:
- **AI 驱动的自动化 WEB 应用开发**,该服务生成完整或部分 WEB 应用程序。
- **可定制的 WEB 解决方案**,这些解决方案由 AI 提供支持,并以 Yao 作为核心技术构建。
- **按需应用生成**,根据特定客户需求,使用 Yao 动态构建、配置或部署应用程序。
在这些情况下,无论是直接使用 Yao还是使用其分支或修改版均需要获得商业许可证。
## 2. 使用 Apache 许可证 2.0
对于所有其他用途,**Apache 许可证 2.0 版** 适用。只要您的使用不属于上述限制的商业场景,您可以自由地根据 Apache 2.0 许可证使用、修改和分发 Yao 项目。
## 3. 获取商业许可证
如需咨询或获取商业许可证,请通过以下方式联系我们:
- **电子邮件**[friends@iqka.com]
- **网站**[https://moapi.ai/contact](https://moapi.ai/contact)
商业许可证的定价和条款会根据使用场景、用户规模及其他因素有所不同。
## 4. 合规与审计
如果您对您的使用场景是否需要商业许可证有任何疑问,请联系我们以获取澄清。我们保留审核使用情况以确保合规,并在必要时执行商业许可条款的权利。
## 5. 免责声明
未遵守这些许可条款可能会导致违反 Yao 许可证协议,并可能导致法律诉讼。
---
**注意:** 此商业许可证是 Apache 2.0 许可证的补充,仅适用于上述特定的商业场景。

18
LICENSE
View file

@ -1,13 +1,13 @@
# Open Source License # Open Source License
Yao App Engine is licensed under a modified version of the Apache License 2.0, with the following additional conditions: Yao Engine is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
1. Commercial Usage Terms: 1. Commercial Usage Terms:
Yao App Engine may be utilized commercially, A commercial license from the producer is required if: Yao Engine may be utilized commercially, A commercial license from the producer is required if:
a. Trademark and Branding Requirements a. Trademark and Branding Requirements
- The Yao App Engine console/application logo and copyright information must not be removed or modified - The Yao Engine / Yao Agents / Tai / Tai Link console/application logo and copyright information must not be removed or modified
- Logo and copyright information can only be changed with an authorization certificate issued through Yao Developer Certificate - Logo and copyright information can only be changed with an authorization certificate issued through Yao Developer Certificate
b. Authorization Verification Requirements b. Authorization Verification Requirements
@ -15,10 +15,16 @@ Yao App Engine is licensed under a modified version of the Apache License 2.0, w
- The Yao certificate verification logic, processes, and related pages (marked in code comments) must be preserved - The Yao certificate verification logic, processes, and related pages (marked in code comments) must be preserved
- The complete Yao certificate verification system must be maintained regardless of usage purpose - The complete Yao certificate verification system must be maintained regardless of usage purpose
c. Enterprise Scale Requirements
- Organizations with 50 or more employees, or with annual revenue exceeding USD 1,000,000, must obtain a commercial license from Infinite Wisdom Software.
- To obtain a commercial license, please contact us at https://yaoagents.com/enterprise
2. Contributor Agreement: 2. Contributor Agreement:
- The producer reserves the right to modify the open-source agreement terms As a contributor, you should agree that:
- Contributed code may be used for commercial purposes, including cloud business operations a. Infinite Wisdom Software can adjust the open-source agreement to be more strict or relaxed as deemed necessary.
b. Your contributed code may be used for commercial purposes, including but not limited to its cloud business operations.
All other rights and restrictions follow the Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0). All other rights and restrictions follow the Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0).
© 2025 Infinite Wisdom Software. © 2026 Infinite Wisdom Software.

30
LICENSE.zh-CN Normal file
View file

@ -0,0 +1,30 @@
# 开源许可证
Yao Engine 基于修改版 Apache License 2.0 授权,并附加以下额外条款:
1. 商业使用条款:
Yao Engine 可用于商业用途,但在以下情况下须向 Infinite Wisdom Software 获取商业授权许可:
a. 商标与品牌要求
- 不得删除或修改 Yao Engine / Yao Agents / Tai / Tai Link 控制台/应用程序的徽标及版权信息
- 徽标和版权信息仅可在持有通过 Yao 开发者证书颁发的授权证书时方可更改
b. 授权验证要求
- 必须保留 Yao 证书验证逻辑、流程及相关页面(已在代码注释中标注)
- 无论使用目的如何,必须维持完整的 Yao 证书验证系统
c. 企业规模要求
- 员工人数达到 50 人及以上,或年收入超过 100 万美元的企业,须向 Infinite Wisdom Software 购买商业授权许可。
- 如需获取商业授权,请访问 https://yaoagents.com/enterprise 联系我们。
2. 贡献者协议:
作为贡献者,您需同意以下条款:
a. Infinite Wisdom Software 可视需要对本开源协议进行更严格或更宽松的调整。
b. 您贡献的代码可被用于商业用途,包括但不限于云服务业务运营。
其他所有权利与限制遵循 Apache License 2.0http://www.apache.org/licenses/LICENSE-2.0)。
© 2026 Infinite Wisdom Software.

View file

@ -2,20 +2,19 @@ package assistant
import ( import (
"fmt" "fmt"
"log"
"time" "time"
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"
"github.com/yaoapp/yao/agent/llm" "github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
agentsandbox "github.com/yaoapp/yao/agent/sandbox" agentsandbox "github.com/yaoapp/yao/agent/sandbox"
sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types" "github.com/yaoapp/yao/llmprovider"
infraV2 "github.com/yaoapp/yao/sandbox/v2"
) )
// Stream stream the agent // Stream stream the agent
@ -167,30 +166,25 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
var sandboxLoadingMsgID string var sandboxLoadingMsgID string
// V2 sandbox state // V2 sandbox state
var v2Runner sandboxTypes.Runner var v2Init *sandboxV2InitResult
var v2Computer infraV2.Computer
var v2LoadingMsgID string
var v2Cfg *sandboxTypes.SandboxConfig
if ast.HasSandboxV2() { if ast.HasSandboxV2() {
ctx.Logger.Phase("Sandbox V2") ctx.Logger.Phase("Sandbox V2")
var err error var err error
var v2Cleanup func() v2Init, err = ast.initSandboxV2(ctx, opts)
v2Runner, v2Computer, v2Cfg, v2Cleanup, v2LoadingMsgID, err = ast.initSandboxV2(ctx, opts)
if err != nil { if err != nil {
ast.traceAgentFail(agentNode, err) ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err) ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err return nil, err
} }
sandboxCleanup = v2Cleanup sandboxCleanup = v2Init.Cleanup
ctx.Logger.PhaseComplete("Sandbox V2") ctx.Logger.PhaseComplete("Sandbox V2")
if v2Computer != nil { if v2Init.Computer != nil {
ci := v2Computer.ComputerInfo() ci := v2Init.Computer.ComputerInfo()
ctx.Logger.Trace("Node: %s (%s)", ci.NodeID, ci.Kind) ctx.Logger.Trace("Node: %s (%s)", ci.NodeID, ci.Kind)
if ci.BoxID != "" { if ci.BoxID != "" {
ctx.Logger.Trace("Computer: %s", ci.BoxID) ctx.Logger.Trace("Computer: %s", ci.BoxID)
} }
ctx.Logger.Trace("Workspace: %s", v2Cfg.WorkspaceID) ctx.Logger.Trace("Workspace: %s", v2Init.Config.WorkspaceID)
if conn, _, err := ast.GetConnector(ctx, opts); err == nil && conn != nil { if conn, _, err := ast.GetConnector(ctx, opts); err == nil && conn != nil {
ctx.Logger.Trace("Connector: %s", conn.ID()) ctx.Logger.Trace("Connector: %s", conn.ID())
} }
@ -330,22 +324,23 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// Execute the LLM streaming call // Execute the LLM streaming call
// Choose between sandbox execution or direct LLM execution // Choose between sandbox execution or direct LLM execution
if ast.HasSandboxV2() && v2Runner != nil && v2Computer != nil && v2Runner.Name() != "yao" { if ast.HasSandboxV2() && v2Init != nil && v2Init.Runner != nil && v2Init.Computer != nil && v2Init.Runner.Name() != "yao" {
// V2 Sandbox execution path (non-yao runners replace LLM.Stream) // V2 Sandbox execution path (non-yao runners replace LLM.Stream)
completionResponse, err = ast.executeSandboxV2Stream(ctx, &sandboxV2StreamParams{ completionResponse, err = ast.executeSandboxV2Stream(ctx, &sandboxV2StreamParams{
Messages: completionMessages, Messages: completionMessages,
AgentNode: agentNode, AgentNode: agentNode,
Handler: streamHandler, Handler: streamHandler,
Runner: v2Runner, Runner: v2Init.Runner,
Computer: v2Computer, Computer: v2Init.Computer,
Config: v2Cfg, Config: v2Init.Config,
LoadingMsgID: v2LoadingMsgID, LoadingMsgID: v2Init.LoadingMsgID,
Options: opts, Options: opts,
Roles: v2Init.Roles,
}) })
} else if ast.HasSandboxV2() && v2Runner != nil && v2Runner.Name() == "yao" { } else if ast.HasSandboxV2() && v2Init != nil && v2Init.Runner != nil && v2Init.Runner.Name() == "yao" {
// V2 yao runner: Prepare is done, close loading, fall through to LLM // V2 yao runner: Prepare is done, close loading, fall through to LLM
if v2LoadingMsgID != "" { if v2Init.LoadingMsgID != "" {
closeLoadingV2(ctx, v2LoadingMsgID, "") closeLoadingV2(ctx, v2Init.LoadingMsgID, "")
} }
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts) completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
} else if ast.HasSandbox() { } else if ast.HasSandbox() {
@ -570,11 +565,47 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err) ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err return nil, err
} }
} else if len(toolCallResponses) > 0 && !ast.HasSandbox() && !ast.isToolLoopDisabled() {
// No Next hook + has tool results + not sandbox → tool loop
ctx.Logger.Debug("Entering tool loop for tool result processing")
loopResponse, loopCompletion, loopTools, err := ast.executeToolLoop(ctx, &ToolLoopParams{
CompletionMessages: completionMessages,
CompletionOptions: completionOptions,
CompletionResponse: completionResponse,
ToolCallResponses: toolCallResponses,
FullMessages: fullMessages,
AgentNode: agentNode,
StreamHandler: streamHandler,
CreateResponse: createResponse,
Opts: opts,
})
if err != nil {
// Fallback to __yao.loop_fallback delegation
ctx.Logger.Warn("Tool loop failed: %v, falling back to loop_fallback", err)
fallbackDelegate := ast.buildLoopFallbackDelegate(ctx, fullMessages, completionResponse, toolCallResponses)
delegateResponse, delegateErr := ast.handleDelegation(ctx, fallbackDelegate, streamHandler)
if delegateErr != nil {
ctx.Logger.Warn("loop_fallback also failed: %v, using standard response", delegateErr)
finalResponse = ast.buildStandardResponse(&NextProcessContext{
Context: ctx,
CompletionResponse: completionResponse,
FullMessages: fullMessages,
ToolCallResponses: toolCallResponses,
StreamHandler: streamHandler,
CreateResponse: createResponse,
})
} else {
finalResponse = delegateResponse
}
} else {
completionResponse = loopCompletion
toolCallResponses = loopTools
finalResponse = loopResponse
}
} else { } else {
// No Next hook: use standard response // No tool calls, sandbox mode, or loop disabled: standard response
finalResponse = ast.buildStandardResponse(&NextProcessContext{ finalResponse = ast.buildStandardResponse(&NextProcessContext{
Context: ctx, Context: ctx,
NextResponse: nil,
CompletionResponse: completionResponse, CompletionResponse: completionResponse,
FullMessages: fullMessages, FullMessages: fullMessages,
ToolCallResponses: toolCallResponses, ToolCallResponses: toolCallResponses,
@ -627,35 +658,41 @@ 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 > defaultConnector (fallback) // 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
} }
if connectorID == "" { // Extract identity for role-based resolution
connectorID = defaultConnector var identity llmprovider.Identity
if ctx != nil && ctx.Authorized != nil {
identity = ctx.Authorized
} }
if connectorID == "" { // Unified resolution: explicit connector / use:: prefix / empty → all handled
return nil, nil, fmt.Errorf("connector not specified") conn, caps, err := llm.ResolveConnector(cid, identity)
if err == nil {
return conn, caps, nil
}
// Legacy fallback
if defaultConnector != "" {
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
}
}
if fallback := findCapableConnector(); fallback != "" {
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
}
} }
conn, err := connector.Select(connectorID) return nil, nil, fmt.Errorf("connector not specified")
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
@ -794,9 +831,10 @@ func (ast *Assistant) buildToolRetryMessages(
// Add assistant message with tool calls // Add assistant message with tool calls
assistantMsg := context.Message{ assistantMsg := context.Message{
Role: context.RoleAssistant, Role: context.RoleAssistant,
Content: completionResponse.Content, Content: completionResponse.Content,
ToolCalls: completionResponse.ToolCalls, ReasoningContent: completionResponse.ReasoningContent,
ToolCalls: completionResponse.ToolCalls,
} }
retryMessages = append(retryMessages, assistantMsg) retryMessages = append(retryMessages, assistantMsg)

View file

@ -161,9 +161,6 @@ func (ast *Assistant) Validate() error {
if ast.Name == "" { if ast.Name == "" {
return fmt.Errorf("name is required") return fmt.Errorf("name is required")
} }
if ast.Connector == "" {
return fmt.Errorf("connector is required")
}
return nil return nil
} }

View file

@ -18,6 +18,7 @@ import (
) )
func TestGetChatKBID(t *testing.T) { func TestGetChatKBID(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
t.Run("WithTeamAndUser", func(t *testing.T) { t.Run("WithTeamAndUser", func(t *testing.T) {
teamID := "5659-5504-2879" teamID := "5659-5504-2879"
userID := "4287-9400-2030-0504" userID := "4287-9400-2030-0504"
@ -81,6 +82,7 @@ func TestGetChatKBID(t *testing.T) {
} }
func TestPrepareKBCollection(t *testing.T) { func TestPrepareKBCollection(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
testutils.Prepare(t) testutils.Prepare(t)
defer testutils.Clean(t) defer testutils.Clean(t)

View file

@ -881,20 +881,11 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
// Init init the assistant // Init init the assistant
// Choose the connector and initialize the assistant // Choose the connector and initialize the assistant
func (ast *Assistant) initialize() error { func (ast *Assistant) initialize() error {
conn := defaultConnector
if ast.Connector != "" {
conn = ast.Connector
}
ast.Connector = conn
// Register scripts as process handlers
if len(ast.Scripts) > 0 { if len(ast.Scripts) > 0 {
if err := ast.RegisterScripts(); err != nil { if err := ast.RegisterScripts(); err != nil {
return fmt.Errorf("failed to register scripts: %w", err) return fmt.Errorf("failed to register scripts: %w", err)
} }
} }
return nil return nil
} }

View file

@ -29,21 +29,28 @@ var systemAgents = []string{
"entity", "entity",
"vision", "vision",
"fetch", "fetch",
"loop_fallback",
} }
// 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)
Keyword string // Connector for __yao.keyword agent Default string // Default connector for the "default" role
QueryDSL string // Connector for __yao.querydsl agent Light string // Default connector for the "light" role
Title string // Connector for __yao.title agent Vision string // Default connector for the "vision" role
Prompt string // Connector for __yao.prompt agent Audio string // Default connector for the "audio" role
RobotPrompt string // Connector for __yao.robot_prompt agent Heavy string // Default connector for the "heavy" role (complex reasoning)
NeedSearch string // Connector for __yao.needsearch agent
Entity string // Connector for __yao.entity agent // Per-agent overrides (consumed by resolveSystemConnector → ast.Connector)
Vision string // Connector for vision capabilities Keyword string // Connector for __yao.keyword agent
Voice string // Connector for voice/STT capabilities QueryDSL string // Connector for __yao.querydsl agent
Title string // Connector for __yao.title agent
Prompt string // Connector for __yao.prompt agent
RobotPrompt string // Connector for __yao.robot_prompt agent
NeedSearch string // Connector for __yao.needsearch agent
Entity string // Connector for __yao.entity agent
LoopFallback string // Connector for __yao.loop_fallback agent
} }
// 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)
@ -160,10 +167,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)
@ -207,97 +213,36 @@ 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 > defaultConnector > fallback to first capable connector // 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 {
case "__yao.keyword":
if systemConfig.Keyword != "" {
return systemConfig.Keyword
}
case "__yao.querydsl":
if systemConfig.QueryDSL != "" {
return systemConfig.QueryDSL
}
case "__yao.title":
if systemConfig.Title != "" {
return systemConfig.Title
}
case "__yao.prompt":
if systemConfig.Prompt != "" {
return systemConfig.Prompt
}
case "__yao.robot_prompt":
if systemConfig.RobotPrompt != "" {
return systemConfig.RobotPrompt
}
case "__yao.needsearch":
if systemConfig.NeedSearch != "" {
return systemConfig.NeedSearch
}
case "__yao.entity":
if systemConfig.Entity != "" {
return systemConfig.Entity
}
case "__yao.vision":
if systemConfig.Vision != "" {
return systemConfig.Vision
}
case "__yao.voice":
if systemConfig.Voice != "" {
return systemConfig.Voice
}
}
// Try system default
if systemConfig.Default != "" {
return systemConfig.Default
}
} }
switch agentID {
// Try global default connector case "__yao.keyword":
if defaultConnector != "" { return systemConfig.Keyword
return defaultConnector case "__yao.querydsl":
return systemConfig.QueryDSL
case "__yao.title":
return systemConfig.Title
case "__yao.prompt":
return systemConfig.Prompt
case "__yao.robot_prompt":
return systemConfig.RobotPrompt
case "__yao.needsearch":
return systemConfig.NeedSearch
case "__yao.entity":
return systemConfig.Entity
case "__yao.vision":
return systemConfig.Vision
case "__yao.audio":
return systemConfig.Audio
case "__yao.loop_fallback":
return systemConfig.LoopFallback
} }
return ""
// Fallback: find first connector that supports tool calling
return findCapableConnector()
}
// GetVisionConnector returns the connector for vision capabilities.
// Priority: system.vision > system.default > defaultConnector > findCapableConnector
func GetVisionConnector() string {
if systemConfig != nil {
if systemConfig.Vision != "" {
return systemConfig.Vision
}
if systemConfig.Default != "" {
return systemConfig.Default
}
}
if defaultConnector != "" {
return defaultConnector
}
return findCapableConnector()
}
// GetVoiceConnector returns the connector for voice/STT capabilities.
// Priority: system.voice > system.default > defaultConnector > findCapableConnector
func GetVoiceConnector() string {
if systemConfig != nil {
if systemConfig.Voice != "" {
return systemConfig.Voice
}
if systemConfig.Default != "" {
return systemConfig.Default
}
}
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"))
}

295
agent/assistant/loop.go Normal file
View file

@ -0,0 +1,295 @@
package assistant
import (
"fmt"
"strings"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/trace/types"
)
// ToolLoopParams holds all parameters needed by executeToolLoop.
type ToolLoopParams struct {
CompletionMessages []context.Message
CompletionOptions *context.CompletionOptions
CompletionResponse *context.CompletionResponse
ToolCallResponses []context.ToolCallResponse
FullMessages []context.Message
AgentNode types.Node
StreamHandler message.StreamFunc
CreateResponse *context.HookCreateResponse
Opts *context.Options
}
// executeToolLoop feeds tool results back to the LLM in a loop until
// the LLM produces a final text response (no more tool_calls) or
// the maximum number of turns is reached.
//
// Returns the final Response, the last CompletionResponse (for tracing),
// accumulated ToolCallResponses, and any error.
func (ast *Assistant) executeToolLoop(
ctx *context.Context,
params *ToolLoopParams,
) (*context.Response, *context.CompletionResponse, []context.ToolCallResponse, error) {
maxTurns := ast.getMaxToolLoopTurns()
currentMessages := params.CompletionMessages
currentCompletion := params.CompletionResponse
allToolResponses := make([]context.ToolCallResponse, 0, len(params.ToolCallResponses))
allToolResponses = append(allToolResponses, params.ToolCallResponses...)
for turn := 0; turn < maxTurns; turn++ {
ctx.Logger.Debug("Tool loop turn %d/%d", turn+1, maxTurns)
// Build messages: previous messages + assistant(tool_calls) + tool results
loopMessages := buildToolLoopMessages(currentMessages, currentCompletion, allToolResponses[len(allToolResponses)-len(params.ToolCallResponses):])
// Step tracking: LLM call
ast.BeginStep(ctx, context.StepTypeLLM, map[string]interface{}{
"messages": loopMessages,
"loop_turn": turn + 1,
})
// Call LLM with tool results included
newCompletion, err := ast.executeLLMStream(ctx, loopMessages, params.CompletionOptions, params.AgentNode, params.StreamHandler, params.Opts)
if err != nil {
return nil, nil, nil, fmt.Errorf("tool loop LLM call failed (turn %d): %w", turn+1, err)
}
ast.CompleteStep(ctx, map[string]interface{}{
"content": newCompletion.Content,
"tool_calls": newCompletion.ToolCalls,
})
// No tool_calls → LLM gave final text response
if newCompletion.ToolCalls == nil || len(newCompletion.ToolCalls) == 0 {
finalResponse := ast.buildStandardResponse(&NextProcessContext{
Context: ctx,
CompletionResponse: newCompletion,
FullMessages: params.FullMessages,
ToolCallResponses: allToolResponses,
StreamHandler: params.StreamHandler,
CreateResponse: params.CreateResponse,
})
return finalResponse, newCompletion, allToolResponses, nil
}
// Has tool_calls → execute them
ast.BeginStep(ctx, context.StepTypeTool, map[string]interface{}{
"tool_calls": newCompletion.ToolCalls,
"loop_turn": turn + 1,
})
toolResults, _ := ast.executeToolCalls(ctx, newCompletion.ToolCalls, 0)
// Convert ToolCallResult → ToolCallResponse
toolCallArgsMap := make(map[string]interface{})
for _, tc := range newCompletion.ToolCalls {
toolCallArgsMap[tc.ID] = tc.Function.Arguments
}
turnResponses := make([]context.ToolCallResponse, len(toolResults))
for i, result := range toolResults {
parsedContent, _ := result.ParsedContent()
turnResponses[i] = context.ToolCallResponse{
ToolCallID: result.ToolCallID,
Server: result.Server(),
Tool: result.Tool(),
Arguments: toolCallArgsMap[result.ToolCallID],
Result: parsedContent,
Error: "",
}
if result.Error != nil {
turnResponses[i].Error = result.Error.Error()
}
}
ast.CompleteStep(ctx, map[string]interface{}{
"results": turnResponses,
"loop_turn": turn + 1,
})
// Accumulate and prepare next iteration
allToolResponses = append(allToolResponses, turnResponses...)
currentMessages = loopMessages
currentCompletion = newCompletion
params.ToolCallResponses = turnResponses
}
return nil, nil, allToolResponses, fmt.Errorf("tool loop reached max turns (%d)", maxTurns)
}
// buildToolLoopMessages constructs the message sequence for the next LLM call:
// previous messages + assistant message (with tool_calls) + tool result messages.
// Unlike buildToolRetryMessages, this does NOT append a retry system prompt.
func buildToolLoopMessages(
previousMessages []context.Message,
completion *context.CompletionResponse,
toolResponses []context.ToolCallResponse,
) []context.Message {
messages := make([]context.Message, 0, len(previousMessages)+len(toolResponses)+2)
messages = append(messages, previousMessages...)
// Assistant message with tool_calls
messages = append(messages, context.Message{
Role: context.RoleAssistant,
Content: completion.Content,
ReasoningContent: completion.ReasoningContent,
ToolCalls: completion.ToolCalls,
})
// One tool-role message per tool call result
for _, tr := range toolResponses {
var content string
if tr.Error != "" {
content = fmt.Sprintf("Error: %s", tr.Error)
} else if tr.Result != nil {
raw, _ := jsoniter.MarshalToString(tr.Result)
content = raw
}
toolCallID := tr.ToolCallID
messages = append(messages, context.Message{
Role: context.RoleTool,
Content: content,
ToolCallID: &toolCallID,
})
}
return messages
}
// isToolLoopDisabled checks mcp.options.tool_loop.
// Default is enabled (returns false). Only disabled when explicitly set to false.
func (ast *Assistant) isToolLoopDisabled() bool {
if ast.MCP == nil || ast.MCP.Options == nil {
return false
}
if v, ok := ast.MCP.Options["tool_loop"]; ok {
if enabled, ok := v.(bool); ok {
return !enabled
}
}
return false
}
// getMaxToolLoopTurns reads mcp.options.max_turn. Default is 5.
func (ast *Assistant) getMaxToolLoopTurns() int {
const defaultMaxTurns = 5
if ast.MCP == nil || ast.MCP.Options == nil {
return defaultMaxTurns
}
if v, ok := ast.MCP.Options["max_turn"]; ok {
switch n := v.(type) {
case float64:
if n > 0 {
return int(n)
}
case int:
if n > 0 {
return n
}
}
}
return defaultMaxTurns
}
// ---------------------------------------------------------------------------
// Fallback: __yao.loop_fallback delegation (used when tool loop fails/maxes out)
// ---------------------------------------------------------------------------
// buildLoopFallbackDelegate constructs a DelegateConfig for __yao.loop_fallback.
// It packages conversation context and tool results into a Markdown user message.
func (ast *Assistant) buildLoopFallbackDelegate(
ctx *context.Context,
fullMessages []context.Message,
completion *context.CompletionResponse,
toolResults []context.ToolCallResponse,
) *context.DelegateConfig {
content := buildLoopFallbackMarkdown(fullMessages, toolResults)
return &context.DelegateConfig{
AgentID: "__yao.loop_fallback",
Messages: []context.Message{
{Role: context.RoleUser, Content: content},
},
}
}
// buildLoopFallbackMarkdown formats context into a Markdown string for the fallback agent.
func buildLoopFallbackMarkdown(
fullMessages []context.Message,
toolResults []context.ToolCallResponse,
) string {
var sb strings.Builder
sb.WriteString("## Assistant Context\n\n")
for _, msg := range fullMessages {
if msg.Role == context.RoleSystem {
if text := messageText(msg); text != "" {
sb.WriteString(text)
sb.WriteString("\n\n")
}
}
}
sb.WriteString("## Conversation\n\n")
for _, msg := range fullMessages {
text := messageText(msg)
switch msg.Role {
case context.RoleUser:
if text != "" {
sb.WriteString(fmt.Sprintf("**User**: %s\n\n", text))
}
case context.RoleAssistant:
if text != "" {
sb.WriteString(fmt.Sprintf("**Assistant**: %s\n\n", text))
}
}
}
sb.WriteString("## Tool Results\n\n")
for _, tr := range toolResults {
toolName := tr.Tool
if tr.Server != "" {
toolName = tr.Server + "." + tr.Tool
}
sb.WriteString(fmt.Sprintf("### %s\n", toolName))
if tr.Error != "" {
sb.WriteString(fmt.Sprintf("Error: %s\n\n", tr.Error))
} else {
raw, _ := jsoniter.MarshalToString(tr.Result)
sb.WriteString(fmt.Sprintf("```json\n%s\n```\n\n", raw))
}
}
sb.WriteString("---\nPlease answer the user's question based on the above context and tool results.\n")
sb.WriteString("Respond in the same language as the user.\n")
return sb.String()
}
// messageText extracts text content from a message's Content field.
// Content can be a string or an array of content parts (multimodal).
func messageText(msg context.Message) string {
if msg.Content == nil {
return ""
}
if str, ok := msg.Content.(string); ok {
return str
}
if parts, ok := msg.Content.([]interface{}); ok {
var texts []string
for _, part := range parts {
if partMap, ok := part.(map[string]interface{}); ok {
if partMap["type"] == "text" {
if text, ok := partMap["text"].(string); ok {
texts = append(texts, text)
}
}
}
}
return strings.Join(texts, "\n")
}
return fmt.Sprintf("%v", msg.Content)
}

View file

@ -375,12 +375,6 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
return []ToolCallResult{result}, true return []ToolCallResult{result}, true
} }
// Check if result is an error
if callResult.IsError {
result.Error = fmt.Errorf("MCP tool error")
result.IsRetryableError = false // MCP internal error is not retryable
}
// Serialize the Content field only ([]ToolContent) // Serialize the Content field only ([]ToolContent)
contentBytes, err := jsoniter.Marshal(callResult.Content) contentBytes, err := jsoniter.Marshal(callResult.Content)
if err != nil { if err != nil {
@ -396,6 +390,19 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
} }
result.Content = string(contentBytes) result.Content = string(contentBytes)
// Check if result is an error — include actual content so LLM can see the details
if callResult.IsError {
result.Error = fmt.Errorf("tool call error: %s", result.Content)
result.IsRetryableError = isRetryableToolError(result.Error)
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolCall.Function.Name, result.Content, result.IsRetryableError)
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
return []ToolCallResult{result}, true
}
ctx.Logger.ToolComplete(toolCall.Function.Name, true) ctx.Logger.ToolComplete(toolCall.Function.Name, true)
if toolNode != nil { if toolNode != nil {
@ -545,7 +552,7 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
// Prepare parallel trace inputs // Prepare parallel trace inputs
var parallelInputs []types.TraceParallelInput var parallelInputs []types.TraceParallelInput
mcpCalls := make([]mcpTypes.ToolCall, 0, len(toolCalls)) mcpCalls := make([]mcpTypes.ToolCall, 0, len(toolCalls))
callMap := make(map[string]agentContext.ToolCall) orderedCalls := make([]agentContext.ToolCall, 0, len(toolCalls))
for _, tc := range toolCalls { for _, tc := range toolCalls {
_, toolName, ok := ParseMCPToolName(tc.Function.Name) _, toolName, ok := ParseMCPToolName(tc.Function.Name)
@ -565,7 +572,7 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
Name: toolName, Name: toolName,
Arguments: args, Arguments: args,
}) })
callMap[toolName] = tc orderedCalls = append(orderedCalls, tc)
ctx.Logger.ToolStart(tc.Function.Name) ctx.Logger.ToolStart(tc.Function.Name)
// Add trace input for this tool // Add trace input for this tool
@ -606,10 +613,8 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
if node != nil { if node != nil {
node.Fail(err) node.Fail(err)
} }
if i < len(mcpCalls) { if i < len(orderedCalls) {
if tc, ok := callMap[mcpCalls[i].Name]; ok { ctx.Logger.ToolComplete(orderedCalls[i].Function.Name, false)
ctx.Logger.ToolComplete(tc.Function.Name, false)
}
} }
} }
return nil, true return nil, true
@ -621,7 +626,7 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
for i, mcpResult := range mcpResponse.Results { for i, mcpResult := range mcpResponse.Results {
toolName := mcpCalls[i].Name toolName := mcpCalls[i].Name
originalCall := callMap[toolName] originalCall := orderedCalls[i]
var toolNode types.Node var toolNode types.Node
if i < len(toolNodes) { if i < len(toolNodes) {
toolNode = toolNodes[i] toolNode = toolNodes[i]
@ -809,19 +814,12 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
toolNode.Fail(err) toolNode.Fail(err)
} }
} else { } else {
// Check if result is an error
if mcpResult.IsError {
result.Error = fmt.Errorf("MCP tool error")
result.IsRetryableError = false // MCP internal error is not retryable
hasErrors = true
}
// Serialize the Content field only ([]ToolContent) // Serialize the Content field only ([]ToolContent)
contentBytes, err := jsoniter.Marshal(mcpResult.Content) contentBytes, err := jsoniter.Marshal(mcpResult.Content)
if err != nil { if err != nil {
result.Error = err result.Error = err
result.Content = fmt.Sprintf("Failed to serialize result: %v", err) result.Content = fmt.Sprintf("Failed to serialize result: %v", err)
result.IsRetryableError = false // Serialization error is not retryable result.IsRetryableError = false
hasErrors = true hasErrors = true
ctx.Logger.ToolComplete(tc.Function.Name, false) ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil { if toolNode != nil {
@ -829,11 +827,24 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
} }
} else { } else {
result.Content = string(contentBytes) result.Content = string(contentBytes)
ctx.Logger.ToolComplete(tc.Function.Name, !mcpResult.IsError)
if toolNode != nil { // Check if result is an error — include actual content so LLM can see the details
toolNode.Complete(map[string]any{ if mcpResult.IsError {
"result": mcpResult.Content, result.Error = fmt.Errorf("tool call error: %s", result.Content)
}) result.IsRetryableError = isRetryableToolError(result.Error)
hasErrors = true
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolName, result.Content, result.IsRetryableError)
ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
} else {
ctx.Logger.ToolComplete(tc.Function.Name, true)
if toolNode != nil {
toolNode.Complete(map[string]any{
"result": mcpResult.Content,
})
}
} }
} }
} }

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,30 +267,26 @@ 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 host, ok := setting["host"].(string); ok { if lc, ok := conn.(goullm.LLMConnector); ok {
execOpts.ConnectorHost = host execOpts.ConnectorHost = lc.GetURL()
} execOpts.ConnectorKey = lc.GetKey()
if key, ok := setting["key"].(string); ok { execOpts.Model = lc.GetModel()
execOpts.ConnectorKey = key } else {
} if host, ok := setting["host"].(string); ok {
if model, ok := setting["model"].(string); ok { execOpts.ConnectorHost = host
execOpts.Model = model }
} if key, ok := setting["key"].(string); ok {
execOpts.ConnectorKey = key
// Extract extra connector options (thinking, max_tokens, temperature, etc.) }
// These are backend-specific parameters that need to be passed through to the proxy if model, ok := setting["model"].(string); ok {
connectorOptions := make(map[string]interface{}) execOpts.Model = model
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
} }
} }
// Whitelist-filter remaining settings for sandbox proxy options
connectorOptions := connector.FilterRequestBodyParams(setting, conn)
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

@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
@ -15,6 +16,7 @@ import (
sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types" sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types"
store "github.com/yaoapp/yao/agent/store/types" store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/config" "github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/llmprovider"
infraV2 "github.com/yaoapp/yao/sandbox/v2" infraV2 "github.com/yaoapp/yao/sandbox/v2"
traceTypes "github.com/yaoapp/yao/trace/types" traceTypes "github.com/yaoapp/yao/trace/types"
"github.com/yaoapp/yao/workspace" "github.com/yaoapp/yao/workspace"
@ -25,15 +27,22 @@ func (ast *Assistant) HasSandboxV2() bool {
return ast.SandboxV2 != nil return ast.SandboxV2 != nil
} }
// sandboxV2InitResult bundles everything returned by initSandboxV2.
type sandboxV2InitResult struct {
Runner sandboxTypes.Runner
Computer infraV2.Computer
Config *sandboxTypes.SandboxConfig
Cleanup func()
LoadingMsgID string
Roles map[string]connector.Connector
}
// initSandboxV2 initializes the V2 sandbox: obtains a Computer, gets a Runner, // initSandboxV2 initializes the V2 sandbox: obtains a Computer, gets a Runner,
// runs Prepare, and returns the runner, computer, a per-request copy of the // resolves the role matrix, runs Prepare, and returns the result.
// SandboxConfig, cleanup closure, loading message ID, and any error.
// //
// A shallow copy of ast.SandboxV2 is made so that concurrent requests to the // A shallow copy of ast.SandboxV2 is made so that concurrent requests to the
// same assistant each get their own mutable config (Owner, ID, NodeID, etc.). // same assistant each get their own mutable config (Owner, ID, NodeID, etc.).
func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) ( func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) (*sandboxV2InitResult, error) {
sandboxTypes.Runner, infraV2.Computer, *sandboxTypes.SandboxConfig, func(), string, error,
) {
cfgCopy := *ast.SandboxV2 cfgCopy := *ast.SandboxV2
cfg := &cfgCopy cfg := &cfgCopy
manager := infraV2.M() manager := infraV2.M()
@ -52,9 +61,12 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
conn, _, err := ast.GetConnector(ctx, opts) conn, _, err := ast.GetConnector(ctx, opts)
if err != nil && cfg.Runner.Name != "yao" { if err != nil && cfg.Runner.Name != "yao" {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, nil, nil, nil, "", fmt.Errorf("get connector: %w", err) return nil, fmt.Errorf("get connector: %w", err)
} }
// 1b. Resolve role matrix once; passed to both Prepare and Stream.
roles := resolveRoles(conn, ctx.Authorized)
// 2. Build human-readable DisplayName from real Agent name + Workspace name. // 2. Build human-readable DisplayName from real Agent name + Workspace name.
cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.Name) cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.Name)
@ -89,7 +101,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager) computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager)
if err != nil { if err != nil {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, nil, nil, nil, "", fmt.Errorf("getComputer failed: %w", err) return nil, fmt.Errorf("getComputer failed: %w", err)
} }
_ = identifier _ = identifier
@ -98,7 +110,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
if err != nil { if err != nil {
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager) sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, nil, nil, nil, "", fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err) return nil, fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err)
} }
// 5. Resolve assistant directory and skills subdirectory. // 5. Resolve assistant directory and skills subdirectory.
@ -129,6 +141,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
Computer: computer, Computer: computer,
Config: cfg, Config: cfg,
Connector: conn, Connector: conn,
Roles: roles,
AssistantID: ast.ID, AssistantID: ast.ID,
SkillsDir: skillsDir, SkillsDir: skillsDir,
AssistantDir: assistantDir, AssistantDir: assistantDir,
@ -140,11 +153,9 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
runner.Cleanup(stdCtx, computer) runner.Cleanup(stdCtx, computer)
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager) sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, nil, nil, nil, "", fmt.Errorf("runner.Prepare: %w", err) return nil, fmt.Errorf("runner.Prepare: %w", err)
} }
// Inject computer + workspace into context so Create/Next hooks
// can access ctx.computer and ctx.workspace.
ctx.SetComputer(computer) ctx.SetComputer(computer)
cleanup := func() { cleanup := func() {
@ -154,7 +165,14 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
sandboxv2.LifecycleAction(cleanCtx, cfg, computer, manager) sandboxv2.LifecycleAction(cleanCtx, cfg, computer, manager)
} }
return runner, computer, cfg, cleanup, loadingMsgID, nil return &sandboxV2InitResult{
Runner: runner,
Computer: computer,
Config: cfg,
Cleanup: cleanup,
LoadingMsgID: loadingMsgID,
Roles: roles,
}, nil
} }
// sandboxV2StreamParams groups arguments for executeSandboxV2Stream. // sandboxV2StreamParams groups arguments for executeSandboxV2Stream.
@ -167,6 +185,7 @@ type sandboxV2StreamParams struct {
Config *sandboxTypes.SandboxConfig Config *sandboxTypes.SandboxConfig
LoadingMsgID string LoadingMsgID string
Options *context.Options Options *context.Options
Roles map[string]connector.Connector
} }
// executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the // executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the
@ -208,6 +227,7 @@ func (ast *Assistant) executeSandboxV2Stream(
Computer: p.Computer, Computer: p.Computer,
Config: cfg, Config: cfg,
Connector: conn, Connector: conn,
Roles: p.Roles,
AssistantID: ast.ID, AssistantID: ast.ID,
Messages: p.Messages, Messages: p.Messages,
SystemPrompt: systemPrompt, SystemPrompt: systemPrompt,
@ -229,6 +249,25 @@ func (ast *Assistant) executeSandboxV2Stream(
return sandboxv2.ExecuteSandboxStream(ctx, execReq, p.Handler) return sandboxv2.ExecuteSandboxStream(ctx, execReq, p.Handler)
} }
// resolveRoles builds the role → connector map using the llmprovider role system.
// The primary connector (user-selected or system default) becomes "default";
// other roles (heavy, light, vision) are fetched from llmprovider settings.
func resolveRoles(conn connector.Connector, identity llmprovider.Identity) map[string]connector.Connector {
roles := map[string]connector.Connector{}
if conn != nil {
roles["default"] = conn
}
if llmprovider.Global == nil || identity == nil {
return roles
}
for _, role := range []string{"heavy", "light", "vision"} {
if c, err := llmprovider.Global.GetRoleModelBy(role, identity); err == nil {
roles[role] = c
}
}
return roles
}
// initStandaloneWorkspace loads the workspace FS into context when no sandbox // initStandaloneWorkspace loads the workspace FS into context when no sandbox
// is configured but the user selected a workspace (metadata["workspace_id"]). // is configured but the user selected a workspace (metadata["workspace_id"]).
func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) { func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) {

View file

@ -91,7 +91,7 @@ func parseSearchField(search any) *SearchIntent {
if v { if v {
return &SearchIntent{ return &SearchIntent{
NeedSearch: true, NeedSearch: true,
SearchTypes: []string{"web", "kb", "db"}, SearchTypes: []string{"web"}, // TODO: 恢复 KB/DB 搜索时改回 []string{"web", "kb", "db"}
Confidence: 1.0, Confidence: 1.0,
Reason: "enabled by hook", Reason: "enabled by hook",
} }

View file

@ -62,6 +62,7 @@ func (c *authTestCollections) cleanup(ctx context.Context, t *testing.T) {
// FilterKBCollectionsByAuth filters collections based on user authorization. // FilterKBCollectionsByAuth filters collections based on user authorization.
func TestKBCollectionAuthFilter(t *testing.T) { func TestKBCollectionAuthFilter(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
testutils.Prepare(t) testutils.Prepare(t)
defer testutils.Clean(t) defer testutils.Clean(t)
@ -155,6 +156,7 @@ func TestKBCollectionAuthFilter(t *testing.T) {
// ========== DB Auth Wheres Tests ========== // ========== DB Auth Wheres Tests ==========
func TestDBAuthWheresFilter(t *testing.T) { func TestDBAuthWheresFilter(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
// Note: This test doesn't need KB, just tests the BuildDBAuthWheres function // Note: This test doesn't need KB, just tests the BuildDBAuthWheres function
t.Run("TeamOnlyGeneratesCorrectWheres", func(t *testing.T) { t.Run("TeamOnlyGeneratesCorrectWheres", func(t *testing.T) {
ctx := createAuthContext(TestUserA, TestTeam1, true, false) ctx := createAuthContext(TestUserA, TestTeam1, true, false)
@ -273,6 +275,7 @@ func TestDBAuthWheresFilter(t *testing.T) {
// ========== KB Search Integration Tests ========== // ========== KB Search Integration Tests ==========
func TestKBSearchIntegration(t *testing.T) { func TestKBSearchIntegration(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
testutils.Prepare(t) testutils.Prepare(t)
defer testutils.Clean(t) defer testutils.Clean(t)

View file

@ -52,23 +52,28 @@ func TestSearchAutoFull(t *testing.T) {
assert.Equal(t, 3, ast.Search.Web.MaxResults) assert.Equal(t, 3, ast.Search.Web.MaxResults)
}) })
// KB/DB search temporarily disabled
t.Run("ShouldHaveKBSearchConfig", func(t *testing.T) { t.Run("ShouldHaveKBSearchConfig", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.Search.KB, "kb search config should be set") assert.NotNil(t, ast.Search.KB, "kb search config should be set")
assert.Equal(t, 0.7, ast.Search.KB.Threshold) assert.Equal(t, 0.7, ast.Search.KB.Threshold)
assert.False(t, ast.Search.KB.Graph) assert.False(t, ast.Search.KB.Graph)
}) })
t.Run("ShouldHaveDBSearchConfig", func(t *testing.T) { t.Run("ShouldHaveDBSearchConfig", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.Search.DB, "db search config should be set") assert.NotNil(t, ast.Search.DB, "db search config should be set")
assert.Equal(t, 10, ast.Search.DB.MaxResults) assert.Equal(t, 10, ast.Search.DB.MaxResults)
}) })
t.Run("ShouldHaveKBCollections", func(t *testing.T) { t.Run("ShouldHaveKBCollections", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.KB, "kb config should be set") assert.NotNil(t, ast.KB, "kb config should be set")
assert.Contains(t, ast.KB.Collections, "test-collection") assert.Contains(t, ast.KB.Collections, "test-collection")
}) })
t.Run("ShouldHaveDBModels", func(t *testing.T) { t.Run("ShouldHaveDBModels", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.DB, "db config should be set") assert.NotNil(t, ast.DB, "db config should be set")
assert.Contains(t, ast.DB.Models, "user") assert.Contains(t, ast.DB.Models, "user")
assert.Contains(t, ast.DB.Models, "article") assert.Contains(t, ast.DB.Models, "article")
@ -87,6 +92,7 @@ func TestSearchAutoFull(t *testing.T) {
}) })
t.Run("StreamShouldExecuteMultipleSearchTypes", func(t *testing.T) { t.Run("StreamShouldExecuteMultipleSearchTypes", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
// Get agent via assistant.Get (required for Stream) // Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-full") agent, err := assistant.Get("tests.search-auto-full")
require.NoError(t, err) require.NoError(t, err)

View file

@ -273,14 +273,14 @@ func TestProcessCall_Timeout_Short(t *testing.T) {
testutils.Prepare(t) testutils.Prepare(t)
defer testutils.Clean(t) defer testutils.Clean(t)
// Set timeout=2 seconds — LLM round-trip will certainly exceed this. // Set timeout=1 second — LLM round-trip will certainly exceed this.
// Verifies that the timeout parameter is respected and produces an error. // Verifies that the timeout parameter is respected and produces an error.
proc := newLLMProcess(t, "agent.call", map[string]interface{}{ proc := newLLMProcess(t, "agent.call", map[string]interface{}{
"assistant_id": "tests.simple-greeting", "assistant_id": "tests.simple-greeting",
"messages": []interface{}{ "messages": []interface{}{
map[string]interface{}{"role": "user", "content": "Tell me a very long story about the history of computing."}, map[string]interface{}{"role": "user", "content": "Tell me a very long story about the history of computing."},
}, },
"timeout": 2, "timeout": 1,
}) })
err := proc.Execute() err := proc.Execute()

View file

@ -77,7 +77,15 @@ func parseContentParts(ctx *agentContext.Context, message agentContext.Message,
for _, part := range content { for _, part := range content {
parsedPart, refs, err := parseContentPart(ctx, part, options) parsedPart, refs, err := parseContentPart(ctx, part, options)
if err != nil { if err != nil {
parts = append(parts, part) if part.Type == agentContext.ContentImageURL {
parts = append(parts, agentContext.ContentPart{
Type: agentContext.ContentText,
Text: "[Image content could not be processed]",
})
} else {
parts = append(parts, part)
}
log.Error("Failed to parse content part type=%s: %v", part.Type, err)
continue continue
} }
parts = append(parts, parsedPart) parts = append(parts, parsedPart)

View file

@ -12,6 +12,7 @@ import (
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
searchTypes "github.com/yaoapp/yao/agent/search/types" searchTypes "github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/attachment" "github.com/yaoapp/yao/attachment"
toolsImage "github.com/yaoapp/yao/tools/image"
) )
// Image handles image content // Image handles image content
@ -65,24 +66,27 @@ func (h *Image) Parse(ctx *agentContext.Context, content agentContext.ContentPar
return h.base64(ctx, content, visionFormat) return h.base64(ctx, content, visionFormat)
} }
// Model doesn't support vision - check cache first, then use vision agent/MCP // Model doesn't support vision - fallback chain:
// Try to get cached text (from attachment's content_preview) // 1. Cache -> 2. Uses.Vision (explicit config) -> 3. tools/vision (auto) -> 4. Placeholder text
cachedText, found, err := h.readFromCache(ctx, content.ImageURL.URL) cachedText, found, err := h.readFromCache(ctx, content.ImageURL.URL)
if err == nil && found { if err == nil && found {
// Cache hit! Return as text content
return agentContext.ContentPart{ return agentContext.ContentPart{
Type: agentContext.ContentText, Type: agentContext.ContentText,
Text: cachedText, Text: cachedText,
}, nil, nil }, nil, nil
} }
// No cache, try to use vision agent/MCP
if h.options.CompletionOptions != nil && h.options.CompletionOptions.Uses != nil && h.options.CompletionOptions.Uses.Vision != "" { if h.options.CompletionOptions != nil && h.options.CompletionOptions.Uses != nil && h.options.CompletionOptions.Uses.Vision != "" {
return h.agent(ctx, content) return h.agent(ctx, content)
} }
// No vision support and no vision tool specified, return error if text, err := h.readImageWithTools(ctx, content); err == nil {
return content, nil, fmt.Errorf("model doesn't support vision and no vision tool specified in uses.Vision") h.saveToCache(ctx, content.ImageURL.URL, text)
return agentContext.ContentPart{Type: agentContext.ContentText, Text: text}, nil, nil
}
return agentContext.ContentPart{Type: agentContext.ContentText, Text: "[Image content - vision model not available]"}, nil, nil
} }
// base64 encodes image content to base64 (for vision support) // base64 encodes image content to base64 (for vision support)
@ -360,6 +364,37 @@ func (h *Image) callMCPVisionTool(ctx *agentContext.Context, serverID string, co
return result, err return result, err
} }
// readImageWithTools calls tools/vision.ReadImage to convert image to text
// using a vision-capable model resolved via llmprovider.
func (h *Image) readImageWithTools(ctx *agentContext.Context, content agentContext.ContentPart) (string, error) {
if ctx.Authorized == nil {
return "", fmt.Errorf("no auth info available for vision model resolution")
}
src := wrapperToAttachURI(content.ImageURL.URL)
loadingID := h.sendLoading(ctx, i18n.T(ctx.Locale, "content.image.analyzing"))
resp, err := toolsImage.ReadImage(ctx.Context, src, "Please describe this image in detail.", 1080, ctx.Authorized, "")
h.sendLoadingDone(ctx, loadingID)
if err != nil {
return "", err
}
return resp.Content, nil
}
// wrapperToAttachURI converts __uploader://fileID to attach://uploader/fileID
// format expected by tools/vision.readBytes.
func wrapperToAttachURI(url string) string {
uploaderName, fileID, ok := attachment.Parse(url)
if !ok {
return url
}
return "attach://" + uploaderName + "/" + fileID
}
// sendLoading sends a loading message and returns the message ID // sendLoading sends a loading message and returns the message ID
// Returns empty string if SilentLoading is enabled // Returns empty string if SilentLoading is enabled
func (h *Image) sendLoading(ctx *agentContext.Context, msg string) string { func (h *Image) sendLoading(ctx *agentContext.Context, msg string) string {

View file

@ -117,11 +117,12 @@ func TestParseWithoutVisionSupport(t *testing.T) {
} }
handler := image.New(options) handler := image.New(options)
_, _, err := handler.Parse(ctx, content) result, _, err := handler.Parse(ctx, content)
// Should return error because no vision support and no vision tool specified // Should return placeholder text (no error) when no vision support
assert.Error(t, err) assert.NoError(t, err)
assert.Contains(t, err.Error(), "no vision tool specified") assert.Equal(t, agentContext.ContentText, result.Type)
assert.Contains(t, result.Text, "Image content")
} }
// TestParseWithEmptyURL tests parsing image with empty URL // TestParseWithEmptyURL tests parsing image with empty URL

View file

@ -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()

View file

@ -14,6 +14,9 @@ type LlmAPI interface {
// Returns *llm.Result or error information // Returns *llm.Result or error information
Stream(connector string, messages []interface{}, opts map[string]interface{}) interface{} Stream(connector string, messages []interface{}, opts map[string]interface{}) interface{}
// GenerateImage generates an image from a text prompt using an image generation model
GenerateImage(connector string, prompt string, opts map[string]interface{}) interface{}
// Parallel LLM call methods - inspired by JavaScript Promise // Parallel LLM call methods - inspired by JavaScript Promise
// All waits for all LLM calls to complete (like Promise.all) // All waits for all LLM calls to complete (like Promise.all)
All(requests []interface{}) []interface{} All(requests []interface{}) []interface{}
@ -68,6 +71,9 @@ func (ctx *Context) newLlmObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
// Single LLM call method // Single LLM call method
llmObj.Set("Stream", ctx.llmStreamMethod(iso)) llmObj.Set("Stream", ctx.llmStreamMethod(iso))
// Image generation method
llmObj.Set("GenerateImage", ctx.llmGenerateImageMethod(iso))
// Parallel LLM call methods - inspired by JavaScript Promise // Parallel LLM call methods - inspired by JavaScript Promise
llmObj.Set("All", ctx.llmAllMethod(iso)) llmObj.Set("All", ctx.llmAllMethod(iso))
llmObj.Set("Any", ctx.llmAnyMethod(iso)) llmObj.Set("Any", ctx.llmAnyMethod(iso))
@ -163,6 +169,54 @@ func (ctx *Context) llmStreamMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
}) })
} }
// llmGenerateImageMethod implements ctx.llm.GenerateImage(connector, prompt, options?)
// Usage: const result = ctx.llm.GenerateImage("dall-e-3", "A sunset over mountains", { size: "1024x1024" })
// Returns: { connector, image (base64), format, error }
func (ctx *Context) llmGenerateImageMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
if len(args) < 2 {
return bridge.JsException(v8ctx, "GenerateImage requires connector and prompt parameters")
}
if !args[0].IsString() {
return bridge.JsException(v8ctx, "connector must be a string")
}
connectorID := args[0].String()
if !args[1].IsString() {
return bridge.JsException(v8ctx, "prompt must be a string")
}
prompt := args[1].String()
var opts map[string]interface{}
if len(args) >= 3 && !args[2].IsUndefined() && !args[2].IsNull() {
goVal, err := bridge.GoValue(args[2], v8ctx)
if err == nil {
if optsMap, ok := goVal.(map[string]interface{}); ok {
opts = optsMap
}
}
}
llmAPI := ctx.Llm()
if llmAPI == nil {
return bridge.JsException(v8ctx, "LLM API not available")
}
result := llmAPI.GenerateImage(connectorID, prompt, opts)
jsVal, err := bridge.JsValue(v8ctx, result)
if err != nil {
return bridge.JsException(v8ctx, "failed to convert result: "+err.Error())
}
return jsVal
})
}
// llmAllMethod implements ctx.llm.All(requests, options?) // llmAllMethod implements ctx.llm.All(requests, options?)
// Usage: const results = ctx.llm.All([ // Usage: const results = ctx.llm.All([
// //

View file

@ -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

View file

@ -566,8 +566,9 @@ type Message struct {
ToolCallID *string `json:"tool_call_id,omitempty"` // Required for tool messages: tool call that this message is responding to ToolCallID *string `json:"tool_call_id,omitempty"` // Required for tool messages: tool call that this message is responding to
// Assistant message specific fields // Assistant message specific fields
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Optional for assistant: tool calls generated by the model ReasoningContent string `json:"reasoning_content,omitempty"` // Optional for assistant: reasoning/thinking content (DeepSeek, OpenAI o-series)
Refusal *string `json:"refusal,omitempty"` // Optional for assistant: refusal message (null when not refusing) ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Optional for assistant: tool calls generated by the model
Refusal *string `json:"refusal,omitempty"` // Optional for assistant: refusal message (null when not refusing)
} }
// ContentPartType represents the type of content part // ContentPartType represents the type of content part

View file

@ -53,19 +53,28 @@ func (a *VisionAdapter) removeImageContent(messages []context.Message) []context
for _, msg := range messages { for _, msg := range messages {
processedMsg := msg processedMsg := msg
// Handle multimodal content (array of map) if contentParts, ok := msg.Content.([]context.ContentPart); ok {
if contentParts, ok := msg.Content.([]map[string]interface{}); ok { filtered := make([]context.ContentPart, 0)
for _, part := range contentParts {
if part.Type != context.ContentImageURL {
filtered = append(filtered, part)
}
}
if len(filtered) == 0 {
processedMsg.Content = "[Image content not supported by this model]"
} else {
processedMsg.Content = filtered
}
} else if contentParts, ok := msg.Content.([]map[string]interface{}); ok {
filteredParts := make([]map[string]interface{}, 0) filteredParts := make([]map[string]interface{}, 0)
for _, part := range contentParts { for _, part := range contentParts {
partType, _ := part["type"].(string) partType, _ := part["type"].(string)
// Skip image content
if partType != "image_url" && partType != "image" { if partType != "image_url" && partType != "image" {
filteredParts = append(filteredParts, part) filteredParts = append(filteredParts, part)
} }
} }
// If all parts were filtered out, add placeholder text
if len(filteredParts) == 0 { if len(filteredParts) == 0 {
processedMsg.Content = "[Image content not supported by this model]" processedMsg.Content = "[Image content not supported by this model]"
} else if len(filteredParts) == 1 { } else if len(filteredParts) == 1 {

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 {
@ -35,12 +44,54 @@ func GetCapabilitiesFromConn(conn connector.Connector) *goullm.Capabilities {
if capabilities, ok := caps.(goullm.Capabilities); ok { if capabilities, ok := caps.(goullm.Capabilities); ok {
return &capabilities return &capabilities
} }
if capsMap, ok := caps.(map[string]interface{}); ok {
return capabilitiesFromMap(capsMap)
}
} }
} }
return getDefaultCapabilities() return getDefaultCapabilities()
} }
// capabilitiesFromMap converts a JSON-deserialized map into goullm.Capabilities.
func capabilitiesFromMap(m map[string]interface{}) *goullm.Capabilities {
caps := getDefaultCapabilities()
if v, ok := m["streaming"].(bool); ok {
caps.Streaming = v
}
if v, ok := m["tool_calls"].(bool); ok {
caps.ToolCalls = v
}
if v, ok := m["vision"]; ok {
caps.Vision = v
}
if v, ok := m["audio"].(bool); ok {
caps.Audio = v
}
if v, ok := m["stt"].(bool); ok {
caps.STT = v
}
if v, ok := m["reasoning"].(bool); ok {
caps.Reasoning = v
}
if v, ok := m["json"].(bool); ok {
caps.JSON = v
}
if v, ok := m["multimodal"].(bool); ok {
caps.Multimodal = v
}
if v, ok := m["temperature_adjustable"].(bool); ok {
caps.TemperatureAdjustable = v
}
if v, ok := m["embedding"].(bool); ok {
caps.Embedding = v
}
if v, ok := m["image_generation"].(bool); ok {
caps.ImageGeneration = v
}
return caps
}
// getDefaultCapabilities returns minimal default capabilities // getDefaultCapabilities returns minimal default capabilities
func getDefaultCapabilities() *goullm.Capabilities { func getDefaultCapabilities() *goullm.Capabilities {
return &goullm.Capabilities{ return &goullm.Capabilities{
@ -65,26 +116,8 @@ func GetCapabilitiesMap(connectorID string) map[string]interface{} {
return ToMap(caps) return ToMap(caps)
} }
// ToMap converts Capabilities to map[string]interface{} // ToMap converts Capabilities to map[string]interface{}.
// Delegates to the canonical Capabilities.ToMap() method in gou/llm.
func ToMap(caps *goullm.Capabilities) map[string]interface{} { func ToMap(caps *goullm.Capabilities) map[string]interface{} {
if caps == nil { return caps.ToMap()
return nil
}
result := make(map[string]interface{})
if caps.Vision != nil {
result["vision"] = caps.Vision
}
result["audio"] = caps.Audio
result["stt"] = caps.STT
result["tool_calls"] = caps.ToolCalls
result["reasoning"] = caps.Reasoning
result["streaming"] = caps.Streaming
result["json"] = caps.JSON
result["multimodal"] = caps.Multimodal
result["temperature_adjustable"] = caps.TemperatureAdjustable
return result
} }

View file

@ -23,3 +23,22 @@ entries:
return: return:
type: object type: object
desc: "OpenAI-compatible response: { id, object, created, model, choices: [{ index, message: { role, content, tool_calls? }, finish_reason }], usage? }" desc: "OpenAI-compatible response: { id, object, created, model, choices: [{ index, message: { role, content, tool_calls? }, finish_reason }], usage? }"
- name: ImageGeneration
desc: Generate an image from a text prompt using an image generation model
args:
- name: connector
type: string
required: true
desc: Connector ID for an image generation model (e.g. dall-e-3)
- name: prompt
type: string
required: true
desc: Text description of the image to generate
- name: opts
type: object
required: false
desc: "Generation options: size (1024x1024), quality, style, n, etc."
return:
type: object
desc: "Image generation result: { image (base64), format (png) }"

183
agent/llm/image.go Normal file
View file

@ -0,0 +1,183 @@
package llm
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/yaoapp/gou/connector"
gouhttp "github.com/yaoapp/gou/http"
goullm "github.com/yaoapp/gou/llm"
)
// ImageGenResponse holds the result of an image generation call.
// Image is always base64 encoded; if the provider returns a URL, it is downloaded and converted.
type ImageGenResponse struct {
Image string `json:"image"` // base64 encoded image data
Format string `json:"format"` // image format, e.g. "png", "jpeg"
}
// GenerateImage calls the /images/generations endpoint through the connector.
// options may include: size, n, quality, style, model, etc.
func GenerateImage(conn connector.Connector, prompt string, options map[string]interface{}) (*ImageGenResponse, error) {
host, key, authMode := resolveConnSettings(conn)
if host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
if key == "" {
return nil, fmt.Errorf("API key is not set")
}
if options == nil {
options = map[string]interface{}{}
}
options["prompt"] = prompt
if _, ok := options["model"]; !ok {
if lc, ok := conn.(goullm.LLMConnector); ok {
if m := lc.GetModel(); m != "" {
options["model"] = m
}
}
}
url := connector.BuildAPIURL(host, "/images/generations")
req := gouhttp.New(url)
req.SetHeader("Content-Type", "application/json")
setImageAuthHeaders(req, authMode, key)
resp := req.Post(options)
if resp.Status != 200 {
errMsg := extractAPIError(resp.Data)
return nil, fmt.Errorf("image generation failed (status %d, url %s): %s", resp.Status, url, errMsg)
}
return extractImageFromResponse(resp.Data)
}
func resolveConnSettings(conn connector.Connector) (host, key string, authMode goullm.AuthMode) {
authMode = goullm.AuthBearer
if lc, ok := conn.(goullm.LLMConnector); ok {
host = lc.GetURL()
key = lc.GetKey()
authMode = lc.GetAuthMode()
}
if host == "" || key == "" {
setting := conn.Setting()
if host == "" {
host, _ = setting["host"].(string)
}
if key == "" {
key, _ = setting["key"].(string)
}
}
return
}
func setImageAuthHeaders(req *gouhttp.Request, authMode goullm.AuthMode, key string) {
switch authMode {
case goullm.AuthAPIKey:
req.SetHeader("api-key", key)
case goullm.AuthXAPIKey:
req.SetHeader("x-api-key", key)
default:
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
}
}
func extractImageFromResponse(data interface{}) (*ImageGenResponse, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("marshal response: %w", err)
}
var parsed struct {
Data []struct {
B64JSON *string `json:"b64_json"`
URL *string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
if len(parsed.Data) == 0 {
return nil, fmt.Errorf("provider returned empty data array, no image was generated")
}
item := parsed.Data[0]
if item.B64JSON != nil && *item.B64JSON != "" {
return &ImageGenResponse{Image: *item.B64JSON, Format: "png"}, nil
}
if item.URL != nil && *item.URL != "" {
b64, format, err := downloadImageAsBase64(*item.URL)
if err != nil {
return nil, fmt.Errorf("provider returned url but download failed: %w", err)
}
return &ImageGenResponse{Image: b64, Format: format}, nil
}
return nil, fmt.Errorf("provider returned data but neither b64_json nor url field is present, the model may not support image generation")
}
func downloadImageAsBase64(imageURL string) (b64 string, format string, err error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(imageURL)
if err != nil {
return "", "", fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("download returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("read body: %w", err)
}
if len(body) == 0 {
return "", "", fmt.Errorf("downloaded image is empty")
}
format = "png"
ct := resp.Header.Get("Content-Type")
switch {
case strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg"):
format = "jpeg"
case strings.Contains(ct, "webp"):
format = "webp"
case strings.Contains(ct, "gif"):
format = "gif"
default:
if strings.Contains(imageURL, ".jpeg") || strings.Contains(imageURL, ".jpg") {
format = "jpeg"
} else if strings.Contains(imageURL, ".webp") {
format = "webp"
}
}
b64 = base64.StdEncoding.EncodeToString(body)
return b64, format, nil
}
func extractAPIError(data interface{}) string {
raw, err := json.Marshal(data)
if err != nil {
return fmt.Sprintf("%v", data)
}
var parsed struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &parsed); err == nil && parsed.Error.Message != "" {
return parsed.Error.Message
}
return string(raw)
}

197
agent/llm/image_test.go Normal file
View file

@ -0,0 +1,197 @@
package llm
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestExtractImageFromResponse_B64(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": "iVBORw0KGgoAAAANS...",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Image != "iVBORw0KGgoAAAANS..." {
t.Errorf("got Image=%q, want %q", resp.Image, "iVBORw0KGgoAAAANS...")
}
if resp.Format != "png" {
t.Errorf("got Format=%q, want %q", resp.Format, "png")
}
}
func TestExtractImageFromResponse_URL(t *testing.T) {
fakeImage := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10} // fake JPEG header bytes
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
w.Write(fakeImage)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": nil,
"url": srv.URL + "/image_0.jpeg",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := base64.StdEncoding.EncodeToString(fakeImage)
if resp.Image != expected {
t.Errorf("got Image=%q, want %q", resp.Image, expected)
}
if resp.Format != "jpeg" {
t.Errorf("got Format=%q, want %q", resp.Format, "jpeg")
}
}
func TestExtractImageFromResponse_URLPng(t *testing.T) {
fakeImage := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(fakeImage)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"url": srv.URL + "/output.png",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Format != "png" {
t.Errorf("got Format=%q, want %q", resp.Format, "png")
}
if resp.Image == "" {
t.Error("expected non-empty base64 Image")
}
}
func TestExtractImageFromResponse_URLDownloadFail(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"url": srv.URL + "/missing.png",
},
},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for failed download")
}
}
func TestExtractImageFromResponse_Empty(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for empty data array")
}
}
func TestExtractImageFromResponse_NoData(t *testing.T) {
data := map[string]interface{}{}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for missing data field")
}
}
func TestExtractImageFromResponse_NullBoth(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": nil,
"url": nil,
},
},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error when both b64_json and url are null")
}
}
func TestDownloadImageAsBase64(t *testing.T) {
payload := []byte("fake-png-data")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(payload)
}))
defer srv.Close()
b64, format, err := downloadImageAsBase64(srv.URL + "/test.png")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if format != "png" {
t.Errorf("got format=%q, want %q", format, "png")
}
decoded, _ := base64.StdEncoding.DecodeString(b64)
if string(decoded) != string(payload) {
t.Errorf("decoded content mismatch")
}
}
func TestDownloadImageAsBase64_FormatFromURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte("data"))
}))
defer srv.Close()
_, format, err := downloadImageAsBase64(srv.URL + "/image.webp")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if format != "webp" {
t.Errorf("got format=%q, want %q (from URL fallback)", format, "webp")
}
}
func TestExtractAPIError_WithMessage(t *testing.T) {
data := map[string]interface{}{
"error": map[string]interface{}{
"message": "insufficient quota",
},
}
msg := extractAPIError(data)
if msg != "insufficient quota" {
t.Errorf("got %q, want %q", msg, "insufficient quota")
}
}
func TestExtractAPIError_NoMessage(t *testing.T) {
data := map[string]interface{}{
"something": "else",
}
msg := extractAPIError(data)
raw, _ := json.Marshal(data)
if msg != string(raw) {
t.Errorf("got %q, want raw JSON fallback", msg)
}
}

View file

@ -31,6 +31,37 @@ func SetJSAPIFactory() {
} }
} }
// GenerateImage implements LlmAPI.GenerateImage - generates an image from a text prompt
func (api *JSAPI) GenerateImage(connectorID string, prompt string, opts map[string]interface{}) interface{} {
result := &ImageGenResult{
Connector: connectorID,
}
conn, err := connector.Select(connectorID)
if err != nil {
result.Error = fmt.Sprintf("failed to select connector %s: %v", connectorID, err)
return result
}
resp, err := GenerateImage(conn, prompt, opts)
if err != nil {
result.Error = fmt.Sprintf("image generation failed: %v", err)
return result
}
result.Image = resp.Image
result.Format = resp.Format
return result
}
// ImageGenResult is the return type for GenerateImage JSAPI
type ImageGenResult struct {
Connector string `json:"connector"`
Image string `json:"image,omitempty"`
Format string `json:"format,omitempty"`
Error string `json:"error,omitempty"`
}
// Stream implements LlmAPI.Stream - calls LLM with streaming output to ctx.Writer // Stream implements LlmAPI.Stream - calls LLM with streaming output to ctx.Writer
func (api *JSAPI) Stream(connectorID string, messages []interface{}, opts map[string]interface{}) interface{} { func (api *JSAPI) Stream(connectorID string, messages []interface{}, opts map[string]interface{}) interface{} {
return api.StreamWithHandler(connectorID, messages, opts, nil) return api.StreamWithHandler(connectorID, messages, opts, nil)

View file

@ -16,6 +16,7 @@ import (
func init() { func init() {
process.Register("llm.ChatCompletions", ProcessChatCompletions) process.Register("llm.ChatCompletions", ProcessChatCompletions)
process.Register("llm.ImageGeneration", ProcessImageGeneration)
} }
// ProcessChatCompletions implements the llm.ChatCompletions Process. // ProcessChatCompletions implements the llm.ChatCompletions Process.
@ -155,6 +156,55 @@ func ProcessChatCompletions(p *process.Process) interface{} {
return toOpenAIFormat(response) return toOpenAIFormat(response)
} }
// ProcessImageGeneration implements the llm.ImageGeneration Process.
//
// Usage:
//
// Process("llm.ImageGeneration", connectorID, prompt)
// Process("llm.ImageGeneration", connectorID, prompt, opts)
//
// Args:
// - connectorID (string): Connector ID for an image generation model
// - prompt (string): Text description of the image to generate
// - opts (map): Optional. size, quality, style, n, etc.
//
// Returns: { image (base64), format (png) }
func ProcessImageGeneration(p *process.Process) interface{} {
p.ValidateArgNums(2)
connectorID := p.ArgsString(0)
if connectorID == "" {
return newErrorResponse("llm.ImageGeneration: connector is required")
}
prompt := p.ArgsString(1)
if prompt == "" {
return newErrorResponse("llm.ImageGeneration: prompt is required")
}
var opts map[string]interface{}
if p.NumOfArgs() > 2 && p.Args[2] != nil {
if o, ok := p.Args[2].(map[string]interface{}); ok {
opts = o
}
}
conn, _, err := selectWithCapabilities(connectorID)
if err != nil {
return newErrorResponse(fmt.Sprintf("llm.ImageGeneration: connector %s not found: %v", connectorID, err))
}
resp, err := GenerateImage(conn, prompt, opts)
if err != nil {
return newErrorResponse(fmt.Sprintf("llm.ImageGeneration: %v", err))
}
return map[string]interface{}{
"image": resp.Image,
"format": resp.Format,
}
}
// toOpenAIFormat converts CompletionResponse to OpenAI chat.completions format // toOpenAIFormat converts CompletionResponse to OpenAI chat.completions format
// for backward compatibility with code that consumed openai.chat.Completions. // for backward compatibility with code that consumed openai.chat.Completions.
func toOpenAIFormat(resp *agentContext.CompletionResponse) map[string]interface{} { func toOpenAIFormat(resp *agentContext.CompletionResponse) map[string]interface{} {

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
@ -44,11 +45,11 @@ func buildAdapters(cap *goullm.Capabilities) []adapters.CapabilityAdapter {
// Tool call adapter // Tool call adapter
result = append(result, adapters.NewToolCallAdapter(cap.ToolCalls)) result = append(result, adapters.NewToolCallAdapter(cap.ToolCalls))
// Vision adapter // Vision adapter (always registered to strip unsupported image content)
visionSupport, visionFormat := context.GetVisionSupport(cap) visionSupport, visionFormat := context.GetVisionSupport(cap)
if visionSupport { if visionSupport {
result = append(result, adapters.NewVisionAdapter(true, visionFormat)) result = append(result, adapters.NewVisionAdapter(true, visionFormat))
} else if cap.Vision != nil { } else {
result = append(result, adapters.NewVisionAdapter(false, context.VisionFormatNone)) result = append(result, adapters.NewVisionAdapter(false, context.VisionFormatNone))
} }
@ -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
@ -942,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
@ -1176,3 +1164,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,16 +62,23 @@ 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 contains(host, "anthropic.com") || contains(host, "api.kimi.com/coding") { }
return "anthropic" if host == "" {
} if settings := conn.Setting(); settings != nil {
if contains(host, "deepseek.com") { host, _ = settings["host"].(string)
return "openai" }
} }
if host != "" {
if contains(host, "anthropic.com") || contains(host, "api.kimi.com/coding") {
return "anthropic"
}
if contains(host, "deepseek.com") {
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"
) )
@ -155,12 +156,11 @@ func buildAdapters(cap *goullm.Capabilities) []adapters.CapabilityAdapter {
// Tool call adapter // Tool call adapter
result = append(result, adapters.NewToolCallAdapter(cap.ToolCalls)) result = append(result, adapters.NewToolCallAdapter(cap.ToolCalls))
// Vision adapter // Vision adapter (always registered to strip unsupported image content)
visionSupport, visionFormat := context.GetVisionSupport(cap) visionSupport, visionFormat := context.GetVisionSupport(cap)
if visionSupport { if visionSupport {
result = append(result, adapters.NewVisionAdapter(true, visionFormat)) result = append(result, adapters.NewVisionAdapter(true, visionFormat))
} else if cap.Vision != nil { } else {
// Vision explicitly disabled, add adapter to remove image content
result = append(result, adapters.NewVisionAdapter(false, context.VisionFormatNone)) result = append(result, adapters.NewVisionAdapter(false, context.VisionFormatNone))
} }
@ -385,16 +385,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 +403,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{
@ -498,16 +492,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()
} }
} }
@ -922,16 +918,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 +930,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)
@ -1006,7 +996,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,
@ -1040,12 +1030,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 {
@ -1099,6 +1083,10 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
apiMsg["tool_calls"] = msg.ToolCalls apiMsg["tool_calls"] = msg.ToolCalls
} }
if msg.ReasoningContent != "" {
apiMsg["reasoning_content"] = msg.ReasoningContent
}
if msg.Refusal != nil { if msg.Refusal != nil {
apiMsg["refusal"] = *msg.Refusal apiMsg["refusal"] = *msg.Refusal
} }
@ -1120,11 +1108,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 { if options.MaxCompletionTokens != nil || options.MaxTokens != nil {
body["max_completion_tokens"] = *options.MaxCompletionTokens maxTokens := 0
} else if options.MaxTokens != nil { if options.MaxCompletionTokens != nil {
// Fallback: convert MaxTokens to max_completion_tokens for compatibility maxTokens = *options.MaxCompletionTokens
body["max_completion_tokens"] = *options.MaxTokens } else {
maxTokens = *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 {
@ -1202,9 +1198,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
@ -1289,3 +1290,44 @@ 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))
}
func reasoningOrFallback(primary, fallback string) string {
if primary != "" {
return primary
}
return fallback
}

View file

@ -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"`

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

@ -0,0 +1,94 @@
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 && llmprovider.Global != nil {
conn, err = llmprovider.Global.GetModel(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

@ -19,6 +19,7 @@ import (
"github.com/yaoapp/yao/agent/store/xun" "github.com/yaoapp/yao/agent/store/xun"
"github.com/yaoapp/yao/agent/types" "github.com/yaoapp/yao/agent/types"
"github.com/yaoapp/yao/config" "github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/llmprovider"
) )
var agentDSL *types.DSL var agentDSL *types.DSL
@ -226,15 +227,18 @@ func initAssistant() error {
// Set system agents configuration // Set system agents configuration
if agentDSL.System != nil { if agentDSL.System != nil {
assistant.SetSystemConfig(&assistant.SystemConfig{ assistant.SetSystemConfig(&assistant.SystemConfig{
Default: agentDSL.System.Default, Default: agentDSL.System.Default,
Keyword: agentDSL.System.Keyword, Light: agentDSL.System.Light,
QueryDSL: agentDSL.System.QueryDSL, Vision: agentDSL.System.Vision,
Title: agentDSL.System.Title, Audio: agentDSL.System.Audio,
Prompt: agentDSL.System.Prompt, Heavy: agentDSL.System.Heavy,
NeedSearch: agentDSL.System.NeedSearch, Keyword: agentDSL.System.Keyword,
Entity: agentDSL.System.Entity, QueryDSL: agentDSL.System.QueryDSL,
Vision: agentDSL.System.Vision, Title: agentDSL.System.Title,
Voice: agentDSL.System.Voice, Prompt: agentDSL.System.Prompt,
RobotPrompt: agentDSL.System.RobotPrompt,
NeedSearch: agentDSL.System.NeedSearch,
Entity: agentDSL.System.Entity,
}) })
} }
@ -453,6 +457,22 @@ func GetSearchConfig() *searchTypes.Config {
return agentDSL.Search return agentDSL.Search
} }
// SyncLLMDefaults writes the agent.yml system role defaults into setting.Global.
// Must be called after both llmprovider.Init() and setting.Init() have completed.
func SyncLLMDefaults() error {
if agentDSL == nil || agentDSL.System == nil {
return nil
}
if llmprovider.Global == nil {
return fmt.Errorf("llmprovider.Global not initialized")
}
roles := buildSystemRoles(agentDSL.System)
if len(roles) == 0 {
return nil
}
return llmprovider.Global.SetDefaults(roles)
}
// defaultAssistant get the default assistant // defaultAssistant get the default assistant
func defaultAssistant() (*assistant.Assistant, error) { func defaultAssistant() (*assistant.Assistant, error) {
if agentDSL.Uses == nil || agentDSL.Uses.Default == "" { if agentDSL.Uses == nil || agentDSL.Uses.Default == "" {
@ -461,12 +481,34 @@ func defaultAssistant() (*assistant.Assistant, error) {
return assistant.Get(agentDSL.Uses.Default) return assistant.Get(agentDSL.Uses.Default)
} }
// buildSystemRoles converts the System config block into a role→connectorID map
// 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 {
roles := make(map[string]string)
add := func(role, cid string) {
if cid != "" {
roles[role] = cid
}
}
add("default", sys.Default)
add("light", sys.Light)
add("vision", sys.Vision)
add("audio", sys.Audio)
add("heavy", sys.Heavy)
return roles
}
// resolveEnvStrings resolves $ENV.XXX references in agent.yml string fields. // resolveEnvStrings resolves $ENV.XXX references in agent.yml string fields.
// agent.yml is parsed via yaml.Unmarshal which does not handle $ENV substitution, // agent.yml is parsed via yaml.Unmarshal which does not handle $ENV substitution,
// unlike connector files which call helper.EnvString explicitly during Register. // unlike connector files which call helper.EnvString explicitly during Register.
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.Heavy = helper.EnvString(setting.System.Heavy)
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)
@ -474,8 +516,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.Voice = helper.EnvString(setting.System.Voice)
} }
if setting.Uses != nil { if setting.Uses != nil {

View file

@ -229,7 +229,7 @@ func TestResolveEnvStrings(t *testing.T) {
NeedSearch: "$ENV.TEST_CONNECTOR", NeedSearch: "$ENV.TEST_CONNECTOR",
Entity: "$ENV.TEST_CONNECTOR", Entity: "$ENV.TEST_CONNECTOR",
Vision: "$ENV.TEST_CONNECTOR", Vision: "$ENV.TEST_CONNECTOR",
Voice: "$ENV.TEST_CONNECTOR", Audio: "$ENV.TEST_CONNECTOR",
}, },
} }
resolveEnvStrings(setting) resolveEnvStrings(setting)
@ -243,24 +243,24 @@ func TestResolveEnvStrings(t *testing.T) {
assert.Equal(t, "openai.gpt-5", setting.System.NeedSearch) assert.Equal(t, "openai.gpt-5", setting.System.NeedSearch)
assert.Equal(t, "openai.gpt-5", setting.System.Entity) assert.Equal(t, "openai.gpt-5", setting.System.Entity)
assert.Equal(t, "openai.gpt-5", setting.System.Vision) assert.Equal(t, "openai.gpt-5", setting.System.Vision)
assert.Equal(t, "openai.gpt-5", setting.System.Voice) assert.Equal(t, "openai.gpt-5", setting.System.Audio)
}) })
t.Run("SystemVisionVoiceSeparateEnv", func(t *testing.T) { t.Run("SystemVisionAudioSeparateEnv", func(t *testing.T) {
t.Setenv("TEST_VISION_CONN", "openai.gpt-4o") t.Setenv("TEST_VISION_CONN", "openai.gpt-4o")
t.Setenv("TEST_VOICE_CONN", "whisper-1") t.Setenv("TEST_AUDIO_CONN", "whisper-1")
setting := &types.DSL{ setting := &types.DSL{
System: &types.System{ System: &types.System{
Default: "$ENV.TEST_CONNECTOR", Default: "$ENV.TEST_CONNECTOR",
Vision: "$ENV.TEST_VISION_CONN", Vision: "$ENV.TEST_VISION_CONN",
Voice: "$ENV.TEST_VOICE_CONN", Audio: "$ENV.TEST_AUDIO_CONN",
}, },
} }
resolveEnvStrings(setting) resolveEnvStrings(setting)
assert.Equal(t, "openai.gpt-5", setting.System.Default) assert.Equal(t, "openai.gpt-5", setting.System.Default)
assert.Equal(t, "openai.gpt-4o", setting.System.Vision) assert.Equal(t, "openai.gpt-4o", setting.System.Vision)
assert.Equal(t, "whisper-1", setting.System.Voice) assert.Equal(t, "whisper-1", setting.System.Audio)
}) })
t.Run("UsesFields", func(t *testing.T) { t.Run("UsesFields", func(t *testing.T) {

View file

@ -52,6 +52,10 @@ func (h *robotHandler) handleDelivery(ctx context.Context, ev *eventtypes.Event,
return return
} }
if ev.Auth != nil {
ctx = context.WithValue(ctx, "identity", ev.Auth)
}
deliveryCtx := &robottypes.DeliveryContext{ deliveryCtx := &robottypes.DeliveryContext{
MemberID: payload.MemberID, MemberID: payload.MemberID,
ExecutionID: payload.ExecutionID, ExecutionID: payload.ExecutionID,

View file

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/yaoapp/gou/model" "github.com/yaoapp/gou/model"
"github.com/yaoapp/gou/process"
kunlog "github.com/yaoapp/kun/log" kunlog "github.com/yaoapp/kun/log"
robotevents "github.com/yaoapp/yao/agent/robot/events" robotevents "github.com/yaoapp/yao/agent/robot/events"
robottypes "github.com/yaoapp/yao/agent/robot/types" robottypes "github.com/yaoapp/yao/agent/robot/types"
@ -98,7 +99,16 @@ func (e *Executor) pushDeliveryEvent(ctx *robottypes.Context, exec *robottypes.E
} }
} }
_, err := event.Push(ctx.Context, robotevents.Delivery, robotevents.DeliveryPayload{ eventCtx := ctx.Context
if ctx.Auth != nil {
eventCtx = event.WithAuth(eventCtx, &process.AuthorizedInfo{
UserID: ctx.Auth.UserID,
TeamID: ctx.Auth.TeamID,
Subject: ctx.Auth.Subject,
})
}
_, err := event.Push(eventCtx, robotevents.Delivery, robotevents.DeliveryPayload{
ExecutionID: exec.ID, ExecutionID: exec.ID,
MemberID: exec.MemberID, MemberID: exec.MemberID,
TeamID: exec.TeamID, TeamID: exec.TeamID,

View file

@ -372,9 +372,12 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
} }
} }
// Note: System prompt and max_turns are passed via CLI flags in BuildCommand // Prevent Claude CLI from using an excessive max_tokens that the backend
// CLAUDE_SYSTEM_PROMPT environment variable is NOT supported by Claude CLI // API will reject. In OpenAI-proxy mode the hardcoded model is
// --append-system-prompt or --system-prompt flags must be used instead // claude-sonnet-4-6 whose limit is 16384.
if opts.ConnectorType != "anthropic" {
env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = "16384"
}
return env return env
} }

View file

@ -10,7 +10,9 @@ 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/log"
"github.com/yaoapp/kun/str" "github.com/yaoapp/kun/str"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/sandbox/v2/types" "github.com/yaoapp/yao/agent/sandbox/v2/types"
@ -18,6 +20,7 @@ import (
) )
const defaultA2OPort = 3099 const defaultA2OPort = 3099
const defaultA2OMaxOutputTokens = 16384
var yaoSessionNS = uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479") var yaoSessionNS = uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")
@ -74,6 +77,9 @@ func (r *Runner) buildCommand(ctx context.Context, req *types.StreamRequest, p p
var systemPrompt string var systemPrompt string
envPrompt := buildSandboxEnvPrompt(p, workDir) envPrompt := buildSandboxEnvPrompt(p, workDir)
if capPrompt := buildModelCapabilityPrompt(req); capPrompt != "" {
envPrompt += "\n\n" + capPrompt
}
if !isContinuation && req.SystemPrompt != "" { if !isContinuation && req.SystemPrompt != "" {
systemPrompt = req.SystemPrompt + "\n\n" + envPrompt systemPrompt = req.SystemPrompt + "\n\n" + envPrompt
} else if !isContinuation { } else if !isContinuation {
@ -140,69 +146,46 @@ 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)
model, _ := setting["model"].(string)
roleConnectors := getRoleConnectors(req) var host, key, model string
getConn := func(id string) connector.Connector { if lc, ok := req.Connector.(goullm.LLMConnector); ok {
c, _ := connector.Connectors[id] host = lc.GetURL()
return c key = lc.GetKey()
model = lc.GetModel()
}
if host == "" {
host, _ = setting["host"].(string)
}
if key == "" {
key, _ = setting["key"].(string)
}
if model == "" {
model, _ = setting["model"].(string)
} }
if req.Connector.Is(connector.ANTHROPIC) { isAnthropic := req.Connector.Is(connector.ANTHROPIC)
env["ANTHROPIC_BASE_URL"] = host
env["ANTHROPIC_API_KEY"] = key
if model != "" {
env["ANTHROPIC_MODEL"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
env["CLAUDE_CODE_SUBAGENT_MODEL"] = model
}
if len(roleConnectors) > 0 { if isAnthropic {
primaryHost := host setAnthropicModelEnv(env, host, key, model, req.Connector)
for role, rm := range claudeRoleEnvMap { applyAnthropicRoleOverrides(env, host, req.Roles)
if role == "primary" {
continue
}
rc := resolveRoleConnector(role, roleConnectors, req.UserExplicit, getConn)
if rc == nil {
continue
}
rcHost := connectorHost(rc)
if rcHost == primaryHost && supportsProtocol(rc, "anthropic") {
rcModel, _ := rc.Setting()["model"].(string)
if rcModel != "" {
env[rm.EnvVar] = rcModel
}
}
}
}
} else { } else {
connectorID := req.Connector.ID() setA2OModelEnv(env, req.Connector.ID(), model, req.Connector)
env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d/c/%s", defaultA2OPort, connectorID) applyA2ORoleOverrides(env, req.Roles)
env["ANTHROPIC_API_KEY"] = "dummy" }
env["ANTHROPIC_MODEL"] = "claude-sonnet-4-6"
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = "claude-sonnet-4-6"
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = "claude-sonnet-4-6"
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "claude-sonnet-4-6"
env["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-sonnet-4-6"
if len(roleConnectors) > 0 { if lc, ok := req.Connector.(goullm.LLMConnector); ok {
for role, rm := range claudeRoleEnvMap { if caps := lc.GetCapabilities(); caps != nil {
if role == "primary" { if caps.MaxOutputTokens > 0 {
continue env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = fmt.Sprintf("%d", caps.MaxOutputTokens)
} }
rc := resolveRoleConnector(role, roleConnectors, req.UserExplicit, getConn) if caps.MaxInputTokens > 0 {
if rc == nil { env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = fmt.Sprintf("%d", caps.MaxInputTokens)
continue
}
env[rm.EnvVar] = rm.ModelName
} }
} }
} }
if _, ok := env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"]; !ok && !req.Connector.Is(connector.ANTHROPIC) {
env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = fmt.Sprintf("%d", defaultA2OMaxOutputTokens)
}
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)
@ -232,6 +215,25 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
} }
} }
logger := req.Logger
if logger == nil {
logger = agentContext.NoopLogger()
}
connectorID := ""
if req.Connector != nil {
connectorID = req.Connector.ID()
}
logger.Debug("claude-env: connector=%s isAnthropic=%v", connectorID, req.Connector != nil && req.Connector.Is(connector.ANTHROPIC))
logger.Debug("claude-env: ANTHROPIC_MODEL=%s", env["ANTHROPIC_MODEL"])
logger.Debug("claude-env: OPUS_MODEL=%s SONNET_MODEL=%s HAIKU_MODEL=%s",
env["ANTHROPIC_DEFAULT_OPUS_MODEL"],
env["ANTHROPIC_DEFAULT_SONNET_MODEL"],
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"])
logger.Debug("claude-env: CUSTOM_MODEL_OPTION=%s CAPABILITIES=%s",
env["ANTHROPIC_CUSTOM_MODEL_OPTION"],
env["ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES"])
logger.Debug("claude-env: MAX_THINKING_TOKENS=%s", env["MAX_THINKING_TOKENS"])
return env return env
} }
@ -302,26 +304,149 @@ func buildSandboxEnvPrompt(p platform, workDir string) string {
shellNote := p.EnvPromptNote() shellNote := p.EnvPromptNote()
envVarSyntax := "$VAR_NAME"
if osName == "windows" {
envVarSyntax = "$env:VAR_NAME"
}
return fmt.Sprintf(`## Sandbox Environment return fmt.Sprintf(`## Sandbox Environment
- **Operating System**: %[2]s - **Operating System**: %[2]s
- **Shell**: %[3]s - **Shell**: %[3]s
- **Working Directory**: %[1]s - **Working Directory**: %[1]s
- **File Access**: You have full read/write access to %[1]s - **File Access**: You have full read/write access to %[1]s
- **Environment variable syntax**: `+"`%[5]s`"+` (e.g. `+"`$CTX_SKILLS_DIR`"+` on POSIX, `+"`$env:CTX_SKILLS_DIR`"+` on Windows)%[4]s %[4]s`, workDir, osName, shell, shellNote)
}
## User Attachments func buildModelCapabilityPrompt(req *types.StreamRequest) string {
if req.Connector == nil {
return ""
}
User-uploaded files (images, documents, code files, etc.) are placed in %[1]s/.attachments/{chatID}/ lc, ok := req.Connector.(goullm.LLMConnector)
Each chat session has its own subdirectory to avoid conflicts. if !ok {
When the user references an attached file, read it from this directory using the Read or Bash tool. return ""
For image files, you can view them directly as Claude supports vision on local files. }
`, workDir, osName, shell, shellNote, envVarSyntax) primaryModel := lc.GetModel()
if primaryModel == "" {
return ""
}
primaryCaps := lc.GetCapabilities()
type tierInfo struct {
tier string
alias string
model string
caps *goullm.Capabilities
conn connector.Connector
}
tiers := []tierInfo{
{tier: "Default", alias: "sonnet", model: primaryModel, caps: primaryCaps, conn: req.Connector},
}
hasDifferentTier := false
if rc, exists := req.Roles["heavy"]; exists && rc != nil {
m := connectorModel(rc)
if m != "" {
caps := connectorCaps(rc)
tiers = append(tiers, tierInfo{tier: "Heavy", alias: "opus", model: m, caps: caps, conn: rc})
if m != primaryModel {
hasDifferentTier = true
}
}
}
if rc, exists := req.Roles["light"]; exists && rc != nil {
m := connectorModel(rc)
if m != "" {
caps := connectorCaps(rc)
tiers = append(tiers, tierInfo{tier: "Light", alias: "haiku", model: m, caps: caps, conn: rc})
if m != primaryModel {
hasDifferentTier = true
}
}
}
var sb strings.Builder
sb.WriteString("## Model Capabilities\n\n")
sb.WriteString(fmt.Sprintf("Your current model: `%s`\n", primaryModel))
if hasDifferentTier {
sb.WriteString("\n### Available Model Tiers\n\n")
sb.WriteString("| Tier | Alias | Model | Capabilities |\n")
sb.WriteString("| ---- | ----- | ----- | ------------ |\n")
for _, t := range tiers {
capList := formatCapabilities(t.caps, t.conn)
sb.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n", t.tier, t.alias, t.model, capList))
}
}
var guidance []string
if hasDifferentTier {
guidance = append(guidance,
"For complex reasoning, multi-step analysis, or tasks requiring deep thought, delegate to a sub-agent with `model: \"opus\"`",
"For simple tasks (formatting, translation, summarization), use `model: \"haiku\"` for faster responses",
)
}
primaryHasVision := primaryCaps.HasVision()
if !primaryHasVision {
if _, hasVisionRole := req.Roles["vision"]; hasVisionRole {
guidance = append(guidance,
"**Image/Vision**: Your current model cannot process images directly. Use the `image_read` system tool (`tai tool image_read`) to analyze images — see the yao-image skill for details",
)
}
}
if len(guidance) > 0 {
sb.WriteString("\n### Usage Guidance\n\n")
for _, g := range guidance {
sb.WriteString("- " + g + "\n")
}
}
if !hasDifferentTier && len(guidance) == 0 {
return ""
}
return sb.String()
}
func connectorModel(c connector.Connector) string {
if lc, ok := c.(goullm.LLMConnector); ok {
if m := lc.GetModel(); m != "" {
return m
}
}
m, _ := c.Setting()["model"].(string)
return m
}
func connectorCaps(c connector.Connector) *goullm.Capabilities {
if lc, ok := c.(goullm.LLMConnector); ok {
return lc.GetCapabilities()
}
return nil
}
func formatCapabilities(caps *goullm.Capabilities, conn connector.Connector) string {
var parts []string
hasThinking := caps.HasReasoning()
if !hasThinking && conn != nil {
if thinking, ok := conn.Setting()["thinking"].(map[string]interface{}); ok {
if t, _ := thinking["type"].(string); t == "enabled" {
hasThinking = true
}
}
}
if hasThinking {
parts = append(parts, "thinking")
}
if caps.HasVision() {
parts = append(parts, "vision")
}
if caps.HasToolCalls() {
parts = append(parts, "tool_calls")
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, ", ")
} }
func hasExistingSession(ctx context.Context, computer infra.Computer, p platform, assistantID string) bool { func hasExistingSession(ctx context.Context, computer infra.Computer, p platform, assistantID string) bool {
@ -398,24 +523,23 @@ func buildLastUserMessageJSONL(messages []agentContext.Message) string {
} }
// claudeRoleEnvMap maps abstract Yao model roles to Claude CLI environment // claudeRoleEnvMap maps abstract Yao model roles to Claude CLI environment
// variables and virtual model name identifiers used as A2O route keys. // variables. Only roles with matching Claude CLI env vars are listed here.
// ModelName uniqueness is only required among roles that have independent // ANTHROPIC_DEFAULT_SONNET_MODEL is set to the primary model in buildEnv.
// connectors (i.e. are added to the A2O routes map). var claudeRoleEnvMap = map[string]struct{ EnvVar string }{
var claudeRoleEnvMap = map[string]struct { "default": {EnvVar: "ANTHROPIC_MODEL"},
EnvVar string "heavy": {EnvVar: "ANTHROPIC_DEFAULT_OPUS_MODEL"},
ModelName string "light": {EnvVar: "ANTHROPIC_DEFAULT_HAIKU_MODEL"},
}{
"primary": {EnvVar: "ANTHROPIC_MODEL", ModelName: "claude-sonnet-4-6"},
"heavy": {EnvVar: "ANTHROPIC_DEFAULT_OPUS_MODEL", ModelName: "claude-opus-4-6"},
"light": {EnvVar: "ANTHROPIC_DEFAULT_HAIKU_MODEL", ModelName: "claude-haiku-4-5"},
"subagent": {EnvVar: "CLAUDE_CODE_SUBAGENT_MODEL", ModelName: "claude-subagent-4-6"},
"vision": {EnvVar: "ANTHROPIC_DEFAULT_SONNET_MODEL", ModelName: "claude-vision-4-5"},
} }
func connectorHost(c connector.Connector) string { 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
} }
@ -443,33 +567,149 @@ func supportsProtocol(c connector.Connector, proto string) bool {
return false return false
} }
// resolveRoleConnector determines which connector to use for a given role.
// Returns nil when the role should use the primary connector (caller decides).
func resolveRoleConnector(
role string,
roleConnectors map[string]*types.RoleConnector,
userExplicit bool,
getConnector func(id string) connector.Connector,
) connector.Connector {
rc, ok := roleConnectors[role]
if !ok || rc == nil {
return nil
}
if rc.Override == "user" && userExplicit {
return nil
}
return getConnector(rc.Connector)
}
func getRoleConnectors(req *types.StreamRequest) map[string]*types.RoleConnector {
if req.Config == nil {
return nil
}
return req.Config.Runner.Connectors
}
var claudeArgWhitelist = map[string]string{ var claudeArgWhitelist = map[string]string{
"max_turns": "--max-turns", "max_turns": "--max-turns",
"disallowed_tools": "--disallowed-tools", "disallowed_tools": "--disallowed-tools",
"allowed_tools": "--allowedTools", "allowed_tools": "--allowedTools",
} }
func isStandardAnthropicModel(model string) bool {
return strings.HasPrefix(model, "claude-") || strings.HasPrefix(model, "anthropic.")
}
func buildClaudeCodeCapabilities(conn connector.Connector) string {
if conn == nil {
return ""
}
setting := conn.Setting()
if setting == nil {
return ""
}
var caps []string
if thinking, ok := setting["thinking"].(map[string]interface{}); ok {
if thinkType, _ := thinking["type"].(string); thinkType == "enabled" {
caps = append(caps, "thinking")
}
}
return strings.Join(caps, ",")
}
func setAnthropicModelEnv(env map[string]string, host, key, model string, conn connector.Connector) {
env["ANTHROPIC_BASE_URL"] = host
env["ANTHROPIC_API_KEY"] = key
if model == "" {
return
}
env["ANTHROPIC_MODEL"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
if isStandardAnthropicModel(model) {
return
}
caps := buildClaudeCodeCapabilities(conn)
env["ANTHROPIC_CUSTOM_MODEL_OPTION"] = model
env["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL_NAME"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME"] = model
if caps != "" {
env["ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES"] = caps
}
}
func applyAnthropicRoleOverrides(
env map[string]string,
primaryHost string,
roles map[string]connector.Connector,
) {
for role, rm := range claudeRoleEnvMap {
if role == "default" {
continue
}
rc, ok := roles[role]
if !ok || rc == nil {
continue
}
roleHost := connectorHost(rc)
if roleHost != primaryHost {
log.Warn("[claude] role %s: host mismatch (%s != %s), falling back to primary", role, roleHost, primaryHost)
continue
}
if !supportsProtocol(rc, "anthropic") {
log.Warn("[claude] role %s: not anthropic protocol, falling back to primary", role)
continue
}
rcModel, _ := rc.Setting()["model"].(string)
if rcModel == "" {
continue
}
env[rm.EnvVar] = rcModel
if isStandardAnthropicModel(rcModel) {
continue
}
env[rm.EnvVar+"_NAME"] = rcModel
if caps := buildClaudeCodeCapabilities(rc); caps != "" {
env[rm.EnvVar+"_SUPPORTED_CAPABILITIES"] = caps
}
}
}
func setA2OModelEnv(env map[string]string, connectorID, model string, conn connector.Connector) {
env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d/c/%s", defaultA2OPort, connectorID)
env["ANTHROPIC_API_KEY"] = "dummy"
env["ANTHROPIC_MODEL"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
if !isStandardAnthropicModel(model) {
env["ANTHROPIC_CUSTOM_MODEL_OPTION"] = model
env["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL_NAME"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME"] = model
if caps := buildClaudeCodeCapabilities(conn); caps != "" {
env["ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES"] = caps
env["ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES"] = caps
}
}
}
func applyA2ORoleOverrides(
env map[string]string,
roles map[string]connector.Connector,
) {
for role, rm := range claudeRoleEnvMap {
if role == "default" {
continue
}
rc, ok := roles[role]
if !ok || rc == nil {
continue
}
var rcModel string
if lc, ok := rc.(goullm.LLMConnector); ok {
rcModel = lc.GetModel()
}
if rcModel == "" {
rcModel, _ = rc.Setting()["model"].(string)
}
if rcModel == "" {
continue
}
env[rm.EnvVar] = rcModel
if !isStandardAnthropicModel(rcModel) {
env[rm.EnvVar+"_NAME"] = rcModel
if caps := buildClaudeCodeCapabilities(rc); caps != "" {
env[rm.EnvVar+"_SUPPORTED_CAPABILITIES"] = caps
}
}
}
}

View file

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
gouTypes "github.com/yaoapp/gou/types" gouTypes "github.com/yaoapp/gou/types"
"github.com/yaoapp/xun/dbal/query" "github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/xun/dbal/schema" "github.com/yaoapp/xun/dbal/schema"
@ -308,7 +309,6 @@ func TestBuildSandboxEnvPrompt(t *testing.T) {
assert.Contains(t, prompt, "darwin") assert.Contains(t, prompt, "darwin")
assert.Contains(t, prompt, "bash") assert.Contains(t, prompt, "bash")
assert.Contains(t, prompt, "Sandbox Environment") assert.Contains(t, prompt, "Sandbox Environment")
assert.Contains(t, prompt, ".attachments")
} }
func TestBuildSandboxEnvPrompt_WindowsPlatform(t *testing.T) { func TestBuildSandboxEnvPrompt_WindowsPlatform(t *testing.T) {
@ -525,64 +525,6 @@ func TestSupportsProtocol(t *testing.T) {
assert.True(t, supportsProtocol(oai, "openai")) assert.True(t, supportsProtocol(oai, "openai"))
} }
func TestResolveRoleConnector_Undeclared(t *testing.T) {
roles := map[string]*types.RoleConnector{}
result := resolveRoleConnector("heavy", roles, false, func(id string) connector.Connector { return nil })
assert.Nil(t, result)
}
func TestResolveRoleConnector_Force(t *testing.T) {
heavyConn := newOpenAIConnector("thinking", "https://api.thinking.com", "think-model", "k")
roles := map[string]*types.RoleConnector{
"heavy": {Connector: "thinking", Override: "force"},
}
result := resolveRoleConnector("heavy", roles, true, func(id string) connector.Connector {
if id == "thinking" {
return heavyConn
}
return nil
})
assert.Equal(t, heavyConn, result)
}
func TestResolveRoleConnector_UserExplicit(t *testing.T) {
roles := map[string]*types.RoleConnector{
"heavy": {Connector: "thinking", Override: "user"},
}
result := resolveRoleConnector("heavy", roles, true, func(id string) connector.Connector {
return newOpenAIConnector("thinking", "h", "m", "k")
})
assert.Nil(t, result, "override=user + userExplicit=true => use user's connector")
}
func TestResolveRoleConnector_UserNotExplicit(t *testing.T) {
heavyConn := newOpenAIConnector("thinking", "h", "m", "k")
roles := map[string]*types.RoleConnector{
"heavy": {Connector: "thinking", Override: "user"},
}
result := resolveRoleConnector("heavy", roles, false, func(id string) connector.Connector {
if id == "thinking" {
return heavyConn
}
return nil
})
assert.Equal(t, heavyConn, result, "override=user + userExplicit=false => use sandbox connector")
}
// --- buildEnv with multi-connector ---
func registerTestConnectors(t *testing.T, connectors map[string]connector.Connector) func() {
t.Helper()
for id, c := range connectors {
connector.Connectors[id] = c
}
return func() {
for id := range connectors {
delete(connector.Connectors, id)
}
}
}
func TestBuildEnv_OpenAI_SingleConnector(t *testing.T) { func TestBuildEnv_OpenAI_SingleConnector(t *testing.T) {
oai := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test") oai := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test")
req := &types.StreamRequest{ req := &types.StreamRequest{
@ -595,37 +537,32 @@ func TestBuildEnv_OpenAI_SingleConnector(t *testing.T) {
env := buildEnv(req, p) env := buildEnv(req, p)
assert.Contains(t, env["ANTHROPIC_BASE_URL"], "127.0.0.1") assert.Contains(t, env["ANTHROPIC_BASE_URL"], "127.0.0.1")
assert.Contains(t, env["ANTHROPIC_BASE_URL"], "kimi") assert.Contains(t, env["ANTHROPIC_BASE_URL"], "kimi")
assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_MODEL"]) assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_MODEL"])
assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_DEFAULT_OPUS_MODEL"]) assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_DEFAULT_OPUS_MODEL"])
} }
func TestBuildEnv_OpenAI_MultiConnector(t *testing.T) { func TestBuildEnv_OpenAI_MultiConnector(t *testing.T) {
primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test") primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test")
vision := newOpenAIConnector("vision-conn", "https://api.vision.com", "vis-model", "sk-v") heavyConn := newOpenAIConnector("heavy-conn", "https://api.heavy.com", "heavy-model", "sk-h")
cleanup := registerTestConnectors(t, map[string]connector.Connector{
"vision-conn": vision,
})
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"vision": {Connector: "vision-conn", Override: "force"},
},
},
},
Connector: primary, Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"heavy": heavyConn,
},
} }
req.Computer = newFakeComputer("/workspace") req.Computer = newFakeComputer("/workspace")
p := testPlatform() p := testPlatform()
env := buildEnv(req, p) env := buildEnv(req, p)
assert.Equal(t, "claude-vision-4-5", env["ANTHROPIC_DEFAULT_SONNET_MODEL"], assert.Equal(t, "heavy-model", env["ANTHROPIC_DEFAULT_OPUS_MODEL"],
"vision role should get its virtual model name for A2O routing") "heavy role should use actual model name from connector")
assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_MODEL"], assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_MODEL"],
"primary should keep default virtual model") "primary should use actual model name from connector")
assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_CUSTOM_MODEL_OPTION"],
"non-standard model should set custom model option")
} }
func TestBuildEnv_Anthropic_SingleConnector(t *testing.T) { func TestBuildEnv_Anthropic_SingleConnector(t *testing.T) {
@ -647,20 +584,13 @@ func TestBuildEnv_Anthropic_MultiConnector_Compatible(t *testing.T) {
primary := newAnthropicConnector("claude", "https://api.yao.run", "claude-sonnet-4-20250514", "sk-ant") primary := newAnthropicConnector("claude", "https://api.yao.run", "claude-sonnet-4-20250514", "sk-ant")
lightConn := newDualProtoConnector("light-conn", "https://api.yao.run", "claude-haiku-3-5-20241022", "sk-light") lightConn := newDualProtoConnector("light-conn", "https://api.yao.run", "claude-haiku-3-5-20241022", "sk-light")
cleanup := registerTestConnectors(t, map[string]connector.Connector{
"light-conn": lightConn,
})
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"light": {Connector: "light-conn", Override: "force"},
},
},
},
Connector: primary, Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"light": lightConn,
},
} }
req.Computer = newFakeComputer("/workspace") req.Computer = newFakeComputer("/workspace")
p := testPlatform() p := testPlatform()
@ -692,10 +622,10 @@ func TestBuildSingleA2OConfig_Nil(t *testing.T) {
func TestInjectA2OConfigWithRoutes_BuildsCorrectJSON(t *testing.T) { func TestInjectA2OConfigWithRoutes_BuildsCorrectJSON(t *testing.T) {
primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-kimi") primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-kimi")
vision := newOpenAIConnector("vision", "https://api.vision.com", "vis-model", "sk-v") heavyConn := newOpenAIConnector("heavy", "https://api.heavy.com", "heavy-model", "sk-h")
roleConnectors := map[string]connector.Connector{ roleConnectors := map[string]connector.Connector{
"claude-vision-4-5": vision, "heavy-model": heavyConn,
} }
primaryCfg := buildSingleA2OConfig(primary) primaryCfg := buildSingleA2OConfig(primary)
@ -720,10 +650,10 @@ func TestInjectA2OConfigWithRoutes_BuildsCorrectJSON(t *testing.T) {
require.True(t, ok, "routes should be present in JSON") require.True(t, ok, "routes should be present in JSON")
assert.Len(t, routesMap, 1) assert.Len(t, routesMap, 1)
visionRoute, ok := routesMap["claude-vision-4-5"].(map[string]interface{}) heavyRoute, ok := routesMap["heavy-model"].(map[string]interface{})
require.True(t, ok) require.True(t, ok)
assert.Equal(t, "vis-model", visionRoute["model"]) assert.Equal(t, "heavy-model", heavyRoute["model"])
assert.Contains(t, visionRoute["backend"], "api.vision.com") assert.Contains(t, heavyRoute["backend"], "api.heavy.com")
} }
func TestResolveAllRoleConnectors_Empty(t *testing.T) { func TestResolveAllRoleConnectors_Empty(t *testing.T) {
@ -736,48 +666,191 @@ func TestResolveAllRoleConnectors_Empty(t *testing.T) {
} }
func TestResolveAllRoleConnectors_WithRoles(t *testing.T) { func TestResolveAllRoleConnectors_WithRoles(t *testing.T) {
vision := newOpenAIConnector("vis", "https://vis.com", "vis-m", "sk") primaryConn := newOpenAIConnector("primary", "https://primary.com", "primary-m", "k")
cleanup := registerTestConnectors(t, map[string]connector.Connector{"vis": vision}) heavyConn := newOpenAIConnector("hvy", "https://heavy.com", "heavy-m", "sk")
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Connector: primaryConn,
Connectors: map[string]*types.RoleConnector{ Roles: map[string]connector.Connector{
"vision": {Connector: "vis", Override: "force"}, "default": primaryConn,
}, "heavy": heavyConn,
},
}, },
Connector: newOpenAIConnector("primary", "h", "m", "k"),
} }
result := resolveAllRoleConnectors(req) result := resolveAllRoleConnectors(req)
assert.Len(t, result, 1) assert.Len(t, result, 2)
assert.Equal(t, vision, result["claude-vision-4-5"]) assert.Equal(t, primaryConn, result["primary-m"])
assert.Equal(t, heavyConn, result["heavy-m"])
} }
func TestBuildEnv_Anthropic_MultiConnector_Incompatible(t *testing.T) { func TestBuildEnv_Anthropic_MultiConnector_Incompatible(t *testing.T) {
primary := newAnthropicConnector("claude", "https://api.anthropic.com", "claude-sonnet-4-20250514", "sk-ant") primary := newAnthropicConnector("claude", "https://api.anthropic.com", "claude-sonnet-4-20250514", "sk-ant")
visionConn := newOpenAIConnector("vision-oai", "https://api.openai.com", "gpt-4o", "sk-oai") heavyConn := newOpenAIConnector("heavy-oai", "https://api.openai.com", "gpt-4o", "sk-oai")
cleanup := registerTestConnectors(t, map[string]connector.Connector{
"vision-oai": visionConn,
})
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"vision": {Connector: "vision-oai", Override: "force"},
},
},
},
Connector: primary, Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"heavy": heavyConn,
},
} }
req.Computer = newFakeComputer("/workspace") req.Computer = newFakeComputer("/workspace")
p := testPlatform() p := testPlatform()
env := buildEnv(req, p) env := buildEnv(req, p)
assert.Equal(t, "claude-sonnet-4-20250514", env["ANTHROPIC_DEFAULT_SONNET_MODEL"], assert.Equal(t, "claude-sonnet-4-20250514", env["ANTHROPIC_DEFAULT_OPUS_MODEL"],
"incompatible connector: vision should keep primary model (different host, no anthropic protocol)") "incompatible connector: heavy should keep primary model (different host, no anthropic protocol)")
}
// --- fakeLLMConnector implements both connector.Connector and goullm.LLMConnector ---
type fakeLLMConnector struct {
fakeConnector
model string
caps *goullm.Capabilities
}
func (f *fakeLLMConnector) GetAuthMode() goullm.AuthMode { return goullm.AuthBearer }
func (f *fakeLLMConnector) GetURL() string { return "" }
func (f *fakeLLMConnector) GetKey() string { return "" }
func (f *fakeLLMConnector) GetModel() string { return f.model }
func (f *fakeLLMConnector) GetSupportedParams() map[string]*goullm.ParamSpec {
return nil
}
func (f *fakeLLMConnector) GetCapabilities() *goullm.Capabilities { return f.caps }
// --- buildModelCapabilityPrompt tests ---
func TestBuildModelCapabilityPrompt_NilConnector(t *testing.T) {
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
result := buildModelCapabilityPrompt(req)
assert.Empty(t, result)
}
func TestBuildModelCapabilityPrompt_NoRoles_NoSpecialCaps(t *testing.T) {
primary := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "test", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-flash"}},
model: "deepseek-v4-flash",
caps: &goullm.Capabilities{ToolCalls: true, Streaming: true},
}
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Connector: primary,
}
result := buildModelCapabilityPrompt(req)
assert.Empty(t, result, "no roles and no special caps → empty")
}
func TestBuildModelCapabilityPrompt_WithHeavyAndLight(t *testing.T) {
primary := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "ds-flash", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-flash"}},
model: "deepseek-v4-flash",
caps: &goullm.Capabilities{ToolCalls: true},
}
heavy := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "ds-pro", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-pro"}},
model: "deepseek-v4-pro",
caps: &goullm.Capabilities{Reasoning: true, ToolCalls: true},
}
light := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "ds-lite", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-flash"}},
model: "deepseek-v4-flash",
caps: &goullm.Capabilities{ToolCalls: true},
}
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"heavy": heavy,
"light": light,
},
}
result := buildModelCapabilityPrompt(req)
assert.Contains(t, result, "deepseek-v4-flash")
assert.Contains(t, result, "deepseek-v4-pro")
assert.Contains(t, result, "opus")
assert.Contains(t, result, "haiku")
assert.Contains(t, result, "thinking")
assert.Contains(t, result, "tool_calls")
assert.Contains(t, result, "sub-agent")
}
func TestBuildModelCapabilityPrompt_VisionGuidance(t *testing.T) {
primary := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "ds-flash", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-flash"}},
model: "deepseek-v4-flash",
caps: &goullm.Capabilities{ToolCalls: true},
}
visionConn := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "vision", typ: connector.OPENAI, settings: map[string]interface{}{"model": "gpt-4o"}},
model: "gpt-4o",
caps: &goullm.Capabilities{Vision: true, ToolCalls: true},
}
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"vision": visionConn,
},
}
result := buildModelCapabilityPrompt(req)
assert.Contains(t, result, "image_read")
assert.Contains(t, result, "Image/Vision")
}
func TestBuildModelCapabilityPrompt_PrimaryHasVision_NoGuidance(t *testing.T) {
primary := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "gpt4o", typ: connector.OPENAI, settings: map[string]interface{}{"model": "gpt-4o"}},
model: "gpt-4o",
caps: &goullm.Capabilities{Vision: true, ToolCalls: true},
}
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"vision": primary,
},
}
result := buildModelCapabilityPrompt(req)
assert.NotContains(t, result, "image_read", "should not suggest image_read when primary has vision")
}
func TestBuildModelCapabilityPrompt_ThinkingFromSettings(t *testing.T) {
primary := &fakeLLMConnector{
fakeConnector: fakeConnector{
id: "ds-pro",
typ: connector.OPENAI,
settings: map[string]interface{}{
"model": "deepseek-v4-pro",
"thinking": map[string]interface{}{
"type": "enabled",
},
},
},
model: "deepseek-v4-pro",
caps: &goullm.Capabilities{ToolCalls: true},
}
heavy := &fakeLLMConnector{
fakeConnector: fakeConnector{id: "ds-pro2", typ: connector.OPENAI, settings: map[string]interface{}{"model": "deepseek-v4-pro-max"}},
model: "deepseek-v4-pro-max",
caps: &goullm.Capabilities{ToolCalls: true},
}
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"heavy": heavy,
},
}
result := buildModelCapabilityPrompt(req)
assert.Contains(t, result, "thinking", "should detect thinking from Setting()[\"thinking\"]")
} }

View file

@ -8,11 +8,14 @@ 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"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
"github.com/yaoapp/yao/agent/sandbox/v2/types" "github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2" infra "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tools"
) )
// Runner implements the sandbox Runner interface for Claude CLI (mode=cli). // Runner implements the sandbox Runner interface for Claude CLI (mode=cli).
@ -48,6 +51,18 @@ func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error {
steps := append([]types.PrepareStep{}, req.Config.Prepare...) steps := append([]types.PrepareStep{}, req.Config.Prepare...)
if ws := req.Computer.Workplace(); ws != nil {
if err := shared.InjectSystemSkills(ws, tools.SkillsFS, ".claude/skills"); err != nil {
r.logger.Warn("inject system skills: %v", err)
}
if err := shared.AppendSystemPrompt(ws, "CLAUDE.md", tools.SystemPrompt); err != nil {
r.logger.Warn("append CLAUDE.md: %v", err)
}
if err := shared.AppendSystemPrompt(ws, "AGENTS.md", tools.SystemPrompt); err != nil {
r.logger.Warn("append AGENTS.md: %v", err)
}
}
if req.SkillsDir != "" { if req.SkillsDir != "" {
ws := req.Computer.Workplace() ws := req.Computer.Workplace()
if ws != nil { if ws != nil {
@ -184,11 +199,13 @@ func (r *Runner) Cleanup(ctx context.Context, computer infra.Computer) error {
} }
type a2oConnectorConfig struct { 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"`
Options map[string]interface{} `json:"options,omitempty"` AuthMode string `json:"auth_mode,omitempty"`
Routes map[string]*a2oConnectorConfig `json:"routes,omitempty"` MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Options map[string]interface{} `json:"options,omitempty"`
Routes map[string]*a2oConnectorConfig `json:"routes,omitempty"`
} }
func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig { func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig {
@ -199,58 +216,66 @@ func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig {
cfg := &a2oConnectorConfig{} cfg := &a2oConnectorConfig{}
if host, ok := settings["host"].(string); ok && host != "" { // Extract standard fields via LLMConnector methods when available
cfg.Backend = connector.BuildAPIURL(host, "/chat/completions") if lc, ok := conn.(goullm.LLMConnector); ok {
} else if proxy, ok := settings["proxy"].(string); ok && proxy != "" { if url := lc.GetURL(); url != "" {
cfg.Backend = connector.BuildAPIURL(proxy, "/chat/completions") cfg.Backend = connector.BuildAPIURL(url, "/chat/completions")
} }
if model, ok := settings["model"].(string); ok && model != "" { cfg.Model = lc.GetModel()
cfg.Model = model cfg.APIKey = lc.GetKey()
} cfg.AuthMode = string(lc.GetAuthMode())
if key, ok := settings["key"].(string); ok && key != "" { if caps := lc.GetCapabilities(); caps != nil && caps.MaxOutputTokens > 0 {
cfg.APIKey = key cfg.MaxOutputTokens = caps.MaxOutputTokens
} }
} else {
extra := make(map[string]interface{}) if host, ok := settings["host"].(string); ok && host != "" {
for k, v := range settings { cfg.Backend = connector.BuildAPIURL(host, "/chat/completions")
switch k { } else if proxy, ok := settings["proxy"].(string); ok && proxy != "" {
case "host", "model", "key", "proxy", "type": cfg.Backend = connector.BuildAPIURL(proxy, "/chat/completions")
continue }
default: if model, ok := settings["model"].(string); ok && model != "" {
extra[k] = v cfg.Model = model
}
if key, ok := settings["key"].(string); ok && key != "" {
cfg.APIKey = key
} }
} }
// Whitelist-filter remaining settings for the options field
extra := connector.FilterRequestBodyParams(settings, conn)
if len(extra) > 0 { if len(extra) > 0 {
cfg.Options = extra cfg.Options = extra
} }
if cfg.MaxOutputTokens == 0 {
cfg.MaxOutputTokens = defaultA2OMaxOutputTokens
}
if cfg.Backend == "" { if cfg.Backend == "" {
return nil return nil
} }
return cfg return cfg
} }
// resolveAllRoleConnectors resolves all declared role connectors and returns // resolveAllRoleConnectors maps pre-resolved role connectors from req.Roles
// a map of virtual model name -> connector for roles that have independent connectors. // to actual model names used as A2O proxy route keys.
func resolveAllRoleConnectors(req *types.StreamRequest) map[string]connector.Connector { func resolveAllRoleConnectors(req *types.StreamRequest) map[string]connector.Connector {
roleConns := getRoleConnectors(req) if len(req.Roles) == 0 {
if len(roleConns) == 0 {
return nil return nil
} }
result := make(map[string]connector.Connector) result := make(map[string]connector.Connector)
for role, rm := range claudeRoleEnvMap { for _, rc := range req.Roles {
if role == "primary" { var model string
continue if lc, ok := rc.(goullm.LLMConnector); ok {
model = lc.GetModel()
} }
rc := resolveRoleConnector(role, roleConns, req.UserExplicit, func(id string) connector.Connector { if model == "" {
c, _ := connector.Connectors[id] model, _ = rc.Setting()["model"].(string)
return c }
}) if model != "" {
if rc == nil { result[model] = rc
continue
} }
result[rm.ModelName] = rc
} }
return result return result
} }

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"
@ -121,14 +122,15 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
// like browsers should be nohup'd; this prevents accidental 2-min hangs. // like browsers should be nohup'd; this prevents accidental 2-min hangs.
env["OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"] = "30000" env["OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"] = "30000"
if req.Connector != nil { primaryConn := resolvePrimaryConnector(req.Connector, req.Roles)
setting := req.Connector.Setting() if primaryConn != nil {
setting := primaryConn.Setting()
key, _ := setting["key"].(string) key, _ := setting["key"].(string)
if key != "" { if key != "" {
env["YAO_PROVIDER_KEY"] = key env["YAO_PROVIDER_KEY"] = key
} }
if req.Connector.Is(connector.ANTHROPIC) { if primaryConn.Is(connector.ANTHROPIC) {
apiKey, _ := setting["key"].(string) apiKey, _ := setting["key"].(string)
if apiKey != "" { if apiKey != "" {
env["ANTHROPIC_API_KEY"] = apiKey env["ANTHROPIC_API_KEY"] = apiKey
@ -174,8 +176,9 @@ func buildArgs(req *types.StreamRequest, r *Runner, isContinuation bool, chatID
args = append(args, "--continue", "--session", sessionID) args = append(args, "--continue", "--session", sessionID)
} }
if req.Connector != nil { primaryConn := resolvePrimaryConnector(req.Connector, req.Roles)
if mid := connectorModelID(req.Connector); mid != "" { if primaryConn != nil {
if mid := connectorModelID(primaryConn); mid != "" {
args = append(args, "--model", mid) args = append(args, "--model", mid)
} }
} }
@ -255,26 +258,13 @@ func buildSandboxEnvPrompt(p platform, workDir string) string {
shell = "bash" shell = "bash"
} }
envVarSyntax := "$VAR_NAME"
if osName == "windows" {
envVarSyntax = "$env:VAR_NAME"
}
return fmt.Sprintf(`## Sandbox Environment return fmt.Sprintf(`## Sandbox Environment
- **Operating System**: %[2]s - **Operating System**: %[2]s
- **Shell**: %[3]s - **Shell**: %[3]s
- **Working Directory**: %[1]s - **Working Directory**: %[1]s
- **File Access**: You have full read/write access to %[1]s - **File Access**: You have full read/write access to %[1]s
- **Environment variable syntax**: `+"`%[4]s`"+` `, workDir, osName, shell)
## User Attachments
User-uploaded files are placed in %[1]s/.attachments/{chatID}/
Each chat session has its own subdirectory.
When the user attaches files, their paths are listed at the top of the message.
**Read these files yourself** using the Read or Bash tool they are NOT passed as CLI arguments.
`, workDir, osName, shell, envVarSyntax)
} }
func getProviderPrefix(conn connector.Connector) string { func getProviderPrefix(conn connector.Connector) string {
@ -284,30 +274,6 @@ func getProviderPrefix(conn connector.Connector) string {
return "openai" return "openai"
} }
// resolveRoleConnector determines which connector to use for a given role.
func resolveRoleConnector(
role string,
roleConnectors map[string]*types.RoleConnector,
userExplicit bool,
getConnector func(id string) connector.Connector,
) connector.Connector {
rc, ok := roleConnectors[role]
if !ok || rc == nil {
return nil
}
if rc.Override == "user" && userExplicit {
return nil
}
return getConnector(rc.Connector)
}
func getRoleConnectors(req *types.StreamRequest) map[string]*types.RoleConnector {
if req.Config == nil {
return nil
}
return req.Config.Runner.Connectors
}
// shellQuoteForPlatform builds a shell-safe command string. On Windows // shellQuoteForPlatform builds a shell-safe command string. On Windows
// (PowerShell) it uses single quotes with ” escaping; on POSIX it uses // (PowerShell) it uses single quotes with ” escaping; on POSIX it uses
// single quotes with '\” escaping. // single quotes with '\” escaping.
@ -349,10 +315,16 @@ func shellQuotePowerShell(program string, args ...string) string {
// connectorModelID returns the "provider/model" string matching the // connectorModelID returns the "provider/model" string matching the
// provider ID used in opencode.json (see buildProviderConfig). // provider ID used in opencode.json (see buildProviderConfig).
// Uses LLMConnector interface first (consistent with buildProviderConfig).
func connectorModelID(c connector.Connector) string { func connectorModelID(c connector.Connector) string {
setting := c.Setting() host := connectorHost(c)
modelName, _ := setting["model"].(string) var modelName string
host, _ := setting["host"].(string) if lc, ok := c.(goullm.LLMConnector); ok {
modelName = lc.GetModel()
}
if modelName == "" {
modelName, _ = c.Setting()["model"].(string)
}
if c.Is(connector.ANTHROPIC) { if c.Is(connector.ANTHROPIC) {
return "anthropic/" + modelName return "anthropic/" + modelName
@ -368,19 +340,15 @@ func connectorModelID(c connector.Connector) string {
// consumed by opencode.json provider blocks (via {env:...} references) and // consumed by opencode.json provider blocks (via {env:...} references) and
// by the custom read.ts tool (for vision API calls). // by the custom read.ts tool (for vision API calls).
func injectRoleEnvVars(env map[string]string, req *types.StreamRequest) { func injectRoleEnvVars(env map[string]string, req *types.StreamRequest) {
if req.Config == nil || req.Config.Runner.Connectors == nil { if len(req.Roles) == 0 {
return return
} }
for role, spec := range openCodeRoleMap { for role, spec := range openCodeRoleMap {
if spec.EnvKeyPrefix == "" { if spec.EnvKeyPrefix == "" {
continue continue
} }
rc, ok := req.Config.Runner.Connectors[role] c, ok := req.Roles[role]
if !ok || rc == nil || rc.Connector == "" { if !ok || c == nil {
continue
}
c, exists := connector.Connectors[rc.Connector]
if !exists || c == nil {
continue continue
} }
setting := c.Setting() setting := c.Setting()
@ -400,6 +368,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,15 +5,18 @@ 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"
) )
type roleSpec struct { type roleSpec struct {
EnvKeyPrefix string EnvKeyPrefix string
TopLevel string TopLevel string
Modalities map[string][]string
} }
// openCodeRoleMap lists roles that map to native OpenCode config concepts.
// "light" → top-level "small_model"; "vision" → env vars only (read.ts hack).
// "heavy" is handled via resolvePrimaryConnector (becomes the main model).
var openCodeRoleMap = map[string]roleSpec{ var openCodeRoleMap = map[string]roleSpec{
"light": { "light": {
EnvKeyPrefix: "YAO_LIGHT", EnvKeyPrefix: "YAO_LIGHT",
@ -21,19 +24,18 @@ var openCodeRoleMap = map[string]roleSpec{
}, },
"vision": { "vision": {
EnvKeyPrefix: "YAO_VISION", EnvKeyPrefix: "YAO_VISION",
Modalities: map[string][]string{
"input": {"text", "image"},
"output": {"text"},
},
},
"heavy": {
EnvKeyPrefix: "YAO_HEAVY",
},
"subagent": {
EnvKeyPrefix: "YAO_SUBAGENT",
}, },
} }
// resolvePrimaryConnector returns the heavy role connector if present in the
// pre-resolved roles map, otherwise falls back to the caller-supplied primary.
func resolvePrimaryConnector(primary connector.Connector, roles map[string]connector.Connector) connector.Connector {
if c, ok := roles["heavy"]; ok && c != nil {
return c
}
return primary
}
// buildOpenCodeConfig generates the opencode.json project configuration. // buildOpenCodeConfig generates the opencode.json project configuration.
// All provider configuration is direct (no a2o proxy). // All provider configuration is direct (no a2o proxy).
func buildOpenCodeConfig(req *types.PrepareRequest, mcpServers []types.MCPServer) []byte { func buildOpenCodeConfig(req *types.PrepareRequest, mcpServers []types.MCPServer) []byte {
@ -46,14 +48,15 @@ func buildOpenCodeConfig(req *types.PrepareRequest, mcpServers []types.MCPServer
"permission": map[string]any{"*": "allow"}, "permission": map[string]any{"*": "allow"},
} }
if req.Connector != nil { primaryConn := resolvePrimaryConnector(req.Connector, req.Roles)
providerID, providerCfg, modelStr := buildProviderConfig(req.Connector) if primaryConn != nil {
providerID, providerCfg, modelStr := buildProviderConfig(primaryConn)
cfg["provider"] = map[string]any{providerID: providerCfg} cfg["provider"] = map[string]any{providerID: providerCfg}
cfg["model"] = modelStr cfg["model"] = modelStr
cfg["enabled_providers"] = []string{providerID} cfg["enabled_providers"] = []string{providerID}
} }
injectRoleProviders(cfg, req) injectRoleProviders(cfg, req, primaryConn)
if len(mcpServers) > 0 { if len(mcpServers) > 0 {
cfg["mcp"] = buildMCPConfig(mcpServers) cfg["mcp"] = buildMCPConfig(mcpServers)
@ -78,8 +81,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}",
@ -114,13 +127,27 @@ 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, using the same FilterRequestBodyParams
// without explicitly sending {"thinking":{"type":"disabled"}}, the API // mechanism as buildRequestBody in yao/agent/llm.
// returns reasoning_content that OpenCode (AI SDK bug) fails to replay. connParams := connector.FilterRequestBodyParams(setting, conn)
modelOpts := buildModelOptions(setting) if len(connParams) > 0 {
if len(modelOpts) > 0 { modelCfg["options"] = connParams
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{
@ -132,21 +159,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 {
@ -170,10 +182,10 @@ func normalizeBaseURL(host string) string {
// injectRoleProviders iterates openCodeRoleMap and injects provider blocks // injectRoleProviders iterates openCodeRoleMap and injects provider blocks
// for every role that has a configured connector. For the "light" role it // for every role that has a configured connector. For the "light" role it
// also sets the top-level "small_model" field. This replaces the old // also sets the top-level "small_model" field. primaryConn is the resolved
// buildSmallModel function and adds support for vision/heavy/subagent roles. // primary connector (may be heavy or default) used for sameProvider checks.
func injectRoleProviders(cfg map[string]any, req *types.PrepareRequest) { func injectRoleProviders(cfg map[string]any, req *types.PrepareRequest, primaryConn connector.Connector) {
if req.Config == nil || req.Config.Runner.Connectors == nil { if len(req.Roles) == 0 {
return return
} }
@ -191,9 +203,9 @@ func injectRoleProviders(cfg map[string]any, req *types.PrepareRequest) {
primaryHost := "" primaryHost := ""
primaryType := "" primaryType := ""
if req.Connector != nil { if primaryConn != nil {
primaryHost = connectorHost(req.Connector) primaryHost = connectorHost(primaryConn)
if req.Connector.Is(connector.ANTHROPIC) { if primaryConn.Is(connector.ANTHROPIC) {
primaryType = "anthropic" primaryType = "anthropic"
} else { } else {
primaryType = "openai" primaryType = "openai"
@ -201,13 +213,8 @@ func injectRoleProviders(cfg map[string]any, req *types.PrepareRequest) {
} }
for role, spec := range openCodeRoleMap { for role, spec := range openCodeRoleMap {
rc, ok := req.Config.Runner.Connectors[role] c, ok := req.Roles[role]
if !ok || rc == nil || rc.Connector == "" { if !ok || c == nil {
continue
}
c, exists := connector.Connectors[rc.Connector]
if !exists || c == nil {
continue continue
} }
@ -238,10 +245,10 @@ func injectRoleProviders(cfg map[string]any, req *types.PrepareRequest) {
if sameProvider { if sameProvider {
providerID = resolveExistingProviderID(providers, primaryType) providerID = resolveExistingProviderID(providers, primaryType)
modelRef = providerID + "/" + modelName modelRef = providerID + "/" + modelName
mergeModelIntoProvider(providers, providerID, modelName, spec.Modalities) mergeModelIntoProvider(providers, providerID, modelName)
} else { } else {
providerID = role providerID = role
providerCfg := buildRoleProviderConfig(c, spec.EnvKeyPrefix, spec.Modalities) providerCfg := buildRoleProviderConfig(c, spec.EnvKeyPrefix)
providers[providerID] = providerCfg providers[providerID] = providerCfg
modelRef = providerID + "/" + modelName modelRef = providerID + "/" + modelName
} }
@ -275,7 +282,7 @@ func resolveExistingProviderID(providers map[string]any, pType string) string {
} }
// mergeModelIntoProvider adds a model entry to an existing provider block. // mergeModelIntoProvider adds a model entry to an existing provider block.
func mergeModelIntoProvider(providers map[string]any, providerID, modelName string, modalities map[string][]string) { func mergeModelIntoProvider(providers map[string]any, providerID, modelName string) {
block, ok := providers[providerID].(map[string]any) block, ok := providers[providerID].(map[string]any)
if !ok { if !ok {
return return
@ -285,17 +292,13 @@ func mergeModelIntoProvider(providers map[string]any, providerID, modelName stri
models = map[string]any{} models = map[string]any{}
block["models"] = models block["models"] = models
} }
modelCfg := map[string]any{"name": modelName} models[modelName] = map[string]any{"name": modelName}
if len(modalities) > 0 {
modelCfg["modalities"] = modalities
}
models[modelName] = modelCfg
} }
// buildRoleProviderConfig creates a provider configuration block for a // buildRoleProviderConfig creates a provider configuration block for a
// non-primary role connector. Uses the role's env key prefix for API key // non-primary role connector. Uses the role's env key prefix for API key
// and base URL references. // and base URL references.
func buildRoleProviderConfig(conn connector.Connector, envKeyPrefix string, modalities map[string][]string) map[string]any { func buildRoleProviderConfig(conn connector.Connector, envKeyPrefix string) map[string]any {
setting := conn.Setting() setting := conn.Setting()
modelName, _ := setting["model"].(string) modelName, _ := setting["model"].(string)
host, _ := setting["host"].(string) host, _ := setting["host"].(string)
@ -305,8 +308,20 @@ func buildRoleProviderConfig(conn connector.Connector, envKeyPrefix string, moda
} }
modelCfg := map[string]any{"name": modelName} modelCfg := map[string]any{"name": modelName}
if len(modalities) > 0 {
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) {

View file

@ -185,8 +185,6 @@ func TestBuildSandboxEnvPrompt_Linux(t *testing.T) {
assert.Contains(t, prompt, "linux") assert.Contains(t, prompt, "linux")
assert.Contains(t, prompt, "bash") assert.Contains(t, prompt, "bash")
assert.Contains(t, prompt, "/workspace") assert.Contains(t, prompt, "/workspace")
assert.Contains(t, prompt, "$VAR_NAME")
assert.NotContains(t, prompt, "$env:")
} }
func TestBuildSandboxEnvPrompt_Windows(t *testing.T) { func TestBuildSandboxEnvPrompt_Windows(t *testing.T) {
@ -195,7 +193,6 @@ func TestBuildSandboxEnvPrompt_Windows(t *testing.T) {
assert.Contains(t, prompt, "windows") assert.Contains(t, prompt, "windows")
assert.Contains(t, prompt, "pwsh") assert.Contains(t, prompt, "pwsh")
assert.Contains(t, prompt, `C:\workspace`) assert.Contains(t, prompt, `C:\workspace`)
assert.Contains(t, prompt, "$env:VAR_NAME")
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -50,26 +50,12 @@ func newFakeAnthropic(id, host, model, key string) *fakeConn {
} }
} }
func registerFakeConnectors(t *testing.T, conns map[string]connector.Connector) func() {
t.Helper()
for id, c := range conns {
connector.Connectors[id] = c
}
return func() {
for id := range conns {
delete(connector.Connectors, id)
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// injectRoleProviders tests // injectRoleProviders tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) { func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis") visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"vision-conn": visionConn})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{ cfg := map[string]any{
@ -80,16 +66,14 @@ func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) {
req := &types.PrepareRequest{ req := &types.PrepareRequest{
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"vision": {Connector: "vision-conn", Override: "force"}, "vision": visionConn,
},
},
}, },
} }
injectRoleProviders(cfg, req) injectRoleProviders(cfg, req, primaryConn)
providers := cfg["provider"].(map[string]any) providers := cfg["provider"].(map[string]any)
visionBlock, ok := providers["vision"] visionBlock, ok := providers["vision"]
@ -99,14 +83,8 @@ func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) {
vBlock := visionBlock.(map[string]any) vBlock := visionBlock.(map[string]any)
models := vBlock["models"].(map[string]any) models := vBlock["models"].(map[string]any)
modelCfg := models["gpt-4o-mini"].(map[string]any) if _, ok := models["gpt-4o-mini"]; !ok {
t.Fatal("vision provider should contain gpt-4o-mini model")
mods, ok := modelCfg["modalities"].(map[string][]string)
if !ok {
t.Fatal("vision model should have modalities declared")
}
if len(mods["input"]) != 2 || mods["input"][0] != "text" || mods["input"][1] != "image" {
t.Errorf("modalities.input = %v, want [text, image]", mods["input"])
} }
enabled := cfg["enabled_providers"].([]string) enabled := cfg["enabled_providers"].([]string)
@ -123,8 +101,6 @@ func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) {
func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) { func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) {
visionConn := newFakeOpenAI("vis", "", "gpt-4o-mini", "sk-oai") visionConn := newFakeOpenAI("vis", "", "gpt-4o-mini", "sk-oai")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"oai-vision": visionConn})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{ cfg := map[string]any{
@ -135,36 +111,23 @@ func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) {
req := &types.PrepareRequest{ req := &types.PrepareRequest{
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"vision": {Connector: "oai-vision", Override: "force"}, "vision": visionConn,
},
},
}, },
} }
injectRoleProviders(cfg, req) injectRoleProviders(cfg, req, primaryConn)
providers := cfg["provider"].(map[string]any) providers := cfg["provider"].(map[string]any)
visionBlock, ok := providers["vision"] if _, ok := providers["vision"]; !ok {
if !ok {
t.Fatal("should have separate 'vision' provider (different host from primary)") t.Fatal("should have separate 'vision' provider (different host from primary)")
} }
vBlock := visionBlock.(map[string]any)
models := vBlock["models"].(map[string]any)
modelCfg := models["gpt-4o-mini"].(map[string]any)
if _, ok := modelCfg["modalities"]; !ok {
t.Error("native OpenAI vision model should still declare modalities")
}
} }
func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) { func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) {
lightConn := newFakeOpenAI("moonshot", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon") lightConn := newFakeOpenAI("moonshot", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"moonshot-conn": lightConn})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{ cfg := map[string]any{
@ -175,16 +138,14 @@ func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) {
req := &types.PrepareRequest{ req := &types.PrepareRequest{
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"light": {Connector: "moonshot-conn", Override: "force"}, "light": lightConn,
},
},
}, },
} }
injectRoleProviders(cfg, req) injectRoleProviders(cfg, req, primaryConn)
providers := cfg["provider"].(map[string]any) providers := cfg["provider"].(map[string]any)
if _, ok := providers["light"]; !ok { if _, ok := providers["light"]; !ok {
@ -213,8 +174,6 @@ func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) {
func TestInjectRoleProviders_LightSameHostAsPrimary(t *testing.T) { func TestInjectRoleProviders_LightSameHostAsPrimary(t *testing.T) {
lightConn := newFakeOpenAI("ds-light", "https://api.deepseek.com", "deepseek-chat", "sk-ds") lightConn := newFakeOpenAI("ds-light", "https://api.deepseek.com", "deepseek-chat", "sk-ds")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"ds-light-conn": lightConn})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
primaryProviderID, primaryCfg, modelStr := buildProviderConfig(primaryConn) primaryProviderID, primaryCfg, modelStr := buildProviderConfig(primaryConn)
@ -227,16 +186,14 @@ func TestInjectRoleProviders_LightSameHostAsPrimary(t *testing.T) {
req := &types.PrepareRequest{ req := &types.PrepareRequest{
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"light": {Connector: "ds-light-conn", Override: "force"}, "light": lightConn,
},
},
}, },
} }
injectRoleProviders(cfg, req) injectRoleProviders(cfg, req, primaryConn)
providers := cfg["provider"].(map[string]any) providers := cfg["provider"].(map[string]any)
if _, ok := providers["light"]; ok { if _, ok := providers["light"]; ok {
@ -262,11 +219,13 @@ func TestInjectRoleProviders_NoConnectors(t *testing.T) {
"enabled_providers": []string{"openai"}, "enabled_providers": []string{"openai"},
} }
primaryConn := newFakeOpenAI("primary", "", "gpt-4o", "sk-oai")
req := &types.PrepareRequest{ req := &types.PrepareRequest{
Config: &types.SandboxConfig{}, Connector: primaryConn,
Config: &types.SandboxConfig{},
} }
injectRoleProviders(cfg, req) injectRoleProviders(cfg, req, primaryConn)
enabled := cfg["enabled_providers"].([]string) enabled := cfg["enabled_providers"].([]string)
if len(enabled) != 1 || enabled[0] != "openai" { if len(enabled) != 1 || enabled[0] != "openai" {
@ -276,8 +235,6 @@ func TestInjectRoleProviders_NoConnectors(t *testing.T) {
func TestInjectRoleProviders_AnthropicVision(t *testing.T) { func TestInjectRoleProviders_AnthropicVision(t *testing.T) {
visionConn := newFakeAnthropic("claude-vis", "https://api.anthropic.com", "claude-sonnet-4-5-20250929", "sk-ant") visionConn := newFakeAnthropic("claude-vis", "https://api.anthropic.com", "claude-sonnet-4-5-20250929", "sk-ant")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"anthropic-vision": visionConn})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{ cfg := map[string]any{
@ -288,16 +245,14 @@ func TestInjectRoleProviders_AnthropicVision(t *testing.T) {
req := &types.PrepareRequest{ req := &types.PrepareRequest{
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"vision": {Connector: "anthropic-vision", Override: "force"}, "vision": visionConn,
},
},
}, },
} }
injectRoleProviders(cfg, req) injectRoleProviders(cfg, req, primaryConn)
providers := cfg["provider"].(map[string]any) providers := cfg["provider"].(map[string]any)
visionBlock, ok := providers["vision"] visionBlock, ok := providers["vision"]
@ -317,16 +272,11 @@ func TestInjectRoleProviders_AnthropicVision(t *testing.T) {
func TestInjectRoleEnvVars_Vision(t *testing.T) { func TestInjectRoleEnvVars_Vision(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis-key") visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis-key")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"vision-conn": visionConn})
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "vision": visionConn,
"vision": {Connector: "vision-conn", Override: "force"},
},
},
}, },
} }
@ -346,16 +296,11 @@ func TestInjectRoleEnvVars_Vision(t *testing.T) {
func TestInjectRoleEnvVars_Light(t *testing.T) { func TestInjectRoleEnvVars_Light(t *testing.T) {
lightConn := newFakeOpenAI("moon", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon") lightConn := newFakeOpenAI("moon", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"moon-conn": lightConn})
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "light": lightConn,
"light": {Connector: "moon-conn", Override: "force"},
},
},
}, },
} }
@ -381,7 +326,7 @@ func TestInjectRoleEnvVars_NoConnectors(t *testing.T) {
env := map[string]string{} env := map[string]string{}
injectRoleEnvVars(env, req) injectRoleEnvVars(env, req)
for _, prefix := range []string{"YAO_VISION", "YAO_LIGHT", "YAO_HEAVY", "YAO_SUBAGENT"} { for _, prefix := range []string{"YAO_VISION", "YAO_LIGHT"} {
for _, suffix := range []string{"_KEY", "_BASE_URL", "_MODEL"} { for _, suffix := range []string{"_KEY", "_BASE_URL", "_MODEL"} {
if v, ok := env[prefix+suffix]; ok { if v, ok := env[prefix+suffix]; ok {
t.Errorf("unexpected env %s=%s with no connectors", prefix+suffix, v) t.Errorf("unexpected env %s=%s with no connectors", prefix+suffix, v)
@ -393,24 +338,12 @@ func TestInjectRoleEnvVars_NoConnectors(t *testing.T) {
func TestInjectRoleEnvVars_MultipleRoles(t *testing.T) { func TestInjectRoleEnvVars_MultipleRoles(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.vision.com", "vis-model", "sk-vis") visionConn := newFakeOpenAI("vis", "https://api.vision.com", "vis-model", "sk-vis")
lightConn := newFakeOpenAI("light-c", "https://api.light.com", "light-model", "sk-light") lightConn := newFakeOpenAI("light-c", "https://api.light.com", "light-model", "sk-light")
heavyConn := newFakeOpenAI("heavy-c", "https://api.heavy.com", "heavy-model", "sk-heavy")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{
"vis-c": visionConn,
"light-c": lightConn,
"heavy-c": heavyConn,
})
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "vision": visionConn,
"vision": {Connector: "vis-c", Override: "force"}, "light": lightConn,
"light": {Connector: "light-c", Override: "force"},
"heavy": {Connector: "heavy-c", Override: "force"},
},
},
}, },
} }
@ -423,9 +356,6 @@ func TestInjectRoleEnvVars_MultipleRoles(t *testing.T) {
if env["YAO_LIGHT_KEY"] != "sk-light" { if env["YAO_LIGHT_KEY"] != "sk-light" {
t.Errorf("YAO_LIGHT_KEY = %q", env["YAO_LIGHT_KEY"]) t.Errorf("YAO_LIGHT_KEY = %q", env["YAO_LIGHT_KEY"])
} }
if env["YAO_HEAVY_KEY"] != "sk-heavy" {
t.Errorf("YAO_HEAVY_KEY = %q", env["YAO_HEAVY_KEY"])
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -435,23 +365,16 @@ func TestInjectRoleEnvVars_MultipleRoles(t *testing.T) {
func TestBuildOpenCodeConfig_WithVisionAndLight(t *testing.T) { func TestBuildOpenCodeConfig_WithVisionAndLight(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis") visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis")
lightConn := newFakeOpenAI("moon", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon") lightConn := newFakeOpenAI("moon", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{
"vision-conn": visionConn,
"light-conn": lightConn,
})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
req := &types.PrepareRequest{ req := &types.PrepareRequest{
AssistantID: "test-assistant", AssistantID: "test-assistant",
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"vision": {Connector: "vision-conn", Override: "force"}, "vision": visionConn,
"light": {Connector: "light-conn", Override: "force"}, "light": lightConn,
},
},
}, },
} }
@ -491,3 +414,46 @@ func TestBuildOpenCodeConfig_WithVisionAndLight(t *testing.T) {
} }
} }
} }
// ---------------------------------------------------------------------------
// resolvePrimaryConnector tests
// ---------------------------------------------------------------------------
func TestResolvePrimaryConnector_HeavyConfigured(t *testing.T) {
defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds")
heavyConn := newFakeOpenAI("heavy", "https://api.openai.com", "o3-pro", "sk-oai")
roles := map[string]connector.Connector{
"default": defaultConn,
"heavy": heavyConn,
}
result := resolvePrimaryConnector(defaultConn, roles)
if result != heavyConn {
t.Error("should return heavy connector when configured")
}
}
func TestResolvePrimaryConnector_NoHeavy(t *testing.T) {
defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds")
lightConn := newFakeOpenAI("light", "https://api.moonshot.cn", "moon-v1", "sk-moon")
roles := map[string]connector.Connector{
"default": defaultConn,
"light": lightConn,
}
result := resolvePrimaryConnector(defaultConn, roles)
if result != defaultConn {
t.Error("should fallback to default when heavy not configured")
}
}
func TestResolvePrimaryConnector_NilRoles(t *testing.T) {
defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds")
result := resolvePrimaryConnector(defaultConn, nil)
if result != defaultConn {
t.Error("should return default when roles is nil")
}
}

View file

@ -12,6 +12,7 @@ import (
"github.com/yaoapp/yao/agent/sandbox/v2/shared" "github.com/yaoapp/yao/agent/sandbox/v2/shared"
"github.com/yaoapp/yao/agent/sandbox/v2/types" "github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2" infra "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tools"
) )
// Runner implements the sandbox Runner interface for OpenCode CLI. // Runner implements the sandbox Runner interface for OpenCode CLI.
@ -47,6 +48,19 @@ func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error {
steps := append([]types.PrepareStep{}, req.Config.Prepare...) steps := append([]types.PrepareStep{}, req.Config.Prepare...)
// 0. Inject system tool SKILLs + prompts (before assistant-specific skills copy)
if ws := req.Computer.Workplace(); ws != nil {
if err := shared.InjectSystemSkills(ws, tools.SkillsFS, ".claude/skills"); err != nil {
log.Warn("[opencode-runner] inject system skills: %v", err)
}
if err := shared.AppendSystemPrompt(ws, "CLAUDE.md", tools.SystemPrompt); err != nil {
log.Warn("[opencode-runner] append CLAUDE.md: %v", err)
}
if err := shared.AppendSystemPrompt(ws, "AGENTS.md", tools.SystemPrompt); err != nil {
log.Warn("[opencode-runner] append AGENTS.md: %v", err)
}
}
// 1. Skills copy (aligned with Claude Runner) // 1. Skills copy (aligned with Claude Runner)
if req.SkillsDir != "" { if req.SkillsDir != "" {
ws := req.Computer.Workplace() ws := req.Computer.Workplace()
@ -79,16 +93,14 @@ func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error {
// config dir ($HOME/.config/opencode/tools/). Only needed when a // config dir ($HOME/.config/opencode/tools/). Only needed when a
// vision connector is configured — the custom read tool overrides the // vision connector is configured — the custom read tool overrides the
// built-in read to route image files through the vision API. // built-in read to route image files through the vision API.
if req.Config != nil && req.Config.Runner.Connectors != nil { if _, ok := req.Roles["vision"]; ok {
if vc, ok := req.Config.Runner.Connectors["vision"]; ok && vc != nil && vc.Connector != "" { p := resolvePlatform(req.Computer)
p := resolvePlatform(req.Computer) steps = append(steps, types.PrepareStep{
steps = append(steps, types.PrepareStep{ Action: "exec",
Action: "exec", Cmd: visionCopyCmd(p),
Cmd: visionCopyCmd(p), Once: true,
Once: true, IgnoreError: true,
IgnoreError: true, })
})
}
} }
// 5. Generate opencode.json (project config at workspace root) // 5. Generate opencode.json (project config at workspace root)

View file

@ -0,0 +1,79 @@
package shared
import (
"bytes"
"errors"
"io/fs"
"os"
"path"
"strings"
)
const systemToolsMarker = "<!-- Yao System Tools (auto-injected) -->"
// writerFS is the minimal filesystem interface needed by the injection helpers.
// workspace.FS satisfies this interface.
type writerFS interface {
ReadFile(name string) ([]byte, error)
WriteFile(name string, data []byte, perm os.FileMode) error
MkdirAll(name string, perm os.FileMode) error
}
// InjectSystemSkills copies SKILL files from an embed.FS into the workspace.
// The skills parameter should be an embed.FS produced by `//go:embed skills`,
// where each file has a path like "skills/yao-web/SKILL.md". This function
// strips the "skills/" prefix and writes files into targetDir (e.g. ".claude/skills").
func InjectSystemSkills(ws writerFS, skills fs.FS, targetDir string) error {
return fs.WalkDir(skills, "skills", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel := strings.TrimPrefix(p, "skills/")
dst := path.Join(targetDir, rel)
data, err := fs.ReadFile(skills, p)
if err != nil {
return err
}
dir := path.Dir(dst)
if err := ws.MkdirAll(dir, 0755); err != nil {
return err
}
return ws.WriteFile(dst, data, 0644)
})
}
// AppendSystemPrompt injects content into a file in the workspace using an
// idempotent marker. If the marker already exists, the injected section is
// replaced with the new content (so updates propagate to existing sandboxes).
// If the file does not exist it is created with just the marker + content.
func AppendSystemPrompt(ws writerFS, filename string, content []byte) error {
existing, err := ws.ReadFile(filename)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return err
}
header := []byte(systemToolsMarker + "\n\n")
return ws.WriteFile(filename, append(header, content...), 0644)
}
idx := bytes.Index(existing, []byte(systemToolsMarker))
if idx >= 0 {
injected := append([]byte(systemToolsMarker+"\n\n"), content...)
prefix := existing[:idx]
merged := append(bytes.TrimRight(prefix, "\n\r\t "), []byte("\n\n---\n\n")...)
if idx == 0 {
merged = nil
}
return ws.WriteFile(filename, append(merged, injected...), 0644)
}
separator := []byte("\n\n---\n\n" + systemToolsMarker + "\n\n")
merged := append(existing, append(separator, content...)...)
return ws.WriteFile(filename, merged, 0644)
}

View file

@ -0,0 +1,209 @@
package shared
import (
"io/fs"
"os"
"path/filepath"
"testing"
"testing/fstest"
)
func TestInjectSystemSkills_CopiesAllFiles(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
skills := fstest.MapFS{
"skills/yao-web/SKILL.md": {Data: []byte("web skill")},
"skills/yao-process/SKILL.md": {Data: []byte("process skill")},
"skills/yao-doc/SKILL.md": {Data: []byte("doc skill")},
}
if err := InjectSystemSkills(ws, skills, ".claude/skills"); err != nil {
t.Fatalf("InjectSystemSkills: %v", err)
}
for _, tc := range []struct {
path string
want string
}{
{".claude/skills/yao-web/SKILL.md", "web skill"},
{".claude/skills/yao-process/SKILL.md", "process skill"},
{".claude/skills/yao-doc/SKILL.md", "doc skill"},
} {
data, err := os.ReadFile(filepath.Join(dir, tc.path))
if err != nil {
t.Errorf("ReadFile(%s): %v", tc.path, err)
continue
}
if string(data) != tc.want {
t.Errorf("%s = %q, want %q", tc.path, data, tc.want)
}
}
}
func TestAppendSystemPrompt_CreatesNewFile(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
content := []byte("## Yao System Tools\ntai tool ...")
if err := AppendSystemPrompt(ws, "CLAUDE.md", content); err != nil {
t.Fatalf("AppendSystemPrompt: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if got := string(data); got == "" {
t.Fatal("file should not be empty")
}
assertContains(t, string(data), systemToolsMarker)
assertContains(t, string(data), "Yao System Tools")
}
func TestAppendSystemPrompt_AppendsToExisting(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
existing := []byte("# My Project\n\nExisting content.\n")
if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), existing, 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
content := []byte("## System Tools\n")
if err := AppendSystemPrompt(ws, "CLAUDE.md", content); err != nil {
t.Fatalf("AppendSystemPrompt: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
got := string(data)
assertContains(t, got, "My Project")
assertContains(t, got, systemToolsMarker)
assertContains(t, got, "System Tools")
}
func TestAppendSystemPrompt_Idempotent(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
content := []byte("## Yao System Tools\n")
if err := AppendSystemPrompt(ws, "AGENTS.md", content); err != nil {
t.Fatalf("first call: %v", err)
}
first, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if err := AppendSystemPrompt(ws, "AGENTS.md", content); err != nil {
t.Fatalf("second call: %v", err)
}
second, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if string(first) != string(second) {
t.Errorf("second call modified the file (not idempotent):\n--- first ---\n%s\n--- second ---\n%s", first, second)
}
}
func TestAppendSystemPrompt_UpdatesExistingContent(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
oldContent := []byte("## Old Tools\nweb_search only\n")
if err := AppendSystemPrompt(ws, "CLAUDE.md", oldContent); err != nil {
t.Fatalf("first call: %v", err)
}
newContent := []byte("## Updated Tools\nweb_search + image_read\n")
if err := AppendSystemPrompt(ws, "CLAUDE.md", newContent); err != nil {
t.Fatalf("second call: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
got := string(data)
assertContains(t, got, "image_read")
assertContains(t, got, systemToolsMarker)
if containsStr(got, "Old Tools") {
t.Error("old content should have been replaced")
}
}
func TestAppendSystemPrompt_UpdatesPreservesUserContent(t *testing.T) {
dir := t.TempDir()
ws := newDirFS(dir)
userContent := []byte("# My Project\n\nUser notes.\n")
if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), userContent, 0644); err != nil {
t.Fatal(err)
}
oldContent := []byte("## Old Tools\n")
if err := AppendSystemPrompt(ws, "CLAUDE.md", oldContent); err != nil {
t.Fatal(err)
}
newContent := []byte("## Updated Tools\nimage_read added\n")
if err := AppendSystemPrompt(ws, "CLAUDE.md", newContent); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
got := string(data)
assertContains(t, got, "My Project")
assertContains(t, got, "User notes")
assertContains(t, got, "image_read")
if containsStr(got, "Old Tools") {
t.Error("old injected content should have been replaced")
}
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
func assertContains(t *testing.T, s, sub string) {
t.Helper()
if len(s) < len(sub) {
t.Errorf("string does not contain %q", sub)
return
}
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return
}
}
t.Errorf("string does not contain %q:\n%s", sub, s)
}
// dirFS is a minimal workspace.FS backed by a real directory (for testing).
type dirFS struct {
root string
}
func newDirFS(root string) *dirFS { return &dirFS{root: root} }
func (d *dirFS) Open(name string) (fs.File, error) {
return os.Open(filepath.Join(d.root, name))
}
func (d *dirFS) ReadFile(name string) ([]byte, error) {
data, err := os.ReadFile(filepath.Join(d.root, name))
if err != nil {
return nil, err
}
return data, nil
}
func (d *dirFS) WriteFile(name string, data []byte, perm os.FileMode) error {
return os.WriteFile(filepath.Join(d.root, name), data, perm)
}
func (d *dirFS) MkdirAll(name string, perm os.FileMode) error {
return os.MkdirAll(filepath.Join(d.root, name), perm)
}

View file

@ -38,7 +38,8 @@ type PrepareRequest struct {
Computer infra.Computer Computer infra.Computer
Config *SandboxConfig Config *SandboxConfig
Connector connector.Connector Connector connector.Connector
AssistantID string // the assistant's own ID (e.g. "yao/postman") Roles map[string]connector.Connector // pre-resolved role matrix from llmprovider
AssistantID string // the assistant's own ID (e.g. "yao/postman")
SkillsDir string SkillsDir string
AssistantDir string // absolute host path to the assistant source directory AssistantDir string // absolute host path to the assistant source directory
MCPServers []MCPServer MCPServers []MCPServer
@ -51,7 +52,8 @@ type StreamRequest struct {
Computer infra.Computer Computer infra.Computer
Config *SandboxConfig Config *SandboxConfig
Connector connector.Connector Connector connector.Connector
AssistantID string // the assistant's own ID (e.g. "yao/postman") Roles map[string]connector.Connector // pre-resolved role matrix from llmprovider
AssistantID string // the assistant's own ID (e.g. "yao/postman")
Messages []agentContext.Message Messages []agentContext.Message
SystemPrompt string SystemPrompt string
ChatID string ChatID string

View file

@ -3,9 +3,11 @@ package web
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/search/types" "github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/tools/websearch"
) )
// Handler implements web search // Handler implements web search
@ -35,7 +37,7 @@ func (h *Handler) Search(req *types.Request) (*types.Result, error) {
func (h *Handler) SearchWithContext(ctx *agentContext.Context, req *types.Request) (*types.Result, error) { func (h *Handler) SearchWithContext(ctx *agentContext.Context, req *types.Request) (*types.Result, error) {
switch { switch {
case h.usesWeb == "builtin" || h.usesWeb == "": case h.usesWeb == "builtin" || h.usesWeb == "":
return h.builtinSearch(req) return h.builtinSearch(ctx, req)
case strings.HasPrefix(h.usesWeb, "mcp:"): case strings.HasPrefix(h.usesWeb, "mcp:"):
return h.mcpSearch(req) return h.mcpSearch(req)
default: default:
@ -54,33 +56,43 @@ func (h *Handler) SearchWithContext(ctx *agentContext.Context, req *types.Reques
} }
} }
// builtinSearch uses Tavily/Serper/SerpAPI directly // builtinSearch delegates to tools/websearch which reads Settings → ENV config.
func (h *Handler) builtinSearch(req *types.Request) (*types.Result, error) { func (h *Handler) builtinSearch(ctx *agentContext.Context, req *types.Request) (*types.Result, error) {
// Determine provider from config startTime := time.Now()
providerName := "tavily" // default
if h.config != nil && h.config.Provider != "" { limit := req.Limit
providerName = h.config.Provider if limit <= 0 {
limit = 10
} }
switch providerName { var userID, teamID string
case "tavily": if ctx != nil && ctx.Authorized != nil {
return NewTavilyProvider(h.config).Search(req) userID = ctx.Authorized.UserID
case "serper": teamID = ctx.Authorized.TeamID
// Serper (serper.dev) - POST request with X-API-KEY header
return NewSerperProvider(h.config).Search(req)
case "serpapi":
// SerpAPI (serpapi.com) - GET request with api_key parameter
return NewSerpAPIProvider(h.config).Search(req)
default:
return &types.Result{
Type: types.SearchTypeWeb,
Query: req.Query,
Source: req.Source,
Items: []*types.ResultItem{},
Total: 0,
Error: fmt.Sprintf("Unknown provider: %s (supported: tavily, serper, serpapi)", providerName),
}, nil
} }
results := websearch.Search(req.Query, limit, userID, teamID)
items := make([]*types.ResultItem, 0, len(results))
for _, r := range results {
items = append(items, &types.ResultItem{
Type: types.SearchTypeWeb,
Title: r.Title,
Content: r.Content,
URL: r.URL,
Score: r.Score,
Source: req.Source,
})
}
return &types.Result{
Type: types.SearchTypeWeb,
Query: req.Query,
Source: req.Source,
Items: items,
Total: len(items),
Duration: time.Since(startTime).Milliseconds(),
}, nil
} }
// agentSearch delegates to an assistant for AI-powered search // agentSearch delegates to an assistant for AI-powered search

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,14 @@ 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
Heavy string `json:"heavy,omitempty" yaml:"heavy,omitempty"` // Default connector for the "heavy" role (complex reasoning)
// 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 +108,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
Voice string `json:"voice,omitempty" yaml:"voice,omitempty"` // Connector for voice/STT capabilities
} }
// Mention Structure // Mention Structure

54
config/decrypt.go Normal file
View file

@ -0,0 +1,54 @@
package config
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"strings"
)
const encPrefix = "enc:"
// DecryptValue decrypts a value encrypted by cloud settings.
// Returns the original string if not encrypted (no "enc:" prefix)
// or if no AES key is configured.
func DecryptValue(s string) string {
if !strings.HasPrefix(s, encPrefix) {
return s
}
secret := Conf.DB.AESKey
if secret == "" {
return strings.TrimPrefix(s, encPrefix)
}
dec, err := aesGCMDecrypt(strings.TrimPrefix(s, encPrefix), secret)
if err != nil {
return s
}
return dec
}
func aesGCMDecrypt(encoded, secret string) (string, error) {
key := sha256.Sum256([]byte(secret))
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", aes.KeySizeError(len(data))
}
plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

File diff suppressed because one or more lines are too long

View file

@ -448,6 +448,11 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
warnings = append(warnings, Warning{Widget: "Setting Registry", Error: err}) warnings = append(warnings, Warning{Widget: "Setting Registry", Error: err})
} }
// Sync agent.yml system defaults into setting.Global (must run after llmprovider + setting init)
if err := agent.SyncLLMDefaults(); err != nil {
warnings = append(warnings, Warning{Widget: "LLM Defaults Sync", Error: err})
}
for name, hook := range LoadHooks { for name, hook := range LoadHooks {
err = hook(cfg) err = hook(cfg)
if err != nil { if err != nil {
@ -721,6 +726,11 @@ func Reload(cfg config.Config, options LoadOption) (err error) {
} }
} }
// Sync agent.yml system defaults into setting.Global (must run after llmprovider + setting init)
if err := agent.SyncLLMDefaults(); err != nil {
printErr(cfg.Mode, "LLM Defaults Sync", err)
}
// Load OpenAPI // Load OpenAPI
_, err = openapi.Load(cfg) _, err = openapi.Load(cfg)
if err != nil { if err != nil {

2
go.mod
View file

@ -45,6 +45,7 @@ require (
github.com/yaoapp/xun v0.9.0 github.com/yaoapp/xun v0.9.0
go.mongodb.org/mongo-driver v1.17.3 go.mongodb.org/mongo-driver v1.17.3
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
golang.org/x/image v0.38.0
golang.org/x/net v0.52.0 golang.org/x/net v0.52.0
golang.org/x/sys v0.42.0 golang.org/x/sys v0.42.0
golang.org/x/text v0.35.0 golang.org/x/text v0.35.0
@ -211,7 +212,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.17.0 // indirect golang.org/x/arch v0.17.0 // indirect
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect

View file

@ -8,12 +8,37 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
goumcp "github.com/yaoapp/gou/mcp" goumcp "github.com/yaoapp/gou/mcp"
"github.com/yaoapp/yao/grpc/auth"
"github.com/yaoapp/yao/grpc/pb" "github.com/yaoapp/yao/grpc/pb"
) )
// Handler implements the MCP gRPC methods. // Handler implements the MCP gRPC methods.
type Handler struct{} type Handler struct{}
// grpcAuthProvider adapts gRPC AuthorizedInfo to the AuthorizedProvider
// interface expected by gou/mcp/process for propagating auth to process calls.
type grpcAuthProvider struct {
m map[string]interface{}
}
func (p *grpcAuthProvider) GetAuthorizedMap() map[string]interface{} { return p.m }
func authProviderFromCtx(ctx context.Context) *grpcAuthProvider {
info := auth.GetAuthorizedInfo(ctx)
if info == nil {
return nil
}
return &grpcAuthProvider{m: map[string]interface{}{
"sub": info.Subject,
"client_id": info.ClientID,
"scope": info.Scope,
"session_id": info.SessionID,
"user_id": info.UserID,
"team_id": info.TeamID,
"tenant_id": info.TenantID,
}}
}
// MCPListTools lists all available MCP tools for a given session. // MCPListTools lists all available MCP tools for a given session.
func (h *Handler) MCPListTools(ctx context.Context, req *pb.MCPListRequest) (*pb.MCPListResponse, error) { func (h *Handler) MCPListTools(ctx context.Context, req *pb.MCPListRequest) (*pb.MCPListResponse, error) {
client, err := goumcp.Select(req.SessionId) client, err := goumcp.Select(req.SessionId)
@ -48,7 +73,12 @@ func (h *Handler) MCPCallTool(ctx context.Context, req *pb.MCPCallRequest) (*pb.
} }
} }
resp, err := client.CallTool(ctx, req.Tool, args) var extraArgs []interface{}
if ap := authProviderFromCtx(ctx); ap != nil {
extraArgs = append(extraArgs, ap)
}
resp, err := client.CallTool(ctx, req.Tool, args, extraArgs...)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "CallTool failed: %v", err) return nil, status.Errorf(codes.Internal, "CallTool failed: %v", err)
} }

357
llmprovider/models.go Normal file
View file

@ -0,0 +1,357 @@
package llmprovider
import (
"fmt"
"strings"
"github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
)
// ---------------------------------------------------------------------------
// GetModel — by connectorID
// ---------------------------------------------------------------------------
// GetModel returns the runtime connector for a given connectorID.
// Lookup order:
// 1. connector.Select (already registered in runtime)
// 2. Model-level ID with ":" separator (e.g. "t123.openai:gpt-4o")
// 3. r.Get by store Key (works when connectorID == Key, e.g. builtin)
// 4. r.GetByConnectorID (linear scan by ConnectorID field, for dynamic providers)
func (r *Registry) GetModel(connectorID string) (connector.Connector, error) {
if conn, err := connector.Select(connectorID); err == nil {
return conn, nil
}
// Path 2: model-level ID "providerCID:modelID"
if parts := strings.SplitN(connectorID, ":", 2); len(parts) == 2 {
return r.getModelConnector(parts[0], parts[1])
}
// Path 3: try by Key (fast, works for builtin where Key == ConnectorID)
if p, err := r.Get(connectorID, true); err == nil {
if eerr := ensureConnector(p); eerr != nil {
return nil, fmt.Errorf("model %q ensure connector: %w", connectorID, eerr)
}
cid := p.ConnectorID
if cid == "" {
cid = connectorID
}
return connector.Select(cid)
}
// Path 4: reverse lookup by ConnectorID field (dynamic providers where Key != ConnectorID)
p, err := r.GetByConnectorID(connectorID, true)
if err != nil {
return nil, fmt.Errorf("model %q not found", connectorID)
}
cid := p.ConnectorID
if cid == "" {
cid = connectorID
}
return connector.Select(cid)
}
// getModelConnector finds a provider by connectorID, locates the model, and
// ensures a per-model connector is registered in the runtime.
func (r *Registry) getModelConnector(providerCID, modelID string) (connector.Connector, error) {
p, err := r.GetByConnectorID(providerCID, true)
if err != nil {
if p2, err2 := r.Get(providerCID, true); err2 == nil {
p = p2
} else {
return nil, fmt.Errorf("provider %q not found for model %q", providerCID, modelID)
}
}
var model *ModelInfo
for i, m := range p.Models {
if m.ID == modelID {
model = &p.Models[i]
break
}
}
if model == nil {
return nil, fmt.Errorf("model %q not found in provider %q", modelID, providerCID)
}
if err := ensureModelConnector(p, model); err != nil {
return nil, err
}
cid := providerCID + ":" + modelID
return connector.Select(cid)
}
// ---------------------------------------------------------------------------
// GetRoleModel — role → connector
// ---------------------------------------------------------------------------
// GetRoleModel returns the connector for a role at system scope.
func (r *Registry) GetRoleModel(role string) (connector.Connector, error) {
cid, err := r.GetRole(role)
if err != nil {
return nil, err
}
return r.GetModel(cid)
}
// GetRoleModelByUser returns the connector for a role, merged user > system.
func (r *Registry) GetRoleModelByUser(role, userID string) (connector.Connector, error) {
cid, err := r.GetRoleByUser(role, userID)
if err != nil {
return nil, err
}
return r.GetModel(cid)
}
// GetRoleModelByTeam returns the connector for a role, merged team > system.
func (r *Registry) GetRoleModelByTeam(role, teamID string) (connector.Connector, error) {
cid, err := r.GetRoleByTeam(role, teamID)
if err != nil {
return nil, err
}
return r.GetModel(cid)
}
// ---------------------------------------------------------------------------
// Built-in role shortcuts
// ---------------------------------------------------------------------------
func (r *Registry) GetDefaultModel() (connector.Connector, error) { return r.GetRoleModel("default") }
func (r *Registry) GetDefaultModelByUser(userID string) (connector.Connector, error) {
return r.GetRoleModelByUser("default", userID)
}
func (r *Registry) GetDefaultModelByTeam(teamID string) (connector.Connector, error) {
return r.GetRoleModelByTeam("default", teamID)
}
func (r *Registry) GetVisionModel() (connector.Connector, error) { return r.GetRoleModel("vision") }
func (r *Registry) GetVisionModelByUser(userID string) (connector.Connector, error) {
return r.GetRoleModelByUser("vision", userID)
}
func (r *Registry) GetVisionModelByTeam(teamID string) (connector.Connector, error) {
return r.GetRoleModelByTeam("vision", teamID)
}
func (r *Registry) GetAudioModel() (connector.Connector, error) { return r.GetRoleModel("audio") }
func (r *Registry) GetAudioModelByUser(userID string) (connector.Connector, error) {
return r.GetRoleModelByUser("audio", userID)
}
func (r *Registry) GetAudioModelByTeam(teamID string) (connector.Connector, error) {
return r.GetRoleModelByTeam("audio", teamID)
}
func (r *Registry) GetEmbeddingModel() (connector.Connector, error) {
return r.GetRoleModel("embedding")
}
func (r *Registry) GetEmbeddingModelByUser(userID string) (connector.Connector, error) {
return r.GetRoleModelByUser("embedding", userID)
}
func (r *Registry) GetEmbeddingModelByTeam(teamID string) (connector.Connector, error) {
return r.GetRoleModelByTeam("embedding", teamID)
}
// ---------------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------------
// GetCapabilities returns capabilities for a connector by connectorID.
func (r *Registry) GetCapabilities(connectorID string) (*goullm.Capabilities, error) {
conn, err := r.GetModel(connectorID)
if err != nil {
return nil, err
}
return capabilitiesFromConn(conn), nil
}
// GetRoleCapabilities returns capabilities for a role at system scope.
func (r *Registry) GetRoleCapabilities(role string) (*goullm.Capabilities, error) {
conn, err := r.GetRoleModel(role)
if err != nil {
return nil, err
}
return capabilitiesFromConn(conn), nil
}
// GetRoleCapabilitiesByUser returns capabilities for a role, merged user > system.
func (r *Registry) GetRoleCapabilitiesByUser(role, userID string) (*goullm.Capabilities, error) {
conn, err := r.GetRoleModelByUser(role, userID)
if err != nil {
return nil, err
}
return capabilitiesFromConn(conn), nil
}
// GetRoleCapabilitiesByTeam returns capabilities for a role, merged team > system.
func (r *Registry) GetRoleCapabilitiesByTeam(role, teamID string) (*goullm.Capabilities, error) {
conn, err := r.GetRoleModelByTeam(role, teamID)
if err != nil {
return nil, err
}
return capabilitiesFromConn(conn), nil
}
// ---------------------------------------------------------------------------
// ListModels
// ---------------------------------------------------------------------------
// ListModels returns all enabled models as []connector.Option (system scope, no owner filter).
func (r *Registry) ListModels() []connector.Option {
return r.listModels(nil)
}
// ListModelsByUser returns builtin + user-owned dynamic models.
func (r *Registry) ListModelsByUser(userID string) []connector.Option {
return r.listModels(&ProviderOwner{Type: "user", UserID: userID})
}
// ListModelsByTeam returns builtin + team-owned dynamic models.
func (r *Registry) ListModelsByTeam(teamID string) []connector.Option {
return r.listModels(&ProviderOwner{Type: "team", TeamID: teamID})
}
// ListModelsBy returns models scoped to the caller's identity (team > user).
func (r *Registry) ListModelsBy(id Identity) []connector.Option {
if id.GetTeamID() != "" {
return r.ListModelsByTeam(id.GetTeamID())
}
return r.ListModelsByUser(id.GetUserID())
}
// ---------------------------------------------------------------------------
// By — Identity-scoped convenience methods
// ---------------------------------------------------------------------------
// GetRoleBy returns the connectorID for a role, scoped by identity.
func (r *Registry) GetRoleModelBy(role string, id Identity) (connector.Connector, error) {
if id.GetTeamID() != "" {
return r.GetRoleModelByTeam(role, id.GetTeamID())
}
return r.GetRoleModelByUser(role, id.GetUserID())
}
func (r *Registry) GetDefaultModelBy(id Identity) (connector.Connector, error) {
return r.GetRoleModelBy("default", id)
}
func (r *Registry) GetVisionModelBy(id Identity) (connector.Connector, error) {
return r.GetRoleModelBy("vision", id)
}
func (r *Registry) GetAudioModelBy(id Identity) (connector.Connector, error) {
return r.GetRoleModelBy("audio", id)
}
func (r *Registry) GetEmbeddingModelBy(id Identity) (connector.Connector, error) {
return r.GetRoleModelBy("embedding", id)
}
func (r *Registry) GetRoleCapabilitiesBy(role string, id Identity) (*goullm.Capabilities, error) {
conn, err := r.GetRoleModelBy(role, id)
if err != nil {
return nil, err
}
return capabilitiesFromConn(conn), nil
}
// ---------------------------------------------------------------------------
// internal
// ---------------------------------------------------------------------------
// listModels returns enabled models. When owner is non-nil, returns builtin
// providers plus dynamic providers belonging to that owner.
// Builtin providers are one-connector-per-model; dynamic providers are expanded
// to per-model options here.
func (r *Registry) listModels(owner *ProviderOwner) []connector.Option {
enabled := true
providers, err := r.List(&ProviderFilter{
Source: ProviderSourceAll,
Enabled: &enabled,
}, true)
if err != nil {
return nil
}
var result []connector.Option
for _, p := range providers {
if owner != nil && p.Source == ProviderSourceDynamic {
if !ownerMatch(&p.Owner, owner) {
continue
}
}
if p.Source == ProviderSourceDynamic && len(p.Models) > 0 {
for _, m := range p.Models {
if !m.Enabled {
continue
}
_ = ensureModelConnector(&p, &m)
cid := p.ConnectorID + ":" + m.ID
label := p.Name + " / " + m.Name
if m.Name == "" {
label = p.Name + " / " + m.ID
}
result = append(result, connector.Option{
Label: label,
Value: cid,
})
}
} else {
result = append(result, connector.Option{
Label: p.Name,
Value: p.ConnectorID,
})
}
}
return result
}
// ownerMatch returns true if the provider owner matches the requested scope.
func ownerMatch(po, want *ProviderOwner) bool {
if want.Type == "team" {
return po.Type == "team" && po.TeamID == want.TeamID
}
return po.Type == "user" && po.UserID == want.UserID
}
// capabilitiesFromConn extracts *llm.Capabilities from a connector.
// Prefers LLMConnector.GetCapabilities() when available.
func capabilitiesFromConn(conn connector.Connector) *goullm.Capabilities {
if conn == nil {
return defaultCaps()
}
if lc, ok := conn.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil {
return caps
}
}
settings := conn.Setting()
if settings != nil {
if caps, ok := settings["capabilities"]; ok {
if c, ok := caps.(*goullm.Capabilities); ok {
return c
}
if c, ok := caps.(goullm.Capabilities); ok {
return &c
}
}
}
return defaultCaps()
}
// capsToMap converts Capabilities to map[string]interface{} for process handlers.
// Delegates to the canonical Capabilities.ToMap() method in gou/llm.
func capsToMap(caps *goullm.Capabilities) map[string]interface{} {
return caps.ToMap()
}
func defaultCaps() *goullm.Capabilities {
return &goullm.Capabilities{
Vision: false,
ToolCalls: false,
Audio: false,
Reasoning: false,
Streaming: false,
JSON: false,
Multimodal: false,
TemperatureAdjustable: true,
}
}

239
llmprovider/models_test.go Normal file
View file

@ -0,0 +1,239 @@
package llmprovider_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/setting"
)
func TestGetModel(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "model-get")
conn, err := r.GetModel(p.ConnectorID)
require.NoError(t, err)
assert.NotNil(t, conn)
s := conn.Setting()
host, _ := s["host"].(string)
assert.Equal(t, "https://api.openai.com", host)
}
func TestGetModelNotFound(t *testing.T) {
r := setupRegistryWithSetting(t)
_, err := r.GetModel("nonexistent-connector")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestGetModelByProviderKey(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "model-key")
conn, err := r.GetModel(p.Key)
require.NoError(t, err)
assert.NotNil(t, conn)
}
func TestGetRoleModel(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "rolemodel-prov")
err := r.SetDefaults(map[string]string{"default": p.Key})
require.NoError(t, err)
conn, err := r.GetRoleModel("default")
require.NoError(t, err)
assert.NotNil(t, conn)
s := conn.Setting()
host, _ := s["host"].(string)
assert.Equal(t, "https://api.openai.com", host)
}
func TestGetDefaultModel(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "default-model")
err := r.SetDefaults(map[string]string{"default": p.Key})
require.NoError(t, err)
conn, err := r.GetDefaultModel()
require.NoError(t, err)
assert.NotNil(t, conn)
}
func TestGetDefaultModelByUser(t *testing.T) {
r := setupRegistryWithSetting(t)
sysP := createTestProviderForRole(t, r, "dm-sys")
userP := createTestProviderForRole(t, r, "dm-user")
err := r.SetDefaults(map[string]string{"default": sysP.Key})
require.NoError(t, err)
_, err = setting.Global.Set(
setting.ScopeID{Scope: setting.ScopeUser, UserID: "dm-u1"},
"llm.roles",
map[string]interface{}{
"default": map[string]interface{}{
"provider": userP.Key,
"model": "gpt-4o",
},
},
)
require.NoError(t, err)
conn, err := r.GetDefaultModelByUser("dm-u1")
require.NoError(t, err)
assert.NotNil(t, conn)
s := conn.Setting()
model, _ := s["model"].(string)
assert.Equal(t, "gpt-4o", model)
}
func TestGetCapabilities(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "caps-prov")
caps, err := r.GetCapabilities(p.ConnectorID)
require.NoError(t, err)
assert.NotNil(t, caps)
}
func TestGetRoleCapabilities(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "rolecaps-prov")
err := r.SetDefaults(map[string]string{"default": p.Key})
require.NoError(t, err)
caps, err := r.GetRoleCapabilities("default")
require.NoError(t, err)
assert.NotNil(t, caps)
}
func TestListModels(t *testing.T) {
r := setupRegistryWithSetting(t)
_ = createTestProviderForRole(t, r, "listm-prov")
opts := r.ListModels()
assert.NotEmpty(t, opts, "should have at least the created provider")
found := false
for _, o := range opts {
if o.Label == "Test listm-prov / GPT-4o" {
found = true
break
}
}
assert.True(t, found, "should contain the test provider's model")
}
func TestListModelsByUser(t *testing.T) {
r := setupRegistryWithSetting(t)
_ = createTestProviderForRole(t, r, "listmu-prov")
opts := r.ListModelsByUser("some-user")
assert.NotEmpty(t, opts)
}
func TestListModelsReturnsConnectorOption(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "opt-prov")
opts := r.ListModels()
modelCID := p.ConnectorID + ":gpt-4o"
found := false
for _, o := range opts {
if o.Value == modelCID {
found = true
assert.Equal(t, "Test opt-prov / GPT-4o", o.Label)
}
}
assert.True(t, found, "should contain model-level option with colon-separated CID")
}
func TestListModelsIncludesBuiltin(t *testing.T) {
r := setupRegistryWithSetting(t)
opts := r.ListModels()
builtinCount := 0
for _, o := range opts {
for _, ai := range connector.AIConnectors {
if o.Value == ai.Value {
builtinCount++
break
}
}
}
t.Logf("ListModels returned %d options, %d matching builtin AIConnectors (total: %d)",
len(opts), builtinCount, len(connector.AIConnectors))
}
func TestProcessGetModel(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "proc-model")
proc := process.New("llmprovider.getmodel", p.ConnectorID)
result, err := proc.Exec()
require.NoError(t, err)
m, ok := result.(map[string]interface{})
require.True(t, ok)
host, _ := m["host"].(string)
assert.Equal(t, "https://api.openai.com", host)
}
func TestProcessListModels(t *testing.T) {
r := setupRegistryWithSetting(t)
_ = createTestProviderForRole(t, r, "proc-listm")
proc := process.New("llmprovider.listmodels")
result, err := proc.Exec()
require.NoError(t, err)
list, ok := result.([]interface{})
require.True(t, ok)
assert.NotEmpty(t, list)
item := list[0].(map[string]interface{})
assert.Contains(t, item, "label")
assert.Contains(t, item, "value")
}
func TestProcessGetCapabilities(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "proc-caps")
proc := process.New("llmprovider.getcapabilities", p.ConnectorID)
result, err := proc.Exec()
require.NoError(t, err)
m, ok := result.(map[string]interface{})
require.True(t, ok)
assert.Contains(t, m, "streaming")
assert.Contains(t, m, "tool_calls")
}
func TestProcessGetRoleModel(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "proc-rm")
err := r.SetDefaults(map[string]string{"default": p.Key})
require.NoError(t, err)
proc := process.New("llmprovider.getrolemodel", "default")
result, err := proc.Exec()
require.NoError(t, err)
m, ok := result.(map[string]interface{})
require.True(t, ok)
host, _ := m["host"].(string)
assert.Equal(t, "https://api.openai.com", host)
}

View file

@ -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

View file

@ -3,12 +3,14 @@ package llmprovider
import ( import (
"encoding/json" "encoding/json"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/process" "github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/exception" "github.com/yaoapp/kun/exception"
) )
func init() { func init() {
process.RegisterGroup("llmprovider", map[string]process.Handler{ process.RegisterGroup("llmprovider", map[string]process.Handler{
// --- existing ---
"get": ProcessGet, "get": ProcessGet,
"getmasked": ProcessGetMasked, "getmasked": ProcessGetMasked,
"create": ProcessCreate, "create": ProcessCreate,
@ -18,6 +20,43 @@ func init() {
"getsetting": ProcessGetSetting, "getsetting": ProcessGetSetting,
"getpresets": ProcessGetPresets, "getpresets": ProcessGetPresets,
"getpreset": ProcessGetPreset, "getpreset": ProcessGetPreset,
// --- roles ---
"getrole": ProcessGetRole,
"getrolebyuser": ProcessGetRoleByUser,
"getrolebyteam": ProcessGetRoleByTeam,
"listroles": ProcessListRoles,
"listrolesbyuser": ProcessListRolesByUser,
"listrolesbyteam": ProcessListRolesByTeam,
// --- models ---
"getmodel": ProcessGetModel,
"getrolemodel": ProcessGetRoleModel,
"getrolemodelbyuser": ProcessGetRoleModelByUser,
"getrolemodelbyteam": ProcessGetRoleModelByTeam,
"getdefaultmodel": ProcessGetDefaultModel,
"getdefaultmodelbyuser": ProcessGetDefaultModelByUser,
"getdefaultmodelbyteam": ProcessGetDefaultModelByTeam,
"getvisionmodel": ProcessGetVisionModel,
"getvisionmodelbyuser": ProcessGetVisionModelByUser,
"getvisionmodelbyteam": ProcessGetVisionModelByTeam,
"getaudiomodel": ProcessGetAudioModel,
"getaudiomodelbyuser": ProcessGetAudioModelByUser,
"getaudiomodelbyteam": ProcessGetAudioModelByTeam,
"getembeddingmodel": ProcessGetEmbeddingModel,
"getembeddingmodelbyuser": ProcessGetEmbeddingModelByUser,
"getembeddingmodelbyteam": ProcessGetEmbeddingModelByTeam,
// --- capabilities ---
"getcapabilities": ProcessGetCapabilities,
"getrolecapabilities": ProcessGetRoleCapabilities,
"getrolecapabilitiesbyuser": ProcessGetRoleCapabilitiesByUser,
"getrolecapabilitiesbyteam": ProcessGetRoleCapabilitiesByTeam,
// --- list models ---
"listmodels": ProcessListModels,
"listmodelsbyuser": ProcessListModelsByUser,
"listmodelsbyteam": ProcessListModelsByTeam,
}) })
} }
@ -29,12 +68,14 @@ func requireGlobal() {
// ProcessGet retrieves a provider by key. // ProcessGet retrieves a provider by key.
// Args[0] string: provider key // Args[0] string: provider key
// Args[1] bool: withKey (optional, default false) — true returns plain-text APIKey
func ProcessGet(p *process.Process) interface{} { func ProcessGet(p *process.Process) interface{} {
requireGlobal() requireGlobal()
p.ValidateArgNums(1) p.ValidateArgNums(1)
key := p.ArgsString(0) key := p.ArgsString(0)
provider, err := Global.Get(key) withKey := len(p.Args) > 1 && toBool(p.Args[1])
provider, err := Global.Get(key, withKey)
if err != nil { if err != nil {
exception.New(err.Error(), 404).Throw() exception.New(err.Error(), 404).Throw()
} }
@ -115,7 +156,8 @@ func ProcessDelete(p *process.Process) interface{} {
} }
// ProcessList returns providers matching a filter. // ProcessList returns providers matching a filter.
// Args[0] map: ProviderFilter (optional) // Args[0] map: ProviderFilter (optional)
// Args[1] bool: withKey (optional, default false) — true returns plain-text APIKeys
func ProcessList(p *process.Process) interface{} { func ProcessList(p *process.Process) interface{} {
requireGlobal() requireGlobal()
@ -130,7 +172,8 @@ func ProcessList(p *process.Process) interface{} {
} }
} }
result, err := Global.List(filter) withKey := len(p.Args) > 1 && toBool(p.Args[1])
result, err := Global.List(filter, withKey)
if err != nil { if err != nil {
exception.New(err.Error(), 500).Throw() exception.New(err.Error(), 500).Throw()
} }
@ -168,3 +211,384 @@ func ProcessGetPreset(p *process.Process) interface{} {
} }
return preset return preset
} }
// ---------------------------------------------------------------------------
// Roles
// ---------------------------------------------------------------------------
// ProcessGetRole returns the connectorID for a role (system scope).
// Args[0] string: role name
func ProcessGetRole(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
cid, err := Global.GetRole(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return cid
}
// ProcessGetRoleByUser returns the connectorID for a role (user > system merge).
// Args[0] string: role, Args[1] string: userID
func ProcessGetRoleByUser(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(2)
cid, err := Global.GetRoleByUser(p.ArgsString(0), p.ArgsString(1))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return cid
}
// ProcessGetRoleByTeam returns the connectorID for a role (team > system merge).
// Args[0] string: role, Args[1] string: teamID
func ProcessGetRoleByTeam(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(2)
cid, err := Global.GetRoleByTeam(p.ArgsString(0), p.ArgsString(1))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return cid
}
// ProcessListRoles returns all role assignments (system scope).
func ProcessListRoles(p *process.Process) interface{} {
requireGlobal()
roles, err := Global.ListRoles()
if err != nil {
exception.New(err.Error(), 500).Throw()
}
return rolesToMap(roles)
}
// ProcessListRolesByUser returns all role assignments (user > system merge).
// Args[0] string: userID
func ProcessListRolesByUser(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
roles, err := Global.ListRolesByUser(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 500).Throw()
}
return rolesToMap(roles)
}
// ProcessListRolesByTeam returns all role assignments (team > system merge).
// Args[0] string: teamID
func ProcessListRolesByTeam(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
roles, err := Global.ListRolesByTeam(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 500).Throw()
}
return rolesToMap(roles)
}
// ---------------------------------------------------------------------------
// Models
// ---------------------------------------------------------------------------
// ProcessGetModel returns the connector setting map by connectorID.
// Args[0] string: connectorID
func ProcessGetModel(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
conn, err := Global.GetModel(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetRoleModel returns the connector setting map for a role (system scope).
// Args[0] string: role
func ProcessGetRoleModel(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
conn, err := Global.GetRoleModel(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetRoleModelByUser returns the connector setting map for a role (user scope).
// Args[0] string: role, Args[1] string: userID
func ProcessGetRoleModelByUser(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(2)
conn, err := Global.GetRoleModelByUser(p.ArgsString(0), p.ArgsString(1))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetRoleModelByTeam returns the connector setting map for a role (team scope).
// Args[0] string: role, Args[1] string: teamID
func ProcessGetRoleModelByTeam(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(2)
conn, err := Global.GetRoleModelByTeam(p.ArgsString(0), p.ArgsString(1))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetDefaultModel returns the default model connector setting map.
func ProcessGetDefaultModel(p *process.Process) interface{} {
requireGlobal()
conn, err := Global.GetDefaultModel()
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetDefaultModelByUser returns the default model for a user.
// Args[0] string: userID
func ProcessGetDefaultModelByUser(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
conn, err := Global.GetDefaultModelByUser(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetDefaultModelByTeam returns the default model for a team.
// Args[0] string: teamID
func ProcessGetDefaultModelByTeam(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
conn, err := Global.GetDefaultModelByTeam(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetVisionModel returns the vision model connector setting map.
func ProcessGetVisionModel(p *process.Process) interface{} {
requireGlobal()
conn, err := Global.GetVisionModel()
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetVisionModelByUser returns the vision model for a user.
// Args[0] string: userID
func ProcessGetVisionModelByUser(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
conn, err := Global.GetVisionModelByUser(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetVisionModelByTeam returns the vision model for a team.
// Args[0] string: teamID
func ProcessGetVisionModelByTeam(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
conn, err := Global.GetVisionModelByTeam(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetAudioModel returns the audio model connector setting map.
func ProcessGetAudioModel(p *process.Process) interface{} {
requireGlobal()
conn, err := Global.GetAudioModel()
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetAudioModelByUser returns the audio model for a user.
// Args[0] string: userID
func ProcessGetAudioModelByUser(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
conn, err := Global.GetAudioModelByUser(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetAudioModelByTeam returns the audio model for a team.
// Args[0] string: teamID
func ProcessGetAudioModelByTeam(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
conn, err := Global.GetAudioModelByTeam(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetEmbeddingModel returns the embedding model connector setting map.
func ProcessGetEmbeddingModel(p *process.Process) interface{} {
requireGlobal()
conn, err := Global.GetEmbeddingModel()
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetEmbeddingModelByUser returns the embedding model for a user.
// Args[0] string: userID
func ProcessGetEmbeddingModelByUser(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
conn, err := Global.GetEmbeddingModelByUser(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ProcessGetEmbeddingModelByTeam returns the embedding model for a team.
// Args[0] string: teamID
func ProcessGetEmbeddingModelByTeam(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
conn, err := Global.GetEmbeddingModelByTeam(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return conn.Setting()
}
// ---------------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------------
// ProcessGetCapabilities returns capabilities for a connectorID.
// Args[0] string: connectorID
func ProcessGetCapabilities(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
caps, err := Global.GetCapabilities(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return capsToMap(caps)
}
// ProcessGetRoleCapabilities returns capabilities for a role (system scope).
// Args[0] string: role
func ProcessGetRoleCapabilities(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
caps, err := Global.GetRoleCapabilities(p.ArgsString(0))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return capsToMap(caps)
}
// ProcessGetRoleCapabilitiesByUser returns capabilities for a role (user scope).
// Args[0] string: role, Args[1] string: userID
func ProcessGetRoleCapabilitiesByUser(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(2)
caps, err := Global.GetRoleCapabilitiesByUser(p.ArgsString(0), p.ArgsString(1))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return capsToMap(caps)
}
// ProcessGetRoleCapabilitiesByTeam returns capabilities for a role (team scope).
// Args[0] string: role, Args[1] string: teamID
func ProcessGetRoleCapabilitiesByTeam(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(2)
caps, err := Global.GetRoleCapabilitiesByTeam(p.ArgsString(0), p.ArgsString(1))
if err != nil {
exception.New(err.Error(), 404).Throw()
}
return capsToMap(caps)
}
// ---------------------------------------------------------------------------
// List Models
// ---------------------------------------------------------------------------
// ProcessListModels returns all enabled models as []Option (system scope).
func ProcessListModels(p *process.Process) interface{} {
requireGlobal()
return optionsToSlice(Global.ListModels())
}
// ProcessListModelsByUser returns models visible to a user.
// Args[0] string: userID
func ProcessListModelsByUser(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
return optionsToSlice(Global.ListModelsByUser(p.ArgsString(0)))
}
// ProcessListModelsByTeam returns models visible to a team.
// Args[0] string: teamID
func ProcessListModelsByTeam(p *process.Process) interface{} {
requireGlobal()
p.ValidateArgNums(1)
return optionsToSlice(Global.ListModelsByTeam(p.ArgsString(0)))
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func rolesToMap(roles map[string]RoleTarget) map[string]interface{} {
result := make(map[string]interface{}, len(roles))
for k, v := range roles {
result[k] = map[string]interface{}{
"provider": v.Provider,
"model": v.Model,
}
}
return result
}
func optionsToSlice(opts []connector.Option) []interface{} {
result := make([]interface{}, len(opts))
for i, o := range opts {
result[i] = map[string]interface{}{
"label": o.Label,
"value": o.Value,
}
}
return result
}
func toBool(v interface{}) bool {
switch b := v.(type) {
case bool:
return b
case float64:
return b != 0
case int:
return b != 0
case string:
return b == "true" || b == "1"
default:
return false
}
}

View file

@ -37,13 +37,22 @@ func TestProcessGet(t *testing.T) {
setupRegistry(t) setupRegistry(t)
createViaProcess(t, "proc-get") createViaProcess(t, "proc-get")
// Default: masked
p := process.New("llmprovider.get", "proc-get") p := process.New("llmprovider.get", "proc-get")
result, err := p.Exec() result, err := p.Exec()
require.NoError(t, err) require.NoError(t, err)
m := toMapResult(t, result) m := toMapResult(t, result)
assert.Equal(t, "proc-get", m["key"]) assert.Equal(t, "proc-get", m["key"])
assert.Equal(t, "sk-proc-test", m["api_key"]) assert.NotEqual(t, "sk-proc-test", m["api_key"], "default should be masked")
// withKey=true: plain text
p2 := process.New("llmprovider.get", "proc-get", true)
result2, err := p2.Exec()
require.NoError(t, err)
m2 := toMapResult(t, result2)
assert.Equal(t, "sk-proc-test", m2["api_key"], "withKey=true should return plain text")
} }
func TestProcessGetMasked(t *testing.T) { func TestProcessGetMasked(t *testing.T) {

View file

@ -47,8 +47,15 @@ func (r *Registry) SetEncryptionKey(key string) {
r.encKey = key r.encKey = key
} }
// shouldExposeKey returns true when the caller explicitly requests plain-text APIKey.
func shouldExposeKey(withKey []bool) bool {
return len(withKey) > 0 && withKey[0]
}
// Get retrieves a provider by key. Lazily ensures its connector is registered. // Get retrieves a provider by key. Lazily ensures its connector is registered.
func (r *Registry) Get(key string) (*Provider, error) { // By default the APIKey is masked; pass withKey=true to get the plain-text key
// (only for internal LLM-request paths).
func (r *Registry) Get(key string, withKey ...bool) (*Provider, error) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
@ -58,18 +65,48 @@ func (r *Registry) Get(key string) (*Provider, error) {
} }
_ = ensureConnector(p) _ = ensureConnector(p)
if !shouldExposeKey(withKey) {
cp := *p
cp.APIKey = maskAPIKey(cp.APIKey)
return &cp, nil
}
return p, nil return p, nil
} }
// GetMasked retrieves a provider with the API key masked for display. // GetByConnectorID finds a provider by its ConnectorID field (linear scan).
func (r *Registry) GetMasked(key string) (*Provider, error) { // Use when the caller has a ConnectorID but not the store Key.
p, err := r.Get(key) // By default the APIKey is masked; pass withKey=true to get the plain-text key.
func (r *Registry) GetByConnectorID(cid string, withKey ...bool) (*Provider, error) {
r.mu.RLock()
defer r.mu.RUnlock()
keys, err := indexGet(r.store, r.cache)
if err != nil { if err != nil {
return nil, err return nil, err
} }
cp := *p
cp.APIKey = maskAPIKey(cp.APIKey) for _, key := range keys {
return &cp, nil p, err := storeGet(r.store, r.cache, key, r.encKey)
if err != nil {
continue
}
if p.ConnectorID == cid {
_ = ensureConnector(p)
if !shouldExposeKey(withKey) {
cp := *p
cp.APIKey = maskAPIKey(cp.APIKey)
return &cp, nil
}
return p, nil
}
}
return nil, fmt.Errorf("provider with connector_id %q not found", cid)
}
// Deprecated: GetMasked is equivalent to Get(key) since Get now masks by default.
func (r *Registry) GetMasked(key string) (*Provider, error) {
return r.Get(key)
} }
// Create adds a new provider. Persists, caches, registers connector, and updates index. // Create adds a new provider. Persists, caches, registers connector, and updates index.
@ -161,7 +198,8 @@ func (r *Registry) Delete(key string) error {
} }
// List returns providers matching the filter. // List returns providers matching the filter.
func (r *Registry) List(filter *ProviderFilter) ([]Provider, error) { // By default the APIKey is masked; pass withKey=true to get plain-text keys.
func (r *Registry) List(filter *ProviderFilter, withKey ...bool) ([]Provider, error) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
@ -170,6 +208,7 @@ func (r *Registry) List(filter *ProviderFilter) ([]Provider, error) {
return nil, err return nil, err
} }
expose := shouldExposeKey(withKey)
var result []Provider var result []Provider
for _, key := range keys { for _, key := range keys {
p, err := storeGet(r.store, r.cache, key, r.encKey) p, err := storeGet(r.store, r.cache, key, r.encKey)
@ -180,7 +219,9 @@ func (r *Registry) List(filter *ProviderFilter) ([]Provider, error) {
continue continue
} }
cp := *p cp := *p
cp.APIKey = maskAPIKey(cp.APIKey) if !expose {
cp.APIKey = maskAPIKey(cp.APIKey)
}
result = append(result, cp) result = append(result, cp)
} }
return result, nil return result, nil
@ -217,7 +258,7 @@ func (r *Registry) Reload() error {
// GetConnector returns the runtime connector for a given provider key. // GetConnector returns the runtime connector for a given provider key.
func (r *Registry) GetConnector(key string) (connector.Connector, error) { func (r *Registry) GetConnector(key string) (connector.Connector, error) {
p, err := r.Get(key) p, err := r.Get(key, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -125,6 +125,144 @@ func TestGetMasked(t *testing.T) {
assert.Contains(t, got.APIKey, "xxxx") assert.Contains(t, got.APIKey, "xxxx")
} }
// ---------------------------------------------------------------------------
// withKey behavior
// ---------------------------------------------------------------------------
func TestGet_DefaultMasked(t *testing.T) {
r := setupRegistry(t)
p := testProvider
_, err := r.Create(&p)
require.NoError(t, err)
got, err := r.Get("test-openai")
require.NoError(t, err)
assert.NotEqual(t, "sk-test-xxxxx", got.APIKey, "Get() default should mask APIKey")
assert.Contains(t, got.APIKey, "*")
}
func TestGet_WithKeyTrue(t *testing.T) {
r := setupRegistry(t)
p := testProvider
_, err := r.Create(&p)
require.NoError(t, err)
got, err := r.Get("test-openai", true)
require.NoError(t, err)
assert.Equal(t, "sk-test-xxxxx", got.APIKey, "Get(key, true) should return plain text APIKey")
}
func TestGetByConnectorID_DefaultMasked(t *testing.T) {
r := setupRegistry(t)
p := testProvider
created, err := r.Create(&p)
require.NoError(t, err)
got, err := r.GetByConnectorID(created.ConnectorID)
require.NoError(t, err)
assert.NotEqual(t, "sk-test-xxxxx", got.APIKey, "GetByConnectorID() default should mask")
assert.Contains(t, got.APIKey, "*")
}
func TestGetByConnectorID_WithKeyTrue(t *testing.T) {
r := setupRegistry(t)
p := testProvider
created, err := r.Create(&p)
require.NoError(t, err)
got, err := r.GetByConnectorID(created.ConnectorID, true)
require.NoError(t, err)
assert.Equal(t, "sk-test-xxxxx", got.APIKey, "GetByConnectorID(cid, true) should return plain text")
}
func TestList_DefaultMasked(t *testing.T) {
r := setupRegistry(t)
p := testProvider
_, err := r.Create(&p)
require.NoError(t, err)
list, err := r.List(&llmprovider.ProviderFilter{Source: llmprovider.ProviderSourceDynamic})
require.NoError(t, err)
require.True(t, len(list) > 0)
for _, item := range list {
assert.NotEqual(t, "sk-test-xxxxx", item.APIKey, "List() default should mask all APIKeys")
}
}
func TestList_WithKeyTrue(t *testing.T) {
r := setupRegistry(t)
p := testProvider
_, err := r.Create(&p)
require.NoError(t, err)
list, err := r.List(&llmprovider.ProviderFilter{Source: llmprovider.ProviderSourceDynamic}, true)
require.NoError(t, err)
found := false
for _, item := range list {
if item.Key == "test-openai" {
found = true
assert.Equal(t, "sk-test-xxxxx", item.APIKey, "List(filter, true) should return plain text")
}
}
assert.True(t, found)
}
func TestGetMasked_EqualsGetDefault(t *testing.T) {
r := setupRegistry(t)
p := testProvider
_, err := r.Create(&p)
require.NoError(t, err)
fromGet, err := r.Get("test-openai")
require.NoError(t, err)
fromGetMasked, err := r.GetMasked("test-openai")
require.NoError(t, err)
assert.Equal(t, fromGet.APIKey, fromGetMasked.APIKey, "GetMasked should equal Get (both masked by default)")
}
func TestListModels_ConnectorHasRealKey(t *testing.T) {
r := setupRegistry(t)
owner := llmprovider.ProviderOwner{Type: "user", UserID: "rk-user"}
p := llmprovider.Provider{
Key: llmprovider.ScopedKey(&owner, "realkey-prov"),
Name: "RealKey Test",
Type: "openai",
APIURL: "https://api.openai.com",
APIKey: "sk-real-secret-key-12345",
Models: []llmprovider.ModelInfo{
{ID: "gpt-4o", Name: "GPT-4o", Capabilities: []string{"streaming"}, Enabled: true},
},
Enabled: true,
Owner: owner,
}
_, err := r.Create(&p)
require.NoError(t, err)
opts := r.ListModelsByUser("rk-user")
require.True(t, len(opts) > 0, "should have at least one model option")
var modelCID string
for _, o := range opts {
if o.Label == "RealKey Test / GPT-4o" {
modelCID = o.Value
break
}
}
require.NotEmpty(t, modelCID, "should find the model option")
conn, err := connector.Select(modelCID)
require.NoError(t, err, "model connector should be registered")
s := conn.Setting()
key, _ := s["key"].(string)
assert.Equal(t, "sk-real-secret-key-12345", key, "connector should have the real API key, not masked")
}
func TestGetLazy(t *testing.T) { func TestGetLazy(t *testing.T) {
r := setupRegistry(t) r := setupRegistry(t)
@ -153,13 +291,14 @@ func TestGetLazy(t *testing.T) {
func TestList(t *testing.T) { func TestList(t *testing.T) {
r := setupRegistry(t) r := setupRegistry(t)
p2Owner := llmprovider.ProviderOwner{Type: "user", UserID: "123"}
providers := []llmprovider.Provider{ providers := []llmprovider.Provider{
{Key: "p1", Name: "Provider 1", Type: "openai", Enabled: true, {Key: "p1", Name: "Provider 1", Type: "openai", Enabled: true,
Models: []llmprovider.ModelInfo{{ID: "gpt-4o", Name: "GPT-4o", Capabilities: []string{"vision", "tool_calls"}, Enabled: true}}, Models: []llmprovider.ModelInfo{{ID: "gpt-4o", Name: "GPT-4o", Capabilities: []string{"vision", "tool_calls"}, Enabled: true}},
Owner: llmprovider.ProviderOwner{Type: "system"}}, Owner: llmprovider.ProviderOwner{Type: "system"}},
{Key: "p2", Name: "Provider 2", Type: "anthropic", Enabled: false, {Key: llmprovider.ScopedKey(&p2Owner, "p2"), Name: "Provider 2", Type: "anthropic", Enabled: false,
Models: []llmprovider.ModelInfo{{ID: "claude-3", Name: "Claude 3", Capabilities: []string{"tool_calls"}, Enabled: true}}, Models: []llmprovider.ModelInfo{{ID: "claude-3", Name: "Claude 3", Capabilities: []string{"tool_calls"}, Enabled: true}},
Owner: llmprovider.ProviderOwner{Type: "user", UserID: "123"}}, Owner: p2Owner},
{Key: "p3", Name: "Provider 3", Type: "openai", Enabled: true, {Key: "p3", Name: "Provider 3", Type: "openai", Enabled: true,
Models: []llmprovider.ModelInfo{{ID: "gpt-4o-mini", Name: "GPT-4o Mini", Capabilities: []string{"streaming"}, Enabled: true}}, Models: []llmprovider.ModelInfo{{ID: "gpt-4o-mini", Name: "GPT-4o Mini", Capabilities: []string{"streaming"}, Enabled: true}},
Owner: llmprovider.ProviderOwner{Type: "system"}}, Owner: llmprovider.ProviderOwner{Type: "system"}},
@ -241,7 +380,7 @@ func TestList(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
found := false found := false
for _, p := range list { for _, p := range list {
if p.Key == "p2" { if p.Name == "Provider 2" {
found = true found = true
} }
} }
@ -369,13 +508,13 @@ func TestEncryptionRoundTrip(t *testing.T) {
_, err := r.Create(&p) _, err := r.Create(&p)
require.NoError(t, err) require.NoError(t, err)
got, err := r.Get("test-encrypted") got, err := r.Get("test-encrypted", true)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "sk-test-xxxxx", got.APIKey, "APIKey should be decrypted on read") assert.Equal(t, "sk-test-xxxxx", got.APIKey, "APIKey should be decrypted on read with withKey=true")
masked, err := r.GetMasked("test-encrypted") masked, err := r.Get("test-encrypted")
require.NoError(t, err) require.NoError(t, err)
assert.NotEqual(t, "sk-test-xxxxx", masked.APIKey) assert.NotEqual(t, "sk-test-xxxxx", masked.APIKey, "Get without withKey should mask")
assert.Contains(t, masked.APIKey, "xxxx") assert.Contains(t, masked.APIKey, "xxxx")
// Verify raw store value is encrypted // Verify raw store value is encrypted
@ -476,8 +615,9 @@ func TestOwnerPrefixedIDs(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.key, func(t *testing.T) { t.Run(tc.key, func(t *testing.T) {
scopedKey := llmprovider.ScopedKey(&tc.owner, tc.key)
p := llmprovider.Provider{ p := llmprovider.Provider{
Key: tc.key, Key: scopedKey,
Name: tc.key, Name: tc.key,
Type: "openai", Type: "openai",
APIURL: "https://api.openai.com", APIURL: "https://api.openai.com",

173
llmprovider/roles.go Normal file
View file

@ -0,0 +1,173 @@
package llmprovider
import (
"fmt"
"github.com/yaoapp/yao/setting"
)
// RolesNamespace is the setting namespace for LLM role assignments.
const RolesNamespace = "llm.roles"
// SetDefaults writes agent.yml system-level role defaults into setting.Global
// under ScopeSystem. roles maps role names (e.g. "default", "vision") to connectorIDs.
func (r *Registry) SetDefaults(roles map[string]string) error {
if setting.Global == nil {
return fmt.Errorf("setting registry not initialized")
}
data := make(map[string]interface{})
for role, cid := range roles {
p, err := r.Get(cid, true)
if err != nil {
// Builtin providers: Key == ConnectorID, use connectorID directly
data[role] = map[string]interface{}{
"provider": cid,
"model": "",
}
continue
}
data[role] = map[string]interface{}{
"provider": p.Key,
"model": defaultModel(p),
}
}
_, err := setting.Global.Set(
setting.ScopeID{Scope: setting.ScopeSystem},
RolesNamespace,
data,
)
return err
}
// GetRole returns the connectorID for a role at system scope.
func (r *Registry) GetRole(role string) (string, error) {
return r.resolveRole(role, "", "")
}
// GetRoleByUser returns the connectorID for a role, merged user > system.
func (r *Registry) GetRoleByUser(role, userID string) (string, error) {
return r.resolveRole(role, userID, "")
}
// GetRoleByTeam returns the connectorID for a role, merged team > system.
func (r *Registry) GetRoleByTeam(role, teamID string) (string, error) {
return r.resolveRole(role, "", teamID)
}
// GetRoleBy returns the connectorID for a role, scoped by identity (team > user).
func (r *Registry) GetRoleBy(role string, id Identity) (string, error) {
if id.GetTeamID() != "" {
return r.GetRoleByTeam(role, id.GetTeamID())
}
return r.GetRoleByUser(role, id.GetUserID())
}
// ListRoles returns all role assignments at system scope.
func (r *Registry) ListRoles() (map[string]RoleTarget, error) {
return r.listRoles("", "")
}
// ListRolesByUser returns all role assignments, merged user > system.
func (r *Registry) ListRolesByUser(userID string) (map[string]RoleTarget, error) {
return r.listRoles(userID, "")
}
// ListRolesByTeam returns all role assignments, merged team > system.
func (r *Registry) ListRolesByTeam(teamID string) (map[string]RoleTarget, error) {
return r.listRoles("", teamID)
}
// ListRolesBy returns all role assignments, scoped by identity (team > user).
func (r *Registry) ListRolesBy(id Identity) (map[string]RoleTarget, error) {
if id.GetTeamID() != "" {
return r.ListRolesByTeam(id.GetTeamID())
}
return r.ListRolesByUser(id.GetUserID())
}
// ---------------------------------------------------------------------------
// internal
// ---------------------------------------------------------------------------
func (r *Registry) resolveRole(role, userID, teamID string) (string, error) {
if setting.Global == nil {
return "", fmt.Errorf("setting registry not initialized")
}
merged, err := setting.Global.GetMerged(userID, teamID, RolesNamespace)
if err != nil {
return "", fmt.Errorf("role %q not configured: %w", role, err)
}
target, ok := merged[role]
if !ok {
return "", fmt.Errorf("role %q not configured", role)
}
cid := r.extractConnectorID(target)
if cid == "" {
return "", fmt.Errorf("role %q has invalid target", role)
}
return cid, nil
}
func (r *Registry) listRoles(userID, teamID string) (map[string]RoleTarget, error) {
if setting.Global == nil {
return nil, fmt.Errorf("setting registry not initialized")
}
merged, err := setting.Global.GetMerged(userID, teamID, RolesNamespace)
if err != nil {
return nil, fmt.Errorf("failed to load roles: %w", err)
}
result := make(map[string]RoleTarget)
for role, target := range merged {
rt := parseRoleTarget(target)
if rt.Provider != "" {
result[role] = rt
}
}
return result, nil
}
func (r *Registry) extractConnectorID(target interface{}) string {
rt := parseRoleTarget(target)
if rt.Provider == "" {
return ""
}
p, err := r.Get(rt.Provider, true)
if err != nil {
if rt.Model != "" {
return rt.Provider + ":" + rt.Model
}
return rt.Provider
}
// Builtin providers have model baked into the connector itself;
// appending :model would create a non-existent composite ID.
if p.Source == ProviderSourceBuiltIn {
return p.ConnectorID
}
if rt.Model != "" {
return p.ConnectorID + ":" + rt.Model
}
return p.ConnectorID
}
func parseRoleTarget(v interface{}) RoleTarget {
switch t := v.(type) {
case map[string]interface{}:
provider, _ := t["provider"].(string)
model, _ := t["model"].(string)
return RoleTarget{Provider: provider, Model: model}
case RoleTarget:
return t
default:
return RoleTarget{}
}
}

255
llmprovider/roles_test.go Normal file
View file

@ -0,0 +1,255 @@
package llmprovider_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/llmprovider"
"github.com/yaoapp/yao/setting"
)
func setupRegistryWithSetting(t *testing.T) *llmprovider.Registry {
t.Helper()
r := setupRegistry(t)
err := setting.Init()
require.NoError(t, err)
t.Cleanup(func() {
s, _ := store.Get("__yao.store")
if s != nil {
s.Del("setting:*")
}
c, _ := store.Get("__yao.cache")
if c != nil {
c.Del("setting:*")
}
})
return r
}
func createTestProviderForRole(t *testing.T, r *llmprovider.Registry, key string) *llmprovider.Provider {
t.Helper()
p := llmprovider.Provider{
Key: key,
Name: "Test " + key,
Type: "openai",
APIURL: "https://api.openai.com",
APIKey: "sk-test-role",
Enabled: true,
Models: []llmprovider.ModelInfo{
{ID: "gpt-4o", Name: "GPT-4o", Capabilities: []string{"vision", "tool_calls", "streaming"}, Enabled: true},
},
Owner: llmprovider.ProviderOwner{Type: "system"},
}
created, err := r.Create(&p)
require.NoError(t, err)
return created
}
func TestSetDefaults(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "sd-provider")
err := r.SetDefaults(map[string]string{
"default": p.Key,
})
require.NoError(t, err)
merged, err := setting.Global.GetMerged("", "", "llm.roles")
require.NoError(t, err)
def, ok := merged["default"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, p.Key, def["provider"])
assert.Equal(t, "gpt-4o", def["model"])
}
func TestGetRole(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "role-provider")
err := r.SetDefaults(map[string]string{"default": p.Key})
require.NoError(t, err)
cid, err := r.GetRole("default")
require.NoError(t, err)
assert.Equal(t, p.ConnectorID+":gpt-4o", cid)
}
func TestGetRoleByUser(t *testing.T) {
r := setupRegistryWithSetting(t)
sysP := createTestProviderForRole(t, r, "sys-prov")
err := r.SetDefaults(map[string]string{"default": sysP.Key})
require.NoError(t, err)
userP := createTestProviderForRole(t, r, "user-prov")
_, err = setting.Global.Set(
setting.ScopeID{Scope: setting.ScopeUser, UserID: "u1"},
"llm.roles",
map[string]interface{}{
"default": map[string]interface{}{
"provider": userP.Key,
"model": "gpt-4o",
},
},
)
require.NoError(t, err)
cid, err := r.GetRoleByUser("default", "u1")
require.NoError(t, err)
assert.Equal(t, userP.ConnectorID+":gpt-4o", cid, "user scope should override system")
cidSys, err := r.GetRole("default")
require.NoError(t, err)
assert.Equal(t, sysP.ConnectorID+":gpt-4o", cidSys, "system scope should still return system provider")
}
func TestGetRoleByTeam(t *testing.T) {
r := setupRegistryWithSetting(t)
sysP := createTestProviderForRole(t, r, "sys-team-prov")
err := r.SetDefaults(map[string]string{"default": sysP.Key})
require.NoError(t, err)
teamP := createTestProviderForRole(t, r, "team-prov")
_, err = setting.Global.Set(
setting.ScopeID{Scope: setting.ScopeTeam, TeamID: "t1"},
"llm.roles",
map[string]interface{}{
"default": map[string]interface{}{
"provider": teamP.Key,
"model": "gpt-4o",
},
},
)
require.NoError(t, err)
cid, err := r.GetRoleByTeam("default", "t1")
require.NoError(t, err)
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) {
_ = setupRegistryWithSetting(t)
_, err := llmprovider.Global.GetRole("nonexistent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not configured")
}
func TestListRoles(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "list-prov")
err := r.SetDefaults(map[string]string{
"default": p.Key,
"vision": p.Key,
})
require.NoError(t, err)
roles, err := r.ListRoles()
require.NoError(t, err)
assert.Contains(t, roles, "default")
assert.Contains(t, roles, "vision")
assert.Equal(t, p.Key, roles["default"].Provider)
}
func TestListRolesByUser(t *testing.T) {
r := setupRegistryWithSetting(t)
sysP := createTestProviderForRole(t, r, "list-sys")
userP := createTestProviderForRole(t, r, "list-user")
err := r.SetDefaults(map[string]string{"default": sysP.Key, "vision": sysP.Key})
require.NoError(t, err)
_, err = setting.Global.Set(
setting.ScopeID{Scope: setting.ScopeUser, UserID: "u2"},
"llm.roles",
map[string]interface{}{
"default": map[string]interface{}{
"provider": userP.Key,
"model": "gpt-4o",
},
},
)
require.NoError(t, err)
roles, err := r.ListRolesByUser("u2")
require.NoError(t, err)
assert.Equal(t, userP.Key, roles["default"].Provider, "user override for default")
assert.Equal(t, sysP.Key, roles["vision"].Provider, "system fallback for vision")
}
func TestProcessGetRole(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "proc-role")
err := r.SetDefaults(map[string]string{"default": p.Key})
require.NoError(t, err)
proc := process.New("llmprovider.getrole", "default")
result, err := proc.Exec()
require.NoError(t, err)
assert.Equal(t, p.ConnectorID+":gpt-4o", result)
}
func TestProcessListRoles(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "proc-list-role")
err := r.SetDefaults(map[string]string{"default": p.Key})
require.NoError(t, err)
proc := process.New("llmprovider.listroles")
result, err := proc.Exec()
require.NoError(t, err)
m, ok := result.(map[string]interface{})
require.True(t, ok)
def, ok := m["default"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, p.Key, def["provider"])
}

473
llmprovider/scope_test.go Normal file
View file

@ -0,0 +1,473 @@
package llmprovider_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/yao/llmprovider"
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/setting"
)
// ---------------------------------------------------------------------------
// ScopedKey
// ---------------------------------------------------------------------------
func TestScopedKeyFormats(t *testing.T) {
assert.Equal(t, "ualice.deepseek", llmprovider.ScopedKey(
&llmprovider.ProviderOwner{Type: "user", UserID: "alice"}, "deepseek"))
assert.Equal(t, "t9253.deepseek", llmprovider.ScopedKey(
&llmprovider.ProviderOwner{Type: "team", TeamID: "9253"}, "deepseek"))
assert.Equal(t, "deepseek", llmprovider.ScopedKey(
&llmprovider.ProviderOwner{Type: "system"}, "deepseek"))
assert.Equal(t, "deepseek", llmprovider.ScopedKey(
&llmprovider.ProviderOwner{}, "deepseek"))
}
func TestDifferentOwnerSameBaseKey(t *testing.T) {
r := setupRegistryWithSetting(t)
ownerA := llmprovider.ProviderOwner{Type: "team", TeamID: "teamA"}
ownerB := llmprovider.ProviderOwner{Type: "team", TeamID: "teamB"}
pA := createOwnedProvider(t, r, "deepseek", ownerA)
pB := createOwnedProvider(t, r, "deepseek", ownerB)
assert.Equal(t, "tteamA.deepseek", pA.Key)
assert.Equal(t, "tteamB.deepseek", pB.Key)
gotA, err := r.Get(pA.Key)
require.NoError(t, err)
assert.Equal(t, pA.Key, gotA.Key)
gotB, err := r.Get(pB.Key)
require.NoError(t, err)
assert.Equal(t, pB.Key, gotB.Key)
}
func TestSameOwnerDuplicateKey(t *testing.T) {
r := setupRegistryWithSetting(t)
owner := llmprovider.ProviderOwner{Type: "user", UserID: "u1"}
_ = createOwnedProvider(t, r, "openai", owner)
dup := llmprovider.Provider{
Key: llmprovider.ScopedKey(&owner, "openai"),
Name: "Dup",
Type: "openai",
APIURL: "https://api.openai.com",
APIKey: "sk-dup",
Enabled: true,
Models: []llmprovider.ModelInfo{{ID: "gpt-4o", Name: "GPT-4o", Enabled: true}},
Owner: owner,
}
_, err := r.Create(&dup)
assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
}
// ---------------------------------------------------------------------------
// Identity interface
// ---------------------------------------------------------------------------
func TestAuthorizedInfoSatisfiesIdentity(t *testing.T) {
info := &oauthTypes.AuthorizedInfo{UserID: "u1", TeamID: "t1"}
var id llmprovider.Identity = info
assert.Equal(t, "u1", id.GetUserID())
assert.Equal(t, "t1", id.GetTeamID())
}
func TestAuthorizedInfoNilSafe(t *testing.T) {
var info *oauthTypes.AuthorizedInfo
assert.Equal(t, "", info.GetUserID())
assert.Equal(t, "", info.GetTeamID())
}
// ---------------------------------------------------------------------------
// ListModels owner filtering
// ---------------------------------------------------------------------------
func TestListModelsByUserFiltersOwner(t *testing.T) {
r := setupRegistryWithSetting(t)
createOwnedProvider(t, r, "user-alice-prov", llmprovider.ProviderOwner{Type: "user", UserID: "alice"})
createOwnedProvider(t, r, "user-bob-prov", llmprovider.ProviderOwner{Type: "user", UserID: "bob"})
createOwnedProvider(t, r, "team-x-prov", llmprovider.ProviderOwner{Type: "team", TeamID: "x"})
opts := r.ListModelsByUser("alice")
labels := optLabels(opts)
assert.Contains(t, labels, "Test user-alice-prov / GPT-4o", "should include alice's model")
assert.NotContains(t, labels, "Test user-bob-prov / GPT-4o", "should exclude bob's model")
assert.NotContains(t, labels, "Test team-x-prov / GPT-4o", "should exclude team model")
}
func TestListModelsByTeamFiltersOwner(t *testing.T) {
r := setupRegistryWithSetting(t)
createOwnedProvider(t, r, "team-alpha-prov", llmprovider.ProviderOwner{Type: "team", TeamID: "alpha"})
createOwnedProvider(t, r, "team-beta-prov", llmprovider.ProviderOwner{Type: "team", TeamID: "beta"})
createOwnedProvider(t, r, "user-u1-prov", llmprovider.ProviderOwner{Type: "user", UserID: "u1"})
opts := r.ListModelsByTeam("alpha")
labels := optLabels(opts)
assert.Contains(t, labels, "Test team-alpha-prov / GPT-4o", "should include alpha's model")
assert.NotContains(t, labels, "Test team-beta-prov / GPT-4o", "should exclude beta's model")
assert.NotContains(t, labels, "Test user-u1-prov / GPT-4o", "should exclude user model")
}
func TestListModelsByIncludesBuiltin(t *testing.T) {
r := setupRegistryWithSetting(t)
createOwnedProvider(t, r, "user-x-prov", llmprovider.ProviderOwner{Type: "user", UserID: "x"})
all := r.ListModels()
byUser := r.ListModelsByUser("x")
builtinAll := countBuiltin(all)
builtinScoped := countBuiltin(byUser)
assert.Equal(t, builtinAll, builtinScoped, "ByUser should include all builtin providers")
}
func TestListModelsBy_TeamRouting(t *testing.T) {
r := setupRegistryWithSetting(t)
createOwnedProvider(t, r, "team-rt-prov", llmprovider.ProviderOwner{Type: "team", TeamID: "rt"})
createOwnedProvider(t, r, "user-rt-prov", llmprovider.ProviderOwner{Type: "user", UserID: "rt"})
info := &oauthTypes.AuthorizedInfo{UserID: "rt", TeamID: "rt"}
opts := r.ListModelsBy(info)
labels := optLabels(opts)
assert.Contains(t, labels, "Test team-rt-prov / GPT-4o", "team takes priority when TeamID is set")
assert.NotContains(t, labels, "Test user-rt-prov / GPT-4o", "user model should be excluded when TeamID is set")
}
func TestListModelsBy_UserFallback(t *testing.T) {
r := setupRegistryWithSetting(t)
createOwnedProvider(t, r, "user-fb-prov", llmprovider.ProviderOwner{Type: "user", UserID: "fb"})
info := &oauthTypes.AuthorizedInfo{UserID: "fb"}
opts := r.ListModelsBy(info)
labels := optLabels(opts)
assert.Contains(t, labels, "Test user-fb-prov / GPT-4o", "should include user model when no TeamID")
}
// ---------------------------------------------------------------------------
// ListModels per-model expansion
// ---------------------------------------------------------------------------
func TestListModelsExpandsMultipleModels(t *testing.T) {
r := setupRegistryWithSetting(t)
owner := llmprovider.ProviderOwner{Type: "user", UserID: "multi-u"}
p := llmprovider.Provider{
Key: llmprovider.ScopedKey(&owner, "multi-model-prov"),
Name: "MultiModel",
Type: "openai",
APIURL: "https://api.openai.com",
APIKey: "sk-test",
Models: []llmprovider.ModelInfo{
{ID: "gpt-4o", Name: "GPT-4o", Enabled: true},
{ID: "gpt-4o-mini", Name: "GPT-4o Mini", Enabled: true},
{ID: "gpt-disabled", Name: "Disabled", Enabled: false},
},
Enabled: true,
Owner: owner,
}
_, err := r.Create(&p)
require.NoError(t, err)
opts := r.ListModelsByUser("multi-u")
labels := optLabels(opts)
values := optValues(opts)
assert.Contains(t, labels, "MultiModel / GPT-4o")
assert.Contains(t, labels, "MultiModel / GPT-4o Mini")
assert.NotContains(t, labels, "MultiModel / Disabled", "disabled model should not appear")
// Values should be "providerCID:modelID" format
for _, v := range values {
if strings.Contains(v, "multi-model-prov") {
assert.Contains(t, v, ":", "dynamic model option should use colon-separated format")
}
}
}
func TestGetModelWithModelLevelCID(t *testing.T) {
r := setupRegistryWithSetting(t)
owner := llmprovider.ProviderOwner{Type: "team", TeamID: "mlcid-t1"}
p := llmprovider.Provider{
Key: llmprovider.ScopedKey(&owner, "mlcid-prov"),
Name: "MLTest",
Type: "openai",
APIURL: "https://api.openai.com",
APIKey: "sk-test",
Models: []llmprovider.ModelInfo{
{ID: "gpt-4o", Name: "GPT-4o", Enabled: true},
{ID: "gpt-4o-mini", Name: "GPT-4o Mini", Enabled: true},
},
Enabled: true,
Owner: owner,
}
created, err := r.Create(&p)
require.NoError(t, err)
modelCID := created.ConnectorID + ":gpt-4o"
conn, err := r.GetModel(modelCID)
require.NoError(t, err)
assert.NotNil(t, conn)
s := conn.Setting()
model, _ := s["model"].(string)
assert.Equal(t, "gpt-4o", model, "model-level connector should have the correct model")
modelCID2 := created.ConnectorID + ":gpt-4o-mini"
conn2, err := r.GetModel(modelCID2)
require.NoError(t, err)
s2 := conn2.Setting()
model2, _ := s2["model"].(string)
assert.Equal(t, "gpt-4o-mini", model2, "second model should have its own connector")
}
// ---------------------------------------------------------------------------
// GetModel ConnectorID reverse lookup
// ---------------------------------------------------------------------------
func TestGetModelByConnectorIDReverseLookup(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createOwnedProvider(t, r, "rev-prov", llmprovider.ProviderOwner{Type: "user", UserID: "u99"})
cid := p.ConnectorID
assert.Equal(t, p.Key, cid, "dynamic provider ConnectorID should equal scoped Key")
_ = connector.Unregister(cid)
conn, err := r.GetModel(cid)
require.NoError(t, err, "GetModel should find provider via ConnectorID reverse lookup")
assert.NotNil(t, conn)
s := conn.Setting()
host, _ := s["host"].(string)
assert.Equal(t, "https://api.openai.com", host)
}
func TestGetByConnectorID(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createOwnedProvider(t, r, "bycid-prov", llmprovider.ProviderOwner{Type: "team", TeamID: "t55"})
found, err := r.GetByConnectorID(p.ConnectorID)
require.NoError(t, err)
assert.Equal(t, p.Key, found.Key)
}
func TestGetByConnectorIDNotFound(t *testing.T) {
r := setupRegistryWithSetting(t)
_, err := r.GetByConnectorID("nonexistent-cid")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
// ---------------------------------------------------------------------------
// GetRoleBy / ListRolesBy / GetRoleModelBy — Identity routing
// ---------------------------------------------------------------------------
func TestGetRoleBy_TeamPriority(t *testing.T) {
r := setupRegistryWithSetting(t)
sysP := createTestProviderForRole(t, r, "grb-sys")
teamP := createTestProviderForRole(t, r, "grb-team")
err := r.SetDefaults(map[string]string{"default": sysP.Key})
require.NoError(t, err)
_, err = setting.Global.Set(
setting.ScopeID{Scope: setting.ScopeTeam, TeamID: "grb-t1"},
"llm.roles",
map[string]interface{}{
"default": map[string]interface{}{
"provider": teamP.Key,
"model": "gpt-4o",
},
},
)
require.NoError(t, err)
info := &oauthTypes.AuthorizedInfo{UserID: "u1", TeamID: "grb-t1"}
cid, err := r.GetRoleBy("default", info)
require.NoError(t, err)
assert.Equal(t, teamP.ConnectorID+":gpt-4o", cid, "should resolve via team scope when TeamID is set")
}
func TestGetRoleBy_UserFallback(t *testing.T) {
r := setupRegistryWithSetting(t)
sysP := createTestProviderForRole(t, r, "grbu-sys")
userP := createTestProviderForRole(t, r, "grbu-user")
err := r.SetDefaults(map[string]string{"default": sysP.Key})
require.NoError(t, err)
_, err = setting.Global.Set(
setting.ScopeID{Scope: setting.ScopeUser, UserID: "grbu-u1"},
"llm.roles",
map[string]interface{}{
"default": map[string]interface{}{
"provider": userP.Key,
"model": "gpt-4o",
},
},
)
require.NoError(t, err)
info := &oauthTypes.AuthorizedInfo{UserID: "grbu-u1"}
cid, err := r.GetRoleBy("default", info)
require.NoError(t, err)
assert.Equal(t, userP.ConnectorID+":gpt-4o", cid, "should resolve via user scope when no TeamID")
}
func TestListRolesBy(t *testing.T) {
r := setupRegistryWithSetting(t)
sysP := createTestProviderForRole(t, r, "lrb-sys")
teamP := createTestProviderForRole(t, r, "lrb-team")
err := r.SetDefaults(map[string]string{"default": sysP.Key, "vision": sysP.Key})
require.NoError(t, err)
_, err = setting.Global.Set(
setting.ScopeID{Scope: setting.ScopeTeam, TeamID: "lrb-t1"},
"llm.roles",
map[string]interface{}{
"default": map[string]interface{}{
"provider": teamP.Key,
"model": "gpt-4o",
},
},
)
require.NoError(t, err)
info := &oauthTypes.AuthorizedInfo{TeamID: "lrb-t1"}
roles, err := r.ListRolesBy(info)
require.NoError(t, err)
assert.Equal(t, teamP.Key, roles["default"].Provider, "team override for default")
assert.Equal(t, sysP.Key, roles["vision"].Provider, "system fallback for vision")
}
func TestGetRoleModelBy(t *testing.T) {
r := setupRegistryWithSetting(t)
sysP := createTestProviderForRole(t, r, "grmb-sys")
userP := createTestProviderForRole(t, r, "grmb-user")
err := r.SetDefaults(map[string]string{"default": sysP.Key})
require.NoError(t, err)
_, err = setting.Global.Set(
setting.ScopeID{Scope: setting.ScopeUser, UserID: "grmb-u1"},
"llm.roles",
map[string]interface{}{
"default": map[string]interface{}{
"provider": userP.Key,
"model": "gpt-4o",
},
},
)
require.NoError(t, err)
info := &oauthTypes.AuthorizedInfo{UserID: "grmb-u1"}
conn, err := r.GetRoleModelBy("default", info)
require.NoError(t, err)
assert.NotNil(t, conn)
s := conn.Setting()
model, _ := s["model"].(string)
assert.Equal(t, "gpt-4o", model)
}
func TestGetDefaultModelBy(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "gdmb-prov")
err := r.SetDefaults(map[string]string{"default": p.Key})
require.NoError(t, err)
info := &oauthTypes.AuthorizedInfo{UserID: "gdmb-u1"}
conn, err := r.GetDefaultModelBy(info)
require.NoError(t, err)
assert.NotNil(t, conn)
}
func TestGetRoleCapabilitiesBy(t *testing.T) {
r := setupRegistryWithSetting(t)
p := createTestProviderForRole(t, r, "grcb-prov")
err := r.SetDefaults(map[string]string{"default": p.Key})
require.NoError(t, err)
info := &oauthTypes.AuthorizedInfo{UserID: "grcb-u1"}
caps, err := r.GetRoleCapabilitiesBy("default", info)
require.NoError(t, err)
assert.NotNil(t, caps)
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func createOwnedProvider(t *testing.T, r *llmprovider.Registry, baseKey string, owner llmprovider.ProviderOwner) *llmprovider.Provider {
t.Helper()
p := llmprovider.Provider{
Key: llmprovider.ScopedKey(&owner, baseKey),
Name: "Test " + baseKey,
Type: "openai",
APIURL: "https://api.openai.com",
APIKey: "sk-test-owned",
Enabled: true,
Models: []llmprovider.ModelInfo{
{ID: "gpt-4o", Name: "GPT-4o", Capabilities: []string{"vision", "tool_calls", "streaming"}, Enabled: true},
},
Owner: owner,
}
created, err := r.Create(&p)
require.NoError(t, err)
return created
}
func optLabels(opts []connector.Option) []string {
labels := make([]string, len(opts))
for i, o := range opts {
labels[i] = o.Label
}
return labels
}
func optValues(opts []connector.Option) []string {
values := make([]string, len(opts))
for i, o := range opts {
values[i] = o.Value
}
return values
}
func countBuiltin(opts []connector.Option) int {
n := 0
for _, o := range opts {
for _, ai := range connector.AIConnectors {
if o.Value == ai.Value {
n++
break
}
}
}
return n
}

View file

@ -5,16 +5,32 @@ import (
"fmt" "fmt"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
) )
// ScopedKey returns a provider key prefixed with the owner scope.
// This ensures unique keys per user/team in the store.
//
// user -> "u<userID>.<baseKey>"
// team -> "t<teamID>.<baseKey>"
// other -> baseKey (unchanged)
func ScopedKey(owner *ProviderOwner, baseKey string) string {
switch owner.Type {
case "user":
return "u" + owner.UserID + "." + baseKey
case "team":
return "t" + owner.TeamID + "." + baseKey
default:
return baseKey
}
}
// connectorID builds the runtime ID for registering into connector.Connectors. // connectorID builds the runtime ID for registering into connector.Connectors.
// Dynamic providers get an owner prefix to avoid collision with builtin IDs. // For user/team providers the Key is already scoped, so use it directly.
func connectorID(p *Provider) string { func connectorID(p *Provider) string {
switch p.Owner.Type { switch p.Owner.Type {
case "user": case "user", "team":
return "u" + p.Owner.UserID + "." + p.Key return p.Key
case "team":
return "t" + p.Owner.TeamID + "." + p.Key
default: default:
return "s." + p.Key return "s." + p.Key
} }
@ -35,19 +51,51 @@ func defaultModel(p *Provider) string {
// marshalDSL builds a connector DSL JSON from the flat Provider fields. // marshalDSL builds a connector DSL JSON from the flat Provider fields.
func marshalDSL(p *Provider) ([]byte, error) { func marshalDSL(p *Provider) ([]byte, error) {
dsl := map[string]interface{}{ opts := map[string]interface{}{
"type": p.Type, "host": p.APIURL,
"name": p.Name, "key": p.APIKey,
"label": p.Name, "model": defaultModel(p),
"options": map[string]interface{}{
"host": p.APIURL,
"key": p.APIKey,
"model": defaultModel(p),
},
} }
if caps := aggregateCapabilities(p); len(caps) > 0 {
opts["capabilities"] = caps
}
dsl := map[string]interface{}{
"type": p.Type,
"name": p.Name,
"label": p.Name,
"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)
} }
// aggregateCapabilities merges all model capabilities into a single map.
// Falls back to type-based defaults when no model declares explicit caps.
func aggregateCapabilities(p *Provider) map[string]bool {
caps := make(map[string]bool)
for _, m := range p.Models {
for _, c := range m.Capabilities {
caps[c] = true
}
}
if len(caps) == 0 {
switch p.Type {
case "openai", "anthropic":
caps["streaming"] = true
caps["tool_calls"] = true
caps["temperature_adjustable"] = true
}
}
return caps
}
// ensureConnector makes sure the provider's connector is registered in the runtime. // ensureConnector makes sure the provider's connector is registered in the runtime.
// Builtin providers are managed by engine.Load and skipped here. // Builtin providers are managed by engine.Load and skipped here.
func ensureConnector(p *Provider) error { func ensureConnector(p *Provider) error {
@ -80,6 +128,106 @@ func ensureConnector(p *Provider) error {
return nil return nil
} }
// ensureModelConnector registers a per-model connector for a dynamic provider.
// The connector ID format is "{providerConnectorID}:{modelID}".
func ensureModelConnector(p *Provider, m *ModelInfo) error {
if p.Source == ProviderSourceBuiltIn {
return nil
}
if !p.Enabled || !m.Enabled {
return nil
}
baseCID := p.ConnectorID
if baseCID == "" {
baseCID = connectorID(p)
}
cid := baseCID + ":" + m.ID
if _, err := connector.Select(cid); err == nil {
return nil
}
dslJSON, err := marshalModelDSL(p, m)
if err != nil {
return fmt.Errorf("ensureModelConnector %s:%s: %w", p.Key, m.ID, err)
}
_, err = connector.LoadSourceSync(dslJSON, cid, "__registry/"+baseCID+"/"+m.ID+".conn.yao")
if err != nil {
return fmt.Errorf("ensureModelConnector %s:%s: LoadSourceSync: %w", p.Key, m.ID, err)
}
return nil
}
// marshalModelDSL builds a connector DSL for a specific model within a provider.
func marshalModelDSL(p *Provider, m *ModelInfo) ([]byte, error) {
caps := make(map[string]interface{})
for _, c := range m.Capabilities {
caps[c] = true
}
if len(caps) == 0 {
switch p.Type {
case "openai", "anthropic":
caps["streaming"] = true
caps["tool_calls"] = 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
}
apiModel := m.ID
if m.Model != "" {
apiModel = m.Model
}
opts := map[string]interface{}{
"host": p.APIURL,
"key": p.APIKey,
"model": apiModel,
}
if len(caps) > 0 {
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
if name == "" {
name = m.ID
}
dsl := map[string]interface{}{
"type": connType,
"name": name,
"label": name,
"options": opts,
}
if p.PresetKey == "azure" {
dsl["auth_mode"] = "api-key"
}
return json.Marshal(dsl)
}
// unregisterConnector removes the provider's connector from the runtime. // unregisterConnector removes the provider's connector from the runtime.
func unregisterConnector(p *Provider) error { func unregisterConnector(p *Provider) error {
if p.Source == ProviderSourceBuiltIn { if p.Source == ProviderSourceBuiltIn {
@ -89,6 +237,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)
} }
@ -133,13 +286,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,
@ -163,6 +337,54 @@ 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")
}
if c.Embedding {
out = append(out, "embedding")
}
if c.ImageGeneration {
out = append(out, "image_generation")
}
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

@ -1,5 +1,12 @@
package llmprovider package llmprovider
// Identity abstracts a caller's user/team context for scope-aware lookups.
// Implemented by oauthTypes.AuthorizedInfo and any struct with UserID/TeamID.
type Identity interface {
GetUserID() string
GetTeamID() string
}
// Provider represents a configured LLM provider (one vendor connection with multiple models). // Provider represents a configured LLM provider (one vendor connection with multiple models).
// Fields align with the frontend ProviderConfig interface. // Fields align with the frontend ProviderConfig interface.
type Provider struct { type Provider struct {
@ -22,10 +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"`
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"`
Options map[string]interface{} `json:"options,omitempty" yaml:"options,omitempty"`
} }
// ProviderOwner identifies who owns a provider. // ProviderOwner identifies who owns a provider.
@ -60,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"`
@ -75,9 +87,6 @@ type ProviderTestResult struct {
LatencyMs int64 `json:"latency_ms,omitempty"` LatencyMs int64 `json:"latency_ms,omitempty"`
} }
// RoleAssignment maps model roles to specific provider+model pairs.
type RoleAssignment map[string]RoleTarget
// RoleTarget identifies a provider and model for a given role. // RoleTarget identifies a provider and model for a given role.
type RoleTarget struct { type RoleTarget struct {
Provider string `json:"provider"` Provider string `json:"provider"`

View file

@ -13,6 +13,7 @@ import (
_ "github.com/yaoapp/yao/rss" _ "github.com/yaoapp/yao/rss"
_ "github.com/yaoapp/yao/seed" _ "github.com/yaoapp/yao/seed"
_ "github.com/yaoapp/yao/sitemap" _ "github.com/yaoapp/yao/sitemap"
_ "github.com/yaoapp/yao/tools"
_ "github.com/yaoapp/yao/trace/jsapi" _ "github.com/yaoapp/yao/trace/jsapi"
_ "github.com/yaoapp/yao/wework" _ "github.com/yaoapp/yao/wework"

View file

@ -20,6 +20,7 @@ import (
"github.com/yaoapp/yao/messenger/providers/twilio" "github.com/yaoapp/yao/messenger/providers/twilio"
"github.com/yaoapp/yao/messenger/template" "github.com/yaoapp/yao/messenger/template"
"github.com/yaoapp/yao/messenger/types" "github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/setting"
"github.com/yaoapp/yao/share" "github.com/yaoapp/yao/share"
) )
@ -50,7 +51,18 @@ func Load(cfg config.Config) error {
return err return err
} }
if !exists { if !exists {
log.Warn("[Messenger] messengers directory not found, skip loading messenger") log.Warn("[Messenger] messengers directory not found, creating empty instance for dynamic resolution")
Instance = &Service{
config: &types.Config{Global: types.GlobalConfig{
RetryAttempts: 3,
RetryDelay: 2 * time.Second,
Timeout: 30 * time.Second,
}},
providers: make(map[string]types.Provider),
providersByType: make(map[types.MessageType][]types.Provider),
channels: make(map[string]types.Channel),
defaults: make(map[string]string),
}
return nil return nil
} }
@ -236,8 +248,16 @@ func createMailgunProvider(config types.ProviderConfig) (types.Provider, error)
return mailgun.NewMailgunProviderWithTemplateManager(config, template.Global) return mailgun.NewMailgunProviderWithTemplateManager(config, template.Global)
} }
// Send sends a message using the specified channel or default provider // Send sends a message using the specified channel or default provider.
// If ctx carries an Identity (key "identity"), dynamic SMTP resolution uses
// user/team scope; otherwise system-scope is tried. Falls back to static
// .yao providers when dynamic resolution yields nothing.
func (m *Service) Send(ctx context.Context, channel string, message *types.Message) error { func (m *Service) Send(ctx context.Context, channel string, message *types.Message) error {
id, _ := ctx.Value("identity").(setting.Identity)
if provider := m.resolveSettingProvider(id, message.Type); provider != nil {
return m.sendViaProvider(ctx, provider, message)
}
m.mutex.RLock() m.mutex.RLock()
defer m.mutex.RUnlock() defer m.mutex.RUnlock()
@ -299,43 +319,43 @@ func (m *Service) SendWithProvider(ctx context.Context, providerName string, mes
return fmt.Errorf("failed to send message after %d attempts: %w", maxAttempts, lastErr) return fmt.Errorf("failed to send message after %d attempts: %w", maxAttempts, lastErr)
} }
// SendT sends a message using a template // SendT sends a message using a template.
// Like Send, it tries dynamic SMTP resolution first (via ctx Identity),
// then falls back to static .yao providers.
// messageType is optional - if not specified, the first available template type will be used // messageType is optional - if not specified, the first available template type will be used
func (m *Service) SendT(ctx context.Context, channel string, templateID string, data types.TemplateData, messageType ...types.MessageType) error { func (m *Service) SendT(ctx context.Context, channel string, templateID string, data types.TemplateData, messageType ...types.MessageType) error {
m.mutex.RLock()
defer m.mutex.RUnlock()
// Determine which message type to use // Determine which message type to use
var msgType types.MessageType var msgType types.MessageType
if len(messageType) > 0 { if len(messageType) > 0 {
// Use specified message type
msgType = messageType[0] msgType = messageType[0]
} else { } else {
// Get available template types and use the first one
availableTypes := template.Global.GetAvailableTypes(templateID) availableTypes := template.Global.GetAvailableTypes(templateID)
if len(availableTypes) == 0 { if len(availableTypes) == 0 {
return fmt.Errorf("template not found: %s", templateID) return fmt.Errorf("template not found: %s", templateID)
} }
// Convert TemplateType to MessageType
msgType = templateTypeToMessageType(availableTypes[0]) msgType = templateTypeToMessageType(availableTypes[0])
} }
// Get provider for this channel and message type id, _ := ctx.Value("identity").(setting.Identity)
if provider := m.resolveSettingProvider(id, msgType); provider != nil {
templateType := messageTypeToTemplateType(msgType)
return provider.SendT(ctx, templateID, templateType, data)
}
m.mutex.RLock()
defer m.mutex.RUnlock()
providerName := m.getProviderForChannel(channel, string(msgType)) providerName := m.getProviderForChannel(channel, string(msgType))
if providerName == "" { if providerName == "" {
return fmt.Errorf("no provider configured for channel %s with message type %s", channel, msgType) return fmt.Errorf("no provider configured for channel %s with message type %s", channel, msgType)
} }
// Get the provider
provider, exists := m.providers[providerName] provider, exists := m.providers[providerName]
if !exists { if !exists {
return fmt.Errorf("provider not found: %s", providerName) return fmt.Errorf("provider not found: %s", providerName)
} }
// Convert MessageType back to TemplateType
templateType := messageTypeToTemplateType(msgType) templateType := messageTypeToTemplateType(msgType)
// Call provider's SendT method
return provider.SendT(ctx, templateID, templateType, data) return provider.SendT(ctx, templateID, templateType, data)
} }
@ -909,6 +929,132 @@ func parseChannelsConfig(channelsConfig map[string]interface{}, defaults map[str
} }
} }
// resolveSettingProvider tries to build a mailer.Provider from UI-configured
// SMTP settings stored in setting.Global. id may be nil in that case
// system-scope settings are used.
func (m *Service) resolveSettingProvider(id setting.Identity, messageType types.MessageType) types.Provider {
if messageType != types.MessageTypeEmail {
return nil
}
if setting.Global == nil {
return nil
}
userID, teamID := "", ""
if id != nil {
userID = id.GetUserID()
teamID = id.GetTeamID()
}
cfg, err := setting.Global.GetMerged(userID, teamID, "smtp")
if err != nil || cfg == nil {
return nil
}
if enabled, ok := cfg["enabled"].(bool); ok && !enabled {
return nil
}
host, _ := cfg["host"].(string)
username, _ := cfg["username"].(string)
password, _ := cfg["password"].(string)
fromEmail, _ := cfg["from_email"].(string)
fromName, _ := cfg["from_name"].(string)
encryption, _ := cfg["encryption"].(string)
if host == "" || username == "" {
return nil
}
if password != "" {
password = setting.Decrypt(password)
}
from := fromEmail
if from == "" {
from = username
}
if fromName != "" {
from = fromName + " <" + from + ">"
}
useTLS, useSSL := false, false
switch encryption {
case "ssl":
useSSL = true
case "tls", "starttls":
useTLS = true
}
port := 587
switch v := cfg["port"].(type) {
case float64:
port = int(v)
case int:
port = v
case int64:
port = int(v)
}
providerCfg := types.ProviderConfig{
Name: "setting-smtp", Connector: "mailer", Enabled: true,
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": host, "port": port,
"username": username, "password": password,
"from": from, "use_tls": useTLS, "use_ssl": useSSL,
},
},
}
provider, err := mailer.NewMailerProvider(providerCfg)
if err != nil {
log.Warn("[Messenger] Failed to create dynamic SMTP provider: %v", err)
return nil
}
return provider
}
// sendViaProvider validates the message and sends it through the given provider
// with the configured retry logic.
func (m *Service) sendViaProvider(ctx context.Context, provider types.Provider, message *types.Message) error {
if err := m.validateMessage(message); err != nil {
return fmt.Errorf("message validation failed: %w", err)
}
maxAttempts := m.config.Global.RetryAttempts
if maxAttempts <= 0 {
maxAttempts = 1
}
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
select {
case <-ctx.Done():
return fmt.Errorf("send cancelled: %w", ctx.Err())
default:
}
err := provider.Send(ctx, message)
if err == nil {
log.Info("[Messenger] Message sent via dynamic provider (attempt %d/%d)", attempt, maxAttempts)
return nil
}
lastErr = err
if attempt < maxAttempts {
log.Warn("[Messenger] Dynamic send attempt %d/%d failed: %v", attempt, maxAttempts, err)
select {
case <-ctx.Done():
return fmt.Errorf("send cancelled during retry: %w", ctx.Err())
case <-time.After(m.config.Global.RetryDelay):
}
}
}
return fmt.Errorf("failed to send message via dynamic provider after %d attempts: %w", maxAttempts, lastErr)
}
// validateMessage validates a message before sending // validateMessage validates a message before sending
func (m *Service) validateMessage(message *types.Message) error { func (m *Service) validateMessage(message *types.Message) error {
if message == nil { if message == nil {

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

@ -1,107 +0,0 @@
# Commercial Usage Declaration - Yao OpenAPI
This document outlines the commercial usage terms for the Yao OpenAPI components contained within this directory.
## Commercial License Requirements
The Yao OpenAPI module is available for commercial use under the following **mandatory conditions**:
### 1. Developer Certificate Requirement
**Commercial usage requires obtaining a Yao Developer Certificate.**
- All commercial deployments of Yao OpenAPI must possess a valid Yao Developer Certificate
- The certificate must be obtained through official Yao certification channels
- Usage without proper certification constitutes license violation
### 2. Trademark and Branding Restrictions
**Yao branding elements are protected and must remain intact:**
- Yao App Engine logos and copyright information **MUST NOT** be removed or modified
- All Yao trademark elements must remain visible and unaltered in production deployments
- Logo and copyright modifications are **ONLY** permitted with explicit authorization through Yao Developer Certificate
### 3. Certificate Verification System Integrity
**The Yao certificate verification system must be maintained in its entirety:**
- **PROHIBITED**: Removal, modification, or circumvention of Yao certificate verification logic
- **PROHIBITED**: Alteration of certificate validation processes and related code components
- **PROHIBITED**: Modification of certificate verification pages (marked in code comments)
- **REQUIRED**: Complete preservation of the Yao certificate verification system regardless of usage purpose
### 4. Code Integrity Requirements
For commercial deployments, the following **core verification mechanisms** must remain unmodified:
- Certificate validation endpoints and middleware
- Authentication verification logic and OAuth implementation
- License verification routines and related authentication flows
- Commercial license checking and enforcement code
- Yao branding and copyright verification systems
_Note: General business logic and API customizations are permitted, but the core licensing and certificate verification infrastructure must remain intact._
## Compliance Obligations
### For Commercial Users:
1. **Obtain Valid Certification**: Acquire and maintain a current Yao Developer Certificate
2. **Preserve Verification Logic**: Maintain all certificate verification code intact
3. **Respect Trademark Rights**: Keep all Yao branding elements unmodified
4. **Regular Compliance Review**: Ensure ongoing adherence to certification requirements
### For Developers and Integrators:
1. **Code Review Responsibility**: Verify that certificate verification logic remains intact
2. **Documentation Compliance**: Maintain all certificate-related documentation and comments
3. **Deployment Verification**: Ensure production deployments include proper certificate validation
4. **Update Compliance**: When updating Yao components, preserve all certificate verification systems
## Prohibited Activities
The following activities are **strictly prohibited** without explicit written authorization:
- Removing or disabling certificate verification mechanisms
- Modifying Yao branding, logos, or copyright notices
- Circumventing license validation processes
- Redistributing modified versions that bypass certificate requirements
- Creating derivative works that remove authentication restrictions
## Legal Compliance
This commercial usage declaration is governed by:
- **Base License**: Modified Apache License 2.0
- **Additional Terms**: As specified in the root LICENSE file
- **Commercial Requirements**: As outlined in this document
- **Jurisdiction**: Subject to applicable intellectual property and software licensing laws
## Violation Consequences
Failure to comply with these commercial usage terms may result in:
- Immediate license termination
- Legal action for intellectual property infringement
- Liability for damages and legal costs
- Permanent prohibition from commercial Yao ecosystem usage
## Contact and Authorization
For commercial licensing inquiries, certificate applications, or clarification of usage terms:
- **Official Website**: [Yao Developer Certification Portal]
- **License Inquiries**: Contact authorized Yao licensing representatives
- **Technical Support**: Available exclusively to certified commercial users
---
**Important Notice**: This commercial declaration supplements and does not replace the terms specified in the root LICENSE file. In case of conflicts, the most restrictive terms apply.
**© 2025 Infinite Wisdom Software. All rights reserved.**
---
_Last Updated: 2025-07-22_
_Document Version: 1.0_

View file

@ -1,107 +0,0 @@
# 商业使用声明 - Yao OpenAPI
本文档阐述了本目录下 Yao OpenAPI 组件的商业使用条款。
## 商业许可证要求
Yao OpenAPI 模块可用于商业用途,但须遵循以下**强制性条件**
### 1. 开发者证书要求
**商业使用需要获得 Yao 开发者证书。**
- 所有 Yao OpenAPI 的商业部署都必须拥有有效的 Yao 开发者证书
- 证书必须通过官方 Yao 认证渠道获得
- 未经适当认证的使用构成许可证违规
### 2. 商标和品牌限制
**Yao 品牌元素受保护,必须保持完整:**
- Yao App Engine 标志和版权信息**不得**被移除或修改
- 所有 Yao 商标元素必须在生产部署中保持可见且未改动
- 标志和版权修改**仅**在通过 Yao 开发者证书获得明确授权后允许
### 3. 证书验证系统完整性
**Yao 证书验证系统必须完整保持:**
- **禁止**:移除、修改或绕过 Yao 证书验证逻辑
- **禁止**:更改证书验证流程和相关代码组件
- **禁止**:修改证书验证页面(在代码注释中标记)
- **要求**:无论使用目的如何,必须完整保持 Yao 证书验证系统
### 4. 代码完整性要求
对于商业部署,以下**核心验证机制**必须保持未修改状态:
- 证书验证端点和中间件
- 认证验证逻辑和 OAuth 实现
- 许可证验证例程和相关认证流程
- 商业许可证检查和执行代码
- Yao 品牌和版权验证系统
_注意一般业务逻辑和 API 定制是允许的但核心许可证和证书验证基础设施必须保持完整。_
## 合规义务
### 商业用户:
1. **获得有效认证**:获得并维护当前有效的 Yao 开发者证书
2. **保持验证逻辑**:保持所有证书验证代码完整
3. **尊重商标权**:保持所有 Yao 品牌元素未修改
4. **定期合规审查**:确保持续遵守认证要求
### 开发者和集成商:
1. **代码审查责任**:验证证书验证逻辑保持完整
2. **文档合规**:维护所有与证书相关的文档和注释
3. **部署验证**:确保生产部署包含适当的证书验证
4. **更新合规**:更新 Yao 组件时,保持所有证书验证系统
## 禁止活动
以下活动在没有明确书面授权的情况下**严格禁止**
- 移除或禁用证书验证机制
- 修改 Yao 品牌、标志或版权声明
- 绕过许可证验证流程
- 重新分发绕过证书要求的修改版本
- 创建移除认证限制的衍生作品
## 法律合规
本商业使用声明受以下条款管辖:
- **基础许可证**:修改版 Apache License 2.0
- **附加条款**:如根目录 LICENSE 文件中指定
- **商业要求**:如本文档中概述
- **管辖权**:受适用的知识产权和软件许可法律约束
## 违规后果
未能遵守这些商业使用条款可能导致:
- 立即终止许可证
- 因知识产权侵权而提起法律诉讼
- 损害赔偿和法律费用责任
- 永久禁止商业 Yao 生态系统使用
## 联系和授权
如需商业许可咨询、证书申请或使用条款澄清:
- **官方网站**[Yao 开发者认证门户]
- **许可证咨询**:联系授权的 Yao 许可代表
- **技术支持**:仅向经认证的商业用户提供
---
**重要声明**:本商业声明是对根目录 LICENSE 文件中指定条款的补充,不能替代这些条款。如有冲突,以最严格的条款为准。
**© 2025 Infinite Wisdom Software. 保留所有权利。**
---
_最后更新2025-07-22_
_文档版本1.0_

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"
@ -100,6 +101,10 @@ func GetChat(c *gin.Context) {
// Check permission // Check permission
hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, true) hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, true)
if err != nil { if err != nil {
if isNotFoundError(err) {
respondNotFound(c, chatID)
return
}
errorResp := &response.ErrorResponse{ errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code, Code: response.ErrServerError.Code,
ErrorDescription: err.Error(), ErrorDescription: err.Error(),
@ -120,16 +125,10 @@ func GetChat(c *gin.Context) {
// Get chat // Get chat
chat, err := chatStore.GetChat(chatID) chat, err := chatStore.GetChat(chatID)
if err != nil { if err != nil {
// Check if it's a "not found" error if isNotFoundError(err) {
if strings.Contains(err.Error(), "not found") { respondNotFound(c, chatID)
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Chat not found",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return return
} }
errorResp := &response.ErrorResponse{ errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code, Code: response.ErrServerError.Code,
ErrorDescription: err.Error(), ErrorDescription: err.Error(),
@ -183,6 +182,10 @@ func UpdateChat(c *gin.Context) {
// Check permission (write access) // Check permission (write access)
hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, false) hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, false)
if err != nil { if err != nil {
if isNotFoundError(err) {
respondNotFound(c, chatID)
return
}
errorResp := &response.ErrorResponse{ errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code, Code: response.ErrServerError.Code,
ErrorDescription: err.Error(), ErrorDescription: err.Error(),
@ -228,6 +231,10 @@ func UpdateChat(c *gin.Context) {
// Update chat // Update chat
if err := chatStore.UpdateChat(chatID, updates); err != nil { if err := chatStore.UpdateChat(chatID, updates); err != nil {
if isNotFoundError(err) {
respondNotFound(c, chatID)
return
}
errorResp := &response.ErrorResponse{ errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code, Code: response.ErrServerError.Code,
ErrorDescription: err.Error(), ErrorDescription: err.Error(),
@ -273,6 +280,10 @@ func DeleteChat(c *gin.Context) {
// Check permission (write access) // Check permission (write access)
hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, false) hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, false)
if err != nil { if err != nil {
if isNotFoundError(err) {
respondNotFound(c, chatID)
return
}
errorResp := &response.ErrorResponse{ errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code, Code: response.ErrServerError.Code,
ErrorDescription: err.Error(), ErrorDescription: err.Error(),
@ -292,6 +303,10 @@ func DeleteChat(c *gin.Context) {
// Delete chat // Delete chat
if err := chatStore.DeleteChat(chatID); err != nil { if err := chatStore.DeleteChat(chatID); err != nil {
if isNotFoundError(err) {
respondNotFound(c, chatID)
return
}
errorResp := &response.ErrorResponse{ errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code, Code: response.ErrServerError.Code,
ErrorDescription: err.Error(), ErrorDescription: err.Error(),
@ -341,6 +356,10 @@ func GetMessages(c *gin.Context) {
// Check permission (read access) // Check permission (read access)
hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, true) hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, true)
if err != nil { if err != nil {
if isNotFoundError(err) {
respondNotFound(c, chatID)
return
}
errorResp := &response.ErrorResponse{ errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code, Code: response.ErrServerError.Code,
ErrorDescription: err.Error(), ErrorDescription: err.Error(),
@ -364,6 +383,10 @@ func GetMessages(c *gin.Context) {
// Get messages // Get messages
messages, err := chatStore.GetMessages(chatID, filter) messages, err := chatStore.GetMessages(chatID, filter)
if err != nil { if err != nil {
if isNotFoundError(err) {
respondNotFound(c, chatID)
return
}
errorResp := &response.ErrorResponse{ errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code, Code: response.ErrServerError.Code,
ErrorDescription: err.Error(), ErrorDescription: err.Error(),
@ -378,6 +401,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,
@ -549,6 +573,18 @@ func buildMessageFilter(c *gin.Context) storetypes.MessageFilter {
return filter return filter
} }
func isNotFoundError(err error) bool {
return err != nil && strings.Contains(err.Error(), "not found")
}
func respondNotFound(c *gin.Context, chatID string) {
errorResp := &response.ErrorResponse{
Code: "resource_not_found",
ErrorDescription: "Chat " + chatID + " not found",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
}
// checkChatPermission checks if the user has permission to access the chat // checkChatPermission checks if the user has permission to access the chat
// readable: true for read access, false for write access // readable: true for read access, false for write access
func checkChatPermission(chatStore storetypes.ChatStore, authInfo *oauthtypes.AuthorizedInfo, chatID string, readable bool) (bool, error) { func checkChatPermission(chatStore storetypes.ChatStore, authInfo *oauthtypes.AuthorizedInfo, chatID string, readable bool) (bool, error) {
@ -598,3 +634,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

@ -6,6 +6,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
agentllm "github.com/yaoapp/yao/agent/llm" agentllm "github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/llmprovider"
"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"
) )
@ -34,7 +36,6 @@ func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) {
func listProviders(c *gin.Context) { func listProviders(c *gin.Context) {
allProviders := make([]Provider, 0) allProviders := make([]Provider, 0)
// Parse filter parameters from query string
filtersParam := c.Query("filters") filtersParam := c.Query("filters")
var filters []string var filters []string
if filtersParam != "" { if filtersParam != "" {
@ -44,49 +45,62 @@ func listProviders(c *gin.Context) {
} }
} }
for _, opt := range connector.AIConnectors { info := authorized.GetInfo(c)
connType := getConnectorType(opt.Value)
if connType == "openai" || connType == "anthropic" {
conn, ok := connector.Connectors[opt.Value]
if !ok {
continue
}
capabilities := getCapabilitiesFromConn(conn) var opts []connector.Option
if llmprovider.Global != nil {
opts = llmprovider.Global.ListModelsBy(info)
} else {
opts = connector.AIConnectors
}
// Apply capability filters for _, opt := range opts {
if len(filters) > 0 && !matchesFilters(capabilities, filters) { var conn connector.Connector
continue var err error
} if llmprovider.Global != nil {
conn, err = llmprovider.Global.GetModel(opt.Value)
allProviders = append(allProviders, Provider{ } else {
Label: opt.Label, conn, err = connector.Select(opt.Value)
Value: opt.Value,
Type: connType,
Builtin: conn.GetMetaInfo().Builtin,
Capabilities: capabilities,
})
} }
if err != nil {
continue
}
connType := connectorType(conn)
if connType != "openai" && connType != "anthropic" {
continue
}
capabilities := getCapabilitiesFromConn(conn)
if isNonChatModel(capabilities) && !hasFilter(filters, "embedding") && !hasFilter(filters, "image_generation") {
continue
}
if len(filters) > 0 && !matchesFilters(capabilities, filters) {
continue
}
allProviders = append(allProviders, Provider{
Label: opt.Label,
Value: opt.Value,
Type: connType,
Builtin: conn.GetMetaInfo().Builtin,
Capabilities: capabilities,
})
} }
response.RespondWithSuccess(c, response.StatusOK, allProviders) response.RespondWithSuccess(c, response.StatusOK, allProviders)
} }
// getConnectorType retrieves the connector type by checking the global connector map // connectorType returns the type string for a connector.
func getConnectorType(id string) string { func connectorType(conn connector.Connector) string {
conn, ok := connector.Connectors[id]
if !ok {
return "unknown"
}
if conn.Is(connector.OPENAI) { if conn.Is(connector.OPENAI) {
return "openai" return "openai"
} }
if conn.Is(connector.ANTHROPIC) { if conn.Is(connector.ANTHROPIC) {
return "anthropic" return "anthropic"
} }
return "unknown" return "unknown"
} }
@ -100,6 +114,27 @@ func getCapabilitiesFromConn(conn connector.Connector) map[string]interface{} {
return agentllm.ToMap(caps) return agentllm.ToMap(caps)
} }
// isNonChatModel returns true if capabilities indicate a non-chat model (embedding or image generation).
func isNonChatModel(caps map[string]interface{}) bool {
if v, ok := caps["embedding"].(bool); ok && v {
return true
}
if v, ok := caps["image_generation"].(bool); ok && v {
return true
}
return false
}
// hasFilter checks whether a specific filter string is present in the filters list.
func hasFilter(filters []string, name string) bool {
for _, f := range filters {
if f == name {
return true
}
}
return false
}
// matchesFilters checks if capabilities match all requested filters // matchesFilters checks if capabilities match all requested filters
// Filters are matched case-insensitively and support the following capability keys: // Filters are matched case-insensitively and support the following capability keys:
// - vision: true or string value like "openai", "claude" // - vision: true or string value like "openai", "claude"
@ -110,6 +145,8 @@ func getCapabilitiesFromConn(conn connector.Connector) map[string]interface{} {
// - streaming: bool // - streaming: bool
// - json: bool // - json: bool
// - multimodal: bool // - multimodal: bool
// - embedding: bool
// - image_generation: bool
// - temperature_adjustable: bool // - temperature_adjustable: bool
func matchesFilters(capabilities map[string]interface{}, filters []string) bool { func matchesFilters(capabilities map[string]interface{}, filters []string) bool {
if capabilities == nil { if capabilities == nil {

View file

@ -635,6 +635,22 @@ type AuthorizedInfo struct {
Constraints DataConstraints `json:"constraints,omitempty"` Constraints DataConstraints `json:"constraints,omitempty"`
} }
// GetUserID implements llmprovider.Identity.
func (auth *AuthorizedInfo) GetUserID() string {
if auth == nil {
return ""
}
return auth.UserID
}
// GetTeamID implements llmprovider.Identity.
func (auth *AuthorizedInfo) GetTeamID() string {
if auth == nil {
return ""
}
return auth.TeamID
}
// AuthorizedToMap converts AuthorizedInfo to map[string]interface{} // AuthorizedToMap converts AuthorizedInfo to map[string]interface{}
// This is useful for passing authorized information to runtime bridges (e.g., V8) // This is useful for passing authorized information to runtime bridges (e.g., V8)
func (auth *AuthorizedInfo) AuthorizedToMap() map[string]interface{} { func (auth *AuthorizedInfo) AuthorizedToMap() map[string]interface{} {

View file

@ -1,21 +1,14 @@
package setting package setting
import ( import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
_ "embed" _ "embed"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yaoapp/yao/config"
"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"
@ -204,6 +197,7 @@ func handleCloudUpdate(c *gin.Context) {
respondError(c, http.StatusInternalServerError, err.Error()) respondError(c, http.StatusInternalServerError, err.Error())
return return
} }
invalidateCloudModelCache()
def := cloudDefaultRegion() def := cloudDefaultRegion()
result := CloudPageData{ result := CloudPageData{
@ -317,35 +311,58 @@ func handleCloudTest(c *gin.Context) {
}) })
} }
// handleCloudRefresh invalidates the cloud model cache and re-fetches the model list.
// POST /setting/cloud/refresh
func handleCloudRefresh(c *gin.Context) {
if !guardOwner(c) {
return
}
info := authorized.GetInfo(c)
scope := cloudScope(info)
saved, _ := setting.Global.Get(scope, cloudNS)
if saved == nil {
respondError(c, http.StatusBadRequest, "cloud service not configured")
return
}
status, _ := saved["status"].(string)
if status != "connected" {
respondError(c, http.StatusBadRequest, "cloud service not connected")
return
}
encKey, _ := saved["api_key"].(string)
if encKey == "" {
respondError(c, http.StatusBadRequest, "no API key configured")
return
}
apiURL := resolveCloudAPIURL(saved)
invalidateCloudModelCache()
models := fetchCloudModels(apiURL, cloudDecrypt(encKey))
response.RespondWithSuccess(c, http.StatusOK, map[string]interface{}{
"success": true,
"count": len(models),
})
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Crypto helpers (AES-256-GCM, same scheme as llmprovider) // Crypto helpers delegates to setting.Encrypt / setting.Decrypt
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func cloudEncrypt(plaintext string) string { func cloudEncrypt(plaintext string) string {
secret := config.Conf.DB.AESKey return setting.Encrypt(plaintext)
if secret == "" {
return plaintext
}
enc, err := cloudEncryptString(plaintext, secret)
if err != nil {
return plaintext
}
return cloudEncPrefix + enc
} }
func cloudDecrypt(value string) string { func cloudDecrypt(value string) string {
if !strings.HasPrefix(value, cloudEncPrefix) { return setting.Decrypt(value)
return value }
}
secret := config.Conf.DB.AESKey // DecryptValue decrypts a value encrypted by cloudEncrypt.
if secret == "" { func DecryptValue(s string) string {
return strings.TrimPrefix(value, cloudEncPrefix) return setting.Decrypt(s)
}
dec, err := cloudDecryptString(strings.TrimPrefix(value, cloudEncPrefix), secret)
if err != nil {
return value
}
return dec
} }
func cloudMaskKey(key string) string { func cloudMaskKey(key string) string {
@ -359,51 +376,3 @@ func cloudMaskKey(key string) string {
suffix := key[len(key)-cloudMaskChars:] suffix := key[len(key)-cloudMaskChars:]
return prefix + "..." + suffix return prefix + "..." + suffix
} }
func cloudDeriveKey(secret string) []byte {
h := sha256.Sum256([]byte(secret))
return h[:]
}
func cloudEncryptString(plaintext, secret string) (string, error) {
key := cloudDeriveKey(secret)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func cloudDecryptString(encoded, secret string) (string, error) {
key := cloudDeriveKey(secret)
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

View file

@ -0,0 +1,92 @@
package setting
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func TestFetchCloudModels_CachesAfterFirstCall(t *testing.T) {
var hits int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&hits, 1)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{
{"id": "gpt-4o", "object": "model"},
},
})
}))
defer srv.Close()
invalidateCloudModelCache()
models := fetchCloudModels(srv.URL, "test-key")
if len(models) == 0 {
t.Fatal("expected models from first fetch, got none")
}
if atomic.LoadInt64(&hits) != 1 {
t.Fatalf("expected 1 HTTP hit after first fetch, got %d", atomic.LoadInt64(&hits))
}
models2 := fetchCloudModels(srv.URL, "test-key")
if len(models2) == 0 {
t.Fatal("expected models from cached fetch, got none")
}
if atomic.LoadInt64(&hits) != 1 {
t.Fatalf("expected still 1 HTTP hit after second fetch (cache), got %d", atomic.LoadInt64(&hits))
}
}
func TestFetchCloudModels_InvalidateForcesRefetch(t *testing.T) {
var hits int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&hits, 1)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{
{"id": "gpt-4o", "object": "model"},
},
})
}))
defer srv.Close()
invalidateCloudModelCache()
fetchCloudModels(srv.URL, "test-key")
if atomic.LoadInt64(&hits) != 1 {
t.Fatalf("expected 1 HTTP hit, got %d", atomic.LoadInt64(&hits))
}
invalidateCloudModelCache()
fetchCloudModels(srv.URL, "test-key")
if atomic.LoadInt64(&hits) != 2 {
t.Fatalf("expected 2 HTTP hits after invalidation, got %d", atomic.LoadInt64(&hits))
}
}
func TestFetchCloudModels_URLChangeForcesRefetch(t *testing.T) {
var hits int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&hits, 1)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{
{"id": "gpt-4o", "object": "model"},
},
})
}))
defer srv.Close()
invalidateCloudModelCache()
fetchCloudModels(srv.URL, "test-key")
if atomic.LoadInt64(&hits) != 1 {
t.Fatalf("expected 1 HTTP hit, got %d", atomic.LoadInt64(&hits))
}
fetchCloudModels(srv.URL+"/other", "test-key")
if atomic.LoadInt64(&hits) != 2 {
t.Fatalf("expected 2 HTTP hits after URL change, got %d", atomic.LoadInt64(&hits))
}
}

View file

@ -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"
@ -16,7 +18,7 @@ import (
"github.com/yaoapp/yao/setting" "github.com/yaoapp/yao/setting"
) )
const llmRolesNS = "llm.roles" var llmRolesNS = llmprovider.RolesNamespace
func llmEnsureEncKey() { func llmEnsureEncKey() {
if llmprovider.Global != nil && config.Conf.DB.AESKey != "" { if llmprovider.Global != nil && config.Conf.DB.AESKey != "" {
@ -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
} }
} }
@ -81,37 +86,352 @@ func llmModelsURL(apiURL string) string {
return apiURL + "/v1/models" return apiURL + "/v1/models"
} }
// llmValidateKey tests connectivity by calling GET {apiURL}/models. // llmCompletionURL builds the chat/messages endpoint URL.
// providerType controls the auth header format (anthropic uses x-api-key). func llmCompletionURL(providerType, apiURL string) string {
endpoint := "chat/completions"
if providerType == "anthropic" {
endpoint = "messages"
}
if strings.HasSuffix(apiURL, "/") {
return apiURL + endpoint
}
return apiURL + "/v1/" + endpoint
}
// llmSetAuthHeader sets the appropriate auth header for the provider type.
func llmSetAuthHeader(req *http.Request, providerType, apiKey string) {
if apiKey == "" {
return
}
if providerType == "anthropic" {
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
} else {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
}
// llmValidateKey tests connectivity and API key validity using a three-step
// approach that works across all provider types (OpenAI, Anthropic, and
// third-party compatible APIs) without incurring any token costs:
//
// 1. POST to real completion endpoint with empty messages (zero cost).
// 401/403 → invalid key. Other response → connection works, proceed.
// 2. GET /models to confirm key validity.
// 200 → key valid. 401/403 → invalid key. 404 → endpoint unsupported,
// trust step-1 result. Other → report error.
// 3. If step-1 returned 404 (model-based routing, e.g. NVIDIA) AND step-2
// returned 200, the /models endpoint may be public. Pick the first model
// from the response and POST again with that real model + empty messages.
func llmValidateKey(providerType, apiURL, apiKey string) error { func llmValidateKey(providerType, apiURL, apiKey string) error {
url := llmModelsURL(apiURL)
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", url, nil)
// --- Step 1: POST real endpoint with fake model + empty messages ---
postURL := llmCompletionURL(providerType, apiURL)
req, err := http.NewRequest("POST", postURL, strings.NewReader(`{"model":"_","messages":[]}`))
if err != nil { if err != nil {
return fmt.Errorf("failed to build request: %w", err) return fmt.Errorf("failed to build request: %w", err)
} }
if apiKey != "" { req.Header.Set("Content-Type", "application/json")
if providerType == "anthropic" { llmSetAuthHeader(req, providerType, apiKey)
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
} else {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("connection failed: %w", err) return fmt.Errorf("connection failed: %w", err)
} }
defer resp.Body.Close() resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("invalid API key (HTTP %d)", resp.StatusCode) return fmt.Errorf("invalid API key (HTTP %d)", resp.StatusCode)
} }
if resp.StatusCode != http.StatusOK { postStatus := resp.StatusCode
return fmt.Errorf("server returned HTTP %d", resp.StatusCode)
// --- Step 2: GET /models to confirm key ---
req2, err := http.NewRequest("GET", llmModelsURL(apiURL), nil)
if err != nil {
return nil
}
llmSetAuthHeader(req2, providerType, apiKey)
resp2, err := client.Do(req2)
if err != nil {
return nil // POST connected, GET network failure is non-fatal
}
modelsStatus := resp2.StatusCode
var modelsBody []byte
if modelsStatus == http.StatusOK {
modelsBody, _ = io.ReadAll(resp2.Body)
}
resp2.Body.Close()
if modelsStatus == http.StatusUnauthorized || modelsStatus == http.StatusForbidden {
return fmt.Errorf("invalid API key (HTTP %d)", modelsStatus)
}
if modelsStatus == http.StatusNotFound {
return nil
}
if modelsStatus == http.StatusOK {
if postStatus == http.StatusNotFound && len(modelsBody) > 0 {
return llmValidateWithModel(client, providerType, apiURL, apiKey, modelsBody)
}
return nil
}
return fmt.Errorf("server returned HTTP %d", modelsStatus)
}
// llmValidateWithModel is the step-3 fallback for providers whose /models
// endpoint is public (always 200). It picks the first model from the /models
// response and POSTs to the completion endpoint with that model + empty
// messages to trigger a real auth check.
func llmValidateWithModel(client *http.Client, providerType, apiURL, apiKey string, modelsBody []byte) error {
var parsed struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(modelsBody, &parsed); err != nil || len(parsed.Data) == 0 {
return nil
}
postURL := llmCompletionURL(providerType, apiURL)
body := fmt.Sprintf(`{"model":%q,"messages":[]}`, parsed.Data[0].ID)
req, err := http.NewRequest("POST", postURL, strings.NewReader(body))
if err != nil {
return nil
}
req.Header.Set("Content-Type", "application/json")
llmSetAuthHeader(req, providerType, apiKey)
resp, err := client.Do(req)
if err != nil {
return nil
}
resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("invalid API key (HTTP %d)", resp.StatusCode)
} }
return nil return nil
} }
// ---------------------------------------------------------------------------
// Cloud preset helpers
// ---------------------------------------------------------------------------
var (
cloudModelCache []map[string]interface{}
cloudModelCacheURL string
cloudModelCacheMu sync.Mutex
)
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 {
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
cloudModelCacheURL = apiURL
cloudModelCacheMu.Unlock()
return models
}
func invalidateCloudModelCache() {
cloudModelCacheMu.Lock()
cloudModelCache = nil
cloudModelCacheURL = ""
cloudModelCacheMu.Unlock()
}
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 +444,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,40 +457,22 @@ 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) {
url := llmModelsURL(input.APIURL) response.RespondWithSuccess(c, http.StatusOK, llmprovider.ProviderTestResult{
start := time.Now() Success: false,
client := &http.Client{Timeout: 10 * time.Second} Message: "API Key is required",
req, err := http.NewRequest("GET", url, nil) })
if err != nil {
respondError(c, http.StatusInternalServerError, err.Error())
return return
} }
if input.APIKey != "" {
if input.Type == "anthropic" {
req.Header.Set("x-api-key", input.APIKey)
req.Header.Set("anthropic-version", "2023-06-01")
} else {
req.Header.Set("Authorization", "Bearer "+input.APIKey)
}
}
resp, err := client.Do(req) start := time.Now()
err := llmValidateKey(input.Type, input.APIURL, input.APIKey)
latency := time.Since(start).Milliseconds() latency := time.Since(start).Milliseconds()
if err != nil { if err != nil {
response.RespondWithSuccess(c, http.StatusOK, llmprovider.ProviderTestResult{ response.RespondWithSuccess(c, http.StatusOK, llmprovider.ProviderTestResult{
Success: false, Success: false,
Message: fmt.Sprintf("Connection failed: %s", err.Error()), Message: err.Error(),
})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
response.RespondWithSuccess(c, http.StatusOK, llmprovider.ProviderTestResult{
Success: false,
Message: fmt.Sprintf("Server returned HTTP %d", resp.StatusCode),
}) })
return return
} }
@ -216,7 +519,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 +570,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 +587,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 +607,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,12 +662,16 @@ 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
} }
provider.Key = presetKey provider.Key = llmprovider.ScopedKey(owner, presetKey)
provider.Name = preset.Name provider.Name = preset.Name
provider.Type = preset.Type provider.Type = preset.Type
provider.APIURL = preset.APIURL provider.APIURL = preset.APIURL
@ -376,6 +698,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 +706,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
@ -391,7 +724,7 @@ func handleLLMProviderCreate(c *gin.Context) {
respondError(c, http.StatusBadRequest, "key is required for custom provider") respondError(c, http.StatusBadRequest, "key is required for custom provider")
return return
} }
provider.Key = key provider.Key = llmprovider.ScopedKey(owner, key)
name, _ := body["name"].(string) name, _ := body["name"].(string)
if name == "" { if name == "" {
@ -469,7 +802,7 @@ func handleLLMProviderUpdate(c *gin.Context) {
llmEnsureEncKey() llmEnsureEncKey()
existing, err := llmprovider.Global.Get(key) existing, err := llmprovider.Global.Get(key, true)
if err != nil { if err != nil {
respondError(c, http.StatusNotFound, fmt.Sprintf("provider \"%s\" not found", key)) respondError(c, http.StatusNotFound, fmt.Sprintf("provider \"%s\" not found", key))
return return
@ -633,7 +966,7 @@ func handleLLMProviderTest(c *gin.Context) {
llmEnsureEncKey() llmEnsureEncKey()
p, err := llmprovider.Global.Get(key) p, err := llmprovider.Global.Get(key, true)
if err != nil { if err != nil {
respondError(c, http.StatusNotFound, fmt.Sprintf("provider \"%s\" not found", key)) respondError(c, http.StatusNotFound, fmt.Sprintf("provider \"%s\" not found", key))
return return

View file

@ -0,0 +1,92 @@
package setting
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/authorized"
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/setting"
)
const preferenceNS = "preference"
func preferenceScope(info *oauthTypes.AuthorizedInfo) setting.ScopeID {
return setting.ScopeID{Scope: setting.ScopeUser, UserID: info.UserID}
}
// handlePreferenceGet returns the current user's preference.
// GET /setting/preference
func handlePreferenceGet(c *gin.Context) {
info := authorized.GetInfo(c)
if setting.Global == nil {
response.RespondWithSuccess(c, http.StatusOK, PreferenceData{})
return
}
merged, _ := setting.Global.GetMerged(info.UserID, info.TeamID, preferenceNS)
data := preferenceFromMap(merged)
response.RespondWithSuccess(c, http.StatusOK, data)
}
// handlePreferenceUpdate partially updates the current user's preference.
// PUT /setting/preference
func handlePreferenceUpdate(c *gin.Context) {
info := authorized.GetInfo(c)
var body PreferenceData
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, http.StatusBadRequest, "invalid request body")
return
}
if setting.Global == nil {
respondError(c, http.StatusInternalServerError, "setting registry not initialized")
return
}
scope := preferenceScope(info)
existing, _ := setting.Global.Get(scope, preferenceNS)
m := make(map[string]interface{})
for k, v := range existing {
m[k] = v
}
// Marshal the body to a map so only non-nil fields are included
bodyBytes, _ := json.Marshal(body)
var bodyMap map[string]interface{}
json.Unmarshal(bodyBytes, &bodyMap)
for k, v := range bodyMap {
m[k] = v
}
if _, err := setting.Global.Set(scope, preferenceNS, m); err != nil {
respondError(c, http.StatusInternalServerError, err.Error())
return
}
merged, _ := setting.Global.GetMerged(info.UserID, info.TeamID, preferenceNS)
result := preferenceFromMap(merged)
response.RespondWithSuccess(c, http.StatusOK, result)
}
func preferenceFromMap(m map[string]interface{}) PreferenceData {
data := PreferenceData{}
if m == nil {
return data
}
if v, ok := m["email_notification"].(bool); ok {
data.EmailNotification = &v
}
if v, ok := m["banner_dismissed"].(bool); ok {
data.BannerDismissed = &v
}
if v, ok := m["onboarding_completed"].(bool); ok {
data.OnboardingCompleted = &v
}
return data
}

View file

@ -36,6 +36,7 @@ func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) {
cloud.GET("", handleCloudGet) cloud.GET("", handleCloudGet)
cloud.PUT("", handleCloudUpdate) cloud.PUT("", handleCloudUpdate)
cloud.POST("/test", handleCloudTest) cloud.POST("/test", handleCloudTest)
cloud.POST("/refresh", handleCloudRefresh)
llm := group.Group("/llm") llm := group.Group("/llm")
llm.GET("", handleLLMGet) llm.GET("", handleLLMGet)
@ -73,6 +74,13 @@ func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) {
sb.POST("/nodes/:nodeId/images/pull-all", handleSandboxPullAll) sb.POST("/nodes/:nodeId/images/pull-all", handleSandboxPullAll)
sb.DELETE("/nodes/:nodeId/images/:imageId", handleSandboxImageDelete) sb.DELETE("/nodes/:nodeId/images/:imageId", handleSandboxImageDelete)
sb.POST("/nodes/:nodeId/check-docker", handleSandboxCheckDocker) sb.POST("/nodes/:nodeId/check-docker", handleSandboxCheckDocker)
group.GET("/setup-status", handleSetupStatus)
group.GET("/setup-status/assistant/:id", handleAssistantSetupStatus)
pref := group.Group("/preference")
pref.GET("", handlePreferenceGet)
pref.PUT("", handlePreferenceUpdate)
} }
// requireOwner checks that the current user is the team owner. // requireOwner checks that the current user is the team owner.

View file

@ -0,0 +1,610 @@
package setting
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/llmprovider"
"github.com/yaoapp/yao/openapi/oauth/authorized"
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/setting"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
)
// handleSetupStatus aggregates all system-level configuration checkpoints.
// GET /setting/setup-status
func handleSetupStatus(c *gin.Context) {
info := authorized.GetInfo(c)
locale := strings.ToLower(c.DefaultQuery("locale", "en-us"))
isCN := strings.HasPrefix(locale, "zh")
checkpoints := make(map[string]Checkpoint, 6)
checkpoints["llm_default"] = checkLLMDefault(info, isCN)
checkpoints["llm_vision"] = checkLLMVision(info, isCN)
checkpoints["sandbox_node"] = checkSandboxNode(info, isCN)
checkpoints["sandbox_image"] = checkSandboxImage(info, locale, isCN)
checkpoints["search"] = checkSearch(info, isCN)
checkpoints["smtp"] = checkSMTP(info, isCN)
completed := true
for _, cp := range checkpoints {
if cp.Required && cp.Status == "fail" {
completed = false
break
}
}
bannerDismissed := false
onboardingCompleted := false
if setting.Global != nil {
// Read user-scope only: these are personal preferences that must not
// inherit from system/team scopes.
prefs, _ := setting.Global.Get(preferenceScope(info), preferenceNS)
if prefs != nil {
if v, ok := prefs["banner_dismissed"].(bool); ok {
bannerDismissed = v
}
if v, ok := prefs["onboarding_completed"].(bool); ok {
onboardingCompleted = v
}
}
}
response.RespondWithSuccess(c, http.StatusOK, SetupStatus{
Completed: completed,
Checkpoints: checkpoints,
OnboardingCompleted: onboardingCompleted,
BannerDismissed: bannerDismissed,
})
}
// handleAssistantSetupStatus checks configuration readiness for a specific assistant.
// GET /setting/setup-status/assistant/:id
func handleAssistantSetupStatus(c *gin.Context) {
id := c.Param("id")
info := authorized.GetInfo(c)
locale := strings.ToLower(c.DefaultQuery("locale", "en-us"))
isCN := strings.HasPrefix(locale, "zh")
cache := assistant.GetCache()
var ast *assistant.Assistant
if cache != nil {
ast, _ = cache.Get(id)
}
if ast == nil {
var err error
ast, err = assistant.Get(id)
if err != nil || ast == nil {
respondError(c, http.StatusNotFound, fmt.Sprintf("assistant %q not found", id))
return
}
}
checkpoints := make(map[string]Checkpoint)
allReady := true
// connector check
cp := checkAssistantConnector(ast, info, isCN)
checkpoints["connector"] = cp
if cp.Status == "fail" {
allReady = false
}
// sandbox check (only if V2 sandbox configured)
if ast.HasSandboxV2() {
cp := checkAssistantSandbox(ast, info, locale, isCN)
checkpoints["sandbox_ready"] = cp
if cp.Status == "fail" {
allReady = false
}
}
// search check (only if uses.search is configured and not disabled)
if ast.Uses != nil && ast.Uses.Search != "" && ast.Uses.Search != "disabled" {
cp := checkAssistantSearch(ast, info, isCN)
checkpoints["search"] = cp
if cp.Status == "fail" {
allReady = false
}
}
name := ast.GetName(locale)
if name == "" {
name = ast.ID
}
response.RespondWithSuccess(c, http.StatusOK, AssistantSetupStatus{
AssistantID: id,
AssistantName: name,
Ready: allReady,
Checkpoints: checkpoints,
})
}
// ---------------------------------------------------------------------------
// System-level checkpoint helpers
// ---------------------------------------------------------------------------
// parseRoleTarget extracts provider key and model ID from a role value.
// Supports both map format {"provider":"x","model":"y"} and legacy string "provider::model".
func parseRoleTarget(val interface{}) (providerKey, modelID string) {
switch v := val.(type) {
case map[string]interface{}:
providerKey, _ = v["provider"].(string)
modelID, _ = v["model"].(string)
case string:
if v == "" {
return
}
parts := strings.SplitN(v, "::", 2)
providerKey = parts[0]
if len(parts) == 2 {
modelID = parts[1]
}
}
return
}
func checkLLMDefault(info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint {
cp := Checkpoint{
Required: true,
Label: "Default Model",
Path: "/settings/models",
Status: "fail",
}
if isCN {
cp.Label = "默认模型"
}
if setting.Global == nil || llmprovider.Global == nil {
return cp
}
roles, _ := setting.Global.GetMerged(info.UserID, info.TeamID, llmprovider.RolesNamespace)
if roles == nil {
return cp
}
providerKey, _ := parseRoleTarget(roles["default"])
if providerKey == "" {
return cp
}
p, err := llmprovider.Global.Get(providerKey)
if err != nil || p == nil || !p.Enabled {
return cp
}
cp.Status = "pass"
return cp
}
func checkLLMVision(info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint {
cp := Checkpoint{
Required: false,
Label: "Vision Model",
Path: "/settings/models",
Status: "fail",
}
if isCN {
cp.Label = "视觉模型"
}
if setting.Global == nil || llmprovider.Global == nil {
return cp
}
roles, _ := setting.Global.GetMerged(info.UserID, info.TeamID, llmprovider.RolesNamespace)
if roles == nil {
return cp
}
// Check dedicated vision role first
if providerKey, modelID := parseRoleTarget(roles["vision"]); providerKey != "" {
if p, err := llmprovider.Global.Get(providerKey); err == nil && p != nil && p.Enabled {
for _, m := range p.Models {
if m.Enabled && hasCapability(m.Capabilities, "vision") {
if modelID == "" || m.ID == modelID {
cp.Status = "pass"
return cp
}
}
}
}
}
// Fallback: check if default role has vision capability
if providerKey, _ := parseRoleTarget(roles["default"]); providerKey != "" {
if p, err := llmprovider.Global.Get(providerKey); err == nil && p != nil && p.Enabled {
for _, m := range p.Models {
if m.Enabled && hasCapability(m.Capabilities, "vision") {
cp.Status = "pass"
return cp
}
}
}
}
return cp
}
func checkSandboxNode(info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint {
cp := Checkpoint{
Required: true,
Label: "Sandbox Node",
Path: "/settings/sandbox",
Status: "fail",
}
if isCN {
cp.Label = "沙箱节点"
}
reg := registry.Global()
if reg == nil {
return cp
}
for _, snap := range reg.List() {
if snap.Mode != "local" && !sandboxNodeOwnedBy(&snap, info) {
continue
}
if snap.Status == "online" && snap.Capabilities.Docker {
cp.Status = "pass"
return cp
}
}
return cp
}
func checkSandboxImage(info *oauthTypes.AuthorizedInfo, locale string, isCN bool) Checkpoint {
cp := Checkpoint{
Required: true,
Label: "Sandbox Images",
Path: "/settings/sandbox",
Status: "fail",
}
if isCN {
cp.Label = "沙箱镜像"
}
needed := collectAssistantImages(locale)
if len(needed) == 0 {
cp.Status = "pass"
if isCN {
cp.Detail = "无需镜像"
} else {
cp.Detail = "No images needed"
}
return cp
}
reg := registry.Global()
if reg == nil {
cp.Detail = fmt.Sprintf("0/%d", len(needed))
return cp
}
downloaded := 0
for _, snap := range reg.List() {
if snap.Mode != "local" && !sandboxNodeOwnedBy(&snap, info) {
continue
}
if snap.Status != "online" || !snap.Capabilities.Docker {
continue
}
res, ok := tai.GetResources(snap.TaiID)
if !ok || res.Image == nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
images, err := res.Image.List(ctx)
cancel()
if err != nil {
continue
}
tagIndex := make(map[string]bool)
for _, img := range images {
for _, tag := range img.Tags {
tagIndex[tag] = true
}
}
for imageRef := range needed {
if tagIndex[imageRef] {
downloaded++
}
}
break // only check the first usable node
}
if isCN {
cp.Detail = fmt.Sprintf("%d/%d 镜像已下载", downloaded, len(needed))
} else {
cp.Detail = fmt.Sprintf("%d/%d images downloaded", downloaded, len(needed))
}
if downloaded > 0 {
cp.Status = "pass"
}
return cp
}
func checkSearch(info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint {
cp := Checkpoint{
Required: false,
Label: "Search Provider",
Path: "/settings/search",
Status: "fail",
}
if isCN {
cp.Label = "搜索服务"
}
if setting.Global == nil {
return cp
}
for _, preset := range searchPresets {
if preset.IsCloud {
saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, cloudNS)
if saved != nil {
if v, ok := saved["status"].(string); ok && v == "connected" {
cp.Status = "pass"
return cp
}
}
} else {
saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, searchProviderNS(preset.Key))
if saved != nil {
if v, ok := saved["status"].(string); ok && v == "connected" {
cp.Status = "pass"
return cp
}
}
}
}
return cp
}
func checkSMTP(info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint {
cp := Checkpoint{
Required: false,
Label: "SMTP Email",
Path: "/settings/smtp",
Status: "fail",
}
if isCN {
cp.Label = "邮件服务"
}
if setting.Global == nil {
return cp
}
saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, smtpNS)
if saved == nil {
return cp
}
status, _ := saved["status"].(string)
if status == "connected" {
if enabled, ok := saved["enabled"].(bool); ok && !enabled {
return cp
}
cp.Status = "pass"
}
return cp
}
// ---------------------------------------------------------------------------
// Assistant-level checkpoint helpers
// ---------------------------------------------------------------------------
func checkAssistantConnector(ast *assistant.Assistant, info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint {
cp := Checkpoint{
Required: true,
Label: "Connector",
Path: "/settings/models",
Status: "fail",
}
if isCN {
cp.Label = "模型连接"
}
connID := ast.Connector
if connID == "" {
connID = "default"
}
// Role-based connector: "use::vision", "use::heavy", etc.
if strings.HasPrefix(connID, "use::") {
roleName := strings.TrimPrefix(connID, "use::")
if setting.Global == nil {
return cp
}
roles, _ := setting.Global.GetMerged(info.UserID, info.TeamID, llmprovider.RolesNamespace)
if roles == nil {
return cp
}
pk, mid := parseRoleTarget(roles[roleName])
if pk == "" {
return cp
}
connID = pk
if mid != "" {
connID = pk + "::" + mid
}
}
// "default" means use the default role
if connID == "default" {
if setting.Global == nil {
return cp
}
roles, _ := setting.Global.GetMerged(info.UserID, info.TeamID, llmprovider.RolesNamespace)
if roles == nil {
return cp
}
pk, mid := parseRoleTarget(roles["default"])
if pk == "" {
return cp
}
connID = pk
if mid != "" {
connID = pk + "::" + mid
}
}
parts := strings.SplitN(connID, "::", 2)
if llmprovider.Global == nil {
return cp
}
p, err := llmprovider.Global.Get(parts[0])
if err != nil || p == nil || !p.Enabled {
return cp
}
cp.Status = "pass"
return cp
}
func checkAssistantSandbox(ast *assistant.Assistant, info *oauthTypes.AuthorizedInfo, locale string, isCN bool) Checkpoint {
cp := Checkpoint{
Required: true,
Label: "Sandbox Ready",
Path: "/settings/sandbox",
Status: "fail",
}
if isCN {
cp.Label = "沙箱就绪"
}
imageRef := ""
if ast.SandboxV2 != nil && ast.SandboxV2.Computer.Image != "" {
imageRef = ast.SandboxV2.Computer.Image
}
if imageRef == "" {
cp.Status = "pass"
return cp
}
reg := registry.Global()
if reg == nil {
if isCN {
cp.Detail = "沙箱节点未配置"
} else {
cp.Detail = "No sandbox node configured"
}
return cp
}
dockerNodeFound := false
for _, snap := range reg.List() {
if snap.Mode != "local" && !sandboxNodeOwnedBy(&snap, info) {
continue
}
if snap.Status != "online" || !snap.Capabilities.Docker {
continue
}
dockerNodeFound = true
res, ok := tai.GetResources(snap.TaiID)
if !ok || res.Image == nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
images, err := res.Image.List(ctx)
cancel()
if err != nil {
continue
}
for _, img := range images {
for _, tag := range img.Tags {
if tag == imageRef {
cp.Status = "pass"
return cp
}
}
}
}
if !dockerNodeFound {
if isCN {
cp.Detail = "Docker 未安装或节点离线"
} else {
cp.Detail = "Docker not installed or node offline"
}
} else {
if isCN {
cp.Detail = "镜像未下载"
} else {
cp.Detail = "Image not downloaded"
}
}
return cp
}
func checkAssistantSearch(ast *assistant.Assistant, info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint {
cp := Checkpoint{
Required: false,
Label: "Search",
Path: "/settings/search",
Status: "fail",
}
if isCN {
cp.Label = "搜索"
}
if setting.Global == nil {
return cp
}
// Check cloud search
cloudSaved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, cloudNS)
if cloudSaved != nil {
if v, ok := cloudSaved["status"].(string); ok && v == "connected" {
cp.Status = "pass"
return cp
}
}
// Check any standalone search provider
for _, preset := range searchPresets {
if preset.IsCloud {
continue
}
saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, searchProviderNS(preset.Key))
if saved != nil {
if v, ok := saved["status"].(string); ok && v == "connected" {
cp.Status = "pass"
return cp
}
}
}
return cp
}
func hasCapability(caps []string, target string) bool {
for _, c := range caps {
if c == target {
return true
}
}
return false
}

View file

@ -219,3 +219,39 @@ type SandboxPageData struct {
Registry SandboxRegistryConfig `json:"registry"` Registry SandboxRegistryConfig `json:"registry"`
Images map[string][]SandboxImage `json:"images"` Images map[string][]SandboxImage `json:"images"`
} }
// ---------------------------------------------------------------------------
// Setup Status
// ---------------------------------------------------------------------------
type Checkpoint struct {
Status string `json:"status"`
Required bool `json:"required"`
Label string `json:"label"`
Path string `json:"path"`
Detail string `json:"detail,omitempty"`
}
type SetupStatus struct {
Completed bool `json:"completed"`
Checkpoints map[string]Checkpoint `json:"checkpoints"`
OnboardingCompleted bool `json:"onboarding_completed"`
BannerDismissed bool `json:"banner_dismissed"`
}
type AssistantSetupStatus struct {
AssistantID string `json:"assistant_id"`
AssistantName string `json:"assistant_name"`
Ready bool `json:"ready"`
Checkpoints map[string]Checkpoint `json:"checkpoints"`
}
// ---------------------------------------------------------------------------
// User Preference
// ---------------------------------------------------------------------------
type PreferenceData struct {
EmailNotification *bool `json:"email_notification,omitempty"`
BannerDismissed *bool `json:"banner_dismissed,omitempty"`
OnboardingCompleted *bool `json:"onboarding_completed,omitempty"`
}

View file

@ -90,19 +90,21 @@ func llmBody(t *testing.T, resp *http.Response) map[string]interface{} {
return body return body
} }
func createTestOpenAI(t *testing.T, serverURL, token string) { func createTestOpenAI(t *testing.T, serverURL, token string) string {
t.Helper() t.Helper()
apiKey := requireOpenAIKey(t) apiKey := requireOpenAIKey(t)
llmprovider.Global.Delete("openai")
payload := map[string]interface{}{ payload := map[string]interface{}{
"preset_key": "openai", "preset_key": "openai",
"api_key": apiKey, "api_key": apiKey,
"model_ids": []string{"gpt-4o", "gpt-4o-mini"}, "model_ids": []string{"gpt-4o", "gpt-4o-mini"},
} }
resp := llmPost(t, llmURL(serverURL, "/providers"), token, payload) resp := llmPost(t, llmURL(serverURL, "/providers"), token, payload)
resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode, "createTestOpenAI should succeed") assert.Equal(t, http.StatusCreated, resp.StatusCode, "createTestOpenAI should succeed")
t.Cleanup(func() { llmprovider.Global.Delete("openai") }) body := llmBody(t, resp)
scopedKey, _ := body["key"].(string)
t.Cleanup(func() { llmprovider.Global.Delete(scopedKey) })
return scopedKey
} }
// ----------- Functional tests ----------- // ----------- Functional tests -----------
@ -141,7 +143,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) {
@ -162,7 +164,6 @@ func TestLLMProviderCreate(t *testing.T) {
initSettingRegistry(t) initSettingRegistry(t)
initLLMRegistry(t) initLLMRegistry(t)
token := obtainToken(t, serverURL) token := obtainToken(t, serverURL)
llmprovider.Global.Delete("openai")
payload := map[string]interface{}{ payload := map[string]interface{}{
"preset_key": "openai", "preset_key": "openai",
@ -172,10 +173,12 @@ func TestLLMProviderCreate(t *testing.T) {
resp := llmPost(t, llmURL(serverURL, "/providers"), token, payload) resp := llmPost(t, llmURL(serverURL, "/providers"), token, payload)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, http.StatusCreated, resp.StatusCode)
t.Cleanup(func() { llmprovider.Global.Delete("openai") })
body := llmBody(t, resp) body := llmBody(t, resp)
assert.Equal(t, "openai", body["key"]) scopedKey, _ := body["key"].(string)
t.Cleanup(func() { llmprovider.Global.Delete(scopedKey) })
assert.Contains(t, scopedKey, ".openai", "scoped key should end with .openai")
assert.Equal(t, "OpenAI", body["name"]) assert.Equal(t, "OpenAI", body["name"])
assert.Equal(t, "openai", body["type"]) assert.Equal(t, "openai", body["type"])
@ -199,7 +202,6 @@ func TestLLMProviderCreateCustom(t *testing.T) {
initSettingRegistry(t) initSettingRegistry(t)
initLLMRegistry(t) initLLMRegistry(t)
token := obtainToken(t, serverURL) token := obtainToken(t, serverURL)
llmprovider.Global.Delete("my-custom-llm")
payload := map[string]interface{}{ payload := map[string]interface{}{
"key": "my-custom-llm", "key": "my-custom-llm",
@ -215,10 +217,12 @@ func TestLLMProviderCreateCustom(t *testing.T) {
resp := llmPost(t, llmURL(serverURL, "/providers"), token, payload) resp := llmPost(t, llmURL(serverURL, "/providers"), token, payload)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, http.StatusCreated, resp.StatusCode)
t.Cleanup(func() { llmprovider.Global.Delete("my-custom-llm") })
body := llmBody(t, resp) body := llmBody(t, resp)
assert.Equal(t, "my-custom-llm", body["key"]) scopedKey, _ := body["key"].(string)
t.Cleanup(func() { llmprovider.Global.Delete(scopedKey) })
assert.Contains(t, scopedKey, ".my-custom-llm", "scoped key should end with .my-custom-llm")
assert.Equal(t, "My Custom LLM", body["name"]) assert.Equal(t, "My Custom LLM", body["name"])
assert.Equal(t, true, body["is_custom"]) assert.Equal(t, true, body["is_custom"])
@ -233,7 +237,7 @@ func TestLLMProviderUpdate(t *testing.T) {
initLLMRegistry(t) initLLMRegistry(t)
token := obtainToken(t, serverURL) token := obtainToken(t, serverURL)
createTestOpenAI(t, serverURL, token) scopedKey := createTestOpenAI(t, serverURL, token)
updatePayload := map[string]interface{}{ updatePayload := map[string]interface{}{
"name": "Updated OpenAI", "name": "Updated OpenAI",
@ -242,7 +246,7 @@ func TestLLMProviderUpdate(t *testing.T) {
{"id": "gpt-4o", "name": "GPT-4o Updated", "capabilities": []string{"vision", "tool_calls"}, "enabled": true}, {"id": "gpt-4o", "name": "GPT-4o Updated", "capabilities": []string{"vision", "tool_calls"}, "enabled": true},
}, },
} }
resp := llmPut(t, llmURL(serverURL, "/providers/openai"), token, updatePayload) resp := llmPut(t, llmURL(serverURL, "/providers/"+scopedKey), token, updatePayload)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@ -265,27 +269,28 @@ func TestLLMProviderDelete(t *testing.T) {
initSettingRegistry(t) initSettingRegistry(t)
initLLMRegistry(t) initLLMRegistry(t)
token := obtainToken(t, serverURL) token := obtainToken(t, serverURL)
llmprovider.Global.Delete("anthropic")
createPayload := map[string]interface{}{ createPayload := map[string]interface{}{
"preset_key": "anthropic", "preset_key": "anthropic",
"api_key": anthropicKey, "api_key": anthropicKey,
} }
createResp := llmPost(t, llmURL(serverURL, "/providers"), token, createPayload) createResp := llmPost(t, llmURL(serverURL, "/providers"), token, createPayload)
createBody := llmBody(t, createResp)
createResp.Body.Close() createResp.Body.Close()
assert.Equal(t, http.StatusCreated, createResp.StatusCode) assert.Equal(t, http.StatusCreated, createResp.StatusCode)
scopedKey, _ := createBody["key"].(string)
rolesPayload := map[string]interface{}{ rolesPayload := map[string]interface{}{
"default": map[string]interface{}{ "default": map[string]interface{}{
"provider": "anthropic", "provider": scopedKey,
"model": "claude-sonnet-4-20250514", "model": "claude-sonnet-4-6",
}, },
} }
rolesResp := llmPut(t, llmURL(serverURL, "/roles"), token, rolesPayload) rolesResp := llmPut(t, llmURL(serverURL, "/roles"), token, rolesPayload)
rolesResp.Body.Close() rolesResp.Body.Close()
assert.Equal(t, http.StatusOK, rolesResp.StatusCode) assert.Equal(t, http.StatusOK, rolesResp.StatusCode)
deleteResp := llmDelete(t, llmURL(serverURL, "/providers/anthropic"), token) deleteResp := llmDelete(t, llmURL(serverURL, "/providers/"+scopedKey), token)
defer deleteResp.Body.Close() defer deleteResp.Body.Close()
assert.Equal(t, http.StatusOK, deleteResp.StatusCode) assert.Equal(t, http.StatusOK, deleteResp.StatusCode)
@ -307,21 +312,22 @@ func TestLLMProviderDeleteForbidden(t *testing.T) {
initLLMRegistry(t) initLLMRegistry(t)
token := obtainToken(t, serverURL) token := obtainToken(t, serverURL)
llmprovider.Global.Delete("other-team-provider") otherOwner := llmprovider.ProviderOwner{Type: "user", UserID: "some-other-user-999"}
scopedKey := llmprovider.ScopedKey(&otherOwner, "other-team-provider")
otherProvider := &llmprovider.Provider{ otherProvider := &llmprovider.Provider{
Key: "other-team-provider", Key: scopedKey,
Name: "Other Team's Provider", Name: "Other Team's Provider",
Type: "openai", Type: "openai",
APIURL: "https://api.example.com", APIURL: "https://api.example.com",
Models: []llmprovider.ModelInfo{}, Models: []llmprovider.ModelInfo{},
Enabled: true, Enabled: true,
Source: llmprovider.ProviderSourceDynamic, Source: llmprovider.ProviderSourceDynamic,
Owner: llmprovider.ProviderOwner{Type: "user", UserID: "some-other-user-999"}, Owner: otherOwner,
} }
llmprovider.Global.Create(otherProvider) llmprovider.Global.Create(otherProvider)
t.Cleanup(func() { llmprovider.Global.Delete("other-team-provider") }) t.Cleanup(func() { llmprovider.Global.Delete(scopedKey) })
resp := llmDelete(t, llmURL(serverURL, "/providers/other-team-provider"), token) resp := llmDelete(t, llmURL(serverURL, "/providers/"+scopedKey), token)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "should not be able to delete another user's provider") assert.Equal(t, http.StatusNotFound, resp.StatusCode, "should not be able to delete another user's provider")
} }
@ -333,9 +339,9 @@ func TestLLMProviderTest(t *testing.T) {
initLLMRegistry(t) initLLMRegistry(t)
token := obtainToken(t, serverURL) token := obtainToken(t, serverURL)
createTestOpenAI(t, serverURL, token) scopedKey := createTestOpenAI(t, serverURL, token)
resp := llmPost(t, llmURL(serverURL, "/providers/openai/test"), token, nil) resp := llmPost(t, llmURL(serverURL, "/providers/"+scopedKey+"/test"), token, nil)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
@ -351,15 +357,15 @@ func TestLLMRoles(t *testing.T) {
initSettingRegistry(t) initSettingRegistry(t)
initLLMRegistry(t) initLLMRegistry(t)
token := obtainToken(t, serverURL) token := obtainToken(t, serverURL)
createTestOpenAI(t, serverURL, token) scopedKey := createTestOpenAI(t, serverURL, token)
rolesPayload := map[string]interface{}{ rolesPayload := map[string]interface{}{
"default": map[string]interface{}{ "default": map[string]interface{}{
"provider": "openai", "provider": scopedKey,
"model": "gpt-4o", "model": "gpt-4o",
}, },
"vision": map[string]interface{}{ "vision": map[string]interface{}{
"provider": "openai", "provider": scopedKey,
"model": "gpt-4o", "model": "gpt-4o",
}, },
} }
@ -404,11 +410,11 @@ func TestLLMRolesValidation(t *testing.T) {
defer resp2.Body.Close() defer resp2.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp2.StatusCode, "should reject non-existent provider") assert.Equal(t, http.StatusBadRequest, resp2.StatusCode, "should reject non-existent provider")
createTestOpenAI(t, serverURL, token) scopedKey := createTestOpenAI(t, serverURL, token)
resp3 := llmPut(t, llmURL(serverURL, "/roles"), token, map[string]interface{}{ resp3 := llmPut(t, llmURL(serverURL, "/roles"), token, map[string]interface{}{
"default": map[string]interface{}{ "default": map[string]interface{}{
"provider": "openai", "provider": scopedKey,
"model": "nonexistent-model", "model": "nonexistent-model",
}, },
}) })

View file

@ -21,6 +21,7 @@ import (
messengertypes "github.com/yaoapp/yao/messenger/types" messengertypes "github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/authorized"
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response" "github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/openapi/utils" "github.com/yaoapp/yao/openapi/utils"
"github.com/yaoapp/yao/share" "github.com/yaoapp/yao/share"
@ -1221,11 +1222,9 @@ func teamInvitationCreate(ctx context.Context, userID, teamID string, invitation
// Use the saved requestBaseURL and settings (not from invitationData, as they were lost in DB operation) // Use the saved requestBaseURL and settings (not from invitationData, as they were lost in DB operation)
// Send email asynchronously to improve user experience // Send email asynchronously to improve user experience
go func() { go func() {
// Use background context for async operation
bgCtx := context.Background() bgCtx := context.Background()
bgCtx = context.WithValue(bgCtx, "identity", &oauthTypes.AuthorizedInfo{UserID: userID, TeamID: teamID})
// Use createdMember data (from database) for email sending
// This ensures we have the actual stored values including properly formatted timestamps
emailData := maps.MapStrAny{} emailData := maps.MapStrAny{}
for k, v := range createdMember { for k, v := range createdMember {
emailData[k] = v emailData[k] = v
@ -1364,8 +1363,8 @@ func teamInvitationResend(ctx context.Context, userID, teamID, invitationID, req
// Send new invitation email (asynchronously) // Send new invitation email (asynchronously)
go func() { go func() {
// Use background context for async operation
bgCtx := context.Background() bgCtx := context.Background()
bgCtx = context.WithValue(bgCtx, "identity", &oauthTypes.AuthorizedInfo{UserID: userID, TeamID: teamID})
err := sendTeamInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, newToken, invitationID, invitationData) err := sendTeamInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, newToken, invitationID, invitationData)
if err != nil { if err != nil {
log.Error("Failed to resend invitation email: %v", err) log.Error("Failed to resend invitation email: %v", err)

58
setting/crypto.go Normal file
View file

@ -0,0 +1,58 @@
package setting
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"io"
"strings"
"github.com/yaoapp/yao/config"
)
const encPrefix = "enc:"
// Encrypt encrypts a plaintext string using AES-256-GCM with the configured AES key.
// Returns the original string if no AES key is configured.
func Encrypt(plaintext string) string {
secret := config.Conf.DB.AESKey
if secret == "" {
return plaintext
}
enc, err := aesGCMEncrypt(plaintext, secret)
if err != nil {
return plaintext
}
return encPrefix + enc
}
// Decrypt decrypts a value previously encrypted by Encrypt.
// Returns the original string if not encrypted or if decryption fails.
func Decrypt(value string) string {
return config.DecryptValue(value)
}
// IsEncrypted returns true if the value has the encryption prefix.
func IsEncrypted(value string) bool {
return strings.HasPrefix(value, encPrefix)
}
func aesGCMEncrypt(plaintext, secret string) (string, error) {
h := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(h[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}

8
setting/identity.go Normal file
View file

@ -0,0 +1,8 @@
package setting
// Identity represents a scoped caller for setting resolution.
// GetMerged(userID, teamID, ns) uses these to cascade: system <- team <- user.
type Identity interface {
GetUserID() string
GetTeamID() string
}

54
tools/docs/docs.go Normal file
View file

@ -0,0 +1,54 @@
package docs
import (
_ "embed"
goudoc "github.com/yaoapp/gou/doc"
"github.com/yaoapp/gou/process"
)
//go:embed list.json
var ListSchemaJSON []byte
//go:embed inspect.json
var InspectSchemaJSON []byte
//go:embed validate.json
var ValidateSchemaJSON []byte
// ListHandler is the tools.doc_list process handler.
// Args[0]: keyword (string, optional — empty lists all)
// Args[1]: limit (int, default 20)
func ListHandler(proc *process.Process) interface{} {
keyword := proc.ArgsString(0)
limit := proc.ArgsInt(1, 20)
var results []*goudoc.Entry
if keyword != "" {
results = goudoc.List(goudoc.TypeProcess, goudoc.ListOption{Search: keyword})
} else {
results = goudoc.List(goudoc.TypeProcess)
}
if len(results) > limit {
results = results[:limit]
}
return results
}
// InspectHandler is the tools.doc_inspect process handler.
// Args[0]: name (string — process name, e.g. "models.user.Find")
func InspectHandler(proc *process.Process) interface{} {
name := proc.ArgsString(0)
entry, ok := goudoc.Get(goudoc.TypeProcess, name)
if !ok {
return nil
}
return entry
}
// ValidateHandler is the tools.doc_validate process handler.
// Args[0]: name (string — process name)
func ValidateHandler(proc *process.Process) interface{} {
name := proc.ArgsString(0)
return goudoc.Validate(goudoc.TypeProcess, name)
}

Some files were not shown because too many files have changed in this diff Show more