refactor(oauth): DB-backed token storage, split codex.go, remove file-based artifacts (#65)

Replace file-based OAuth token storage with DB-backed storage using
llm_providers (access token) + config_secrets (refresh token).

- Store: Add Settings JSONB field, chatgpt_oauth provider type
- OAuth: DBTokenSource backed by provider + secrets stores
- HTTP: oauth.go uses DB stores + registers provider in-memory
- Providers: chatgpt_oauth support in registerInMemory/registerProvidersFromDB
- Config: Remove HasOAuthToken, revert envFallback→envStr
- CLI: auth commands call HTTP API on running gateway
- Split codex.go (478→189 LOC) into codex.go + codex_build.go + codex_types.go
- Frontend: Remove fake OAUTH_PROVIDER_ID, use real DB-backed providers
- Tests: Rewrite with mock stores, fix SSE mock servers
This commit is contained in:
Luan Vu authored and GitHub committed 2026-03-07 00:15:30 +07:00
1 parent 519ecd1eac
commit 7d744eb4f2
32 files changed
+2997 -138

No files matched your search

+123
View File
@@ -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
},
}
}
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+6 -2
View File
@@ -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 {
+1
View File
@@ -43,6 +43,7 @@ func init() {
rootCmd.AddCommand(sessionsCmd())
rootCmd.AddCommand(migrateCmd())
rootCmd.AddCommand(upgradeCmd())
rootCmd.AddCommand(authCmd())
}
func versionCmd() *cobra.Command {
+1
View File
@@ -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)
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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)
+9
View File
@@ -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 }
+221
View File
@@ -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
}
+240
View File
@@ -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")
}
}
+8 -3
View File
@@ -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 {
+291
View File
@@ -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, `<html><body><h2>Authentication Failed</h2><p>Invalid state parameter.</p></body></html>`)
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, `<html><body><h2>Authentication Failed</h2><p>%s</p></body></html>`, html.EscapeString(errMsg))
errCh <- fmt.Errorf("oauth callback: %s", errMsg)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><body><h2>Authentication Successful!</h2><p>You can close this window.</p></body></html>`)
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()
}
}
+228
View File
@@ -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 != ""
}
+324
View File
@@ -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)
}
}
+189
View File
@@ -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"
}
}
}
}
+179
View File
@@ -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
}
+628
View File
@@ -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"])
}
}
+61
View File
@@ -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
}
+15
View File
@@ -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:"-"`
+26 -7
View File
@@ -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)
+11 -6
View File
@@ -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
@@ -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;
+1
View File
@@ -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: "" },
@@ -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
@@ -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 (
<ConfigSection
@@ -66,11 +66,12 @@ function MediaSettingsForm({
const [saving, setSaving] = useState(false);
// Resolve provider name → id for model list and verify
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();
useEffect(() => {
@@ -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<AgentsData>(data ?? DEFAULT);
const [dirty, setDirty] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [openSubs, setOpenSubs] = useState<Set<string>>(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) {
</div>
</SubSection>
{saveError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{saveError}
</div>
)}
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
<Button size="sm" onClick={async () => {
setSaveError(null);
try { await onSave(draft); } catch (err) { setSaveError(err instanceof Error ? err.message : "Failed to save"); }
}} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? "Saving..." : "Save"}
</Button>
</div>
@@ -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`,
);
@@ -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
<DialogDescription>Configure an LLM provider connection.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto min-h-0">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Provider type selector — always shown in create mode */}
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(slugify(e.target.value))}
placeholder="e.g. openrouter"
disabled={isEdit}
/>
<p className="text-xs text-muted-foreground">Lowercase, numbers, hyphens</p>
</div>
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="OpenRouter"
/>
</div>
</div>
<div className="space-y-2">
<Label>Provider Type *</Label>
<Select
value={providerType}
onValueChange={(v) => {
setProviderType(v);
if (!isEdit) {
<Label>Provider Type *</Label>
<Select
value={providerType}
onValueChange={(v) => {
setProviderType(v);
const preset = PROVIDER_TYPES.find((t) => t.value === v);
setApiBase(preset?.apiBase || "");
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
if (v === "chatgpt_oauth") {
setName("openai-codex");
setDisplayName("ChatGPT (OAuth)");
} else {
if (name === "openai-codex") setName("");
if (displayName === "ChatGPT (OAuth)") setDisplayName("");
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="apiBase">API Base URL</Label>
<Input
id="apiBase"
value={apiBase}
onChange={(e) => 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"}
/>
</div>
{isOAuth ? (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Name</Label>
<Input value="openai-codex" disabled />
</div>
<div className="space-y-2">
<Label>Display Name</Label>
<Input value="ChatGPT (OAuth)" disabled />
</div>
</div>
<OAuthSection onSuccess={() => { queryClient.invalidateQueries({ queryKey: ["providers"] }); onOpenChange(false); }} />
</>
) : (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(slugify(e.target.value))}
placeholder="e.g. openrouter"
disabled={isEdit}
/>
<p className="text-xs text-muted-foreground">Lowercase, numbers, hyphens</p>
</div>
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="OpenRouter"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={isEdit ? "Leave as-is or enter new key" : "sk-..."}
/>
{isEdit && apiKey === "***" && (
<p className="text-xs text-muted-foreground">
API key is set. Clear and type a new value to change it.
</p>
)}
</div>
{isEdit && (
<div className="space-y-2">
<Label>Provider Type *</Label>
<Select value={providerType} onValueChange={setProviderType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_TYPES.filter((t) => t.value !== "chatgpt_oauth").map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center justify-between">
<Label htmlFor="enabled">Enabled</Label>
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
<div className="space-y-2">
<Label htmlFor="apiBase">API Base URL</Label>
<Input
id="apiBase"
value={apiBase}
onChange={(e) => 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"}
/>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={isEdit ? "Leave as-is or enter new key" : "sk-..."}
/>
{isEdit && apiKey === "***" && (
<p className="text-xs text-muted-foreground">
API key is set. Clear and type a new value to change it.
</p>
)}
</div>
<div className="flex items-center justify-between">
<Label htmlFor="enabled">Enabled</Label>
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!name.trim() || !isValidSlug(name) || !providerType || loading}
>
{loading ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
{isOAuth ? "Close" : "Cancel"}
</Button>
{!isOAuth && (
<Button
onClick={handleSubmit}
disabled={!name.trim() || !isValidSlug(name) || !providerType || loading}
>
{loading ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// --- 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<OAuthStatus | null>(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<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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<OAuthStatus>("/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<StartResponse>("/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 (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Checking authentication status...
</div>
);
}
// Just authenticated — show success with countdown
if (justAuthenticated) {
return (
<div className="space-y-3 py-2">
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/5 px-4 py-3 text-sm text-green-700 dark:text-green-400">
<CheckCircle className="h-5 w-5 shrink-0" />
<div>
<p className="font-medium">Authentication successful!</p>
<p className="text-xs mt-0.5 opacity-80">
Provider <code className="rounded bg-muted px-1 font-mono text-xs">openai-codex</code> is
now active. Closing in {countdown}s...
</p>
</div>
</div>
</div>
);
}
// Already authenticated (opened dialog when already authed)
if (status?.authenticated) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2 text-sm text-green-700 dark:text-green-400">
<CheckCircle className="h-4 w-4 shrink-0" />
<span>
Authenticated — provider <code className="rounded bg-muted px-1 font-mono text-xs">openai-codex</code> active
</span>
</div>
<p className="text-xs text-muted-foreground">
Use model prefix <code className="rounded bg-muted px-1 font-mono">openai-codex/</code> in
agent config (e.g. openai-codex/gpt-4o). Token refreshes automatically.
</p>
<Button variant="outline" size="sm" onClick={handleLogout} className="gap-1.5">
Remove Token
</Button>
</div>
);
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Sign in with your ChatGPT account to use your subscription's models
without a separate API key.
</p>
{waitingCallback ? (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2 text-sm text-blue-700 dark:text-blue-400">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
<span>Waiting for authentication... Complete sign-in in the opened window.</span>
</div>
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3 space-y-2">
<p className="text-xs text-amber-700 dark:text-amber-400">
<strong>Remote/VPS?</strong> After signing in, your browser will try to open{" "}
<code className="text-xs">localhost:1455</code> and show a{" "}
<strong>&quot;Can&apos;t reach this page&quot;</strong> error.
That&apos;s normal — <strong>copy the full URL from the browser address bar</strong> and paste it below.
</p>
<div className="flex gap-2">
<Input
placeholder="http://localhost:1455/auth/callback?code=...&state=..."
value={pasteUrl}
onChange={(e) => setPasteUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handlePasteSubmit()}
className="text-xs font-mono h-8"
/>
<Button
size="sm"
onClick={handlePasteSubmit}
disabled={submitting || !pasteUrl.trim()}
className="gap-1.5 shrink-0 h-8"
>
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ClipboardPaste className="h-3.5 w-3.5" />}
Submit
</Button>
</div>
</div>
</div>
) : (
<Button size="sm" onClick={handleStart} disabled={starting} className="gap-1.5">
{starting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ExternalLink className="h-3.5 w-3.5" />}
{starting ? "Starting..." : "Sign in with ChatGPT"}
</Button>
)}
</div>
);
}
+23 -18
View File
@@ -26,6 +26,7 @@ const typeBadge: Record<string, { label: string; variant: "default" | "secondary
minimax_native: { label: "MiniMax", variant: "secondary" },
cohere: { label: "Cohere", variant: "secondary" },
perplexity: { label: "Perplexity", variant: "secondary" },
chatgpt_oauth: { label: "ChatGPT (OAuth)", variant: "default" },
};
export function ProvidersPage() {
@@ -142,7 +143,9 @@ export function ProvidersPage() {
{p.api_base || "-"}
</td>
<td className="px-4 py-3">
{p.api_key === "***" ? (
{p.provider_type === "chatgpt_oauth" ? (
<Badge variant="outline" className="text-xs">OAuth Token</Badge>
) : p.api_key === "***" ? (
<Badge variant="outline" className="font-mono text-xs">***</Badge>
) : (
<span className="text-xs text-muted-foreground">Not set</span>
@@ -154,23 +157,25 @@ export function ProvidersPage() {
</Badge>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => { setEditProvider(p); setFormOpen(true); }}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(p)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{p.provider_type !== "chatgpt_oauth" && (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => { setEditProvider(p); setFormOpen(true); }}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(p)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</td>
</tr>
);
+1 -1
View File
@@ -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("");