mirror of
https://github.com/ollama/ollama.git
synced 2026-05-22 13:42:25 -05:00
295 lines
7.5 KiB
Go
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
|
|
}
|