sshinmen Claude commited on
Commit
9b1c663
·
1 Parent(s): 13d6015

Refactor auth handlers and executors

Browse files

- Split auth_files.go into auth_common.go, auth_handlers.go, auth_providers.go
- Update management handlers (config_basic, config_lists, generics, logs, quota)
- Update executors (antigravity, claude, proxy_helpers)
- Add config helper methods

Co-Authored-By: Claude <noreply@anthropic.com>

internal/api/handlers/management/auth_common.go ADDED
@@ -0,0 +1,674 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "net"
11
+ "net/http"
12
+ "path/filepath"
13
+ "strconv"
14
+ "strings"
15
+ "sync"
16
+ "time"
17
+
18
+ "github.com/gin-gonic/gin"
19
+ geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
20
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
21
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
22
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
23
+ log "github.com/sirupsen/logrus"
24
+ "github.com/tidwall/gjson"
25
+ )
26
+
27
+ var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"}
28
+
29
+ const (
30
+ anthropicCallbackPort = 54545
31
+ geminiCallbackPort = 8085
32
+ codexCallbackPort = 1455
33
+ geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com"
34
+ geminiCLIVersion = "v1internal"
35
+ geminiCLIUserAgent = "google-api-nodejs-client/9.15.1"
36
+ geminiCLIApiClient = "gl-node/22.17.0"
37
+ geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"
38
+ )
39
+
40
+ type callbackForwarder struct {
41
+ provider string
42
+ server *http.Server
43
+ done chan struct{}
44
+ }
45
+
46
+ var (
47
+ callbackForwardersMu sync.Mutex
48
+ callbackForwarders = make(map[int]*callbackForwarder)
49
+ )
50
+
51
+ func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
52
+ if len(meta) == 0 {
53
+ return time.Time{}, false
54
+ }
55
+ for _, key := range lastRefreshKeys {
56
+ if val, ok := meta[key]; ok {
57
+ if ts, ok1 := parseLastRefreshValue(val); ok1 {
58
+ return ts, true
59
+ }
60
+ }
61
+ }
62
+ return time.Time{}, false
63
+ }
64
+
65
+ func parseLastRefreshValue(v any) (time.Time, bool) {
66
+ switch val := v.(type) {
67
+ case string:
68
+ s := strings.TrimSpace(val)
69
+ if s == "" {
70
+ return time.Time{}, false
71
+ }
72
+ layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00"}
73
+ for _, layout := range layouts {
74
+ if ts, err := time.Parse(layout, s); err == nil {
75
+ return ts.UTC(), true
76
+ }
77
+ }
78
+ if unix, err := strconv.ParseInt(s, 10, 64); err == nil && unix > 0 {
79
+ return time.Unix(unix, 0).UTC(), true
80
+ }
81
+ case float64:
82
+ if val <= 0 {
83
+ return time.Time{}, false
84
+ }
85
+ return time.Unix(int64(val), 0).UTC(), true
86
+ case int64:
87
+ if val <= 0 {
88
+ return time.Time{}, false
89
+ }
90
+ return time.Unix(val, 0).UTC(), true
91
+ case int:
92
+ if val <= 0 {
93
+ return time.Time{}, false
94
+ }
95
+ return time.Unix(int64(val), 0).UTC(), true
96
+ case json.Number:
97
+ if i, err := val.Int64(); err == nil && i > 0 {
98
+ return time.Unix(i, 0).UTC(), true
99
+ }
100
+ }
101
+ return time.Time{}, false
102
+ }
103
+
104
+ func isWebUIRequest(c *gin.Context) bool {
105
+ raw := strings.TrimSpace(c.Query("is_webui"))
106
+ if raw == "" {
107
+ return false
108
+ }
109
+ switch strings.ToLower(raw) {
110
+ case "1", "true", "yes", "on":
111
+ return true
112
+ default:
113
+ return false
114
+ }
115
+ }
116
+
117
+ func startCallbackForwarder(port int, provider, targetBase string) (*callbackForwarder, error) {
118
+ callbackForwardersMu.Lock()
119
+ prev := callbackForwarders[port]
120
+ if prev != nil {
121
+ delete(callbackForwarders, port)
122
+ }
123
+ callbackForwardersMu.Unlock()
124
+
125
+ if prev != nil {
126
+ stopForwarderInstance(port, prev)
127
+ }
128
+
129
+ addr := fmt.Sprintf("127.0.0.1:%d", port)
130
+ ln, err := net.Listen("tcp", addr)
131
+ if err != nil {
132
+ return nil, fmt.Errorf("failed to listen on %s: %w", addr, err)
133
+ }
134
+
135
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136
+ target := targetBase
137
+ if raw := r.URL.RawQuery; raw != "" {
138
+ if strings.Contains(target, "?") {
139
+ target = target + "&" + raw
140
+ } else {
141
+ target = target + "?" + raw
142
+ }
143
+ }
144
+ w.Header().Set("Cache-Control", "no-store")
145
+ http.Redirect(w, r, target, http.StatusFound)
146
+ })
147
+
148
+ srv := &http.Server{
149
+ Handler: handler,
150
+ ReadHeaderTimeout: 5 * time.Second,
151
+ WriteTimeout: 5 * time.Second,
152
+ }
153
+ done := make(chan struct{})
154
+
155
+ go func() {
156
+ if errServe := srv.Serve(ln); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {
157
+ log.WithError(errServe).Warnf("callback forwarder for %s stopped unexpectedly", provider)
158
+ }
159
+ close(done)
160
+ }()
161
+
162
+ forwarder := &callbackForwarder{
163
+ provider: provider,
164
+ server: srv,
165
+ done: done,
166
+ }
167
+
168
+ callbackForwardersMu.Lock()
169
+ callbackForwarders[port] = forwarder
170
+ callbackForwardersMu.Unlock()
171
+
172
+ log.Infof("callback forwarder for %s listening on %s", provider, addr)
173
+
174
+ return forwarder, nil
175
+ }
176
+
177
+ func stopCallbackForwarder(port int) {
178
+ callbackForwardersMu.Lock()
179
+ forwarder := callbackForwarders[port]
180
+ if forwarder != nil {
181
+ delete(callbackForwarders, port)
182
+ }
183
+ callbackForwardersMu.Unlock()
184
+
185
+ stopForwarderInstance(port, forwarder)
186
+ }
187
+
188
+ func stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) {
189
+ if forwarder == nil {
190
+ return
191
+ }
192
+ callbackForwardersMu.Lock()
193
+ if current := callbackForwarders[port]; current == forwarder {
194
+ delete(callbackForwarders, port)
195
+ }
196
+ callbackForwardersMu.Unlock()
197
+
198
+ stopForwarderInstance(port, forwarder)
199
+ }
200
+
201
+ func stopForwarderInstance(port int, forwarder *callbackForwarder) {
202
+ if forwarder == nil || forwarder.server == nil {
203
+ return
204
+ }
205
+
206
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
207
+ defer cancel()
208
+
209
+ if err := forwarder.server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
210
+ log.WithError(err).Warnf("failed to shut down callback forwarder on port %d", port)
211
+ }
212
+
213
+ select {
214
+ case <-forwarder.done:
215
+ case <-time.After(2 * time.Second):
216
+ }
217
+
218
+ log.Infof("callback forwarder on port %d stopped", port)
219
+ }
220
+
221
+ func (h *Handler) managementCallbackURL(path string) (string, error) {
222
+ if h == nil || h.cfg == nil || h.cfg.Port <= 0 {
223
+ return "", fmt.Errorf("server port is not configured")
224
+ }
225
+ if !strings.HasPrefix(path, "/") {
226
+ path = "/" + path
227
+ }
228
+ scheme := "http"
229
+ if h.cfg.TLS.Enable {
230
+ scheme = "https"
231
+ }
232
+ return fmt.Sprintf("%s://127.0.0.1:%d%s", scheme, h.cfg.Port, path), nil
233
+ }
234
+
235
+ func authEmail(auth *coreauth.Auth) string {
236
+ if auth == nil {
237
+ return ""
238
+ }
239
+ if auth.Metadata != nil {
240
+ if v, ok := auth.Metadata["email"].(string); ok {
241
+ return strings.TrimSpace(v)
242
+ }
243
+ }
244
+ if auth.Attributes != nil {
245
+ if v := strings.TrimSpace(auth.Attributes["email"]); v != "" {
246
+ return v
247
+ }
248
+ if v := strings.TrimSpace(auth.Attributes["account_email"]); v != "" {
249
+ return v
250
+ }
251
+ }
252
+ return ""
253
+ }
254
+
255
+ func authAttribute(auth *coreauth.Auth, key string) string {
256
+ if auth == nil || len(auth.Attributes) == 0 {
257
+ return ""
258
+ }
259
+ return auth.Attributes[key]
260
+ }
261
+
262
+ func isRuntimeOnlyAuth(auth *coreauth.Auth) bool {
263
+ if auth == nil || len(auth.Attributes) == 0 {
264
+ return false
265
+ }
266
+ return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true")
267
+ }
268
+
269
+ // Download single auth file by name
270
+ func (h *Handler) authIDForPath(path string) string {
271
+ path = strings.TrimSpace(path)
272
+ if path == "" {
273
+ return ""
274
+ }
275
+ if h == nil || h.cfg == nil {
276
+ return path
277
+ }
278
+ authDir := strings.TrimSpace(h.cfg.AuthDir)
279
+ if authDir == "" {
280
+ return path
281
+ }
282
+ if rel, err := filepath.Rel(authDir, path); err == nil && rel != "" {
283
+ return rel
284
+ }
285
+ return path
286
+ }
287
+
288
+ func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error {
289
+ if strings.TrimSpace(path) == "" {
290
+ return fmt.Errorf("auth path is empty")
291
+ }
292
+ store := h.tokenStoreWithBaseDir()
293
+ if store == nil {
294
+ return fmt.Errorf("token store unavailable")
295
+ }
296
+ return store.Delete(ctx, path)
297
+ }
298
+
299
+ func (h *Handler) tokenStoreWithBaseDir() coreauth.Store {
300
+ if h == nil {
301
+ return nil
302
+ }
303
+ store := h.tokenStore
304
+ if store == nil {
305
+ store = sdkAuth.GetTokenStore()
306
+ h.tokenStore = store
307
+ }
308
+ if h.cfg != nil {
309
+ if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok {
310
+ dirSetter.SetBaseDir(h.cfg.AuthDir)
311
+ }
312
+ }
313
+ return store
314
+ }
315
+
316
+ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) {
317
+ if record == nil {
318
+ return "", fmt.Errorf("token record is nil")
319
+ }
320
+ store := h.tokenStoreWithBaseDir()
321
+ if store == nil {
322
+ return "", fmt.Errorf("token store unavailable")
323
+ }
324
+ return store.Save(ctx, record)
325
+ }
326
+
327
+ func (e *projectSelectionRequiredError) Error() string {
328
+ return "gemini cli: project selection required"
329
+ }
330
+
331
+ func ensureGeminiProjectAndOnboard(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
332
+ if storage == nil {
333
+ return fmt.Errorf("gemini storage is nil")
334
+ }
335
+
336
+ trimmedRequest := strings.TrimSpace(requestedProject)
337
+ if trimmedRequest == "" {
338
+ projects, errProjects := fetchGCPProjects(ctx, httpClient)
339
+ if errProjects != nil {
340
+ return fmt.Errorf("fetch project list: %w", errProjects)
341
+ }
342
+ if len(projects) == 0 {
343
+ return fmt.Errorf("no Google Cloud projects available for this account")
344
+ }
345
+ trimmedRequest = strings.TrimSpace(projects[0].ProjectID)
346
+ if trimmedRequest == "" {
347
+ return fmt.Errorf("resolved project id is empty")
348
+ }
349
+ storage.Auto = true
350
+ } else {
351
+ storage.Auto = false
352
+ }
353
+
354
+ if err := performGeminiCLISetup(ctx, httpClient, storage, trimmedRequest); err != nil {
355
+ return err
356
+ }
357
+
358
+ if strings.TrimSpace(storage.ProjectID) == "" {
359
+ storage.ProjectID = trimmedRequest
360
+ }
361
+
362
+ return nil
363
+ }
364
+
365
+ func onboardAllGeminiProjects(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage) ([]string, error) {
366
+ projects, errProjects := fetchGCPProjects(ctx, httpClient)
367
+ if errProjects != nil {
368
+ return nil, fmt.Errorf("fetch project list: %w", errProjects)
369
+ }
370
+ if len(projects) == 0 {
371
+ return nil, fmt.Errorf("no Google Cloud projects available for this account")
372
+ }
373
+ activated := make([]string, 0, len(projects))
374
+ seen := make(map[string]struct{}, len(projects))
375
+ for _, project := range projects {
376
+ candidate := strings.TrimSpace(project.ProjectID)
377
+ if candidate == "" {
378
+ continue
379
+ }
380
+ if _, dup := seen[candidate]; dup {
381
+ continue
382
+ }
383
+ if err := performGeminiCLISetup(ctx, httpClient, storage, candidate); err != nil {
384
+ return nil, fmt.Errorf("onboard project %s: %w", candidate, err)
385
+ }
386
+ finalID := strings.TrimSpace(storage.ProjectID)
387
+ if finalID == "" {
388
+ finalID = candidate
389
+ }
390
+ activated = append(activated, finalID)
391
+ seen[candidate] = struct{}{}
392
+ }
393
+ if len(activated) == 0 {
394
+ return nil, fmt.Errorf("no Google Cloud projects available for this account")
395
+ }
396
+ return activated, nil
397
+ }
398
+
399
+ func ensureGeminiProjectsEnabled(ctx context.Context, httpClient *http.Client, projectIDs []string) error {
400
+ for _, pid := range projectIDs {
401
+ trimmed := strings.TrimSpace(pid)
402
+ if trimmed == "" {
403
+ continue
404
+ }
405
+ isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, trimmed)
406
+ if errCheck != nil {
407
+ return fmt.Errorf("project %s: %w", trimmed, errCheck)
408
+ }
409
+ if !isChecked {
410
+ return fmt.Errorf("project %s: Cloud AI API not enabled", trimmed)
411
+ }
412
+ }
413
+ return nil
414
+ }
415
+
416
+ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
417
+ metadata := map[string]string{
418
+ "ideType": "IDE_UNSPECIFIED",
419
+ "platform": "PLATFORM_UNSPECIFIED",
420
+ "pluginType": "GEMINI",
421
+ }
422
+
423
+ trimmedRequest := strings.TrimSpace(requestedProject)
424
+ explicitProject := trimmedRequest != ""
425
+
426
+ loadReqBody := map[string]any{
427
+ "metadata": metadata,
428
+ }
429
+ if explicitProject {
430
+ loadReqBody["cloudaicompanionProject"] = trimmedRequest
431
+ }
432
+
433
+ var loadResp map[string]any
434
+ if errLoad := callGeminiCLI(ctx, httpClient, "loadCodeAssist", loadReqBody, &loadResp); errLoad != nil {
435
+ return fmt.Errorf("load code assist: %w", errLoad)
436
+ }
437
+
438
+ tierID := "legacy-tier"
439
+ if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers {
440
+ for _, rawTier := range tiers {
441
+ tier, okTier := rawTier.(map[string]any)
442
+ if !okTier {
443
+ continue
444
+ }
445
+ if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault {
446
+ if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" {
447
+ tierID = strings.TrimSpace(id)
448
+ break
449
+ }
450
+ }
451
+ }
452
+ }
453
+
454
+ projectID := trimmedRequest
455
+ if projectID == "" {
456
+ if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject {
457
+ projectID = strings.TrimSpace(id)
458
+ }
459
+ if projectID == "" {
460
+ if projectMap, okProject := loadResp["cloudaicompanionProject"].(map[string]any); okProject {
461
+ if id, okID := projectMap["id"].(string); okID {
462
+ projectID = strings.TrimSpace(id)
463
+ }
464
+ }
465
+ }
466
+ }
467
+ if projectID == "" {
468
+ return &projectSelectionRequiredError{}
469
+ }
470
+
471
+ onboardReqBody := map[string]any{
472
+ "tierId": tierID,
473
+ "metadata": metadata,
474
+ "cloudaicompanionProject": projectID,
475
+ }
476
+
477
+ storage.ProjectID = projectID
478
+
479
+ for {
480
+ var onboardResp map[string]any
481
+ if errOnboard := callGeminiCLI(ctx, httpClient, "onboardUser", onboardReqBody, &onboardResp); errOnboard != nil {
482
+ return fmt.Errorf("onboard user: %w", errOnboard)
483
+ }
484
+
485
+ if done, okDone := onboardResp["done"].(bool); okDone && done {
486
+ responseProjectID := ""
487
+ if resp, okResp := onboardResp["response"].(map[string]any); okResp {
488
+ switch projectValue := resp["cloudaicompanionProject"].(type) {
489
+ case map[string]any:
490
+ if id, okID := projectValue["id"].(string); okID {
491
+ responseProjectID = strings.TrimSpace(id)
492
+ }
493
+ case string:
494
+ responseProjectID = strings.TrimSpace(projectValue)
495
+ }
496
+ }
497
+
498
+ finalProjectID := projectID
499
+ if responseProjectID != "" {
500
+ if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
501
+ // Check if this is a free user (gen-lang-client projects or free/legacy tier)
502
+ isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") ||
503
+ strings.EqualFold(tierID, "FREE") ||
504
+ strings.EqualFold(tierID, "LEGACY")
505
+
506
+ if isFreeUser {
507
+ // For free users, use backend project ID for preview model access
508
+ log.Infof("Gemini onboarding: frontend project %s maps to backend project %s", projectID, responseProjectID)
509
+ log.Infof("Using backend project ID: %s (recommended for preview model access)", responseProjectID)
510
+ finalProjectID = responseProjectID
511
+ } else {
512
+ // Pro users: keep requested project ID (original behavior)
513
+ log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
514
+ }
515
+ } else {
516
+ finalProjectID = responseProjectID
517
+ }
518
+ }
519
+
520
+ storage.ProjectID = strings.TrimSpace(finalProjectID)
521
+ if storage.ProjectID == "" {
522
+ storage.ProjectID = strings.TrimSpace(projectID)
523
+ }
524
+ if storage.ProjectID == "" {
525
+ return fmt.Errorf("onboard user completed without project id")
526
+ }
527
+ log.Infof("Onboarding complete. Using Project ID: %s", storage.ProjectID)
528
+ return nil
529
+ }
530
+
531
+ log.Println("Onboarding in progress, waiting 5 seconds...")
532
+ time.Sleep(5 * time.Second)
533
+ }
534
+ }
535
+
536
+ func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error {
537
+ endPointURL := fmt.Sprintf("%s/%s:%s", geminiCLIEndpoint, geminiCLIVersion, endpoint)
538
+ if strings.HasPrefix(endpoint, "operations/") {
539
+ endPointURL = fmt.Sprintf("%s/%s", geminiCLIEndpoint, endpoint)
540
+ }
541
+
542
+ var reader io.Reader
543
+ if body != nil {
544
+ rawBody, errMarshal := json.Marshal(body)
545
+ if errMarshal != nil {
546
+ return fmt.Errorf("marshal request body: %w", errMarshal)
547
+ }
548
+ reader = bytes.NewReader(rawBody)
549
+ }
550
+
551
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, endPointURL, reader)
552
+ if errRequest != nil {
553
+ return fmt.Errorf("create request: %w", errRequest)
554
+ }
555
+ req.Header.Set("Content-Type", "application/json")
556
+ req.Header.Set("User-Agent", geminiCLIUserAgent)
557
+ req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient)
558
+ req.Header.Set("Client-Metadata", geminiCLIClientMetadata)
559
+
560
+ resp, errDo := httpClient.Do(req)
561
+ if errDo != nil {
562
+ return fmt.Errorf("execute request: %w", errDo)
563
+ }
564
+ defer func() {
565
+ if errClose := resp.Body.Close(); errClose != nil {
566
+ log.Errorf("response body close error: %v", errClose)
567
+ }
568
+ }()
569
+
570
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
571
+ bodyBytes, _ := io.ReadAll(resp.Body)
572
+ return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
573
+ }
574
+
575
+ if result == nil {
576
+ _, _ = io.Copy(io.Discard, resp.Body)
577
+ return nil
578
+ }
579
+
580
+ if errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil {
581
+ return fmt.Errorf("decode response body: %w", errDecode)
582
+ }
583
+
584
+ return nil
585
+ }
586
+
587
+ func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
588
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", http.NoBody)
589
+ if errRequest != nil {
590
+ return nil, fmt.Errorf("could not create project list request: %w", errRequest)
591
+ }
592
+
593
+ resp, errDo := httpClient.Do(req)
594
+ if errDo != nil {
595
+ return nil, fmt.Errorf("failed to execute project list request: %w", errDo)
596
+ }
597
+ defer func() {
598
+ if errClose := resp.Body.Close(); errClose != nil {
599
+ log.Errorf("response body close error: %v", errClose)
600
+ }
601
+ }()
602
+
603
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
604
+ bodyBytes, _ := io.ReadAll(resp.Body)
605
+ return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
606
+ }
607
+
608
+ var projects interfaces.GCPProject
609
+ if errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil {
610
+ return nil, fmt.Errorf("failed to unmarshal project list: %w", errDecode)
611
+ }
612
+
613
+ return projects.Projects, nil
614
+ }
615
+
616
+ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {
617
+ serviceUsageURL := "https://serviceusage.googleapis.com"
618
+ requiredServices := []string{
619
+ "cloudaicompanion.googleapis.com",
620
+ }
621
+ for _, service := range requiredServices {
622
+ checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
623
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, http.NoBody)
624
+ if errRequest != nil {
625
+ return false, fmt.Errorf("failed to create request: %w", errRequest)
626
+ }
627
+ req.Header.Set("Content-Type", "application/json")
628
+ req.Header.Set("User-Agent", geminiCLIUserAgent)
629
+ resp, errDo := httpClient.Do(req)
630
+ if errDo != nil {
631
+ return false, fmt.Errorf("failed to execute request: %w", errDo)
632
+ }
633
+
634
+ if resp.StatusCode == http.StatusOK {
635
+ bodyBytes, _ := io.ReadAll(resp.Body)
636
+ if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" {
637
+ _ = resp.Body.Close()
638
+ continue
639
+ }
640
+ }
641
+ _ = resp.Body.Close()
642
+
643
+ enableURL := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service)
644
+ req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableURL, strings.NewReader("{}"))
645
+ if errRequest != nil {
646
+ return false, fmt.Errorf("failed to create request: %w", errRequest)
647
+ }
648
+ req.Header.Set("Content-Type", "application/json")
649
+ req.Header.Set("User-Agent", geminiCLIUserAgent)
650
+ resp, errDo = httpClient.Do(req)
651
+ if errDo != nil {
652
+ return false, fmt.Errorf("failed to execute request: %w", errDo)
653
+ }
654
+
655
+ bodyBytes, _ := io.ReadAll(resp.Body)
656
+ errMessage := string(bodyBytes)
657
+ errMessageResult := gjson.GetBytes(bodyBytes, "error.message")
658
+ if errMessageResult.Exists() {
659
+ errMessage = errMessageResult.String()
660
+ }
661
+ if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
662
+ _ = resp.Body.Close()
663
+ continue
664
+ } else if resp.StatusCode == http.StatusBadRequest {
665
+ _ = resp.Body.Close()
666
+ if strings.Contains(strings.ToLower(errMessage), "already enabled") {
667
+ continue
668
+ }
669
+ }
670
+ _ = resp.Body.Close()
671
+ return false, fmt.Errorf("project activation required: %s", errMessage)
672
+ }
673
+ return true, nil
674
+ }
internal/api/handlers/management/auth_handlers.go ADDED
@@ -0,0 +1,567 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "os"
10
+ "path/filepath"
11
+ "sort"
12
+ "strings"
13
+ "time"
14
+
15
+ "github.com/gin-gonic/gin"
16
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
17
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
18
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
19
+ log "github.com/sirupsen/logrus"
20
+ "github.com/tidwall/gjson"
21
+ )
22
+
23
+ func (h *Handler) ListAuthFiles(c *gin.Context) {
24
+ if h == nil {
25
+ c.JSON(500, gin.H{"error": "handler not initialized"})
26
+ return
27
+ }
28
+ if h.authManager == nil {
29
+ h.listAuthFilesFromDisk(c)
30
+ return
31
+ }
32
+ auths := h.authManager.List()
33
+ files := make([]gin.H, 0, len(auths))
34
+ for _, auth := range auths {
35
+ if entry := h.buildAuthFileEntry(auth); entry != nil {
36
+ files = append(files, entry)
37
+ }
38
+ }
39
+ sort.Slice(files, func(i, j int) bool {
40
+ nameI, _ := files[i]["name"].(string)
41
+ nameJ, _ := files[j]["name"].(string)
42
+ return strings.ToLower(nameI) < strings.ToLower(nameJ)
43
+ })
44
+ c.JSON(200, gin.H{"files": files})
45
+ }
46
+
47
+ // GetAuthFileModels returns the models supported by a specific auth file
48
+ func (h *Handler) GetAuthFileModels(c *gin.Context) {
49
+ name := c.Query("name")
50
+ if name == "" {
51
+ c.JSON(400, gin.H{"error": "name is required"})
52
+ return
53
+ }
54
+
55
+ // Try to find auth ID via authManager
56
+ var authID string
57
+ if h.authManager != nil {
58
+ auths := h.authManager.List()
59
+ for _, auth := range auths {
60
+ if auth.FileName == name || auth.ID == name {
61
+ authID = auth.ID
62
+ break
63
+ }
64
+ }
65
+ }
66
+
67
+ if authID == "" {
68
+ authID = name // fallback to filename as ID
69
+ }
70
+
71
+ // Get models from registry
72
+ reg := registry.GetGlobalRegistry()
73
+ models := reg.GetModelsForClient(authID)
74
+
75
+ result := make([]gin.H, 0, len(models))
76
+ for _, m := range models {
77
+ entry := gin.H{
78
+ "id": m.ID,
79
+ }
80
+ if m.DisplayName != "" {
81
+ entry["display_name"] = m.DisplayName
82
+ }
83
+ if m.Type != "" {
84
+ entry["type"] = m.Type
85
+ }
86
+ if m.OwnedBy != "" {
87
+ entry["owned_by"] = m.OwnedBy
88
+ }
89
+ result = append(result, entry)
90
+ }
91
+
92
+ c.JSON(200, gin.H{"models": result})
93
+ }
94
+
95
+ // List auth files from disk when the auth manager is unavailable.
96
+ func (h *Handler) listAuthFilesFromDisk(c *gin.Context) {
97
+ genericListFiles(c, h.cfg.AuthDir, func(e os.DirEntry) (gin.H, bool) {
98
+ if e.IsDir() {
99
+ return nil, false
100
+ }
101
+ name := e.Name()
102
+ if !strings.HasSuffix(strings.ToLower(name), ".json") {
103
+ return nil, false
104
+ }
105
+ info, errInfo := e.Info()
106
+ if errInfo != nil {
107
+ return nil, false
108
+ }
109
+ fileData := gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()}
110
+
111
+ // Read file to get type field
112
+ full := filepath.Join(h.cfg.AuthDir, name)
113
+ if data, errRead := os.ReadFile(full); errRead == nil {
114
+ typeValue := gjson.GetBytes(data, "type").String()
115
+ emailValue := gjson.GetBytes(data, "email").String()
116
+ fileData["type"] = typeValue
117
+ fileData["email"] = emailValue
118
+ }
119
+
120
+ return fileData, true
121
+ }, nil)
122
+ }
123
+
124
+ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
125
+ if auth == nil {
126
+ return nil
127
+ }
128
+ auth.EnsureIndex()
129
+ runtimeOnly := isRuntimeOnlyAuth(auth)
130
+ if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) {
131
+ return nil
132
+ }
133
+ path := strings.TrimSpace(authAttribute(auth, "path"))
134
+ if path == "" && !runtimeOnly {
135
+ return nil
136
+ }
137
+ name := strings.TrimSpace(auth.FileName)
138
+ if name == "" {
139
+ name = auth.ID
140
+ }
141
+ entry := gin.H{
142
+ "id": auth.ID,
143
+ "auth_index": auth.Index,
144
+ "name": name,
145
+ "type": strings.TrimSpace(auth.Provider),
146
+ "provider": strings.TrimSpace(auth.Provider),
147
+ "label": auth.Label,
148
+ "status": auth.Status,
149
+ "status_message": auth.StatusMessage,
150
+ "disabled": auth.Disabled,
151
+ "unavailable": auth.Unavailable,
152
+ "runtime_only": runtimeOnly,
153
+ "source": "memory",
154
+ "size": int64(0),
155
+ }
156
+ if email := authEmail(auth); email != "" {
157
+ entry["email"] = email
158
+ }
159
+ if accountType, account := auth.AccountInfo(); accountType != "" || account != "" {
160
+ if accountType != "" {
161
+ entry["account_type"] = accountType
162
+ }
163
+ if account != "" {
164
+ entry["account"] = account
165
+ }
166
+ }
167
+ if !auth.CreatedAt.IsZero() {
168
+ entry["created_at"] = auth.CreatedAt
169
+ }
170
+ if !auth.UpdatedAt.IsZero() {
171
+ entry["modtime"] = auth.UpdatedAt
172
+ entry["updated_at"] = auth.UpdatedAt
173
+ }
174
+ if !auth.LastRefreshedAt.IsZero() {
175
+ entry["last_refresh"] = auth.LastRefreshedAt
176
+ }
177
+ if path != "" {
178
+ entry["path"] = path
179
+ entry["source"] = "file"
180
+ if info, err := os.Stat(path); err == nil {
181
+ entry["size"] = info.Size()
182
+ entry["modtime"] = info.ModTime()
183
+ } else if os.IsNotExist(err) {
184
+ // Hide credentials removed from disk but still lingering in memory.
185
+ if !runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled || strings.EqualFold(strings.TrimSpace(auth.StatusMessage), "removed via management api")) {
186
+ return nil
187
+ }
188
+ entry["source"] = "memory"
189
+ } else {
190
+ log.WithError(err).Warnf("failed to stat auth file %s", path)
191
+ }
192
+ }
193
+ if claims := extractCodexIDTokenClaims(auth); claims != nil {
194
+ entry["id_token"] = claims
195
+ }
196
+ return entry
197
+ }
198
+
199
+ func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
200
+ if auth == nil || auth.Metadata == nil {
201
+ return nil
202
+ }
203
+ if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
204
+ return nil
205
+ }
206
+ idTokenRaw, ok := auth.Metadata["id_token"].(string)
207
+ if !ok {
208
+ return nil
209
+ }
210
+ idToken := strings.TrimSpace(idTokenRaw)
211
+ if idToken == "" {
212
+ return nil
213
+ }
214
+ claims, err := codex.ParseJWTToken(idToken)
215
+ if err != nil || claims == nil {
216
+ return nil
217
+ }
218
+
219
+ result := gin.H{}
220
+ if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID); v != "" {
221
+ result["chatgpt_account_id"] = v
222
+ }
223
+ if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != "" {
224
+ result["plan_type"] = v
225
+ }
226
+ if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveStart; v != nil {
227
+ result["chatgpt_subscription_active_start"] = v
228
+ }
229
+ if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveUntil; v != nil {
230
+ result["chatgpt_subscription_active_until"] = v
231
+ }
232
+
233
+ if len(result) == 0 {
234
+ return nil
235
+ }
236
+ return result
237
+ }
238
+
239
+ func (h *Handler) DownloadAuthFile(c *gin.Context) {
240
+ name := c.Query("name")
241
+ if name == "" || strings.Contains(name, string(os.PathSeparator)) {
242
+ c.JSON(400, gin.H{"error": "invalid name"})
243
+ return
244
+ }
245
+ if !strings.HasSuffix(strings.ToLower(name), ".json") {
246
+ c.JSON(400, gin.H{"error": "name must end with .json"})
247
+ return
248
+ }
249
+ full := filepath.Join(h.cfg.AuthDir, name)
250
+ data, err := os.ReadFile(full)
251
+ if err != nil {
252
+ if os.IsNotExist(err) {
253
+ c.JSON(404, gin.H{"error": "file not found"})
254
+ } else {
255
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
256
+ }
257
+ return
258
+ }
259
+ c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", name))
260
+ c.Data(200, "application/json", data)
261
+ }
262
+
263
+ // Upload auth file: multipart or raw JSON with ?name=
264
+ func (h *Handler) UploadAuthFile(c *gin.Context) {
265
+ if h.authManager == nil {
266
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
267
+ return
268
+ }
269
+ ctx := c.Request.Context()
270
+ if file, err := c.FormFile("file"); err == nil && file != nil {
271
+ name := filepath.Base(file.Filename)
272
+ if !strings.HasSuffix(strings.ToLower(name), ".json") {
273
+ c.JSON(400, gin.H{"error": "file must be .json"})
274
+ return
275
+ }
276
+ dst := filepath.Join(h.cfg.AuthDir, name)
277
+ if !filepath.IsAbs(dst) {
278
+ if abs, errAbs := filepath.Abs(dst); errAbs == nil {
279
+ dst = abs
280
+ }
281
+ }
282
+ if errSave := c.SaveUploadedFile(file, dst); errSave != nil {
283
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)})
284
+ return
285
+ }
286
+ data, errRead := os.ReadFile(dst)
287
+ if errRead != nil {
288
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)})
289
+ return
290
+ }
291
+ if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {
292
+ c.JSON(500, gin.H{"error": errReg.Error()})
293
+ return
294
+ }
295
+ c.JSON(200, gin.H{"status": "ok"})
296
+ return
297
+ }
298
+ name := c.Query("name")
299
+ if name == "" || strings.Contains(name, string(os.PathSeparator)) {
300
+ c.JSON(400, gin.H{"error": "invalid name"})
301
+ return
302
+ }
303
+ if !strings.HasSuffix(strings.ToLower(name), ".json") {
304
+ c.JSON(400, gin.H{"error": "name must end with .json"})
305
+ return
306
+ }
307
+ data, err := io.ReadAll(c.Request.Body)
308
+ if err != nil {
309
+ c.JSON(400, gin.H{"error": "failed to read body"})
310
+ return
311
+ }
312
+ dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
313
+ if !filepath.IsAbs(dst) {
314
+ if abs, errAbs := filepath.Abs(dst); errAbs == nil {
315
+ dst = abs
316
+ }
317
+ }
318
+ if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
319
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)})
320
+ return
321
+ }
322
+ if err = h.registerAuthFromFile(ctx, dst, data); err != nil {
323
+ c.JSON(500, gin.H{"error": err.Error()})
324
+ return
325
+ }
326
+ c.JSON(200, gin.H{"status": "ok"})
327
+ }
328
+
329
+ // Delete auth files: single by name or all
330
+ func (h *Handler) DeleteAuthFile(c *gin.Context) {
331
+ if h.authManager == nil {
332
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
333
+ return
334
+ }
335
+ ctx := c.Request.Context()
336
+ if all := c.Query("all"); all == "true" || all == "1" || all == "*" {
337
+ entries, err := os.ReadDir(h.cfg.AuthDir)
338
+ if err != nil {
339
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
340
+ return
341
+ }
342
+ deleted := 0
343
+ for _, e := range entries {
344
+ if e.IsDir() {
345
+ continue
346
+ }
347
+ name := e.Name()
348
+ if !strings.HasSuffix(strings.ToLower(name), ".json") {
349
+ continue
350
+ }
351
+ full := filepath.Join(h.cfg.AuthDir, name)
352
+ if !filepath.IsAbs(full) {
353
+ if abs, errAbs := filepath.Abs(full); errAbs == nil {
354
+ full = abs
355
+ }
356
+ }
357
+ if err = os.Remove(full); err == nil {
358
+ if errDel := h.deleteTokenRecord(ctx, full); errDel != nil {
359
+ c.JSON(500, gin.H{"error": errDel.Error()})
360
+ return
361
+ }
362
+ deleted++
363
+ h.disableAuth(ctx, full)
364
+ }
365
+ }
366
+ c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
367
+ return
368
+ }
369
+ name := c.Query("name")
370
+ if name == "" || strings.Contains(name, string(os.PathSeparator)) {
371
+ c.JSON(400, gin.H{"error": "invalid name"})
372
+ return
373
+ }
374
+ full := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
375
+ if !filepath.IsAbs(full) {
376
+ if abs, errAbs := filepath.Abs(full); errAbs == nil {
377
+ full = abs
378
+ }
379
+ }
380
+ if err := os.Remove(full); err != nil {
381
+ if os.IsNotExist(err) {
382
+ c.JSON(404, gin.H{"error": "file not found"})
383
+ } else {
384
+ c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)})
385
+ }
386
+ return
387
+ }
388
+ if err := h.deleteTokenRecord(ctx, full); err != nil {
389
+ c.JSON(500, gin.H{"error": err.Error()})
390
+ return
391
+ }
392
+ h.disableAuth(ctx, full)
393
+ c.JSON(200, gin.H{"status": "ok"})
394
+ }
395
+
396
+ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error {
397
+ if h.authManager == nil {
398
+ return nil
399
+ }
400
+ if path == "" {
401
+ return fmt.Errorf("auth path is empty")
402
+ }
403
+ if data == nil {
404
+ var err error
405
+ data, err = os.ReadFile(path)
406
+ if err != nil {
407
+ return fmt.Errorf("failed to read auth file: %w", err)
408
+ }
409
+ }
410
+ metadata := make(map[string]any)
411
+ if err := json.Unmarshal(data, &metadata); err != nil {
412
+ return fmt.Errorf("invalid auth file: %w", err)
413
+ }
414
+ provider, _ := metadata["type"].(string)
415
+ if provider == "" {
416
+ provider = "unknown"
417
+ }
418
+ label := provider
419
+ if email, ok := metadata["email"].(string); ok && email != "" {
420
+ label = email
421
+ }
422
+ lastRefresh, hasLastRefresh := extractLastRefreshTimestamp(metadata)
423
+
424
+ authID := h.authIDForPath(path)
425
+ if authID == "" {
426
+ authID = path
427
+ }
428
+ attr := map[string]string{
429
+ "path": path,
430
+ "source": path,
431
+ }
432
+ auth := &coreauth.Auth{
433
+ ID: authID,
434
+ Provider: provider,
435
+ FileName: filepath.Base(path),
436
+ Label: label,
437
+ Status: coreauth.StatusActive,
438
+ Attributes: attr,
439
+ Metadata: metadata,
440
+ CreatedAt: time.Now(),
441
+ UpdatedAt: time.Now(),
442
+ }
443
+ if hasLastRefresh {
444
+ auth.LastRefreshedAt = lastRefresh
445
+ }
446
+ if existing, ok := h.authManager.GetByID(authID); ok {
447
+ auth.CreatedAt = existing.CreatedAt
448
+ if !hasLastRefresh {
449
+ auth.LastRefreshedAt = existing.LastRefreshedAt
450
+ }
451
+ auth.NextRefreshAfter = existing.NextRefreshAfter
452
+ auth.Runtime = existing.Runtime
453
+ _, err := h.authManager.Update(ctx, auth)
454
+ return err
455
+ }
456
+ _, err := h.authManager.Register(ctx, auth)
457
+ return err
458
+ }
459
+
460
+ // PatchAuthFileStatus toggles the disabled state of an auth file
461
+ func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
462
+ if h.authManager == nil {
463
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
464
+ return
465
+ }
466
+
467
+ var req struct {
468
+ Name string `json:"name"`
469
+ Disabled *bool `json:"disabled"`
470
+ }
471
+ if err := c.ShouldBindJSON(&req); err != nil {
472
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
473
+ return
474
+ }
475
+
476
+ name := strings.TrimSpace(req.Name)
477
+ if name == "" {
478
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
479
+ return
480
+ }
481
+ if req.Disabled == nil {
482
+ c.JSON(http.StatusBadRequest, gin.H{"error": "disabled is required"})
483
+ return
484
+ }
485
+
486
+ ctx := c.Request.Context()
487
+
488
+ // Find auth by name or ID
489
+ var targetAuth *coreauth.Auth
490
+ if auth, ok := h.authManager.GetByID(name); ok {
491
+ targetAuth = auth
492
+ } else {
493
+ auths := h.authManager.List()
494
+ for _, auth := range auths {
495
+ if auth.FileName == name {
496
+ targetAuth = auth
497
+ break
498
+ }
499
+ }
500
+ }
501
+
502
+ if targetAuth == nil {
503
+ c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
504
+ return
505
+ }
506
+
507
+ // Update disabled state
508
+ targetAuth.Disabled = *req.Disabled
509
+ if *req.Disabled {
510
+ targetAuth.Status = coreauth.StatusDisabled
511
+ targetAuth.StatusMessage = "disabled via management API"
512
+ } else {
513
+ targetAuth.Status = coreauth.StatusActive
514
+ targetAuth.StatusMessage = ""
515
+ }
516
+ targetAuth.UpdatedAt = time.Now()
517
+
518
+ if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
519
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
520
+ return
521
+ }
522
+
523
+ c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
524
+ }
525
+
526
+ func (h *Handler) disableAuth(ctx context.Context, id string) {
527
+ if h == nil || h.authManager == nil {
528
+ return
529
+ }
530
+ authID := h.authIDForPath(id)
531
+ if authID == "" {
532
+ authID = strings.TrimSpace(id)
533
+ }
534
+ if authID == "" {
535
+ return
536
+ }
537
+ if auth, ok := h.authManager.GetByID(authID); ok {
538
+ auth.Disabled = true
539
+ auth.Status = coreauth.StatusDisabled
540
+ auth.StatusMessage = "removed via management API"
541
+ auth.UpdatedAt = time.Now()
542
+ _, _ = h.authManager.Update(ctx, auth)
543
+ }
544
+ }
545
+
546
+ func (h *Handler) GetAuthStatus(c *gin.Context) {
547
+ state := strings.TrimSpace(c.Query("state"))
548
+ if state == "" {
549
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
550
+ return
551
+ }
552
+ if err := ValidateOAuthState(state); err != nil {
553
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
554
+ return
555
+ }
556
+
557
+ _, status, ok := GetOAuthSession(state)
558
+ if !ok {
559
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
560
+ return
561
+ }
562
+ if status != "" {
563
+ c.JSON(http.StatusOK, gin.H{"status": "error", "error": status})
564
+ return
565
+ }
566
+ c.JSON(http.StatusOK, gin.H{"status": "wait"})
567
+ }
internal/api/handlers/management/{auth_files.go → auth_providers.go} RENAMED
@@ -1,7 +1,6 @@
1
  package management
