Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 219 additions & 1 deletion internal/opencode/models.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package opencode

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)

// DefaultCachePath returns the default path to the OpenCode models cache file.
Expand Down Expand Up @@ -341,19 +343,235 @@ func LoadConfigProviders(path string) (map[string]ConfigProvider, error) {
}
return map[string]ConfigProvider{}, err
}
return loadConfigProvidersData(path, data)
}

func loadConfigProvidersData(source string, data []byte) (map[string]ConfigProvider, error) {
var raw struct {
Provider map[string]ConfigProvider `json:"provider"`
}
if source == "OPENCODE_CONFIG_CONTENT" || strings.EqualFold(filepath.Ext(source), ".jsonc") {
data = stripJSONC(data)
}
if err := json.Unmarshal(data, &raw); err != nil {
return map[string]ConfigProvider{}, fmt.Errorf("parse opencode settings %q: %w", path, err)
return map[string]ConfigProvider{}, fmt.Errorf("parse opencode settings %q: %w", source, err)
}
if raw.Provider == nil {
return map[string]ConfigProvider{}, nil
}
return raw.Provider, nil
}

// LoadEffectiveConfigProviders loads custom providers from OpenCode's effective
// config sources: the global settings path plus project config discovered from
// the current working directory. Project config overrides global config.
func LoadEffectiveConfigProviders(settingsPath string) (map[string]ConfigProvider, error) {
cwd, err := os.Getwd()
if err != nil {
providers, loadErr := LoadConfigProviders(settingsPath)
if loadErr != nil {
return providers, loadErr
}
return providers, err
}
return LoadEffectiveConfigProvidersForDir(settingsPath, cwd)
}

// LoadEffectiveConfigProvidersForDir is the testable form of
// LoadEffectiveConfigProviders.
func LoadEffectiveConfigProvidersForDir(settingsPath, cwd string) (map[string]ConfigProvider, error) {
merged := map[string]ConfigProvider{}
var firstErr error

for _, path := range globalConfigPaths(settingsPath) {
providers, err := LoadConfigProviders(path)
if err != nil && firstErr == nil {
firstErr = err
}
merged = mergeConfigProviders(merged, providers)
}

if path := os.Getenv("OPENCODE_CONFIG"); path != "" {
providers, err := LoadConfigProviders(path)
if err != nil && firstErr == nil {
firstErr = err
}
merged = mergeConfigProviders(merged, providers)
}

for _, path := range projectConfigPaths(cwd) {
providers, err := LoadConfigProviders(path)
if err != nil && firstErr == nil {
firstErr = err
}
merged = mergeConfigProviders(merged, providers)
}

if content := os.Getenv("OPENCODE_CONFIG_CONTENT"); content != "" {
providers, err := loadConfigProvidersData("OPENCODE_CONFIG_CONTENT", []byte(content))
if err != nil && firstErr == nil {
firstErr = err
}
merged = mergeConfigProviders(merged, providers)
}

return merged, firstErr
}

func globalConfigPaths(settingsPath string) []string {
if settingsPath == "" {
return nil
}
if !strings.EqualFold(filepath.Base(settingsPath), "opencode.json") {
return []string{settingsPath}
}
// Always probe both opencode.json and opencode.jsonc so that a user who
// keeps providers in the .jsonc file (e.g. for comments) is not missed.
// The .jsonc entry comes last so its values win on merge.
jsoncPath := filepath.Join(filepath.Dir(settingsPath), "opencode.jsonc")
return []string{settingsPath, jsoncPath}
}

func projectConfigPaths(cwd string) []string {
var dirs []string
for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
dirs = append(dirs, dir)
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
break
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
}
Comment on lines +435 to +446

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Bound project discovery strictly to a detected Git root.

Current traversal appends every ancestor until / when .git is absent, so parent-directory opencode.json* files can be merged unexpectedly. That breaks the intended .git-scoped project resolution behavior.

💡 Proposed fix
 func projectConfigPaths(cwd string) []string {
 	var dirs []string
+	foundGit := false
 	for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
 		dirs = append(dirs, dir)
 		if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
+			foundGit = true
 			break
 		}
 		parent := filepath.Dir(dir)
 		if parent == dir {
 			break
 		}
 	}
