Files
tutti/services/tuttid/service/agent/opencode_model_catalog.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

229 lines
6.4 KiB
Go

package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"regexp"
"sort"
"strings"
"time"
"github.com/tutti-os/tutti/packages/agent/daemon/providerregistry"
"github.com/tutti-os/tutti/packages/agent/daemon/runtimecmd"
)
const (
// OpenCode is commonly launched through a Windows npm shim. The first
// `models` invocation can spend several seconds bootstrapping the runtime
// and provider registry before it writes any output.
opencodeModelListTimeout = 30 * time.Second
opencodeModelFetchTimeout = 35 * time.Second
opencodeModelCacheTTL = 5 * time.Minute
opencodeModelErrorCacheTTL = 30 * time.Second
)
var opencodeModelTokenPattern = regexp.MustCompile(`[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._:-]*`)
type OpenCodeCLIModelLister struct {
Command string
Args []string
Cwd string
Timeout time.Duration
Environ func() []string
HomeDir func() (string, error)
}
func (l OpenCodeCLIModelLister) ListModels(ctx context.Context) (AgentModelListResult, error) {
timeout := l.Timeout
if timeout <= 0 {
timeout = opencodeModelListTimeout
}
processCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
command := strings.TrimSpace(l.Command)
if command == "" {
command = "opencode"
}
resolver := runtimecmd.Resolver{
Environ: l.Environ,
HomeDir: l.HomeDir,
}
env := resolver.Env(nil)
command = resolver.Resolve(command, env)
args := append([]string{}, l.Args...)
if len(args) == 0 {
args = []string{"models"}
}
cmd := newProviderCLICommand(processCtx, command, args...)
cmd.Env = env
if cwd := strings.TrimSpace(l.Cwd); cwd != "" {
cmd.Dir = cwd
}
output, err := cmd.Output()
if err != nil {
if errors.Is(processCtx.Err(), context.DeadlineExceeded) {
return AgentModelListResult{}, fmt.Errorf(
"opencode models timed out after %s: %w",
timeout,
context.DeadlineExceeded,
)
}
if ctx.Err() != nil {
return AgentModelListResult{}, fmt.Errorf("opencode models canceled: %w", ctx.Err())
}
if exitErr, ok := err.(*exec.ExitError); ok {
output = append(output, exitErr.Stderr...)
}
return AgentModelListResult{}, fmt.Errorf("run opencode models: %w: %s", err, strings.TrimSpace(string(output)))
}
models := parseOpenCodeModelsOutput(output)
if len(models) == 0 {
return AgentModelListResult{}, fmt.Errorf("opencode models returned no provider/model entries")
}
return AgentModelListResult{Models: models}, nil
}
func parseOpenCodeModelsOutput(output []byte) []AgentModelOption {
if models := parseVerboseOpenCodeModelsOutput(output); len(models) > 0 {
return models
}
scanner := bufio.NewScanner(bytes.NewReader(output))
modelsByID := map[string]AgentModelOption{}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
for _, token := range opencodeModelTokenPattern.FindAllString(line, -1) {
modelID := strings.TrimSpace(token)
if modelID == "" {
continue
}
modelsByID[modelID] = AgentModelOption{
ID: modelID,
DisplayName: modelID,
Description: "OpenCode model",
}
}
}
models := make([]AgentModelOption, 0, len(modelsByID))
for _, model := range modelsByID {
models = append(models, model)
}
sort.SliceStable(models, func(leftIndex, rightIndex int) bool {
return strings.Compare(models[leftIndex].ID, models[rightIndex].ID) < 0
})
return models
}
func parseVerboseOpenCodeModelsOutput(output []byte) []AgentModelOption {
lines := bytes.Split(output, []byte("\n"))
models := make([]AgentModelOption, 0)
for index := 0; index < len(lines); index++ {
modelID := strings.TrimSpace(string(lines[index]))
if !opencodeModelTokenPattern.MatchString(modelID) || index+1 >= len(lines) {
continue
}
var block bytes.Buffer
for index++; index < len(lines); index++ {
block.Write(lines[index])
block.WriteByte('\n')
var metadata map[string]any
if json.Unmarshal(block.Bytes(), &metadata) != nil {
continue
}
models = append(models, normalizeVerboseOpenCodeModel(modelID, metadata))
break
}
}
if len(models) == 0 {
return nil
}
sort.SliceStable(models, func(leftIndex, rightIndex int) bool {
return strings.Compare(models[leftIndex].ID, models[rightIndex].ID) < 0
})
return models
}
func normalizeVerboseOpenCodeModel(modelID string, metadata map[string]any) AgentModelOption {
name := strings.TrimSpace(stringFromAny(metadata["name"]))
displayName := openCodeModelDisplayName(modelID, name)
variants, variantsAdvertised := metadata["variants"].(map[string]any)
reasoningEfforts := make([]AgentModelReasoningEffortOption, 0, len(variants))
for value := range variants {
value = strings.TrimSpace(value)
if value != "" && providerregistry.OpenCodeModelSupportsReasoningEffort(modelID, value) {
reasoningEfforts = append(reasoningEfforts, AgentModelReasoningEffortOption{Value: value})
}
}
sort.SliceStable(reasoningEfforts, func(leftIndex, rightIndex int) bool {
return openCodeReasoningEffortOrder(reasoningEfforts[leftIndex].Value) < openCodeReasoningEffortOrder(reasoningEfforts[rightIndex].Value)
})
var supportsImageInput *bool
if capabilities, ok := metadata["capabilities"].(map[string]any); ok {
if input, ok := capabilities["input"].(map[string]any); ok {
if image, ok := input["image"].(bool); ok {
supportsImageInput = &image
}
}
}
return AgentModelOption{
ID: modelID,
DisplayName: displayName,
Description: "OpenCode model",
ReasoningEffortsAdvertised: variantsAdvertised,
SupportedReasoningEfforts: reasoningEfforts,
SupportsImageInput: supportsImageInput,
}
}
func openCodeModelDisplayName(modelID string, name string) string {
modelID = strings.TrimSpace(modelID)
name = strings.TrimSpace(name)
providerID, modelName, qualified := strings.Cut(modelID, "/")
providerID = strings.TrimSpace(providerID)
if name == "" && qualified {
name = strings.TrimSpace(modelName)
}
if name == "" {
return modelID
}
if !qualified || providerID == "" {
return name
}
if strings.EqualFold(providerID, "opencode") {
return name
}
return name + " / " + providerID
}
func openCodeReasoningEffortOrder(value string) string {
switch value {
case "none":
return "0"
case "minimal":
return "1"
case "low":
return "2"
case "medium":
return "3"
case "high":
return "4"
case "max", "xhigh":
return "5"
default:
return "6:" + value
}
}
func readOpenCodeConfiguredDefaultModel() string {
return ""
}