diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 00000000..8ce074e9 --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" +) + +func authCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Authenticate with LLM providers via OAuth", + Long: "Manage OAuth authentication via the running gateway. Requires the gateway to be running.", + } + cmd.AddCommand(authStatusCmd()) + cmd.AddCommand(authLogoutCmd()) + return cmd +} + +// gatewayURL returns the base URL for the running gateway. +func gatewayURL() string { + if u := os.Getenv("GOCLAW_GATEWAY_URL"); u != "" { + return strings.TrimRight(u, "/") + } + host := os.Getenv("GOCLAW_HOST") + if host == "" { + host = "127.0.0.1" + } + port := os.Getenv("GOCLAW_PORT") + if port == "" { + port = "3577" + } + return fmt.Sprintf("http://%s:%s", host, port) +} + +// gatewayRequest sends an authenticated request to the running gateway. +func gatewayRequest(method, path string) (map[string]interface{}, error) { + url := gatewayURL() + path + req, err := http.NewRequest(method, url, nil) + if err != nil { + return nil, err + } + + if token := os.Getenv("GOCLAW_TOKEN"); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot reach gateway at %s: %w", gatewayURL(), err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("invalid response from gateway: %s", string(body)) + } + + if resp.StatusCode >= 400 { + if msg, ok := result["error"].(string); ok { + return nil, fmt.Errorf("gateway error: %s", msg) + } + return nil, fmt.Errorf("gateway returned status %d", resp.StatusCode) + } + + return result, nil +} + +func authStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show OAuth authentication status", + Long: "Check if ChatGPT OAuth is configured on the running gateway.", + RunE: func(cmd *cobra.Command, args []string) error { + result, err := gatewayRequest("GET", "/v1/auth/openai/status") + if err != nil { + return err + } + + if auth, _ := result["authenticated"].(bool); auth { + name, _ := result["provider_name"].(string) + fmt.Printf("OpenAI OAuth: active (provider: %s)\n", name) + fmt.Println("Use model prefix 'openai-codex/' in agent config (e.g. openai-codex/gpt-4o).") + } else { + fmt.Println("No OAuth tokens found.") + fmt.Println("Use the web UI to authenticate with ChatGPT OAuth.") + } + return nil + }, + } +} + +func authLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout [provider]", + Short: "Remove stored OAuth tokens", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + provider := "openai" + if len(args) > 0 { + provider = args[0] + } + + if provider != "openai" { + return fmt.Errorf("unknown provider: %s (supported: openai)", provider) + } + + _, err := gatewayRequest("POST", "/v1/auth/openai/logout") + if err != nil { + return err + } + + fmt.Println("OpenAI OAuth token removed.") + return nil + }, + } +} diff --git a/cmd/gateway.go b/cmd/gateway.go index 1c9d8a3f..58bf7b69 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -25,6 +25,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/gateway/methods" + httpapi "github.com/nextlevelbuilder/goclaw/internal/http" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/providers" @@ -369,7 +370,7 @@ func runGateway() { // Register providers from DB (overrides config providers). if pgStores.Providers != nil { - registerProvidersFromDB(providerRegistry, pgStores.Providers) + registerProvidersFromDB(providerRegistry, pgStores.Providers, pgStores.ConfigSecrets) } // Wire embedding provider to PGMemoryStore so IndexDocument generates vectors. @@ -556,6 +557,7 @@ func runGateway() { server.SetDB(pgStores.DB) server.SetPolicyEngine(permPE) server.SetPairingService(pgStores.Pairing) + server.SetOAuthHandler(httpapi.NewOAuthHandler(cfg.Gateway.Token, pgStores.Providers, pgStores.ConfigSecrets, providerRegistry)) // contextFileInterceptor is created inside wireExtras. // Declared here so it can be passed to registerAllMethods → AgentsMethods diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index f73f10d1..dfee694c 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -519,7 +519,7 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR } if stores != nil && stores.Providers != nil { - providersH = httpapi.NewProvidersHandler(stores.Providers, token, providerReg) + providersH = httpapi.NewProvidersHandler(stores.Providers, stores.ConfigSecrets, token, providerReg) } if stores != nil && stores.Teams != nil { diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index e0396d76..11e95dba 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -5,6 +5,7 @@ import ( "log/slog" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/oauth" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -84,7 +85,7 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) { // registerProvidersFromDB loads providers from Postgres and registers them. // DB providers are registered after config providers, so they take precedence (overwrite). -func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore) { +func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore) { ctx := context.Background() dbProviders, err := provStore.ListProviders(ctx) if err != nil { @@ -95,7 +96,10 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi if !p.Enabled || p.APIKey == "" { continue } - if p.ProviderType == store.ProviderAnthropicNative { + if p.ProviderType == store.ProviderChatGPTOAuth { + ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name) + registry.Register(providers.NewCodexProvider(p.Name, ts, p.APIBase, "")) + } else if p.ProviderType == store.ProviderAnthropicNative { registry.Register(providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(p.APIBase))) } else if p.ProviderType == store.ProviderDashScope { diff --git a/cmd/root.go b/cmd/root.go index f0191fc7..543f79f3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -43,6 +43,7 @@ func init() { rootCmd.AddCommand(sessionsCmd()) rootCmd.AddCommand(migrateCmd()) rootCmd.AddCommand(upgradeCmd()) + rootCmd.AddCommand(authCmd()) } func versionCmd() *cobra.Command { diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 0e04a5b7..50d4d37e 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -571,6 +571,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) Content: resp.Content, Thinking: resp.Thinking, // reasoning_content passback for thinking models (Kimi, DeepSeek) ToolCalls: resp.ToolCalls, + Phase: resp.Phase, // preserve Codex phase metadata (gpt-5.3-codex) RawAssistantContent: resp.RawAssistantContent, // preserve thinking blocks for Anthropic passback } messages = append(messages, assistantMsg) diff --git a/internal/config/config.go b/internal/config/config.go index ff23c4fb..747c8716 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,7 +52,7 @@ type Config struct { Telemetry TelemetryConfig `json:"telemetry,omitempty"` Tailscale TailscaleConfig `json:"tailscale,omitempty"` Bindings []AgentBinding `json:"bindings,omitempty"` - mu sync.RWMutex + mu sync.RWMutex } // TailscaleConfig configures the optional Tailscale tsnet listener. diff --git a/internal/config/config_load.go b/internal/config/config_load.go index f9aea804..19143c0b 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -143,7 +143,7 @@ func (c *Config) applyEnvOverrides() { c.Channels.WhatsApp.Enabled = true } - // Allow overriding default provider/model + // Default provider/model: env overrides config. envStr("GOCLAW_PROVIDER", &c.Agents.Defaults.Provider) envStr("GOCLAW_MODEL", &c.Agents.Defaults.Model) diff --git a/internal/gateway/server.go b/internal/gateway/server.go index c24c857d..28c04eb8 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -42,6 +42,7 @@ type Server struct { providersHandler *httpapi.ProvidersHandler // provider CRUD API delegationsHandler *httpapi.DelegationsHandler // delegation history API builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API + oauthHandler *httpapi.OAuthHandler // OAuth endpoints agentStore store.AgentStore // for context injection in tools_invoke upgrader websocket.Upgrader @@ -192,6 +193,11 @@ func (s *Server) BuildMux() *http.ServeMux { s.builtinToolsHandler.RegisterRoutes(mux) } + // OAuth endpoints (available in all modes) + if s.oauthHandler != nil { + s.oauthHandler.RegisterRoutes(mux) + } + s.mux = mux return mux } @@ -302,6 +308,9 @@ func (s *Server) SetBuiltinToolsHandler(h *httpapi.BuiltinToolsHandler) { s.builtinToolsHandler = h } +// SetOAuthHandler sets the OAuth handler (available in all modes). +func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.oauthHandler = h } + // SetAgentStore sets the agent store for context injection in tools_invoke. func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as } diff --git a/internal/http/oauth.go b/internal/http/oauth.go new file mode 100644 index 00000000..e160d4cb --- /dev/null +++ b/internal/http/oauth.go @@ -0,0 +1,221 @@ +package http + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/oauth" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// OAuthHandler handles OAuth-related HTTP endpoints for web UI. +type OAuthHandler struct { + token string // gateway auth token + provStore store.ProviderStore + secretStore store.ConfigSecretsStore + providerReg *providers.Registry + + mu sync.Mutex + pending *oauth.PendingLogin // active OAuth flow (if any) +} + +// NewOAuthHandler creates a handler for OAuth endpoints. +func NewOAuthHandler(token string, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, providerReg *providers.Registry) *OAuthHandler { + return &OAuthHandler{ + token: token, + provStore: provStore, + secretStore: secretStore, + providerReg: providerReg, + } +} + +// RegisterRoutes registers OAuth routes on the given mux. +func (h *OAuthHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/auth/openai/status", h.auth(h.handleStatus)) + mux.HandleFunc("POST /v1/auth/openai/start", h.auth(h.handleStart)) + mux.HandleFunc("POST /v1/auth/openai/callback", h.auth(h.handleManualCallback)) + mux.HandleFunc("POST /v1/auth/openai/logout", h.auth(h.handleLogout)) +} + +func (h *OAuthHandler) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !tokenMatch(extractBearerToken(r), h.token) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + next(w, r) + } +} + +func (h *OAuthHandler) newTokenSource() *oauth.DBTokenSource { + return oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName) +} + +func (h *OAuthHandler) handleStatus(w http.ResponseWriter, r *http.Request) { + ts := h.newTokenSource() + if !ts.Exists(r.Context()) { + writeJSON(w, http.StatusOK, map[string]any{"authenticated": false}) + return + } + + if _, err := ts.Token(); err != nil { + writeJSON(w, http.StatusOK, map[string]any{ + "authenticated": false, + "error": "token invalid or expired", + }) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "authenticated": true, + "provider_name": oauth.DefaultProviderName, + }) +} + +func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) { + h.mu.Lock() + defer h.mu.Unlock() + + // Already authenticated? + ts := h.newTokenSource() + if ts.Exists(r.Context()) { + if _, err := ts.Token(); err == nil { + writeJSON(w, http.StatusOK, map[string]any{"status": "already_authenticated"}) + return + } + } + + // Shut down any previous pending flow to release port 1455 + if h.pending != nil { + h.pending.Shutdown() + h.pending = nil + } + + pending, err := oauth.StartLoginOpenAI() + if err != nil { + slog.Error("oauth.start", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to start OAuth flow (is port 1455 available?)", + }) + return + } + + h.pending = pending + + // Wait for callback in background, save token when done + go h.waitForCallback(pending) + + writeJSON(w, http.StatusOK, map[string]any{"auth_url": pending.AuthURL}) +} + +// waitForCallback waits for the OAuth callback and saves the token. +func (h *OAuthHandler) waitForCallback(pending *oauth.PendingLogin) { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) + defer cancel() + + tokenResp, err := pending.Wait(ctx) + + h.mu.Lock() + if h.pending == pending { + h.pending = nil + } + h.mu.Unlock() + + if err != nil { + slog.Warn("oauth.callback failed", "error", err) + return + } + + if _, err := h.saveAndRegister(ctx, tokenResp); err != nil { + slog.Error("oauth.save_token", "error", err) + return + } + + slog.Info("oauth: OpenAI token saved via web UI callback") +} + +func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Request) { + var body struct { + RedirectURL string `json:"redirect_url"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RedirectURL == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "redirect_url is required"}) + return + } + + h.mu.Lock() + pending := h.pending + h.mu.Unlock() + + if pending == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no pending OAuth flow"}) + return + } + + tokenResp, err := pending.ExchangeRedirectURL(body.RedirectURL) + if err != nil { + slog.Warn("oauth.manual_callback", "error", err) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + // Shut down the callback server and clear pending + pending.Shutdown() + h.mu.Lock() + if h.pending == pending { + h.pending = nil + } + h.mu.Unlock() + + providerID, err := h.saveAndRegister(r.Context(), tokenResp) + if err != nil { + slog.Error("oauth.save_token", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save token"}) + return + } + + slog.Info("oauth: OpenAI token saved via manual callback") + writeJSON(w, http.StatusOK, map[string]any{ + "authenticated": true, + "provider_name": oauth.DefaultProviderName, + "provider_id": providerID.String(), + }) +} + +func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) { + ts := h.newTokenSource() + if err := ts.Delete(r.Context()); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + if h.providerReg != nil { + h.providerReg.Unregister(oauth.DefaultProviderName) + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) +} + +// saveAndRegister persists the OAuth result to DB and registers the CodexProvider in-memory. +func (h *OAuthHandler) saveAndRegister(ctx context.Context, tokenResp *oauth.OpenAITokenResponse) (uuid.UUID, error) { + ts := h.newTokenSource() + providerID, err := ts.SaveOAuthResult(ctx, tokenResp) + if err != nil { + return uuid.Nil, err + } + + // Register CodexProvider in-memory for immediate use + if h.providerReg != nil { + codex := providers.NewCodexProvider(oauth.DefaultProviderName, ts, "", "") + h.providerReg.Register(codex) + } + + return providerID, nil +} diff --git a/internal/http/oauth_test.go b/internal/http/oauth_test.go new file mode 100644 index 00000000..16a065b6 --- /dev/null +++ b/internal/http/oauth_test.go @@ -0,0 +1,240 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// --- mock stores for tests --- + +type mockProviderStore struct { + providers map[string]*store.LLMProviderData +} + +func newMockProviderStore() *mockProviderStore { + return &mockProviderStore{providers: make(map[string]*store.LLMProviderData)} +} + +func (m *mockProviderStore) CreateProvider(_ context.Context, p *store.LLMProviderData) error { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + m.providers[p.Name] = p + return nil +} + +func (m *mockProviderStore) GetProvider(_ context.Context, id uuid.UUID) (*store.LLMProviderData, error) { + for _, p := range m.providers { + if p.ID == id { + return p, nil + } + } + return nil, fmt.Errorf("not found") +} + +func (m *mockProviderStore) GetProviderByName(_ context.Context, name string) (*store.LLMProviderData, error) { + if p, ok := m.providers[name]; ok { + return p, nil + } + return nil, fmt.Errorf("not found") +} + +func (m *mockProviderStore) ListProviders(_ context.Context) ([]store.LLMProviderData, error) { + var out []store.LLMProviderData + for _, p := range m.providers { + out = append(out, *p) + } + return out, nil +} + +func (m *mockProviderStore) UpdateProvider(_ context.Context, id uuid.UUID, updates map[string]any) error { + for _, p := range m.providers { + if p.ID == id { + if v, ok := updates["api_key"]; ok { + p.APIKey = v.(string) + } + if v, ok := updates["settings"]; ok { + p.Settings = v.(json.RawMessage) + } + if v, ok := updates["enabled"]; ok { + p.Enabled = v.(bool) + } + return nil + } + } + return fmt.Errorf("not found") +} + +func (m *mockProviderStore) DeleteProvider(_ context.Context, id uuid.UUID) error { + for name, p := range m.providers { + if p.ID == id { + delete(m.providers, name) + return nil + } + } + return fmt.Errorf("not found") +} + +type mockSecretsStore struct { + data map[string]string +} + +func newMockSecretsStore() *mockSecretsStore { + return &mockSecretsStore{data: make(map[string]string)} +} + +func (m *mockSecretsStore) Get(_ context.Context, key string) (string, error) { + if v, ok := m.data[key]; ok { + return v, nil + } + return "", fmt.Errorf("not found: %s", key) +} + +func (m *mockSecretsStore) Set(_ context.Context, key, value string) error { + m.data[key] = value + return nil +} + +func (m *mockSecretsStore) Delete(_ context.Context, key string) error { + delete(m.data, key) + return nil +} + +func (m *mockSecretsStore) GetAll(_ context.Context) (map[string]string, error) { + return m.data, nil +} + +// --- helper --- + +func newTestOAuthHandler(token string) *OAuthHandler { + return NewOAuthHandler(token, newMockProviderStore(), newMockSecretsStore(), nil) +} + +// --- tests --- + +func TestOAuthHandlerStatusNoToken(t *testing.T) { + h := newTestOAuthHandler("") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest("GET", "/v1/auth/openai/status", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d", w.Code, http.StatusOK) + } + + var result map[string]interface{} + json.NewDecoder(w.Body).Decode(&result) + + if result["authenticated"] != false { + t.Errorf("authenticated = %v, want false", result["authenticated"]) + } +} + +func TestOAuthHandlerAuth(t *testing.T) { + h := newTestOAuthHandler("secret-token") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // Without token - should be unauthorized + req := httptest.NewRequest("GET", "/v1/auth/openai/status", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status code without token = %d, want %d", w.Code, http.StatusUnauthorized) + } + + // With correct token - should work + req2 := httptest.NewRequest("GET", "/v1/auth/openai/status", nil) + req2.Header.Set("Authorization", "Bearer secret-token") + w2 := httptest.NewRecorder() + mux.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("status code with token = %d, want %d", w2.Code, http.StatusOK) + } +} + +func TestOAuthHandlerLogoutNoProvider(t *testing.T) { + h := newTestOAuthHandler("") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest("POST", "/v1/auth/openai/logout", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d", w.Code, http.StatusOK) + } + + var result map[string]string + json.NewDecoder(w.Body).Decode(&result) + + if result["status"] != "logged out" { + t.Errorf("status = %q, want 'logged out'", result["status"]) + } +} + +func TestOAuthHandlerRouteRegistration(t *testing.T) { + h := newTestOAuthHandler("") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + routes := []struct { + method string + path string + }{ + {"GET", "/v1/auth/openai/status"}, + {"POST", "/v1/auth/openai/logout"}, + {"POST", "/v1/auth/openai/start"}, + } + + for _, r := range routes { + req := httptest.NewRequest(r.method, r.path, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code == http.StatusNotFound { + t.Errorf("%s %s returned 404", r.method, r.path) + } + } +} + +func TestOAuthHandlerStartReturnsAuthURL(t *testing.T) { + h := newTestOAuthHandler("") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest("POST", "/v1/auth/openai/start", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + // Skip if port 1455 is already in use (environment issue, not code bug) + if w.Code == http.StatusInternalServerError { + t.Skip("port 1455 unavailable, skipping") + } + if w.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var result map[string]interface{} + json.NewDecoder(w.Body).Decode(&result) + + _, hasURL := result["auth_url"] + _, hasStatus := result["status"] + + if !hasURL && !hasStatus { + t.Fatal("response has neither auth_url nor status") + } +} diff --git a/internal/http/providers.go b/internal/http/providers.go index e91803fe..7f4d8d5d 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/oauth" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -14,13 +15,14 @@ import ( // ProvidersHandler handles LLM provider CRUD endpoints. type ProvidersHandler struct { store store.ProviderStore + secretStore store.ConfigSecretsStore token string providerReg *providers.Registry } // NewProvidersHandler creates a handler for provider management endpoints. -func NewProvidersHandler(s store.ProviderStore, token string, providerReg *providers.Registry) *ProvidersHandler { - return &ProvidersHandler{store: s, token: token, providerReg: providerReg} +func NewProvidersHandler(s store.ProviderStore, secretStore store.ConfigSecretsStore, token string, providerReg *providers.Registry) *ProvidersHandler { + return &ProvidersHandler{store: s, secretStore: secretStore, token: token, providerReg: providerReg} } // RegisterRoutes registers all provider management routes on the given mux. @@ -64,7 +66,10 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { if h.providerReg == nil || !p.Enabled || p.APIKey == "" { return } - if p.ProviderType == store.ProviderAnthropicNative { + if p.ProviderType == store.ProviderChatGPTOAuth { + ts := oauth.NewDBTokenSource(h.store, h.secretStore, p.Name) + h.providerReg.Register(providers.NewCodexProvider(p.Name, ts, p.APIBase, "")) + } else if p.ProviderType == store.ProviderAnthropicNative { h.providerReg.Register(providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(p.APIBase))) } else if p.ProviderType == store.ProviderDashScope { diff --git a/internal/oauth/openai.go b/internal/oauth/openai.go new file mode 100644 index 00000000..a97db74c --- /dev/null +++ b/internal/oauth/openai.go @@ -0,0 +1,291 @@ +// Package oauth implements OAuth 2.0 PKCE flows for LLM provider authentication. +package oauth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "html" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os/exec" + "sync" + "runtime" + "time" +) + +const ( + OpenAIAuthURL = "https://auth.openai.com/oauth/authorize" + OpenAITokenURL = "https://auth.openai.com/oauth/token" + OpenAIClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + OpenAIScopes = "openid profile email offline_access api.connectors.read api.connectors.invoke" + OpenAIRedirectURI = "http://localhost:1455/auth/callback" + + callbackPort = "1455" + + tokenHTTPTimeout = 30 * time.Second +) + +// httpClient is used for token exchange/refresh requests with a timeout. +var httpClient = &http.Client{Timeout: tokenHTTPTimeout} + +// OpenAITokenResponse is the response from the OpenAI token endpoint. +type OpenAITokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + IDToken string `json:"id_token,omitempty"` +} + +// generatePKCE generates a PKCE code verifier and S256 challenge. +func generatePKCE() (verifier, challenge string, err error) { + buf := make([]byte, 64) + if _, err := rand.Read(buf); err != nil { + return "", "", fmt.Errorf("generate random bytes: %w", err) + } + verifier = base64.RawURLEncoding.EncodeToString(buf) + h := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(h[:]) + return verifier, challenge, nil +} + +// PendingLogin represents an in-progress OAuth flow. +// Call Wait() to block until the user completes authentication. +type PendingLogin struct { + AuthURL string + codeCh chan string + errCh chan error + verifier string + state string + srv *http.Server +} + +// Wait blocks until the OAuth callback is received or ctx is cancelled. +// Shuts down the callback server when done. +func (p *PendingLogin) Wait(ctx context.Context) (*OpenAITokenResponse, error) { + defer p.srv.Shutdown(context.Background()) + + select { + case code := <-p.codeCh: + slog.Debug("received authorization code") + return exchangeOpenAICode(code, p.verifier) + case err := <-p.errCh: + return nil, err + case <-ctx.Done(): + return nil, fmt.Errorf("authentication timed out: %w", ctx.Err()) + } +} + +// Shutdown stops the callback server without waiting for a callback. +func (p *PendingLogin) Shutdown() { + p.srv.Shutdown(context.Background()) +} + +// ExchangeRedirectURL extracts the code from a pasted redirect URL and exchanges it for tokens. +// Used for remote/VPS environments where the localhost callback can't be reached. +func (p *PendingLogin) ExchangeRedirectURL(redirectURL string) (*OpenAITokenResponse, error) { + u, err := url.Parse(redirectURL) + if err != nil { + return nil, fmt.Errorf("invalid redirect URL: %w", err) + } + + state := u.Query().Get("state") + code := u.Query().Get("code") + + if code == "" { + errMsg := u.Query().Get("error") + if errMsg != "" { + return nil, fmt.Errorf("OAuth error: %s", errMsg) + } + return nil, fmt.Errorf("no authorization code in redirect URL") + } + + if state == "" || state != p.state { + return nil, fmt.Errorf("invalid state parameter (possible CSRF)") + } + + return exchangeOpenAICode(code, p.verifier) +} + +// StartLoginOpenAI begins the OAuth PKCE flow: starts the callback server +// and returns a PendingLogin with the auth URL. Does NOT open a browser. +// The caller is responsible for directing the user to AuthURL. +func StartLoginOpenAI() (*PendingLogin, error) { + verifier, challenge, err := generatePKCE() + if err != nil { + return nil, err + } + + stateBuf := make([]byte, 16) + if _, err := rand.Read(stateBuf); err != nil { + return nil, fmt.Errorf("generate state: %w", err) + } + state := base64.RawURLEncoding.EncodeToString(stateBuf) + + params := url.Values{ + "client_id": {OpenAIClientID}, + "redirect_uri": {OpenAIRedirectURI}, + "response_type": {"code"}, + "scope": {OpenAIScopes}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + "codex_cli_simplified_flow": {"true"}, + "id_token_add_organizations": {"true"}, + "originator": {"pi"}, + } + authURL := OpenAIAuthURL + "?" + params.Encode() + + codeCh := make(chan string, 1) + errCh := make(chan error, 1) + var callbackOnce sync.Once + + listener, err := net.Listen("tcp", "127.0.0.1:"+callbackPort) + if err != nil { + return nil, fmt.Errorf("start callback server on port %s: %w", callbackPort, err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + callbackOnce.Do(func() { + if r.URL.Query().Get("state") != state { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `

