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.
486 lines
17 KiB
Go
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:]
|
|
}
|