mirror of
https://github.com/ollama/ollama.git
synced 2026-04-18 06:54:09 +02:00
299 lines
8.1 KiB
Go
299 lines
8.1 KiB
Go
package launch
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/ollama/ollama/api"
|
|
"github.com/ollama/ollama/cmd/internal/fileutil"
|
|
"github.com/ollama/ollama/envconfig"
|
|
modeltype "github.com/ollama/ollama/types/model"
|
|
)
|
|
|
|
// 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, 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); 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) string {
|
|
if o.configContent != "" {
|
|
return o.configContent
|
|
}
|
|
models := readModelJSONModels()
|
|
if !slices.Contains(models, model) {
|
|
models = append([]string{model}, models...)
|
|
}
|
|
content, err := buildInlineConfig(model, models)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return content
|
|
}
|
|
|
|
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(modelList []string) error {
|
|
if len(modelList) == 0 {
|
|
return nil
|
|
}
|
|
|
|
content, err := buildInlineConfig(modelList[0], modelList)
|
|
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)
|
|
}
|
|
|
|
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 string, models []string) (string, error) {
|
|
if primary == "" || 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,
|
|
}
|
|
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 []string) map[string]any {
|
|
client := api.NewClient(envconfig.Host(), http.DefaultClient)
|
|
ctx := context.Background()
|
|
|
|
models := make(map[string]any)
|
|
for _, model := range modelList {
|
|
entry := map[string]any{
|
|
"name": model,
|
|
}
|
|
if isCloudModelName(model) {
|
|
if l, ok := lookupCloudModelLimit(model); ok {
|
|
entry["limit"] = map[string]any{
|
|
"context": l.Context,
|
|
"output": l.Output,
|
|
}
|
|
}
|
|
}
|
|
applyOpenCodeReasoning(ctx, client, model, entry)
|
|
models[model] = entry
|
|
}
|
|
return models
|
|
}
|
|
|
|
// applyOpenCodeReasoning detects thinking capability and sets reasoning config
|
|
// on the model entry. When the model supports thinking, it sets "reasoning": true
|
|
// and configures variants for the OpenCode TUI:
|
|
// - GPT-OSS: supports variable effort levels (low/medium/high) and defaults to
|
|
// medium via options. Thinking cannot be turned off.
|
|
// - Other models: only support on/off. Disables built-in low/medium/high variants
|
|
// and adds a "none" variant so users can toggle thinking off via Ctrl+T.
|
|
//
|
|
// When the model does not support thinking, no reasoning config is set.
|
|
func applyOpenCodeReasoning(ctx context.Context, client *api.Client, modelName string, entry map[string]any) {
|
|
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelName})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if slices.Contains(resp.Capabilities, modeltype.CapabilityThinking) {
|
|
entry["reasoning"] = true
|
|
|
|
if strings.Contains(modelName, "gpt-oss") {
|
|
// GPT-OSS models support variable thinking effort levels
|
|
// and cannot turn thinking off. Keep the built-in
|
|
// low/medium/high variants as-is and default to medium.
|
|
options, ok := entry["options"].(map[string]any)
|
|
if !ok {
|
|
options = make(map[string]any)
|
|
}
|
|
options["reasoningEffort"] = "medium"
|
|
entry["options"] = options
|
|
} else {
|
|
// Most models only support thinking on or off.
|
|
// Disable the built-in low/medium/high variants and add none.
|
|
entry["variants"] = map[string]any{
|
|
"none": map[string]any{"reasoningEffort": "none"},
|
|
"low": map[string]any{"disabled": true},
|
|
"medium": map[string]any{"disabled": true},
|
|
"high": map[string]any{"disabled": true},
|
|
}
|
|
}
|
|
}
|
|
}
|