opencode.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /*
  2. Copyright 2026 LocoStack.
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package reconciler
  14. import (
  15. "context"
  16. "crypto/sha256"
  17. "encoding/hex"
  18. "encoding/json"
  19. "fmt"
  20. "maps"
  21. "sort"
  22. "strings"
  23. "github.com/LocoStack/loco-operator/api/v1alpha1"
  24. "github.com/LocoStack/loco-operator/pkg/templates/litellm"
  25. "github.com/LocoStack/loco-operator/pkg/templates/opencode"
  26. corev1 "k8s.io/api/core/v1"
  27. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  28. "k8s.io/apimachinery/pkg/runtime"
  29. "sigs.k8s.io/controller-runtime/pkg/client"
  30. "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
  31. )
  32. // LocoOpenCodeAgentReconciler reconciles the "opencode" template, running the OpenCode
  33. // CLI (https://opencode.ai) in headless server mode. Unlike loco-agents, OpenCode is
  34. // configured entirely through its own config file rather than a LocoStack-specific
  35. // shim, so this reconciler translates AgentComponents into OpenCode's native schema:
  36. // a custom "openai-compatible" provider for the model, native "mcp" entries for tools,
  37. // and native SKILL.md discovery for skills (via AddSkillsToTmpl).
  38. //
  39. // KnowledgeBases and Agents (subagents) dependencies are resolved by getDependencies
  40. // like any other agent template, but intentionally left unused here: OpenCode has no
  41. // native knowledge-base/RAG concept, and no native support for remote (A2A) subagents.
  42. type LocoOpenCodeAgentReconciler struct {
  43. *DefaultComponentReconciler
  44. *AgentReconciler
  45. client client.Client
  46. scheme *runtime.Scheme
  47. stack *v1alpha1.Stack
  48. component *v1alpha1.Component
  49. }
  50. func NewLocoOpenCodeAgentReconciler(client client.Client, scheme *runtime.Scheme, stack *v1alpha1.Stack, component *v1alpha1.Component) *LocoOpenCodeAgentReconciler {
  51. return &LocoOpenCodeAgentReconciler{
  52. DefaultComponentReconciler: NewDefaultComponentReconciler(client, scheme, stack, component),
  53. AgentReconciler: NewAgentReconciler(client, scheme, stack, component),
  54. client: client,
  55. scheme: scheme,
  56. stack: stack,
  57. component: component,
  58. }
  59. }
  60. func (r *LocoOpenCodeAgentReconciler) ReconcileComponent(ctx context.Context, tmpl *v1alpha1.Template, variables map[string]string) ([]client.Object, error) {
  61. deps, comps, err := r.getDependencies(ctx)
  62. if err != nil {
  63. return nil, fmt.Errorf("Failed to get dependencies for %s: %w", r.component.Name, err)
  64. }
  65. configHash, err := r.reconcileOpenCodeConfig(ctx, r.component.Spec.Variables["systemPrompt"], comps)
  66. if err != nil {
  67. return nil, fmt.Errorf("Failed to reconcile config for %s: %w", r.component.Name, err)
  68. }
  69. skillsHash, err := r.AddSkillsToTmpl(ctx, comps, tmpl, opencode.OpenCodeSkillsRoot)
  70. if err != nil {
  71. return nil, fmt.Errorf("Failed to mount skills for %s: %w", r.component.Name, err)
  72. }
  73. if tmpl.Metadata.Annotations == nil {
  74. tmpl.Metadata.Annotations = make(map[string]string)
  75. }
  76. // OpenCode discovers its skills at startup and the mounted ConfigMaps change under a
  77. // running Pod, so the skill files count towards the Pod template just like the config
  78. h := sha256.Sum256([]byte(configHash + skillsHash))
  79. maps.Copy(tmpl.Metadata.Annotations, map[string]string{
  80. "locostack.com/configHash": hex.EncodeToString(h[:]),
  81. })
  82. if _, err := r.DefaultComponentReconciler.ReconcileComponent(ctx, tmpl, variables); err != nil {
  83. return nil, err
  84. }
  85. return deps, nil
  86. }
  87. type openCodeProviderModel struct {
  88. Name string `json:"name,omitempty"`
  89. }
  90. type openCodeProviderOptions struct {
  91. BaseURL string `json:"baseURL"`
  92. APIKey string `json:"apiKey,omitempty"`
  93. }
  94. type openCodeProvider struct {
  95. NPM string `json:"npm"`
  96. Name string `json:"name,omitempty"`
  97. Options openCodeProviderOptions `json:"options"`
  98. Models map[string]openCodeProviderModel `json:"models"`
  99. }
  100. type openCodeMCPServer struct {
  101. Type string `json:"type"`
  102. URL string `json:"url"`
  103. Headers map[string]string `json:"headers,omitempty"`
  104. }
  105. type openCodeAgentConfig struct {
  106. Prompt string `json:"prompt,omitempty"`
  107. }
  108. type openCodeConfig struct {
  109. Schema string `json:"$schema"`
  110. Provider map[string]openCodeProvider `json:"provider"`
  111. Model string `json:"model,omitempty"`
  112. MCP map[string]openCodeMCPServer `json:"mcp,omitempty"`
  113. Agent map[string]openCodeAgentConfig `json:"agent,omitempty"`
  114. }
  115. const openCodeModelProviderID = "locostack"
  116. func (r *LocoOpenCodeAgentReconciler) reconcileOpenCodeConfig(ctx context.Context, systemPrompt string, comps *AgentComponents) (string, error) {
  117. cfg := openCodeConfig{
  118. Schema: "https://opencode.ai/config.json",
  119. Provider: map[string]openCodeProvider{
  120. openCodeModelProviderID: {
  121. NPM: "@ai-sdk/openai-compatible",
  122. Name: "LocoStack",
  123. Options: openCodeProviderOptions{
  124. BaseURL: comps.Model.URL,
  125. APIKey: comps.GatewayKey,
  126. },
  127. Models: map[string]openCodeProviderModel{
  128. comps.Model.Name: {},
  129. },
  130. },
  131. },
  132. Model: fmt.Sprintf("%s/%s", openCodeModelProviderID, comps.Model.Name),
  133. }
  134. if systemPrompt != "" {
  135. cfg.Agent = map[string]openCodeAgentConfig{
  136. "build": {Prompt: systemPrompt},
  137. }
  138. }
  139. if len(comps.Tools) > 0 {
  140. cfg.MCP = make(map[string]openCodeMCPServer)
  141. if comps.Gateway.URL != "" {
  142. toolNames := make([]string, 0, len(comps.Tools))
  143. for _, tool := range comps.Tools {
  144. toolNames = append(toolNames, tool.Name)
  145. }
  146. sort.Strings(toolNames)
  147. cfg.MCP["litellm"] = openCodeMCPServer{
  148. Type: "remote",
  149. URL: fmt.Sprintf("%s/%s/mcp", comps.Gateway.URL, strings.Join(toolNames, ",")),
  150. Headers: map[string]string{
  151. litellm.MCPAuthHeaderName: "Bearer " + comps.GatewayKey,
  152. },
  153. }
  154. } else {
  155. for _, tool := range comps.Tools {
  156. cfg.MCP[tool.Name] = openCodeMCPServer{Type: "remote", URL: tool.URL}
  157. }
  158. }
  159. }
  160. jsonBytes, err := json.Marshal(cfg)
  161. if err != nil {
  162. return "", fmt.Errorf("marshaling opencode config: %w", err)
  163. }
  164. h := sha256.Sum256(jsonBytes)
  165. configHash := hex.EncodeToString(h[:])
  166. secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("config"), Namespace: r.component.GetNamespace()}}
  167. _, err = controllerutil.CreateOrUpdate(ctx, r.client, secret, func() error {
  168. if secret.Labels == nil {
  169. secret.Labels = map[string]string{}
  170. }
  171. maps.Copy(secret.Labels, r.ResourceLabels())
  172. if secret.Annotations == nil {
  173. secret.Annotations = make(map[string]string)
  174. }
  175. secret.Annotations["hash"] = configHash
  176. secret.Data = map[string][]byte{
  177. "opencode.json": jsonBytes,
  178. }
  179. return controllerutil.SetControllerReference(r.component, secret, r.scheme)
  180. })
  181. return configHash, err
  182. }