diff --git a/apiclient/types/modelprovider.go b/apiclient/types/modelprovider.go index 0c58695662..29f093cd89 100644 --- a/apiclient/types/modelprovider.go +++ b/apiclient/types/modelprovider.go @@ -5,6 +5,9 @@ type CommonProviderMetadata struct { IconDark string `json:"iconDark,omitempty"` Description string `json:"description,omitempty"` Link string `json:"link,omitempty"` + // Dialect specifies the LLM API format used by this provider + // (e.g. "AnthropicMessages", "OpenAIChatCompletions", "OpenAIResponses"). + Dialect string `json:"dialect,omitempty"` } type CommonProviderStatus struct { diff --git a/go.mod b/go.mod index dbab0e8f6c..f3da8f693f 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ replace ( github.com/obot-platform/obot/logger => ./logger ) +replace github.com/nanobot-ai/nanobot => github.com/calvinmclean/nanobot v0.0.0-20260408174919-d7f157a83d0c + require ( cloud.google.com/go/storage v1.43.0 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 @@ -58,7 +60,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.40.0 golang.org/x/crypto v0.47.0 golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 - golang.org/x/mod v0.31.0 + golang.org/x/mod v0.32.0 golang.org/x/oauth2 v0.34.0 golang.org/x/sync v0.19.0 golang.org/x/term v0.39.0 @@ -254,7 +256,7 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pkoukk/tiktoken-go v0.1.7 // indirect + github.com/pkoukk/tiktoken-go v0.1.8 // indirect github.com/pkoukk/tiktoken-go-loader v0.0.2-0.20240522064338-c17e8bc0f699 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -301,9 +303,9 @@ require ( go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/tools v0.41.0 // indirect google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect diff --git a/go.sum b/go.sum index 1a317d98b1..d1fc7cb8c9 100644 --- a/go.sum +++ b/go.sum @@ -164,6 +164,8 @@ github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/bombsimon/logrusr/v4 v4.1.0 h1:uZNPbwusB0eUXlO8hIUwStE6Lr5bLN6IgYgG+75kuh4= github.com/bombsimon/logrusr/v4 v4.1.0/go.mod h1:pjfHC5e59CvjTBIU3V3sGhFWFAnsnhOR03TRc6im0l8= +github.com/calvinmclean/nanobot v0.0.0-20260408174919-d7f157a83d0c h1:0XulKTIDELtp3KS+nIqub+rOJ5bve1LOhFewzE218Gg= +github.com/calvinmclean/nanobot v0.0.0-20260408174919-d7f157a83d0c/go.mod h1:6Yi07gQdKON69TMEIVIkPjuNL7R+Iyy6kJ3CUck5Qeg= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -546,8 +548,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nanobot-ai/nanobot v0.0.54 h1:+VROiTn0XhgwOsp8bpKioFdtSsp91yt00Ni22TPaqYY= -github.com/nanobot-ai/nanobot v0.0.54/go.mod h1:Es0FimWKLR9KtK2qi1MU7GfAD/g6PLQjbkAv/3PnzBc= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nightlyone/lockfile v1.0.0 h1:RHep2cFKK4PonZJDdEl4GmkabuhbsRMgk/k3uAmxBiA= @@ -588,8 +588,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw= -github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= +github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo= +github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pkoukk/tiktoken-go-loader v0.0.2-0.20240522064338-c17e8bc0f699 h1:Sp8yiuxsitkmCfEvUnmNf8wzuZwlGNkRjI2yF0C3QUQ= github.com/pkoukk/tiktoken-go-loader v0.0.2-0.20240522064338-c17e8bc0f699/go.mod h1:4mIkYyZooFlnenDlormIo6cd5wrlUKNr97wp9nGgEKo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -840,8 +840,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -932,8 +932,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= @@ -966,8 +966,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= diff --git a/pkg/controller/handlers/nanobotagent/nanobotagent.go b/pkg/controller/handlers/nanobotagent/nanobotagent.go index 71e34ec646..28d670d1e8 100644 --- a/pkg/controller/handlers/nanobotagent/nanobotagent.go +++ b/pkg/controller/handlers/nanobotagent/nanobotagent.go @@ -10,7 +10,10 @@ import ( "strings" "time" + "encoding/json" + "github.com/gptscript-ai/go-gptscript" + nanobottypes "github.com/nanobot-ai/nanobot/pkg/types" "github.com/obot-platform/nah/pkg/backend" "github.com/obot-platform/nah/pkg/name" "github.com/obot-platform/nah/pkg/router" @@ -27,6 +30,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kclient "sigs.k8s.io/controller-runtime/pkg/client" + sigsyaml "sigs.k8s.io/yaml" ) const ( @@ -110,6 +114,17 @@ func (h *Handler) EnsureMCPServer(req router.Request, resp router.Response) erro File: true, DynamicFile: true, }, + { + MCPHeader: types.MCPHeader{ + Name: "NANOBOT_PROVIDER_CONFIG", + Description: "Provider config YAML for Nanobot", + Key: "NANOBOT_PROVIDER_CONFIG", + Sensitive: true, + Required: true, + }, + File: true, + DynamicFile: true, + }, } currentArgs := existing.Spec.Manifest.ContainerizedConfig.Args @@ -124,7 +139,7 @@ func (h *Handler) EnsureMCPServer(req router.Request, resp router.Response) erro } } - if len(existing.Spec.Manifest.Env) != len(expectedEnv) || existing.Spec.Manifest.Env[0] != expectedEnv[0] { + if !slices.Equal(existing.Spec.Manifest.Env, expectedEnv) { needsUpdate = true } @@ -149,7 +164,7 @@ func (h *Handler) EnsureMCPServer(req router.Request, resp router.Response) erro } // Create new MCPServer - args := []string{"run"} + args := []string{"run", "--state", ".nanobot/state/nanobot.db"} if agent.Spec.DefaultAgent != "" { args = append(args, "--agent", agent.Spec.DefaultAgent) } @@ -186,6 +201,17 @@ func (h *Handler) EnsureMCPServer(req router.Request, resp router.Response) erro File: true, DynamicFile: true, }, + { + MCPHeader: types.MCPHeader{ + Name: "NANOBOT_PROVIDER_CONFIG", + Description: "Provider config YAML for Nanobot", + Key: "NANOBOT_PROVIDER_CONFIG", + Sensitive: true, + Required: true, + }, + File: true, + DynamicFile: true, + }, }, }, }, @@ -222,8 +248,13 @@ func (h *Handler) ensureCredentials(ctx context.Context, req router.Request, res needsRefresh = true log.Debugf("Nanobot credential missing, creating: agent=%s mcpServer=%s", agent.Name, mcpServerName) } else { - // Credential exists, check if token needs refreshing - if token := credEnvFileVars["OPENAI_API_KEY"]; token != "" { + // Credential exists, check if token needs refreshing. + // Look for the token in any known provider API key env var. + token := credEnvFileVars["OPENAI_API_KEY"] + if token == "" { + token = credEnvFileVars["ANTHROPIC_API_KEY"] + } + if token != "" { tokenCtx, err := h.tokenService.DecodeToken(ctx, token) if err != nil { // Token is invalid, needs refresh @@ -256,12 +287,18 @@ func (h *Handler) ensureCredentials(ctx context.Context, req router.Request, res return err } - if !needsRefresh && credEnvFileVars["NANOBOT_DEFAULT_MODEL"] == llmModel && credEnvFileVars["NANOBOT_DEFAULT_MINI_MODEL"] == miniModel { + llmProvider, llmDefault := h.nanobotProviderFor(llmModel) + miniProvider, miniDefault := h.nanobotProviderFor(miniModel) + + if !needsRefresh && + credEnvFileVars["NANOBOT_DEFAULT_MODEL"] == llmDefault && + credEnvFileVars["NANOBOT_DEFAULT_MINI_MODEL"] == miniDefault && + cred.Env["NANOBOT_PROVIDER_CONFIG"] != "" { // Credentials are up to date return nil } - log.Debugf("Refreshing nanobot credentials: agent=%s mcpServer=%s model=%s miniModel=%s", agent.Name, mcpServerName, llmModel, miniModel) + log.Debugf("Refreshing nanobot credentials: agent=%s mcpServer=%s model=%s miniModel=%s", agent.Name, mcpServerName, llmDefault, miniDefault) // Generate a new token that expires in 12 hours now := time.Now() @@ -313,26 +350,42 @@ func (h *Handler) ensureCredentials(ctx context.Context, req router.Request, res return fmt.Errorf("failed to create API key: %w", err) } - // Create or update the credential with the new token and API key + // Build provider config YAML and env file with only the providers in use. + providerYAML, err := buildNanobotProviderConfigYAML(llmProvider, miniProvider) + if err != nil { + return fmt.Errorf("failed to build nanobot provider config: %w", err) + } + + envFileLines := []string{ + fmt.Sprintf("OBOT_URL=%s", h.serverURL), + } + seenProviders := map[string]bool{} + for _, p := range []nanobotLLMProvider{llmProvider, miniProvider} { + if seenProviders[p.Name] { + continue + } + seenProviders[p.Name] = true + envVarName := strings.TrimSuffix(strings.TrimPrefix(p.APIKey, "${"), "}") + envFileLines = append(envFileLines, fmt.Sprintf("%s=%s", envVarName, token)) + } + envFileLines = append(envFileLines, + fmt.Sprintf("MCP_API_KEY=%s", apiKeyResp.Key), + fmt.Sprintf("MCP_API_KEY_ID=%d", apiKeyResp.ID), + fmt.Sprintf("MCP_API_KEY_ID_PREV=%s", credEnvFileVars["MCP_API_KEY_ID"]), + fmt.Sprintf("MCP_SERVER_SEARCH_URL=%s", system.MCPConnectURL(h.serverURL, system.ObotMCPServerName)), + fmt.Sprintf("MCP_SERVER_SEARCH_API_KEY=%s", apiKeyResp.Key), + fmt.Sprintf("NANOBOT_DEFAULT_MODEL=%s", llmDefault), + fmt.Sprintf("NANOBOT_DEFAULT_MINI_MODEL=%s", miniDefault), + ) + + // Create or update the credential with the new token, API key, and provider config. if err := h.gptClient.CreateCredential(ctx, gptscript.Credential{ Context: credCtx, ToolName: mcpServerName, Type: gptscript.CredentialTypeTool, Env: map[string]string{ - "NANOBOT_ENV_FILE": strings.Join([]string{ - fmt.Sprintf("OBOT_URL=%s", h.serverURL), - fmt.Sprintf("ANTHROPIC_BASE_URL=%s/api/llm-proxy/anthropic", h.serverURL), - fmt.Sprintf("OPENAI_BASE_URL=%s/api/llm-proxy/openai", h.serverURL), - fmt.Sprintf("ANTHROPIC_API_KEY=%s", token), - fmt.Sprintf("OPENAI_API_KEY=%s", token), - fmt.Sprintf("MCP_API_KEY=%s", apiKeyResp.Key), - fmt.Sprintf("MCP_API_KEY_ID=%d", apiKeyResp.ID), - fmt.Sprintf("MCP_API_KEY_ID_PREV=%s", credEnvFileVars["MCP_API_KEY_ID"]), - fmt.Sprintf("MCP_SERVER_SEARCH_URL=%s", system.MCPConnectURL(h.serverURL, system.ObotMCPServerName)), - fmt.Sprintf("MCP_SERVER_SEARCH_API_KEY=%s", apiKeyResp.Key), - fmt.Sprintf("NANOBOT_DEFAULT_MODEL=%s", llmModel), - fmt.Sprintf("NANOBOT_DEFAULT_MINI_MODEL=%s", miniModel), - }, "\n"), + "NANOBOT_ENV_FILE": strings.Join(envFileLines, "\n"), + "NANOBOT_PROVIDER_CONFIG": providerYAML, }, }); err != nil { return fmt.Errorf("failed to create credential: %w", err) @@ -355,26 +408,130 @@ func (h *Handler) ensureCredentials(ctx context.Context, req router.Request, res return nil } -func getModelForAlias(ctx context.Context, client kclient.Client, namespace string, aliasName types.DefaultModelAliasType) (string, error) { +// resolvedLLMModel pairs the resolved target model name with its configured provider reference +// and the dialect declared by that provider (if any). +type resolvedLLMModel struct { + TargetModel string + ModelProvider string // e.g. "openai-model-provider", "anthropic-model-provider" + ProviderDialect nanobottypes.Dialect // from ProviderMeta.Dialect; empty if not declared +} + +// nanobotLLMProvider describes how a single LLM provider should be configured in nanobot's YAML. +type nanobotLLMProvider struct { + Name string // key in llmProviders map (e.g. "openai", "anthropic") + Dialect nanobottypes.Dialect + APIKey string // env var reference, e.g. "${ANTHROPIC_API_KEY}" + BaseURL string // actual Obot proxy URL +} + +// nanobotProviderFor returns the nanobot provider config and the fully-qualified +// model name (provider/model) for a resolved model. +// +// If the provider has declared a dialect via ProviderMeta.Dialect, that dialect +// is used and the base URL is derived from it. Otherwise the known built-in +// providers (openai, anthropic) supply both; everything else falls back to +// OpenResponses via the generic /api/llm-proxy dispatch. +func (h *Handler) nanobotProviderFor(model resolvedLLMModel) (nanobotLLMProvider, string) { + name := model.ModelProvider + envVarName := strings.ToUpper(strings.ReplaceAll(name, "-", "_")) + "_API_KEY" + + dialect := model.ProviderDialect + if dialect == "" { + // No declared dialect — fall back to per-provider defaults. + switch model.ModelProvider { + case system.AnthropicModelProviderTool: + dialect = nanobottypes.DialectAnthropicMessages + case system.OpenAIModelProviderTool: + dialect = nanobottypes.DialectOpenAIResponses + default: + dialect = nanobottypes.DialectOpenResponses + } + } + + var baseURL string + switch dialect { + case nanobottypes.DialectAnthropicMessages: + baseURL = h.serverURL + "/api/llm-proxy/anthropic" + case nanobottypes.DialectOpenAIResponses: + baseURL = h.serverURL + "/api/llm-proxy/openai" + default: + baseURL = h.serverURL + "/api/llm-proxy" + } + + p := nanobotLLMProvider{ + Name: name, + Dialect: dialect, + APIKey: fmt.Sprintf("${%s}", envVarName), + BaseURL: baseURL, + } + return p, fmt.Sprintf("%s/%s", p.Name, model.TargetModel) +} + +// buildNanobotProviderConfigYAML generates a nanobot Config YAML containing only the +// providers required by the given LLM and mini-LLM models. +func buildNanobotProviderConfigYAML(providers ...nanobotLLMProvider) (string, error) { + llmProviders := make(map[string]nanobottypes.LLMProvider, len(providers)) + for _, p := range providers { + if _, exists := llmProviders[p.Name]; exists { + continue + } + llmProviders[p.Name] = nanobottypes.LLMProvider{ + Dialect: p.Dialect, + APIKey: p.APIKey, + BaseURL: p.BaseURL, + } + } + data, err := sigsyaml.Marshal(nanobottypes.Config{LLMProviders: llmProviders}) + if err != nil { + return "", err + } + return string(data), nil +} + +// lookupProviderDialect reads the dialect declared in a model provider ToolReference's +// providerMeta tool metadata. Returns an empty string if not declared or on any error. +func lookupProviderDialect(ctx context.Context, client kclient.Client, namespace, modelProvider string) nanobottypes.Dialect { + if client == nil || modelProvider == "" { + return "" + } + var toolRef v1.ToolReference + if err := client.Get(ctx, kclient.ObjectKey{Namespace: namespace, Name: modelProvider}, &toolRef); err != nil { + return "" + } + if toolRef.Status.Tool == nil || toolRef.Status.Tool.Metadata["providerMeta"] == "" { + return "" + } + var meta types.CommonProviderMetadata + if err := json.Unmarshal([]byte(toolRef.Status.Tool.Metadata["providerMeta"]), &meta); err != nil { + return "" + } + return nanobottypes.Dialect(meta.Dialect) +} + +func getModelForAlias(ctx context.Context, client kclient.Client, namespace string, aliasName types.DefaultModelAliasType) (resolvedLLMModel, error) { llmModel, err := alias.GetFromScope(ctx, client, "Model", namespace, string(aliasName)) if err != nil { - return "", fmt.Errorf("failed to get default model alias %v: %w", aliasName, err) + return resolvedLLMModel{}, fmt.Errorf("failed to get default model alias %v: %w", aliasName, err) } modelAlias, ok := llmModel.(*v1.DefaultModelAlias) if !ok { - return "", fmt.Errorf("alias %v is not of type Alias", aliasName) + return resolvedLLMModel{}, fmt.Errorf("alias %v is not of type Alias", aliasName) } var model v1.Model if err := alias.Get(ctx, client, &model, namespace, modelAlias.Spec.Manifest.Model); err != nil { - return "", err + return resolvedLLMModel{}, err } - return model.Spec.Manifest.TargetModel, nil + return resolvedLLMModel{ + TargetModel: model.Spec.Manifest.TargetModel, + ModelProvider: model.Spec.Manifest.ModelProvider, + ProviderDialect: lookupProviderDialect(ctx, client, namespace, model.Spec.Manifest.ModelProvider), + }, nil } -// resolveModel returns a concrete model name for a default alias. +// resolveModel returns a resolved model and its provider for a default alias. // // It prefers an explicitly configured alias target when one exists. If the // alias is unset or cannot be resolved, it falls back to active LLM models in @@ -382,14 +539,14 @@ func getModelForAlias(ctx context.Context, client kclient.Client, namespace stri // for that alias. The llm-mini alias falls back to the resolved llm model when // no preferred mini model is available. All other aliases fall back to the // first active LLM model available. -func resolveModel(ctx context.Context, client kclient.Client, namespace string, aliasName types.DefaultModelAliasType) (string, error) { - if model, err := getModelForAlias(ctx, client, namespace, aliasName); err == nil && strings.TrimSpace(model) != "" { +func resolveModel(ctx context.Context, client kclient.Client, namespace string, aliasName types.DefaultModelAliasType) (resolvedLLMModel, error) { + if model, err := getModelForAlias(ctx, client, namespace, aliasName); err == nil && strings.TrimSpace(model.TargetModel) != "" { return model, nil } models, err := listActiveLLMModels(ctx, client, namespace) if err != nil { - return "", err + return resolvedLLMModel{}, err } return chooseModel(ctx, client, namespace, models, aliasName) @@ -422,12 +579,16 @@ func listActiveLLMModels(ctx context.Context, client kclient.Client, namespace s return result, nil } -func chooseModel(ctx context.Context, client kclient.Client, namespace string, models []v1.Model, aliasName types.DefaultModelAliasType) (string, error) { +func chooseModel(ctx context.Context, client kclient.Client, namespace string, models []v1.Model, aliasName types.DefaultModelAliasType) (resolvedLLMModel, error) { preferred := preferredModelsForAlias(aliasName) for _, preferredName := range preferred { for _, model := range models { if model.Spec.Manifest.TargetModel == preferredName || model.Spec.Manifest.Name == preferredName { - return model.Spec.Manifest.TargetModel, nil + return resolvedLLMModel{ + TargetModel: model.Spec.Manifest.TargetModel, + ModelProvider: model.Spec.Manifest.ModelProvider, + ProviderDialect: lookupProviderDialect(ctx, client, namespace, model.Spec.Manifest.ModelProvider), + }, nil } } } @@ -437,10 +598,14 @@ func chooseModel(ctx context.Context, client kclient.Client, namespace string, m } if len(models) > 0 { - return models[0].Spec.Manifest.TargetModel, nil + return resolvedLLMModel{ + TargetModel: models[0].Spec.Manifest.TargetModel, + ModelProvider: models[0].Spec.Manifest.ModelProvider, + ProviderDialect: lookupProviderDialect(ctx, client, namespace, models[0].Spec.Manifest.ModelProvider), + }, nil } - return "", fmt.Errorf("failed to resolve default model for alias %s: no active llm models available", aliasName) + return resolvedLLMModel{}, fmt.Errorf("failed to resolve default model for alias %s: no active llm models available", aliasName) } func preferredModelsForAlias(aliasName types.DefaultModelAliasType) []string { diff --git a/pkg/controller/handlers/nanobotagent/nanobotagent_test.go b/pkg/controller/handlers/nanobotagent/nanobotagent_test.go index 17a12f66eb..184329430a 100644 --- a/pkg/controller/handlers/nanobotagent/nanobotagent_test.go +++ b/pkg/controller/handlers/nanobotagent/nanobotagent_test.go @@ -4,11 +4,14 @@ import ( "context" "testing" + nanobottypes "github.com/nanobot-ai/nanobot/pkg/types" "github.com/obot-platform/obot/apiclient/types" v1 "github.com/obot-platform/obot/pkg/storage/apis/obot.obot.ai/v1" storagescheme "github.com/obot-platform/obot/pkg/storage/scheme" + "github.com/obot-platform/obot/pkg/system" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client/fake" + sigsyaml "sigs.k8s.io/yaml" ) func TestChooseModelPrefersKnownNames(t *testing.T) { @@ -40,8 +43,8 @@ func TestChooseModelPrefersKnownNames(t *testing.T) { t.Fatalf("expected model, got error: %v", err) } - if model != "gpt-5.4" { - t.Fatalf("expected gpt-5.4, got %q", model) + if model.TargetModel != "gpt-5.4" { + t.Fatalf("expected gpt-5.4, got %q", model.TargetModel) } } @@ -64,8 +67,8 @@ func TestChooseModelFallsBackToFirstActiveModel(t *testing.T) { t.Fatalf("expected model, got error: %v", err) } - if model != "model-a" { - t.Fatalf("expected model-a, got %q", model) + if model.TargetModel != "model-a" { + t.Fatalf("expected model-a, got %q", model.TargetModel) } } @@ -98,8 +101,297 @@ func TestChooseModelPrefersSuggestedOrder(t *testing.T) { t.Fatalf("expected model, got error: %v", err) } - if model != "gpt-5.4" { - t.Fatalf("expected gpt-5.4, got %q", model) + if model.TargetModel != "gpt-5.4" { + t.Fatalf("expected gpt-5.4, got %q", model.TargetModel) + } +} + +func TestLookupProviderDialectFound(t *testing.T) { + toolRef := &v1.ToolReference{ + TypeMeta: metav1.TypeMeta{APIVersion: v1.SchemeGroupVersion.String(), Kind: "ToolReference"}, + ObjectMeta: metav1.ObjectMeta{Name: "groq-model-provider"}, + Status: v1.ToolReferenceStatus{ + Tool: &v1.ToolShortDescription{ + Metadata: map[string]string{ + "providerMeta": `{"dialect": "OpenAIChatCompletions"}`, + }, + }, + }, + } + c := fake.NewClientBuilder().WithScheme(storagescheme.Scheme).WithObjects(toolRef).Build() + + got := lookupProviderDialect(context.Background(), c, "", "groq-model-provider") + if got != nanobottypes.DialectOpenAIChatCompletions { + t.Errorf("expected OpenAIChatCompletions, got %q", got) + } +} + +func TestLookupProviderDialectNotDeclared(t *testing.T) { + toolRef := &v1.ToolReference{ + TypeMeta: metav1.TypeMeta{APIVersion: v1.SchemeGroupVersion.String(), Kind: "ToolReference"}, + ObjectMeta: metav1.ObjectMeta{Name: "some-provider"}, + Status: v1.ToolReferenceStatus{ + Tool: &v1.ToolShortDescription{ + Metadata: map[string]string{ + "providerMeta": `{"icon": "icon.svg"}`, + }, + }, + }, + } + c := fake.NewClientBuilder().WithScheme(storagescheme.Scheme).WithObjects(toolRef).Build() + + got := lookupProviderDialect(context.Background(), c, "", "some-provider") + if got != "" { + t.Errorf("expected empty dialect, got %q", got) + } +} + +func TestLookupProviderDialectNotFound(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(storagescheme.Scheme).Build() + + got := lookupProviderDialect(context.Background(), c, "", "nonexistent-provider") + if got != "" { + t.Errorf("expected empty dialect for missing ToolReference, got %q", got) + } +} + +func TestNanobotProviderForDeclaredDialectDrivesURL(t *testing.T) { + h := &Handler{serverURL: "https://obot.example.com"} + + for _, tc := range []struct { + dialect nanobottypes.Dialect + wantBaseURL string + }{ + {nanobottypes.DialectAnthropicMessages, "https://obot.example.com/api/llm-proxy/anthropic"}, + {nanobottypes.DialectOpenAIResponses, "https://obot.example.com/api/llm-proxy/openai"}, + {nanobottypes.DialectOpenAIChatCompletions, "https://obot.example.com/api/llm-proxy"}, + {nanobottypes.DialectOpenResponses, "https://obot.example.com/api/llm-proxy"}, + } { + model := resolvedLLMModel{ + TargetModel: "some-model", + ModelProvider: "custom-model-provider", + ProviderDialect: tc.dialect, + } + p, _ := h.nanobotProviderFor(model) + if p.BaseURL != tc.wantBaseURL { + t.Errorf("dialect %s: baseURL = %q, want %q", tc.dialect, p.BaseURL, tc.wantBaseURL) + } + if p.Dialect != tc.dialect { + t.Errorf("dialect %s: provider dialect = %q, want same", tc.dialect, p.Dialect) + } + } +} + +func TestNanobotProviderForBuiltinFallbacks(t *testing.T) { + h := &Handler{serverURL: "https://obot.example.com"} + + for _, tc := range []struct { + modelProvider string + wantDialect nanobottypes.Dialect + wantBaseURL string + }{ + {system.OpenAIModelProviderTool, nanobottypes.DialectOpenAIResponses, "https://obot.example.com/api/llm-proxy/openai"}, + {system.AnthropicModelProviderTool, nanobottypes.DialectAnthropicMessages, "https://obot.example.com/api/llm-proxy/anthropic"}, + {"unknown-model-provider", nanobottypes.DialectOpenResponses, "https://obot.example.com/api/llm-proxy"}, + } { + model := resolvedLLMModel{TargetModel: "my-model", ModelProvider: tc.modelProvider} + p, qualifiedName := h.nanobotProviderFor(model) + if p.Dialect != tc.wantDialect { + t.Errorf("%s: dialect = %q, want %q", tc.modelProvider, p.Dialect, tc.wantDialect) + } + if p.BaseURL != tc.wantBaseURL { + t.Errorf("%s: baseURL = %q, want %q", tc.modelProvider, p.BaseURL, tc.wantBaseURL) + } + wantName := tc.modelProvider + "/my-model" + if qualifiedName != wantName { + t.Errorf("%s: qualified name = %q, want %q", tc.modelProvider, qualifiedName, wantName) + } + } +} + +func TestBuildNanobotProviderConfigYAMLSingleProvider(t *testing.T) { + p := nanobotLLMProvider{ + Name: "openai-model-provider", + Dialect: nanobottypes.DialectOpenAIResponses, + APIKey: "${OPENAI_MODEL_PROVIDER_API_KEY}", + BaseURL: "https://obot.example.com/api/llm-proxy/openai", + } + + yaml, err := buildNanobotProviderConfigYAML(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var cfg nanobottypes.Config + if err := sigsyaml.Unmarshal([]byte(yaml), &cfg); err != nil { + t.Fatalf("failed to parse output YAML: %v", err) + } + + if len(cfg.LLMProviders) != 1 { + t.Fatalf("expected 1 provider, got %d", len(cfg.LLMProviders)) + } + got := cfg.LLMProviders["openai-model-provider"] + if got.Dialect != nanobottypes.DialectOpenAIResponses { + t.Errorf("dialect = %q, want OpenAIResponses", got.Dialect) + } + if got.BaseURL != p.BaseURL { + t.Errorf("baseURL = %q, want %q", got.BaseURL, p.BaseURL) + } +} + +func TestBuildNanobotProviderConfigYAMLMultipleProviders(t *testing.T) { + openai := nanobotLLMProvider{ + Name: "openai-model-provider", + Dialect: nanobottypes.DialectOpenAIResponses, + APIKey: "${OPENAI_MODEL_PROVIDER_API_KEY}", + BaseURL: "https://obot.example.com/api/llm-proxy/openai", + } + anthropic := nanobotLLMProvider{ + Name: "anthropic-model-provider", + Dialect: nanobottypes.DialectAnthropicMessages, + APIKey: "${ANTHROPIC_MODEL_PROVIDER_API_KEY}", + BaseURL: "https://obot.example.com/api/llm-proxy/anthropic", + } + + yaml, err := buildNanobotProviderConfigYAML(openai, anthropic) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var cfg nanobottypes.Config + if err := sigsyaml.Unmarshal([]byte(yaml), &cfg); err != nil { + t.Fatalf("failed to parse output YAML: %v", err) + } + + if len(cfg.LLMProviders) != 2 { + t.Fatalf("expected 2 providers, got %d: %v", len(cfg.LLMProviders), cfg.LLMProviders) + } + if cfg.LLMProviders["openai-model-provider"].Dialect != nanobottypes.DialectOpenAIResponses { + t.Errorf("openai dialect = %q, want OpenAIResponses", cfg.LLMProviders["openai-model-provider"].Dialect) + } + if cfg.LLMProviders["anthropic-model-provider"].Dialect != nanobottypes.DialectAnthropicMessages { + t.Errorf("anthropic dialect = %q, want AnthropicMessages", cfg.LLMProviders["anthropic-model-provider"].Dialect) + } +} + +func TestBuildNanobotProviderConfigYAMLDeduplicates(t *testing.T) { + p := nanobotLLMProvider{ + Name: "openai-model-provider", + Dialect: nanobottypes.DialectOpenAIResponses, + APIKey: "${OPENAI_MODEL_PROVIDER_API_KEY}", + BaseURL: "https://obot.example.com/api/llm-proxy/openai", + } + + yaml, err := buildNanobotProviderConfigYAML(p, p) // same provider twice + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var cfg nanobottypes.Config + if err := sigsyaml.Unmarshal([]byte(yaml), &cfg); err != nil { + t.Fatalf("failed to parse output YAML: %v", err) + } + + if len(cfg.LLMProviders) != 1 { + t.Errorf("expected deduplication to 1 provider, got %d", len(cfg.LLMProviders)) + } +} + +func TestResolveModelCarriesProviderAndDialect(t *testing.T) { + c := fake.NewClientBuilder(). + WithScheme(storagescheme.Scheme). + WithObjects( + &v1.DefaultModelAlias{ + TypeMeta: metav1.TypeMeta{APIVersion: v1.SchemeGroupVersion.String(), Kind: "DefaultModelAlias"}, + ObjectMeta: metav1.ObjectMeta{Name: "llm"}, + Spec: v1.DefaultModelAliasSpec{ + Manifest: types.DefaultModelAliasManifest{Alias: "llm", Model: "groq-llama"}, + }, + }, + &v1.Model{ + TypeMeta: metav1.TypeMeta{APIVersion: v1.SchemeGroupVersion.String(), Kind: "Model"}, + ObjectMeta: metav1.ObjectMeta{Name: "groq-llama"}, + Spec: v1.ModelSpec{ + Manifest: types.ModelManifest{ + Name: "groq-llama", + TargetModel: "llama-3.1-70b-versatile", + ModelProvider: "groq-model-provider", + Active: true, + Usage: types.ModelUsageLLM, + }, + }, + }, + &v1.ToolReference{ + TypeMeta: metav1.TypeMeta{APIVersion: v1.SchemeGroupVersion.String(), Kind: "ToolReference"}, + ObjectMeta: metav1.ObjectMeta{Name: "groq-model-provider"}, + Status: v1.ToolReferenceStatus{ + Tool: &v1.ToolShortDescription{ + Metadata: map[string]string{ + "providerMeta": `{"dialect": "OpenAIChatCompletions"}`, + }, + }, + }, + }, + ).Build() + + model, err := resolveModel(context.Background(), c, "", types.DefaultModelAliasTypeLLM) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if model.TargetModel != "llama-3.1-70b-versatile" { + t.Errorf("TargetModel = %q, want llama-3.1-70b-versatile", model.TargetModel) + } + if model.ModelProvider != "groq-model-provider" { + t.Errorf("ModelProvider = %q, want groq-model-provider", model.ModelProvider) + } + if model.ProviderDialect != nanobottypes.DialectOpenAIChatCompletions { + t.Errorf("ProviderDialect = %q, want OpenAIChatCompletions", model.ProviderDialect) + } +} + +// TestMultipleProvidersWhenLLMAndMiniDiffer verifies that when the default LLM and +// mini-LLM models are on different providers, both providers appear in the generated +// nanobot config YAML. +func TestMultipleProvidersWhenLLMAndMiniDiffer(t *testing.T) { + h := &Handler{serverURL: "https://obot.example.com"} + + llmModel := resolvedLLMModel{ + TargetModel: "claude-sonnet-4-6", + ModelProvider: system.AnthropicModelProviderTool, + } + miniModel := resolvedLLMModel{ + TargetModel: "gpt-4.1-mini", + ModelProvider: system.OpenAIModelProviderTool, + } + + llmProvider, llmDefault := h.nanobotProviderFor(llmModel) + miniProvider, miniDefault := h.nanobotProviderFor(miniModel) + + if llmDefault != system.AnthropicModelProviderTool+"/claude-sonnet-4-6" { + t.Errorf("llmDefault = %q, want %s/claude-sonnet-4-6", llmDefault, system.AnthropicModelProviderTool) + } + if miniDefault != system.OpenAIModelProviderTool+"/gpt-4.1-mini" { + t.Errorf("miniDefault = %q, want %s/gpt-4.1-mini", miniDefault, system.OpenAIModelProviderTool) + } + + yaml, err := buildNanobotProviderConfigYAML(llmProvider, miniProvider) + if err != nil { + t.Fatalf("buildNanobotProviderConfigYAML: %v", err) + } + + var cfg nanobottypes.Config + if err := sigsyaml.Unmarshal([]byte(yaml), &cfg); err != nil { + t.Fatalf("failed to parse output YAML: %v", err) + } + + if len(cfg.LLMProviders) != 2 { + t.Fatalf("expected 2 providers (one per model), got %d:\n%s", len(cfg.LLMProviders), yaml) + } + if _, ok := cfg.LLMProviders[system.AnthropicModelProviderTool]; !ok { + t.Errorf("anthropic-model-provider missing from YAML") + } + if _, ok := cfg.LLMProviders[system.OpenAIModelProviderTool]; !ok { + t.Errorf("openai-model-provider missing from YAML") } } @@ -160,7 +452,7 @@ func TestChooseModelMiniFallsBackToResolvedLLM(t *testing.T) { t.Fatalf("expected model, got error: %v", err) } - if model != "gpt-5.4" { - t.Fatalf("expected gpt-5.4, got %q", model) + if model.TargetModel != "gpt-5.4" { + t.Fatalf("expected gpt-5.4, got %q", model.TargetModel) } } diff --git a/pkg/mcp/backend.go b/pkg/mcp/backend.go index 47b48d16fb..10178a457f 100644 --- a/pkg/mcp/backend.go +++ b/pkg/mcp/backend.go @@ -11,6 +11,8 @@ import ( "strings" "time" + "github.com/nanobot-ai/nanobot/pkg/mcp" + nanobottypes "github.com/nanobot-ai/nanobot/pkg/types" "github.com/oasdiff/yaml" "github.com/obot-platform/obot/apiclient/types" ) @@ -176,25 +178,25 @@ func webhookToServerConfig(webhook Webhook, baseImage, mcpServerName, userID, sc }, nil } -func constructNanobotYAMLForCompositeServer(servers []ComponentServer) ([]byte, error) { - mcpServers := make(map[string]nanobotConfigMCPServer, len(servers)) +func constructMCPServerNanobotYAMLForComposite(servers []ComponentServer) ([]byte, error) { + mcpServers := make(map[string]mcp.Server, len(servers)) names := make([]string, 0, len(servers)) replacer := strings.NewReplacer("/", "-", ":", "-", "?", "-") for _, component := range servers { - tools := make(map[string]toolOverride, len(component.Tools)) + tools := make(map[string]mcp.ToolOverride, len(component.Tools)) for _, tool := range component.Tools { if !tool.Enabled { continue } - tools[tool.Name] = toolOverride{ + tools[tool.Name] = mcp.ToolOverride{ Name: tool.OverrideName, Description: tool.OverrideDescription, } } name := replacer.Replace(component.Name) - mcpServers[name] = nanobotConfigMCPServer{ + mcpServers[name] = mcp.Server{ BaseURL: component.URL, ToolOverrides: tools, } @@ -202,8 +204,8 @@ func constructNanobotYAMLForCompositeServer(servers []ComponentServer) ([]byte, names = append(names, name) } - config := nanobotConfig{ - Publish: nanobotConfigPublish{ + config := nanobottypes.Config{ + Publish: nanobottypes.Publish{ MCPServers: names, }, MCPServers: mcpServers, @@ -217,37 +219,42 @@ func constructNanobotYAMLForCompositeServer(servers []ComponentServer) ([]byte, return data, nil } -func constructNanobotYAMLForServer(name, url, command string, args []string, env, headers map[string][]byte, webhooks []Webhook) ([]byte, error) { +func constructMCPServerNanobotYAML(name, url, command string, args []string, env, headers map[string][]byte, webhooks []Webhook) ([]byte, error) { replacer := strings.NewReplacer("/", "-", ":", "-", "?", "-") - webhookDefinitions := make(map[string][]string, len(webhooks)) - mcpServers := make(map[string]nanobotConfigMCPServer, len(webhooks)+1) + hookTargets := make(map[string][]string, len(webhooks)) + mcpServers := make(map[string]mcp.Server, len(webhooks)+1) for _, webhook := range webhooks { - name := replacer.Replace(webhook.DisplayName) - if name == "" { - name = replacer.Replace(webhook.Name) + webhookName := replacer.Replace(webhook.DisplayName) + if webhookName == "" { + webhookName = replacer.Replace(webhook.Name) } - mcpServers[name] = nanobotConfigMCPServer{ + mcpServers[webhookName] = mcp.Server{ BaseURL: webhook.URL, } for _, def := range webhook.Definitions { - webhookDefinitions[def] = append(webhookDefinitions[def], fmt.Sprintf("%s/%s", name, webhookToolName)) + hookTargets[def] = append(hookTargets[def], fmt.Sprintf("%s/%s", webhookName, webhookToolName)) } } + hooks := make(mcp.Hooks, 0, len(hookTargets)) + for def, targets := range hookTargets { + hooks = append(hooks, mcp.HookMapping{Name: def, Targets: targets}) + } + name = replacer.Replace(name) - mcpServers[name] = nanobotConfigMCPServer{ + mcpServers[name] = mcp.Server{ BaseURL: url, Command: command, Args: args, Env: convertMapStringBytesToMapStringString(env), Headers: convertMapStringBytesToMapStringString(headers), - Hooks: webhookDefinitions, + Hooks: hooks, } - config := nanobotConfig{ - Publish: nanobotConfigPublish{ + config := nanobottypes.Config{ + Publish: nanobottypes.Publish{ MCPServers: []string{name}, }, MCPServers: mcpServers, @@ -272,28 +279,3 @@ func convertMapStringBytesToMapStringString(m map[string][]byte) map[string]stri } return result } - -type nanobotConfig struct { - Publish nanobotConfigPublish `json:"publish,omitzero"` - MCPServers map[string]nanobotConfigMCPServer `json:"mcpServers,omitempty"` -} - -type nanobotConfigPublish struct { - MCPServers []string `json:"mcpServers,omitempty"` -} - -type nanobotConfigMCPServer struct { - Command string `json:"command,omitempty"` - Args []string `json:"args,omitempty"` - Hooks map[string][]string `json:"hooks,omitempty"` - Env map[string]string `json:"env,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - BaseURL string `json:"url,omitempty"` - - ToolOverrides map[string]toolOverride `json:"toolOverrides,omitempty"` -} - -type toolOverride struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` -} diff --git a/pkg/mcp/docker.go b/pkg/mcp/docker.go index 888c537235..07884deb2b 100644 --- a/pkg/mcp/docker.go +++ b/pkg/mcp/docker.go @@ -860,6 +860,10 @@ func (d *dockerBackend) createAndStartContainer(ctx context.Context, server Serv Source: workspaceName, Target: nanobotWorkspaceMountPath, }) + err = d.writeNanobotAgentProviderConfig(ctx, workspaceName, fileVolumeName, server.MCPServerName) + if err != nil { + return "", 0, fmt.Errorf("failed to write nanobot agent provider config: %w", err) + } } } @@ -923,10 +927,9 @@ func (d *dockerBackend) createAndStartContainer(ctx context.Context, server Serv containerPort = defaultContainerPort - // Prepare nanobot configuration - nanobotVolumeName, err := d.prepareNanobotConfig(ctx, server, fileEnvVars, webhooks) + nanobotVolumeName, err := d.prepareMCPServerNanobotConfig(ctx, server, fileEnvVars, webhooks) if err != nil { - return "", 0, fmt.Errorf("failed to prepare nanobot config: %w", err) + return "", 0, fmt.Errorf("failed to prepare MCP server nanobot config: %w", err) } volumeMounts = append(volumeMounts, mount.Mount{ @@ -1232,6 +1235,66 @@ func (d *dockerBackend) createVolumeWithFiles(ctx context.Context, files []File, return volumeName, envVars, nil } +func (d *dockerBackend) writeNanobotAgentProviderConfig(ctx context.Context, workspaceName, fileVolumeName, serverName string) error { + src := fmt.Sprintf("/files/%s-NANOBOT_PROVIDER_CONFIG", serverName) + script := fmt.Sprintf("mkdir -p %[1]s/.nanobot && ln -sf %s %[1]s/.nanobot/nanobot.yaml", + nanobotWorkspaceMountPath, src) + return d.runInitContainer(ctx, "nanobot-provider-config-init", script, []mount.Mount{ + {Type: mount.TypeVolume, Source: fileVolumeName, Target: "/files", ReadOnly: true}, + {Type: mount.TypeVolume, Source: workspaceName, Target: nanobotWorkspaceMountPath}, + }) +} + +// runInitContainer pulls alpine:latest (if not present), runs a one-shot sh -c container +// with the given script and mounts, waits for it to exit, and returns any error. +func (d *dockerBackend) runInitContainer(ctx context.Context, namePrefix, script string, mounts []mount.Mount) error { + initImage := "alpine:latest" + if err := d.pullImage(ctx, initImage, true); err != nil { + return fmt.Errorf("failed to ensure init image exists: %w", err) + } + + networkingConfig := &network.NetworkingConfig{} + if d.network != "" { + networkingConfig.EndpointsConfig = map[string]*network.EndpointSettings{ + d.network: {}, + } + } + + resp, err := d.client.ContainerCreate(ctx, + &container.Config{ + Image: initImage, + Entrypoint: []string{"sh", "-c"}, + Cmd: []string{script}, + }, + &container.HostConfig{ + Mounts: mounts, + AutoRemove: true, + }, + networkingConfig, nil, + fmt.Sprintf("%s-%s", namePrefix, strings.ToLower(rand.Text()))) + if err != nil { + return fmt.Errorf("failed to create init container: %w", err) + } + + if err := d.client.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { + return fmt.Errorf("failed to start init container: %w", err) + } + + statusCh, errCh := d.client.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning) + select { + case err := <-errCh: + if err != nil && !cerrdefs.IsNotFound(err) { + return fmt.Errorf("error waiting for init container: %w", err) + } + case status := <-statusCh: + if status.StatusCode != 0 { + return fmt.Errorf("init container %s failed with exit code %d", namePrefix, status.StatusCode) + } + } + + return nil +} + func containerFiles(files []File, containerName string) (map[string]string, map[string]string) { fileContents := make(map[string]string, len(files)) envVars := make(map[string]string, len(files)) @@ -1282,11 +1345,6 @@ func fileEnvKeysHash(files []File) string { } func (d *dockerBackend) populateFilesVolume(ctx context.Context, volumeName, containerName string, fileContents map[string]string) error { - initImage := "alpine:latest" - if err := d.pullImage(ctx, initImage, true); err != nil { - return fmt.Errorf("failed to ensure init image exists: %w", err) - } - var script strings.Builder script.WriteString("#!/bin/sh\nset -e\n") script.WriteString("rm -f /files/*\n") @@ -1302,44 +1360,11 @@ func (d *dockerBackend) populateFilesVolume(ctx context.Context, volumeName, con fmt.Fprintf(&script, "cat > '%s' << 'EOF'\n%s\nEOF\n", containerPath, fileContents[filename]) } - initConfig := &container.Config{ - Image: initImage, - Entrypoint: []string{"sh", "-c"}, - Cmd: []string{script.String()}, - WorkingDir: "/", - } - - initHostConfig := &container.HostConfig{ - Mounts: []mount.Mount{{ - Type: mount.TypeVolume, - Source: volumeName, - Target: "/files", - }}, - AutoRemove: true, - } - - resp, err := d.client.ContainerCreate(ctx, initConfig, initHostConfig, &network.NetworkingConfig{}, nil, fmt.Sprintf("%s-init-%s", containerName, strings.ToLower(rand.Text()))) - if err != nil { - return fmt.Errorf("failed to create init container: %w", err) - } - - if err := d.client.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { - return fmt.Errorf("failed to start init container: %w", err) - } - - statusCh, errCh := d.client.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning) - select { - case err := <-errCh: - if err != nil && !cerrdefs.IsNotFound(err) { - return fmt.Errorf("error waiting for init container: %w", err) - } - case status := <-statusCh: - if status.StatusCode != 0 { - return fmt.Errorf("init container failed with exit code %d", status.StatusCode) - } - } - - return nil + return d.runInitContainer(ctx, containerName+"-init", script.String(), []mount.Mount{{ + Type: mount.TypeVolume, + Source: volumeName, + Target: "/files", + }}) } func (d *dockerBackend) pullImage(ctx context.Context, imageName string, ifNotExists bool) error { @@ -1374,8 +1399,9 @@ func (d *dockerBackend) pullImage(ctx context.Context, imageName string, ifNotEx return nil } -// prepareNanobotConfig creates a volume with nanobot YAML configuration for UVX/NPX runtimes -func (d *dockerBackend) prepareNanobotConfig(ctx context.Context, server ServerConfig, envVars map[string]string, webhooks []Webhook) (string, error) { +// prepareMCPServerNanobotConfig creates a volume containing the nanobot.yaml that configures +// how nanobot proxies to the underlying MCP server (used for UVX/NPX/remote/composite runtimes). +func (d *dockerBackend) prepareMCPServerNanobotConfig(ctx context.Context, server ServerConfig, envVars map[string]string, webhooks []Webhook) (string, error) { // Create all environment variables map allEnvVars := make(map[string][]byte, len(server.Env)+len(envVars)) headers := make(map[string][]byte, len(server.Headers)) @@ -1402,83 +1428,35 @@ func (d *dockerBackend) prepareNanobotConfig(ctx context.Context, server ServerC err error ) if server.Runtime == otypes.RuntimeComposite { - nanobotYAML, err = constructNanobotYAMLForCompositeServer(server.Components) + nanobotYAML, err = constructMCPServerNanobotYAMLForComposite(server.Components) } else { - nanobotYAML, err = constructNanobotYAMLForServer(server.MCPServerDisplayName, server.URL, server.Command, server.Args, allEnvVars, headers, webhooks) + nanobotYAML, err = constructMCPServerNanobotYAML(server.MCPServerDisplayName, server.URL, server.Command, server.Args, allEnvVars, headers, webhooks) } if err != nil { return "", fmt.Errorf("failed to construct nanobot YAML: %w", err) } - volumeName := server.MCPServerName + "-nanobot-config" - // Create volume for nanobot config + volumeName := server.MCPServerName + "-mcp-server-nanobot-config" _, err = d.client.VolumeCreate(ctx, volume.CreateOptions{ Labels: map[string]string{ "mcp.server.id": server.MCPServerName, - "mcp.purpose": "nanobot-config", + "mcp.purpose": "mcp-server-nanobot-config", }, Name: volumeName, }) if err != nil && !cerrdefs.IsAlreadyExists(err) { - return "", fmt.Errorf("failed to create nanobot config volume: %w", err) + return "", fmt.Errorf("failed to create MCP server nanobot config volume: %w", err) } - // Create init container to populate the volume with nanobot config - initImage := "alpine:latest" - if err = d.pullImage(ctx, initImage, true); err != nil { - return "", fmt.Errorf("failed to ensure init image exists: %w", err) - } - - // Create script to write nanobot config script := fmt.Sprintf("cat > /config/nanobot.yaml << 'EOF'\n%s\nEOF\n", nanobotYAML) - - // Create and run init container - initConfig := &container.Config{ - Image: initImage, - Entrypoint: []string{"sh", "-c"}, - Cmd: []string{script}, - } - - initHostConfig := &container.HostConfig{ - Mounts: []mount.Mount{ - { - Type: mount.TypeVolume, - Source: volumeName, - Target: "/config", - }, + if err = d.runInitContainer(ctx, server.MCPServerName+"-nanobot-init", script, []mount.Mount{ + { + Type: mount.TypeVolume, + Source: volumeName, + Target: "/config", }, - AutoRemove: true, - } - - // Configure network (same as main containers) - initNetworkingConfig := &network.NetworkingConfig{} - if d.network != "" { - initNetworkingConfig.EndpointsConfig = map[string]*network.EndpointSettings{ - d.network: {}, - } - } - - resp, err := d.client.ContainerCreate(ctx, initConfig, initHostConfig, initNetworkingConfig, nil, fmt.Sprintf("%s-nanobot-init-%s", server.MCPServerName, strings.ToLower(rand.Text()))) - if err != nil { - return "", fmt.Errorf("failed to create nanobot init container: %w", err) - } - - // Start and wait for init container to complete - if err := d.client.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { - return "", fmt.Errorf("failed to start init container: %w", err) - } - - // Wait for init container to complete - statusCh, errCh := d.client.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning) - select { - case err := <-errCh: - if err != nil && !cerrdefs.IsNotFound(err) { - return "", fmt.Errorf("error waiting for nanobot init container: %w", err) - } - case status := <-statusCh: - if status.StatusCode != 0 { - return "", fmt.Errorf("nanobot init container failed with exit code %d", status.StatusCode) - } + }); err != nil { + return "", err } return volumeName, nil diff --git a/pkg/mcp/kubernetes.go b/pkg/mcp/kubernetes.go index 59489a1f20..8c32ea8899 100644 --- a/pkg/mcp/kubernetes.go +++ b/pkg/mcp/kubernetes.go @@ -607,7 +607,7 @@ func (k *kubernetesBackend) k8sObjects(ctx context.Context, server ServerConfig, if server.NanobotAgentName == "" { // If this is anything other than a remote runtime, then we need to add a special shim container. // The remote runtime will just be the shim and is deployed as the "real" container. - nanobotFileString, err := constructNanobotYAMLForServer( + nanobotFileString, err := constructMCPServerNanobotYAML( server.MCPServerDisplayName+" Shim", fmt.Sprintf("http://localhost:%d/%s", port, strings.TrimPrefix(server.ContainerPath, "/")), "", @@ -817,6 +817,26 @@ func (k *kubernetesBackend) k8sObjects(ctx context.Context, server ServerConfig, Tolerations: k8sSettings.Tolerations, RuntimeClassName: k8sSettings.RuntimeClassName, SecurityContext: getPodSecurityContext(psaLevel), + InitContainers: func() []corev1.Container { + if workspacePVCName == "" { + return nil + } + src := fmt.Sprintf("/files/%s-NANOBOT_PROVIDER_CONFIG", server.MCPServerName) + initScript := fmt.Sprintf("mkdir -p %[1]s/.nanobot && ln -sf %[2]s %[1]s/.nanobot/nanobot.yaml", + nanobotWorkspaceMountPath, src) + return []corev1.Container{ + { + Name: "nanobot-provider-config-init", + Image: "alpine:latest", + Command: []string{"sh", "-c"}, + Args: []string{initScript}, + VolumeMounts: []corev1.VolumeMount{ + {Name: "files", MountPath: "/files", ReadOnly: true}, + {Name: nanobotWorkspaceVolumeName, MountPath: nanobotWorkspaceMountPath}, + }, + }, + } + }(), Volumes: func() []corev1.Volume { volumes := []corev1.Volume{ { @@ -867,13 +887,14 @@ func (k *kubernetesBackend) k8sObjects(ctx context.Context, server ServerConfig, objs = append(objs, dep) if server.Runtime != types.RuntimeContainerized { - // Setup the nanobot config file and add it to the last container in the deployment. + // Setup the MCP server nanobot config (nanobot.yaml that configures how nanobot proxies + // to the underlying MCP server) and mount it into the last container in the deployment. var nanobotFileString []byte if server.Runtime == types.RuntimeComposite { - nanobotFileString, err = constructNanobotYAMLForCompositeServer(server.Components) + nanobotFileString, err = constructMCPServerNanobotYAMLForComposite(server.Components) annotations["nanobot-composite-file-rev"] = hash.Digest(nanobotFileString) } else { - nanobotFileString, err = constructNanobotYAMLForServer(server.MCPServerDisplayName, server.URL, server.Command, server.Args, secretEnvData, headerData, webhooks) + nanobotFileString, err = constructMCPServerNanobotYAML(server.MCPServerDisplayName, server.URL, server.Command, server.Args, secretEnvData, headerData, webhooks) } if err != nil { return nil, fmt.Errorf("failed to construct nanobot.yaml: %w", err) diff --git a/pkg/storage/openapi/generated/openapi_generated.go b/pkg/storage/openapi/generated/openapi_generated.go index 16576a85cd..328138c380 100644 --- a/pkg/storage/openapi/generated/openapi_generated.go +++ b/pkg/storage/openapi/generated/openapi_generated.go @@ -2098,6 +2098,13 @@ func schema_obot_platform_obot_apiclient_types_CommonProviderMetadata(ref common Format: "", }, }, + "dialect": { + SchemaProps: spec.SchemaProps{ + Description: "Dialect specifies the LLM API format used by this provider (e.g. \"AnthropicMessages\", \"OpenAIChatCompletions\", \"OpenAIResponses\").", + Type: []string{"string"}, + Format: "", + }, + }, }, }, },