opencode-provider-switch/internal/app/service.go

548 lines
15 KiB
Go

package app
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/Apale7/opencode-provider-switch/internal/config"
"github.com/Apale7/opencode-provider-switch/internal/opencode"
"github.com/Apale7/opencode-provider-switch/internal/proxy"
)
type Service struct {
configPath string
traces *proxy.TraceStore
mu sync.Mutex
proxyCancel context.CancelFunc
proxyDone chan struct{}
proxyReady chan error
proxyErr error
proxyStatus ProxyStatusView
}
func NewService(configPath string) *Service {
return &Service{configPath: strings.TrimSpace(configPath), traces: proxy.NewTraceStore(200)}
}
func (s *Service) ConfigPath() string {
if s.configPath != "" {
return s.configPath
}
return config.DefaultPath()
}
func (s *Service) GetOverview(ctx context.Context) (Overview, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return Overview{}, err
}
aliases := cfg.AvailableAliasNames()
sort.Strings(aliases)
status := s.currentProxyStatus(proxyBindAddress(cfg))
return Overview{
ConfigPath: cfg.Path(),
ProviderCount: len(cfg.Providers),
AliasCount: len(cfg.Aliases),
AvailableAliases: aliases,
Proxy: status,
Desktop: desktopPrefsView(cfg.Desktop),
}, nil
}
func (s *Service) ListProviders(ctx context.Context) ([]ProviderView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return nil, err
}
providers := append([]config.Provider(nil), cfg.Providers...)
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
views := make([]ProviderView, 0, len(providers))
for _, provider := range providers {
views = append(views, providerView(provider))
}
return views, nil
}
func (s *Service) ListAliases(ctx context.Context) ([]AliasView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return nil, err
}
aliases := append([]config.Alias(nil), cfg.Aliases...)
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
views := make([]AliasView, 0, len(aliases))
for _, alias := range aliases {
views = append(views, aliasView(cfg, alias))
}
return views, nil
}
func (s *Service) RunDoctor(ctx context.Context) (DoctorReport, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return DoctorReport{}, err
}
issues := cfg.Validate()
path, existed := opencode.ResolveGlobalConfigPath()
raw, err := opencode.Load(path)
if err != nil {
issues = append(issues, fmt.Errorf("load opencode config target: %w", err))
} else {
aliasNames := cfg.AvailableAliasNames()
baseURL := proxyBaseURL(cfg)
opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if err := opencode.ValidateOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
issues = append(issues, fmt.Errorf("opencode provider.ocswitch invalid: %w", err))
}
}
report := DoctorReport{
OK: len(issues) == 0,
Issues: doctorIssues(issues),
ConfigPath: cfg.Path(),
ProviderCount: len(cfg.Providers),
AliasCount: len(cfg.Aliases),
ProxyBindAddress: proxyBindAddress(cfg),
OpenCodeTargetPath: path,
OpenCodeTargetFound: existed,
}
if report.OK {
return report, nil
}
return report, fmt.Errorf("%d config issue(s)", len(issues))
}
func (s *Service) PreviewOpenCodeSync(ctx context.Context, in SyncInput) (SyncPreview, error) {
prepared, err := s.prepareSync(ctx, in)
if err != nil {
return SyncPreview{}, err
}
return SyncPreview{
TargetPath: prepared.targetPath,
AliasNames: append([]string(nil), prepared.aliasNames...),
SetModel: in.SetModel,
SetSmallModel: in.SetSmallModel,
WouldChange: prepared.changed,
}, nil
}
func (s *Service) ApplyOpenCodeSync(ctx context.Context, in SyncInput) (SyncResult, error) {
prepared, err := s.prepareSync(ctx, in)
if err != nil {
return SyncResult{}, err
}
result := SyncResult{
TargetPath: prepared.targetPath,
AliasNames: append([]string(nil), prepared.aliasNames...),
Changed: prepared.changed,
DryRun: in.DryRun,
SetModel: in.SetModel,
SetSmallModel: in.SetSmallModel,
}
if !prepared.changed || in.DryRun {
return result, nil
}
if err := opencode.Save(prepared.targetPath, prepared.raw); err != nil {
return SyncResult{}, err
}
return result, nil
}
func (s *Service) StartProxy(ctx context.Context) error {
cfg, err := s.loadConfig()
if err != nil {
return err
}
if errs := cfg.Validate(); len(errs) > 0 {
return errs[0]
}
bindAddress := proxyBindAddress(cfg)
s.mu.Lock()
if s.proxyCancel == nil {
runCtx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
ready := make(chan error, 1)
s.proxyCancel = cancel
s.proxyDone = done
s.proxyReady = ready
s.proxyErr = nil
s.proxyStatus = ProxyStatusView{
Running: true,
BindAddress: bindAddress,
StartedAt: formatTimestamp(time.Now()),
}
go s.runProxy(runCtx, cancel, done, ready, cfg, bindAddress)
}
ready := s.proxyReady
done := s.proxyDone
s.mu.Unlock()
select {
case err := <-ready:
return err
case <-done:
return s.WaitProxy(context.Background())
case <-ctx.Done():
_ = s.StopProxy(context.Background())
return ctx.Err()
}
}
func (s *Service) StopProxy(ctx context.Context) error {
s.mu.Lock()
cancel := s.proxyCancel
done := s.proxyDone
s.mu.Unlock()
if cancel == nil || done == nil {
return nil
}
cancel()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) WaitProxy(ctx context.Context) error {
s.mu.Lock()
done := s.proxyDone
proxyErr := s.proxyErr
s.mu.Unlock()
if done == nil {
return proxyErr
}
select {
case <-done:
s.mu.Lock()
defer s.mu.Unlock()
return s.proxyErr
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) GetProxyStatus(ctx context.Context) (ProxyStatusView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return ProxyStatusView{}, err
}
return s.currentProxyStatus(proxyBindAddress(cfg)), nil
}
func (s *Service) GetProxySettings(ctx context.Context) (ProxySettingsView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return ProxySettingsView{}, err
}
return proxySettingsView(cfg.Server), nil
}
func (s *Service) SaveProxySettings(ctx context.Context, in ProxySettingsInput) (ProxySettingsSaveResult, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return ProxySettingsSaveResult{}, err
}
cfg.Server.ConnectTimeoutMs = normalizePositiveInt(in.ConnectTimeoutMs, config.DefaultConnectTimeoutMs)
cfg.Server.ResponseHeaderTimeoutMs = normalizePositiveInt(in.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs)
cfg.Server.FirstByteTimeoutMs = normalizePositiveInt(in.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
cfg.Server.RequestReadTimeoutMs = normalizePositiveInt(in.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs)
cfg.Server.StreamIdleTimeoutMs = normalizePositiveInt(in.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs)
if err := cfg.Save(); err != nil {
return ProxySettingsSaveResult{}, err
}
warnings := []string{}
status := s.currentProxyStatus(proxyBindAddress(cfg))
if status.Running {
warnings = append(warnings, "saved proxy timeout settings; restart proxy to apply changes")
}
return ProxySettingsSaveResult{Settings: proxySettingsView(cfg.Server), Warnings: warnings}, nil
}
func (s *Service) ListRequestTraces(ctx context.Context, limit int) ([]RequestTrace, error) {
_ = ctx
raw := s.traces.List(limit)
out := make([]RequestTrace, 0, len(raw))
for _, trace := range raw {
out = append(out, requestTraceView(trace))
}
return out, nil
}
func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return DesktopPrefsView{}, err
}
return desktopPrefsView(cfg.Desktop), nil
}
func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (DesktopPrefsView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return DesktopPrefsView{}, err
}
cfg.Desktop = config.Desktop{
LaunchAtLogin: in.LaunchAtLogin,
AutoStartProxy: in.AutoStartProxy,
MinimizeToTray: in.MinimizeToTray,
Notifications: in.Notifications,
Theme: normalizeThemePreference(in.Theme),
Language: normalizeLanguagePreference(in.Language),
}
if err := cfg.Save(); err != nil {
return DesktopPrefsView{}, err
}
return desktopPrefsView(cfg.Desktop), nil
}
type preparedSync struct {
targetPath string
aliasNames []string
raw opencode.Raw
changed bool
}
func (s *Service) prepareSync(ctx context.Context, in SyncInput) (preparedSync, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return preparedSync{}, err
}
if errs := cfg.Validate(); len(errs) > 0 {
return preparedSync{}, errs[0]
}
targetPath := strings.TrimSpace(in.Target)
if targetPath == "" {
resolved, _ := opencode.ResolveGlobalConfigPath()
targetPath = resolved
}
raw, err := opencode.Load(targetPath)
if err != nil {
return preparedSync{}, err
}
aliasNames := cfg.AvailableAliasNames()
sort.Strings(aliasNames)
if err := validateSyncedModelSelection(in.SetModel, aliasNames, "--set-model"); err != nil {
return preparedSync{}, err
}
if err := validateSyncedModelSelection(in.SetSmallModel, aliasNames, "--set-small-model"); err != nil {
return preparedSync{}, err
}
baseURL := proxyBaseURL(cfg)
changed := opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if in.SetModel != "" && raw["model"] != in.SetModel {
raw["model"] = in.SetModel
changed = true
}
if in.SetSmallModel != "" && raw["small_model"] != in.SetSmallModel {
raw["small_model"] = in.SetSmallModel
changed = true
}
return preparedSync{targetPath: targetPath, aliasNames: aliasNames, raw: raw, changed: changed}, nil
}
func (s *Service) loadConfig() (*config.Config, error) {
return config.Load(s.configPath)
}
func (s *Service) currentProxyStatus(bindAddress string) ProxyStatusView {
s.mu.Lock()
defer s.mu.Unlock()
status := s.proxyStatus
if !status.Running || status.BindAddress == "" {
status.BindAddress = bindAddress
}
return status
}
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, ready chan error, cfg *config.Config, bindAddress string) {
err := proxy.New(cfg, s.traces).ListenAndServeWithReady(runCtx, ready)
s.mu.Lock()
defer s.mu.Unlock()
if s.proxyDone == done {
s.proxyCancel = nil
s.proxyDone = nil
s.proxyReady = nil
}
s.proxyErr = err
status := s.proxyStatus
status.Running = false
status.BindAddress = bindAddress
if err != nil {
status.LastError = err.Error()
} else {
status.LastError = ""
}
s.proxyStatus = status
close(done)
}
func providerView(provider config.Provider) ProviderView {
return ProviderView{
ID: provider.ID,
Name: provider.Name,
BaseURL: provider.BaseURL,
APIKeySet: provider.APIKey != "",
APIKeyMasked: maskKey(provider.APIKey),
Headers: cloneHeaders(provider.Headers),
Models: append([]string(nil), provider.Models...),
ModelsSource: provider.ModelsSource,
Disabled: provider.Disabled,
}
}
func aliasView(cfg *config.Config, alias config.Alias) AliasView {
targets := make([]AliasTargetView, 0, len(alias.Targets))
for _, target := range alias.Targets {
targets = append(targets, AliasTargetView{
Provider: target.Provider,
Model: target.Model,
Enabled: target.Enabled,
})
}
return AliasView{
Alias: alias.Alias,
DisplayName: alias.DisplayName,
Enabled: alias.Enabled,
TargetCount: len(alias.Targets),
AvailableTargetCount: len(cfg.AvailableTargets(alias)),
Targets: targets,
}
}
func doctorIssues(errs []error) []DoctorIssue {
issues := make([]DoctorIssue, 0, len(errs))
for _, err := range errs {
issues = append(issues, DoctorIssue{Message: err.Error()})
}
return issues
}
func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
return DesktopPrefsView{
LaunchAtLogin: prefs.LaunchAtLogin,
AutoStartProxy: prefs.AutoStartProxy,
MinimizeToTray: prefs.MinimizeToTray,
Notifications: prefs.Notifications,
Theme: normalizeThemePreference(prefs.Theme),
Language: normalizeLanguagePreference(prefs.Language),
}
}
func proxySettingsView(server config.Server) ProxySettingsView {
return ProxySettingsView{
ConnectTimeoutMs: normalizePositiveInt(server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
ResponseHeaderTimeoutMs: normalizePositiveInt(server.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs),
FirstByteTimeoutMs: normalizePositiveInt(server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs),
RequestReadTimeoutMs: normalizePositiveInt(server.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs),
StreamIdleTimeoutMs: normalizePositiveInt(server.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs),
}
}
func normalizePositiveInt(value int, fallback int) int {
if value <= 0 {
return fallback
}
return value
}
func normalizeThemePreference(value string) string {
trimmed := strings.TrimSpace(value)
switch trimmed {
case "light", "dark":
return trimmed
default:
return "system"
}
}
func normalizeLanguagePreference(value string) string {
trimmed := strings.TrimSpace(value)
switch trimmed {
case "en-US", "zh-CN":
return trimmed
default:
return "system"
}
}
func validateSyncedModelSelection(value string, aliases []string, flagName string) error {
if value == "" {
return nil
}
const prefix = "ocswitch/"
if !strings.HasPrefix(value, prefix) {
return fmt.Errorf("%s must use the ocswitch/<alias> form", flagName)
}
alias := strings.TrimPrefix(value, prefix)
if alias == "" {
return fmt.Errorf("%s must use the ocswitch/<alias> form", flagName)
}
for _, name := range aliases {
if name == alias {
return nil
}
}
if len(aliases) == 0 {
return fmt.Errorf("%s requires at least one routable alias; run ocswitch alias list or doctor first", flagName)
}
choices := make([]string, 0, len(aliases))
for _, name := range aliases {
choices = append(choices, prefix+name)
}
return fmt.Errorf("%s %q is not a routable alias; available: %s", flagName, value, strings.Join(choices, ", "))
}
func proxyBindAddress(cfg *config.Config) string {
return fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
}
func proxyBaseURL(cfg *config.Config) string {
return fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
}
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 formatTimestamp(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format(time.RFC3339Nano)
}
func maskKey(k string) string {
if k == "" {
return ""
}
if len(k) <= 8 {
return "***"
}
return k[:4] + "…" + k[len(k)-4:]
}