| package proxy |
|
|
| import ( |
| "bytes" |
| "encoding/json" |
| "fmt" |
| "io" |
| "kiro-api-proxy/auth" |
| "kiro-api-proxy/config" |
| "kiro-api-proxy/pool" |
| "math/rand" |
| "net/http" |
| "net/http/httptest" |
| "net/http/httputil" |
| "net/url" |
| "os" |
| "strconv" |
| "strings" |
| "sync" |
| "sync/atomic" |
| "time" |
|
|
| "github.com/google/uuid" |
| ) |
|
|
| type statusRecorder struct { |
| http.ResponseWriter |
| status int |
| } |
|
|
| func (sr *statusRecorder) WriteHeader(code int) { |
| sr.status = code |
| sr.ResponseWriter.WriteHeader(code) |
| } |
|
|
| func (sr *statusRecorder) Write(b []byte) (int, error) { |
| if sr.status == 0 { |
| sr.status = http.StatusOK |
| } |
| return sr.ResponseWriter.Write(b) |
| } |
|
|
| type RequestLogAttempt struct { |
| Try int `json:"try"` |
| AccountID string `json:"accountId,omitempty"` |
| Email string `json:"email,omitempty"` |
| StatusCode int `json:"statusCode"` |
| Error string `json:"error,omitempty"` |
| DurationMs int64 `json:"durationMs"` |
| } |
|
|
| type RequestLogEntry struct { |
| Time int64 `json:"time"` |
| Path string `json:"path"` |
| Model string `json:"model,omitempty"` |
| AccountID string `json:"accountId,omitempty"` |
| Email string `json:"email,omitempty"` |
| Attempts int `json:"attempts"` |
| FinalStatus int `json:"finalStatus"` |
| DurationMs int64 `json:"durationMs"` |
| Error string `json:"error,omitempty"` |
| AttemptItems []RequestLogAttempt `json:"attemptItems,omitempty"` |
| } |
|
|
| type requestLogRing struct { |
| mu sync.RWMutex |
| buf []RequestLogEntry |
| next int |
| count int |
| } |
|
|
| func newRequestLogRing(size int) *requestLogRing { |
| if size <= 0 { |
| size = 500 |
| } |
| return &requestLogRing{buf: make([]RequestLogEntry, size)} |
| } |
|
|
| func (r *requestLogRing) Add(entry RequestLogEntry) { |
| r.mu.Lock() |
| defer r.mu.Unlock() |
| r.buf[r.next] = entry |
| r.next = (r.next + 1) % len(r.buf) |
| if r.count < len(r.buf) { |
| r.count++ |
| } |
| } |
|
|
| func (r *requestLogRing) List(limit int) []RequestLogEntry { |
| r.mu.RLock() |
| defer r.mu.RUnlock() |
| if r.count == 0 { |
| return []RequestLogEntry{} |
| } |
| if limit <= 0 || limit > r.count { |
| limit = r.count |
| } |
| result := make([]RequestLogEntry, 0, limit) |
| for i := 0; i < limit; i++ { |
| idx := (r.next - 1 - i + len(r.buf)) % len(r.buf) |
| result = append(result, r.buf[idx]) |
| } |
| return result |
| } |
|
|
| type RequestFinalMetrics struct { |
| Path string |
| Model string |
| AccountID string |
| AccountEmail string |
| Attempts int |
| FinalStatus int |
| DurationMs int64 |
| Error string |
| AttemptItems []RequestLogAttempt |
| TotalTokens int |
| Credits float64 |
| } |
|
|
| |
| type Handler struct { |
| pool *pool.AccountPool |
| |
| totalRequests int64 |
| successRequests int64 |
| failedRequests int64 |
| attemptFailedRequests int64 |
| totalRetries int64 |
| totalTokens int64 |
| totalCredits float64 |
| creditsMu sync.RWMutex |
| startTime int64 |
| stopRefresh chan struct{} |
| stopStatsSaver chan struct{} |
| requestLogs *requestLogRing |
| |
| cachedModels []ModelInfo |
| modelsCacheMu sync.RWMutex |
| modelsCacheTime int64 |
| gatewayBase string |
| gatewayAPIKey string |
| gatewayProxy *httputil.ReverseProxy |
| } |
|
|
| func NewHandler() *Handler { |
| totalReq, successReq, failedReq, attemptFailedReq, totalRetries, totalTokens, totalCredits := config.GetStats() |
| h := &Handler{ |
| pool: pool.GetPool(), |
| totalRequests: int64(totalReq), |
| successRequests: int64(successReq), |
| failedRequests: int64(failedReq), |
| attemptFailedRequests: int64(attemptFailedReq), |
| totalRetries: int64(totalRetries), |
| totalTokens: int64(totalTokens), |
| totalCredits: totalCredits, |
| startTime: time.Now().Unix(), |
| stopRefresh: make(chan struct{}), |
| stopStatsSaver: make(chan struct{}), |
| requestLogs: newRequestLogRing(500), |
| gatewayBase: strings.TrimRight(os.Getenv("KIRO_GATEWAY_BASE"), "/"), |
| gatewayAPIKey: os.Getenv("KIRO_GATEWAY_API_KEY"), |
| } |
| if h.gatewayBase != "" { |
| if u, err := url.Parse(h.gatewayBase); err == nil { |
| h.gatewayProxy = httputil.NewSingleHostReverseProxy(u) |
| origDirector := h.gatewayProxy.Director |
| h.gatewayProxy.Director = func(req *http.Request) { |
| origDirector(req) |
| if h.gatewayAPIKey != "" { |
| req.Header.Set("Authorization", "Bearer "+h.gatewayAPIKey) |
| } |
| } |
| logger := fmt.Sprintf("[GatewayProxy] enabled -> %s", h.gatewayBase) |
| _ = logger |
| } |
| } |
| |
| go h.backgroundRefresh() |
| |
| go h.backgroundStatsSaver() |
| return h |
| } |
|
|
| |
| func (h *Handler) backgroundRefresh() { |
| ticker := time.NewTicker(30 * time.Minute) |
| defer ticker.Stop() |
|
|
| |
| time.Sleep(10 * time.Second) |
| h.refreshModelsCache() |
| h.refreshAllAccounts() |
|
|
| for { |
| select { |
| case <-ticker.C: |
| h.refreshModelsCache() |
| h.refreshAllAccounts() |
| case <-h.stopRefresh: |
| return |
| } |
| } |
| } |
|
|
| |
| func (h *Handler) refreshAllAccounts() { |
| accounts := config.GetAccounts() |
| for i := range accounts { |
| account := &accounts[i] |
| if !account.Enabled || account.AccessToken == "" { |
| continue |
| } |
|
|
| |
| if account.ExpiresAt > 0 && time.Now().Unix() > account.ExpiresAt-300 { |
| newAccessToken, newRefreshToken, newExpiresAt, err := auth.RefreshToken(account) |
| if err != nil { |
| fmt.Printf("[BackgroundRefresh] Token refresh failed for %s: %v\n", account.Email, err) |
| continue |
| } |
| account.AccessToken = newAccessToken |
| if newRefreshToken != "" { |
| account.RefreshToken = newRefreshToken |
| } |
| account.ExpiresAt = newExpiresAt |
| config.UpdateAccountToken(account.ID, newAccessToken, newRefreshToken, newExpiresAt) |
| h.pool.UpdateToken(account.ID, newAccessToken, newRefreshToken, newExpiresAt) |
| } |
|
|
| |
| info, err := RefreshAccountInfo(account) |
| if err != nil { |
| fmt.Printf("[BackgroundRefresh] Failed to refresh %s: %v\n", account.Email, err) |
| continue |
| } |
|
|
| config.UpdateAccountInfo(account.ID, *info) |
| fmt.Printf("[BackgroundRefresh] Refreshed %s: %s %.1f/%.1f\n", account.Email, info.SubscriptionType, info.UsageCurrent, info.UsageLimit) |
| } |
| h.pool.Reload() |
| } |
|
|
| |
| func (h *Handler) validateApiKey(r *http.Request) bool { |
| if !config.IsApiKeyRequired() { |
| return true |
| } |
|
|
| expectedKey := config.GetApiKey() |
| if expectedKey == "" { |
| return true |
| } |
|
|
| |
| authHeader := r.Header.Get("Authorization") |
| apiKeyHeader := r.Header.Get("X-Api-Key") |
|
|
| var providedKey string |
| if strings.HasPrefix(authHeader, "Bearer ") { |
| providedKey = strings.TrimPrefix(authHeader, "Bearer ") |
| } else if apiKeyHeader != "" { |
| providedKey = apiKeyHeader |
| } |
|
|
| return providedKey == expectedKey |
| } |
|
|
| func (h *Handler) useGatewayProxy() bool { |
| return h.gatewayProxy != nil |
| } |
|
|
| func (h *Handler) selectGatewayAccountWithTried(tried map[string]bool) *config.Account { |
| accounts := h.pool.GetAllAccounts() |
| if len(accounts) == 0 { |
| return nil |
| } |
|
|
| candidates := make([]config.Account, 0, len(accounts)) |
| for _, a := range accounts { |
| if !a.Enabled || a.RefreshToken == "" { |
| continue |
| } |
| if tried != nil && tried[a.ID] { |
| continue |
| } |
| candidates = append(candidates, a) |
| } |
| if len(candidates) == 0 { |
| return nil |
| } |
|
|
| |
| maxWeight := 0 |
| for i := range candidates { |
| w := candidates[i].Weight |
| if w <= 0 { |
| w = 100 |
| } |
| if w > maxWeight { |
| maxWeight = w |
| } |
| } |
| if maxWeight <= 0 { |
| maxWeight = 100 |
| } |
|
|
| top := make([]config.Account, 0, len(candidates)) |
| for i := range candidates { |
| w := candidates[i].Weight |
| if w <= 0 { |
| w = 100 |
| } |
| if w == maxWeight { |
| top = append(top, candidates[i]) |
| } |
| } |
| if len(top) == 0 { |
| chosen := candidates[rand.Intn(len(candidates))] |
| return &chosen |
| } |
| chosen := top[rand.Intn(len(top))] |
| return &chosen |
| } |
|
|
| func (h *Handler) buildGatewayPools(accounts []config.Account) ([]config.Account, []config.Account) { |
| primary := make([]config.Account, 0) |
| fallback := make([]config.Account, 0) |
| for _, a := range accounts { |
| if !a.Enabled || a.RefreshToken == "" { |
| continue |
| } |
| if a.UsageCurrent >= 10 { |
| primary = append(primary, a) |
| } else { |
| fallback = append(fallback, a) |
| } |
| } |
| return primary, fallback |
| } |
|
|
| func (h *Handler) pickWeightedGateway(candidates []config.Account) *config.Account { |
| if len(candidates) == 0 { |
| return nil |
| } |
|
|
| |
| maxWeight := 0 |
| for i := range candidates { |
| w := candidates[i].Weight |
| if w <= 0 { |
| w = 100 |
| } |
| if w > maxWeight { |
| maxWeight = w |
| } |
| } |
| if maxWeight <= 0 { |
| maxWeight = 100 |
| } |
|
|
| top := make([]config.Account, 0, len(candidates)) |
| for i := range candidates { |
| w := candidates[i].Weight |
| if w <= 0 { |
| w = 100 |
| } |
| if w == maxWeight { |
| top = append(top, candidates[i]) |
| } |
| } |
| if len(top) == 0 { |
| chosen := candidates[rand.Intn(len(candidates))] |
| return &chosen |
| } |
|
|
| chosen := top[rand.Intn(len(top))] |
| return &chosen |
| } |
|
|
| func (h *Handler) proxyToGatewayWithAccount(w http.ResponseWriter, r *http.Request, account *config.Account) { |
| if h.gatewayProxy == nil { |
| http.Error(w, "gateway proxy not configured", 500) |
| return |
| } |
|
|
| if account != nil { |
| r.Header.Set("X-Kiro-Refresh-Token", account.RefreshToken) |
| r.Header.Set("X-Kiro-Region", account.Region) |
| r.Header.Set("X-Kiro-Auth-Method", account.AuthMethod) |
| r.Header.Set("X-Kiro-Client-Id", account.ClientID) |
| r.Header.Set("X-Kiro-Client-Secret", account.ClientSecret) |
| } |
|
|
| h.gatewayProxy.ServeHTTP(w, r) |
| } |
|
|
| func (h *Handler) proxyWithFailover(w http.ResponseWriter, r *http.Request) { |
| if h.gatewayProxy == nil { |
| http.Error(w, "gateway proxy not configured", 500) |
| return |
| } |
|
|
| reqStart := time.Now() |
| bodyBytes, _ := io.ReadAll(r.Body) |
| r.Body.Close() |
| model := extractModelFromRequestBody(bodyBytes) |
|
|
| maxAttempts := 4 |
| if r.URL.Path == "/v1/models" || r.Method == http.MethodGet { |
| maxAttempts = 2 |
| } |
|
|
| var ( |
| lastStatus int |
| lastBody []byte |
| lastHeader http.Header |
| lastError string |
| lastAccID string |
| lastEmail string |
| ) |
|
|
| attemptItems := make([]RequestLogAttempt, 0, maxAttempts) |
| tried := map[string]bool{} |
|
|
| for i := 0; i < maxAttempts; i++ { |
| acc := h.selectGatewayAccountWithTried(tried) |
| if acc == nil { |
| break |
| } |
| tried[acc.ID] = true |
|
|
| lastAccID = acc.ID |
| lastEmail = acc.Email |
|
|
| cloneReq := r.Clone(r.Context()) |
| cloneReq.Body = io.NopCloser(bytes.NewReader(bodyBytes)) |
| cloneReq.ContentLength = int64(len(bodyBytes)) |
| if len(bodyBytes) > 0 { |
| cloneReq.GetBody = func() (io.ReadCloser, error) { |
| return io.NopCloser(bytes.NewReader(bodyBytes)), nil |
| } |
| } |
|
|
| tryStart := time.Now() |
| rec := httptest.NewRecorder() |
| h.proxyToGatewayWithAccount(rec, cloneReq, acc) |
| resp := rec.Result() |
| respBody, _ := io.ReadAll(resp.Body) |
| resp.Body.Close() |
|
|
| lastStatus = resp.StatusCode |
| lastBody = respBody |
| lastHeader = resp.Header.Clone() |
| if resp.StatusCode >= 400 { |
| lastError = string(respBody) |
| } else { |
| lastError = "" |
| } |
|
|
| attemptItems = append(attemptItems, RequestLogAttempt{ |
| Try: len(attemptItems) + 1, |
| AccountID: acc.ID, |
| Email: acc.Email, |
| StatusCode: resp.StatusCode, |
| DurationMs: time.Since(tryStart).Milliseconds(), |
| Error: truncateError(lastError), |
| }) |
|
|
| |
| if resp.StatusCode >= 200 && resp.StatusCode < 400 { |
| for k, vals := range resp.Header { |
| for _, v := range vals { |
| w.Header().Add(k, v) |
| } |
| } |
| w.WriteHeader(resp.StatusCode) |
| _, _ = w.Write(respBody) |
|
|
| totalTokens, credits := extractUsageMetrics(respBody) |
| h.finalizeRequest(RequestFinalMetrics{ |
| Path: r.URL.Path, |
| Model: model, |
| AccountID: acc.ID, |
| AccountEmail: acc.Email, |
| Attempts: len(attemptItems), |
| FinalStatus: resp.StatusCode, |
| DurationMs: time.Since(reqStart).Milliseconds(), |
| AttemptItems: attemptItems, |
| TotalTokens: totalTokens, |
| Credits: credits, |
| }) |
| return |
| } |
|
|
| |
| if resp.StatusCode == 402 || resp.StatusCode == 429 || resp.StatusCode >= 500 { |
| |
| h.pool.RecordError(acc.ID, resp.StatusCode == 402 || resp.StatusCode == 429) |
| continue |
| } |
|
|
| |
| for k, vals := range resp.Header { |
| for _, v := range vals { |
| w.Header().Add(k, v) |
| } |
| } |
| w.WriteHeader(resp.StatusCode) |
| _, _ = w.Write(respBody) |
|
|
| h.finalizeRequest(RequestFinalMetrics{ |
| Path: r.URL.Path, |
| Model: model, |
| AccountID: acc.ID, |
| AccountEmail: acc.Email, |
| Attempts: len(attemptItems), |
| FinalStatus: resp.StatusCode, |
| DurationMs: time.Since(reqStart).Milliseconds(), |
| Error: truncateError(lastError), |
| AttemptItems: attemptItems, |
| }) |
| return |
| } |
|
|
| if lastStatus == 0 { |
| lastStatus = http.StatusServiceUnavailable |
| lastError = "no available account" |
| http.Error(w, "no available account", http.StatusServiceUnavailable) |
| } else { |
| if lastHeader != nil { |
| for k, vals := range lastHeader { |
| for _, v := range vals { |
| w.Header().Add(k, v) |
| } |
| } |
| } |
| w.WriteHeader(lastStatus) |
| _, _ = w.Write(lastBody) |
| } |
|
|
| h.finalizeRequest(RequestFinalMetrics{ |
| Path: r.URL.Path, |
| Model: model, |
| AccountID: lastAccID, |
| AccountEmail: lastEmail, |
| Attempts: max(1, len(attemptItems)), |
| FinalStatus: lastStatus, |
| DurationMs: time.Since(reqStart).Milliseconds(), |
| Error: truncateError(lastError), |
| AttemptItems: attemptItems, |
| }) |
| } |
|
|
| func (h *Handler) proxyToGateway(w http.ResponseWriter, r *http.Request) { |
| |
| h.proxyToGatewayWithAccount(w, r, nil) |
| } |
|
|
| |
| func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
|
|
| path := r.URL.Path |
|
|
| |
| w.Header().Set("Access-Control-Allow-Origin", "*") |
| w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") |
| w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Api-Key, anthropic-version, anthropic-beta, x-api-key, x-stainless-os, x-stainless-lang, x-stainless-package-version, x-stainless-runtime, x-stainless-runtime-version, x-stainless-arch") |
| w.Header().Set("Access-Control-Expose-Headers", "x-request-id, x-ratelimit-limit-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-requests, x-ratelimit-remaining-tokens, x-ratelimit-reset-requests, x-ratelimit-reset-tokens") |
|
|
| if r.Method == "OPTIONS" { |
| w.WriteHeader(204) |
| return |
| } |
|
|
| |
| switch { |
| |
| case path == "/v1/messages" || path == "/messages" || path == "/anthropic/v1/messages": |
| if !h.validateApiKey(r) { |
| h.sendClaudeError(w, 401, "authentication_error", "Invalid or missing API key") |
| return |
| } |
| if h.useGatewayProxy() { |
| h.proxyWithFailover(w, r) |
| return |
| } |
| h.handleClaudeMessages(w, r) |
| case path == "/v1/messages/count_tokens" || path == "/messages/count_tokens": |
| if !h.validateApiKey(r) { |
| h.sendClaudeError(w, 401, "authentication_error", "Invalid or missing API key") |
| return |
| } |
| if h.useGatewayProxy() { |
| h.proxyWithFailover(w, r) |
| return |
| } |
| h.handleCountTokens(w, r) |
| case path == "/v1/chat/completions" || path == "/chat/completions": |
| if !h.validateApiKey(r) { |
| h.sendOpenAIError(w, 401, "authentication_error", "Invalid or missing API key") |
| return |
| } |
| if h.useGatewayProxy() { |
| h.proxyWithFailover(w, r) |
| return |
| } |
| h.handleOpenAIChat(w, r) |
| case path == "/v1/models" || path == "/models": |
| if h.useGatewayProxy() { |
| h.proxyWithFailover(w, r) |
| return |
| } |
| h.handleModels(w, r) |
| case path == "/api/event_logging/batch": |
| |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| w.Write([]byte(`{"status":"ok"}`)) |
|
|
| |
| case path == "/admin" || path == "/admin/": |
| h.serveAdminPage(w, r) |
| case strings.HasPrefix(path, "/admin/api/"): |
| h.handleAdminAPI(w, r) |
| case strings.HasPrefix(path, "/admin/"): |
| h.serveStaticFile(w, r) |
|
|
| |
| case path == "/health" || path == "/": |
| h.handleHealth(w, r) |
|
|
| |
| case path == "/v1/stats": |
| if !h.validateApiKey(r) { |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| w.WriteHeader(401) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid or missing API key"}) |
| return |
| } |
| h.handleStats(w, r) |
|
|
| default: |
| http.Error(w, "Not Found", 404) |
| } |
| } |
|
|
| |
| func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "status": "ok", |
| "version": config.Version, |
| "uptime": time.Now().Unix() - h.startTime, |
| }) |
| } |
|
|
| |
| func (h *Handler) handleStats(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "status": "ok", |
| "version": config.Version, |
| "accounts": h.pool.Count(), |
| "available": h.pool.AvailableCount(), |
| "totalRequests": atomic.LoadInt64(&h.totalRequests), |
| "successRequests": atomic.LoadInt64(&h.successRequests), |
| "failedRequests": atomic.LoadInt64(&h.failedRequests), |
| "totalTokens": atomic.LoadInt64(&h.totalTokens), |
| "totalCredits": h.getCredits(), |
| "uptime": time.Now().Unix() - h.startTime, |
| }) |
| } |
|
|
| |
| func (h *Handler) handleModels(w http.ResponseWriter, r *http.Request) { |
| |
| h.modelsCacheMu.RLock() |
| cached := h.cachedModels |
| h.modelsCacheMu.RUnlock() |
|
|
| thinkingSuffix := config.GetThinkingConfig().Suffix |
|
|
| var models []map[string]interface{} |
| if len(cached) > 0 { |
| for _, m := range cached { |
| models = append(models, map[string]interface{}{ |
| "id": m.ModelId, "object": "model", "owned_by": "anthropic", |
| }) |
| |
| models = append(models, map[string]interface{}{ |
| "id": m.ModelId + thinkingSuffix, "object": "model", "owned_by": "anthropic", |
| }) |
| } |
| } else { |
| |
| models = []map[string]interface{}{ |
| {"id": "claude-sonnet-4.6", "object": "model", "owned_by": "anthropic"}, |
| {"id": "claude-sonnet-4.6" + thinkingSuffix, "object": "model", "owned_by": "anthropic"}, |
| {"id": "claude-sonnet-4.5", "object": "model", "owned_by": "anthropic"}, |
| {"id": "claude-sonnet-4.5" + thinkingSuffix, "object": "model", "owned_by": "anthropic"}, |
| {"id": "claude-sonnet-4", "object": "model", "owned_by": "anthropic"}, |
| {"id": "claude-sonnet-4" + thinkingSuffix, "object": "model", "owned_by": "anthropic"}, |
| {"id": "claude-haiku-4.5", "object": "model", "owned_by": "anthropic"}, |
| {"id": "claude-haiku-4.5" + thinkingSuffix, "object": "model", "owned_by": "anthropic"}, |
| {"id": "claude-opus-4.5", "object": "model", "owned_by": "anthropic"}, |
| {"id": "claude-opus-4.5" + thinkingSuffix, "object": "model", "owned_by": "anthropic"}, |
| } |
| } |
| |
| models = append(models, |
| map[string]interface{}{"id": "auto", "object": "model", "owned_by": "kiro-proxy"}, |
| map[string]interface{}{"id": "gpt-4o", "object": "model", "owned_by": "kiro-proxy"}, |
| map[string]interface{}{"id": "gpt-4", "object": "model", "owned_by": "kiro-proxy"}, |
| ) |
|
|
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "object": "list", |
| "data": models, |
| }) |
| } |
|
|
| |
| func (h *Handler) refreshModelsCache() { |
| account := h.pool.GetNext() |
| if account == nil { |
| return |
| } |
|
|
| |
| if err := h.ensureValidToken(account); err != nil { |
| return |
| } |
|
|
| models, err := ListAvailableModels(account) |
| if err != nil { |
| fmt.Printf("[ModelsCache] Failed to refresh: %v\n", err) |
| return |
| } |
|
|
| if len(models) > 0 { |
| h.modelsCacheMu.Lock() |
| h.cachedModels = models |
| h.modelsCacheTime = time.Now().Unix() |
| h.modelsCacheMu.Unlock() |
| fmt.Printf("[ModelsCache] Cached %d models\n", len(models)) |
| } |
| } |
|
|
| |
| func (h *Handler) handleCountTokens(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "POST" { |
| http.Error(w, "Method Not Allowed", 405) |
| return |
| } |
|
|
| body, err := io.ReadAll(r.Body) |
| if err != nil { |
| h.sendClaudeError(w, 400, "invalid_request_error", "Failed to read request body") |
| return |
| } |
|
|
| var req struct { |
| Messages []struct { |
| Role string `json:"role"` |
| Content interface{} `json:"content"` |
| } `json:"messages"` |
| System interface{} `json:"system"` |
| } |
| if err := json.Unmarshal(body, &req); err != nil { |
| h.sendClaudeError(w, 400, "invalid_request_error", "Invalid JSON") |
| return |
| } |
|
|
| |
| var totalChars int |
| for _, msg := range req.Messages { |
| switch content := msg.Content.(type) { |
| case string: |
| totalChars += len(content) |
| case []interface{}: |
| for _, part := range content { |
| if p, ok := part.(map[string]interface{}); ok { |
| if text, ok := p["text"].(string); ok { |
| totalChars += len(text) |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| switch system := req.System.(type) { |
| case string: |
| totalChars += len(system) |
| case []interface{}: |
| for _, part := range system { |
| if p, ok := part.(map[string]interface{}); ok { |
| if text, ok := p["text"].(string); ok { |
| totalChars += len(text) |
| } |
| } |
| } |
| } |
|
|
| estimatedTokens := (totalChars + 3) / 4 |
| if estimatedTokens < 1 { |
| estimatedTokens = 1 |
| } |
|
|
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| json.NewEncoder(w).Encode(map[string]int{"input_tokens": estimatedTokens}) |
| } |
|
|
| |
| func (h *Handler) handleClaudeMessages(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "POST" { |
| http.Error(w, "Method Not Allowed", 405) |
| return |
| } |
|
|
| requestStart := time.Now() |
|
|
| |
| body, err := io.ReadAll(r.Body) |
| if err != nil { |
| h.sendClaudeError(w, 400, "invalid_request_error", "Failed to read request body") |
| return |
| } |
|
|
| var req ClaudeRequest |
| if err := json.Unmarshal(body, &req); err != nil { |
| h.sendClaudeError(w, 400, "invalid_request_error", "Invalid JSON: "+err.Error()) |
| return |
| } |
|
|
| |
| account := h.pool.GetNext() |
| if account == nil { |
| h.sendClaudeError(w, 503, "api_error", "No available accounts") |
| h.finalizeRequest(RequestFinalMetrics{ |
| Path: r.URL.Path, |
| Model: req.Model, |
| Attempts: 1, |
| FinalStatus: 503, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| Error: "No available accounts", |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| StatusCode: 503, |
| Error: "No available accounts", |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| return |
| } |
|
|
| |
| if err := h.ensureValidToken(account); err != nil { |
| h.sendClaudeError(w, 503, "api_error", "Token refresh failed: "+err.Error()) |
| h.finalizeRequest(RequestFinalMetrics{ |
| Path: r.URL.Path, |
| Model: req.Model, |
| AccountID: account.ID, |
| AccountEmail: account.Email, |
| Attempts: 1, |
| FinalStatus: 503, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| Error: truncateError(err.Error()), |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| AccountID: account.ID, |
| Email: account.Email, |
| StatusCode: 503, |
| Error: truncateError(err.Error()), |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| return |
| } |
|
|
| |
| thinkingCfg := config.GetThinkingConfig() |
| actualModel, thinking := ParseModelAndThinking(req.Model, thinkingCfg.Suffix) |
| req.Model = actualModel |
|
|
| |
| kiroPayload := ClaudeToKiro(&req, thinking) |
|
|
| |
| if req.Stream { |
| h.handleClaudeStream(w, account, kiroPayload, req.Model, requestStart) |
| } else { |
| h.handleClaudeNonStream(w, account, kiroPayload, req.Model, requestStart) |
| } |
| } |
|
|
| |
| func (h *Handler) handleClaudeStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, requestStart time.Time) { |
| w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") |
| w.Header().Set("Cache-Control", "no-cache") |
| w.Header().Set("Connection", "keep-alive") |
|
|
| flusher, ok := w.(http.Flusher) |
| if !ok { |
| h.sendClaudeError(w, 500, "api_error", "Streaming not supported") |
| return |
| } |
|
|
| |
| thinkingFormat := config.GetThinkingConfig().ClaudeFormat |
|
|
| msgID := "msg_" + uuid.New().String() |
| var contentStarted bool |
| var toolUseIndex int |
| var inputTokens, outputTokens int |
| var credits float64 |
| var toolUses []KiroToolUse |
|
|
| |
| var textBuffer string |
| var inThinkingBlock bool |
|
|
| |
| |
| sendText := func(text string, thinkingState int) { |
| |
| if !contentStarted { |
| h.sendSSE(w, flusher, "content_block_start", map[string]interface{}{ |
| "type": "content_block_start", |
| "index": 0, |
| "content_block": map[string]string{"type": "text", "text": ""}, |
| }) |
| contentStarted = true |
| } |
|
|
| if thinkingState == 0 { |
| |
| if text == "" { |
| return |
| } |
| h.sendSSE(w, flusher, "content_block_delta", map[string]interface{}{ |
| "type": "content_block_delta", |
| "index": 0, |
| "delta": map[string]string{"type": "text_delta", "text": text}, |
| }) |
| } else { |
| |
| var outputText string |
| switch thinkingFormat { |
| case "think": |
| switch thinkingState { |
| case 1: |
| outputText = "<think>" + text |
| case 2: |
| outputText = text |
| case 3: |
| outputText = text + "</think>" |
| } |
| case "reasoning_content": |
| |
| outputText = text |
| default: |
| switch thinkingState { |
| case 1: |
| outputText = "<thinking>" + text |
| case 2: |
| outputText = text |
| case 3: |
| outputText = text + "</thinking>" |
| } |
| } |
| if outputText == "" { |
| return |
| } |
| h.sendSSE(w, flusher, "content_block_delta", map[string]interface{}{ |
| "type": "content_block_delta", |
| "index": 0, |
| "delta": map[string]string{"type": "text_delta", "text": outputText}, |
| }) |
| } |
| } |
|
|
| |
| var thinkingStarted bool |
|
|
| processClaudeText := func(text string, isThinking bool, forceFlush bool) { |
| |
| if isThinking { |
| if !thinkingStarted { |
| sendText(text, 1) |
| thinkingStarted = true |
| } else { |
| sendText(text, 2) |
| } |
| return |
| } |
|
|
| textBuffer += text |
|
|
| for { |
| if !inThinkingBlock { |
| thinkingStart := strings.Index(textBuffer, "<thinking>") |
| if thinkingStart != -1 { |
| if thinkingStart > 0 { |
| sendText(textBuffer[:thinkingStart], 0) |
| } |
| textBuffer = textBuffer[thinkingStart+10:] |
| inThinkingBlock = true |
| thinkingStarted = false |
| } else if forceFlush || len([]rune(textBuffer)) > 50 { |
| |
| runes := []rune(textBuffer) |
| safeLen := len(runes) |
| if !forceFlush { |
| safeLen = max(0, len(runes)-15) |
| } |
| if safeLen > 0 { |
| sendText(string(runes[:safeLen]), 0) |
| textBuffer = string(runes[safeLen:]) |
| } |
| break |
| } else { |
| break |
| } |
| } else { |
| thinkingEnd := strings.Index(textBuffer, "</thinking>") |
| if thinkingEnd != -1 { |
| content := textBuffer[:thinkingEnd] |
| if !thinkingStarted { |
| sendText(content, 1) |
| sendText("", 3) |
| } else { |
| sendText(content, 3) |
| } |
| textBuffer = textBuffer[thinkingEnd+11:] |
| inThinkingBlock = false |
| thinkingStarted = false |
| } else if forceFlush { |
| if textBuffer != "" { |
| if !thinkingStarted { |
| sendText(textBuffer, 1) |
| sendText("", 3) |
| } else { |
| sendText(textBuffer, 3) |
| } |
| textBuffer = "" |
| } |
| break |
| } else { |
| |
| runes := []rune(textBuffer) |
| if len(runes) > 20 { |
| safeLen := len(runes) - 15 |
| if safeLen > 0 { |
| if !thinkingStarted { |
| sendText(string(runes[:safeLen]), 1) |
| thinkingStarted = true |
| } else { |
| sendText(string(runes[:safeLen]), 2) |
| } |
| textBuffer = string(runes[safeLen:]) |
| } |
| } |
| break |
| } |
| } |
| } |
| } |
|
|
| |
| h.sendSSE(w, flusher, "message_start", map[string]interface{}{ |
| "type": "message_start", |
| "message": map[string]interface{}{ |
| "id": msgID, |
| "type": "message", |
| "role": "assistant", |
| "content": []interface{}{}, |
| "model": model, |
| }, |
| }) |
|
|
| callback := &KiroStreamCallback{ |
| OnText: func(text string, isThinking bool) { |
| if text == "" { |
| return |
| } |
| processClaudeText(text, isThinking, false) |
| }, |
| OnToolUse: func(tu KiroToolUse) { |
| |
| processClaudeText("", false, true) |
|
|
| toolUses = append(toolUses, tu) |
|
|
| |
| if contentStarted && toolUseIndex == 0 { |
| h.sendSSE(w, flusher, "content_block_stop", map[string]interface{}{ |
| "type": "content_block_stop", |
| "index": 0, |
| }) |
| } |
|
|
| idx := toolUseIndex |
| if contentStarted { |
| idx = toolUseIndex + 1 |
| } |
| toolUseIndex++ |
|
|
| h.sendSSE(w, flusher, "content_block_start", map[string]interface{}{ |
| "type": "content_block_start", |
| "index": idx, |
| "content_block": map[string]interface{}{ |
| "type": "tool_use", |
| "id": tu.ToolUseID, |
| "name": tu.Name, |
| "input": map[string]interface{}{}, |
| }, |
| }) |
|
|
| inputJSON, _ := json.Marshal(tu.Input) |
| h.sendSSE(w, flusher, "content_block_delta", map[string]interface{}{ |
| "type": "content_block_delta", |
| "index": idx, |
| "delta": map[string]interface{}{ |
| "type": "input_json_delta", |
| "partial_json": string(inputJSON), |
| }, |
| }) |
|
|
| h.sendSSE(w, flusher, "content_block_stop", map[string]interface{}{ |
| "type": "content_block_stop", |
| "index": idx, |
| }) |
| }, |
| OnComplete: func(inTok, outTok int) { |
| inputTokens = inTok |
| outputTokens = outTok |
| }, |
| OnError: func(err error) { |
| h.pool.RecordError(account.ID, strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "quota")) |
| }, |
| OnCredits: func(c float64) { |
| credits = c |
| }, |
| } |
|
|
| err := CallKiroAPI(account, payload, callback) |
| if err != nil { |
| h.pool.RecordError(account.ID, strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "quota")) |
| h.sendSSE(w, flusher, "error", map[string]interface{}{ |
| "type": "error", |
| "error": map[string]string{"type": "api_error", "message": err.Error()}, |
| }) |
| h.finalizeRequest(RequestFinalMetrics{ |
| Path: "/v1/messages", |
| Model: model, |
| AccountID: account.ID, |
| AccountEmail: account.Email, |
| Attempts: 1, |
| FinalStatus: 500, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| Error: truncateError(err.Error()), |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| AccountID: account.ID, |
| Email: account.Email, |
| StatusCode: 500, |
| Error: truncateError(err.Error()), |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| return |
| } |
|
|
| |
| processClaudeText("", false, true) |
|
|
| h.recordSuccess(inputTokens, outputTokens, credits, 1) |
| h.pool.RecordSuccess(account.ID) |
| h.pool.UpdateStats(account.ID, inputTokens+outputTokens, credits) |
|
|
| |
| if contentStarted && toolUseIndex == 0 { |
| h.sendSSE(w, flusher, "content_block_stop", map[string]interface{}{ |
| "type": "content_block_stop", |
| "index": 0, |
| }) |
| } |
|
|
| |
| stopReason := "end_turn" |
| if len(toolUses) > 0 { |
| stopReason = "tool_use" |
| } |
|
|
| h.sendSSE(w, flusher, "message_delta", map[string]interface{}{ |
| "type": "message_delta", |
| "delta": map[string]interface{}{ |
| "stop_reason": stopReason, |
| }, |
| "usage": map[string]int{ |
| "input_tokens": inputTokens, |
| "output_tokens": outputTokens, |
| }, |
| }) |
|
|
| h.sendSSE(w, flusher, "message_stop", map[string]interface{}{ |
| "type": "message_stop", |
| }) |
|
|
| h.appendRequestLog(RequestFinalMetrics{ |
| Path: "/v1/messages", |
| Model: model, |
| AccountID: account.ID, |
| AccountEmail: account.Email, |
| Attempts: 1, |
| FinalStatus: 200, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| AccountID: account.ID, |
| Email: account.Email, |
| StatusCode: 200, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| } |
|
|
| func (h *Handler) sendSSE(w http.ResponseWriter, flusher http.Flusher, event string, data interface{}) { |
| jsonData, _ := json.Marshal(data) |
| fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(jsonData)) |
| flusher.Flush() |
| } |
|
|
| |
| func (h *Handler) backgroundStatsSaver() { |
| ticker := time.NewTicker(30 * time.Second) |
| defer ticker.Stop() |
|
|
| for { |
| select { |
| case <-ticker.C: |
| h.saveStats() |
| case <-h.stopStatsSaver: |
| h.saveStats() |
| return |
| } |
| } |
| } |
|
|
| |
| func (h *Handler) saveStats() { |
| config.UpdateStats( |
| int(atomic.LoadInt64(&h.totalRequests)), |
| int(atomic.LoadInt64(&h.successRequests)), |
| int(atomic.LoadInt64(&h.failedRequests)), |
| int(atomic.LoadInt64(&h.attemptFailedRequests)), |
| int(atomic.LoadInt64(&h.totalRetries)), |
| int(atomic.LoadInt64(&h.totalTokens)), |
| h.getCredits(), |
| ) |
| } |
|
|
| |
| func (h *Handler) getCredits() float64 { |
| h.creditsMu.RLock() |
| defer h.creditsMu.RUnlock() |
| return h.totalCredits |
| } |
|
|
| |
| func (h *Handler) addCredits(credits float64) { |
| h.creditsMu.Lock() |
| h.totalCredits += credits |
| h.creditsMu.Unlock() |
| } |
|
|
| |
| func (h *Handler) recordSuccess(inputTokens, outputTokens int, credits float64, attempts int) { |
| atomic.AddInt64(&h.totalRequests, 1) |
| atomic.AddInt64(&h.successRequests, 1) |
| atomic.AddInt64(&h.totalTokens, int64(inputTokens+outputTokens)) |
| if attempts > 1 { |
| atomic.AddInt64(&h.totalRetries, int64(attempts-1)) |
| } |
| h.addCredits(credits) |
| } |
|
|
| func (h *Handler) recordFailure(attempts int) { |
| atomic.AddInt64(&h.totalRequests, 1) |
| atomic.AddInt64(&h.failedRequests, 1) |
| if attempts > 0 { |
| atomic.AddInt64(&h.attemptFailedRequests, int64(attempts)) |
| } |
| if attempts > 1 { |
| atomic.AddInt64(&h.totalRetries, int64(attempts-1)) |
| } |
| } |
|
|
| func (h *Handler) recordAttemptFailure(count int) { |
| if count > 0 { |
| atomic.AddInt64(&h.attemptFailedRequests, int64(count)) |
| } |
| } |
|
|
| func (h *Handler) appendRequestLog(m RequestFinalMetrics) { |
| if h.requestLogs == nil { |
| return |
| } |
| entry := RequestLogEntry{ |
| Time: time.Now().Unix(), |
| Path: m.Path, |
| Model: m.Model, |
| AccountID: m.AccountID, |
| Email: m.AccountEmail, |
| Attempts: max(1, m.Attempts), |
| FinalStatus: m.FinalStatus, |
| DurationMs: m.DurationMs, |
| Error: m.Error, |
| AttemptItems: m.AttemptItems, |
| } |
| h.requestLogs.Add(entry) |
| } |
|
|
| func (h *Handler) finalizeRequest(m RequestFinalMetrics) { |
| attempts := max(1, m.Attempts) |
| attemptFailures := 0 |
| if len(m.AttemptItems) > 0 { |
| for _, a := range m.AttemptItems { |
| if a.StatusCode >= 400 || a.Error != "" { |
| attemptFailures++ |
| } |
| } |
| } else if m.FinalStatus >= 400 { |
| attemptFailures = attempts |
| } |
|
|
| if m.FinalStatus >= 200 && m.FinalStatus < 400 { |
| h.recordSuccess(0, 0, 0, attempts) |
| h.recordAttemptFailure(max(0, attemptFailures)) |
| } else { |
| h.recordFailure(max(attempts, attemptFailures)) |
| } |
|
|
| |
| if m.TotalTokens > 0 { |
| atomic.AddInt64(&h.totalTokens, int64(m.TotalTokens)) |
| } |
| if m.Credits > 0 { |
| h.addCredits(m.Credits) |
| } |
|
|
| h.appendRequestLog(m) |
| } |
|
|
| func extractUsageMetrics(body []byte) (int, float64) { |
| if len(body) == 0 { |
| return 0, 0 |
| } |
| var obj map[string]interface{} |
| if err := json.Unmarshal(body, &obj); err != nil { |
| return 0, 0 |
| } |
|
|
| totalTokens := 0 |
| if usage, ok := obj["usage"].(map[string]interface{}); ok { |
| if v, ok := usage["total_tokens"].(float64); ok { |
| totalTokens = int(v) |
| } else { |
| pt, _ := usage["prompt_tokens"].(float64) |
| ct, _ := usage["completion_tokens"].(float64) |
| totalTokens = int(pt + ct) |
| } |
| } |
|
|
| credits := 0.0 |
| if v, ok := obj["credits"].(float64); ok { |
| credits = v |
| } |
| return totalTokens, credits |
| } |
|
|
| func extractModelFromRequestBody(body []byte) string { |
| if len(body) == 0 { |
| return "" |
| } |
| var obj map[string]interface{} |
| if err := json.Unmarshal(body, &obj); err != nil { |
| return "" |
| } |
| if m, ok := obj["model"].(string); ok { |
| return m |
| } |
| return "" |
| } |
|
|
| func truncateError(err string) string { |
| err = strings.TrimSpace(err) |
| if len(err) > 300 { |
| return err[:300] + "..." |
| } |
| return err |
| } |
|
|
| |
| func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, requestStart time.Time) { |
| var content string |
| var thinkingContent string |
| var toolUses []KiroToolUse |
| var inputTokens, outputTokens int |
| var credits float64 |
|
|
| callback := &KiroStreamCallback{ |
| OnText: func(text string, isThinking bool) { |
| if isThinking { |
| thinkingContent += text |
| } else { |
| content += text |
| } |
| }, |
| OnToolUse: func(tu KiroToolUse) { |
| toolUses = append(toolUses, tu) |
| }, |
| OnComplete: func(inTok, outTok int) { |
| inputTokens = inTok |
| outputTokens = outTok |
| }, |
| OnError: func(err error) { |
| h.pool.RecordError(account.ID, strings.Contains(err.Error(), "429")) |
| }, |
| OnCredits: func(c float64) { |
| credits = c |
| }, |
| } |
|
|
| err := CallKiroAPI(account, payload, callback) |
| if err != nil { |
| h.pool.RecordError(account.ID, strings.Contains(err.Error(), "429")) |
| h.sendClaudeError(w, 500, "api_error", err.Error()) |
| h.finalizeRequest(RequestFinalMetrics{ |
| Path: "/v1/messages", |
| Model: model, |
| AccountID: account.ID, |
| AccountEmail: account.Email, |
| Attempts: 1, |
| FinalStatus: 500, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| Error: truncateError(err.Error()), |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| AccountID: account.ID, |
| Email: account.Email, |
| StatusCode: 500, |
| Error: truncateError(err.Error()), |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| return |
| } |
|
|
| h.recordSuccess(inputTokens, outputTokens, credits, 1) |
| h.pool.RecordSuccess(account.ID) |
| h.pool.UpdateStats(account.ID, inputTokens+outputTokens, credits) |
|
|
| |
| thinkingFormat := config.GetThinkingConfig().ClaudeFormat |
| finalContent := content |
| if thinkingContent != "" { |
| switch thinkingFormat { |
| case "think": |
| finalContent = "<think>" + thinkingContent + "</think>" + content |
| case "reasoning_content": |
| finalContent = thinkingContent + content |
| default: |
| finalContent = "<thinking>" + thinkingContent + "</thinking>" + content |
| } |
| } |
|
|
| resp := KiroToClaudeResponse(finalContent, toolUses, inputTokens, outputTokens, model) |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| json.NewEncoder(w).Encode(resp) |
|
|
| h.appendRequestLog(RequestFinalMetrics{ |
| Path: "/v1/messages", |
| Model: model, |
| AccountID: account.ID, |
| AccountEmail: account.Email, |
| Attempts: 1, |
| FinalStatus: 200, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| AccountID: account.ID, |
| Email: account.Email, |
| StatusCode: 200, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| } |
|
|
| func (h *Handler) sendClaudeError(w http.ResponseWriter, status int, errType, message string) { |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| w.WriteHeader(status) |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "type": "error", |
| "error": map[string]string{ |
| "type": errType, |
| "message": message, |
| }, |
| }) |
| } |
|
|
| |
| func (h *Handler) handleOpenAIChat(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "POST" { |
| http.Error(w, "Method Not Allowed", 405) |
| return |
| } |
|
|
| requestStart := time.Now() |
|
|
| body, err := io.ReadAll(r.Body) |
| if err != nil { |
| h.sendOpenAIError(w, 400, "invalid_request_error", "Failed to read request body") |
| return |
| } |
|
|
| var req OpenAIRequest |
| if err := json.Unmarshal(body, &req); err != nil { |
| h.sendOpenAIError(w, 400, "invalid_request_error", "Invalid JSON") |
| return |
| } |
|
|
| account := h.pool.GetNext() |
| if account == nil { |
| h.sendOpenAIError(w, 503, "server_error", "No available accounts") |
| h.finalizeRequest(RequestFinalMetrics{ |
| Path: r.URL.Path, |
| Model: req.Model, |
| Attempts: 1, |
| FinalStatus: 503, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| Error: "No available accounts", |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| StatusCode: 503, |
| Error: "No available accounts", |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| return |
| } |
|
|
| if err := h.ensureValidToken(account); err != nil { |
| h.sendOpenAIError(w, 503, "server_error", "Token refresh failed") |
| h.finalizeRequest(RequestFinalMetrics{ |
| Path: r.URL.Path, |
| Model: req.Model, |
| AccountID: account.ID, |
| AccountEmail: account.Email, |
| Attempts: 1, |
| FinalStatus: 503, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| Error: truncateError(err.Error()), |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| AccountID: account.ID, |
| Email: account.Email, |
| StatusCode: 503, |
| Error: truncateError(err.Error()), |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| return |
| } |
|
|
| |
| thinkingCfg := config.GetThinkingConfig() |
| actualModel, thinking := ParseModelAndThinking(req.Model, thinkingCfg.Suffix) |
| req.Model = actualModel |
|
|
| kiroPayload := OpenAIToKiro(&req, thinking) |
|
|
| if req.Stream { |
| h.handleOpenAIStream(w, account, kiroPayload, req.Model, requestStart) |
| } else { |
| h.handleOpenAINonStream(w, account, kiroPayload, req.Model, requestStart) |
| } |
| } |
|
|
| |
| func (h *Handler) handleOpenAIStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, requestStart time.Time) { |
| w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") |
| w.Header().Set("Cache-Control", "no-cache") |
| w.Header().Set("Connection", "keep-alive") |
|
|
| flusher, ok := w.(http.Flusher) |
| if !ok { |
| h.sendOpenAIError(w, 500, "server_error", "Streaming not supported") |
| return |
| } |
|
|
| |
| thinkingFormat := config.GetThinkingConfig().OpenAIFormat |
|
|
| chatID := "chatcmpl-" + uuid.New().String() |
| var toolCalls []ToolCall |
| var toolCallIndex int |
| var inputTokens, outputTokens int |
| var credits float64 |
|
|
| |
| var textBuffer string |
| var inThinkingBlock bool |
|
|
| |
| |
| sendChunk := func(content string, thinkingState int) { |
| if content == "" && thinkingState == 2 { |
| return |
| } |
|
|
| var chunk map[string]interface{} |
|
|
| if thinkingState > 0 { |
| |
| switch thinkingFormat { |
| case "thinking": |
| |
| var text string |
| switch thinkingState { |
| case 1: |
| text = "<thinking>" + content |
| case 2: |
| text = content |
| case 3: |
| text = content + "</thinking>" |
| } |
| if text == "" { |
| return |
| } |
| chunk = map[string]interface{}{ |
| "id": chatID, |
| "object": "chat.completion.chunk", |
| "created": time.Now().Unix(), |
| "model": model, |
| "choices": []map[string]interface{}{{ |
| "index": 0, |
| "delta": map[string]string{"content": text}, |
| "finish_reason": nil, |
| }}, |
| } |
| case "think": |
| var text string |
| switch thinkingState { |
| case 1: |
| text = "<think>" + content |
| case 2: |
| text = content |
| case 3: |
| text = content + "</think>" |
| } |
| if text == "" { |
| return |
| } |
| chunk = map[string]interface{}{ |
| "id": chatID, |
| "object": "chat.completion.chunk", |
| "created": time.Now().Unix(), |
| "model": model, |
| "choices": []map[string]interface{}{{ |
| "index": 0, |
| "delta": map[string]string{"content": text}, |
| "finish_reason": nil, |
| }}, |
| } |
| default: |
| if content == "" { |
| return |
| } |
| chunk = map[string]interface{}{ |
| "id": chatID, |
| "object": "chat.completion.chunk", |
| "created": time.Now().Unix(), |
| "model": model, |
| "choices": []map[string]interface{}{{ |
| "index": 0, |
| "delta": map[string]string{"reasoning_content": content}, |
| "finish_reason": nil, |
| }}, |
| } |
| } |
| } else { |
| |
| if content == "" { |
| return |
| } |
| chunk = map[string]interface{}{ |
| "id": chatID, |
| "object": "chat.completion.chunk", |
| "created": time.Now().Unix(), |
| "model": model, |
| "choices": []map[string]interface{}{{ |
| "index": 0, |
| "delta": map[string]string{"content": content}, |
| "finish_reason": nil, |
| }}, |
| } |
| } |
| data, _ := json.Marshal(chunk) |
| fmt.Fprintf(w, "data: %s\n\n", string(data)) |
| flusher.Flush() |
| } |
|
|
| |
| |
| var thinkingStarted bool |
|
|
| processText := func(text string, isThinking bool, forceFlush bool) { |
| |
| if isThinking { |
| if !thinkingStarted { |
| sendChunk(text, 1) |
| thinkingStarted = true |
| } else { |
| sendChunk(text, 2) |
| } |
| return |
| } |
|
|
| textBuffer += text |
|
|
| for { |
| if !inThinkingBlock { |
| |
| thinkingStart := strings.Index(textBuffer, "<thinking>") |
| if thinkingStart != -1 { |
| |
| if thinkingStart > 0 { |
| sendChunk(textBuffer[:thinkingStart], 0) |
| } |
| textBuffer = textBuffer[thinkingStart+10:] |
| inThinkingBlock = true |
| thinkingStarted = false |
| } else if forceFlush || len([]rune(textBuffer)) > 50 { |
| |
| runes := []rune(textBuffer) |
| safeLen := len(runes) |
| if !forceFlush { |
| safeLen = max(0, len(runes)-15) |
| } |
| if safeLen > 0 { |
| sendChunk(string(runes[:safeLen]), 0) |
| textBuffer = string(runes[safeLen:]) |
| } |
| break |
| } else { |
| break |
| } |
| } else { |
| |
| thinkingEnd := strings.Index(textBuffer, "</thinking>") |
| if thinkingEnd != -1 { |
| |
| content := textBuffer[:thinkingEnd] |
| if !thinkingStarted { |
| |
| sendChunk(content, 1) |
| sendChunk("", 3) |
| } else { |
| |
| sendChunk(content, 3) |
| } |
| textBuffer = textBuffer[thinkingEnd+11:] |
| inThinkingBlock = false |
| thinkingStarted = false |
| } else if forceFlush { |
| |
| if textBuffer != "" { |
| if !thinkingStarted { |
| sendChunk(textBuffer, 1) |
| sendChunk("", 3) |
| } else { |
| sendChunk(textBuffer, 3) |
| } |
| textBuffer = "" |
| } |
| break |
| } else { |
| |
| runes := []rune(textBuffer) |
| if len(runes) > 20 { |
| safeLen := len(runes) - 15 |
| if safeLen > 0 { |
| if !thinkingStarted { |
| sendChunk(string(runes[:safeLen]), 1) |
| thinkingStarted = true |
| } else { |
| sendChunk(string(runes[:safeLen]), 2) |
| } |
| textBuffer = string(runes[safeLen:]) |
| } |
| } |
| break |
| } |
| } |
| } |
| } |
|
|
| callback := &KiroStreamCallback{ |
| OnText: func(text string, isThinking bool) { |
| if text == "" { |
| return |
| } |
| processText(text, isThinking, false) |
| }, |
| OnToolUse: func(tu KiroToolUse) { |
| |
| processText("", false, true) |
|
|
| args, _ := json.Marshal(tu.Input) |
| tc := ToolCall{ID: tu.ToolUseID, Type: "function"} |
| tc.Function.Name = tu.Name |
| tc.Function.Arguments = string(args) |
| toolCalls = append(toolCalls, tc) |
|
|
| chunk := map[string]interface{}{ |
| "id": chatID, |
| "object": "chat.completion.chunk", |
| "created": time.Now().Unix(), |
| "model": model, |
| "choices": []map[string]interface{}{{ |
| "index": 0, |
| "delta": map[string]interface{}{ |
| "tool_calls": []map[string]interface{}{{ |
| "index": toolCallIndex, |
| "id": tu.ToolUseID, |
| "type": "function", |
| "function": map[string]string{ |
| "name": tu.Name, |
| "arguments": string(args), |
| }, |
| }}, |
| }, |
| "finish_reason": nil, |
| }}, |
| } |
| toolCallIndex++ |
| data, _ := json.Marshal(chunk) |
| fmt.Fprintf(w, "data: %s\n\n", string(data)) |
| flusher.Flush() |
| }, |
| OnComplete: func(inTok, outTok int) { |
| inputTokens = inTok |
| outputTokens = outTok |
| }, |
| OnError: func(err error) { |
| h.pool.RecordError(account.ID, strings.Contains(err.Error(), "429")) |
| }, |
| OnCredits: func(c float64) { |
| credits = c |
| }, |
| } |
|
|
| err := CallKiroAPI(account, payload, callback) |
| if err != nil { |
| h.pool.RecordError(account.ID, strings.Contains(err.Error(), "429")) |
| h.finalizeRequest(RequestFinalMetrics{ |
| Path: "/v1/chat/completions", |
| Model: model, |
| AccountID: account.ID, |
| AccountEmail: account.Email, |
| Attempts: 1, |
| FinalStatus: 500, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| Error: truncateError(err.Error()), |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| AccountID: account.ID, |
| Email: account.Email, |
| StatusCode: 500, |
| Error: truncateError(err.Error()), |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| return |
| } |
|
|
| |
| processText("", false, true) |
|
|
| h.recordSuccess(inputTokens, outputTokens, credits, 1) |
| h.pool.RecordSuccess(account.ID) |
| h.pool.UpdateStats(account.ID, inputTokens+outputTokens, credits) |
|
|
| |
| finishReason := "stop" |
| if len(toolCalls) > 0 { |
| finishReason = "tool_calls" |
| } |
|
|
| chunk := map[string]interface{}{ |
| "id": chatID, |
| "object": "chat.completion.chunk", |
| "created": time.Now().Unix(), |
| "model": model, |
| "choices": []map[string]interface{}{{ |
| "index": 0, |
| "delta": map[string]interface{}{}, |
| "finish_reason": finishReason, |
| }}, |
| "usage": map[string]int{ |
| "prompt_tokens": inputTokens, |
| "completion_tokens": outputTokens, |
| "total_tokens": inputTokens + outputTokens, |
| }, |
| } |
| data, _ := json.Marshal(chunk) |
| fmt.Fprintf(w, "data: %s\n\n", string(data)) |
| fmt.Fprintf(w, "data: [DONE]\n\n") |
| flusher.Flush() |
|
|
| h.appendRequestLog(RequestFinalMetrics{ |
| Path: "/v1/chat/completions", |
| Model: model, |
| AccountID: account.ID, |
| AccountEmail: account.Email, |
| Attempts: 1, |
| FinalStatus: 200, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| AccountID: account.ID, |
| Email: account.Email, |
| StatusCode: 200, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| } |
|
|
| |
| func (h *Handler) handleOpenAINonStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, requestStart time.Time) { |
| var content string |
| var reasoningContent string |
| var toolUses []KiroToolUse |
| var inputTokens, outputTokens int |
| var credits float64 |
|
|
| callback := &KiroStreamCallback{ |
| OnText: func(text string, isThinking bool) { |
| if isThinking { |
| reasoningContent += text |
| } else { |
| content += text |
| } |
| }, |
| OnToolUse: func(tu KiroToolUse) { toolUses = append(toolUses, tu) }, |
| OnComplete: func(inTok, outTok int) { inputTokens = inTok; outputTokens = outTok }, |
| OnError: func(err error) { h.pool.RecordError(account.ID, strings.Contains(err.Error(), "429")) }, |
| OnCredits: func(c float64) { credits = c }, |
| } |
|
|
| err := CallKiroAPI(account, payload, callback) |
| if err != nil { |
| h.pool.RecordError(account.ID, strings.Contains(err.Error(), "429")) |
| h.sendOpenAIError(w, 500, "server_error", err.Error()) |
| h.finalizeRequest(RequestFinalMetrics{ |
| Path: "/v1/chat/completions", |
| Model: model, |
| AccountID: account.ID, |
| AccountEmail: account.Email, |
| Attempts: 1, |
| FinalStatus: 500, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| Error: truncateError(err.Error()), |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| AccountID: account.ID, |
| Email: account.Email, |
| StatusCode: 500, |
| Error: truncateError(err.Error()), |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| return |
| } |
|
|
| h.recordSuccess(inputTokens, outputTokens, credits, 1) |
| h.pool.RecordSuccess(account.ID) |
| h.pool.UpdateStats(account.ID, inputTokens+outputTokens, credits) |
|
|
| |
| finalContent, extractedReasoning := extractThinkingFromContent(content) |
| if extractedReasoning != "" { |
| reasoningContent = extractedReasoning + reasoningContent |
| } |
|
|
| thinkingFormat := config.GetThinkingConfig().OpenAIFormat |
| resp := KiroToOpenAIResponseWithReasoning(finalContent, reasoningContent, toolUses, inputTokens, outputTokens, model, thinkingFormat) |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| json.NewEncoder(w).Encode(resp) |
|
|
| h.appendRequestLog(RequestFinalMetrics{ |
| Path: "/v1/chat/completions", |
| Model: model, |
| AccountID: account.ID, |
| AccountEmail: account.Email, |
| Attempts: 1, |
| FinalStatus: 200, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| AttemptItems: []RequestLogAttempt{{ |
| Try: 1, |
| AccountID: account.ID, |
| Email: account.Email, |
| StatusCode: 200, |
| DurationMs: time.Since(requestStart).Milliseconds(), |
| }}, |
| }) |
| } |
|
|
| func (h *Handler) sendOpenAIError(w http.ResponseWriter, status int, errType, message string) { |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| w.WriteHeader(status) |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "error": map[string]interface{}{ |
| "type": errType, |
| "message": message, |
| }, |
| }) |
| } |
|
|
| |
| func (h *Handler) ensureValidToken(account *config.Account) error { |
| if account.ExpiresAt == 0 || time.Now().Unix() < account.ExpiresAt-300 { |
| return nil |
| } |
|
|
| accessToken, refreshToken, expiresAt, err := auth.RefreshToken(account) |
| if err != nil { |
| return err |
| } |
|
|
| |
| h.pool.UpdateToken(account.ID, accessToken, refreshToken, expiresAt) |
| account.AccessToken = accessToken |
| if refreshToken != "" { |
| account.RefreshToken = refreshToken |
| } |
| account.ExpiresAt = expiresAt |
|
|
| |
| config.UpdateAccountToken(account.ID, accessToken, refreshToken, expiresAt) |
|
|
| return nil |
| } |
|
|
| |
|
|
| func (h *Handler) handleAdminAPI(w http.ResponseWriter, r *http.Request) { |
| |
| password := r.Header.Get("X-Admin-Password") |
| if password == "" { |
| cookie, _ := r.Cookie("admin_password") |
| if cookie != nil { |
| password = cookie.Value |
| } |
| } |
|
|
| if password != config.GetPassword() { |
| w.WriteHeader(401) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) |
| return |
| } |
|
|
| path := strings.TrimPrefix(r.URL.Path, "/admin/api") |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
|
|
| switch { |
| case path == "/accounts" && r.Method == "GET": |
| h.apiGetAccounts(w, r) |
| case path == "/accounts" && r.Method == "POST": |
| h.apiAddAccount(w, r) |
| case strings.HasPrefix(path, "/accounts/") && strings.HasSuffix(path, "/refresh") && r.Method == "POST": |
| id := strings.TrimSuffix(strings.TrimPrefix(path, "/accounts/"), "/refresh") |
| h.apiRefreshAccount(w, r, id) |
| case strings.HasPrefix(path, "/accounts/") && strings.HasSuffix(path, "/models") && r.Method == "GET": |
| id := strings.TrimSuffix(strings.TrimPrefix(path, "/accounts/"), "/models") |
| h.apiGetAccountModels(w, r, id) |
| case strings.HasPrefix(path, "/accounts/") && strings.HasSuffix(path, "/full") && r.Method == "GET": |
| id := strings.TrimSuffix(strings.TrimPrefix(path, "/accounts/"), "/full") |
| h.apiGetAccountFull(w, r, id) |
| case strings.HasPrefix(path, "/accounts/") && r.Method == "DELETE": |
| h.apiDeleteAccount(w, r, strings.TrimPrefix(path, "/accounts/")) |
| case strings.HasPrefix(path, "/accounts/") && r.Method == "PUT": |
| h.apiUpdateAccount(w, r, strings.TrimPrefix(path, "/accounts/")) |
| case path == "/auth/iam-sso/start" && r.Method == "POST": |
| h.apiStartIamSso(w, r) |
| case path == "/auth/iam-sso/complete" && r.Method == "POST": |
| h.apiCompleteIamSso(w, r) |
| case path == "/auth/builderid/start" && r.Method == "POST": |
| h.apiStartBuilderIdLogin(w, r) |
| case path == "/auth/builderid/poll" && r.Method == "POST": |
| h.apiPollBuilderIdAuth(w, r) |
| case path == "/auth/sso-token" && r.Method == "POST": |
| h.apiImportSsoToken(w, r) |
| case path == "/auth/credentials" && r.Method == "POST": |
| h.apiImportCredentials(w, r) |
| case path == "/status" && r.Method == "GET": |
| h.apiGetStatus(w, r) |
| case path == "/settings" && r.Method == "GET": |
| h.apiGetSettings(w, r) |
| case path == "/settings" && r.Method == "POST": |
| h.apiUpdateSettings(w, r) |
| case path == "/stats" && r.Method == "GET": |
| h.apiGetStats(w, r) |
| case path == "/request-logs" && r.Method == "GET": |
| h.apiGetRequestLogs(w, r) |
| case path == "/accounts/weight" && r.Method == "POST": |
| h.apiUpdateAccountWeight(w, r) |
| case path == "/stats/reset" && r.Method == "POST": |
| h.apiResetStats(w, r) |
| case path == "/generate-machine-id" && r.Method == "GET": |
| h.apiGenerateMachineId(w, r) |
| case path == "/thinking" && r.Method == "GET": |
| h.apiGetThinkingConfig(w, r) |
| case path == "/thinking" && r.Method == "POST": |
| h.apiUpdateThinkingConfig(w, r) |
| case path == "/endpoint" && r.Method == "GET": |
| h.apiGetEndpointConfig(w, r) |
| case path == "/endpoint" && r.Method == "POST": |
| h.apiUpdateEndpointConfig(w, r) |
| case path == "/version" && r.Method == "GET": |
| h.apiGetVersion(w, r) |
| case path == "/export" && r.Method == "POST": |
| h.apiExportAccounts(w, r) |
| default: |
| w.WriteHeader(404) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Not Found"}) |
| } |
| } |
|
|
| func (h *Handler) apiGetAccounts(w http.ResponseWriter, r *http.Request) { |
| accounts := config.GetAccounts() |
| poolAccounts := h.pool.GetAllAccounts() |
|
|
| |
| statsMap := make(map[string]config.Account) |
| for _, a := range poolAccounts { |
| statsMap[a.ID] = a |
| } |
|
|
| |
| result := make([]map[string]interface{}, len(accounts)) |
| for i, a := range accounts { |
| |
| stats := statsMap[a.ID] |
|
|
| result[i] = map[string]interface{}{ |
| "id": a.ID, |
| "email": a.Email, |
| "userId": a.UserId, |
| "nickname": a.Nickname, |
| "weight": a.Weight, |
| "authMethod": a.AuthMethod, |
| "provider": a.Provider, |
| "region": a.Region, |
| "enabled": a.Enabled, |
| "banStatus": a.BanStatus, |
| "banReason": a.BanReason, |
| "banTime": a.BanTime, |
| "expiresAt": a.ExpiresAt, |
| "hasToken": a.AccessToken != "", |
| "machineId": a.MachineId, |
| "subscriptionType": a.SubscriptionType, |
| "subscriptionTitle": a.SubscriptionTitle, |
| "daysRemaining": a.DaysRemaining, |
| "usageCurrent": a.UsageCurrent, |
| "usageLimit": a.UsageLimit, |
| "usagePercent": a.UsagePercent, |
| "nextResetDate": a.NextResetDate, |
| "lastRefresh": a.LastRefresh, |
| "trialUsageCurrent": a.TrialUsageCurrent, |
| "trialUsageLimit": a.TrialUsageLimit, |
| "trialUsagePercent": a.TrialUsagePercent, |
| "trialStatus": a.TrialStatus, |
| "trialExpiresAt": a.TrialExpiresAt, |
| "requestCount": stats.RequestCount, |
| "errorCount": stats.ErrorCount, |
| "totalTokens": stats.TotalTokens, |
| "totalCredits": stats.TotalCredits, |
| "lastUsed": stats.LastUsed, |
| } |
| } |
| json.NewEncoder(w).Encode(result) |
| } |
|
|
| func (h *Handler) apiAddAccount(w http.ResponseWriter, r *http.Request) { |
| var account config.Account |
| if err := json.NewDecoder(r.Body).Decode(&account); err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) |
| return |
| } |
|
|
| if account.ID == "" { |
| account.ID = auth.GenerateAccountID() |
| } |
| if account.Region == "" { |
| account.Region = "us-east-1" |
| } |
|
|
| if err := config.AddAccount(account); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| h.pool.Reload() |
| json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "id": account.ID}) |
| } |
|
|
| func (h *Handler) apiDeleteAccount(w http.ResponseWriter, r *http.Request, id string) { |
| if err := config.DeleteAccount(id); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
| h.pool.Reload() |
| json.NewEncoder(w).Encode(map[string]bool{"success": true}) |
| } |
|
|
| func (h *Handler) apiUpdateAccount(w http.ResponseWriter, r *http.Request, id string) { |
| var updates map[string]interface{} |
| if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) |
| return |
| } |
|
|
| |
| accounts := config.GetAccounts() |
| var existing *config.Account |
| for i := range accounts { |
| if accounts[i].ID == id { |
| existing = &accounts[i] |
| break |
| } |
| } |
| if existing == nil { |
| w.WriteHeader(404) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Account not found"}) |
| return |
| } |
|
|
| |
| if v, ok := updates["enabled"].(bool); ok { |
| existing.Enabled = v |
| } |
| if v, ok := updates["nickname"].(string); ok { |
| existing.Nickname = v |
| } |
| if v, ok := updates["machineId"].(string); ok { |
| existing.MachineId = v |
| } |
| if v, ok := updates["weight"]; ok { |
| switch vv := v.(type) { |
| case float64: |
| existing.Weight = int(vv) |
| case int: |
| existing.Weight = vv |
| } |
| if existing.Weight <= 0 { |
| existing.Weight = 100 |
| } |
| } |
|
|
| if err := config.UpdateAccount(id, *existing); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| h.pool.Reload() |
| json.NewEncoder(w).Encode(map[string]bool{"success": true}) |
| } |
|
|
| func (h *Handler) apiStartIamSso(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| StartUrl string `json:"startUrl"` |
| Region string `json:"region"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) |
| return |
| } |
|
|
| if req.StartUrl == "" { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "startUrl is required"}) |
| return |
| } |
|
|
| sessionID, authorizeUrl, expiresIn, err := auth.StartIamSsoLogin(req.StartUrl, req.Region) |
| if err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "sessionId": sessionID, |
| "authorizeUrl": authorizeUrl, |
| "expiresIn": expiresIn, |
| }) |
| } |
|
|
| func (h *Handler) apiCompleteIamSso(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| SessionID string `json:"sessionId"` |
| CallbackUrl string `json:"callbackUrl"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) |
| return |
| } |
|
|
| accessToken, refreshToken, clientID, clientSecret, region, expiresIn, err := auth.CompleteIamSsoLogin(req.SessionID, req.CallbackUrl) |
| if err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| |
| email, _, _ := auth.GetUserInfo(accessToken) |
|
|
| |
| account := config.Account{ |
| ID: auth.GenerateAccountID(), |
| Email: email, |
| AccessToken: accessToken, |
| RefreshToken: refreshToken, |
| ClientID: clientID, |
| ClientSecret: clientSecret, |
| AuthMethod: "idc", |
| Region: region, |
| ExpiresAt: time.Now().Unix() + int64(expiresIn), |
| Enabled: true, |
| MachineId: config.GenerateMachineId(), |
| } |
|
|
| if err := config.AddAccount(account); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| h.pool.Reload() |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "success": true, |
| "account": map[string]interface{}{ |
| "id": account.ID, |
| "email": account.Email, |
| }, |
| }) |
| } |
|
|
| func (h *Handler) apiStartBuilderIdLogin(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| Region string `json:"region"` |
| } |
| json.NewDecoder(r.Body).Decode(&req) |
|
|
| session, err := auth.StartBuilderIdLogin(req.Region) |
| if err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "sessionId": session.ID, |
| "userCode": session.UserCode, |
| "verificationUri": session.VerificationUri, |
| "interval": session.Interval, |
| }) |
| } |
|
|
| func (h *Handler) apiPollBuilderIdAuth(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| SessionID string `json:"sessionId"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) |
| return |
| } |
|
|
| accessToken, refreshToken, clientID, clientSecret, region, expiresIn, status, err := auth.PollBuilderIdAuth(req.SessionID) |
| if err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "success": false, |
| "error": err.Error(), |
| }) |
| return |
| } |
|
|
| if status == "pending" || status == "slow_down" { |
| |
| interval := 5 |
| if session := auth.GetBuilderIdSession(req.SessionID); session != nil { |
| interval = session.Interval |
| } |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "success": true, |
| "completed": false, |
| "status": status, |
| "interval": interval, |
| }) |
| return |
| } |
|
|
| |
| email, _, _ := auth.GetUserInfo(accessToken) |
|
|
| |
| account := config.Account{ |
| ID: auth.GenerateAccountID(), |
| Email: email, |
| AccessToken: accessToken, |
| RefreshToken: refreshToken, |
| ClientID: clientID, |
| ClientSecret: clientSecret, |
| AuthMethod: "idc", |
| Provider: "BuilderId", |
| Region: region, |
| ExpiresAt: time.Now().Unix() + int64(expiresIn), |
| Enabled: true, |
| MachineId: config.GenerateMachineId(), |
| } |
|
|
| if err := config.AddAccount(account); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| h.pool.Reload() |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "success": true, |
| "completed": true, |
| "account": map[string]interface{}{ |
| "id": account.ID, |
| "email": account.Email, |
| }, |
| }) |
| } |
|
|
| func (h *Handler) apiImportSsoToken(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| BearerToken string `json:"bearerToken"` |
| Region string `json:"region"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) |
| return |
| } |
|
|
| if req.BearerToken == "" { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "bearerToken is required"}) |
| return |
| } |
|
|
| |
| tokens := strings.Split(strings.TrimSpace(req.BearerToken), "\n") |
| var imported []map[string]interface{} |
| var errors []string |
|
|
| for _, token := range tokens { |
| token = strings.TrimSpace(token) |
| if token == "" { |
| continue |
| } |
|
|
| accessToken, refreshToken, clientID, clientSecret, expiresIn, err := auth.ImportFromSsoToken(token, req.Region) |
| if err != nil { |
| errors = append(errors, err.Error()) |
| continue |
| } |
|
|
| |
| email, _, _ := auth.GetUserInfo(accessToken) |
|
|
| |
| account := config.Account{ |
| ID: auth.GenerateAccountID(), |
| Email: email, |
| AccessToken: accessToken, |
| RefreshToken: refreshToken, |
| ClientID: clientID, |
| ClientSecret: clientSecret, |
| AuthMethod: "idc", |
| Region: req.Region, |
| ExpiresAt: time.Now().Unix() + int64(expiresIn), |
| Enabled: true, |
| MachineId: config.GenerateMachineId(), |
| } |
|
|
| if err := config.AddAccount(account); err != nil { |
| errors = append(errors, err.Error()) |
| continue |
| } |
|
|
| imported = append(imported, map[string]interface{}{ |
| "id": account.ID, |
| "email": account.Email, |
| }) |
| } |
|
|
| h.pool.Reload() |
|
|
| if len(imported) == 0 && len(errors) > 0 { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "success": false, |
| "error": strings.Join(errors, "; "), |
| }) |
| return |
| } |
|
|
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "success": true, |
| "accounts": imported, |
| "errors": errors, |
| }) |
| } |
|
|
| func (h *Handler) apiImportCredentials(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| AccessToken string `json:"accessToken"` |
| RefreshToken string `json:"refreshToken"` |
| ClientID string `json:"clientId"` |
| ClientSecret string `json:"clientSecret"` |
| AuthMethod string `json:"authMethod"` |
| Provider string `json:"provider"` |
| Region string `json:"region"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) |
| return |
| } |
|
|
| if req.RefreshToken == "" { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "refreshToken is required"}) |
| return |
| } |
|
|
| |
| if req.Region == "" { |
| req.Region = "us-east-1" |
| } |
| if req.AuthMethod == "" { |
| if req.ClientID != "" { |
| req.AuthMethod = "idc" |
| } else { |
| req.AuthMethod = "social" |
| } |
| } |
| |
| switch strings.ToLower(req.AuthMethod) { |
| case "idc", "builderid", "enterprise": |
| req.AuthMethod = "idc" |
| case "social", "google", "github": |
| req.AuthMethod = "social" |
| default: |
| if req.ClientID != "" && req.ClientSecret != "" { |
| req.AuthMethod = "idc" |
| } else { |
| req.AuthMethod = "social" |
| } |
| } |
|
|
| |
| var accessToken string |
| var expiresAt int64 |
| tempAccount := &config.Account{ |
| RefreshToken: req.RefreshToken, |
| ClientID: req.ClientID, |
| ClientSecret: req.ClientSecret, |
| AuthMethod: req.AuthMethod, |
| Region: req.Region, |
| } |
| newAccessToken, newRefreshToken, newExpiresAt, err := auth.RefreshToken(tempAccount) |
| if err != nil { |
| |
| if req.AccessToken != "" { |
| accessToken = req.AccessToken |
| expiresAt = time.Now().Unix() + 300 |
| } else { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Token refresh failed: " + err.Error()}) |
| return |
| } |
| } else { |
| accessToken = newAccessToken |
| if newRefreshToken != "" { |
| req.RefreshToken = newRefreshToken |
| } |
| expiresAt = newExpiresAt |
| } |
|
|
| |
| email, _, _ := auth.GetUserInfo(accessToken) |
|
|
| |
| account := config.Account{ |
| ID: auth.GenerateAccountID(), |
| Email: email, |
| AccessToken: accessToken, |
| RefreshToken: req.RefreshToken, |
| ClientID: req.ClientID, |
| ClientSecret: req.ClientSecret, |
| AuthMethod: req.AuthMethod, |
| Provider: req.Provider, |
| Region: req.Region, |
| ExpiresAt: expiresAt, |
| Enabled: true, |
| MachineId: config.GenerateMachineId(), |
| } |
|
|
| if err := config.AddAccount(account); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| h.pool.Reload() |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "success": true, |
| "account": map[string]interface{}{ |
| "id": account.ID, |
| "email": account.Email, |
| }, |
| }) |
| } |
|
|
| func (h *Handler) apiGetStatus(w http.ResponseWriter, r *http.Request) { |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "accounts": h.pool.Count(), |
| "available": h.pool.AvailableCount(), |
| "totalRequests": atomic.LoadInt64(&h.totalRequests), |
| "successRequests": atomic.LoadInt64(&h.successRequests), |
| "failedRequests": atomic.LoadInt64(&h.failedRequests), |
| "attemptFailedRequests": atomic.LoadInt64(&h.attemptFailedRequests), |
| "totalRetries": atomic.LoadInt64(&h.totalRetries), |
| "totalTokens": atomic.LoadInt64(&h.totalTokens), |
| "totalCredits": h.getCredits(), |
| "uptime": time.Now().Unix() - h.startTime, |
| "statsDescription": map[string]string{ |
| "failedRequests": "最终失败请求数(重试后仍失败)", |
| "attemptFailedRequests": "尝试失败次数(包含重试中的失败)", |
| "totalRetries": "总重试次数(sum(attempts-1))", |
| }, |
| }) |
| } |
|
|
| func (h *Handler) apiGetSettings(w http.ResponseWriter, r *http.Request) { |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "apiKey": config.GetApiKey(), |
| "requireApiKey": config.IsApiKeyRequired(), |
| "port": config.GetPort(), |
| "host": config.GetHost(), |
| }) |
| } |
|
|
| func (h *Handler) apiUpdateSettings(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| ApiKey string `json:"apiKey"` |
| RequireApiKey bool `json:"requireApiKey"` |
| Password string `json:"password"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) |
| return |
| } |
|
|
| if err := config.UpdateSettings(req.ApiKey, req.RequireApiKey, req.Password); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| json.NewEncoder(w).Encode(map[string]bool{"success": true}) |
| } |
|
|
| func (h *Handler) apiGetStats(w http.ResponseWriter, r *http.Request) { |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "totalRequests": atomic.LoadInt64(&h.totalRequests), |
| "successRequests": atomic.LoadInt64(&h.successRequests), |
| "failedRequests": atomic.LoadInt64(&h.failedRequests), |
| "attemptFailedRequests": atomic.LoadInt64(&h.attemptFailedRequests), |
| "totalRetries": atomic.LoadInt64(&h.totalRetries), |
| "totalTokens": atomic.LoadInt64(&h.totalTokens), |
| "totalCredits": h.getCredits(), |
| "uptime": time.Now().Unix() - h.startTime, |
| "statsDescription": map[string]string{ |
| "failedRequests": "最终失败请求数(重试后仍失败)", |
| "attemptFailedRequests": "尝试失败次数(包含重试中的失败)", |
| "totalRetries": "总重试次数(sum(attempts-1))", |
| }, |
| }) |
| } |
|
|
| func (h *Handler) apiResetStats(w http.ResponseWriter, r *http.Request) { |
| atomic.StoreInt64(&h.totalRequests, 0) |
| atomic.StoreInt64(&h.successRequests, 0) |
| atomic.StoreInt64(&h.failedRequests, 0) |
| atomic.StoreInt64(&h.attemptFailedRequests, 0) |
| atomic.StoreInt64(&h.totalRetries, 0) |
| atomic.StoreInt64(&h.totalTokens, 0) |
| h.creditsMu.Lock() |
| h.totalCredits = 0 |
| h.creditsMu.Unlock() |
| config.UpdateStats(0, 0, 0, 0, 0, 0, 0) |
| json.NewEncoder(w).Encode(map[string]bool{"success": true}) |
| } |
|
|
| func (h *Handler) apiGetRequestLogs(w http.ResponseWriter, r *http.Request) { |
| limit := 100 |
| if raw := r.URL.Query().Get("limit"); raw != "" { |
| if v, err := strconv.Atoi(raw); err == nil && v > 0 { |
| limit = v |
| } |
| } |
| if limit > 500 { |
| limit = 500 |
| } |
| items := h.requestLogs.List(limit) |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "items": items, |
| "total": len(items), |
| }) |
| } |
|
|
| func (h *Handler) apiUpdateAccountWeight(w http.ResponseWriter, r *http.Request) { |
| var raw map[string]interface{} |
| if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) |
| return |
| } |
|
|
| id, _ := raw["id"].(string) |
| if id == "" { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "id is required"}) |
| return |
| } |
|
|
| weight := 100 |
| switch v := raw["weight"].(type) { |
| case float64: |
| weight = int(v) |
| case int: |
| weight = v |
| case string: |
| if p, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { |
| weight = p |
| } |
| } |
| if weight <= 0 { |
| weight = 100 |
| } |
| if weight > 10000 { |
| weight = 10000 |
| } |
|
|
| accounts := config.GetAccounts() |
| var account *config.Account |
| for i := range accounts { |
| if accounts[i].ID == id { |
| account = &accounts[i] |
| break |
| } |
| } |
|
|
| if account == nil { |
| w.WriteHeader(404) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Account not found"}) |
| return |
| } |
|
|
| account.Weight = weight |
| if err := config.UpdateAccount(id, *account); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| h.pool.Reload() |
| json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "id": id, "weight": weight}) |
| } |
|
|
| |
| func (h *Handler) apiGenerateMachineId(w http.ResponseWriter, r *http.Request) { |
| machineId := config.GenerateMachineId() |
| json.NewEncoder(w).Encode(map[string]string{"machineId": machineId}) |
| } |
|
|
| |
| func (h *Handler) apiRefreshAccount(w http.ResponseWriter, r *http.Request, id string) { |
| accounts := config.GetAccounts() |
| var account *config.Account |
| for i := range accounts { |
| if accounts[i].ID == id { |
| account = &accounts[i] |
| break |
| } |
| } |
|
|
| if account == nil { |
| w.WriteHeader(404) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Account not found"}) |
| return |
| } |
|
|
| |
| refreshTokenIfNeeded := func() error { |
| if account.RefreshToken == "" { |
| return nil |
| } |
| newAccessToken, newRefreshToken, newExpiresAt, err := auth.RefreshToken(account) |
| if err != nil { |
| return err |
| } |
| account.AccessToken = newAccessToken |
| if newRefreshToken != "" { |
| account.RefreshToken = newRefreshToken |
| } |
| account.ExpiresAt = newExpiresAt |
| config.UpdateAccountToken(id, newAccessToken, newRefreshToken, newExpiresAt) |
| h.pool.UpdateToken(id, newAccessToken, newRefreshToken, newExpiresAt) |
| return nil |
| } |
|
|
| |
| if account.ExpiresAt > 0 && time.Now().Unix() > account.ExpiresAt-300 { |
| if err := refreshTokenIfNeeded(); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Token refresh failed: " + err.Error()}) |
| return |
| } |
| } |
|
|
| |
| info, err := RefreshAccountInfo(account) |
| if err != nil { |
| |
| errMsg := err.Error() |
| if strings.Contains(errMsg, "TEMPORARILY_SUSPENDED") || strings.Contains(errMsg, "Account suspended") { |
| |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "success": true, |
| "message": "Account status updated", |
| }) |
| return |
| } |
|
|
| |
| if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "401") || strings.Contains(errMsg, "invalid") || strings.Contains(errMsg, "expired") { |
| if refreshErr := refreshTokenIfNeeded(); refreshErr == nil { |
| |
| info, err = RefreshAccountInfo(account) |
| if err != nil { |
| |
| if strings.Contains(err.Error(), "TEMPORARILY_SUSPENDED") || strings.Contains(err.Error(), "Account suspended") { |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "success": true, |
| "message": "Account status updated", |
| }) |
| return |
| } |
| } |
| } |
| } |
|
|
| |
| if err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
| } |
|
|
| |
| if err := config.UpdateAccountInfo(id, *info); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "success": true, |
| "info": info, |
| }) |
| } |
|
|
| |
| func (h *Handler) apiGetAccountFull(w http.ResponseWriter, r *http.Request, id string) { |
| accounts := config.GetAccounts() |
| poolAccounts := h.pool.GetAllAccounts() |
|
|
| |
| var account *config.Account |
| for i := range accounts { |
| if accounts[i].ID == id { |
| account = &accounts[i] |
| break |
| } |
| } |
|
|
| if account == nil { |
| w.WriteHeader(404) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Account not found"}) |
| return |
| } |
|
|
| |
| var stats config.Account |
| for _, a := range poolAccounts { |
| if a.ID == id { |
| stats = a |
| break |
| } |
| } |
|
|
| |
| result := map[string]interface{}{ |
| "id": account.ID, |
| "email": account.Email, |
| "userId": account.UserId, |
| "nickname": account.Nickname, |
| "weight": account.Weight, |
| "accessToken": account.AccessToken, |
| "refreshToken": account.RefreshToken, |
| "clientId": account.ClientID, |
| "clientSecret": account.ClientSecret, |
| "authMethod": account.AuthMethod, |
| "provider": account.Provider, |
| "region": account.Region, |
| "expiresAt": account.ExpiresAt, |
| "machineId": account.MachineId, |
| "enabled": account.Enabled, |
| "banStatus": account.BanStatus, |
| "banReason": account.BanReason, |
| "banTime": account.BanTime, |
| "subscriptionType": account.SubscriptionType, |
| "subscriptionTitle": account.SubscriptionTitle, |
| "daysRemaining": account.DaysRemaining, |
| "usageCurrent": account.UsageCurrent, |
| "usageLimit": account.UsageLimit, |
| "usagePercent": account.UsagePercent, |
| "nextResetDate": account.NextResetDate, |
| "lastRefresh": account.LastRefresh, |
| "trialUsageCurrent": account.TrialUsageCurrent, |
| "trialUsageLimit": account.TrialUsageLimit, |
| "trialUsagePercent": account.TrialUsagePercent, |
| "trialStatus": account.TrialStatus, |
| "trialExpiresAt": account.TrialExpiresAt, |
| "requestCount": stats.RequestCount, |
| "errorCount": stats.ErrorCount, |
| "totalTokens": stats.TotalTokens, |
| "totalCredits": stats.TotalCredits, |
| "lastUsed": stats.LastUsed, |
| } |
|
|
| json.NewEncoder(w).Encode(result) |
| } |
|
|
| |
| func (h *Handler) apiGetAccountModels(w http.ResponseWriter, r *http.Request, id string) { |
| accounts := config.GetAccounts() |
| var account *config.Account |
| for i := range accounts { |
| if accounts[i].ID == id { |
| account = &accounts[i] |
| break |
| } |
| } |
|
|
| if account == nil { |
| w.WriteHeader(404) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Account not found"}) |
| return |
| } |
|
|
| models, err := ListAvailableModels(account) |
| if err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "success": true, |
| "models": models, |
| }) |
| } |
|
|
| |
|
|
| func (h *Handler) serveAdminPage(w http.ResponseWriter, r *http.Request) { |
| http.ServeFile(w, r, "web/index.html") |
| } |
|
|
| func (h *Handler) serveStaticFile(w http.ResponseWriter, r *http.Request) { |
| path := strings.TrimPrefix(r.URL.Path, "/admin/") |
| http.ServeFile(w, r, "web/"+path) |
| } |
|
|
| |
| func (h *Handler) apiGetThinkingConfig(w http.ResponseWriter, r *http.Request) { |
| cfg := config.GetThinkingConfig() |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "suffix": cfg.Suffix, |
| "openaiFormat": cfg.OpenAIFormat, |
| "claudeFormat": cfg.ClaudeFormat, |
| }) |
| } |
|
|
| |
| func (h *Handler) apiUpdateThinkingConfig(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| Suffix string `json:"suffix"` |
| OpenAIFormat string `json:"openaiFormat"` |
| ClaudeFormat string `json:"claudeFormat"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) |
| return |
| } |
|
|
| |
| validFormats := map[string]bool{"reasoning_content": true, "thinking": true, "think": true} |
| if req.OpenAIFormat != "" && !validFormats[req.OpenAIFormat] { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid openaiFormat, must be: reasoning_content, thinking, or think"}) |
| return |
| } |
| if req.ClaudeFormat != "" && !validFormats[req.ClaudeFormat] { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid claudeFormat, must be: reasoning_content, thinking, or think"}) |
| return |
| } |
|
|
| if err := config.UpdateThinkingConfig(req.Suffix, req.OpenAIFormat, req.ClaudeFormat); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| json.NewEncoder(w).Encode(map[string]bool{"success": true}) |
| } |
|
|
| |
| func (h *Handler) apiGetEndpointConfig(w http.ResponseWriter, r *http.Request) { |
| json.NewEncoder(w).Encode(map[string]string{ |
| "preferredEndpoint": config.GetPreferredEndpoint(), |
| }) |
| } |
|
|
| |
| func (h *Handler) apiUpdateEndpointConfig(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| PreferredEndpoint string `json:"preferredEndpoint"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) |
| return |
| } |
|
|
| valid := map[string]bool{"auto": true, "codewhisperer": true, "amazonq": true} |
| if !valid[req.PreferredEndpoint] { |
| w.WriteHeader(400) |
| json.NewEncoder(w).Encode(map[string]string{"error": "Invalid endpoint, must be: auto, codewhisperer, or amazonq"}) |
| return |
| } |
|
|
| if err := config.UpdatePreferredEndpoint(req.PreferredEndpoint); err != nil { |
| w.WriteHeader(500) |
| json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) |
| return |
| } |
|
|
| json.NewEncoder(w).Encode(map[string]bool{"success": true}) |
| } |
|
|
| |
| func (h *Handler) apiGetVersion(w http.ResponseWriter, r *http.Request) { |
| json.NewEncoder(w).Encode(map[string]string{ |
| "version": config.Version, |
| }) |
| } |
|
|
| |
| func (h *Handler) apiExportAccounts(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| IDs []string `json:"ids"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| |
| req.IDs = nil |
| } |
|
|
| accounts := config.GetAccounts() |
|
|
| |
| if len(req.IDs) > 0 { |
| idSet := make(map[string]bool) |
| for _, id := range req.IDs { |
| idSet[id] = true |
| } |
| var filtered []config.Account |
| for _, a := range accounts { |
| if idSet[a.ID] { |
| filtered = append(filtered, a) |
| } |
| } |
| accounts = filtered |
| } |
|
|
| |
| type ExportCredentials struct { |
| AccessToken string `json:"accessToken"` |
| CsrfToken string `json:"csrfToken"` |
| RefreshToken string `json:"refreshToken"` |
| ClientID string `json:"clientId,omitempty"` |
| ClientSecret string `json:"clientSecret,omitempty"` |
| Region string `json:"region,omitempty"` |
| ExpiresAt int64 `json:"expiresAt"` |
| AuthMethod string `json:"authMethod,omitempty"` |
| Provider string `json:"provider,omitempty"` |
| } |
|
|
| type ExportSubscription struct { |
| Type string `json:"type"` |
| Title string `json:"title,omitempty"` |
| } |
|
|
| type ExportUsage struct { |
| Current float64 `json:"current"` |
| Limit float64 `json:"limit"` |
| PercentUsed float64 `json:"percentUsed"` |
| LastUpdated int64 `json:"lastUpdated"` |
| } |
|
|
| type ExportAccount struct { |
| ID string `json:"id"` |
| Email string `json:"email"` |
| Nickname string `json:"nickname,omitempty"` |
| Idp string `json:"idp"` |
| UserId string `json:"userId,omitempty"` |
| MachineId string `json:"machineId,omitempty"` |
| Credentials ExportCredentials `json:"credentials"` |
| Subscription ExportSubscription `json:"subscription"` |
| Usage ExportUsage `json:"usage"` |
| Tags []string `json:"tags"` |
| Status string `json:"status"` |
| CreatedAt int64 `json:"createdAt"` |
| LastUsedAt int64 `json:"lastUsedAt"` |
| } |
|
|
| type ExportData struct { |
| Version string `json:"version"` |
| ExportedAt int64 `json:"exportedAt"` |
| Accounts []ExportAccount `json:"accounts"` |
| Groups []interface{} `json:"groups"` |
| Tags []interface{} `json:"tags"` |
| } |
|
|
| exportAccounts := make([]ExportAccount, 0, len(accounts)) |
| for _, a := range accounts { |
| |
| idp := a.Provider |
| if idp == "" { |
| if a.AuthMethod == "social" { |
| idp = "Google" |
| } else { |
| idp = "BuilderId" |
| } |
| } |
|
|
| |
| authMethod := a.AuthMethod |
| if authMethod == "idc" { |
| authMethod = "IdC" |
| } |
|
|
| |
| subType := "Free" |
| rawType := strings.ToUpper(a.SubscriptionType) |
| if strings.Contains(rawType, "PRO_PLUS") || strings.Contains(rawType, "PROPLUS") { |
| subType = "Pro_Plus" |
| } else if strings.Contains(rawType, "PRO") { |
| subType = "Pro" |
| } else if strings.Contains(rawType, "POWER") { |
| subType = "Pro_Plus" |
| } |
|
|
| exportAccounts = append(exportAccounts, ExportAccount{ |
| ID: a.ID, |
| Email: a.Email, |
| Nickname: a.Nickname, |
| Idp: idp, |
| UserId: a.UserId, |
| MachineId: a.MachineId, |
| Credentials: ExportCredentials{ |
| AccessToken: a.AccessToken, |
| CsrfToken: "", |
| RefreshToken: a.RefreshToken, |
| ClientID: a.ClientID, |
| ClientSecret: a.ClientSecret, |
| Region: a.Region, |
| ExpiresAt: a.ExpiresAt * 1000, |
| AuthMethod: authMethod, |
| Provider: a.Provider, |
| }, |
| Subscription: ExportSubscription{ |
| Type: subType, |
| Title: a.SubscriptionTitle, |
| }, |
| Usage: ExportUsage{ |
| Current: a.UsageCurrent, |
| Limit: a.UsageLimit, |
| PercentUsed: a.UsagePercent, |
| LastUpdated: time.Now().UnixMilli(), |
| }, |
| Tags: []string{}, |
| Status: "active", |
| CreatedAt: time.Now().UnixMilli(), |
| LastUsedAt: time.Now().UnixMilli(), |
| }) |
| } |
|
|
| data := ExportData{ |
| Version: config.Version, |
| ExportedAt: time.Now().UnixMilli(), |
| Accounts: exportAccounts, |
| Groups: []interface{}{}, |
| Tags: []interface{}{}, |
| } |
|
|
| json.NewEncoder(w).Encode(data) |
| } |
|
|