Files
ollama/cmd/launch/opencode.go
2026-05-21 11:57:20 -07:00

295 lines
7.5 KiB
Go

package launch
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
)
// OpenCode implements Runner and Editor for OpenCode integration.
// Config is passed via OPENCODE_CONFIG_CONTENT env var at launch time
// instead of writing to opencode's config files.
type OpenCode struct {
configContent string // JSON config built by Edit, passed to Run via env var
}
func (o *OpenCode) String() string { return "OpenCode" }
// findOpenCode returns the opencode binary path, checking PATH first then the
// curl installer location (~/.opencode/bin) which may not be on PATH yet.
func findOpenCode() (string, bool) {
if p, err := exec.LookPath("opencode"); err == nil {
return p, true
}
home, err := os.UserHomeDir()
if err != nil {
return "", false
}
name := "opencode"
if runtime.GOOS == "windows" {
name = "opencode.exe"
}
fallback := filepath.Join(home, ".opencode", "bin", name)
if _, err := os.Stat(fallback); err == nil {
return fallback, true
}
return "", false
}
func (o *OpenCode) Run(model string, models []LaunchModel, args []string) error {
opencodePath, ok := findOpenCode()
if !ok {
return fmt.Errorf("opencode is not installed, install from https://opencode.ai")
}
cmd := exec.Command(opencodePath, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if content := o.resolveContent(model, models); content != "" {
cmd.Env = append(cmd.Env, "OPENCODE_CONFIG_CONTENT="+content)
}
return cmd.Run()
}
// resolveContent returns the inline config to send via OPENCODE_CONFIG_CONTENT.
// Returns content built by Edit if available, otherwise builds from model.json
// with the requested model as primary (e.g. re-launch with saved config).
func (o *OpenCode) resolveContent(model string, models []LaunchModel) string {
if o.configContent != "" {
return o.configContent
}
resolvedModels := resolveOpenCodeRunModels(model, models, readModelJSONModels())
if len(resolvedModels) == 0 {
return ""
}
content, err := buildInlineConfig(resolvedModels[0], resolvedModels)
if err != nil {
return ""
}
return content
}
func resolveOpenCodeRunModels(primary string, models []LaunchModel, stateModels []string) []LaunchModel {
if primary == "" {
return nil
}
resolved := make([]LaunchModel, 0, 1+len(models)+len(stateModels))
appendModel := func(name string) {
if name == "" || hasLaunchModel(resolved, name) {
return
}
if model, ok := findLaunchModel(models, name); ok {
resolved = append(resolved, model)
return
}
resolved = append(resolved, fallbackLaunchModel(name))
}
appendModel(primary)
for _, model := range models {
appendModel(model.Name)
}
for _, model := range stateModels {
appendModel(model)
}
return resolved
}
func hasLaunchModel(models []LaunchModel, name string) bool {
for _, model := range models {
if launchModelMatches(model.Name, name) || launchModelMatches(name, model.Name) {
return true
}
}
return false
}
func (o *OpenCode) Paths() []string {
sp, err := openCodeStatePath()
if err != nil {
return nil
}
if _, err := os.Stat(sp); err == nil {
return []string{sp}
}
return nil
}
// openCodeStatePath returns the path to opencode's model state file.
// TODO: this hardcodes the Linux/macOS XDG path. On Windows, opencode stores
// state under %LOCALAPPDATA% (or similar) — verify and branch on runtime.GOOS.
func openCodeStatePath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "state", "opencode", "model.json"), nil
}
func (o *OpenCode) Edit(models []LaunchModel) error {
modelList := launchModelNames(models)
if len(modelList) == 0 {
return nil
}
content, err := buildInlineConfig(models[0], models)
if err != nil {
return err
}
o.configContent = content
// Write model state file so models appear in OpenCode's model picker
statePath, err := openCodeStatePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil {
return err
}
state := map[string]any{
"recent": []any{},
"favorite": []any{},
"variant": map[string]any{},
}
if data, err := os.ReadFile(statePath); err == nil {
_ = json.Unmarshal(data, &state) // Ignore parse errors; use defaults
}
recent, _ := state["recent"].([]any)
modelSet := make(map[string]bool)
for _, m := range modelList {
modelSet[m] = true
}
// Filter out existing Ollama models we're about to re-add
newRecent := slices.DeleteFunc(slices.Clone(recent), func(entry any) bool {
e, ok := entry.(map[string]any)
if !ok || e["providerID"] != "ollama" {
return false
}
modelID, _ := e["modelID"].(string)
return modelSet[modelID]
})
// Prepend models in reverse order so first model ends up first
for _, model := range slices.Backward(modelList) {
newRecent = slices.Insert(newRecent, 0, any(map[string]any{
"providerID": "ollama",
"modelID": model,
}))
}
const maxRecentModels = 10
newRecent = newRecent[:min(len(newRecent), maxRecentModels)]
state["recent"] = newRecent
stateData, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return fileutil.WriteWithBackup(statePath, stateData, "opencode")
}
func (o *OpenCode) Models() []string {
return nil
}
// buildInlineConfig produces the JSON string for OPENCODE_CONFIG_CONTENT.
// primary is the model to launch with, models is the full list of available models.
func buildInlineConfig(primary LaunchModel, models []LaunchModel) (string, error) {
if primary.Name == "" || len(models) == 0 {
return "", fmt.Errorf("buildInlineConfig: primary and models are required")
}
config := map[string]any{
"$schema": "https://opencode.ai/config.json",
"provider": map[string]any{
"ollama": map[string]any{
"npm": "@ai-sdk/openai-compatible",
"name": "Ollama",
"options": map[string]any{
"baseURL": envconfig.Host().String() + "/v1",
},
"models": buildModelEntries(models),
},
},
"model": "ollama/" + primary.Name,
}
data, err := json.Marshal(config)
if err != nil {
return "", err
}
return string(data), nil
}
// readModelJSONModels reads ollama model IDs from the opencode model.json state file
func readModelJSONModels() []string {
statePath, err := openCodeStatePath()
if err != nil {
return nil
}
data, err := os.ReadFile(statePath)
if err != nil {
return nil
}
var state map[string]any
if err := json.Unmarshal(data, &state); err != nil {
return nil
}
recent, _ := state["recent"].([]any)
var models []string
for _, entry := range recent {
e, ok := entry.(map[string]any)
if !ok {
continue
}
if e["providerID"] != "ollama" {
continue
}
if id, ok := e["modelID"].(string); ok && id != "" {
models = append(models, id)
}
}
return models
}
func buildModelEntries(modelList []LaunchModel) map[string]any {
models := make(map[string]any)
for _, model := range modelList {
entry := map[string]any{
"name": model.Name,
}
if model.HasCapability("vision") {
entry["modalities"] = map[string]any{
"input": []string{"text", "image"},
"output": []string{"text"},
}
}
if model.ContextLength > 0 || model.MaxOutputTokens > 0 {
limit := make(map[string]any)
if model.ContextLength > 0 {
limit["context"] = model.ContextLength
}
if model.MaxOutputTokens > 0 {
limit["output"] = model.MaxOutputTokens
}
entry["limit"] = limit
}
models[model.Name] = entry
}
return models
}