Authentication Failed

Invalid state parameter.

`) + errCh <- fmt.Errorf("oauth callback: state mismatch (possible CSRF)") + return + } + + code := r.URL.Query().Get("code") + if code == "" { + errMsg := r.URL.Query().Get("error") + if errMsg == "" { + errMsg = "no authorization code received" + } + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `

Authentication Failed

%s

`, html.EscapeString(errMsg)) + errCh <- fmt.Errorf("oauth callback: %s", errMsg) + return + } + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `

Authentication Successful!

You can close this window.

`) + codeCh <- code + }) + }) + + srv := &http.Server{Handler: mux} + go func() { + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + errCh <- fmt.Errorf("callback server: %w", err) + } + }() + + return &PendingLogin{ + AuthURL: authURL, + codeCh: codeCh, + errCh: errCh, + verifier: verifier, + state: state, + srv: srv, + }, nil +} + +// LoginOpenAI runs the interactive OAuth PKCE flow for OpenAI. +// Opens the user's browser, waits for callback, and returns the token response. +func LoginOpenAI(ctx context.Context) (*OpenAITokenResponse, error) { + pending, err := StartLoginOpenAI() + if err != nil { + return nil, err + } + + // Open browser (CLI flow only) + fmt.Println("Opening browser for OpenAI authentication...") + fmt.Printf("If the browser doesn't open, visit:\n%s\n\n", pending.AuthURL) + openBrowser(pending.AuthURL) + + fmt.Println("Waiting for authentication callback...") + return pending.Wait(ctx) +} + +// exchangeOpenAICode exchanges an authorization code for tokens. +func exchangeOpenAICode(code, verifier string) (*OpenAITokenResponse, error) { + data := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {OpenAIClientID}, + "code": {code}, + "redirect_uri": {OpenAIRedirectURI}, + "code_verifier": {verifier}, + } + + resp, err := httpClient.PostForm(OpenAITokenURL, data) + if err != nil { + return nil, fmt.Errorf("token exchange request: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token exchange failed (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var tokenResp OpenAITokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("parse token response: %w", err) + } + return &tokenResp, nil +} + +// RefreshOpenAIToken refreshes an expired access token using the refresh token. +func RefreshOpenAIToken(refreshToken string) (*OpenAITokenResponse, error) { + data := url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {OpenAIClientID}, + "refresh_token": {refreshToken}, + } + + resp, err := httpClient.PostForm(OpenAITokenURL, data) + if err != nil { + return nil, fmt.Errorf("token refresh request: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var tokenResp OpenAITokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("parse refresh response: %w", err) + } + return &tokenResp, nil +} + +// openBrowser tries to open a URL in the user's default browser. +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + // Try common Linux openers + for _, opener := range []string{"xdg-open", "sensible-browser", "x-www-browser"} { + if path, err := exec.LookPath(opener); err == nil { + cmd = exec.Command(path, url) + break + } + } + } + if cmd != nil { + _ = cmd.Start() + } +} diff --git a/internal/oauth/token.go b/internal/oauth/token.go new file mode 100644 index 00000000..21f9b015 --- /dev/null +++ b/internal/oauth/token.go @@ -0,0 +1,228 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const ( + // DefaultProviderName is the provider name for ChatGPT OAuth. + DefaultProviderName = "openai-codex" + + // refreshTokenSecretKey is the config_secrets key for the refresh token. + refreshTokenSecretKey = "oauth.openai-codex.refresh_token" + + // refreshMargin is how early before expiry we refresh the token. + refreshMargin = 5 * time.Minute +) + +// OAuthSettings is stored in llm_providers.settings JSONB (non-sensitive metadata). +type OAuthSettings struct { + ExpiresAt int64 `json:"expires_at"` // unix timestamp + Scopes string `json:"scopes,omitempty"` +} + +// DBTokenSource provides a valid access token backed by the llm_providers + config_secrets tables. +// Implements providers.TokenSource. +type DBTokenSource struct { + providerStore store.ProviderStore + secretsStore store.ConfigSecretsStore + providerName string + + mu sync.Mutex + cachedToken string + expiresAt time.Time +} + +// NewDBTokenSource creates a DB-backed token source. +func NewDBTokenSource(provStore store.ProviderStore, secretsStore store.ConfigSecretsStore, providerName string) *DBTokenSource { + return &DBTokenSource{ + providerStore: provStore, + secretsStore: secretsStore, + providerName: providerName, + } +} + +// Token returns a valid access token, refreshing if expired or about to expire. +func (ts *DBTokenSource) Token() (string, error) { + ts.mu.Lock() + defer ts.mu.Unlock() + + // Use cached token if still valid + if ts.cachedToken != "" && time.Until(ts.expiresAt) > refreshMargin { + return ts.cachedToken, nil + } + + ctx := context.Background() + + // Load from DB if not cached + if ts.cachedToken == "" { + p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName) + if err != nil { + return "", fmt.Errorf("load oauth provider %q: %w", ts.providerName, err) + } + ts.cachedToken = p.APIKey + + var settings OAuthSettings + if len(p.Settings) > 0 { + _ = json.Unmarshal(p.Settings, &settings) + } + if settings.ExpiresAt > 0 { + ts.expiresAt = time.Unix(settings.ExpiresAt, 0) + } + } + + // Refresh if expired or expiring soon + if time.Until(ts.expiresAt) < refreshMargin { + if err := ts.refresh(ctx); err != nil { + // If refresh fails but we still have a token, return it (might still work) + if ts.cachedToken != "" { + slog.Warn("oauth token refresh failed, using existing token", "error", err) + return ts.cachedToken, nil + } + return "", fmt.Errorf("refresh oauth token: %w", err) + } + } + + return ts.cachedToken, nil +} + +// refresh gets the refresh token from config_secrets, calls RefreshOpenAIToken, and updates DB. +func (ts *DBTokenSource) refresh(ctx context.Context) error { + refreshToken, err := ts.secretsStore.Get(ctx, refreshTokenSecretKey) + if err != nil { + return fmt.Errorf("get refresh token: %w", err) + } + + slog.Info("refreshing OpenAI OAuth token") + newToken, err := RefreshOpenAIToken(refreshToken) + if err != nil { + return err + } + + // Update cached values + ts.cachedToken = newToken.AccessToken + ts.expiresAt = time.Now().Add(time.Duration(newToken.ExpiresIn) * time.Second) + + // Update provider api_key (access token) in DB + p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName) + if err != nil { + return fmt.Errorf("get provider for update: %w", err) + } + + settings := OAuthSettings{ + ExpiresAt: ts.expiresAt.Unix(), + } + settingsJSON, _ := json.Marshal(settings) + + if err := ts.providerStore.UpdateProvider(ctx, p.ID, map[string]any{ + "api_key": newToken.AccessToken, + "settings": json.RawMessage(settingsJSON), + }); err != nil { + slog.Warn("failed to persist refreshed access token", "error", err) + } + + // Update refresh token if a new one was issued + if newToken.RefreshToken != "" { + if err := ts.secretsStore.Set(ctx, refreshTokenSecretKey, newToken.RefreshToken); err != nil { + slog.Warn("failed to persist new refresh token", "error", err) + } + } + + return nil +} + +// SaveOAuthResult persists OAuth tokens after a successful exchange. +// Creates or updates the provider in llm_providers and stores refresh token in config_secrets. +// Returns the provider ID. +func (ts *DBTokenSource) SaveOAuthResult(ctx context.Context, tokenResp *OpenAITokenResponse) (uuid.UUID, error) { + expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + settings := OAuthSettings{ + ExpiresAt: expiresAt.Unix(), + Scopes: tokenResp.Scope, + } + settingsJSON, _ := json.Marshal(settings) + + // Update cache + ts.mu.Lock() + ts.cachedToken = tokenResp.AccessToken + ts.expiresAt = expiresAt + ts.mu.Unlock() + + // Check if provider already exists + existing, err := ts.providerStore.GetProviderByName(ctx, ts.providerName) + if err == nil { + // Update existing provider + if err := ts.providerStore.UpdateProvider(ctx, existing.ID, map[string]any{ + "api_key": tokenResp.AccessToken, + "settings": json.RawMessage(settingsJSON), + "enabled": true, + }); err != nil { + return uuid.Nil, fmt.Errorf("update provider: %w", err) + } + + // Save refresh token + if tokenResp.RefreshToken != "" { + if err := ts.secretsStore.Set(ctx, refreshTokenSecretKey, tokenResp.RefreshToken); err != nil { + return uuid.Nil, fmt.Errorf("save refresh token: %w", err) + } + } + + return existing.ID, nil + } + + // Create new provider + p := &store.LLMProviderData{ + Name: ts.providerName, + DisplayName: "ChatGPT (OAuth)", + ProviderType: store.ProviderChatGPTOAuth, + APIBase: "https://chatgpt.com/backend-api", + APIKey: tokenResp.AccessToken, + Enabled: true, + Settings: settingsJSON, + } + if err := ts.providerStore.CreateProvider(ctx, p); err != nil { + return uuid.Nil, fmt.Errorf("create provider: %w", err) + } + + // Save refresh token + if tokenResp.RefreshToken != "" { + if err := ts.secretsStore.Set(ctx, refreshTokenSecretKey, tokenResp.RefreshToken); err != nil { + return uuid.Nil, fmt.Errorf("save refresh token: %w", err) + } + } + + return p.ID, nil +} + +// Delete removes the OAuth provider from DB and its refresh token from config_secrets. +func (ts *DBTokenSource) Delete(ctx context.Context) error { + ts.mu.Lock() + ts.cachedToken = "" + ts.expiresAt = time.Time{} + ts.mu.Unlock() + + // Delete refresh token from config_secrets + _ = ts.secretsStore.Delete(ctx, refreshTokenSecretKey) + + // Delete provider from llm_providers + p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName) + if err != nil { + return nil // already gone + } + return ts.providerStore.DeleteProvider(ctx, p.ID) +} + +// Exists checks if an OAuth provider exists and has a valid token. +func (ts *DBTokenSource) Exists(ctx context.Context) bool { + p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName) + return err == nil && p.APIKey != "" +} diff --git a/internal/oauth/token_test.go b/internal/oauth/token_test.go new file mode 100644 index 00000000..afc11a7d --- /dev/null +++ b/internal/oauth/token_test.go @@ -0,0 +1,324 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// --- mock stores --- + +type mockProviderStore struct { + providers map[string]*store.LLMProviderData +} + +func newMockProviderStore() *mockProviderStore { + return &mockProviderStore{providers: make(map[string]*store.LLMProviderData)} +} + +func (m *mockProviderStore) CreateProvider(_ context.Context, p *store.LLMProviderData) error { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + m.providers[p.Name] = p + return nil +} + +func (m *mockProviderStore) GetProvider(_ context.Context, id uuid.UUID) (*store.LLMProviderData, error) { + for _, p := range m.providers { + if p.ID == id { + return p, nil + } + } + return nil, fmt.Errorf("not found") +} + +func (m *mockProviderStore) GetProviderByName(_ context.Context, name string) (*store.LLMProviderData, error) { + if p, ok := m.providers[name]; ok { + return p, nil + } + return nil, fmt.Errorf("not found") +} + +func (m *mockProviderStore) ListProviders(_ context.Context) ([]store.LLMProviderData, error) { + var out []store.LLMProviderData + for _, p := range m.providers { + out = append(out, *p) + } + return out, nil +} + +func (m *mockProviderStore) UpdateProvider(_ context.Context, id uuid.UUID, updates map[string]any) error { + for _, p := range m.providers { + if p.ID == id { + if v, ok := updates["api_key"]; ok { + p.APIKey = v.(string) + } + if v, ok := updates["settings"]; ok { + p.Settings = v.(json.RawMessage) + } + if v, ok := updates["enabled"]; ok { + p.Enabled = v.(bool) + } + return nil + } + } + return fmt.Errorf("not found") +} + +func (m *mockProviderStore) DeleteProvider(_ context.Context, id uuid.UUID) error { + for name, p := range m.providers { + if p.ID == id { + delete(m.providers, name) + return nil + } + } + return fmt.Errorf("not found") +} + +type mockSecretsStore struct { + data map[string]string +} + +func newMockSecretsStore() *mockSecretsStore { + return &mockSecretsStore{data: make(map[string]string)} +} + +func (m *mockSecretsStore) Get(_ context.Context, key string) (string, error) { + if v, ok := m.data[key]; ok { + return v, nil + } + return "", fmt.Errorf("not found: %s", key) +} + +func (m *mockSecretsStore) Set(_ context.Context, key, value string) error { + m.data[key] = value + return nil +} + +func (m *mockSecretsStore) Delete(_ context.Context, key string) error { + delete(m.data, key) + return nil +} + +func (m *mockSecretsStore) GetAll(_ context.Context) (map[string]string, error) { + return m.data, nil +} + +// --- tests --- + +func TestDBTokenSourceSaveAndLoad(t *testing.T) { + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + ts := NewDBTokenSource(provStore, secretStore, DefaultProviderName) + + resp := &OpenAITokenResponse{ + AccessToken: "access-token-abc123", + RefreshToken: "refresh-token-xyz789", + ExpiresIn: 3600, + } + + ctx := context.Background() + id, err := ts.SaveOAuthResult(ctx, resp) + if err != nil { + t.Fatalf("SaveOAuthResult: %v", err) + } + if id == uuid.Nil { + t.Fatal("expected non-nil provider ID") + } + + // Verify provider was created + p, err := provStore.GetProviderByName(ctx, DefaultProviderName) + if err != nil { + t.Fatalf("GetProviderByName: %v", err) + } + if p.APIKey != "access-token-abc123" { + t.Errorf("APIKey = %q, want %q", p.APIKey, "access-token-abc123") + } + + // Verify refresh token was saved + rt, err := secretStore.Get(ctx, refreshTokenSecretKey) + if err != nil { + t.Fatalf("Get refresh token: %v", err) + } + if rt != "refresh-token-xyz789" { + t.Errorf("refresh token = %q, want %q", rt, "refresh-token-xyz789") + } + + // Load token from fresh instance + ts2 := NewDBTokenSource(provStore, secretStore, DefaultProviderName) + token, err := ts2.Token() + if err != nil { + t.Fatalf("Token: %v", err) + } + if token != "access-token-abc123" { + t.Errorf("Token() = %q, want %q", token, "access-token-abc123") + } +} + +func TestDBTokenSourceCaching(t *testing.T) { + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + ts := NewDBTokenSource(provStore, secretStore, DefaultProviderName) + + resp := &OpenAITokenResponse{ + AccessToken: "cached-token", + RefreshToken: "refresh", + ExpiresIn: 3600, + } + + ctx := context.Background() + if _, err := ts.SaveOAuthResult(ctx, resp); err != nil { + t.Fatalf("SaveOAuthResult: %v", err) + } + + // Token is cached from SaveOAuthResult + token1, err := ts.Token() + if err != nil { + t.Fatalf("Token (1): %v", err) + } + + // Delete provider from store — cached token should still work + p, _ := provStore.GetProviderByName(ctx, DefaultProviderName) + delete(provStore.providers, DefaultProviderName) + + token2, err := ts.Token() + if err != nil { + t.Fatalf("Token (2): %v", err) + } + + if token1 != token2 { + t.Errorf("cached tokens differ: %q vs %q", token1, token2) + } + + // Restore for cleanup + provStore.providers[DefaultProviderName] = p +} + +func TestDBTokenSourceExists(t *testing.T) { + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + ts := NewDBTokenSource(provStore, secretStore, DefaultProviderName) + ctx := context.Background() + + if ts.Exists(ctx) { + t.Error("Exists() = true before save, want false") + } + + resp := &OpenAITokenResponse{ + AccessToken: "token", + RefreshToken: "refresh", + ExpiresIn: 3600, + } + if _, err := ts.SaveOAuthResult(ctx, resp); err != nil { + t.Fatalf("SaveOAuthResult: %v", err) + } + + if !ts.Exists(ctx) { + t.Error("Exists() = false after save, want true") + } +} + +func TestDBTokenSourceDelete(t *testing.T) { + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + ts := NewDBTokenSource(provStore, secretStore, DefaultProviderName) + ctx := context.Background() + + resp := &OpenAITokenResponse{ + AccessToken: "to-delete", + RefreshToken: "refresh-to-delete", + ExpiresIn: 3600, + } + if _, err := ts.SaveOAuthResult(ctx, resp); err != nil { + t.Fatalf("SaveOAuthResult: %v", err) + } + + if err := ts.Delete(ctx); err != nil { + t.Fatalf("Delete: %v", err) + } + + if ts.Exists(ctx) { + t.Error("Exists() = true after delete, want false") + } + + if _, err := secretStore.Get(ctx, refreshTokenSecretKey); err == nil { + t.Error("refresh token still exists after delete") + } +} + +func TestDBTokenSourceUpdateExisting(t *testing.T) { + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + ts := NewDBTokenSource(provStore, secretStore, DefaultProviderName) + ctx := context.Background() + + // Save first time + resp1 := &OpenAITokenResponse{ + AccessToken: "token-v1", + RefreshToken: "refresh-v1", + ExpiresIn: 3600, + } + id1, err := ts.SaveOAuthResult(ctx, resp1) + if err != nil { + t.Fatalf("SaveOAuthResult (1): %v", err) + } + + // Save second time — should update, not create duplicate + resp2 := &OpenAITokenResponse{ + AccessToken: "token-v2", + RefreshToken: "refresh-v2", + ExpiresIn: 7200, + } + id2, err := ts.SaveOAuthResult(ctx, resp2) + if err != nil { + t.Fatalf("SaveOAuthResult (2): %v", err) + } + + if id1 != id2 { + t.Errorf("IDs differ on update: %s vs %s", id1, id2) + } + + p, _ := provStore.GetProviderByName(ctx, DefaultProviderName) + if p.APIKey != "token-v2" { + t.Errorf("APIKey = %q after update, want %q", p.APIKey, "token-v2") + } +} + +func TestDBTokenSourceSettings(t *testing.T) { + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + ts := NewDBTokenSource(provStore, secretStore, DefaultProviderName) + ctx := context.Background() + + resp := &OpenAITokenResponse{ + AccessToken: "token", + RefreshToken: "refresh", + ExpiresIn: 3600, + Scope: "openid profile", + } + if _, err := ts.SaveOAuthResult(ctx, resp); err != nil { + t.Fatalf("SaveOAuthResult: %v", err) + } + + p, _ := provStore.GetProviderByName(ctx, DefaultProviderName) + var settings OAuthSettings + if err := json.Unmarshal(p.Settings, &settings); err != nil { + t.Fatalf("Unmarshal settings: %v", err) + } + + if settings.Scopes != "openid profile" { + t.Errorf("Scopes = %q, want %q", settings.Scopes, "openid profile") + } + + expectedMin := time.Now().Add(3600 * time.Second).Unix() + if settings.ExpiresAt < expectedMin-5 { + t.Errorf("ExpiresAt = %d, expected >= %d", settings.ExpiresAt, expectedMin-5) + } +} diff --git a/internal/providers/codex.go b/internal/providers/codex.go new file mode 100644 index 00000000..744a6c76 --- /dev/null +++ b/internal/providers/codex.go @@ -0,0 +1,189 @@ +package providers + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// CodexProvider implements Provider for the OpenAI Responses API, +// used with ChatGPT subscription via OAuth (Codex flow). +// Wire format: POST /codex/responses on chatgpt.com backend. +type CodexProvider struct { + name string + apiBase string // e.g. "https://api.openai.com/v1" or "https://chatgpt.com/backend-api" + defaultModel string + client *http.Client + retryConfig RetryConfig + tokenSource TokenSource +} + +// NewCodexProvider creates a provider for the OpenAI Responses API with OAuth token. +func NewCodexProvider(name string, tokenSource TokenSource, apiBase, defaultModel string) *CodexProvider { + if apiBase == "" { + apiBase = "https://chatgpt.com/backend-api" + } + apiBase = strings.TrimRight(apiBase, "/") + + if defaultModel == "" { + defaultModel = "gpt-5.3-codex" + } + + return &CodexProvider{ + name: name, + apiBase: apiBase, + defaultModel: defaultModel, + client: &http.Client{Timeout: 300 * time.Second}, + retryConfig: DefaultRetryConfig(), + tokenSource: tokenSource, + } +} + +func (p *CodexProvider) Name() string { return p.name } +func (p *CodexProvider) DefaultModel() string { return p.defaultModel } +func (p *CodexProvider) SupportsThinking() bool { return true } + +func (p *CodexProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + // Codex Responses API requires stream=true; delegate to ChatStream with no chunk handler. + return p.ChatStream(ctx, req, nil) +} + +func (p *CodexProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) { + body := p.buildRequestBody(req, true) + + respBody, err := RetryDo(ctx, p.retryConfig, func() (io.ReadCloser, error) { + return p.doRequest(ctx, body) + }) + if err != nil { + return nil, err + } + defer respBody.Close() + + result := &ChatResponse{FinishReason: "stop"} + toolCalls := make(map[string]*codexToolCallAcc) // keyed by item_id + + scanner := bufio.NewScanner(respBody) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimPrefix(line, "data:") + data = strings.TrimPrefix(data, " ") + if data == "[DONE]" { + break + } + + var event codexSSEEvent + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue + } + + p.processSSEEvent(&event, result, toolCalls, onChunk) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("%s: stream read error: %w", p.name, err) + } + + // Build tool calls from accumulators + for _, acc := range toolCalls { + if acc.name == "" { + continue + } + args := make(map[string]interface{}) + _ = json.Unmarshal([]byte(acc.rawArgs), &args) + result.ToolCalls = append(result.ToolCalls, ToolCall{ + ID: acc.callID, + Name: acc.name, + Arguments: args, + }) + } + + if len(result.ToolCalls) > 0 { + result.FinishReason = "tool_calls" + } + + if onChunk != nil { + onChunk(StreamChunk{Done: true}) + } + + return result, nil +} + +// processSSEEvent handles a single SSE event during streaming. +func (p *CodexProvider) processSSEEvent(event *codexSSEEvent, result *ChatResponse, toolCalls map[string]*codexToolCallAcc, onChunk func(StreamChunk)) { + switch event.Type { + case "response.output_text.delta": + if event.Delta != "" { + result.Content += event.Delta + if onChunk != nil { + onChunk(StreamChunk{Content: event.Delta}) + } + } + + case "response.function_call_arguments.delta": + if event.ItemID != "" { + acc := toolCalls[event.ItemID] + if acc == nil { + acc = &codexToolCallAcc{} + toolCalls[event.ItemID] = acc + } + acc.rawArgs += event.Delta + } + + case "response.output_item.done": + if event.Item != nil { + switch event.Item.Type { + case "message": + if event.Item.Phase != "" { + result.Phase = event.Item.Phase + } + case "function_call": + acc := toolCalls[event.Item.ID] + if acc == nil { + acc = &codexToolCallAcc{} + } + acc.callID = event.Item.CallID + acc.name = event.Item.Name + if event.Item.Arguments != "" { + acc.rawArgs = event.Item.Arguments + } + toolCalls[event.Item.ID] = acc + case "reasoning": + for _, s := range event.Item.Summary { + if s.Text != "" { + result.Thinking += s.Text + if onChunk != nil { + onChunk(StreamChunk{Thinking: s.Text}) + } + } + } + } + } + + case "response.completed", "response.incomplete", "response.failed": + if event.Response != nil { + if event.Response.Usage != nil { + u := event.Response.Usage + result.Usage = &Usage{ + PromptTokens: u.InputTokens, + CompletionTokens: u.OutputTokens, + TotalTokens: u.TotalTokens, + } + if u.OutputTokensDetails != nil { + result.Usage.ThinkingTokens = u.OutputTokensDetails.ReasoningTokens + } + } + if event.Response.Status == "incomplete" { + result.FinishReason = "length" + } + } + } +} diff --git a/internal/providers/codex_build.go b/internal/providers/codex_build.go new file mode 100644 index 00000000..b415129f --- /dev/null +++ b/internal/providers/codex_build.go @@ -0,0 +1,179 @@ +package providers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// buildRequestBody converts internal ChatRequest to Responses API format. +func (p *CodexProvider) buildRequestBody(req ChatRequest, stream bool) map[string]interface{} { + model := req.Model + if model == "" { + model = p.defaultModel + } + + var instructions string + var input []interface{} + + for _, m := range req.Messages { + switch m.Role { + case "system": + if instructions == "" { + instructions = m.Content + } else { + instructions += "\n\n" + m.Content + } + + case "user": + if len(m.Images) > 0 { + var parts []map[string]interface{} + for _, img := range m.Images { + parts = append(parts, map[string]interface{}{ + "type": "input_image", + "image_url": fmt.Sprintf("data:%s;base64,%s", img.MimeType, img.Data), + }) + } + if m.Content != "" { + parts = append(parts, map[string]interface{}{ + "type": "input_text", + "text": m.Content, + }) + } + input = append(input, map[string]interface{}{ + "role": "user", + "content": parts, + }) + } else { + input = append(input, map[string]interface{}{ + "role": "user", + "content": m.Content, + }) + } + + case "assistant": + if len(m.ToolCalls) > 0 { + for _, tc := range m.ToolCalls { + argsJSON, _ := json.Marshal(tc.Arguments) + callID := toFcID(tc.ID) + input = append(input, map[string]interface{}{ + "type": "function_call", + "id": callID, + "call_id": callID, + "name": tc.Name, + "arguments": string(argsJSON), + }) + } + } + if m.Content != "" { + item := map[string]interface{}{ + "type": "message", + "role": "assistant", + "content": []map[string]interface{}{ + {"type": "output_text", "text": m.Content}, + }, + } + if m.Phase != "" { + item["phase"] = m.Phase + } + input = append(input, item) + } + + case "tool": + input = append(input, map[string]interface{}{ + "type": "function_call_output", + "call_id": toFcID(m.ToolCallID), + "output": m.Content, + }) + } + } + + body := map[string]interface{}{ + "model": model, + "input": input, + "stream": stream, + "store": false, + } + + if instructions == "" { + instructions = "You are a helpful assistant." + } + body["instructions"] = instructions + + if len(req.Tools) > 0 { + var tools []map[string]interface{} + for _, t := range req.Tools { + tools = append(tools, map[string]interface{}{ + "type": "function", + "name": t.Function.Name, + "description": t.Function.Description, + "parameters": t.Function.Parameters, + }) + } + body["tools"] = tools + } + + if level, ok := req.Options[OptThinkingLevel].(string); ok && level != "" && level != "off" { + body["reasoning"] = map[string]interface{}{"effort": level} + } + + return body +} + +func (p *CodexProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("%s: marshal request: %w", p.name, err) + } + + endpoint := p.apiBase + "/codex/responses" + httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("%s: create request: %w", p.name, err) + } + + httpReq.Header.Set("Content-Type", "application/json") + + token, err := p.tokenSource.Token() + if err != nil { + return nil, fmt.Errorf("%s: get auth token: %w", p.name, err) + } + httpReq.Header.Set("Authorization", "Bearer "+token) + httpReq.Header.Set("OpenAI-Beta", "responses=v1") + + resp, err := p.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("%s: request failed: %w", p.name, err) + } + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + retryAfter := ParseRetryAfter(resp.Header.Get("Retry-After")) + return nil, &HTTPError{ + Status: resp.StatusCode, + Body: fmt.Sprintf("%s: %s", p.name, string(respBody)), + RetryAfter: retryAfter, + } + } + + return resp.Body, nil +} + +// toFcID ensures a tool call ID starts with "fc_" as required by the Responses API. +func toFcID(id string) string { + if strings.HasPrefix(id, "fc_") { + return id + } + if strings.HasPrefix(id, "tool_") { + return "fc_" + id[len("tool_"):] + } + if strings.HasPrefix(id, "call_") { + return "fc_" + id[len("call_"):] + } + return "fc_" + id +} diff --git a/internal/providers/codex_test.go b/internal/providers/codex_test.go new file mode 100644 index 00000000..b7c1d60d --- /dev/null +++ b/internal/providers/codex_test.go @@ -0,0 +1,628 @@ +package providers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// staticTokenSource implements TokenSource for testing. +type staticTokenSource struct { + token string +} + +func (s *staticTokenSource) Token() (string, error) { + return s.token, nil +} + +// mustJSON marshals v to JSON string; panics on error. +func mustJSON(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(b) +} + +// writeSSEDone writes a minimal SSE response with just a completed event and [DONE]. +func writeSSEDone(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{ + Type: "response.completed", + Response: &codexAPIResponse{ID: "resp-1", Status: "completed"}, + })) + fmt.Fprint(w, "data: [DONE]\n\n") +} + +func TestCodexProviderName(t *testing.T) { + p := NewCodexProvider("openai-codex", &staticTokenSource{token: "test"}, "", "gpt-4o") + if p.Name() != "openai-codex" { + t.Errorf("Name() = %q, want %q", p.Name(), "openai-codex") + } +} + +func TestCodexProviderDefaultModel(t *testing.T) { + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "") + if p.DefaultModel() != "gpt-5.3-codex" { + t.Errorf("DefaultModel() = %q, want %q", p.DefaultModel(), "gpt-5.3-codex") + } + + p2 := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "o3") + if p2.DefaultModel() != "o3" { + t.Errorf("DefaultModel() = %q, want %q", p2.DefaultModel(), "o3") + } +} + +func TestCodexProviderBuildRequestBody(t *testing.T) { + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") + + req := ChatRequest{ + Model: "gpt-4o", + Messages: []Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi there!"}, + {Role: "user", Content: "How are you?"}, + }, + Options: map[string]interface{}{ + OptMaxTokens: 1024, + OptTemperature: 0.7, + }, + } + + body := p.buildRequestBody(req, false) + + // Check model + if body["model"] != "gpt-4o" { + t.Errorf("model = %v, want gpt-4o", body["model"]) + } + + // Check stream + if body["stream"] != false { + t.Errorf("stream = %v, want false", body["stream"]) + } + + // Check store is false + if body["store"] != false { + t.Errorf("store = %v, want false", body["store"]) + } + + // Check instructions extracted from system message + if body["instructions"] != "You are helpful." { + t.Errorf("instructions = %v, want 'You are helpful.'", body["instructions"]) + } + + // Check input items (should exclude system messages) + input, ok := body["input"].([]interface{}) + if !ok { + t.Fatalf("input is not []interface{}: %T", body["input"]) + } + if len(input) != 3 { + t.Fatalf("input length = %d, want 3 (user + assistant + user)", len(input)) + } + + // chatgpt.com backend does not support max_output_tokens or temperature + if _, ok := body["max_output_tokens"]; ok { + t.Error("max_output_tokens should not be set (unsupported by chatgpt.com backend)") + } + if _, ok := body["temperature"]; ok { + t.Error("temperature should not be set (unsupported by chatgpt.com backend)") + } +} + +func TestCodexProviderBuildRequestBodyWithTools(t *testing.T) { + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") + + req := ChatRequest{ + Messages: []Message{{Role: "user", Content: "What's the weather?"}}, + Tools: []ToolDefinition{ + { + Function: ToolFunctionSchema{ + Name: "get_weather", + Description: "Get current weather", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "city": map[string]interface{}{"type": "string"}, + }, + "required": []string{"city"}, + }, + }, + }, + }, + } + + body := p.buildRequestBody(req, true) + + tools, ok := body["tools"].([]map[string]interface{}) + if !ok { + t.Fatalf("tools is not []map[string]interface{}: %T", body["tools"]) + } + if len(tools) != 1 { + t.Fatalf("tools length = %d, want 1", len(tools)) + } + + tool := tools[0] + if tool["type"] != "function" { + t.Errorf("tool type = %v, want function", tool["type"]) + } + if tool["name"] != "get_weather" { + t.Errorf("tool name = %v, want get_weather", tool["name"]) + } + if _, hasStrict := tool["strict"]; hasStrict { + t.Error("tool should not have strict field") + } +} + +func TestCodexProviderBuildRequestBodyToolCallMessages(t *testing.T) { + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") + + req := ChatRequest{ + Messages: []Message{ + {Role: "user", Content: "What's the weather?"}, + { + Role: "assistant", + ToolCalls: []ToolCall{ + {ID: "call_123", Name: "get_weather", Arguments: map[string]interface{}{"city": "London"}}, + }, + }, + {Role: "tool", ToolCallID: "call_123", Content: `{"temp": 15}`}, + }, + } + + body := p.buildRequestBody(req, false) + + input, ok := body["input"].([]interface{}) + if !ok { + t.Fatalf("input is not []interface{}: %T", body["input"]) + } + if len(input) != 3 { + t.Fatalf("input length = %d, want 3", len(input)) + } + + // Second item should be function_call + fc, ok := input[1].(map[string]interface{}) + if !ok { + t.Fatalf("input[1] is not map: %T", input[1]) + } + if fc["type"] != "function_call" { + t.Errorf("input[1] type = %v, want function_call", fc["type"]) + } + if fc["name"] != "get_weather" { + t.Errorf("input[1] name = %v, want get_weather", fc["name"]) + } + + // Third item should be function_call_output + fco, ok := input[2].(map[string]interface{}) + if !ok { + t.Fatalf("input[2] is not map: %T", input[2]) + } + if fco["type"] != "function_call_output" { + t.Errorf("input[2] type = %v, want function_call_output", fco["type"]) + } + if fco["call_id"] != "fc_123" { + t.Errorf("input[2] call_id = %v, want fc_123", fco["call_id"]) + } +} + +func TestCodexProviderBuildRequestBodyThinking(t *testing.T) { + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") + + req := ChatRequest{ + Messages: []Message{{Role: "user", Content: "Think about this"}}, + Options: map[string]interface{}{OptThinkingLevel: "high"}, + } + + body := p.buildRequestBody(req, false) + + reasoning, ok := body["reasoning"].(map[string]interface{}) + if !ok { + t.Fatalf("reasoning is not map: %T", body["reasoning"]) + } + if reasoning["effort"] != "high" { + t.Errorf("reasoning effort = %v, want high", reasoning["effort"]) + } +} + +func TestCodexProviderChat(t *testing.T) { + // Mock Responses API server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify headers + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("Authorization = %q, want Bearer test-token", r.Header.Get("Authorization")) + } + if r.Header.Get("OpenAI-Beta") != "responses=v1" { + t.Errorf("OpenAI-Beta = %q, want responses=v1", r.Header.Get("OpenAI-Beta")) + } + + // Verify endpoint + if r.URL.Path != "/codex/responses" { + t.Errorf("path = %q, want /responses", r.URL.Path) + } + + // Verify request body + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + if body["model"] != "gpt-4o" { + t.Errorf("request model = %v, want gpt-4o", body["model"]) + } + if body["store"] != false { + t.Errorf("request store = %v, want false", body["store"]) + } + + // Return SSE mock response (Chat delegates to ChatStream) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{Type: "response.output_text.delta", Delta: "Hello! I'm doing great."})) + fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{ + Type: "response.output_item.done", + Item: &codexItem{Type: "message", Role: "assistant"}, + })) + fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{ + Type: "response.completed", + Response: &codexAPIResponse{ + ID: "resp-123", Status: "completed", + Usage: &codexUsage{InputTokens: 10, OutputTokens: 8, TotalTokens: 18}, + }, + })) + fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer server.Close() + + p := NewCodexProvider("openai-codex", &staticTokenSource{token: "test-token"}, server.URL, "gpt-4o") + p.retryConfig.Attempts = 1 + + result, err := p.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hello"}}, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + + if result.Content != "Hello! I'm doing great." { + t.Errorf("Content = %q, want 'Hello! I'm doing great.'", result.Content) + } + if result.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want stop", result.FinishReason) + } + if result.Usage == nil { + t.Fatal("Usage is nil") + } + if result.Usage.PromptTokens != 10 { + t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens) + } +} + +func TestCodexProviderChatStream(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + + events := []string{ + `{"type":"response.output_text.delta","delta":"Hello"}`, + `{"type":"response.output_text.delta","delta":" world"}`, + `{"type":"response.completed","response":{"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`, + } + + for _, e := range events { + fmt.Fprintf(w, "data: %s\n\n", e) + flusher.Flush() + } + fmt.Fprint(w, "data: [DONE]\n\n") + flusher.Flush() + })) + defer server.Close() + + p := NewCodexProvider("openai-codex", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") + p.retryConfig.Attempts = 1 + + var chunks []string + result, err := p.ChatStream(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hi"}}, + }, func(chunk StreamChunk) { + if chunk.Content != "" { + chunks = append(chunks, chunk.Content) + } + }) + if err != nil { + t.Fatalf("ChatStream: %v", err) + } + + if result.Content != "Hello world" { + t.Errorf("Content = %q, want 'Hello world'", result.Content) + } + if len(chunks) != 2 { + t.Errorf("chunks = %v, want 2 chunks", chunks) + } + if result.Usage == nil { + t.Fatal("Usage is nil") + } + if result.Usage.TotalTokens != 7 { + t.Errorf("TotalTokens = %d, want 7", result.Usage.TotalTokens) + } +} + +func TestCodexProviderChatStreamToolCalls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + + events := []string{ + `{"type":"response.function_call_arguments.delta","item_id":"item_1","delta":"{\"city\""}`, + `{"type":"response.function_call_arguments.delta","item_id":"item_1","delta":":\"London\"}"}`, + `{"type":"response.output_item.done","item":{"type":"function_call","id":"item_1","call_id":"call_abc","name":"get_weather","arguments":"{\"city\":\"London\"}"}}`, + `{"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}`, + } + + for _, e := range events { + fmt.Fprintf(w, "data: %s\n\n", e) + flusher.Flush() + } + fmt.Fprint(w, "data: [DONE]\n\n") + flusher.Flush() + })) + defer server.Close() + + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") + p.retryConfig.Attempts = 1 + + result, err := p.ChatStream(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "Weather?"}}, + }, nil) + if err != nil { + t.Fatalf("ChatStream: %v", err) + } + + if result.FinishReason != "tool_calls" { + t.Errorf("FinishReason = %q, want tool_calls", result.FinishReason) + } + if len(result.ToolCalls) != 1 { + t.Fatalf("ToolCalls length = %d, want 1", len(result.ToolCalls)) + } + tc := result.ToolCalls[0] + if tc.Name != "get_weather" { + t.Errorf("ToolCall name = %q, want get_weather", tc.Name) + } + if tc.ID != "call_abc" { + t.Errorf("ToolCall ID = %q, want call_abc", tc.ID) + } +} + +func TestCodexProviderChatToolCallsNonStream(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{ + Type: "response.output_item.done", + Item: &codexItem{ + Type: "function_call", + ID: "item_1", + CallID: "call_xyz", + Name: "search", + Arguments: `{"query":"test"}`, + }, + })) + fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{ + Type: "response.completed", + Response: &codexAPIResponse{ + ID: "resp-456", Status: "completed", + Usage: &codexUsage{InputTokens: 5, OutputTokens: 3, TotalTokens: 8}, + }, + })) + fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer server.Close() + + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") + p.retryConfig.Attempts = 1 + + result, err := p.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "Search"}}, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + + if result.FinishReason != "tool_calls" { + t.Errorf("FinishReason = %q, want tool_calls", result.FinishReason) + } + if len(result.ToolCalls) != 1 { + t.Fatalf("ToolCalls = %d, want 1", len(result.ToolCalls)) + } + if result.ToolCalls[0].Arguments["query"] != "test" { + t.Errorf("ToolCall arg query = %v, want test", result.ToolCalls[0].Arguments["query"]) + } +} + +func TestCodexProviderHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(`{"error":"rate limited"}`)) + })) + defer server.Close() + + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") + p.retryConfig.Attempts = 1 + + _, err := p.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hi"}}, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "rate limited") { + t.Errorf("error = %q, expected to contain 'rate limited'", err.Error()) + } +} + +func TestCodexProviderMultipleSystemMessages(t *testing.T) { + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") + + req := ChatRequest{ + Messages: []Message{ + {Role: "system", Content: "First instruction."}, + {Role: "system", Content: "Second instruction."}, + {Role: "user", Content: "Hello"}, + }, + } + + body := p.buildRequestBody(req, false) + + expected := "First instruction.\n\nSecond instruction." + if body["instructions"] != expected { + t.Errorf("instructions = %q, want %q", body["instructions"], expected) + } +} + +func TestCodexProviderStreamReasoning(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + + events := []string{ + `{"type":"response.output_item.done","item":{"type":"reasoning","summary":[{"type":"summary_text","text":"Thinking about this..."}]}}`, + `{"type":"response.output_text.delta","delta":"The answer is 42."}`, + `{"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30,"output_tokens_details":{"reasoning_tokens":15}}}}`, + } + + for _, e := range events { + fmt.Fprintf(w, "data: %s\n\n", e) + flusher.Flush() + } + fmt.Fprint(w, "data: [DONE]\n\n") + flusher.Flush() + })) + defer server.Close() + + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") + p.retryConfig.Attempts = 1 + + var thinkingChunks []string + result, err := p.ChatStream(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "Think"}}, + }, func(chunk StreamChunk) { + if chunk.Thinking != "" { + thinkingChunks = append(thinkingChunks, chunk.Thinking) + } + }) + if err != nil { + t.Fatalf("ChatStream: %v", err) + } + + if result.Thinking != "Thinking about this..." { + t.Errorf("Thinking = %q, want 'Thinking about this...'", result.Thinking) + } + if result.Content != "The answer is 42." { + t.Errorf("Content = %q, want 'The answer is 42.'", result.Content) + } + if result.Usage != nil && result.Usage.ThinkingTokens != 15 { + t.Errorf("ThinkingTokens = %d, want 15", result.Usage.ThinkingTokens) + } +} + +// Verify the endpoint format (apiBase + /responses) +func TestCodexProviderEndpoint(t *testing.T) { + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + writeSSEDone(w) + })) + defer server.Close() + + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") + p.retryConfig.Attempts = 1 + + p.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "test"}}, + }) + + if capturedPath != "/codex/responses" { + t.Errorf("endpoint path = %q, want /responses", capturedPath) + } +} + +// Verify trailing slash is stripped from apiBase +func TestCodexProviderAPIBaseTrailingSlash(t *testing.T) { + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + writeSSEDone(w) + })) + defer server.Close() + + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL+"/", "gpt-4o") + p.retryConfig.Attempts = 1 + p.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "test"}}, + }) + + if capturedPath != "/codex/responses" { + t.Errorf("endpoint path = %q, want /responses (trailing slash should be stripped)", capturedPath) + } +} + +// Ensure token is used from TokenSource +func TestCodexProviderTokenSource(t *testing.T) { + var capturedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + writeSSEDone(w) + })) + defer server.Close() + + p := NewCodexProvider("test", &staticTokenSource{token: "my-oauth-token"}, server.URL, "gpt-4o") + p.retryConfig.Attempts = 1 + p.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "test"}}, + }) + + if capturedAuth != "Bearer my-oauth-token" { + t.Errorf("Authorization = %q, want 'Bearer my-oauth-token'", capturedAuth) + } +} + +// Verify request body includes image content +func TestCodexProviderBuildRequestBodyWithImages(t *testing.T) { + p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") + + req := ChatRequest{ + Messages: []Message{ + { + Role: "user", + Content: "What's this?", + Images: []ImageContent{{MimeType: "image/png", Data: "base64data"}}, + }, + }, + } + + body := p.buildRequestBody(req, false) + + input, ok := body["input"].([]interface{}) + if !ok || len(input) != 1 { + t.Fatalf("expected 1 input item, got %v", body["input"]) + } + + item, ok := input[0].(map[string]interface{}) + if !ok { + t.Fatalf("input[0] is not map: %T", input[0]) + } + + content, ok := item["content"].([]map[string]interface{}) + if !ok { + t.Fatalf("content is not []map: %T", item["content"]) + } + + // Should have image + text + if len(content) != 2 { + t.Fatalf("content length = %d, want 2 (image + text)", len(content)) + } + if content[0]["type"] != "input_image" { + t.Errorf("content[0] type = %v, want input_image", content[0]["type"]) + } + if content[1]["type"] != "input_text" { + t.Errorf("content[1] type = %v, want input_text", content[1]["type"]) + } +} + diff --git a/internal/providers/codex_types.go b/internal/providers/codex_types.go new file mode 100644 index 00000000..79a2a2cf --- /dev/null +++ b/internal/providers/codex_types.go @@ -0,0 +1,61 @@ +package providers + +// Wire types for the OpenAI Responses API (Codex flow). + +type codexAPIResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Model string `json:"model"` + Output []codexItem `json:"output"` + Usage *codexUsage `json:"usage,omitempty"` + Status string `json:"status"` +} + +type codexItem struct { + ID string `json:"id"` + Type string `json:"type"` // "message", "function_call", "reasoning" + Role string `json:"role,omitempty"` + Phase string `json:"phase,omitempty"` // gpt-5.3-codex: "commentary" or "final_answer" + Content []codexContent `json:"content,omitempty"` + CallID string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + Summary []codexSummary `json:"summary,omitempty"` +} + +type codexContent struct { + Type string `json:"type"` // "output_text" + Text string `json:"text"` +} + +type codexSummary struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type codexUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + OutputTokensDetails *codexTokensDetails `json:"output_tokens_details,omitempty"` +} + +type codexTokensDetails struct { + ReasoningTokens int `json:"reasoning_tokens"` +} + +// SSE streaming types + +type codexSSEEvent struct { + Type string `json:"type"` + Delta string `json:"delta,omitempty"` + ItemID string `json:"item_id,omitempty"` + Item *codexItem `json:"item,omitempty"` + Response *codexAPIResponse `json:"response,omitempty"` +} + +type codexToolCallAcc struct { + callID string + name string + rawArgs string +} diff --git a/internal/providers/types.go b/internal/providers/types.go index 4450a601..3d8a0c5b 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -15,6 +15,11 @@ const ( OptThinkingBudget = "thinking_budget" ) +// TokenSource provides an OAuth access token (with auto-refresh). +type TokenSource interface { + Token() (string, error) +} + // Provider is the interface all LLM providers must implement. type Provider interface { // Chat sends messages to the LLM and returns a response. @@ -54,6 +59,10 @@ type ChatResponse struct { FinishReason string `json:"finish_reason"` // "stop", "tool_calls", "length" Usage *Usage `json:"usage,omitempty"` + // Phase is Codex-specific (gpt-5.3-codex): "commentary" or "final_answer". + // Agent loop must persist this on assistant messages for Codex performance. + Phase string `json:"phase,omitempty"` + // RawAssistantContent preserves the raw content blocks array from the provider response. // Used by Anthropic to pass thinking blocks back in tool use loops (required by API). RawAssistantContent json.RawMessage `json:"-"` @@ -81,6 +90,12 @@ type Message struct { ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` // for role="tool" responses + // Phase is a Codex-specific field (gpt-5.3-codex) indicating message purpose. + // Values: "commentary" (intermediate), "final_answer" (closeout), or "" (unset). + // Must be persisted and passed back in subsequent requests for Codex performance. + // Other providers ignore this field. + Phase string `json:"phase,omitempty"` + // RawAssistantContent carries raw provider content blocks through tool loop iterations. // Anthropic requires thinking blocks to be passed back exactly as received. RawAssistantContent json.RawMessage `json:"-"` diff --git a/internal/store/pg/providers.go b/internal/store/pg/providers.go index b99d3747..4db4aa26 100644 --- a/internal/store/pg/providers.go +++ b/internal/store/pg/providers.go @@ -42,13 +42,18 @@ func (s *PGProviderStore) CreateProvider(ctx context.Context, p *store.LLMProvid apiKey = encrypted } + settings := p.Settings + if len(settings) == 0 { + settings = []byte("{}") + } + now := time.Now() p.CreatedAt = now p.UpdatedAt = now _, err := s.db.ExecContext(ctx, - `INSERT INTO llm_providers (id, name, display_name, provider_type, api_base, api_key, enabled, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - p.ID, p.Name, p.DisplayName, p.ProviderType, p.APIBase, apiKey, p.Enabled, now, now, + `INSERT INTO llm_providers (id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + p.ID, p.Name, p.DisplayName, p.ProviderType, p.APIBase, apiKey, p.Enabled, settings, now, now, ) return err } @@ -57,9 +62,9 @@ func (s *PGProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store var p store.LLMProviderData var apiKey string err := s.db.QueryRowContext(ctx, - `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, created_at, updated_at + `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at FROM llm_providers WHERE id = $1`, id, - ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.CreatedAt, &p.UpdatedAt) + ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt) if err != nil { return nil, fmt.Errorf("provider not found: %s", id) } @@ -67,9 +72,23 @@ func (s *PGProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store return &p, nil } +func (s *PGProviderStore) GetProviderByName(ctx context.Context, name string) (*store.LLMProviderData, error) { + var p store.LLMProviderData + var apiKey string + err := s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at + FROM llm_providers WHERE name = $1`, name, + ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("provider not found: %s", name) + } + p.APIKey = s.decryptKey(apiKey, p.Name) + return &p, nil +} + func (s *PGProviderStore) ListProviders(ctx context.Context) ([]store.LLMProviderData, error) { rows, err := s.db.QueryContext(ctx, - `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, created_at, updated_at + `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at FROM llm_providers ORDER BY name`) if err != nil { return nil, err @@ -80,7 +99,7 @@ func (s *PGProviderStore) ListProviders(ctx context.Context) ([]store.LLMProvide for rows.Next() { var p store.LLMProviderData var apiKey string - if err := rows.Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.CreatedAt, &p.UpdatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt); err != nil { continue } p.APIKey = s.decryptKey(apiKey, p.Name) diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index 5aae7c53..aa63bb82 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "github.com/google/uuid" ) @@ -21,6 +22,7 @@ const ( ProviderPerplexity = "perplexity" ProviderDashScope = "dashscope" ProviderBailian = "bailian" + ProviderChatGPTOAuth = "chatgpt_oauth" ) // ValidProviderTypes lists all accepted provider_type values. @@ -38,23 +40,26 @@ var ValidProviderTypes = map[string]bool{ ProviderPerplexity: true, ProviderDashScope: true, ProviderBailian: true, + ProviderChatGPTOAuth: true, } // LLMProviderData represents an LLM provider configuration. type LLMProviderData struct { BaseModel - Name string `json:"name"` - DisplayName string `json:"display_name,omitempty"` - ProviderType string `json:"provider_type"` - APIBase string `json:"api_base,omitempty"` - APIKey string `json:"api_key,omitempty"` - Enabled bool `json:"enabled"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + ProviderType string `json:"provider_type"` + APIBase string `json:"api_base,omitempty"` + APIKey string `json:"api_key,omitempty"` + Enabled bool `json:"enabled"` + Settings json.RawMessage `json:"settings,omitempty"` } // ProviderStore manages LLM providers. type ProviderStore interface { CreateProvider(ctx context.Context, p *LLMProviderData) error GetProvider(ctx context.Context, id uuid.UUID) (*LLMProviderData, error) + GetProviderByName(ctx context.Context, name string) (*LLMProviderData, error) ListProviders(ctx context.Context) ([]LLMProviderData, error) UpdateProvider(ctx context.Context, id uuid.UUID, updates map[string]any) error DeleteProvider(ctx context.Context, id uuid.UUID) error diff --git a/ui/web/src/components/shared/provider-model-select.tsx b/ui/web/src/components/shared/provider-model-select.tsx index a924f2a0..8737bd59 100644 --- a/ui/web/src/components/shared/provider-model-select.tsx +++ b/ui/web/src/components/shared/provider-model-select.tsx @@ -61,11 +61,12 @@ export function ProviderModelSelect({ } }, [provider, enabledProviders, onProviderChange]); - const selectedProviderId = useMemo( - () => enabledProviders.find((p) => p.name === provider)?.id, + const selectedProvider = useMemo( + () => enabledProviders.find((p) => p.name === provider), [enabledProviders, provider], ); - const { models, loading: modelsLoading } = useProviderModels(selectedProviderId); + const selectedProviderId = selectedProvider?.id; + const { models, loading: modelsLoading } = useProviderModels(selectedProviderId, selectedProvider?.provider_type); const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify(); const hasSavedValues = savedProvider !== undefined && savedModel !== undefined; diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index efbb9dd8..ebb9e351 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -6,6 +6,7 @@ export interface ProviderTypeInfo { } export const PROVIDER_TYPES: ProviderTypeInfo[] = [ + { value: "chatgpt_oauth", label: "ChatGPT Subscription (OAuth)", apiBase: "", placeholder: "" }, { value: "anthropic_native", label: "Anthropic (Native)", apiBase: "", placeholder: "https://api.anthropic.com" }, { value: "openai_compat", label: "OpenAI Compatible", apiBase: "", placeholder: "https://api.openai.com/v1" }, { value: "gemini_native", label: "Google Gemini", apiBase: "https://generativelanguage.googleapis.com/v1beta/openai", placeholder: "" }, diff --git a/ui/web/src/pages/agents/agent-create-dialog.tsx b/ui/web/src/pages/agents/agent-create-dialog.tsx index 099cbf1d..ccb220aa 100644 --- a/ui/web/src/pages/agents/agent-create-dialog.tsx +++ b/ui/web/src/pages/agents/agent-create-dialog.tsx @@ -45,11 +45,12 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD const enabledProviders = providers.filter((p) => p.enabled); // Look up provider ID from selected provider name for model fetching - const selectedProviderId = useMemo( - () => enabledProviders.find((p) => p.name === provider)?.id, + const selectedProvider = useMemo( + () => enabledProviders.find((p) => p.name === provider), [enabledProviders, provider], ); - const { models, loading: modelsLoading } = useProviderModels(selectedProviderId); + const selectedProviderId = selectedProvider?.id; + const { models, loading: modelsLoading } = useProviderModels(selectedProviderId, selectedProvider?.provider_type); const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify(); // Reset verification when provider or model changes diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/subagents-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/subagents-section.tsx index 093ffeba..72d22ef9 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/subagents-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/subagents-section.tsx @@ -24,11 +24,11 @@ export function SubagentsSection({ enabled, value, onToggle, onChange }: Subagen const { providers } = useProviders(); const enabledProviders = providers.filter((p) => p.enabled); // Use the first enabled provider for model list suggestions - const defaultProviderId = useMemo( - () => enabledProviders[0]?.id, + const defaultProvider = useMemo( + () => enabledProviders[0], [enabledProviders], ); - const { models, loading: modelsLoading } = useProviderModels(defaultProviderId); + const { models, loading: modelsLoading } = useProviderModels(defaultProvider?.id, defaultProvider?.provider_type); return ( enabledProviders.find((p) => p.name === provider)?.id, + const selectedProvider = useMemo( + () => enabledProviders.find((p) => p.name === provider), [enabledProviders, provider], ); - const { models, loading: modelsLoading } = useProviderModels(selectedProviderId); + const selectedProviderId = selectedProvider?.id; + const { models, loading: modelsLoading } = useProviderModels(selectedProviderId, selectedProvider?.provider_type); const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify(); useEffect(() => { diff --git a/ui/web/src/pages/config/sections/agents-defaults-section.tsx b/ui/web/src/pages/config/sections/agents-defaults-section.tsx index 3ebcf6da..7dda2de1 100644 --- a/ui/web/src/pages/config/sections/agents-defaults-section.tsx +++ b/ui/web/src/pages/config/sections/agents-defaults-section.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Save, ChevronDown, ChevronRight } from "lucide-react"; +import { Save, ChevronDown, ChevronRight, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -23,11 +23,13 @@ interface Props { export function AgentsDefaultsSection({ data, onSave, saving }: Props) { const [draft, setDraft] = useState(data ?? DEFAULT); const [dirty, setDirty] = useState(false); + const [saveError, setSaveError] = useState(null); const [openSubs, setOpenSubs] = useState>(new Set()); useEffect(() => { setDraft(data ?? DEFAULT); setDirty(false); + setSaveError(null); }, [data]); const defaults = draft.defaults ?? {}; @@ -249,9 +251,18 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) { + {saveError && ( +
+ + {saveError} +
+ )} {dirty && (
-
diff --git a/ui/web/src/pages/providers/hooks/use-provider-models.ts b/ui/web/src/pages/providers/hooks/use-provider-models.ts index 6030dee1..7d4681dd 100644 --- a/ui/web/src/pages/providers/hooks/use-provider-models.ts +++ b/ui/web/src/pages/providers/hooks/use-provider-models.ts @@ -5,12 +5,27 @@ import type { ModelInfo } from "@/types/provider"; export type { ModelInfo }; -export function useProviderModels(providerId: string | undefined) { +// Hardcoded models for ChatGPT OAuth provider. +// OAuth token lacks api.model.read scope, so we can't call /v1/models. +const CODEX_MODELS: ModelInfo[] = [ + { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, + { id: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { id: "gpt-5.2", name: "GPT-5.2" }, + { id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, + { id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" }, + { id: "gpt-5.1-codex", name: "GPT-5.1 Codex" }, + { id: "gpt-5.1", name: "GPT-5.1" }, +]; + +export function useProviderModels(providerId: string | undefined, providerType?: string) { const http = useHttp(); + const isOAuth = providerType === "chatgpt_oauth"; const { data: models = [], isLoading: loading } = useQuery({ queryKey: queryKeys.providers.models(providerId ?? ""), queryFn: async () => { + if (isOAuth) return CODEX_MODELS; const res = await http.get<{ models: ModelInfo[] }>( `/v1/providers/${providerId}/models`, ); diff --git a/ui/web/src/pages/providers/provider-form-dialog.tsx b/ui/web/src/pages/providers/provider-form-dialog.tsx index dfa75818..493eb5ea 100644 --- a/ui/web/src/pages/providers/provider-form-dialog.tsx +++ b/ui/web/src/pages/providers/provider-form-dialog.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { Dialog, DialogContent, @@ -18,7 +19,10 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Loader2, ExternalLink, CheckCircle, ClipboardPaste } from "lucide-react"; import type { ProviderData, ProviderInput } from "./hooks/use-providers"; +import { useHttp } from "@/hooks/use-ws"; +import { toast } from "@/stores/use-toast-store"; import { slugify, isValidSlug } from "@/lib/slug"; import { PROVIDER_TYPES } from "@/constants/providers"; @@ -31,6 +35,7 @@ interface ProviderFormDialogProps { export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: ProviderFormDialogProps) { const isEdit = !!provider; + const queryClient = useQueryClient(); const [name, setName] = useState(""); const [displayName, setDisplayName] = useState(""); const [providerType, setProviderType] = useState("openai_compat"); @@ -40,6 +45,8 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const isOAuth = providerType === "chatgpt_oauth"; + useEffect(() => { if (open) { setError(""); @@ -95,100 +102,372 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P Configure an LLM provider connection.
-
+ {/* Provider type selector — always shown in create mode */} + {!isEdit && (
- - setName(slugify(e.target.value))} - placeholder="e.g. openrouter" - disabled={isEdit} - /> -

Lowercase, numbers, hyphens

-
-
- - setDisplayName(e.target.value)} - placeholder="OpenRouter" - /> -
-
- -
- - { + setProviderType(v); const preset = PROVIDER_TYPES.find((t) => t.value === v); setApiBase(preset?.apiBase || ""); - } - }} - > - - - - - {PROVIDER_TYPES.map((t) => ( - - {t.label} - - ))} - - -
+ if (v === "chatgpt_oauth") { + setName("openai-codex"); + setDisplayName("ChatGPT (OAuth)"); + } else { + if (name === "openai-codex") setName(""); + if (displayName === "ChatGPT (OAuth)") setDisplayName(""); + } + }} + > + + + + + {PROVIDER_TYPES.map((t) => ( + + {t.label} + + ))} + + +
+ )} -
- - setApiBase(e.target.value)} - placeholder={PROVIDER_TYPES.find((t) => t.value === providerType)?.placeholder || PROVIDER_TYPES.find((t) => t.value === providerType)?.apiBase || "https://api.example.com/v1"} - /> -
+ {isOAuth ? ( + <> +
+
+ + +
+
+ + +
+
+ { queryClient.invalidateQueries({ queryKey: ["providers"] }); onOpenChange(false); }} /> + + ) : ( + <> +
+
+ + setName(slugify(e.target.value))} + placeholder="e.g. openrouter" + disabled={isEdit} + /> +

