Skip to content

feat(opencode): load custom providers in model picker#946

Open
mauricioalfarodev wants to merge 2 commits into
Gentleman-Programming:mainfrom
mauricioalfarodev:feat/opencode-custom-providers
Open

feat(opencode): load custom providers in model picker#946
mauricioalfarodev wants to merge 2 commits into
Gentleman-Programming:mainfrom
mauricioalfarodev:feat/opencode-custom-providers

Conversation

@mauricioalfarodev

@mauricioalfarodev mauricioalfarodev commented Jun 23, 2026

Copy link
Copy Markdown

Linked Issue

Closes #TODO


PR Type

What kind of change does this PR introduce?

  • type:bug — Bug fix (non-breaking change that fixes an issue)
  • type:feature — New feature (non-breaking change that adds functionality)
  • type:docs — Documentation only
  • type:refactor — Code refactoring (no functional changes)
  • type:chore — Build, CI, or tooling changes
  • type:breaking-change — Breaking change (fix or feature that changes existing behavior)

Summary

  • Adds OpenCode custom provider discovery so project and global OpenCode config providers can appear in the Gentle AI model picker.
  • Loads providers from opencode.json, opencode.jsonc, .opencode.json, .opencode.jsonc, and inline OpenCode config content with explicit precedence.
  • Fixes the global config edge case where providers in opencode.jsonc were skipped when opencode.json already existed.

Changes

File / Area What Changed
internal/opencode/models.go Adds OpenCode custom provider loading from global, project, and inline config sources, including JSONC support and merge precedence.
internal/opencode/models_test.go Adds coverage for provider parsing, source precedence, project config loading, inline config loading, and the .jsonc global config regression.
internal/tui/screens/model_picker.go Wires OpenCode custom providers into the model picker data shown to users.
internal/tui/screens/model_picker_test.go Adds model picker coverage for displaying custom OpenCode providers.

Test Plan

Unit Tests

go test ./internal/opencode

Local Verification

git diff --check
  • Unit tests pass (go test ./internal/opencode)
  • E2E tests pass (cd e2e && ./docker-test.sh)
  • Manually tested locally

Notes:

  • git diff --check passed locally before the latest commit.
  • go test ./internal/opencode could not be run in this terminal because go is not available in PATH.
  • E2E tests were not run in this terminal.

Automated Checks

The following checks run automatically on this PR:

Check Status Description
Check PR Cognitive Load Pending PR should stay within 400 changed lines (additions + deletions) or use size:exception
Check Issue Reference Pending PR body must contain Closes/Fixes/Resolves #N
Check Issue Has status:approved Pending Linked issue must have been approved before work began
Check PR Has type:* Label Pending Exactly one type:* label must be applied
Unit Tests Pending go test ./... must pass
E2E Tests Pending cd e2e && ./docker-test.sh must pass

Contributor Checklist

  • PR is linked to an issue with status:approved
  • PR stays within 400 changed lines, or I have requested/obtained maintainer-applied size:exception with rationale documented
  • I have added the appropriate type:* label to this PR
  • Unit tests pass (go test ./...)
  • E2E tests pass (cd e2e && ./docker-test.sh)
  • I have updated documentation if necessary
  • My commits follow Conventional Commits format
  • My commits do not include Co-Authored-By trailers

Notes for Reviewers

Replace #TODO with the approved issue number before creating the PR, and add the type:feature label after opening it.

This PR is 551 changed lines against Gentleman-Programming/gentle-ai:main, so it needs either a maintainer-applied size:exception label or a split into smaller PRs before the policy check can pass.

Summary by CodeRabbit

  • New Features
    • Configuration files now support JSONC format (JSON with comments) for improved readability
    • Automatic discovery and merging of project-level configurations with global settings, with project configurations taking precedence

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds JSONC comment-stripping and trailing-comma removal utilities to the config loader, refactors LoadConfigProviders to use them, and introduces LoadEffectiveConfigProviders/LoadEffectiveConfigProvidersForDir that merge global and project-scoped configs discovered by walking up to .git. The TUI model picker is updated to call the new effective loader.

Changes

Effective Config Provider Loading

Layer / File(s) Summary
JSONC normalization and LoadConfigProviders refactor
internal/opencode/models.go, internal/opencode/models_test.go
Adds bytes/strings imports; refactors LoadConfigProviders to delegate to new loadConfigProvidersData helper that conditionally strips JSONC; implements stripJSONC and removeTrailingJSONCommas. Tests cover .jsonc fixtures, comment-inside-string edge cases, trailing commas, and unterminated block comments.
Effective provider discovery, merge logic, and exported loaders
internal/opencode/models.go, internal/opencode/models_test.go
Adds LoadEffectiveConfigProviders, LoadEffectiveConfigProvidersForDir, globalConfigPaths, projectConfigPaths, and mergeConfigProviders. Tests assert project config inclusion, multi-source precedence (OPENCODE_CONFIG/OPENCODE_CONFIG_CONTENT/global JSONC/project JSON), JSONC global fallback, and inline JSONC via env variable.
TUI model picker wiring and integration test
internal/tui/screens/model_picker.go, internal/tui/screens/model_picker_test.go
Switches NewModelPickerState to call opencode.LoadEffectiveConfigProviders. Test constructs cache, global, and project JSONC configs with a .git marker, chdirs into the project, and asserts both providers appear in picker state. Adds containsString helper.