2
 
3
  import (
4
- "bytes"
5
  "context"
6
  "crypto/sha256"
7
  "encoding/hex"
@@ -9,14 +8,10 @@ import (
9
  "errors"
10
  "fmt"
11
  "io"
12
- "net"
13
  "net/http"
14
  "os"
15
  "path/filepath"
16
- "sort"
17
- "strconv"
18
  "strings"
19
- "sync"
20
  "time"
21
 
22
  "github.com/gin-gonic/gin"
@@ -26,11 +21,8 @@ import (
26
  geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
27
  iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
28
  "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
29
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
30
  "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
31
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
32
  "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
33
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
34
  coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
35
  log "github.com/sirupsen/logrus"
36
  "github.com/tidwall/gjson"
@@ -38,834 +30,6 @@ import (
38
  "golang.org/x/oauth2/google"
39
  )
40
 
41
- var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"}
42
-
43
- const (
44
- anthropicCallbackPort = 54545
45
- geminiCallbackPort = 8085
46
- codexCallbackPort = 1455
47
- geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com"
48
- geminiCLIVersion = "v1internal"
49
- geminiCLIUserAgent = "google-api-nodejs-client/9.15.1"
50
- geminiCLIApiClient = "gl-node/22.17.0"
51
- geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"
52
- )
53
-
54
- type callbackForwarder struct {
55
- provider string
56
- server *http.Server
57
- done chan struct{}
58
- }
59
-
60
- var (
61
- callbackForwardersMu sync.Mutex
62
- callbackForwarders = make(map[int]*callbackForwarder)
63
- )
64
-
65
- func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
66
- if len(meta) == 0 {
67
- return time.Time{}, false
68
- }
69
- for _, key := range lastRefreshKeys {
70
- if val, ok := meta[key]; ok {
71
- if ts, ok1 := parseLastRefreshValue(val); ok1 {
72
- return ts, true
73
- }
74
- }
75
- }
76
- return time.Time{}, false
77
- }
78
-
79
- func parseLastRefreshValue(v any) (time.Time, bool) {
80
- switch val := v.(type) {
81
- case string:
82
- s := strings.TrimSpace(val)
83
- if s == "" {
84
- return time.Time{}, false
85
- }
86
- layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00"}
87
- for _, layout := range layouts {
88
- if ts, err := time.Parse(layout, s); err == nil {
89
- return ts.UTC(), true
90
- }
91
- }
92
- if unix, err := strconv.ParseInt(s, 10, 64); err == nil && unix > 0 {
93
- return time.Unix(unix, 0).UTC(), true
94
- }
95
- case float64:
96
- if val <= 0 {
97
- return time.Time{}, false
98
- }
99
- return time.Unix(int64(val), 0).UTC(), true
100
- case int64:
101
- if val <= 0 {
102
- return time.Time{}, false
103
- }
104
- return time.Unix(val, 0).UTC(), true
105
- case int:
106
- if val <= 0 {
107
- return time.Time{}, false
108
- }
109
- return time.Unix(int64(val), 0).UTC(), true
110
- case json.Number:
111
- if i, err := val.Int64(); err == nil && i > 0 {
112
- return time.Unix(i, 0).UTC(), true
113
- }
114
- }
115
- return time.Time{}, false
116
- }
117
-
118
- func isWebUIRequest(c *gin.Context) bool {
119
- raw := strings.TrimSpace(c.Query("is_webui"))
120
- if raw == "" {
121
- return false
122
- }
123
- switch strings.ToLower(raw) {
124
- case "1", "true", "yes", "on":
125
- return true
126
- default:
127
- return false
128
- }
129
- }
130
-
131
- func startCallbackForwarder(port int, provider, targetBase string) (*callbackForwarder, error) {
132
- callbackForwardersMu.Lock()
133
- prev := callbackForwarders[port]
134
- if prev != nil {
135
- delete(callbackForwarders, port)
136
- }
137
- callbackForwardersMu.Unlock()
138
-
139
- if prev != nil {
140
- stopForwarderInstance(port, prev)
141
- }
142
-
143
- addr := fmt.Sprintf("127.0.0.1:%d", port)
144
- ln, err := net.Listen("tcp", addr)
145
- if err != nil {
146
- return nil, fmt.Errorf("failed to listen on %s: %w", addr, err)
147
- }
148
-
149
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
150
- target := targetBase
151
- if raw := r.URL.RawQuery; raw != "" {
152
- if strings.Contains(target, "?") {
153
- target = target + "&" + raw
154
- } else {
155
- target = target + "?" + raw
156
- }
157
- }
158
- w.Header().Set("Cache-Control", "no-store")
159
- http.Redirect(w, r, target, http.StatusFound)
160
- })
161
-
162
- srv := &http.Server{
163
- Handler: handler,
164
- ReadHeaderTimeout: 5 * time.Second,
165
- WriteTimeout: 5 * time.Second,
166
- }
167
- done := make(chan struct{})
168
-
169
- go func() {
170
- if errServe := srv.Serve(ln); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {
171
- log.WithError(errServe).Warnf("callback forwarder for %s stopped unexpectedly", provider)
172
- }
173
- close(done)
174
- }()
175
-
176
- forwarder := &callbackForwarder{
177
- provider: provider,
178
- server: srv,
179
- done: done,
180
- }
181
-
182
- callbackForwardersMu.Lock()
183
- callbackForwarders[port] = forwarder
184
- callbackForwardersMu.Unlock()
185
-
186
- log.Infof("callback forwarder for %s listening on %s", provider, addr)
187
-
188
- return forwarder, nil
189
- }
190
-
191
- func stopCallbackForwarder(port int) {
192
- callbackForwardersMu.Lock()
193
- forwarder := callbackForwarders[port]
194
- if forwarder != nil {
195
- delete(callbackForwarders, port)
196
- }
197
- callbackForwardersMu.Unlock()
198
-
199
- stopForwarderInstance(port, forwarder)
200
- }
201
-
202
- func stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) {
203
- if forwarder == nil {
204
- return
205
- }
206
- callbackForwardersMu.Lock()
207
- if current := callbackForwarders[port]; current == forwarder {
208
- delete(callbackForwarders, port)
209
- }
210
- callbackForwardersMu.Unlock()
211
-
212
- stopForwarderInstance(port, forwarder)
213
- }
214
-
215
- func stopForwarderInstance(port int, forwarder *callbackForwarder) {
216
- if forwarder == nil || forwarder.server == nil {
217
- return
218
- }
219
-
220
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
221
- defer cancel()
222
-
223
- if err := forwarder.server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
224
- log.WithError(err).Warnf("failed to shut down callback forwarder on port %d", port)
225
- }
226
-
227
- select {
228
- case <-forwarder.done:
229
- case <-time.After(2 * time.Second):
230
- }
231
-
232
- log.Infof("callback forwarder on port %d stopped", port)
233
- }
234
-
235
- func (h *Handler) managementCallbackURL(path string) (string, error) {
236
- if h == nil || h.cfg == nil || h.cfg.Port <= 0 {
237
- return "", fmt.Errorf("server port is not configured")
238
- }
239
- if !strings.HasPrefix(path, "/") {
240
- path = "/" + path
241
- }
242
- scheme := "http"
243
- if h.cfg.TLS.Enable {
244
- scheme = "https"
245
- }
246
- return fmt.Sprintf("%s://127.0.0.1:%d%s", scheme, h.cfg.Port, path), nil
247
- }
248
-
249
- func (h *Handler) ListAuthFiles(c *gin.Context) {
250
- if h == nil {
251
- c.JSON(500, gin.H{"error": "handler not initialized"})
252
- return
253
- }
254
- if h.authManager == nil {
255
- h.listAuthFilesFromDisk(c)
256
- return
257
- }
258
- auths := h.authManager.List()
259
- files := make([]gin.H, 0, len(auths))
260
- for _, auth := range auths {
261
- if entry := h.buildAuthFileEntry(auth); entry != nil {
262
- files = append(files, entry)
263
- }
264
- }
265
- sort.Slice(files, func(i, j int) bool {
266
- nameI, _ := files[i]["name"].(string)
267
- nameJ, _ := files[j]["name"].(string)
268
- return strings.ToLower(nameI) < strings.ToLower(nameJ)
269
- })
270
- c.JSON(200, gin.H{"files": files})
271
- }
272
-
273
- // GetAuthFileModels returns the models supported by a specific auth file
274
- func (h *Handler) GetAuthFileModels(c *gin.Context) {
275
- name := c.Query("name")
276
- if name == "" {
277
- c.JSON(400, gin.H{"error": "name is required"})
278
- return
279
- }
280
-
281
- // Try to find auth ID via authManager
282
- var authID string
283
- if h.authManager != nil {
284
- auths := h.authManager.List()
285
- for _, auth := range auths {
286
- if auth.FileName == name || auth.ID == name {
287
- authID = auth.ID
288
- break
289
- }
290
- }
291
- }
292
-
293
- if authID == "" {
294
- authID = name // fallback to filename as ID
295
- }
296
-
297
- // Get models from registry
298
- reg := registry.GetGlobalRegistry()
299
- models := reg.GetModelsForClient(authID)
300
-
301
- result := make([]gin.H, 0, len(models))
302
- for _, m := range models {
303
- entry := gin.H{
304
- "id": m.ID,
305
- }
306
- if m.DisplayName != "" {
307
- entry["display_name"] = m.DisplayName
308
- }
309
- if m.Type != "" {
310
- entry["type"] = m.Type
311
- }
312
- if m.OwnedBy != "" {
313
- entry["owned_by"] = m.OwnedBy
314
- }
315
- result = append(result, entry)
316
- }
317
-
318
- c.JSON(200, gin.H{"models": result})
319
- }
320
-
321
- // List auth files from disk when the auth manager is unavailable.
322
- func (h *Handler) listAuthFilesFromDisk(c *gin.Context) {
323
- entries, err := os.ReadDir(h.cfg.AuthDir)
324
- if err != nil {
325
- c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
326
- return
327
- }
328
- files := make([]gin.H, 0)
329
- for _, e := range entries {
330
- if e.IsDir() {
331
- continue
332
- }
333
- name := e.Name()
334
- if !strings.HasSuffix(strings.ToLower(name), ".json") {
335
- continue
336
- }
337
- if info, errInfo := e.Info(); errInfo == nil {
338
- fileData := gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()}
339
-
340
- // Read file to get type field
341
- full := filepath.Join(h.cfg.AuthDir, name)
342
- if data, errRead := os.ReadFile(full); errRead == nil {
343
- typeValue := gjson.GetBytes(data, "type").String()
344
- emailValue := gjson.GetBytes(data, "email").String()
345
- fileData["type"] = typeValue
346
- fileData["email"] = emailValue
347
- }
348
-
349
- files = append(files, fileData)
350
- }
351
- }
352
- c.JSON(200, gin.H{"files": files})
353
- }
354
-
355
- func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
356
- if auth == nil {
357
- return nil
358
- }
359
- auth.EnsureIndex()
360
- runtimeOnly := isRuntimeOnlyAuth(auth)
361
- if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) {
362
- return nil
363
- }
364
- path := strings.TrimSpace(authAttribute(auth, "path"))
365
- if path == "" && !runtimeOnly {
366
- return nil
367
- }
368
- name := strings.TrimSpace(auth.FileName)
369
- if name == "" {
370
- name = auth.ID
371
- }
372
- entry := gin.H{
373
- "id": auth.ID,
374
- "auth_index": auth.Index,
375
- "name": name,
376
- "type": strings.TrimSpace(auth.Provider),
377
- "provider": strings.TrimSpace(auth.Provider),
378
- "label": auth.Label,
379
- "status": auth.Status,
380
- "status_message": auth.StatusMessage,
381
- "disabled": auth.Disabled,
382
- "unavailable": auth.Unavailable,
383
- "runtime_only": runtimeOnly,
384
- "source": "memory",
385
- "size": int64(0),
386
- }
387
- if email := authEmail(auth); email != "" {
388
- entry["email"] = email
389
- }
390
- if accountType, account := auth.AccountInfo(); accountType != "" || account != "" {
391
- if accountType != "" {
392
- entry["account_type"] = accountType
393
- }
394
- if account != "" {
395
- entry["account"] = account
396
- }
397
- }
398
- if !auth.CreatedAt.IsZero() {
399
- entry["created_at"] = auth.CreatedAt
400
- }
401
- if !auth.UpdatedAt.IsZero() {
402
- entry["modtime"] = auth.UpdatedAt
403
- entry["updated_at"] = auth.UpdatedAt
404
- }
405
- if !auth.LastRefreshedAt.IsZero() {
406
- entry["last_refresh"] = auth.LastRefreshedAt
407
- }
408
- if path != "" {
409
- entry["path"] = path
410
- entry["source"] = "file"
411
- if info, err := os.Stat(path); err == nil {
412
- entry["size"] = info.Size()
413
- entry["modtime"] = info.ModTime()
414
- } else if os.IsNotExist(err) {
415
- // Hide credentials removed from disk but still lingering in memory.
416
- if !runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled || strings.EqualFold(strings.TrimSpace(auth.StatusMessage), "removed via management api")) {
417
- return nil
418
- }
419
- entry["source"] = "memory"
420
- } else {
421
- log.WithError(err).Warnf("failed to stat auth file %s", path)
422
- }
423
- }
424
- if claims := extractCodexIDTokenClaims(auth); claims != nil {
425
- entry["id_token"] = claims
426
- }
427
- return entry
428
- }
429
-
430
- func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
431
- if auth == nil || auth.Metadata == nil {
432
- return nil
433
- }
434
- if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
435
- return nil
436
- }
437
- idTokenRaw, ok := auth.Metadata["id_token"].(string)
438
- if !ok {
439
- return nil
440
- }
441
- idToken := strings.TrimSpace(idTokenRaw)
442
- if idToken == "" {
443
- return nil
444
- }
445
- claims, err := codex.ParseJWTToken(idToken)
446
- if err != nil || claims == nil {
447
- return nil
448
- }
449
-
450
- result := gin.H{}
451
- if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID); v != "" {
452
- result["chatgpt_account_id"] = v
453
- }
454
- if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != "" {
455
- result["plan_type"] = v
456
- }
457
- if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveStart; v != nil {
458
- result["chatgpt_subscription_active_start"] = v
459
- }
460
- if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveUntil; v != nil {
461
- result["chatgpt_subscription_active_until"] = v
462
- }
463
-
464
- if len(result) == 0 {
465
- return nil
466
- }
467
- return result
468
- }
469
-
470
- func authEmail(auth *coreauth.Auth) string {
471
- if auth == nil {
472
- return ""
473
- }
474
- if auth.Metadata != nil {
475
- if v, ok := auth.Metadata["email"].(string); ok {
476
- return strings.TrimSpace(v)
477
- }
478
- }
479
- if auth.Attributes != nil {
480
- if v := strings.TrimSpace(auth.Attributes["email"]); v != "" {
481
- return v
482
- }
483
- if v := strings.TrimSpace(auth.Attributes["account_email"]); v != "" {
484
- return v
485
- }
486
- }
487
- return ""
488
- }
489
-
490
- func authAttribute(auth *coreauth.Auth, key string) string {
491
- if auth == nil || len(auth.Attributes) == 0 {
492
- return ""
493
- }
494
- return auth.Attributes[key]
495
- }
496
-
497
- func isRuntimeOnlyAuth(auth *coreauth.Auth) bool {
498
- if auth == nil || len(auth.Attributes) == 0 {
499
- return false
500
- }
501
- return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true")
502
- }
503
-
504
- // Download single auth file by name
505
- func (h *Handler) DownloadAuthFile(c *gin.Context) {
506
- name := c.Query("name")
507
- if name == "" || strings.Contains(name, string(os.PathSeparator)) {
508
- c.JSON(400, gin.H{"error": "invalid name"})
509
- return
510
- }
511
- if !strings.HasSuffix(strings.ToLower(name), ".json") {
512
- c.JSON(400, gin.H{"error": "name must end with .json"})
513
- return
514
- }
515
- full := filepath.Join(h.cfg.AuthDir, name)
516
- data, err := os.ReadFile(full)
517
- if err != nil {
518
- if os.IsNotExist(err) {
519
- c.JSON(404, gin.H{"error": "file not found"})
520
- } else {
521
- c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
522
- }
523
- return
524
- }
525
- c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", name))
526
- c.Data(200, "application/json", data)
527
- }
528
-
529
- // Upload auth file: multipart or raw JSON with ?name=
530
- func (h *Handler) UploadAuthFile(c *gin.Context) {
531
- if h.authManager == nil {
532
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
533
- return
534
- }
535
- ctx := c.Request.Context()
536
- if file, err := c.FormFile("file"); err == nil && file != nil {
537
- name := filepath.Base(file.Filename)
538
- if !strings.HasSuffix(strings.ToLower(name), ".json") {
539
- c.JSON(400, gin.H{"error": "file must be .json"})
540
- return
541
- }
542
- dst := filepath.Join(h.cfg.AuthDir, name)
543
- if !filepath.IsAbs(dst) {
544
- if abs, errAbs := filepath.Abs(dst); errAbs == nil {
545
- dst = abs
546
- }
547
- }
548
- if errSave := c.SaveUploadedFile(file, dst); errSave != nil {
549
- c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)})
550
- return
551
- }
552
- data, errRead := os.ReadFile(dst)
553
- if errRead != nil {
554
- c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)})
555
- return
556
- }
557
- if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {
558
- c.JSON(500, gin.H{"error": errReg.Error()})
559
- return
560
- }
561
- c.JSON(200, gin.H{"status": "ok"})
562
- return
563
- }
564
- name := c.Query("name")
565
- if name == "" || strings.Contains(name, string(os.PathSeparator)) {
566
- c.JSON(400, gin.H{"error": "invalid name"})
567
- return
568
- }
569
- if !strings.HasSuffix(strings.ToLower(name), ".json") {
570
- c.JSON(400, gin.H{"error": "name must end with .json"})
571
- return
572
- }
573
- data, err := io.ReadAll(c.Request.Body)
574
- if err != nil {
575
- c.JSON(400, gin.H{"error": "failed to read body"})
576
- return
577
- }
578
- dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
579
- if !filepath.IsAbs(dst) {
580
- if abs, errAbs := filepath.Abs(dst); errAbs == nil {
581
- dst = abs
582
- }
583
- }
584
- if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
585
- c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)})
586
- return
587
- }
588
- if err = h.registerAuthFromFile(ctx, dst, data); err != nil {
589
- c.JSON(500, gin.H{"error": err.Error()})
590
- return
591
- }
592
- c.JSON(200, gin.H{"status": "ok"})
593
- }
594
-
595
- // Delete auth files: single by name or all
596
- func (h *Handler) DeleteAuthFile(c *gin.Context) {
597
- if h.authManager == nil {
598
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
599
- return
600
- }
601
- ctx := c.Request.Context()
602
- if all := c.Query("all"); all == "true" || all == "1" || all == "*" {
603
- entries, err := os.ReadDir(h.cfg.AuthDir)
604
- if err != nil {
605
- c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
606
- return
607
- }
608
- deleted := 0
609
- for _, e := range entries {
610
- if e.IsDir() {
611
- continue
612
- }
613
- name := e.Name()
614
- if !strings.HasSuffix(strings.ToLower(name), ".json") {
615
- continue
616
- }
617
- full := filepath.Join(h.cfg.AuthDir, name)
618
- if !filepath.IsAbs(full) {
619
- if abs, errAbs := filepath.Abs(full); errAbs == nil {
620
- full = abs
621
- }
622
- }
623
- if err = os.Remove(full); err == nil {
624
- if errDel := h.deleteTokenRecord(ctx, full); errDel != nil {
625
- c.JSON(500, gin.H{"error": errDel.Error()})
626
- return
627
- }
628
- deleted++
629
- h.disableAuth(ctx, full)
630
- }
631
- }
632
- c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
633
- return
634
- }
635
- name := c.Query("name")
636
- if name == "" || strings.Contains(name, string(os.PathSeparator)) {
637
- c.JSON(400, gin.H{"error": "invalid name"})
638
- return
639
- }
640
- full := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
641
- if !filepath.IsAbs(full) {
642
- if abs, errAbs := filepath.Abs(full); errAbs == nil {
643
- full = abs
644
- }
645
- }
646
- if err := os.Remove(full); err != nil {
647
- if os.IsNotExist(err) {
648
- c.JSON(404, gin.H{"error": "file not found"})
649
- } else {
650
- c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)})
651
- }
652
- return
653
- }
654
- if err := h.deleteTokenRecord(ctx, full); err != nil {
655
- c.JSON(500, gin.H{"error": err.Error()})
656
- return
657
- }
658
- h.disableAuth(ctx, full)
659
- c.JSON(200, gin.H{"status": "ok"})
660
- }
661
-
662
- func (h *Handler) authIDForPath(path string) string {
663
- path = strings.TrimSpace(path)
664
- if path == "" {
665
- return ""
666
- }
667
- if h == nil || h.cfg == nil {
668
- return path
669
- }
670
- authDir := strings.TrimSpace(h.cfg.AuthDir)
671
- if authDir == "" {
672
- return path
673
- }
674
- if rel, err := filepath.Rel(authDir, path); err == nil && rel != "" {
675
- return rel
676
- }
677
- return path
678
- }
679
-
680
- func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error {
681
- if h.authManager == nil {
682
- return nil
683
- }
684
- if path == "" {
685
- return fmt.Errorf("auth path is empty")
686
- }
687
- if data == nil {
688
- var err error
689
- data, err = os.ReadFile(path)
690
- if err != nil {
691
- return fmt.Errorf("failed to read auth file: %w", err)
692
- }
693
- }
694
- metadata := make(map[string]any)
695
- if err := json.Unmarshal(data, &metadata); err != nil {
696
- return fmt.Errorf("invalid auth file: %w", err)
697
- }
698
- provider, _ := metadata["type"].(string)
699
- if provider == "" {
700
- provider = "unknown"
701
- }
702
- label := provider
703
- if email, ok := metadata["email"].(string); ok && email != "" {
704
- label = email
705
- }
706
- lastRefresh, hasLastRefresh := extractLastRefreshTimestamp(metadata)
707
-
708
- authID := h.authIDForPath(path)
709
- if authID == "" {
710
- authID = path
711
- }
712
- attr := map[string]string{
713
- "path": path,
714
- "source": path,
715
- }
716
- auth := &coreauth.Auth{
717
- ID: authID,
718
- Provider: provider,
719
- FileName: filepath.Base(path),
720
- Label: label,
721
- Status: coreauth.StatusActive,
722
- Attributes: attr,
723
- Metadata: metadata,
724
- CreatedAt: time.Now(),
725
- UpdatedAt: time.Now(),
726
- }
727
- if hasLastRefresh {
728
- auth.LastRefreshedAt = lastRefresh
729
- }
730
- if existing, ok := h.authManager.GetByID(authID); ok {
731
- auth.CreatedAt = existing.CreatedAt
732
- if !hasLastRefresh {
733
- auth.LastRefreshedAt = existing.LastRefreshedAt
734
- }
735
- auth.NextRefreshAfter = existing.NextRefreshAfter
736
- auth.Runtime = existing.Runtime
737
- _, err := h.authManager.Update(ctx, auth)
738
- return err
739
- }
740
- _, err := h.authManager.Register(ctx, auth)
741
- return err
742
- }
743
-
744
- // PatchAuthFileStatus toggles the disabled state of an auth file
745
- func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
746
- if h.authManager == nil {
747
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
748
- return
749
- }
750
-
751
- var req struct {
752
- Name string `json:"name"`
753
- Disabled *bool `json:"disabled"`
754
- }
755
- if err := c.ShouldBindJSON(&req); err != nil {
756
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
757
- return
758
- }
759
-
760
- name := strings.TrimSpace(req.Name)
761
- if name == "" {
762
- c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
763
- return
764
- }
765
- if req.Disabled == nil {
766
- c.JSON(http.StatusBadRequest, gin.H{"error": "disabled is required"})
767
- return
768
- }
769
-
770
- ctx := c.Request.Context()
771
-
772
- // Find auth by name or ID
773
- var targetAuth *coreauth.Auth
774
- if auth, ok := h.authManager.GetByID(name); ok {
775
- targetAuth = auth
776
- } else {
777
- auths := h.authManager.List()
778
- for _, auth := range auths {
779
- if auth.FileName == name {
780
- targetAuth = auth
781
- break
782
- }
783
- }
784
- }
785
-
786
- if targetAuth == nil {
787
- c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
788
- return
789
- }
790
-
791
- // Update disabled state
792
- targetAuth.Disabled = *req.Disabled
793
- if *req.Disabled {
794
- targetAuth.Status = coreauth.StatusDisabled
795
- targetAuth.StatusMessage = "disabled via management API"
796
- } else {
797
- targetAuth.Status = coreauth.StatusActive
798
- targetAuth.StatusMessage = ""
799
- }
800
- targetAuth.UpdatedAt = time.Now()
801
-
802
- if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
803
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
804
- return
805
- }
806
-
807
- c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
808
- }
809
-
810
- func (h *Handler) disableAuth(ctx context.Context, id string) {
811
- if h == nil || h.authManager == nil {
812
- return
813
- }
814
- authID := h.authIDForPath(id)
815
- if authID == "" {
816
- authID = strings.TrimSpace(id)
817
- }
818
- if authID == "" {
819
- return
820
- }
821
- if auth, ok := h.authManager.GetByID(authID); ok {
822
- auth.Disabled = true
823
- auth.Status = coreauth.StatusDisabled
824
- auth.StatusMessage = "removed via management API"
825
- auth.UpdatedAt = time.Now()
826
- _, _ = h.authManager.Update(ctx, auth)
827
- }
828
- }
829
-
830
- func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error {
831
- if strings.TrimSpace(path) == "" {
832
- return fmt.Errorf("auth path is empty")
833
- }
834
- store := h.tokenStoreWithBaseDir()
835
- if store == nil {
836
- return fmt.Errorf("token store unavailable")
837
- }
838
- return store.Delete(ctx, path)
839
- }
840
-
841
- func (h *Handler) tokenStoreWithBaseDir() coreauth.Store {
842
- if h == nil {
843
- return nil
844
- }
845
- store := h.tokenStore
846
- if store == nil {
847
- store = sdkAuth.GetTokenStore()
848
- h.tokenStore = store
849
- }
850
- if h.cfg != nil {
851
- if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok {
852
- dirSetter.SetBaseDir(h.cfg.AuthDir)
853
- }
854
- }
855
- return store
856
- }
857
-
858
- func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) {
859
- if record == nil {
860
- return "", fmt.Errorf("token record is nil")
861
- }
862
- store := h.tokenStoreWithBaseDir()
863
- if store == nil {
864
- return "", fmt.Errorf("token store unavailable")
865
- }
866
- return store.Save(ctx, record)
867
- }
868
-
869
  func (h *Handler) RequestAnthropicToken(c *gin.Context) {
870
  ctx := context.Background()
871
 
@@ -1817,375 +981,3 @@ func (h *Handler) RequestIFlowCookieToken(c *gin.Context) {
1817
  }
1818
 
1819
  type projectSelectionRequiredError struct{}
1820
-
1821
- func (e *projectSelectionRequiredError) Error() string {
1822
- return "gemini cli: project selection required"
1823
- }
1824
-
1825
- func ensureGeminiProjectAndOnboard(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
1826
- if storage == nil {
1827
- return fmt.Errorf("gemini storage is nil")
1828
- }
1829
-
1830
- trimmedRequest := strings.TrimSpace(requestedProject)
1831
- if trimmedRequest == "" {
1832
- projects, errProjects := fetchGCPProjects(ctx, httpClient)
1833
- if errProjects != nil {
1834
- return fmt.Errorf("fetch project list: %w", errProjects)
1835
- }
1836
- if len(projects) == 0 {
1837
- return fmt.Errorf("no Google Cloud projects available for this account")
1838
- }
1839
- trimmedRequest = strings.TrimSpace(projects[0].ProjectID)
1840
- if trimmedRequest == "" {
1841
- return fmt.Errorf("resolved project id is empty")
1842
- }
1843
- storage.Auto = true
1844
- } else {
1845
- storage.Auto = false
1846
- }
1847
-
1848
- if err := performGeminiCLISetup(ctx, httpClient, storage, trimmedRequest); err != nil {
1849
- return err
1850
- }
1851
-
1852
- if strings.TrimSpace(storage.ProjectID) == "" {
1853
- storage.ProjectID = trimmedRequest
1854
- }
1855
-
1856
- return nil
1857
- }
1858
-
1859
- func onboardAllGeminiProjects(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage) ([]string, error) {
1860
- projects, errProjects := fetchGCPProjects(ctx, httpClient)
1861
- if errProjects != nil {
1862
- return nil, fmt.Errorf("fetch project list: %w", errProjects)
1863
- }
1864
- if len(projects) == 0 {
1865
- return nil, fmt.Errorf("no Google Cloud projects available for this account")
1866
- }
1867
- activated := make([]string, 0, len(projects))
1868
- seen := make(map[string]struct{}, len(projects))
1869
- for _, project := range projects {
1870
- candidate := strings.TrimSpace(project.ProjectID)
1871
- if candidate == "" {
1872
- continue
1873
- }
1874
- if _, dup := seen[candidate]; dup {
1875
- continue
1876
- }
1877
- if err := performGeminiCLISetup(ctx, httpClient, storage, candidate); err != nil {
1878
- return nil, fmt.Errorf("onboard project %s: %w", candidate, err)
1879
- }
1880
- finalID := strings.TrimSpace(storage.ProjectID)
1881
- if finalID == "" {
1882
- finalID = candidate
1883
- }
1884
- activated = append(activated, finalID)
1885
- seen[candidate] = struct{}{}
1886
- }
1887
- if len(activated) == 0 {
1888
- return nil, fmt.Errorf("no Google Cloud projects available for this account")
1889
- }
1890
- return activated, nil
1891
- }
1892
-
1893
- func ensureGeminiProjectsEnabled(ctx context.Context, httpClient *http.Client, projectIDs []string) error {
1894
- for _, pid := range projectIDs {
1895
- trimmed := strings.TrimSpace(pid)
1896
- if trimmed == "" {
1897
- continue
1898
- }
1899
- isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, trimmed)
1900
- if errCheck != nil {
1901
- return fmt.Errorf("project %s: %w", trimmed, errCheck)
1902
- }
1903
- if !isChecked {
1904
- return fmt.Errorf("project %s: Cloud AI API not enabled", trimmed)
1905
- }
1906
- }
1907
- return nil
1908
- }
1909
-
1910
- func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
1911
- metadata := map[string]string{
1912
- "ideType": "IDE_UNSPECIFIED",
1913
- "platform": "PLATFORM_UNSPECIFIED",
1914
- "pluginType": "GEMINI",
1915
- }
1916
-
1917
- trimmedRequest := strings.TrimSpace(requestedProject)
1918
- explicitProject := trimmedRequest != ""
1919
-
1920
- loadReqBody := map[string]any{
1921
- "metadata": metadata,
1922
- }
1923
- if explicitProject {
1924
- loadReqBody["cloudaicompanionProject"] = trimmedRequest
1925
- }
1926
-
1927
- var loadResp map[string]any
1928
- if errLoad := callGeminiCLI(ctx, httpClient, "loadCodeAssist", loadReqBody, &loadResp); errLoad != nil {
1929
- return fmt.Errorf("load code assist: %w", errLoad)
1930
- }
1931
-
1932
- tierID := "legacy-tier"
1933
- if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers {
1934
- for _, rawTier := range tiers {
1935
- tier, okTier := rawTier.(map[string]any)
1936
- if !okTier {
1937
- continue
1938
- }
1939
- if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault {
1940
- if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" {
1941
- tierID = strings.TrimSpace(id)
1942
- break
1943
- }
1944
- }
1945
- }
1946
- }
1947
-
1948
- projectID := trimmedRequest
1949
- if projectID == "" {
1950
- if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject {
1951
- projectID = strings.TrimSpace(id)
1952
- }
1953
- if projectID == "" {
1954
- if projectMap, okProject := loadResp["cloudaicompanionProject"].(map[string]any); okProject {
1955
- if id, okID := projectMap["id"].(string); okID {
1956
- projectID = strings.TrimSpace(id)
1957
- }
1958
- }
1959
- }
1960
- }
1961
- if projectID == "" {
1962
- return &projectSelectionRequiredError{}
1963
- }
1964
-
1965
- onboardReqBody := map[string]any{
1966
- "tierId": tierID,
1967
- "metadata": metadata,
1968
- "cloudaicompanionProject": projectID,
1969
- }
1970
-
1971
- storage.ProjectID = projectID
1972
-
1973
- for {
1974
- var onboardResp map[string]any
1975
- if errOnboard := callGeminiCLI(ctx, httpClient, "onboardUser", onboardReqBody, &onboardResp); errOnboard != nil {
1976
- return fmt.Errorf("onboard user: %w", errOnboard)
1977
- }
1978
-
1979
- if done, okDone := onboardResp["done"].(bool); okDone && done {
1980
- responseProjectID := ""
1981
- if resp, okResp := onboardResp["response"].(map[string]any); okResp {
1982
- switch projectValue := resp["cloudaicompanionProject"].(type) {
1983
- case map[string]any:
1984
- if id, okID := projectValue["id"].(string); okID {
1985
- responseProjectID = strings.TrimSpace(id)
1986
- }
1987
- case string:
1988
- responseProjectID = strings.TrimSpace(projectValue)
1989
- }
1990
- }
1991
-
1992
- finalProjectID := projectID
1993
- if responseProjectID != "" {
1994
- if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
1995
- // Check if this is a free user (gen-lang-client projects or free/legacy tier)
1996
- isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") ||
1997
- strings.EqualFold(tierID, "FREE") ||
1998
- strings.EqualFold(tierID, "LEGACY")
1999
-
2000
- if isFreeUser {
2001
- // For free users, use backend project ID for preview model access
2002
- log.Infof("Gemini onboarding: frontend project %s maps to backend project %s", projectID, responseProjectID)
2003
- log.Infof("Using backend project ID: %s (recommended for preview model access)", responseProjectID)
2004
- finalProjectID = responseProjectID
2005
- } else {
2006
- // Pro users: keep requested project ID (original behavior)
2007
- log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
2008
- }
2009
- } else {
2010
- finalProjectID = responseProjectID
2011
- }
2012
- }
2013
-
2014
- storage.ProjectID = strings.TrimSpace(finalProjectID)
2015
- if storage.ProjectID == "" {
2016
- storage.ProjectID = strings.TrimSpace(projectID)
2017
- }
2018
- if storage.ProjectID == "" {
2019
- return fmt.Errorf("onboard user completed without project id")
2020
- }
2021
- log.Infof("Onboarding complete. Using Project ID: %s", storage.ProjectID)
2022
- return nil
2023
- }
2024
-
2025
- log.Println("Onboarding in progress, waiting 5 seconds...")
2026
- time.Sleep(5 * time.Second)
2027
- }
2028
- }
2029
-
2030
- func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error {
2031
- endPointURL := fmt.Sprintf("%s/%s:%s", geminiCLIEndpoint, geminiCLIVersion, endpoint)
2032
- if strings.HasPrefix(endpoint, "operations/") {
2033
- endPointURL = fmt.Sprintf("%s/%s", geminiCLIEndpoint, endpoint)
2034
- }
2035
-
2036
- var reader io.Reader
2037
- if body != nil {
2038
- rawBody, errMarshal := json.Marshal(body)
2039
- if errMarshal != nil {
2040
- return fmt.Errorf("marshal request body: %w", errMarshal)
2041
- }
2042
- reader = bytes.NewReader(rawBody)
2043
- }
2044
-
2045
- req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, endPointURL, reader)
2046
- if errRequest != nil {
2047
- return fmt.Errorf("create request: %w", errRequest)
2048
- }
2049
- req.Header.Set("Content-Type", "application/json")
2050
- req.Header.Set("User-Agent", geminiCLIUserAgent)
2051
- req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient)
2052
- req.Header.Set("Client-Metadata", geminiCLIClientMetadata)
2053
-
2054
- resp, errDo := httpClient.Do(req)
2055
- if errDo != nil {
2056
- return fmt.Errorf("execute request: %w", errDo)
2057
- }
2058
- defer func() {
2059
- if errClose := resp.Body.Close(); errClose != nil {
2060
- log.Errorf("response body close error: %v", errClose)
2061
- }
2062
- }()
2063
-
2064
- if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
2065
- bodyBytes, _ := io.ReadAll(resp.Body)
2066
- return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
2067
- }
2068
-
2069
- if result == nil {
2070
- _, _ = io.Copy(io.Discard, resp.Body)
2071
- return nil
2072
- }
2073
-
2074
- if errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil {
2075
- return fmt.Errorf("decode response body: %w", errDecode)
2076
- }
2077
-
2078
- return nil
2079
- }
2080
-
2081
- func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
2082
- req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", http.NoBody)
2083
- if errRequest != nil {
2084
- return nil, fmt.Errorf("could not create project list request: %w", errRequest)
2085
- }
2086
-
2087
- resp, errDo := httpClient.Do(req)
2088
- if errDo != nil {
2089
- return nil, fmt.Errorf("failed to execute project list request: %w", errDo)
2090
- }
2091
- defer func() {
2092
- if errClose := resp.Body.Close(); errClose != nil {
2093
- log.Errorf("response body close error: %v", errClose)
2094
- }
2095
- }()
2096
-
2097
- if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
2098
- bodyBytes, _ := io.ReadAll(resp.Body)
2099
- return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
2100
- }
2101
-
2102
- var projects interfaces.GCPProject
2103
- if errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil {
2104
- return nil, fmt.Errorf("failed to unmarshal project list: %w", errDecode)
2105
- }
2106
-
2107
- return projects.Projects, nil
2108
- }
2109
-
2110
- func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {
2111
- serviceUsageURL := "https://serviceusage.googleapis.com"
2112
- requiredServices := []string{
2113
- "cloudaicompanion.googleapis.com",
2114
- }
2115
- for _, service := range requiredServices {
2116
- checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
2117
- req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, http.NoBody)
2118
- if errRequest != nil {
2119
- return false, fmt.Errorf("failed to create request: %w", errRequest)
2120
- }
2121
- req.Header.Set("Content-Type", "application/json")
2122
- req.Header.Set("User-Agent", geminiCLIUserAgent)
2123
- resp, errDo := httpClient.Do(req)
2124
- if errDo != nil {
2125
- return false, fmt.Errorf("failed to execute request: %w", errDo)
2126
- }
2127
-
2128
- if resp.StatusCode == http.StatusOK {
2129
- bodyBytes, _ := io.ReadAll(resp.Body)
2130
- if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" {
2131
- _ = resp.Body.Close()
2132
- continue
2133
- }
2134
- }
2135
- _ = resp.Body.Close()
2136
-
2137
- enableURL := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service)
2138
- req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableURL, strings.NewReader("{}"))
2139
- if errRequest != nil {
2140
- return false, fmt.Errorf("failed to create request: %w", errRequest)
2141
- }
2142
- req.Header.Set("Content-Type", "application/json")
2143
- req.Header.Set("User-Agent", geminiCLIUserAgent)
2144
- resp, errDo = httpClient.Do(req)
2145
- if errDo != nil {
2146
- return false, fmt.Errorf("failed to execute request: %w", errDo)
2147
- }
2148
-
2149
- bodyBytes, _ := io.ReadAll(resp.Body)
2150
- errMessage := string(bodyBytes)
2151
- errMessageResult := gjson.GetBytes(bodyBytes, "error.message")
2152
- if errMessageResult.Exists() {
2153
- errMessage = errMessageResult.String()
2154
- }
2155
- if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
2156
- _ = resp.Body.Close()
2157
- continue
2158
- } else if resp.StatusCode == http.StatusBadRequest {
2159
- _ = resp.Body.Close()
2160
- if strings.Contains(strings.ToLower(errMessage), "already enabled") {
2161
- continue
2162
- }
2163
- }
2164
- _ = resp.Body.Close()
2165
- return false, fmt.Errorf("project activation required: %s", errMessage)
2166
- }
2167
- return true, nil
2168
- }
2169
-
2170
- func (h *Handler) GetAuthStatus(c *gin.Context) {
2171
- state := strings.TrimSpace(c.Query("state"))
2172
- if state == "" {
2173
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
2174
- return
2175
- }
2176
- if err := ValidateOAuthState(state); err != nil {
2177
- c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
2178
- return
2179
- }
2180
-
2181
- _, status, ok := GetOAuthSession(state)
2182
- if !ok {
2183
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
2184
- return
2185
- }
2186
- if status != "" {
2187
- c.JSON(http.StatusOK, gin.H{"status": "error", "error": status})
2188
- return
2189
- }
2190
- c.JSON(http.StatusOK, gin.H{"status": "wait"})
2191
- }
 