Lowercase, numbers, hyphens

+
+
+ + setDisplayName(e.target.value)} + placeholder="OpenRouter" + /> +
+
-
- - setApiKey(e.target.value)} - placeholder={isEdit ? "Leave as-is or enter new key" : "sk-..."} - /> - {isEdit && apiKey === "***" && ( -

- API key is set. Clear and type a new value to change it. -

- )} -
+ {isEdit && ( +
+ + +
+ )} -
- - -
- {error && ( -

{error}

+
+ + setApiBase(e.target.value)} + placeholder={PROVIDER_TYPES.find((t) => t.value === providerType)?.placeholder || PROVIDER_TYPES.find((t) => t.value === providerType)?.apiBase || "https://api.example.com/v1"} + /> +
+ +
+ + setApiKey(e.target.value)} + placeholder={isEdit ? "Leave as-is or enter new key" : "sk-..."} + /> + {isEdit && apiKey === "***" && ( +

+ API key is set. Clear and type a new value to change it. +

+ )} +
+ +
+ + +
+ {error && ( +

{error}

+ )} + )} - + {!isOAuth && ( + + )} ); } + +// --- OAuth section shown inside the dialog when chatgpt_oauth is selected --- + +interface OAuthStatus { + authenticated: boolean; + provider_name?: string; + error?: string; +} + +interface StartResponse { + auth_url?: string; + status?: string; +} + +function OAuthSection({ onSuccess }: { onSuccess: () => void }) { + const http = useHttp(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [starting, setStarting] = useState(false); + const [waitingCallback, setWaitingCallback] = useState(false); + const [pasteUrl, setPasteUrl] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [justAuthenticated, setJustAuthenticated] = useState(false); + const [countdown, setCountdown] = useState(0); + const pollRef = useRef | null>(null); + const timeoutRef = useRef | null>(null); + + const stopPolling = () => { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } + }; + + const fetchStatus = useCallback(async () => { + try { + const res = await http.get("/v1/auth/openai/status"); + setStatus(res); + return res; + } catch { + setStatus(null); + return null; + } finally { + setLoading(false); + } + }, [http]); + + useEffect(() => { + fetchStatus(); + return stopPolling; + }, [fetchStatus]); + + // Countdown timer — auto-close dialog after auth success + useEffect(() => { + if (!justAuthenticated) return; + setCountdown(3); + const iv = setInterval(() => { + setCountdown((c) => { + if (c <= 1) { + clearInterval(iv); + onSuccess(); + return 0; + } + return c - 1; + }); + }, 1000); + return () => clearInterval(iv); + }, [justAuthenticated, onSuccess]); + + const showSuccess = () => { + setWaitingCallback(false); + setJustAuthenticated(true); + }; + + const handleStart = async () => { + setStarting(true); + try { + const res = await http.post("/v1/auth/openai/start"); + if (res.status === "already_authenticated") { + await fetchStatus(); + showSuccess(); + return; + } + if (res.auth_url) { + window.open(res.auth_url, "_blank", "noopener,noreferrer"); + setWaitingCallback(true); + setPasteUrl(""); + pollRef.current = setInterval(async () => { + const s = await fetchStatus(); + if (s?.authenticated) { + stopPolling(); + showSuccess(); + } + }, 2000); + timeoutRef.current = setTimeout(() => { + stopPolling(); + setWaitingCallback(false); + }, 6 * 60 * 1000); + } + } catch (err) { + toast.error("OAuth failed", err instanceof Error ? err.message : "Unknown error"); + } finally { + setStarting(false); + } + }; + + const handlePasteSubmit = async () => { + const url = pasteUrl.trim(); + if (!url) return; + setSubmitting(true); + try { + await http.post("/v1/auth/openai/callback", { redirect_url: url }); + stopPolling(); + setPasteUrl(""); + await fetchStatus(); + showSuccess(); + } catch (err) { + toast.error("Exchange failed", err instanceof Error ? err.message : "Invalid redirect URL"); + } finally { + setSubmitting(false); + } + }; + + const handleLogout = async () => { + try { + await http.post("/v1/auth/openai/logout"); + setStatus({ authenticated: false }); + toast.success("Logged out", "OpenAI OAuth token removed"); + } catch (err) { + toast.error("Logout failed", err instanceof Error ? err.message : "Unknown error"); + } + }; + + if (loading) { + return ( +
+ Checking authentication status... +
+ ); + } + + // Just authenticated — show success with countdown + if (justAuthenticated) { + return ( +
+
+ +
+

