mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 14:16:30 +00:00
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:
1 parent
519ecd1eac
commit
7d744eb4f2
32 files changed
+2997
-138
No files matched your search
+123
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -43,6 +43,7 @@ func init() {
|
||||
rootCmd.AddCommand(sessionsCmd())
|
||||
rootCmd.AddCommand(migrateCmd())
|
||||
rootCmd.AddCommand(upgradeCmd())
|
||||
rootCmd.AddCommand(authCmd())
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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:"-"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>"Can't reach this page"</strong> error.
|
||||
That'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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("");
|
||||
|
||||
Reference in new issue
Block a user