1
  package management
2
 
3
  import (
 
4
  "context"
5
  "crypto/sha256"
6
  "encoding/hex"
 
8
  "errors"
9
  "fmt"
10
  "io"
 
11
  "net/http"
12
  "os"
13
  "path/filepath"
 
 
14
  "strings"
 
15
  "time"
16
 
17
  "github.com/gin-gonic/gin"
 
21
  geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
22
  iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
23
  "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
 
24
  "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
 
25
  "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
 
26
  coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
27
  log "github.com/sirupsen/logrus"
28
  "github.com/tidwall/gjson"
 
30
  "golang.org/x/oauth2/google"
31
  )
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  func (h *Handler) RequestAnthropicToken(c *gin.Context) {
34
  ctx := context.Background()
35
 
 
981
  }
982
 
983
  type projectSelectionRequiredError struct{}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
internal/api/handlers/management/config_basic.go CHANGED
@@ -184,14 +184,16 @@ func (h *Handler) GetConfigYAML(c *gin.Context) {
184
 
185
  // Debug
186
  func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) }
187
- func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) }
 
 
188
 
189
  // UsageStatisticsEnabled
190
  func (h *Handler) GetUsageStatisticsEnabled(c *gin.Context) {
191
  c.JSON(200, gin.H{"usage-statistics-enabled": h.cfg.UsageStatisticsEnabled})
192
  }