Sequence Diagram(s)

sequenceDiagram
    participant TUI as NewModelPickerState
    participant Loader as LoadEffectiveConfigProvidersForDir
    participant Global as globalConfigPaths
    participant Project as projectConfigPaths
    participant Parser as loadConfigProvidersData
    participant Merger as mergeConfigProviders

    TUI->>Loader: settingsPath (+ os.Getwd() as cwd)
    Loader->>Global: settingsPath → opencode.json, opencode.jsonc
    Loader->>Project: cwd → walk to .git, collect opencode.json/jsonc/.opencode/opencode.json
    loop each discovered config path
        Loader->>Parser: path / content
        Parser->>Parser: stripJSONC + removeTrailingJSONCommas (if .jsonc or OPENCODE_CONFIG_CONTENT)
        Parser-->>Loader: providers map
        Loader->>Merger: accumulated + new providers
        Merger-->>Loader: merged map (project overrides global)
    end
    Loader-->>TUI: merged providers, firstErr
    TUI->>TUI: merge into picker state, set ConfigWarning if firstErr != nil
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(opencode): load custom providers in model picker' directly and accurately describes the main change: integrating OpenCode custom provider discovery into the model picker component.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/tui/screens/model_picker.go (1)

88-110: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update warning text to reflect effective config sources.

After switching to LoadEffectiveConfigProviders, the warning still says opencode.json, which is misleading for env/project/global-source failures.

💡 Proposed fix
 	var configWarning string
 	if configErr != nil {
-		configWarning = fmt.Sprintf("Could not load custom providers from opencode.json: %v", configErr)
+		configWarning = fmt.Sprintf("Could not load custom providers from OpenCode config sources: %v", configErr)
 	}
🤖 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/tui/screens/model_picker.go` around lines 88 - 110, The
configWarning message in the model_picker.go file incorrectly references only
opencode.json as the configuration source, but the function
LoadEffectiveConfigProviders loads configuration from multiple effective sources
(environment, project, global configs, etc.). Update the warning message that is
assigned to configWarning when configErr is not nil to accurately reflect that
it could be failing to load custom providers from any of the effective
configuration sources rather than just the opencode.json file specifically.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@internal/opencode/models_test.go`:
- Around line 663-676: The test setup for validating project inclusion is
missing a .git marker in projectDir, which means it cannot properly test
git-root boundary behavior. Add code to create a .git directory in projectDir
using os.Mkdir or os.MkdirAll after the workDir is created and before the
WriteFile call for opencode.json. This ensures the test matches the intended
discovery contract by establishing a clear git repository boundary at
projectDir.

In `@internal/opencode/models.go`:
- Around line 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.
- Around line 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.

---

Outside diff comments:
In `@internal/tui/screens/model_picker.go`:
- Around line 88-110: The configWarning message in the model_picker.go file
incorrectly references only opencode.json as the configuration source, but the
function LoadEffectiveConfigProviders loads configuration from multiple
effective sources (environment, project, global configs, etc.). Update the
warning message that is assigned to configWarning when configErr is not nil to
accurately reflect that it could be failing to load custom providers from any of
the effective configuration sources rather than just the opencode.json file
specifically.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1c95c3e1-cef9-4c1e-808b-ee44af0f51b5

📥 Commits

Reviewing files that changed from the base of the PR and between aa33ce5 and 413ae32.

📒 Files selected for processing (4)
  • internal/opencode/models.go
  • internal/opencode/models_test.go
  • internal/tui/screens/model_picker.go
  • internal/tui/screens/model_picker_test.go

Comment on lines +663 to +676
projectDir := filepath.Join(dir, "repo")
workDir := filepath.Join(projectDir, "subdir")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatalf("create project dir: %v", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "opencode.json"), []byte(`{
"provider": {
"lite-llm": {"name": "LITE-LLM", "models": {"proxy-model": {"name": "Proxy", "tool_call": true}}},
"shared": {"name": "Project Shared", "models": {"project-model": {"name": "Project", "tool_call": true}}}
}
}`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen this test by adding a .git marker.

This case currently validates project inclusion without creating .git, so it won’t catch regressions in git-root boundary behavior. Add .git in projectDir so the test matches the intended discovery contract.

🤖 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_test.go` around lines 663 - 676, The test setup for
validating project inclusion is missing a .git marker in projectDir, which means
it cannot properly test git-root boundary behavior. Add code to create a .git
directory in projectDir using os.Mkdir or os.MkdirAll after the workDir is
created and before the WriteFile call for opencode.json. This ensures the test
matches the intended discovery contract by establishing a clear git repository
boundary at projectDir.

Comment on lines +435 to +446
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
}
}

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.

Comment on lines +460 to +481
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
}

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant