opencode-provider-switch/internal/cli/provider.go
apale7 cf3dcec399 refactor(cli): rename tool to ocswitch
Avoid collision with the existing Opswitch product while keeping the repository name as opencode-provider-switch.

This aligns the CLI, provider key, config paths, examples, and Trellis history under one public-facing name.
2026-04-17 21:43:21 +08:00

341 lines
11 KiB
Go

package cli
import (
"fmt"
"sort"
"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 disabled 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. The command validates that shape, but it does not
contact the upstream or test credentials.
When updating an existing provider, omitted mutable fields keep their current
values: name, api key, headers, and disabled state are preserved unless you
explicitly pass new values. 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 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
}
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)
}
hdrs[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
p := config.Provider{
ID: id,
Name: name,
BaseURL: baseURL,
APIKey: apiKey,
Headers: hdrs,
Disabled: disabled,
}
if existing := cfg.FindProvider(id); existing != nil {
if p.Name == "" {
p.Name = existing.Name
}
if p.APIKey == "" {
p.APIKey = existing.APIKey
}
if len(headers) == 0 && len(existing.Headers) > 0 {
p.Headers = cloneHeaders(existing.Headers)
}
if !disabled {
p.Disabled = existing.Disabled
}
}
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)
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(&disabled, "disabled", false, "save provider in disabled state")
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 {
cfg, err := loadCfg()
if err != nil {
return err
}
providers := append([]config.Provider(nil), cfg.Providers...)
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
if len(providers) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "(no providers)")
return nil
}
for _, p := range providers {
key := "(none)"
if p.APIKey != "" {
key = maskKey(p.APIKey)
}
state := "enabled"
if !p.IsEnabled() {
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 both baseURL and apiKey
are imported. 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 {
if srcPath == "" {
p, existed := opencode.ResolveGlobalConfigPath()
if !existed {
return fmt.Errorf("no OpenCode config found at %s; use --from to specify", p)
}
srcPath = p
}
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
}
cfg.UpsertProvider(config.Provider{
ID: ip.ID,
Name: ip.Name,
BaseURL: ip.BaseURL,
APIKey: ip.APIKey,
Disabled: false,
})
imported++
fmt.Fprintf(cmd.OutOrStdout(), "import %q → %s (models: %s)\n", ip.ID, ip.BaseURL, strings.Join(ip.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
}
// 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:]
}