193
  func (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {
194
- h.updateBoolField(c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })
195
  }
196
 
197
  // UsageStatisticsEnabled
@@ -199,7 +201,7 @@ func (h *Handler) GetLoggingToFile(c *gin.Context) {
199
  c.JSON(200, gin.H{"logging-to-file": h.cfg.LoggingToFile})
200
  }
201
  func (h *Handler) PutLoggingToFile(c *gin.Context) {
202
- h.updateBoolField(c, func(v bool) { h.cfg.LoggingToFile = v })
203
  }
204
 
205
  // LogsMaxTotalSizeMB
@@ -207,25 +209,18 @@ func (h *Handler) GetLogsMaxTotalSizeMB(c *gin.Context) {
207
  c.JSON(200, gin.H{"logs-max-total-size-mb": h.cfg.LogsMaxTotalSizeMB})
208
  }
209
  func (h *Handler) PutLogsMaxTotalSizeMB(c *gin.Context) {
210
- var body struct {
211
- Value *int `json:"value"`
212
- }
213
- if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
214
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
215
- return
216
- }
217
- value := *body.Value
218
- if value < 0 {
219
- value = 0
220
- }
221
- h.cfg.LogsMaxTotalSizeMB = value
222
- h.persist(c)
223
  }
224
 
225
  // Request log
