From 6cb48243759cac5d0b4c91d16463fec4bf7a444b Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 10 Oct 2026 18:54:04 +0800 Subject: [PATCH 1/3] feat(pelican): add persistent background tasks and comparison workbench --- .downstream/native-domains.md | 43 +- .../admin/pelican_background_handler.go | 155 +++++ .../admin/pelican_background_handler_test.go | 60 ++ .../repository/pelican_background_repo.go | 108 +++ ...elican_background_repo_integration_test.go | 73 +++ .../internal/server/routes/pelican_tests.go | 7 + .../codex_gateway_borrow_test_runner.go | 57 +- .../internal/service/pelican_background.go | 273 ++++++++ .../service/pelican_background_test.go | 255 ++++++++ .../internal/service/pelican_test_catalog.go | 11 +- .../service/pelican_test_catalog_test.go | 14 +- frontend/src/api/admin/pelicanTests.ts | 57 ++ .../admin/codex/PelicanResultCard.vue | 54 ++ .../admin/codex/PelicanTaskComposer.vue | 178 +++++ .../__tests__/PelicanTaskComposer.spec.ts | 54 ++ .../__tests__/pelicanTaskSelection.spec.ts | 27 + frontend/src/utils/pelicanTaskSelection.ts | 44 ++ .../admin/CodexPelicanComparisonView.vue | 616 ++++++------------ .../CodexPelicanComparisonView.spec.ts | 342 ++-------- 19 files changed, 1698 insertions(+), 730 deletions(-) create mode 100644 backend/internal/handler/admin/pelican_background_handler.go create mode 100644 backend/internal/handler/admin/pelican_background_handler_test.go create mode 100644 backend/internal/repository/pelican_background_repo.go create mode 100644 backend/internal/repository/pelican_background_repo_integration_test.go create mode 100644 backend/internal/service/pelican_background.go create mode 100644 backend/internal/service/pelican_background_test.go create mode 100644 frontend/src/components/admin/codex/PelicanResultCard.vue create mode 100644 frontend/src/components/admin/codex/PelicanTaskComposer.vue create mode 100644 frontend/src/components/admin/codex/__tests__/PelicanTaskComposer.spec.ts create mode 100644 frontend/src/utils/__tests__/pelicanTaskSelection.spec.ts create mode 100644 frontend/src/utils/pelicanTaskSelection.ts diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index d900aab86864..44fca4557c73 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -213,9 +213,10 @@ candidate owner. `probe_context.subject_role` distinguishes source self-checks from target checks. Self-check results do not qualify business traffic or mutate the source's health/status. -Ranxi reference: v2.10.2 (`d3e43f2de33af9e987cffa511d76dfabbcd749da`). Its five +Ranxi reference: v2.10.3 (`fd1b5ee4eeb20961fbb783fa6f136a1704271e90`). Its fingerprint, gateway-cookie, target-probe and automatic-setup core files are -unchanged from the retained v2.10.0 reference. OAuth search-history compatibility +unchanged from v2.10.2. The scoped update and retained host adaptations are +listed in [the review](upstream-review-ranxi-v2.10.3.md). OAuth search-history compatibility is supplied by the official v0.2.15 integration. Automatic group membership, Mihomo exit rotation and removal of harvesting attempt/concurrency bounds are not adopted; existing account mappings, network routes and protocol switches stay @@ -227,15 +228,35 @@ the independent tests described below. ## Independent Pelican tests -`/admin/pelican-tests` provides local account/model options, explicit generation -with SSE progress, and stored history/detail. Candidates include all platforms, -account types and account states, using local mappings, saved catalogs and -platform defaults. Option reads never refresh credentials or request an upstream -model directory. Each selected account/model receives the fixed Pelican prompt -once. The server chooses its actual existing account sending path, model mapping, -credentials, identity headers, proxy, TLS, protocol and borrowing configuration; -it creates no API Key, group policy or client handshake, and never switches to a -different account on failure. Unsupported text capabilities retain a skip reason. +`/admin/pelican-tests` is a task/result workbench. The new-task composer loads +accounts in pages of 50, retains selection across pages, and offers explicit +current-page/all-matching scopes. Models use locally saved mappings and catalogs; +valid backend defaults are respected and effort defaults to the model default. +Known unsupported choices and duplicate account/model rows must be corrected or +removed before submission; manual unverified models remain explicitly marked. +Bulk configuration preserves incompatible rows. Text capability and generation +use the same local resolver, including the already integrated Cline/Command Code +senders. No new provider protocol is introduced. + +`POST /admin/pelican-tests/tasks` returns 202 and starts a server-owned job. +The GET list, snapshot (`/:id`), paginated summaries (`/:id/results`), full single +result (`/:id/results/:result_id`), and SSE (`/:id/events`) never start generation. +Only `POST /:id/cancel` cancels a background job. Disconnecting observers or +closing the page leaves it running. Repeated client UUIDs with identical normalized +parameters return the existing snapshot; conflicting parameters fail. Restart and +shutdown preserve completed works, mark unfinished work interrupted, and never +replay uncertain model calls. No schema migration is added. + +The original `/tests` SSE endpoint retains request-bound cancellation and its +request/response contract. Both APIs use the same runner, history and execution +capacity. Summary SQL does not load raw responses, answers or HTML; details retain +complete administrator evidence. The page supports 2–4 same/cross-task comparisons, +filters, enlargement, downloads, parameter copying and failed-item retries into a +new reviewed task. History, browsing, reconnecting and comparison are read-only. +The fixed Pelican prompt is preserved. The actual sending path, credentials, +identity, model mapping, proxy, TLS, protocol and configured borrowing remain +owned by each account. No API Key, group policy, model score or fallback account +is manufactured for a test. All batches share ten model execution slots, with one request per account. Nested borrowing probes participate in the same coordination and normal business diff --git a/backend/internal/handler/admin/pelican_background_handler.go b/backend/internal/handler/admin/pelican_background_handler.go new file mode 100644 index 000000000000..ef8a75c20983 --- /dev/null +++ b/backend/internal/handler/admin/pelican_background_handler.go @@ -0,0 +1,155 @@ +package admin + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func (h *PelicanTestHandler) StartTask(c *gin.Context) { + codexGatewayBorrowPrivateHeaders(c) + var request service.CodexGatewayBorrowTestRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, err.Error()) + return + } + var createdBy int64 + if subject, ok := middleware.GetAuthSubjectFromContext(c); ok { + createdBy = subject.UserID + } + task, err := h.runner.StartBackground(c.Request.Context(), createdBy, request, h.catalog) + if err != nil { + codexGatewayBorrowTestError(c, err) + return + } + middleware.SetAuditExtra(c, map[string]any{"result": "pelican_background_test", "task_id": task.ID, "replayed": task.Replayed, "targets": task.Total}) + response.Accepted(c, task) +} + +func pelicanTaskID(c *gin.Context) (string, bool) { + codexGatewayBorrowPrivateHeaders(c) + id, err := uuid.Parse(strings.TrimSpace(c.Param("id"))) + if err != nil { + response.BadRequest(c, "task ID must be a UUID") + return "", false + } + return id.String(), true +} + +func (h *PelicanTestHandler) GetTask(c *gin.Context) { + id, ok := pelicanTaskID(c) + if !ok { + return + } + task, err := h.runner.TaskSnapshot(c.Request.Context(), id) + if err != nil { + codexGatewayBorrowTestError(c, err) + return + } + response.Success(c, task) +} + +func (h *PelicanTestHandler) GetTaskResults(c *gin.Context) { + id, ok := pelicanTaskID(c) + if !ok { + return + } + page, size := response.ParsePagination(c) + accountID, _ := strconv.ParseInt(c.Query("account_id"), 10, 64) + result, err := h.runner.TaskResults(c.Request.Context(), id, service.PelicanResultFilter{Page: page, Size: size, AccountID: accountID, Model: c.Query("model"), Status: c.Query("status")}) + if err != nil { + codexGatewayBorrowTestError(c, err) + return + } + response.Success(c, result) +} + +func (h *PelicanTestHandler) GetTaskResult(c *gin.Context) { + id, ok := pelicanTaskID(c) + if !ok { + return + } + resultID, err := uuid.Parse(c.Param("result_id")) + if err != nil { + response.BadRequest(c, "result ID must be a UUID") + return + } + result, err := h.runner.TaskResult(c.Request.Context(), id, resultID.String()) + if err != nil { + codexGatewayBorrowTestError(c, err) + return + } + response.Success(c, h.preview.DecorateResult(result)) +} + +func (h *PelicanTestHandler) CancelTask(c *gin.Context) { + id, ok := pelicanTaskID(c) + if !ok { + return + } + task, err := h.runner.CancelBackground(c.Request.Context(), id) + if err != nil { + codexGatewayBorrowTestError(c, err) + return + } + middleware.SetAuditExtra(c, map[string]any{"result": "pelican_cancel_requested", "task_id": task.ID}) + response.Success(c, task) +} + +// Read-only progress stream. Disconnecting never owns or cancels execution; +// reconnect starts with a durable snapshot, not a POST or model replay. +func (h *PelicanTestHandler) ObserveTask(c *gin.Context) { + id, ok := pelicanTaskID(c) + if !ok { + return + } + task, err := h.runner.TaskSnapshot(c.Request.Context(), id) + if err != nil { + codexGatewayBorrowTestError(c, err) + return + } + c.Header("Content-Type", "text/event-stream") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + kind := "snapshot" + if task.Status != "pending" && task.Status != "running" { + kind = "task_complete" + } + payload, err := json.Marshal(struct { + Type string `json:"type"` + Task *service.PelicanTaskSnapshot `json:"task"` + }{kind, task}) + if err != nil { + return + } + if _, err = fmt.Fprintf(c.Writer, "data: %s\n\n", payload); err != nil { + return + } + c.Writer.Flush() + if kind == "task_complete" { + return + } + select { + case <-c.Request.Context().Done(): + return + case <-ticker.C: + } + task, err = h.runner.TaskSnapshot(c.Request.Context(), id) + if err != nil { + return + } + } +} diff --git a/backend/internal/handler/admin/pelican_background_handler_test.go b/backend/internal/handler/admin/pelican_background_handler_test.go new file mode 100644 index 000000000000..4fd7311e0d0f --- /dev/null +++ b/backend/internal/handler/admin/pelican_background_handler_test.go @@ -0,0 +1,60 @@ +//go:build unit + +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type pelicanSummaryHandlerRepo struct { + *codexGatewayBorrowHandlerTestRepo +} + +func (r *pelicanSummaryHandlerRepo) GetTaskSnapshot(context.Context, string) (*service.PelicanTaskSnapshot, error) { + copy := *r.task + copy.Results = nil + return &service.PelicanTaskSnapshot{CodexGatewayBorrowTestTask: ©, Counts: map[string]int{"complete": 1}}, nil +} +func (r *pelicanSummaryHandlerRepo) ListTaskResults(context.Context, string, service.PelicanResultFilter) (*service.PelicanResultPage, error) { + return &service.PelicanResultPage{Items: []*service.PelicanResultSummary{{ID: r.result.ID, TaskID: r.task.ID, Status: "complete", HasPreview: true}}, Total: 1, Page: 1, PageSize: 24}, nil +} +func TestPelicanBackgroundReadEndpointsNeverGenerateOrSendRawInProgress(t *testing.T) { + gin.SetMode(gin.TestMode) + legacy, base := newCodexGatewayBorrowHandlerFixture(t) + repo := &pelicanSummaryHandlerRepo{base} + runner := service.NewCodexGatewayBorrowTestRunner(repo, nil, nil) + t.Cleanup(runner.Stop) + h := NewPelicanTestHandler(nil, runner, legacy.preview) + router := gin.New() + router.GET("/tasks/:id", h.GetTask) + router.GET("/tasks/:id/results", h.GetTaskResults) + router.GET("/tasks/:id/results/:result_id", h.GetTaskResult) + router.GET("/tasks/:id/events", h.ObserveTask) + for _, suffix := range []string{"", "/results", "/events"} { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/tasks/"+base.task.ID+suffix, nil)) + require.Equal(t, http.StatusOK, w.Code) + require.NotContains(t, w.Body.String(), "raw_answer") + require.NotContains(t, w.Body.String(), "admin-visible") + require.Equal(t, "private, no-store", w.Header().Get("Cache-Control")) + } + full := httptest.NewRecorder() + router.ServeHTTP(full, httptest.NewRequest(http.MethodGet, "/tasks/"+base.task.ID+"/results/"+base.result.ID, nil)) + require.Equal(t, http.StatusOK, full.Code) + require.Contains(t, full.Body.String(), "admin-visible") + base.task.Status = "running" + ctx, cancel := context.WithCancel(context.Background()) + cancel() + disconnected := httptest.NewRecorder() + router.ServeHTTP(disconnected, httptest.NewRequest(http.MethodGet, "/tasks/"+base.task.ID+"/events", nil).WithContext(ctx)) + require.Equal(t, "running", base.task.Status) + require.Zero(t, base.creates) + require.Zero(t, base.writes) +} diff --git a/backend/internal/repository/pelican_background_repo.go b/backend/internal/repository/pelican_background_repo.go new file mode 100644 index 000000000000..130d3773a23f --- /dev/null +++ b/backend/internal/repository/pelican_background_repo.go @@ -0,0 +1,108 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +var _ service.PelicanTaskReader = (*codexGatewayBorrowTestRepository)(nil) + +func (r *codexGatewayBorrowTestRepository) GetTaskSnapshot(ctx context.Context, id string) (*service.PelicanTaskSnapshot, error) { + task, err := scanCodexGatewayBorrowTestTask(r.db.QueryRowContext(ctx, `SELECT `+codexGatewayBorrowTestTaskColumns+` + FROM codex_gateway_borrow_test_tasks WHERE scope='manual_pelican' AND (id=$1 OR client_task_id=$1) AND expires_at>CURRENT_TIMESTAMP`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, service.ErrCodexGatewayBorrowTestNotFound + } + if err != nil { + return nil, err + } + snapshot := &service.PelicanTaskSnapshot{CodexGatewayBorrowTestTask: task, Counts: make(map[string]int)} + rows, err := r.db.QueryContext(ctx, `SELECT status, COUNT(*), COUNT(*) FILTER (WHERE status='complete' AND octet_length(html)=0) + FROM codex_gateway_borrow_test_results WHERE task_id=$1 GROUP BY status`, task.ID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var status string + var count, noPreview int + if err := rows.Scan(&status, &count, &noPreview); err != nil { + return nil, err + } + snapshot.Counts[status] = count + snapshot.Counts["no_preview"] += noPreview + } + snapshot.Completed = 0 + for status, count := range snapshot.Counts { + if status != "pending" && status != "running" && status != "no_preview" { + snapshot.Completed += count + } + } + return snapshot, rows.Err() +} + +// Keep this projection independent from codexGatewayBorrowTestResultColumns: +// no raw_answer, raw_response, raw_html, html or complete error enters a page. +const pelicanResultSummaryColumns = `r.id,r.task_id,r.ordinal,r.account_id,r.account_name,r.platform,r.model_id,r.effort,r.status, + (r.status='complete' AND octet_length(r.html)>0), + (r.status='incomplete' AND t.error=convert_to('process stopped before the test finished; this task will not be replayed','UTF8')), + r.started_at,r.finished_at,r.generation_started_at,r.duration_ms,r.queue_duration_ms,r.preparation_duration_ms,r.generation_duration_ms,t.expires_at` + +func (r *codexGatewayBorrowTestRepository) ListTaskResults(ctx context.Context, id string, filter service.PelicanResultFilter) (*service.PelicanResultPage, error) { + if filter.Page < 1 { + filter.Page = 1 + } + if filter.Size < 1 { + filter.Size = 24 + } + if filter.Size > 50 { + filter.Size = 50 + } + args := []any{id} + where := []string{"t.scope='manual_pelican'", "t.id=$1", "t.expires_at>CURRENT_TIMESTAMP"} + add := func(clause string, value any) { + args = append(args, value) + where = append(where, fmt.Sprintf(clause, len(args))) + } + if filter.AccountID > 0 { + add("r.account_id=$%d", filter.AccountID) + } + if filter.Model != "" { + add("r.model_id=$%d", filter.Model) + } + switch filter.Status { + case "": + case "no_preview": + where = append(where, "r.status='complete' AND octet_length(r.html)=0") + case "pending", "running", "complete", "failed", "incomplete", "cancelled", "skipped": + add("r.status=$%d", filter.Status) + default: + return nil, service.ErrCodexGatewayBorrowTestInvalidRequest + } + from := ` FROM codex_gateway_borrow_test_results r JOIN codex_gateway_borrow_test_tasks t ON t.id=r.task_id WHERE ` + strings.Join(where, " AND ") + page := &service.PelicanResultPage{Items: []*service.PelicanResultSummary{}, Page: filter.Page, PageSize: filter.Size} + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*)"+from, args...).Scan(&page.Total); err != nil { + return nil, err + } + args = append(args, filter.Size, (filter.Page-1)*filter.Size) + query := "SELECT " + pelicanResultSummaryColumns + from + fmt.Sprintf(" ORDER BY r.ordinal LIMIT $%d OFFSET $%d", len(args)-1, len(args)) + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + item := &service.PelicanResultSummary{} + if err := rows.Scan(&item.ID, &item.TaskID, &item.Ordinal, &item.AccountID, &item.AccountName, &item.Platform, &item.ModelID, &item.Effort, &item.Status, + &item.HasPreview, &item.Interrupted, &item.StartedAt, &item.FinishedAt, &item.GenerationStartedAt, &item.DurationMS, &item.QueueDurationMS, &item.PreparationDurationMS, &item.GenerationDurationMS, &item.ExpiresAt); err != nil { + return nil, err + } + page.Items = append(page.Items, item) + } + return page, rows.Err() +} diff --git a/backend/internal/repository/pelican_background_repo_integration_test.go b/backend/internal/repository/pelican_background_repo_integration_test.go new file mode 100644 index 000000000000..cffc30e098dd --- /dev/null +++ b/backend/internal/repository/pelican_background_repo_integration_test.go @@ -0,0 +1,73 @@ +//go:build integration + +package repository + +import ( + "context" + "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "strings" + "testing" + "time" +) + +func TestPelicanBackgroundSummaryPaginationRetainsFullDetails(t *testing.T) { + ctx := context.Background() + db := codexGatewayBorrowPrivateIntegrationDB(t, ctx) + repo := NewCodexGatewayBorrowTestRepository(db) + reader, ok := repo.(service.PelicanTaskReader) + require.True(t, ok) + task := codexGatewayBorrowIntegrationTask(t, 42) + first := task.Results[0] + for i := 2; i <= 3; i++ { + copy := *first + copy.ID = uuid.NewString() + copy.Ordinal = i + copy.ModelID = first.ModelID + strings.Repeat("x", i) + task.Results = append(task.Results, ©) + } + task.Total = 3 + _, _, err := repo.Create(ctx, task) + require.NoError(t, err) + first.Status = "complete" + first.RawResponse = "RAW-CANARY" + strings.Repeat("large response", 10000) + first.Error = string([]byte{0xff, 0, 1}) + require.NoError(t, repo.SaveResult(ctx, first)) + task.Results[1].Status = "failed" + require.NoError(t, repo.SaveResult(ctx, task.Results[1])) + snapshot, err := reader.GetTaskSnapshot(ctx, task.ClientTaskID) + require.NoError(t, err) + require.Nil(t, snapshot.Results) + require.Equal(t, 1, snapshot.Counts["complete"]) + require.Equal(t, 1, snapshot.Counts["no_preview"]) + require.Equal(t, 1, snapshot.Counts["failed"]) + page, err := reader.ListTaskResults(ctx, task.ID, service.PelicanResultFilter{Page: 1, Size: 2}) + require.NoError(t, err) + require.EqualValues(t, 3, page.Total) + require.Len(t, page.Items, 2) + encoded, err := json.Marshal(page) + require.NoError(t, err) + require.NotContains(t, string(encoded), "RAW-CANARY") + require.NotContains(t, string(encoded), "raw_response") + failed, err := reader.ListTaskResults(ctx, task.ID, service.PelicanResultFilter{Page: 1, Size: 50, Status: "failed"}) + require.NoError(t, err) + require.Len(t, failed.Items, 1) + full, err := repo.GetResult(ctx, first.ID) + require.NoError(t, err) + require.Equal(t, first.RawResponse, full.RawResponse) + require.Equal(t, first.Error, full.Error) + require.NoError(t, repo.MarkInterrupted(ctx, time.Now())) + interrupted, err := reader.ListTaskResults(ctx, task.ID, service.PelicanResultFilter{Status: "incomplete"}) + require.NoError(t, err) + require.Len(t, interrupted.Items, 1) + require.True(t, interrupted.Items[0].Interrupted) + full, err = repo.GetResult(ctx, first.ID) + require.NoError(t, err) + require.Equal(t, "complete", full.Status) + _, err = db.ExecContext(ctx, "UPDATE codex_gateway_borrow_test_tasks SET created_at=$2 - INTERVAL '24 hours',expires_at=$2 WHERE id=$1", task.ID, time.Now().Add(-time.Second)) + require.NoError(t, err) + _, err = reader.GetTaskSnapshot(ctx, task.ID) + require.ErrorIs(t, err, service.ErrCodexGatewayBorrowTestNotFound) +} diff --git a/backend/internal/server/routes/pelican_tests.go b/backend/internal/server/routes/pelican_tests.go index 492d580f167f..520e8b5c965e 100644 --- a/backend/internal/server/routes/pelican_tests.go +++ b/backend/internal/server/routes/pelican_tests.go @@ -9,6 +9,13 @@ func registerPelicanTestRoutes(admin *gin.RouterGroup, h *handler.Handlers) { pelican := admin.Group("/pelican-tests") pelican.GET("/options", h.Admin.PelicanTest.GetOptions) pelican.POST("/options", h.Admin.PelicanTest.GetAccountOptions) + pelican.POST("/tasks", h.Admin.PelicanTest.StartTask) + pelican.GET("/tasks", h.Admin.PelicanTest.ListTests) + pelican.GET("/tasks/:id", h.Admin.PelicanTest.GetTask) + pelican.GET("/tasks/:id/results", h.Admin.PelicanTest.GetTaskResults) + pelican.GET("/tasks/:id/results/:result_id", h.Admin.PelicanTest.GetTaskResult) + pelican.GET("/tasks/:id/events", h.Admin.PelicanTest.ObserveTask) + pelican.POST("/tasks/:id/cancel", h.Admin.PelicanTest.CancelTask) pelican.POST("/tests", h.Admin.PelicanTest.StartTests) pelican.GET("/tests", h.Admin.PelicanTest.ListTests) pelican.GET("/tests/:id", h.Admin.PelicanTest.GetTest) diff --git a/backend/internal/service/codex_gateway_borrow_test_runner.go b/backend/internal/service/codex_gateway_borrow_test_runner.go index b6adbfbc5b26..e1ef40574cfd 100644 --- a/backend/internal/service/codex_gateway_borrow_test_runner.go +++ b/backend/internal/service/codex_gateway_borrow_test_runner.go @@ -126,6 +126,10 @@ type codexGatewayBorrowAccountReader interface { // All batches, including legacy cache-only tests, share ten outbound execution // slots. Account leases surround actual requests rather than route preparation. type CodexGatewayBorrowTestRunner struct { + submissionMu sync.Mutex + backgroundMu sync.Mutex + background map[string]*pelicanBackgroundTask + backgroundRunning sync.WaitGroup repo CodexGatewayBorrowTestRepository generator codexGatewayBorrowPelicanGenerator standaloneGenerator codexGatewayBorrowPelicanGenerator @@ -141,6 +145,7 @@ type CodexGatewayBorrowTestRunner struct { running sync.WaitGroup stopped bool activeTasks map[string]bool + activeCancels map[string]context.CancelFunc now func() time.Time generationContext func(context.Context, time.Duration) (context.Context, context.CancelFunc) } @@ -171,6 +176,9 @@ func (r *CodexGatewayBorrowTestRunner) Stop() { r.stop() r.runMu.Unlock() <-r.cleanupDone + r.submissionMu.Lock() + r.backgroundRunning.Wait() + r.submissionMu.Unlock() r.running.Wait() }) } @@ -255,16 +263,7 @@ func normalizeCodexGatewayBorrowTestRequest(request CodexGatewayBorrowTestReques return request, nil } -// Create commits the task and all pending children before Run can dispatch. -// The repository's unique client UUID is the final guard against concurrent POSTs. -func (r *CodexGatewayBorrowTestRunner) Create(ctx context.Context, createdBy int64, request CodexGatewayBorrowTestRequest) (*CodexGatewayBorrowTestTask, bool, error) { - request, err := normalizeCodexGatewayBorrowTestRequest(request, r.now().UTC()) - if err != nil { - return nil, false, err - } - if err = r.initialize(ctx); err != nil { - return nil, false, err - } +func codexGatewayBorrowTestRequestHash(request CodexGatewayBorrowTestRequest) string { mode := PelicanExecutionModeLegacyCache if request.Standalone { mode = PelicanExecutionModeAccount @@ -281,9 +280,26 @@ func (r *CodexGatewayBorrowTestRunner) Create(ctx context.Context, createdBy int }{request.Targets, request.GenerationTimeoutSeconds, mode}) } hash := sha256.Sum256(raw) + return hex.EncodeToString(hash[:]) +} + +// Create commits the task and all pending children before Run can dispatch. +// The repository's unique client UUID is the final guard against concurrent POSTs. +func (r *CodexGatewayBorrowTestRunner) Create(ctx context.Context, createdBy int64, request CodexGatewayBorrowTestRequest) (*CodexGatewayBorrowTestTask, bool, error) { + request, err := normalizeCodexGatewayBorrowTestRequest(request, r.now().UTC()) + if err != nil { + return nil, false, err + } + if err = r.initialize(ctx); err != nil { + return nil, false, err + } + mode := PelicanExecutionModeLegacyCache + if request.Standalone { + mode = PelicanExecutionModeAccount + } now := r.now().UTC() task := &CodexGatewayBorrowTestTask{ID: uuid.NewString(), ClientTaskID: request.ClientTaskID, - CreatedBy: createdBy, RequestHash: hex.EncodeToString(hash[:]), Status: "pending", + CreatedBy: createdBy, RequestHash: codexGatewayBorrowTestRequestHash(request), Status: "pending", GenerationTimeoutSeconds: request.GenerationTimeoutSeconds, ExecutionMode: mode, Prompt: CodexGatewayBorrowPelicanPrompt, CreatedAt: now, ExpiresAt: now.Add(CodexGatewayBorrowTestTTL), Total: len(request.Targets), Results: make([]*CodexGatewayBorrowTestResult, 0, len(request.Targets))} @@ -307,7 +323,9 @@ func (r *CodexGatewayBorrowTestRunner) Create(ctx context.Context, createdBy int } stored, replayed, err := r.repo.Create(ctx, task) if stored != nil { - stored.Replayed = replayed + copy := *stored + copy.Replayed = replayed + stored = © } return stored, replayed, err } @@ -344,7 +362,7 @@ func (r *CodexGatewayBorrowTestRunner) Run(parent context.Context, task *CodexGa } if r.stopped { r.runMu.Unlock() - return r.finishUnstarted(parent, task, "cancelled", "test runner stopped", emit) + return r.finishUnstarted(parent, task, "incomplete", pelicanInterruptedMessage, emit) } r.running.Add(1) r.activeTasks[task.ID] = true @@ -352,11 +370,18 @@ func (r *CodexGatewayBorrowTestRunner) Run(parent context.Context, task *CodexGa defer func() { r.runMu.Lock() delete(r.activeTasks, task.ID) + delete(r.activeCancels, task.ID) r.runMu.Unlock() r.running.Done() }() ctx, cancel := context.WithDeadline(parent, task.ExpiresAt) defer cancel() + r.runMu.Lock() + if r.activeCancels == nil { + r.activeCancels = make(map[string]context.CancelFunc) + } + r.activeCancels[task.ID] = cancel + r.runMu.Unlock() stopLink := context.AfterFunc(r.stopCtx, cancel) defer stopLink() started := r.now().UTC() @@ -425,6 +450,9 @@ func (r *CodexGatewayBorrowTestRunner) Run(parent context.Context, task *CodexGa } result.Error += "generation budget exhausted" } + if r.stopCtx.Err() != nil && result.Status != "complete" { + result.Status, result.Error, result.PreviewUnavailable = "incomplete", pelicanInterruptedMessage+"\n"+result.Error, "test interrupted" + } writeCtx, writeCancel := context.WithTimeout(context.WithoutCancel(parent), 10*time.Second) writeErr := r.repo.SaveResult(writeCtx, result) writeCancel() @@ -443,6 +471,9 @@ func (r *CodexGatewayBorrowTestRunner) Run(parent context.Context, task *CodexGa finished := r.now().UTC() task.FinishedAt, task.Completed = &finished, len(task.Results) task.Status = codexGatewayBorrowTaskStatus(task.Results, ctx.Err() != nil) + if r.stopCtx.Err() != nil && task.Status != "complete" { + task.Status, task.Error = "incomplete", pelicanInterruptedMessage + } if len(persistenceErrors) > 0 { task.Status, task.Error = "incomplete", strings.Join(persistenceErrors, "\n") } diff --git a/backend/internal/service/pelican_background.go b/backend/internal/service/pelican_background.go new file mode 100644 index 000000000000..8b2f47755a51 --- /dev/null +++ b/backend/internal/service/pelican_background.go @@ -0,0 +1,273 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log/slog" + "slices" + "strings" + "time" +) + +type PelicanResultSummary struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Ordinal int `json:"ordinal"` + AccountID int64 `json:"account_id"` + AccountName string `json:"account_name"` + Platform string `json:"platform"` + ModelID string `json:"model_id"` + Effort string `json:"effort"` + Status string `json:"status"` + Phase string `json:"phase,omitempty"` + HasPreview bool `json:"has_preview"` + Interrupted bool `json:"interrupted"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + GenerationStartedAt *time.Time `json:"generation_started_at,omitempty"` + DurationMS int64 `json:"duration_ms"` + QueueDurationMS int64 `json:"queue_duration_ms"` + PreparationDurationMS int64 `json:"preparation_duration_ms"` + GenerationDurationMS int64 `json:"generation_duration_ms"` + ExpiresAt time.Time `json:"expires_at"` +} + +type PelicanTaskSnapshot struct { + *CodexGatewayBorrowTestTask + Counts map[string]int `json:"counts"` +} + +type PelicanResultPage struct { + Items []*PelicanResultSummary `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type PelicanResultFilter struct { + Page, Size int + AccountID int64 + Model, Status string +} + +// Production reads select metadata explicitly. They never load all raw works +// just to discard them before serializing a list or progress event. +type PelicanTaskReader interface { + GetTaskSnapshot(context.Context, string) (*PelicanTaskSnapshot, error) + ListTaskResults(context.Context, string, PelicanResultFilter) (*PelicanResultPage, error) +} + +type pelicanBackgroundTask struct { + cancel context.CancelFunc + phases map[string]*PelicanResultSummary +} + +const pelicanInterruptedMessage = "process stopped before the test finished; this task will not be replayed" + +func pelicanResultSummary(result *CodexGatewayBorrowTestResult, phase string) *PelicanResultSummary { + return &PelicanResultSummary{ID: result.ID, TaskID: result.TaskID, Ordinal: result.Ordinal, + AccountID: result.AccountID, AccountName: result.AccountName, Platform: result.Platform, + ModelID: result.ModelID, Effort: result.Effort, Status: result.Status, Phase: phase, + HasPreview: result.Status == "complete" && result.HTML != "", Interrupted: strings.Contains(result.Error, pelicanInterruptedMessage), + StartedAt: result.StartedAt, FinishedAt: result.FinishedAt, GenerationStartedAt: result.GenerationStartedAt, + DurationMS: result.DurationMS, QueueDurationMS: result.QueueDurationMS, + PreparationDurationMS: result.PreparationDurationMS, GenerationDurationMS: result.GenerationDurationMS, ExpiresAt: result.ExpiresAt} +} + +func (r *CodexGatewayBorrowTestRunner) StartBackground(ctx context.Context, createdBy int64, request CodexGatewayBorrowTestRequest, catalog *PelicanTestCatalog) (*PelicanTaskSnapshot, error) { + request.Standalone = true + normalized, err := normalizeCodexGatewayBorrowTestRequest(request, r.now().UTC()) + if err != nil { + return nil, err + } + // Legacy normalization deduplicates; the new workbench must reject conflicting + // efforts instead of silently dropping a later row. + if len(normalized.Targets) != len(request.Targets) { + return nil, fmt.Errorf("%w: one effort per account/model; edit the existing combination", ErrCodexGatewayBorrowTestInvalidRequest) + } + if err := ctx.Err(); err != nil { + return nil, err + } + // Once a valid submission is accepted, its database commit and handoff also + // survive disconnects; no request context is retained by the server-owned job. + submissionCtx, cancelSubmission := context.WithTimeout(r.stopCtx, 30*time.Second) + defer cancelSubmission() + ctx = submissionCtx + // Serialize submission with cancellation so an immediate Stop cannot miss an + // accepted task between its database commit and background registration. + r.submissionMu.Lock() + defer r.submissionMu.Unlock() + if r.stopCtx.Err() != nil { + return nil, errors.New("test runner stopped") + } + // Replays compare their immutable normalized parameters before consulting + // today's account capabilities, and read summaries rather than every raw work. + if existing, readErr := r.TaskSnapshot(ctx, normalized.ClientTaskID); readErr == nil { + if existing.RequestHash != codexGatewayBorrowTestRequestHash(normalized) { + return nil, ErrCodexGatewayBorrowTestReplayConflict + } + existing.Replayed = true + return existing, nil + } else if !errors.Is(readErr, ErrCodexGatewayBorrowTestNotFound) { + return nil, readErr + } + if catalog == nil { + return nil, errors.New("pelican catalog is unavailable") + } + if err := catalog.ValidateTargets(ctx, normalized.Targets); err != nil { + return nil, err + } + task, replayed, err := r.Create(ctx, createdBy, normalized) + if err != nil { + return nil, err + } + if replayed { + snapshot, err := r.TaskSnapshot(ctx, task.ID) + if snapshot != nil { + snapshot.Replayed = true + } + return snapshot, err + } + copyTask := *task + copyTask.Results = nil + snapshot := &PelicanTaskSnapshot{CodexGatewayBorrowTestTask: ©Task, Counts: map[string]int{"pending": task.Total}} + // No HTTP request values, Gin writer or observer cancellation enters the run. + runCtx, cancel := context.WithCancel(r.stopCtx) + control := &pelicanBackgroundTask{cancel: cancel, phases: make(map[string]*PelicanResultSummary)} + r.backgroundMu.Lock() + if r.background == nil { + r.background = make(map[string]*pelicanBackgroundTask) + } + r.background[task.ID] = control + r.backgroundMu.Unlock() + r.backgroundRunning.Add(1) + go func() { + defer r.backgroundRunning.Done() + defer cancel() + runErr := r.Run(runCtx, task, func(event CodexGatewayBorrowTestEvent) { + if event.Result == nil { + return + } + summary := pelicanResultSummary(event.Result, event.Phase) + r.backgroundMu.Lock() + control.phases[summary.ID] = summary + r.backgroundMu.Unlock() + }) + if runErr != nil { + slog.Error("pelican_background_task_failed", "task_id", task.ID, "error", runErr) + if !errors.Is(runErr, ErrCodexGatewayBorrowTestNotFound) { + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 10*time.Second) + _ = r.repo.FinishTask(writeCtx, task.ID, "incomplete", runErr.Error(), r.now().UTC()) + cancelWrite() + } + } + r.backgroundMu.Lock() + delete(r.background, task.ID) + r.backgroundMu.Unlock() + }() + return snapshot, nil +} + +func (r *CodexGatewayBorrowTestRunner) TaskSnapshot(ctx context.Context, id string) (*PelicanTaskSnapshot, error) { + if err := r.initialize(ctx); err != nil { + return nil, err + } + reader, ok := r.repo.(PelicanTaskReader) + if !ok { + return nil, errors.New("pelican summary repository is unavailable") + } + return reader.GetTaskSnapshot(ctx, id) +} + +func (r *CodexGatewayBorrowTestRunner) TaskResults(ctx context.Context, id string, filter PelicanResultFilter) (*PelicanResultPage, error) { + if _, err := r.TaskSnapshot(ctx, id); err != nil { + return nil, err + } + reader, ok := r.repo.(PelicanTaskReader) + if !ok { + return nil, errors.New("pelican summary repository is unavailable") + } + page, err := reader.ListTaskResults(ctx, id, filter) + if err != nil { + return nil, err + } + r.backgroundMu.Lock() + defer r.backgroundMu.Unlock() + if task := r.background[id]; task != nil { + for i, row := range page.Items { + if latest := task.phases[row.ID]; latest != nil && (row.Status == "pending" || row.Status == "running") { + copy := *latest + page.Items[i] = © + } + } + } + return page, nil +} + +func (r *CodexGatewayBorrowTestRunner) CancelBackground(ctx context.Context, id string) (*PelicanTaskSnapshot, error) { + r.submissionMu.Lock() + defer r.submissionMu.Unlock() + snapshot, err := r.TaskSnapshot(ctx, id) + if err != nil { + return nil, err + } + id = snapshot.ID + r.backgroundMu.Lock() + if task := r.background[id]; task != nil { + task.cancel() + } + r.backgroundMu.Unlock() + r.runMu.Lock() + if cancel := r.activeCancels[id]; cancel != nil { + cancel() + } + r.runMu.Unlock() + return r.TaskSnapshot(ctx, id) +} + +func (r *CodexGatewayBorrowTestRunner) TaskResult(ctx context.Context, taskID, resultID string) (*CodexGatewayBorrowTestResult, error) { + if err := r.initialize(ctx); err != nil { + return nil, err + } + result, err := r.repo.GetResult(ctx, resultID) + if err != nil { + return nil, err + } + if result.TaskID != taskID { + return nil, ErrCodexGatewayBorrowTestNotFound + } + return result, nil +} + +// Validate exactly the capabilities used again by the native generator. Reads +// are local; paused/error/limited accounts stay explicitly selectable. +func (s *PelicanTestCatalog) ValidateTargets(ctx context.Context, targets []CodexGatewayBorrowTestTarget) error { + ids := make([]int64, 0, len(targets)) + for _, target := range targets { + ids = append(ids, target.AccountID) + } + if len(ids) == 0 || len(ids) > pelicanTestCatalogMaxAccounts { + return ErrCodexGatewayBorrowTestInvalidRequest + } + accounts, err := s.accounts.GetByIDs(ctx, ids) + if err != nil { + return err + } + byID := make(map[int64]*Account, len(accounts)) + for _, account := range accounts { + if account != nil { + byID[account.ID] = account + } + } + for _, target := range targets { + option := PelicanTestModelOptions(byID[target.AccountID], target.ModelID) + if !option.TextSupported { + return fmt.Errorf("%w: account %d / %s: %s", ErrCodexGatewayBorrowTestInvalidRequest, target.AccountID, target.ModelID, option.CapabilityReason) + } + if target.Effort != "" && !slices.Contains(option.ReasoningEfforts, strings.ToLower(strings.TrimSpace(target.Effort))) { + return fmt.Errorf("%w: account %d / %s does not support effort %q", ErrCodexGatewayBorrowTestInvalidRequest, target.AccountID, target.ModelID, target.Effort) + } + } + return nil +} diff --git a/backend/internal/service/pelican_background_test.go b/backend/internal/service/pelican_background_test.go new file mode 100644 index 000000000000..5f23325f48d6 --- /dev/null +++ b/backend/internal/service/pelican_background_test.go @@ -0,0 +1,255 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type pelicanBackgroundRepo struct { + CodexGatewayBorrowTestRepository + mu sync.Mutex + tasks map[string]*CodexGatewayBorrowTestTask + fullReads int +} + +func clonePelicanTestTask(task *CodexGatewayBorrowTestTask) *CodexGatewayBorrowTestTask { + copy := *task + copy.Results = make([]*CodexGatewayBorrowTestResult, 0, len(task.Results)) + for _, row := range task.Results { + item := *row + copy.Results = append(copy.Results, &item) + } + return © +} +func (r *pelicanBackgroundRepo) Create(_ context.Context, task *CodexGatewayBorrowTestTask) (*CodexGatewayBorrowTestTask, bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, old := range r.tasks { + if old.ClientTaskID == task.ClientTaskID { + if old.RequestHash != task.RequestHash { + return nil, true, ErrCodexGatewayBorrowTestReplayConflict + } + return clonePelicanTestTask(old), true, nil + } + } + r.tasks[task.ID] = clonePelicanTestTask(task) + return clonePelicanTestTask(task), false, nil +} +func (r *pelicanBackgroundRepo) Get(_ context.Context, id string) (*CodexGatewayBorrowTestTask, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.fullReads++ + if task := r.tasks[id]; task != nil { + return clonePelicanTestTask(task), nil + } + return nil, ErrCodexGatewayBorrowTestNotFound +} +func (r *pelicanBackgroundRepo) GetResult(_ context.Context, id string) (*CodexGatewayBorrowTestResult, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, task := range r.tasks { + for _, result := range task.Results { + if result.ID == id { + copy := *result + return ©, nil + } + } + } + return nil, ErrCodexGatewayBorrowTestNotFound +} +func (r *pelicanBackgroundRepo) StartTask(_ context.Context, id string, now time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + r.tasks[id].Status = "running" + r.tasks[id].StartedAt = &now + return nil +} +func (r *pelicanBackgroundRepo) SaveResult(_ context.Context, item *CodexGatewayBorrowTestResult) error { + r.mu.Lock() + defer r.mu.Unlock() + task := r.tasks[item.TaskID] + for i, row := range task.Results { + if row.ID == item.ID { + copy := *item + task.Results[i] = © + } + } + task.Completed = 0 + for _, row := range task.Results { + if row.Status != "pending" && row.Status != "running" { + task.Completed++ + } + } + return nil +} +func (r *pelicanBackgroundRepo) FinishTask(_ context.Context, id, status, message string, now time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + task := r.tasks[id] + task.Status, task.Error, task.FinishedAt = status, message, &now + return nil +} +func (r *pelicanBackgroundRepo) MarkInterrupted(_ context.Context, now time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + for _, task := range r.tasks { + if task.Status == "pending" || task.Status == "running" { + task.Status, task.Error = "incomplete", pelicanInterruptedMessage + for _, row := range task.Results { + if row.Status == "pending" || row.Status == "running" { + row.Status, row.Error, row.FinishedAt = "incomplete", pelicanInterruptedMessage, &now + } + } + } + } + return nil +} +func (r *pelicanBackgroundRepo) CleanupExpired(context.Context, time.Time) error { return nil } +func (r *pelicanBackgroundRepo) GetTaskSnapshot(_ context.Context, id string) (*PelicanTaskSnapshot, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, task := range r.tasks { + if task.ID == id || task.ClientTaskID == id { + copy := *task + copy.Results = nil + counts := map[string]int{} + for _, row := range task.Results { + counts[row.Status]++ + if row.Status == "complete" && row.HTML == "" { + counts["no_preview"]++ + } + } + return &PelicanTaskSnapshot{CodexGatewayBorrowTestTask: ©, Counts: counts}, nil + } + } + return nil, ErrCodexGatewayBorrowTestNotFound +} +func (r *pelicanBackgroundRepo) ListTaskResults(_ context.Context, id string, filter PelicanResultFilter) (*PelicanResultPage, error) { + r.mu.Lock() + defer r.mu.Unlock() + page := &PelicanResultPage{Page: 1, PageSize: 50, Items: []*PelicanResultSummary{}} + for _, row := range r.tasks[id].Results { + page.Items = append(page.Items, pelicanResultSummary(row, "")) + } + page.Total = int64(len(page.Items)) + return page, nil +} + +func newPelicanBackgroundFixture(t *testing.T, generate func(context.Context, int64, string, string) (*CodexGatewayBorrowPelicanResult, error)) (*CodexGatewayBorrowTestRunner, *pelicanBackgroundRepo, *PelicanTestCatalog) { + t.Helper() + repo := &pelicanBackgroundRepo{tasks: map[string]*CodexGatewayBorrowTestTask{}} + runner := newCodexGatewayBorrowTestRunner(repo, nil, nil) + runner.SetStandaloneGenerator(codexGatewayBorrowFakeGenerator{generate: generate}) + t.Cleanup(runner.Stop) + catalog := &PelicanTestCatalog{now: time.Now, accounts: &pelicanTestCatalogReaderStub{accounts: []*Account{borrowCoreAccount(1)}}} + return runner, repo, catalog +} + +func TestPelicanBackgroundSurvivesCallerAndReplaysOnlySnapshot(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + var calls atomic.Int32 + runner, repo, catalog := newPelicanBackgroundFixture(t, func(ctx context.Context, _ int64, _ string, effort string) (*CodexGatewayBorrowPelicanResult, error) { + require.Empty(t, effort) + calls.Add(1) + close(entered) + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return &CodexGatewayBorrowPelicanResult{Status: "complete", RawAnswer: "saved work", RawResponse: "complete raw response"}, nil + }) + caller, cancel := context.WithCancel(context.Background()) + request := CodexGatewayBorrowTestRequest{ClientTaskID: codexGatewayBorrowV7(t), Targets: []CodexGatewayBorrowTestTarget{{AccountID: 1, ModelID: "gpt-6-astra"}}} + task, err := runner.StartBackground(caller, 7, request, catalog) + require.NoError(t, err) + <-entered + cancel() + replay, err := runner.StartBackground(context.Background(), 7, request, catalog) + require.NoError(t, err) + require.True(t, replay.Replayed) + require.Equal(t, task.ID, replay.ID) + require.EqualValues(t, 1, calls.Load()) + request.GenerationTimeoutSeconds = 900 + _, err = runner.StartBackground(context.Background(), 7, request, catalog) + require.ErrorIs(t, err, ErrCodexGatewayBorrowTestReplayConflict) + page, err := runner.TaskResults(context.Background(), task.ID, PelicanResultFilter{}) + require.NoError(t, err) + require.Len(t, page.Items, 1) + raw, err := json.Marshal(page) + require.NoError(t, err) + require.NotContains(t, string(raw), "raw_response") + require.NotContains(t, string(raw), "raw_answer") + close(release) + require.Eventually(t, func() bool { + snapshot, _ := runner.TaskSnapshot(context.Background(), task.ID) + return snapshot.Status == "complete" + }, time.Second, time.Millisecond) + result, err := runner.TaskResult(context.Background(), task.ID, page.Items[0].ID) + require.NoError(t, err) + require.Equal(t, "complete raw response", result.RawResponse) + require.Contains(t, result.HTML, "saved work") + repo.mu.Lock() + require.Zero(t, repo.fullReads) + repo.mu.Unlock() +} + +func TestPelicanBackgroundExplicitCancelAndShutdownInterrupt(t *testing.T) { + for _, shutdown := range []bool{false, true} { + t.Run(map[bool]string{false: "explicit_cancel", true: "shutdown"}[shutdown], func(t *testing.T) { + entered := make(chan struct{}) + runner, _, catalog := newPelicanBackgroundFixture(t, func(ctx context.Context, _ int64, _ string, _ string) (*CodexGatewayBorrowPelicanResult, error) { + close(entered) + <-ctx.Done() + return &CodexGatewayBorrowPelicanResult{RawAnswer: "partial work", RawResponse: "partial response"}, ctx.Err() + }) + task, err := runner.StartBackground(context.Background(), 7, CodexGatewayBorrowTestRequest{ClientTaskID: codexGatewayBorrowV7(t), Targets: []CodexGatewayBorrowTestTarget{{AccountID: 1, ModelID: "gpt-6-astra"}}}, catalog) + require.NoError(t, err) + <-entered + expected := "cancelled" + if shutdown { + runner.Stop() + expected = "incomplete" + } else { + _, err = runner.CancelBackground(context.Background(), task.ID) + require.NoError(t, err) + } + require.Eventually(t, func() bool { + snapshot, _ := runner.TaskSnapshot(context.Background(), task.ID) + return snapshot.Status == expected + }, time.Second, time.Millisecond) + page, err := runner.TaskResults(context.Background(), task.ID, PelicanResultFilter{}) + require.NoError(t, err) + result, err := runner.TaskResult(context.Background(), task.ID, page.Items[0].ID) + require.NoError(t, err) + require.Equal(t, "partial work", result.RawAnswer) + if shutdown { + require.Contains(t, result.Error, pelicanInterruptedMessage) + } + }) + } +} + +func TestPelicanBackgroundRejectsDuplicateAndUnsupportedBeforeCreating(t *testing.T) { + runner, repo, catalog := newPelicanBackgroundFixture(t, func(context.Context, int64, string, string) (*CodexGatewayBorrowPelicanResult, error) { + t.Error("must not generate") + return nil, errors.New("unexpected") + }) + for _, targets := range [][]CodexGatewayBorrowTestTarget{ + {{AccountID: 1, ModelID: "gpt-6-astra", Effort: "low"}, {AccountID: 1, ModelID: "gpt-6-astra", Effort: "high"}}, + {{AccountID: 1, ModelID: "codex-auto-review"}}, + } { + _, err := runner.StartBackground(context.Background(), 7, CodexGatewayBorrowTestRequest{ClientTaskID: codexGatewayBorrowV7(t), Targets: targets}, catalog) + require.ErrorIs(t, err, ErrCodexGatewayBorrowTestInvalidRequest) + } + require.Empty(t, repo.tasks) +} diff --git a/backend/internal/service/pelican_test_catalog.go b/backend/internal/service/pelican_test_catalog.go index ce3dc7ae4c20..f01a87135160 100644 --- a/backend/internal/service/pelican_test_catalog.go +++ b/backend/internal/service/pelican_test_catalog.go @@ -137,7 +137,7 @@ func pelicanTestAccountOptions(account *Account) PelicanTestAccountOption { ProxyID: account.ProxyID, RateLimitedUntil: account.RateLimitResetAt, OverloadUntil: account.OverloadUntil, TempUnschedulableUntil: account.TempUnschedulableUntil, TempUnschedulableReason: account.TempUnschedulableReason, ErrorMessage: account.ErrorMessage, CapabilityReason: pelicanTestAccountCapabilityReason(account), - ManualModelAllowed: true, Models: []PelicanTestModelOption{}, + ManualModelAllowed: pelicanTestAccountCapabilityReason(account) == "", Models: []PelicanTestModelOption{}, } if account.Proxy != nil { option.ProxyName = account.Proxy.Name @@ -194,7 +194,7 @@ func pelicanTestLocalModelIDs(account *Account) []string { func pelicanTestPlatformModelIDs(account *Account) []string { switch account.Platform { - case PlatformKimi, PlatformZhipu: + case PlatformKimi, PlatformZhipu, PlatformCline, PlatformCommandCode: return nil // These platforms publish no local default catalog. case PlatformDeepseek: return []string{"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-flash"} @@ -223,7 +223,7 @@ func pelicanTestAccountCapabilityReason(account *Account) string { return "TypeSafe System One provides a structured native response and does not support arbitrary HTML text generation" } switch account.Platform { - case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformCline, PlatformCommandCode: default: return fmt.Sprintf("platform %q has no text generation sender", account.Platform) } @@ -239,7 +239,7 @@ func pelicanTestAccountCapabilityReason(account *Account) string { supported = account.Type == AccountTypeAPIKey || account.Type == AccountTypeOAuth case PlatformAntigravity: supported = account.Type == AccountTypeOAuth || account.Type == AccountTypeUpstream - case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformCline, PlatformCommandCode: supported = account.Type == AccountTypeAPIKey } if !supported { @@ -406,8 +406,5 @@ func PelicanTestModelOptions(account *Account, model string) PelicanTestModelOpt if option.ReasoningEfforts == nil { option.ReasoningEfforts = []string{} } - if slices.Contains(option.ReasoningEfforts, "high") { - option.DefaultEffort = "high" - } return option } diff --git a/backend/internal/service/pelican_test_catalog_test.go b/backend/internal/service/pelican_test_catalog_test.go index 993f35a0a914..0f0d452fcb9a 100644 --- a/backend/internal/service/pelican_test_catalog_test.go +++ b/backend/internal/service/pelican_test_catalog_test.go @@ -86,9 +86,9 @@ func TestPelicanTestCatalogUsesMappingAndSourceBoundLocalMetadataWithoutMutation require.NotContains(t, byID, "gpt-6-*") require.NotContains(t, byID, "not-mapped") require.Equal(t, "gpt-6.1-sol", byID["sol-alias"].UpstreamModel) - require.Equal(t, "high", byID["sol-alias"].DefaultEffort) + require.Empty(t, byID["sol-alias"].DefaultEffort) require.Equal(t, []string{"low", "high"}, byID["custom"].ReasoningEfforts) - require.Equal(t, "high", byID["custom"].DefaultEffort) + require.Empty(t, byID["custom"].DefaultEffort) require.False(t, byID["media"].TextSupported) require.Contains(t, byID["media"].CapabilityReason, "gpt-image-2") afterCredentials, err := json.Marshal(account.Credentials) @@ -123,6 +123,8 @@ func TestPelicanTestCatalogDefaultsNativeCapabilitiesAndSpecificUnsupportedReaso {"deepseek", PlatformDeepseek, AccountTypeAPIKey, "deepseek-v4-pro", false}, {"minimax", PlatformMiniMax, AccountTypeAPIKey, "MiniMax-M3", false}, {"opencode", PlatformOpenCodeGo, AccountTypeAPIKey, DefaultOpenCodeGoTestModel, false}, + {"cline", PlatformCline, AccountTypeAPIKey, "gpt-6.1-sol", false}, + {"command code", PlatformCommandCode, AccountTypeAPIKey, "gpt-6.1-sol", false}, } { t.Run(test.name, func(t *testing.T) { account := &Account{Platform: test.platform, Type: test.kind} @@ -130,7 +132,7 @@ func TestPelicanTestCatalogDefaultsNativeCapabilitiesAndSpecificUnsupportedReaso require.True(t, option.TextSupported, option.CapabilityReason) if test.high { require.Contains(t, option.ReasoningEfforts, "high") - require.Equal(t, "high", option.DefaultEffort) + require.Empty(t, option.DefaultEffort) } else { require.Empty(t, option.DefaultEffort, "models without high keep their normal default") } @@ -155,7 +157,7 @@ func TestPelicanTestCatalogPassthroughAndDeclaredNoTextUseEffectiveModel(t *test option := PelicanTestModelOptions(account, "gpt-6.1-sol") require.Equal(t, "gpt-6.1-sol", option.UpstreamModel) require.True(t, option.TextSupported) - require.Equal(t, "high", option.DefaultEffort) + require.Empty(t, option.DefaultEffort) account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{ SourceIdentity: UpstreamModelMetadataSourceIdentity(account), Models: map[string]UpstreamModelMetadata{ "gpt-6.1-sol": {ID: "gpt-6.1-sol", InputModalities: []string{"image"}}, @@ -174,7 +176,7 @@ func TestPelicanTestCatalogNativeUpstreamAndAdaptiveUseBusinessEffortCapabilitie require.True(t, option.TextSupported, option.CapabilityReason) require.Equal(t, "claude-opus-5-5", option.UpstreamModel, "native upstream forwarding preserves the request model") require.Contains(t, option.ReasoningEfforts, "high") - require.Equal(t, "high", option.DefaultEffort) + require.Empty(t, option.DefaultEffort) adaptive := &Account{Platform: PlatformKimi, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_protocol": APIProtocolAdaptive}} reasoning := true @@ -185,7 +187,7 @@ func TestPelicanTestCatalogNativeUpstreamAndAdaptiveUseBusinessEffortCapabilitie }) option = PelicanTestModelOptions(adaptive, "kimi-k2.5") require.Equal(t, []string{"medium", "high"}, option.ReasoningEfforts, "single native request can use the declared effort on adaptive accounts") - require.Equal(t, "high", option.DefaultEffort) + require.Empty(t, option.DefaultEffort) adaptive.Credentials["api_protocol"] = APIProtocolAnthropic option = PelicanTestModelOptions(adaptive, "claude-opus-5-5") require.True(t, option.TextSupported) diff --git a/frontend/src/api/admin/pelicanTests.ts b/frontend/src/api/admin/pelicanTests.ts index bb0372ab43ef..4b160b76cb2f 100644 --- a/frontend/src/api/admin/pelicanTests.ts +++ b/frontend/src/api/admin/pelicanTests.ts @@ -152,7 +152,64 @@ export async function streamPelicanTests( } } +export interface PelicanResultSummary { + id: string; task_id: string; ordinal: number; account_id: number; account_name: string + platform: string; model_id: string; effort: string; status: string; phase?: PelicanPhase + has_preview: boolean; interrupted: boolean; started_at?: string; finished_at?: string + generation_started_at?: string; duration_ms: number; queue_duration_ms: number + preparation_duration_ms: number; generation_duration_ms: number; expires_at: string +} +export interface PelicanTaskSnapshot extends PelicanTestTask { prompt: string; counts: Record } +export interface PelicanResultPage { items: PelicanResultSummary[]; total: number; page: number; page_size: number } +export interface PelicanResultFilter { page?: number; page_size?: number; account_id?: number; model?: string; status?: string } +export interface PelicanTaskEvent { type: 'snapshot' | 'task_complete'; task: PelicanTaskSnapshot } + +export async function observePelicanTask(id: string, onEvent: (event: PelicanTaskEvent) => void, signal: AbortSignal): Promise { + const token = localStorage.getItem('auth_token') + const response = await fetch(buildApiUrl(`${basePath}/tasks/${encodeURIComponent(id)}/events`), { + method: 'GET', credentials: 'include', signal, + headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), Accept: 'text/event-stream', [ADMIN_UI_REQUEST_HEADER]: '1' } + }) + if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`) + if (!response.body) throw new Error('Task observation has no response body') + const reader = response.body.getReader(), decoder = new TextDecoder() + let buffer = '', complete = false + const abort = () => { void reader.cancel().catch(() => {}) } + signal.addEventListener('abort', abort, { once: true }) + try { + while (!complete && !signal.aborted) { + const { value, done } = await reader.read() + if (signal.aborted) return + buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done }) + let boundary: RegExpExecArray | null + while ((boundary = /\r?\n\r?\n/.exec(buffer))) { + const block = buffer.slice(0, boundary.index) + buffer = buffer.slice(boundary.index + boundary[0].length) + const data = block.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n') + if (!data) continue + const event = JSON.parse(data) as PelicanTaskEvent + if (event.type !== 'snapshot' && event.type !== 'task_complete') continue + onEvent(event) + if (event.type === 'task_complete') { complete = true; break } + } + if (done) break + } + if (!complete && !signal.aborted) throw new Error('Task observation disconnected') + } finally { + signal.removeEventListener('abort', abort) + await reader.cancel().catch(() => {}) + reader.releaseLock() + } +} + export const pelicanTestsAPI = { + startTask: async (request: PelicanTestRequest, signal?: AbortSignal) => (await apiClient.post(`${basePath}/tasks`, request, { signal })).data, + listTasks: async (page = 1, signal?: AbortSignal) => (await apiClient.get(`${basePath}/tasks`, { params: { page, page_size: 12 }, signal })).data, + getTask: async (id: string, signal?: AbortSignal) => (await apiClient.get(`${basePath}/tasks/${encodeURIComponent(id)}`, { signal })).data, + getResults: async (id: string, filters: PelicanResultFilter = {}, signal?: AbortSignal) => (await apiClient.get(`${basePath}/tasks/${encodeURIComponent(id)}/results`, { params: { page: 1, page_size: 24, ...filters }, signal })).data, + getResult: async (task: string, id: string, signal?: AbortSignal) => (await apiClient.get(`${basePath}/tasks/${encodeURIComponent(task)}/results/${encodeURIComponent(id)}`, { signal })).data, + cancelTask: async (id: string) => (await apiClient.post(`${basePath}/tasks/${encodeURIComponent(id)}/cancel`)).data, + observeTask: observePelicanTask, getOptions: async (signal?: AbortSignal) => (await apiClient.get(`${basePath}/options`, { signal })).data, getAccountOptions: async (accountIds: number[], signal?: AbortSignal) => (await apiClient.post(`${basePath}/options`, { account_ids: accountIds }, { signal })).data, listTests: async (page = 1, signal?: AbortSignal) => (await apiClient.get(`${basePath}/tests`, { params: { page, size: 12 }, signal })).data, diff --git a/frontend/src/components/admin/codex/PelicanResultCard.vue b/frontend/src/components/admin/codex/PelicanResultCard.vue new file mode 100644 index 000000000000..64fadca25f97 --- /dev/null +++ b/frontend/src/components/admin/codex/PelicanResultCard.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/src/components/admin/codex/PelicanTaskComposer.vue b/frontend/src/components/admin/codex/PelicanTaskComposer.vue new file mode 100644 index 000000000000..363e04f6abd3 --- /dev/null +++ b/frontend/src/components/admin/codex/PelicanTaskComposer.vue @@ -0,0 +1,178 @@ + + + diff --git a/frontend/src/components/admin/codex/__tests__/PelicanTaskComposer.spec.ts b/frontend/src/components/admin/codex/__tests__/PelicanTaskComposer.spec.ts new file mode 100644 index 000000000000..50d011045444 --- /dev/null +++ b/frontend/src/components/admin/codex/__tests__/PelicanTaskComposer.spec.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import PelicanTaskComposer from '../PelicanTaskComposer.vue' +import Pagination from '@/components/common/Pagination.vue' +const mocks = vi.hoisted(() => ({ list: vi.fn(), options: vi.fn(), groups: vi.fn() })) +vi.mock('@/api/admin/accounts', () => ({ accountsAPI: { list: mocks.list } })) +vi.mock('@/api/admin/groups', () => ({ groupsAPI: { getAllIncludingInactive: mocks.groups } })) +vi.mock('@/api/admin/pelicanTests', async original => ({ ...(await original()), pelicanTestsAPI: { getAccountOptions: mocks.options } })) +vi.mock('vue-i18n', async original => ({ ...(await original()), useI18n: () => ({ locale: { value: 'zh' }, t: (key: string) => key }) })) +const wrappers: ReturnType[] = [] +beforeEach(() => { + vi.clearAllMocks() + mocks.groups.mockResolvedValue([]) + mocks.list.mockImplementation(async (page: number) => ({ items: Array.from({ length: 50 }, (_, index) => ({ id: (page - 1) * 50 + index + 1, name: `Account ${(page - 1) * 50 + index + 1}`, platform: 'openai', status: 'error' })), total: 850 })) + mocks.options.mockImplementation(async (ids: number[]) => ({ accounts: ids.map(id => ({ id, name: `Account ${id}`, default_model_id: 'gpt-6.1-sol', manual_model_allowed: true, models: [ + { id: 'codex-auto-review', text_supported: false, capability_reason: 'No text generation', reasoning_efforts: [] }, + { id: 'gpt-6.1-sol', text_supported: true, reasoning_efforts: ['low', 'high'], default_effort: 'high' } + ] })) })) +}) +afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) }) +async function make(props = {}) { const wrapper = mount(PelicanTaskComposer, { props, global: { stubs: { Pagination: { props: ['page', 'total', 'pageSize'], emits: ['update:page'], template: '
' } } } }); wrappers.push(wrapper); await flushPromises(); return wrapper } +describe('Pelican three-step composer', () => { + it('loads one 50-account page and retains selected accounts across pages with model-default effort', async () => { + const wrapper = await make() + expect(mocks.list).toHaveBeenCalledOnce() + expect(mocks.list).toHaveBeenCalledWith(1, 50, expect.objectContaining({ status: '', platform: '' }), expect.anything()) + expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(50) + await wrapper.get('[data-test="pelican-account-1"]').setValue(true) + await flushPromises() + wrapper.getComponent(Pagination).vm.$emit('update:page', 2) + await flushPromises() + await wrapper.get('[data-test="pelican-account-51"]').setValue(true) + await flushPromises() + await wrapper.get('[data-test="pelican-next"]').trigger('click') + const models = wrapper.findAll('[data-test^="pelican-model-"]') + expect(models.map(model => model.element.value)).toEqual(['gpt-6.1-sol', 'gpt-6.1-sol']) + expect(wrapper.findAll('[data-test^="pelican-effort-"]').every(effort => effort.element.value === '')).toBe(true) + await wrapper.get('[data-test="pelican-next"]').trigger('click') + await wrapper.get('[data-test="pelican-test-start"]').trigger('click') + expect(wrapper.emitted('submit')?.[0]?.[0]).toEqual({ generation_timeout_seconds: 600, targets: [{ account_id: 1, model_id: 'gpt-6.1-sol', effort: '' }, { account_id: 51, model_id: 'gpt-6.1-sol', effort: '' }] }) + }) + it('requires correction of unsupported and duplicate combinations instead of dropping them', async () => { + const wrapper = await make({ initial: { generation_timeout_seconds: 600, targets: [{ account_id: 1, model_id: 'codex-auto-review', effort: '' }] } }) + expect(wrapper.text()).toContain('No text generation') + expect(wrapper.get('[data-test="pelican-next"]').element.disabled).toBe(true) + await wrapper.get('[data-test^="pelican-model-"]').setValue('gpt-6.1-sol') + expect(wrapper.get('[data-test="pelican-next"]').element.disabled).toBe(false) + const duplicate = await make({ initial: { generation_timeout_seconds: 600, targets: [{ account_id: 1, model_id: 'gpt-6.1-sol', effort: 'low' }, { account_id: 1, model_id: 'gpt-6.1-sol', effort: 'high' }] } }) + expect(duplicate.text()).toContain('只能有一个档位') + expect(duplicate.findAll('[data-test^="pelican-model-"]')).toHaveLength(2) + expect(duplicate.get('[data-test="pelican-next"]').element.disabled).toBe(true) + expect(duplicate.emitted('submit')).toBeUndefined() + }) +}) diff --git a/frontend/src/utils/__tests__/pelicanTaskSelection.spec.ts b/frontend/src/utils/__tests__/pelicanTaskSelection.spec.ts new file mode 100644 index 000000000000..9add133651be --- /dev/null +++ b/frontend/src/utils/__tests__/pelicanTaskSelection.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { applyPelicanBulk, pelicanDefaultModel, pelicanDuplicateKeys, pelicanCombinationIssue } from '../pelicanTaskSelection' +import type { PelicanAccountOption } from '@/api/admin/pelicanTests' + +const account = { id: 1, default_model_id: 'sol', manual_model_allowed: true, models: [ + { id: 'codex-auto-review', text_supported: false, capability_reason: 'not text', reasoning_efforts: [] }, + { id: 'sol', text_supported: true, reasoning_efforts: ['low', 'high'] } +] } as PelicanAccountOption +describe('Pelican combination policy', () => { + it('honors only a valid backend default and leaves invalid defaults empty', () => { + expect(pelicanDefaultModel(account)).toBe('sol') + expect(pelicanDefaultModel({ ...account, default_model_id: 'codex-auto-review' })).toBe('') + expect(pelicanCombinationIssue({ key: 1, account_id: 1, model_id: 'codex-auto-review', effort: '' }, account)).toBe('not text') + }) + it('detects conflicting reasoning rows without discarding either selection', () => { + const rows = [{ key: 1, account_id: 1, model_id: 'sol', effort: 'low' }, { key: 2, account_id: 1, model_id: ' sol ', effort: 'high' }] + expect([...pelicanDuplicateKeys(rows)]).toEqual([1, 2]) + expect(rows.map(row => row.effort)).toEqual(['low', 'high']) + }) + it('keeps incompatible bulk rows unchanged and permits explicit unverified manual input', () => { + const rows = [{ key: 1, account_id: 1, model_id: 'sol', effort: '' }, { key: 2, account_id: 2, model_id: 'native', effort: 'low' }] + const other = { ...account, id: 2, models: [{ id: 'native', text_supported: true, reasoning_efforts: ['low'] }] } as PelicanAccountOption + const result = applyPelicanBulk(rows, { 1: account, 2: other }, 'sol', 'high') + expect(result.rows[0].effort).toBe('high'); expect(result.rows[1]).toEqual(rows[1]); expect(result.skipped).toEqual([2]) + expect(pelicanCombinationIssue({ key: 3, account_id: 1, model_id: 'manual-model', effort: '' }, account)).toBe('') + }) +}) diff --git a/frontend/src/utils/pelicanTaskSelection.ts b/frontend/src/utils/pelicanTaskSelection.ts new file mode 100644 index 000000000000..ad54ebe82d08 --- /dev/null +++ b/frontend/src/utils/pelicanTaskSelection.ts @@ -0,0 +1,44 @@ +import type { PelicanAccountOption, PelicanTestRequest } from '@/api/admin/pelicanTests' + +export type PelicanCombination = PelicanTestRequest['targets'][number] & { key: number } + +export function pelicanDefaultModel(account?: PelicanAccountOption): string { + const candidate = account?.models.find(model => model.id === account.default_model_id) + return candidate?.text_supported ? candidate.id : '' +} + +export function pelicanCombinationIssue(row: PelicanCombination, account?: PelicanAccountOption): string { + if (!account) return 'options_pending' + if (!row.model_id.trim()) return 'model_required' + const model = account.models.find(model => model.id === row.model_id.trim()) + if (model && !model.text_supported) return model.capability_reason || 'unsupported' + if (!model && !account.manual_model_allowed) return account.capability_reason || 'unsupported' + if (row.effort && (!model || !model.reasoning_efforts.includes(row.effort))) return 'effort_unsupported' + // A platform without a sender cannot be enabled by manually entering a model. + if (account.capability_reason && !account.models.length) return account.capability_reason + return '' +} + +export function pelicanDuplicateKeys(rows: PelicanCombination[]): Set { + const first = new Map(), duplicates = new Set() + for (const row of rows) { + const identity = `${row.account_id}\0${row.model_id.trim()}` + if (first.has(identity)) { duplicates.add(first.get(identity)!); duplicates.add(row.key) } + else first.set(identity, row.key) + } + return duplicates +} + +// Batch edits leave incompatible rows intact and report them. An unknown manual +// model is allowed only when entered explicitly on that individual account. +export function applyPelicanBulk(rows: PelicanCombination[], accounts: Record, model: string, effort: string) { + const skipped: number[] = [] + const updated = rows.map(row => { + const selected = model.trim() || row.model_id + const known = accounts[row.account_id]?.models.find(candidate => candidate.id === selected) + if (!known?.text_supported || (effort && !known.reasoning_efforts.includes(effort))) { skipped.push(row.account_id); return row } + return { ...row, model_id: selected, effort } + }) + if (pelicanDuplicateKeys(updated).size) return { rows, skipped: rows.map(row => row.account_id), duplicate: true } + return { rows: updated, skipped, duplicate: false } +} diff --git a/frontend/src/views/admin/CodexPelicanComparisonView.vue b/frontend/src/views/admin/CodexPelicanComparisonView.vue index 49b4feaa33e9..2a00ef2a4398 100644 --- a/frontend/src/views/admin/CodexPelicanComparisonView.vue +++ b/frontend/src/views/admin/CodexPelicanComparisonView.vue @@ -1,170 +1,55 @@