Authentication successful!

+

+ Provider openai-codex is + now active. Closing in {countdown}s... +

+
+
+
+ ); + } + + // Already authenticated (opened dialog when already authed) + if (status?.authenticated) { + return ( +
+
+ + + Authenticated — provider openai-codex active + +
+

+ Use model prefix openai-codex/ in + agent config (e.g. openai-codex/gpt-4o). Token refreshes automatically. +

+ +
+ ); + } + + return ( +
+

+ Sign in with your ChatGPT account to use your subscription's models + without a separate API key. +

+ {waitingCallback ? ( +
+
+ + Waiting for authentication... Complete sign-in in the opened window. +
+
+

+ Remote/VPS? After signing in, your browser will try to open{" "} + localhost:1455 and show a{" "} + "Can't reach this page" error. + That's normal — copy the full URL from the browser address bar and paste it below. +

+
+ setPasteUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handlePasteSubmit()} + className="text-xs font-mono h-8" + /> + +
+
+
+ ) : ( + + )} +
+ ); +} diff --git a/ui/web/src/pages/providers/providers-page.tsx b/ui/web/src/pages/providers/providers-page.tsx index e4ac3540..54cb677d 100644 --- a/ui/web/src/pages/providers/providers-page.tsx +++ b/ui/web/src/pages/providers/providers-page.tsx @@ -26,6 +26,7 @@ const typeBadge: Record - {p.api_key === "***" ? ( + {p.provider_type === "chatgpt_oauth" ? ( + OAuth Token + ) : p.api_key === "***" ? ( *** ) : ( Not set @@ -154,23 +157,25 @@ export function ProvidersPage() { -
- - -
+ {p.provider_type !== "chatgpt_oauth" && ( +
+ + +
+ )} ); diff --git a/ui/web/src/pages/setup/step-model.tsx b/ui/web/src/pages/setup/step-model.tsx index 49a82241..d3c6946f 100644 --- a/ui/web/src/pages/setup/step-model.tsx +++ b/ui/web/src/pages/setup/step-model.tsx @@ -17,7 +17,7 @@ interface StepModelProps { } export function StepModel({ provider, onComplete }: StepModelProps) { - const { models, loading: modelsLoading } = useProviderModels(provider.id); + const { models, loading: modelsLoading } = useProviderModels(provider.id, provider.provider_type); const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify(); const [model, setModel] = useState("");