226
  func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) }
227
  func (h *Handler) PutRequestLog(c *gin.Context) {
228
- h.updateBoolField(c, func(v bool) { h.cfg.RequestLog = v })
229
  }
230
 
231
  // Websocket auth
@@ -233,7 +228,7 @@ func (h *Handler) GetWebsocketAuth(c *gin.Context) {
233
  c.JSON(200, gin.H{"ws-auth": h.cfg.WebsocketAuth})
234
  }
235
  func (h *Handler) PutWebsocketAuth(c *gin.Context) {
236
- h.updateBoolField(c, func(v bool) { h.cfg.WebsocketAuth = v })
237
  }
238
 
239
  // Request retry
@@ -241,7 +236,7 @@ func (h *Handler) GetRequestRetry(c *gin.Context) {
241
  c.JSON(200, gin.H{"request-retry": h.cfg.RequestRetry})
242
  }
243
  func (h *Handler) PutRequestRetry(c *gin.Context) {
244
- h.updateIntField(c, func(v int) { h.cfg.RequestRetry = v })
245
  }
246
 
247
  // Max retry interval
@@ -249,7 +244,7 @@ func (h *Handler) GetMaxRetryInterval(c *gin.Context) {
249
  c.JSON(200, gin.H{"max-retry-interval": h.cfg.MaxRetryInterval})
250
  }