+	if !foundGit {
+		return nil
+	}
 
 	paths := make([]string, 0, len(dirs)*3)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func projectConfigPaths(cwd string) []string {
var dirs []string
for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
dirs = append(dirs, dir)
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
break
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
}
func projectConfigPaths(cwd string) []string {
var dirs []string
foundGit := false
for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
dirs = append(dirs, dir)
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
foundGit = true
break
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
}
if !foundGit {
return nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/opencode/models.go` around lines 435 - 446, The projectConfigPaths
function continues traversing to the filesystem root when no .git directory is
found, which allows ancestor-directory opencode.json files to be unexpectedly
merged. Fix this by ensuring the traversal stops at the Git repository root:
once .git is found and the loop breaks, return only the directories collected up
to that point (those within the Git repository). If no .git is found before
reaching the filesystem root, limit the results to prevent merging configuration
files from outside the intended project scope.


paths := make([]string, 0, len(dirs)*3)
for i := len(dirs) - 1; i >= 0; i-- {
dir := dirs[i]
paths = append(paths,
filepath.Join(dir, "opencode.json"),
filepath.Join(dir, "opencode.jsonc"),
filepath.Join(dir, ".opencode", "opencode.json"),
)
}
return paths
}

func mergeConfigProviders(base, override map[string]ConfigProvider) map[string]ConfigProvider {
if len(override) == 0 {
return base
}
if base == nil {
base = map[string]ConfigProvider{}
}
for id, incoming := range override {
existing := base[id]
if incoming.Name != "" {
existing.Name = incoming.Name
}
if len(incoming.Models) > 0 {
if existing.Models == nil {
existing.Models = map[string]ConfigModel{}
}
for modelID, model := range incoming.Models {
existing.Models[modelID] = model
}
}
base[id] = existing
}
Comment on lines +460 to +481

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve full provider structs when merging new provider IDs.

mergeConfigProviders initializes existing := base[id] and only copies Name/Models. For first-time IDs, other parsed fields (e.g., provider metadata like npm/options) are dropped, corrupting merged config data.

💡 Proposed fix
 func mergeConfigProviders(base, override map[string]ConfigProvider) map[string]ConfigProvider {
 	if len(override) == 0 {
 		return base
 	}
 	if base == nil {
 		base = map[string]ConfigProvider{}
 	}
 	for id, incoming := range override {
-		existing := base[id]
+		existing, exists := base[id]
+		if !exists {
+			// Preserve full provider payload on first insert.
+			base[id] = incoming
+			continue
+		}
 		if incoming.Name != "" {
 			existing.Name = incoming.Name
 		}
 		if len(incoming.Models) > 0 {
 			if existing.Models == nil {
 				existing.Models = map[string]ConfigModel{}
 			}
 			for modelID, model := range incoming.Models {
 				existing.Models[modelID] = model
 			}
 		}
 		base[id] = existing
 	}
 	return base
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func mergeConfigProviders(base, override map[string]ConfigProvider) map[string]ConfigProvider {
if len(override) == 0 {
return base
}
if base == nil {
base = map[string]ConfigProvider{}
}
for id, incoming := range override {
existing := base[id]
if incoming.Name != "" {
existing.Name = incoming.Name
}
if len(incoming.Models) > 0 {
if existing.Models == nil {
existing.Models = map[string]ConfigModel{}
}
for modelID, model := range incoming.Models {
existing.Models[modelID] = model
}
}
base[id] = existing
}
func mergeConfigProviders(base, override map[string]ConfigProvider) map[string]ConfigProvider {
if len(override) == 0 {
return base
}
if base == nil {
base = map[string]ConfigProvider{}
}
for id, incoming := range override {
existing, exists := base[id]
if !exists {
// Preserve full provider payload on first insert.
base[id] = incoming
continue
}
if incoming.Name != "" {
existing.Name = incoming.Name
}
if len(incoming.Models) > 0 {
if existing.Models == nil {
existing.Models = map[string]ConfigModel{}
}
for modelID, model := range incoming.Models {
existing.Models[modelID] = model
}
}
base[id] = existing
}
return base
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/opencode/models.go` around lines 460 - 481, The
`mergeConfigProviders` function initializes `existing` as an empty struct and
only copies the `Name` and `Models` fields, which causes other ConfigProvider
fields to be lost when processing new provider IDs. Fix this by copying the full
`incoming` provider to `existing` first when the provider ID is new to the base
map, then merge specific fields like `Models` on top if needed, ensuring all
provider metadata and options are preserved in the merged configuration.

return base
}

func stripJSONC(data []byte) []byte {
var out bytes.Buffer
inString := false
escaped := false
for i := 0; i < len(data); i++ {
c := data[i]
if inString {
out.WriteByte(c)
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '"' {
inString = false
}
continue
}

if c == '"' {
inString = true
out.WriteByte(c)
continue
}
if c == '/' && i+1 < len(data) && data[i+1] == '/' {
for i < len(data) && data[i] != '\n' {
i++
}
if i < len(data) {
out.WriteByte(data[i])
}
continue
}
if c == '/' && i+1 < len(data) && data[i+1] == '*' {
i += 2
for i+1 < len(data) && !(data[i] == '*' && data[i+1] == '/') {
i++
}
i++
continue
}
out.WriteByte(c)
}

return removeTrailingJSONCommas(out.Bytes())
}

func removeTrailingJSONCommas(data []byte) []byte {
var out bytes.Buffer
inString := false
escaped := false
for i := 0; i < len(data); i++ {
c := data[i]
if inString {
out.WriteByte(c)
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '"' {
inString = false
}
continue
}
if c == '"' {
inString = true
out.WriteByte(c)
continue
}
if c == ',' {
j := i + 1
for j < len(data) && (data[j] == ' ' || data[j] == '\t' || data[j] == '\r' || data[j] == '\n') {
j++
}
if j < len(data) && (data[j] == '}' || data[j] == ']') {
continue
}
}
out.WriteByte(c)
}
return out.Bytes()
}

// MergeCustomProviders merges custom providers from opencode.json into the cache-loaded
// providers map. Custom models use the tool_call value from opencode.json, defaulting to false
// when omitted. Custom entries win on ID collision (user-managed beats cached catalog).
Expand Down
Loading
Loading