opencode-provider-switch/internal/cli/provider.go
apale7 5c4f60f2b7 feat(desktop): finish Windows GUI integration
Bring the Wails desktop shell to feature parity with the CLI for provider and alias management while keeping the browser fallback working. Wire tray, notifications, autostart, and GUI warnings so desktop flows behave predictably.
2026-04-19 01:02:16 +08:00

486 lines
17 KiB
Go

package cli
import (
"fmt"
"os"
"reflect"
"strings"
"github.com/spf13/cobra"
"github.com/Apale7/opencode-provider-switch/internal/config"
"github.com/Apale7/opencode-provider-switch/internal/opencode"
)
func newProviderCmd() *cobra.Command {
c := &cobra.Command{
Use: "provider",
Short: "Manage upstream providers",
Long: `Provider commands manage upstream OpenAI-compatible endpoints stored in the
local ocswitch config file.
Providers are separate from aliases: a provider defines connection details such
as base URL, API key, and extra headers, while aliases decide failover order by
binding one or more provider/model targets.
Common workflow: add or import providers first, inspect them with provider list,
then bind them to aliases with ocswitch alias bind.`,
Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example
ocswitch provider import-opencode
ocswitch provider list`,
}
c.AddCommand(
newProviderAddCmd(),
newProviderListCmd(),
newProviderEnableCmd(),
newProviderDisableCmd(),
newProviderRemoveCmd(),
newProviderImportCmd(),
)
return c
}
func newProviderAddCmd() *cobra.Command {
var id, name, baseURL, apiKey string
var headers []string
var clearHeaders bool
var disabled bool
var skipModels bool
cmd := &cobra.Command{
Use: "add",
Short: "Add or update an upstream provider",
Long: `provider add creates or updates one upstream provider entry in local ocswitch
config.
It writes only the ocswitch config file. --base-url must point at an
OpenAI-compatible /v1 root. By default the command also calls the upstream
/v1/models endpoint with the supplied credentials and stores the discovered
model list so later bind operations can catch typos early. Discovery failures
only emit warnings and do not block saving connection settings. Use
--skip-models when the upstream blocks model discovery or you only want to save
connection settings.
When updating an existing provider, omitted mutable fields keep their current
values: name, api key, headers, and disabled state are preserved unless the
corresponding flag is explicitly passed. Use --clear-headers to remove all saved
extra headers before storing the updated provider. Discovered model catalogs are refreshed when
possible. If connection details changed but discovery was skipped or failed, any
existing model catalog is kept only as untrusted metadata so later validation no
longer relies on stale entries. Repeated --header KEY=VALUE entries replace the
stored header map for this command invocation.
Typical next step: run ocswitch provider list or bind the provider to an alias.`,
Example: ` ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1
ocswitch provider add --id su8 --base-url https://cn2.su8.codes/v1 --api-key sk-example
ocswitch provider add --id relay --base-url https://example.com/v1 --api-key sk-example --header X-Token=abc --header X-Workspace=my-team
ocswitch provider add --id relay --base-url https://example.com/v1 --skip-models
ocswitch provider add --id su8 --base-url https://new.example.com/v1`,
RunE: func(cmd *cobra.Command, args []string) error {
if id == "" || baseURL == "" {
return fmt.Errorf("--id and --base-url are required")
}
if err := config.ValidateProviderBaseURL(baseURL); err != nil {
return fmt.Errorf("invalid --base-url: %w", err)
}
cfg, err := loadCfg()
if err != nil {
return err
}
apiKeyChanged := cmd.Flags().Changed("api-key")
headersChanged := cmd.Flags().Changed("header")
clearHeadersRequested := cmd.Flags().Changed("clear-headers") && clearHeaders
disabledChanged := cmd.Flags().Changed("disabled")
var hdrs map[string]string
for _, h := range headers {
k, v, ok := strings.Cut(h, "=")
if !ok {
return fmt.Errorf("invalid --header %q (want KEY=VALUE)", h)
}
key := strings.ToLower(strings.TrimSpace(k))
if key == "" {
return fmt.Errorf("invalid --header %q (header name must not be empty)", h)
}
if hdrs == nil {
hdrs = make(map[string]string, len(headers))
}
hdrs[key] = strings.TrimSpace(v)
}
p := config.Provider{
ID: id,
Name: name,
BaseURL: config.NormalizeProviderBaseURL(baseURL),
APIKey: apiKey,
Headers: normalizeProviderHeaders(hdrs),
Disabled: disabled,
}
connectionChanged := false
if existing := cfg.FindProvider(id); existing != nil {
if p.Name == "" {
p.Name = existing.Name
}
if !apiKeyChanged {
p.APIKey = existing.APIKey
}
if !headersChanged && !clearHeadersRequested && len(existing.Headers) > 0 {
p.Headers = cloneHeaders(existing.Headers)
}
if !disabledChanged {
p.Disabled = existing.Disabled
}
p.Models = append([]string(nil), existing.Models...)
p.ModelsSource = existing.ModelsSource
connectionChanged = !providerConnectionEqual(*existing, p)
}
if !skipModels {
models, err := opencode.FetchProviderModels(p.BaseURL, p.APIKey, p.Headers)
if err != nil {
if connectionChanged {
p.Models = append([]string(nil), p.Models...)
p.ModelsSource = ""
fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider connection changed and model discovery failed; keeping existing model catalog as untrusted")
}
fmt.Fprintf(cmd.ErrOrStderr(), "warning: could not discover provider models: %v\n", err)
} else if normalized := config.NormalizeProviderModels(models); len(normalized) > 0 {
p.Models = normalized
p.ModelsSource = "discovered"
} else {
if connectionChanged {
p.Models = append([]string(nil), p.Models...)
p.ModelsSource = ""
fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider connection changed and model discovery returned no models; keeping existing model catalog as untrusted")
} else {
fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider model discovery returned no models; keeping existing model catalog")
}
}
} else if connectionChanged {
p.Models = append([]string(nil), p.Models...)
p.ModelsSource = ""
fmt.Fprintln(cmd.ErrOrStderr(), "warning: provider connection changed with --skip-models; keeping existing model catalog as untrusted")
}
cfg.UpsertProvider(p)
if err := cfg.Save(); err != nil {
return err
}
state := "enabled"
if p.Disabled {
state = "disabled"
}
fmt.Fprintf(cmd.OutOrStdout(), "saved provider %q [%s] → %s\n", id, state, baseURL)
if !skipModels && p.ModelsSource == "discovered" {
fmt.Fprintf(cmd.OutOrStdout(), " discovered %d model(s)\n", len(p.Models))
}
return nil
},
}
cmd.Flags().StringVar(&id, "id", "", "provider id (required)")
cmd.Flags().StringVar(&name, "name", "", "display name")
cmd.Flags().StringVar(&baseURL, "base-url", "", "OpenAI-compatible base URL, including /v1 (required)")
cmd.Flags().StringVar(&apiKey, "api-key", "", "upstream API key")
cmd.Flags().StringArrayVar(&headers, "header", nil, "extra header KEY=VALUE (repeatable)")
cmd.Flags().BoolVar(&clearHeaders, "clear-headers", false, "remove all saved extra headers before applying updates")
cmd.Flags().BoolVar(&disabled, "disabled", false, "save provider in disabled state")
cmd.Flags().BoolVar(&skipModels, "skip-models", false, "skip provider /v1/models discovery")
if err := cmd.Flags().SetAnnotation("skip-models", cobra.BashCompOneRequiredFlag, []string{"false"}); err != nil {
panic(err)
}
return cmd
}
func cloneHeaders(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func newProviderListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List configured providers",
Long: `provider list prints the providers currently stored in local ocswitch config.
Output is inspection-oriented: provider ids, enabled state, base URLs, and
redacted API keys are shown so you can confirm what was saved or imported before
binding aliases.
This command does not modify config and does not contact upstream providers.`,
Example: ` ocswitch provider list
ocswitch --config /path/to/config.json provider list`,
RunE: func(cmd *cobra.Command, args []string) error {
providers, err := appService().ListProviders(cmd.Context())
if err != nil {
return err
}
if len(providers) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "(no providers)")
return nil
}
for _, p := range providers {
key := "(none)"
if p.APIKeySet {
key = p.APIKeyMasked
}
state := "enabled"
if p.Disabled {
state = "disabled"
}
fmt.Fprintf(cmd.OutOrStdout(), "%-20s [%s] %s apiKey=%s\n", p.ID, state, p.BaseURL, key)
}
return nil
},
}
}
func newProviderEnableCmd() *cobra.Command {
return newProviderStateCmd("enable", false)
}
func newProviderDisableCmd() *cobra.Command {
return newProviderStateCmd("disable", true)
}
func newProviderStateCmd(use string, disabled bool) *cobra.Command {
action := "enabled"
if disabled {
action = "disabled"
}
return &cobra.Command{
Use: use + " <id>",
Args: cobra.ExactArgs(1),
Short: strings.Title(action[:len(action)-1]) + " a provider without changing alias target state",
Long: fmt.Sprintf(`provider %s flips one provider's disabled state in local ocswitch config.
It changes routing eligibility for every alias target that references this
provider, but it does not rewrite alias target enabled flags. This matters when
the same provider is shared across multiple aliases.
This command writes only the ocswitch config file and does not test upstream
reachability. Typical next step: run ocswitch doctor to confirm routable aliases.`, use),
Example: fmt.Sprintf(` ocswitch provider %s <id>
ocswitch doctor`, use),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg()
if err != nil {
return err
}
existing := cfg.FindProvider(args[0])
if existing == nil {
return fmt.Errorf("provider %q not found", args[0])
}
updated := *existing
updated.Disabled = disabled
cfg.UpsertProvider(updated)
if err := cfg.Save(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s provider %q\n", action, args[0])
return nil
},
}
}
func newProviderRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <id>",
Args: cobra.ExactArgs(1),
Short: "Remove a provider (targets referencing it must be removed first or will fail doctor)",
Long: `provider remove deletes one provider from local ocswitch config.
It does not automatically clean alias target references that still point at the
removed provider. If aliases still reference it, doctor will report invalid
config and those aliases will not be routable.
Typical follow-up: inspect aliases, unbind stale targets, then run ocswitch doctor.`,
Example: ` ocswitch provider remove su8
ocswitch alias list
ocswitch doctor`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg()
if err != nil {
return err
}
if !cfg.RemoveProvider(args[0]) {
return fmt.Errorf("provider %q not found", args[0])
}
if err := cfg.Save(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "removed provider %q\n", args[0])
return nil
},
}
}
func newProviderImportCmd() *cobra.Command {
var srcPath string
var overwrite bool
cmd := &cobra.Command{
Use: "import-opencode",
Short: "Import @ai-sdk/openai custom providers from an OpenCode config file",
Long: `provider import-opencode reads an OpenCode config file and copies supported
custom providers into local ocswitch config.
By default it reads the global user OpenCode config resolved in precedence order
opencode.jsonc > opencode.json > config.json under ~/.config/opencode (XDG
aware). It does not follow OPENCODE_CONFIG_DIR for this default source; use
--from when you want a different file.
Only config-defined @ai-sdk/openai custom providers with baseURL are imported.
Imported baseURL values must still satisfy the local /v1 requirement. Providers
with an empty apiKey are allowed and kept as-is. Unsupported provider shapes are
skipped by design. Existing ocswitch providers are skipped unless --overwrite is
given.
Typical next step: run ocswitch provider list, then create aliases and bindings.`,
Example: ` ocswitch provider import-opencode
ocswitch provider import-opencode --from /path/to/opencode.jsonc
ocswitch provider import-opencode --overwrite`,
RunE: func(cmd *cobra.Command, args []string) error {
fromChanged := cmd.Flags().Changed("from")
if srcPath == "" {
p, existed := opencode.ResolveGlobalConfigPath()
if !existed {
return fmt.Errorf("no OpenCode config found at %s; use --from to specify", p)
}
srcPath = p
} else if fromChanged {
if _, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("read %s: %w", srcPath, err)
}
}
raw, err := opencode.Load(srcPath)
if err != nil {
return err
}
imports := opencode.ImportCustomProviders(raw)
if len(imports) == 0 {
fmt.Fprintf(cmd.OutOrStdout(), "no importable @ai-sdk/openai providers found in %s\n", srcPath)
return nil
}
cfg, err := loadCfg()
if err != nil {
return err
}
imported := 0
skipped := 0
for _, ip := range imports {
if !overwrite && cfg.FindProvider(ip.ID) != nil {
skipped++
fmt.Fprintf(cmd.OutOrStdout(), "skip %q (already exists, use --overwrite)\n", ip.ID)
continue
}
baseURL := config.NormalizeProviderBaseURL(ip.BaseURL)
if err := config.ValidateProviderBaseURL(baseURL); err != nil {
skipped++
fmt.Fprintf(cmd.OutOrStdout(), "skip %q (invalid baseURL %q: %v)\n", ip.ID, ip.BaseURL, err)
continue
}
existing := cfg.FindProvider(ip.ID)
merged := mergeImportedProvider(existing, opencode.ImportableProvider{
ID: ip.ID,
Name: ip.Name,
BaseURL: baseURL,
APIKey: ip.APIKey,
Models: ip.Models,
})
cfg.UpsertProvider(merged)
imported++
fmt.Fprintf(cmd.OutOrStdout(), "import %q → %s (models: %s)\n", ip.ID, baseURL, strings.Join(merged.Models, ","))
}
if imported > 0 {
if err := cfg.Save(); err != nil {
return err
}
}
fmt.Fprintf(cmd.OutOrStdout(), "imported=%d skipped=%d\n", imported, skipped)
return nil
},
}
cmd.Flags().StringVar(&srcPath, "from", "", "OpenCode config to read (default: global user config)")
cmd.Flags().BoolVar(&overwrite, "overwrite", false, "overwrite existing provider entries")
return cmd
}
func normalizeProviderHeaders(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
key := strings.ToLower(strings.TrimSpace(k))
if key == "" {
continue
}
out[key] = strings.TrimSpace(v)
}
if len(out) == 0 {
return nil
}
return out
}
func providerConnectionEqual(a, b config.Provider) bool {
return config.NormalizeProviderBaseURL(a.BaseURL) == config.NormalizeProviderBaseURL(b.BaseURL) &&
a.APIKey == b.APIKey &&
reflect.DeepEqual(normalizeProviderHeaders(a.Headers), normalizeProviderHeaders(b.Headers))
}
func providerCatalogEndpointEqual(a, b config.Provider) bool {
return config.NormalizeProviderBaseURL(a.BaseURL) == config.NormalizeProviderBaseURL(b.BaseURL) &&
a.APIKey == b.APIKey &&
reflect.DeepEqual(normalizeProviderHeaders(a.Headers), normalizeProviderHeaders(b.Headers))
}
func mergeImportedProvider(existing *config.Provider, ip opencode.ImportableProvider) config.Provider {
importedModels := config.NormalizeProviderModels(ip.Models)
merged := config.Provider{
ID: ip.ID,
Name: ip.Name,
BaseURL: config.NormalizeProviderBaseURL(ip.BaseURL),
APIKey: ip.APIKey,
Models: importedModels,
ModelsSource: "imported",
}
if len(importedModels) == 0 {
merged.ModelsSource = ""
}
if existing == nil {
return merged
}
merged.Headers = cloneHeaders(existing.Headers)
merged.Disabled = existing.Disabled
if merged.Name == "" {
merged.Name = existing.Name
}
if existing.ModelsSource == "discovered" {
prospective := merged
prospective.Headers = cloneHeaders(existing.Headers)
prospective.Disabled = existing.Disabled
if providerCatalogEndpointEqual(*existing, prospective) {
merged.Models = append([]string(nil), existing.Models...)
merged.ModelsSource = existing.ModelsSource
return merged
}
if len(importedModels) == 0 {
merged.Models = append([]string(nil), existing.Models...)
merged.ModelsSource = ""
return merged
}
}
if len(importedModels) == 0 {
merged.Models = nil
merged.ModelsSource = ""
}
return merged
}
// maskKey redacts middle characters of an API key for display.
func maskKey(k string) string {
if len(k) <= 8 {
return "***"
}
return k[:4] + "…" + k[len(k)-4:]
}