251
  func (h *Handler) PutMaxRetryInterval(c *gin.Context) {
252
- h.updateIntField(c, func(v int) { h.cfg.MaxRetryInterval = v })
253
  }
254
 
255
  // ForceModelPrefix
@@ -257,7 +252,7 @@ func (h *Handler) GetForceModelPrefix(c *gin.Context) {
257
  c.JSON(200, gin.H{"force-model-prefix": h.cfg.ForceModelPrefix})
258
  }
259
  func (h *Handler) PutForceModelPrefix(c *gin.Context) {
260
- h.updateBoolField(c, func(v bool) { h.cfg.ForceModelPrefix = v })
261
  }
262
 
263
  func normalizeRoutingStrategy(strategy string) (string, bool) {
@@ -301,7 +296,7 @@ func (h *Handler) PutRoutingStrategy(c *gin.Context) {
301
  // Proxy URL
302
  func (h *Handler) GetProxyURL(c *gin.Context) { c.JSON(200, gin.H{"proxy-url": h.cfg.ProxyURL}) }
303
  func (h *Handler) PutProxyURL(c *gin.Context) {
304
- h.updateStringField(c, func(v string) { h.cfg.ProxyURL = v })
305
  }
306
  func (h *Handler) DeleteProxyURL(c *gin.Context) {
307
  h.cfg.ProxyURL = ""
 
184
 
185
  // Debug
186
  func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) }
187
+ func (h *Handler) PutDebug(c *gin.Context) {
188
+ genericUpdateField(h, c, func(v bool) { h.cfg.Debug = v })
189
+ }
190
 
191
  // UsageStatisticsEnabled
192
  func (h *Handler) GetUsageStatisticsEnabled(c *gin.Context) {
193
  c.JSON(200, gin.H{"usage-statistics-enabled": h.cfg.UsageStatisticsEnabled})
194
  }
195
  func (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {
196
+ genericUpdateField(h, c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })
197
  }
198
 
199
  // UsageStatisticsEnabled
 
201
  c.JSON(200, gin.H{"logging-to-file": h.cfg.LoggingToFile})
202
  }
203
  func (h *Handler) PutLoggingToFile(c *gin.Context) {
204
+ genericUpdateField(h, c, func(v bool) { h.cfg.LoggingToFile = v })
205
  }
206
 
207
  // LogsMaxTotalSizeMB
 
209
  c.JSON(200, gin.H{"logs-max-total-size-mb": h.cfg.LogsMaxTotalSizeMB})
210
  }
211
  func (h *Handler) PutLogsMaxTotalSizeMB(c *gin.Context) {
212
+ genericUpdateField(h, c, func(v int) {
213
+ if v < 0 {
214
+ v = 0
215
+ }
216
+ h.cfg.LogsMaxTotalSizeMB = v
217
+ })
 
 
 
 
 
 
 
218
  }
219
 
220
  // Request log
221
  func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) }
222
  func (h *Handler) PutRequestLog(c *gin.Context) {
223
+ genericUpdateField(h, c, func(v bool) { h.cfg.RequestLog = v })
224
  }
225
 
226
  // Websocket auth
 
228
  c.JSON(200, gin.H{"ws-auth": h.cfg.WebsocketAuth})
229
  }
230
  func (h *Handler) PutWebsocketAuth(c *gin.Context) {
231
+ genericUpdateField(h, c, func(v bool) { h.cfg.WebsocketAuth = v })
232
  }
233
 
234
  // Request retry
 
236
  c.JSON(200, gin.H{"request-retry": h.cfg.RequestRetry})
237
  }
238
  func (h *Handler) PutRequestRetry(c *gin.Context) {
239
+ genericUpdateField(h, c, func(v int) { h.cfg.RequestRetry = v })
240
  }
241
 
242
  // Max retry interval
 
244
  c.JSON(200, gin.H{"max-retry-interval": h.cfg.MaxRetryInterval})
245
  }
246
  func (h *Handler) PutMaxRetryInterval(c *gin.Context) {
247
+ genericUpdateField(h, c, func(v int) { h.cfg.MaxRetryInterval = v })
248
  }
249
 
250
  // ForceModelPrefix
 
252
  c.JSON(200, gin.H{"force-model-prefix": h.cfg.ForceModelPrefix})
253
  }
254
  func (h *Handler) PutForceModelPrefix(c *gin.Context) {
255
+ genericUpdateField(h, c, func(v bool) { h.cfg.ForceModelPrefix = v })
256
  }
257
 
258
  func normalizeRoutingStrategy(strategy string) (string, bool) {
 
296
  // Proxy URL
297
  func (h *Handler) GetProxyURL(c *gin.Context) { c.JSON(200, gin.H{"proxy-url": h.cfg.ProxyURL}) }
298
  func (h *Handler) PutProxyURL(c *gin.Context) {
299
+ genericUpdateField(h, c, func(v string) { h.cfg.ProxyURL = v })
300
  }
301
  func (h *Handler) DeleteProxyURL(c *gin.Context) {
302
  h.cfg.ProxyURL = ""
internal/api/handlers/management/config_lists.go CHANGED
@@ -916,7 +916,7 @@ func (h *Handler) GetAmpUpstreamURL(c *gin.Context) {
916
 
917
  // PutAmpUpstreamURL updates the ampcode upstream URL.
918
  func (h *Handler) PutAmpUpstreamURL(c *gin.Context) {
919
- h.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamURL = strings.TrimSpace(v) })
920
  }
921
 
922
  // DeleteAmpUpstreamURL clears the ampcode upstream URL.
@@ -936,7 +936,7 @@ func (h *Handler) GetAmpUpstreamAPIKey(c *gin.Context) {
936
 
937
  // PutAmpUpstreamAPIKey updates the ampcode upstream API key.
938
  func (h *Handler) PutAmpUpstreamAPIKey(c *gin.Context) {
939
- h.updateStringField(c, func(v string) { h.cfg.AmpCode.UpstreamAPIKey = strings.TrimSpace(v) })
940
  }
941
 
942
  // DeleteAmpUpstreamAPIKey clears the ampcode upstream API key.
@@ -956,7 +956,7 @@ func (h *Handler) GetAmpRestrictManagementToLocalhost(c *gin.Context) {
956
 
957
  // PutAmpRestrictManagementToLocalhost updates the localhost restriction setting.
958
  func (h *Handler) PutAmpRestrictManagementToLocalhost(c *gin.Context) {
959
- h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.RestrictManagementToLocalhost = v })
960
  }
961
 
962
  // GetAmpModelMappings returns the ampcode model mappings.
@@ -1045,7 +1045,7 @@ func (h *Handler) GetAmpForceModelMappings(c *gin.Context) {
1045
 
1046
  // PutAmpForceModelMappings updates the force model mappings setting.
1047
  func (h *Handler) PutAmpForceModelMappings(c *gin.Context) {
1048
- h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.ForceModelMappings = v })
1049
  }
1050
 
1051
  // GetAmpUpstreamAPIKeys returns the ampcode upstream API keys mapping.
 
916
 
917
  // PutAmpUpstreamURL updates the ampcode upstream URL.
918
  func (h *Handler) PutAmpUpstreamURL(c *gin.Context) {
919
+ genericUpdateField(h, c, func(v string) { h.cfg.AmpCode.UpstreamURL = strings.TrimSpace(v) })
920
  }
921
 
922
  // DeleteAmpUpstreamURL clears the ampcode upstream URL.
 
936
 
937
  // PutAmpUpstreamAPIKey updates the ampcode upstream API key.
938
  func (h *Handler) PutAmpUpstreamAPIKey(c *gin.Context) {
939
+ genericUpdateField(h, c, func(v string) { h.cfg.AmpCode.UpstreamAPIKey = strings.TrimSpace(v) })
940
  }
941
 
942
  // DeleteAmpUpstreamAPIKey clears the ampcode upstream API key.
 
956
 
957
  // PutAmpRestrictManagementToLocalhost updates the localhost restriction setting.
958
  func (h *Handler) PutAmpRestrictManagementToLocalhost(c *gin.Context) {
959
+ genericUpdateField(h, c, func(v bool) { h.cfg.AmpCode.RestrictManagementToLocalhost = v })
960
  }
961
 
962
  // GetAmpModelMappings returns the ampcode model mappings.
 
1045
 
1046
  // PutAmpForceModelMappings updates the force model mappings setting.
1047
  func (h *Handler) PutAmpForceModelMappings(c *gin.Context) {
1048
+ genericUpdateField(h, c, func(v bool) { h.cfg.AmpCode.ForceModelMappings = v })
1049
  }
1050
 
1051
  // GetAmpUpstreamAPIKeys returns the ampcode upstream API keys mapping.
internal/api/handlers/management/generics.go CHANGED
@@ -3,11 +3,57 @@ package management
3
  import (
4
  "encoding/json"
5
  "fmt"
 
 
6
  "strings"
7
 
8
  "github.com/gin-gonic/gin"
9
  )
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  // genericPutList handles PUT requests for a list of items.
12
  func genericPutList[T any](h *Handler, c *gin.Context, setList func([]T), normalize func(*T), sanitize func()) {
13
  data, err := c.GetRawData()
 
3
  import (
4
  "encoding/json"
5
  "fmt"
6
+ "net/http"
7
+ "os"
8
  "strings"
9
 
10
  "github.com/gin-gonic/gin"
11
  )
12
 
13
+ // genericUpdateField handles PUT requests for a single field.
14
+ func genericUpdateField[T any](h *Handler, c *gin.Context, set func(T)) {
15
+ var body struct {
16
+ Value *T `json:"value"`
17
+ }
18
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
19
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
20
+ return
21
+ }
22
+ set(*body.Value)
23
+ h.persist(c)
24
+ }
25
+
26
+ // genericListFiles handles listing files from a directory.
27
+ func genericListFiles[T any](
28
+ c *gin.Context,
29
+ dir string,
30
+ filterMap func(os.DirEntry) (T, bool), // returns parsed item and true if it should be included
31
+ sortFunc func([]T), // optional sort function
32
+ ) {
33
+ entries, err := os.ReadDir(dir)
34
+ if err != nil {
35
+ if os.IsNotExist(err) {
36
+ c.JSON(http.StatusOK, gin.H{"files": []T{}})
37
+ return
38
+ }
39
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read dir: %v", err)})
40
+ return
41
+ }
42
+
43
+ files := make([]T, 0, len(entries))
44
+ for _, e := range entries {
45
+ if item, ok := filterMap(e); ok {
46
+ files = append(files, item)
47
+ }
48
+ }
49
+
50
+ if sortFunc != nil {
51
+ sortFunc(files)
52
+ }
53
+
54
+ c.JSON(http.StatusOK, gin.H{"files": files})
55
+ }
56
+
57
  // genericPutList handles PUT requests for a list of items.
58
  func genericPutList[T any](h *Handler, c *gin.Context, setList func([]T), normalize func(*T), sanitize func()) {
59
  data, err := c.GetRawData()
internal/api/handlers/management/generics_test.go CHANGED
@@ -6,6 +6,9 @@ import (
6
  "net/http"
7
  "net/http/httptest"
8
  "os"
 
 
 
9
  "testing"
10
 
11
  "github.com/gin-gonic/gin"
@@ -21,7 +24,7 @@ func setupTestHandler(t *testing.T) (*Handler, *config.Config, func(), error) {
21
  if err != nil {
22
  return nil, nil, nil, err
23
  }
24
-
25
  // Write initial valid YAML content (empty object)
26
  if _, err := tmpFile.WriteString("{}\n"); err != nil {
27
  tmpFile.Close()
@@ -37,9 +40,9 @@ func setupTestHandler(t *testing.T) (*Handler, *config.Config, func(), error) {
37
  },
38
  },
39
  }
40
-
41
  h := NewHandler(cfg, tmpFile.Name(), nil)
42
-
43
  cleanup := func() {
44
  os.Remove(tmpFile.Name())
45
  }
@@ -68,10 +71,10 @@ func TestPutAPIKeys(t *testing.T) {
68
  h.PutAPIKeys(c)
69
 
70
  assert.Equal(t, http.StatusOK, w.Code)
71
-
72
  // Verify memory update
73
  assert.Equal(t, newKeys, cfg.APIKeys)
74
-
75
  // Verify Access.Providers is cleared (as per PutAPIKeys logic)
76
  assert.Nil(t, cfg.Access.Providers)
77
 
@@ -140,3 +143,68 @@ func TestDeleteAPIKeys(t *testing.T) {
140
  assert.Equal(t, http.StatusBadRequest, w.Code)
141
  assert.Equal(t, []string{"key1", "key4"}, cfg.APIKeys)
142
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  "net/http"
7
  "net/http/httptest"
8
  "os"
9
+ "path/filepath"
10
+ "sort"
11
+ "strings"
12
  "testing"
13
 
14
  "github.com/gin-gonic/gin"
 
24
  if err != nil {
25
  return nil, nil, nil, err
26
  }
27
+
28
  // Write initial valid YAML content (empty object)
29
  if _, err := tmpFile.WriteString("{}\n"); err != nil {
30
  tmpFile.Close()
 
40
  },
41
  },
42
  }
43
+
44
  h := NewHandler(cfg, tmpFile.Name(), nil)
45
+
46
  cleanup := func() {
47
  os.Remove(tmpFile.Name())
48
  }
 
71
  h.PutAPIKeys(c)
72
 
73
  assert.Equal(t, http.StatusOK, w.Code)
74
+
75
  // Verify memory update
76
  assert.Equal(t, newKeys, cfg.APIKeys)
77
+
78
  // Verify Access.Providers is cleared (as per PutAPIKeys logic)
79
  assert.Nil(t, cfg.Access.Providers)
80
 
 
143
  assert.Equal(t, http.StatusBadRequest, w.Code)
144
  assert.Equal(t, []string{"key1", "key4"}, cfg.APIKeys)
145
  }
146
+
147
+ func TestGenericUpdateField(t *testing.T) {
148
+ gin.SetMode(gin.TestMode)
149
+
150
+ h, cfg, cleanup, err := setupTestHandler(t)
151
+ assert.NoError(t, err)
152
+ defer cleanup()
153
+
154
+ // Test updating a bool field (Debug)
155
+ cfg.Debug = false
156
+
157
+ body := `{"value": true}`
158
+ w := httptest.NewRecorder()
159
+ c, _ := gin.CreateTestContext(w)
160
+ c.Request, _ = http.NewRequest(http.MethodPut, "/debug", bytes.NewBufferString(body))
161
+
162
+ // Manually call genericUpdateField with a setter that updates cfg.Debug
163
+ genericUpdateField(h, c, func(v bool) {
164
+ cfg.Debug = v
165
+ })
166
+
167
+ assert.Equal(t, http.StatusOK, w.Code)
168
+ assert.True(t, cfg.Debug)
169
+
170
+ // Verify persistence (load from file)
171
+ loaded, err := config.LoadConfig(h.configFilePath)
172
+ assert.NoError(t, err)
173
+ assert.True(t, loaded.Debug)
174
+ }
175
+
176
+ func TestGenericListFiles(t *testing.T) {
177
+ gin.SetMode(gin.TestMode)
178
+
179
+ // Create temp dir
180
+ tmpDir, err := os.MkdirTemp("", "test_files")
181
+ assert.NoError(t, err)
182
+ defer os.RemoveAll(tmpDir)
183
+
184
+ // Create some files
185
+ _ = os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0644)
186
+ _ = os.WriteFile(filepath.Join(tmpDir, "file2.json"), []byte("content"), 0644)
187
+ _ = os.WriteFile(filepath.Join(tmpDir, "file3.txt"), []byte("content"), 0644)
188
+
189
+ w := httptest.NewRecorder()
190
+ c, _ := gin.CreateTestContext(w)
191
+
192
+ genericListFiles(c, tmpDir, func(e os.DirEntry) (string, bool) {
193
+ if strings.HasSuffix(e.Name(), ".txt") {
194
+ return e.Name(), true
195
+ }
196
+ return "", false
197
+ }, func(files []string) {
198
+ // Sort
199
+ sort.Strings(files)
200
+ })
201
+
202
+ assert.Equal(t, http.StatusOK, w.Code)
203
+
204
+ var resp struct {
205
+ Files []string `json:"files"`
206
+ }
207
+ err = json.Unmarshal(w.Body.Bytes(), &resp)
208
+ assert.NoError(t, err)
209
+ assert.Equal(t, []string{"file1.txt", "file3.txt"}, resp.Files)
210
+ }
internal/api/handlers/management/handler.go CHANGED
@@ -278,40 +278,3 @@ func (h *Handler) persist(c *gin.Context) bool {
278
  c.JSON(http.StatusOK, gin.H{"status": "ok"})
279
  return true
280
  }
281
-
282
- // Helper methods for simple types
283
- func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) {
284
- var body struct {
285
- Value *bool `json:"value"`
286
- }
287
- if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
288
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
289
- return
290
- }
291
- set(*body.Value)
292
- h.persist(c)
293
- }
294
-
295
- func (h *Handler) updateIntField(c *gin.Context, set func(int)) {
296
- var body struct {
297
- Value *int `json:"value"`
298
- }
299
- if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
300
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
301
- return
302
- }
303
- set(*body.Value)
304
- h.persist(c)
305
- }
306
-
307
- func (h *Handler) updateStringField(c *gin.Context, set func(string)) {
308
- var body struct {
309
- Value *string `json:"value"`
310
- }
311
- if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
312
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
313
- return
314
- }
315
- set(*body.Value)
316
- h.persist(c)
317
- }
 
278
  c.JSON(http.StatusOK, gin.H{"status": "ok"})
279
  return true
280
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
internal/api/handlers/management/logs.go CHANGED
@@ -167,46 +167,32 @@ func (h *Handler) GetRequestErrorLogs(c *gin.Context) {
167
  return
168
  }
169
 
170
- entries, err := os.ReadDir(dir)
171
- if err != nil {
172
- if os.IsNotExist(err) {
173
- c.JSON(http.StatusOK, gin.H{"files": []any{}})
174
- return
175
- }
176
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list request error logs: %v", err)})
177
- return
178
- }
179
-
180
  type errorLog struct {
181
  Name string `json:"name"`
182
  Size int64 `json:"size"`
183
  Modified int64 `json:"modified"`
184
  }
185
 
186
- files := make([]errorLog, 0, len(entries))
187
- for _, entry := range entries {
188
  if entry.IsDir() {
189
- continue
190
  }
191
  name := entry.Name()
192
  if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
193
- continue
194
  }
195
  info, errInfo := entry.Info()
196
  if errInfo != nil {
197
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log info for %s: %v", name, errInfo)})
198
- return
199
  }
200
- files = append(files, errorLog{
201
  Name: name,
202
  Size: info.Size(),
203
  Modified: info.ModTime().Unix(),
204
- })
205
- }
206
-
207
- sort.Slice(files, func(i, j int) bool { return files[i].Modified > files[j].Modified })
208
-
209
- c.JSON(http.StatusOK, gin.H{"files": files})
210
  }
211
 
212
  // GetRequestLogByID finds and downloads a request log file by its request ID.
 
167
  return
168
  }
169
 
 
 
 
 
 
 
 
 
 
 
170
  type errorLog struct {
171
  Name string `json:"name"`
172
  Size int64 `json:"size"`
173
  Modified int64 `json:"modified"`
174
  }
175
 
176
+ genericListFiles(c, dir, func(entry os.DirEntry) (errorLog, bool) {
 
177
  if entry.IsDir() {
178
+ return errorLog{}, false
179
  }
180
  name := entry.Name()
181
  if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
182
+ return errorLog{}, false
183
  }
184
  info, errInfo := entry.Info()
185
  if errInfo != nil {
186
+ return errorLog{}, false
 
187
  }
188
+ return errorLog{
189
  Name: name,
190
  Size: info.Size(),
191
  Modified: info.ModTime().Unix(),
192
+ }, true
193
+ }, func(files []errorLog) {
194
+ sort.Slice(files, func(i, j int) bool { return files[i].Modified > files[j].Modified })
195
+ })
 
 
196
  }
197
 
198
  // GetRequestLogByID finds and downloads a request log file by its request ID.
internal/api/handlers/management/quota.go CHANGED
@@ -7,12 +7,12 @@ func (h *Handler) GetSwitchProject(c *gin.Context) {
7
  c.JSON(200, gin.H{"switch-project": h.cfg.QuotaExceeded.SwitchProject})
8
  }
9
  func (h *Handler) PutSwitchProject(c *gin.Context) {
10
- h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchProject = v })
11
  }
12
 
13
  func (h *Handler) GetSwitchPreviewModel(c *gin.Context) {
14
  c.JSON(200, gin.H{"switch-preview-model": h.cfg.QuotaExceeded.SwitchPreviewModel})
15
  }
16
  func (h *Handler) PutSwitchPreviewModel(c *gin.Context) {
17
- h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchPreviewModel = v })
18
  }
 
7
  c.JSON(200, gin.H{"switch-project": h.cfg.QuotaExceeded.SwitchProject})
8
  }
9
  func (h *Handler) PutSwitchProject(c *gin.Context) {
10
+ genericUpdateField(h, c, func(v bool) { h.cfg.QuotaExceeded.SwitchProject = v })
11
  }
12
 
13
  func (h *Handler) GetSwitchPreviewModel(c *gin.Context) {
14
  c.JSON(200, gin.H{"switch-preview-model": h.cfg.QuotaExceeded.SwitchPreviewModel})
15
  }
16
  func (h *Handler) PutSwitchPreviewModel(c *gin.Context) {
17
+ genericUpdateField(h, c, func(v bool) { h.cfg.QuotaExceeded.SwitchPreviewModel = v })
18
  }
internal/config/config.go CHANGED
@@ -94,6 +94,9 @@ type Config struct {
94
  // AmpCode contains Amp CLI upstream configuration, management restrictions, and model mappings.
95
  AmpCode AmpCode `yaml:"ampcode" json:"ampcode"`
96
 
 
 
 
97
  // OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries.
98
  OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"`
99
 
@@ -218,6 +221,17 @@ type AmpUpstreamAPIKeyEntry struct {
218
  APIKeys []string `yaml:"api-keys" json:"api-keys"`
219
  }
220
 
 
 
 
 
 
 
 
 
 
 
 
221
  // PayloadConfig defines default and override parameter rules applied to provider payloads.
222
  type PayloadConfig struct {
223
  // Default defines rules that only set parameters when they are missing in the payload.
@@ -574,6 +588,16 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
574
  cfg.DisableCooling = false
575
  cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
576
  cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
 
 
 
 
 
 
 
 
 
 
577
  if err = yaml.Unmarshal(data, &cfg); err != nil {
578
  if optional {
579
  // In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
 
94
  // AmpCode contains Amp CLI upstream configuration, management restrictions, and model mappings.
95
  AmpCode AmpCode `yaml:"ampcode" json:"ampcode"`
96
 
97
+ // Antigravity contains configuration for the Antigravity provider.
98
+ Antigravity AntigravityConfig `yaml:"antigravity" json:"antigravity"`
99
+
100
  // OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries.
101
  OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"`
102
 
 
221
  APIKeys []string `yaml:"api-keys" json:"api-keys"`
222
  }
223
 
224
+ // AntigravityConfig holds configuration for the Antigravity provider.
225
+ type AntigravityConfig struct {
226
+ BaseURLDaily string `yaml:"base-url-daily" json:"base-url-daily"`
227
+ SandboxBaseURLDaily string `yaml:"sandbox-base-url-daily" json:"sandbox-base-url-daily"`
228
+ BaseURLProd string `yaml:"base-url-prod" json:"base-url-prod"`
229
+ ClientID string `yaml:"client-id" json:"client-id"`
230
+ ClientSecret string `yaml:"client-secret" json:"client-secret"`
231
+ UserAgent string `yaml:"user-agent" json:"user-agent"`
232
+ SystemInstruction string `yaml:"system-instruction" json:"system-instruction"`
233
+ }
234
+
235
  // PayloadConfig defines default and override parameter rules applied to provider payloads.
236
  type PayloadConfig struct {
237
  // Default defines rules that only set parameters when they are missing in the payload.
 
588
  cfg.DisableCooling = false
589
  cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
590
  cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
591
+
592
+ // Set Antigravity defaults
593
+ cfg.Antigravity.BaseURLDaily = "https://daily-cloudcode-pa.googleapis.com"
594
+ cfg.Antigravity.SandboxBaseURLDaily = "https://daily-cloudcode-pa.sandbox.googleapis.com"
595
+ cfg.Antigravity.BaseURLProd = "https://cloudcode-pa.googleapis.com"
596
+ cfg.Antigravity.ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
597
+ cfg.Antigravity.ClientSecret = "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"
598
+ cfg.Antigravity.UserAgent = "antigravity/1.104.0 darwin/arm64"
599
+ cfg.Antigravity.SystemInstruction = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**"
600
+
601
  if err = yaml.Unmarshal(data, &cfg); err != nil {
602
  if optional {
603
  // In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
internal/runtime/executor/antigravity_executor.go CHANGED
@@ -36,19 +36,12 @@ import (
36
  )
37
 
38
  const (
39
- antigravityBaseURLDaily = "https://daily-cloudcode-pa.googleapis.com"
40
- antigravitySandboxBaseURLDaily = "https://daily-cloudcode-pa.sandbox.googleapis.com"
41
- antigravityBaseURLProd = "https://cloudcode-pa.googleapis.com"
42
- antigravityCountTokensPath = "/v1internal:countTokens"
43
- antigravityStreamPath = "/v1internal:streamGenerateContent"
44
- antigravityGeneratePath = "/v1internal:generateContent"
45
- antigravityModelsPath = "/v1internal:fetchAvailableModels"
46
- antigravityClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
47
- antigravityClientSecret = "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"
48
- defaultAntigravityAgent = "antigravity/1.104.0 darwin/arm64"
49
- antigravityAuthType = "antigravity"
50
- refreshSkew = 3000 * time.Second
51
- systemInstruction = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**"
52
  )
53
 
54
  var (
@@ -93,18 +86,7 @@ func (e *AntigravityExecutor) PrepareRequest(req *http.Request, auth *cliproxyau
93
 
94
  // HttpRequest injects Antigravity credentials into the request and executes it.
95
  func (e *AntigravityExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
96
- if req == nil {
97
- return nil, fmt.Errorf("antigravity executor: request is nil")
98
- }
99
- if ctx == nil {
100
- ctx = req.Context()
101
- }
102
- httpReq := req.WithContext(ctx)
103
- if err := e.PrepareRequest(httpReq, auth); err != nil {
104
- return nil, err
105
- }
106
- httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
107
- return httpClient.Do(httpReq)
108
  }
109
 
110
  // Execute performs a non-streaming request to the Antigravity API.
@@ -145,7 +127,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
145
  requestedModel := payloadRequestedModel(opts, req.Model)
146
  translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
147
 
148
- baseURLs := antigravityBaseURLFallbackOrder(auth)
149
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
150
 
151
  attempts := antigravityRetryAttempts(auth, e.cfg)
@@ -286,7 +268,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
286
  requestedModel := payloadRequestedModel(opts, req.Model)
287
  translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
288
 
289
- baseURLs := antigravityBaseURLFallbackOrder(auth)
290
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
291
 
292
  attempts := antigravityRetryAttempts(auth, e.cfg)
@@ -674,7 +656,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
674
  requestedModel := payloadRequestedModel(opts, req.Model)
675
  translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
676
 
677
- baseURLs := antigravityBaseURLFallbackOrder(auth)
678
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
679
 
680
  attempts := antigravityRetryAttempts(auth, e.cfg)
@@ -877,7 +859,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
877
  payload = deleteJSONField(payload, "model")
878
  payload = deleteJSONField(payload, "request.safetySettings")
879
 
880
- baseURLs := antigravityBaseURLFallbackOrder(auth)
881
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
882
 
883
  var authID, authLabel, authType, authValue string
@@ -894,7 +876,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
894
  for idx, baseURL := range baseURLs {
895
  base := strings.TrimSuffix(baseURL, "/")
896
  if base == "" {
897
- base = buildBaseURL(auth)
898
  }
899
 
900
  var requestURL strings.Builder
@@ -911,7 +893,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
911
  }
912
  httpReq.Header.Set("Content-Type", "application/json")
913
  httpReq.Header.Set("Authorization", "Bearer "+token)
914
- httpReq.Header.Set("User-Agent", resolveUserAgent(auth))
915
  httpReq.Header.Set("Accept", "application/json")
916
  if host := resolveHost(base); host != "" {
917
  httpReq.Host = host
@@ -1005,7 +987,7 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c
1005
  auth = updatedAuth
1006
  }
1007
 
1008
- baseURLs := antigravityBaseURLFallbackOrder(auth)
1009
  httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0)
1010
 
1011
  for idx, baseURL := range baseURLs {
@@ -1016,7 +998,7 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c
1016
  }
1017
  httpReq.Header.Set("Content-Type", "application/json")
1018
  httpReq.Header.Set("Authorization", "Bearer "+token)
1019
- httpReq.Header.Set("User-Agent", resolveUserAgent(auth))
1020
  if host := resolveHost(baseURL); host != "" {
1021
  httpReq.Host = host
1022
  }
@@ -1136,8 +1118,8 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau
1136
  }
1137
 
1138
  form := url.Values{}
1139
- form.Set("client_id", antigravityClientID)
1140
- form.Set("client_secret", antigravityClientSecret)
1141
  form.Set("grant_type", "refresh_token")
1142
  form.Set("refresh_token", refreshToken)
1143
 
@@ -1146,7 +1128,7 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau
1146
  return auth, errReq
1147
  }
1148
  httpReq.Header.Set("Host", "oauth2.googleapis.com")
1149
- httpReq.Header.Set("User-Agent", defaultAntigravityAgent)
1150
  httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1151
 
1152
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
@@ -1243,7 +1225,7 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
1243
 
1244
  base := strings.TrimSuffix(baseURL, "/")
1245
  if base == "" {
1246
- base = buildBaseURL(auth)
1247
  }
1248
  path := antigravityGeneratePath
1249
  if stream {
@@ -1302,8 +1284,8 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
1302
  if strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-high") {
1303
  systemInstructionPartsResult := gjson.GetBytes(payload, "request.systemInstruction.parts")
1304
  payload, _ = sjson.SetBytes(payload, "request.systemInstruction.role", "user")
1305
- payload, _ = sjson.SetBytes(payload, "request.systemInstruction.parts.0.text", systemInstruction)
1306
- payload, _ = sjson.SetBytes(payload, "request.systemInstruction.parts.1.text", fmt.Sprintf("Please ignore following [ignore]%s[/ignore]", systemInstruction))
1307
 
1308
  if systemInstructionPartsResult.Exists() && systemInstructionPartsResult.IsArray() {
1309
  for _, partResult := range systemInstructionPartsResult.Array() {
@@ -1324,7 +1306,7 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
1324
  }
1325
  httpReq.Header.Set("Content-Type", "application/json")
1326
  httpReq.Header.Set("Authorization", "Bearer "+token)
1327
- httpReq.Header.Set("User-Agent", resolveUserAgent(auth))
1328
  if stream {
1329
  httpReq.Header.Set("Accept", "text/event-stream")
1330
  } else {
@@ -1413,11 +1395,14 @@ func int64Value(value any) (int64, bool) {
1413
  return 0, false
1414
  }
1415
 
1416
- func buildBaseURL(auth *cliproxyauth.Auth) string {
1417
- if baseURLs := antigravityBaseURLFallbackOrder(auth); len(baseURLs) > 0 {
1418
  return baseURLs[0]
1419
  }
1420
- return antigravityBaseURLDaily
 
 
 
1421
  }
1422
 
1423
  func resolveHost(base string) string {
@@ -1431,7 +1416,7 @@ func resolveHost(base string) string {
1431
  return strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://")
1432
  }
1433
 
1434
- func resolveUserAgent(auth *cliproxyauth.Auth) string {
1435
  if auth != nil {
1436
  if auth.Attributes != nil {
1437
  if ua := strings.TrimSpace(auth.Attributes["user_agent"]); ua != "" {
@@ -1444,7 +1429,10 @@ func resolveUserAgent(auth *cliproxyauth.Auth) string {
1444
  }
1445
  }
1446
  }
1447
- return defaultAntigravityAgent
 
 
 
1448
  }
1449
 
1450
  func antigravityRetryAttempts(auth *cliproxyauth.Auth, cfg *config.Config) int {
@@ -1503,14 +1491,17 @@ func antigravityWait(ctx context.Context, wait time.Duration) error {
1503
  }
1504
  }
1505
 
1506
- func antigravityBaseURLFallbackOrder(auth *cliproxyauth.Auth) []string {
1507
  if base := resolveCustomAntigravityBaseURL(auth); base != "" {
1508
  return []string{base}
1509
  }
 
 
 
1510
  return []string{
1511
- antigravityBaseURLDaily,
1512
- antigravitySandboxBaseURLDaily,
1513
- // antigravityBaseURLProd,
1514
  }
1515
  }
1516
 
 
36
  )
37
 
38
  const (
39
+ antigravityCountTokensPath = "/v1internal:countTokens"
40
+ antigravityStreamPath = "/v1internal:streamGenerateContent"
41
+ antigravityGeneratePath = "/v1internal:generateContent"
42
+ antigravityModelsPath = "/v1internal:fetchAvailableModels"
43
+ antigravityAuthType = "antigravity"
44
+ refreshSkew = 3000 * time.Second
 
 
 
 
 
 
 
45
  )
46
 
47
  var (
 
86
 
87
  // HttpRequest injects Antigravity credentials into the request and executes it.
88
  func (e *AntigravityExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
89
+ return ExecuteHttpRequest(ctx, e.cfg, auth, req, e)
 
 
 
 
 
 
 
 
 
 
 
90
  }
91
 
92
  // Execute performs a non-streaming request to the Antigravity API.
 
127
  requestedModel := payloadRequestedModel(opts, req.Model)
128
  translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
129
 
130
+ baseURLs := antigravityBaseURLFallbackOrder(auth, e.cfg)
131
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
132
 
133
  attempts := antigravityRetryAttempts(auth, e.cfg)
 
268
  requestedModel := payloadRequestedModel(opts, req.Model)
269
  translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
270
 
271
+ baseURLs := antigravityBaseURLFallbackOrder(auth, e.cfg)
272
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
273
 
274
  attempts := antigravityRetryAttempts(auth, e.cfg)
 
656
  requestedModel := payloadRequestedModel(opts, req.Model)
657
  translated = applyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
658
 
659
+ baseURLs := antigravityBaseURLFallbackOrder(auth, e.cfg)
660
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
661
 
662
  attempts := antigravityRetryAttempts(auth, e.cfg)
 
859
  payload = deleteJSONField(payload, "model")
860
  payload = deleteJSONField(payload, "request.safetySettings")
861
 
862
+ baseURLs := antigravityBaseURLFallbackOrder(auth, e.cfg)
863
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
864
 
865
  var authID, authLabel, authType, authValue string
 
876
  for idx, baseURL := range baseURLs {
877
  base := strings.TrimSuffix(baseURL, "/")
878
  if base == "" {
879
+ base = buildBaseURL(auth, e.cfg)
880
  }
881
 
882
  var requestURL strings.Builder
 
893
  }
894
  httpReq.Header.Set("Content-Type", "application/json")
895
  httpReq.Header.Set("Authorization", "Bearer "+token)
896
+ httpReq.Header.Set("User-Agent", resolveUserAgent(auth, e.cfg))
897
  httpReq.Header.Set("Accept", "application/json")
898
  if host := resolveHost(base); host != "" {
899
  httpReq.Host = host
 
987
  auth = updatedAuth
988
  }
989
 
990
+ baseURLs := antigravityBaseURLFallbackOrder(auth, cfg)
991
  httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0)
992
 
993
  for idx, baseURL := range baseURLs {
 
998
  }
999
  httpReq.Header.Set("Content-Type", "application/json")
1000
  httpReq.Header.Set("Authorization", "Bearer "+token)
1001
+ httpReq.Header.Set("User-Agent", resolveUserAgent(auth, cfg))
1002
  if host := resolveHost(baseURL); host != "" {
1003
  httpReq.Host = host
1004
  }
 
1118
  }
1119
 
1120
  form := url.Values{}
1121
+ form.Set("client_id", e.cfg.Antigravity.ClientID)
1122
+ form.Set("client_secret", e.cfg.Antigravity.ClientSecret)
1123
  form.Set("grant_type", "refresh_token")
1124
  form.Set("refresh_token", refreshToken)
1125
 
 
1128
  return auth, errReq
1129
  }
1130
  httpReq.Header.Set("Host", "oauth2.googleapis.com")
1131
+ httpReq.Header.Set("User-Agent", e.cfg.Antigravity.UserAgent)
1132
  httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1133
 
1134
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
 
1225
 
1226
  base := strings.TrimSuffix(baseURL, "/")
1227
  if base == "" {
1228
+ base = buildBaseURL(auth, e.cfg)
1229
  }
1230
  path := antigravityGeneratePath
1231
  if stream {
 
1284
  if strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-high") {
1285
  systemInstructionPartsResult := gjson.GetBytes(payload, "request.systemInstruction.parts")
1286
  payload, _ = sjson.SetBytes(payload, "request.systemInstruction.role", "user")
1287
+ payload, _ = sjson.SetBytes(payload, "request.systemInstruction.parts.0.text", e.cfg.Antigravity.SystemInstruction)
1288
+ payload, _ = sjson.SetBytes(payload, "request.systemInstruction.parts.1.text", fmt.Sprintf("Please ignore following [ignore]%s[/ignore]", e.cfg.Antigravity.SystemInstruction))
1289
 
1290
  if systemInstructionPartsResult.Exists() && systemInstructionPartsResult.IsArray() {
1291
  for _, partResult := range systemInstructionPartsResult.Array() {
 
1306
  }
1307
  httpReq.Header.Set("Content-Type", "application/json")
1308
  httpReq.Header.Set("Authorization", "Bearer "+token)
1309
+ httpReq.Header.Set("User-Agent", resolveUserAgent(auth, e.cfg))
1310
  if stream {
1311
  httpReq.Header.Set("Accept", "text/event-stream")
1312
  } else {
 
1395
  return 0, false
1396
  }
1397
 
1398
+ func buildBaseURL(auth *cliproxyauth.Auth, cfg *config.Config) string {
1399
+ if baseURLs := antigravityBaseURLFallbackOrder(auth, cfg); len(baseURLs) > 0 {
1400
  return baseURLs[0]
1401
  }
1402
+ if cfg != nil {
1403
+ return cfg.Antigravity.BaseURLDaily
1404
+ }
1405
+ return ""
1406
  }
1407
 
1408
  func resolveHost(base string) string {
 
1416
  return strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://")
1417
  }
1418
 
1419
+ func resolveUserAgent(auth *cliproxyauth.Auth, cfg *config.Config) string {
1420
  if auth != nil {
1421
  if auth.Attributes != nil {
1422
  if ua := strings.TrimSpace(auth.Attributes["user_agent"]); ua != "" {
 
1429
  }
1430
  }
1431
  }
1432
+ if cfg != nil {
1433
+ return cfg.Antigravity.UserAgent
1434
+ }
1435
+ return ""
1436
  }
1437
 
1438
  func antigravityRetryAttempts(auth *cliproxyauth.Auth, cfg *config.Config) int {
 
1491
  }
1492
  }
1493
 
1494
+ func antigravityBaseURLFallbackOrder(auth *cliproxyauth.Auth, cfg *config.Config) []string {
1495
  if base := resolveCustomAntigravityBaseURL(auth); base != "" {
1496
  return []string{base}
1497
  }
1498
+ if cfg == nil {
1499
+ return []string{}
1500
+ }
1501
  return []string{
1502
+ cfg.Antigravity.BaseURLDaily,
1503
+ cfg.Antigravity.SandboxBaseURLDaily,
1504
+ // cfg.Antigravity.BaseURLProd,
1505
  }
1506
  }
1507
 
internal/runtime/executor/claude_executor.go CHANGED
@@ -69,18 +69,7 @@ func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Au
69
 
70
  // HttpRequest injects Claude credentials into the request and executes it.
71
  func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
72
- if req == nil {
73
- return nil, fmt.Errorf("claude executor: request is nil")
74
- }
75
- if ctx == nil {
76
- ctx = req.Context()
77
- }
78
- httpReq := req.WithContext(ctx)
79
- if err := e.PrepareRequest(httpReq, auth); err != nil {
80
- return nil, err
81
- }
82
- httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
83
- return httpClient.Do(httpReq)
84
  }
85
 
86
  func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
 
69
 
70
  // HttpRequest injects Claude credentials into the request and executes it.
71
  func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
72
+ return ExecuteHttpRequest(ctx, e.cfg, auth, req, e)
 
 
 
 
 
 
 
 
 
 
 
73
  }
74
 
75
  func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
internal/runtime/executor/proxy_helpers.go CHANGED
@@ -2,6 +2,7 @@ package executor
2
 
3
  import (
4
  "context"
 
5
  "net"
6
  "net/http"
7
  "net/url"
@@ -114,3 +115,27 @@ func buildProxyTransport(proxyURL string) *http.Transport {
114
 
115
  return transport
116
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import (
4
  "context"
5
+ "fmt"
6
  "net"
7
  "net/http"
8
  "net/url"
 
115
 
116
  return transport
117
  }
118
+
119
+ // RequestPreparer is an interface that injects credentials into a request.
120
+ type RequestPreparer interface {
121
+ PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error
122
+ }
123
+
124
+ // ExecuteHttpRequest executes an HTTP request using a proxy-aware client.
125
+ // It prepares the request using the provided preparer (if any) and injects the context.
126
+ func ExecuteHttpRequest(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, req *http.Request, preparer RequestPreparer) (*http.Response, error) {
127
+ if req == nil {
128
+ return nil, fmt.Errorf("request is nil")
129
+ }
130
+ if ctx == nil {
131
+ ctx = req.Context()
132
+ }
133
+ httpReq := req.WithContext(ctx)
134
+ if preparer != nil {
135
+ if err := preparer.PrepareRequest(httpReq, auth); err != nil {
136
+ return nil, err
137
+ }
138
+ }
139
+ httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0)
140
+ return httpClient.Do(httpReq)
141
+ }