diff --git a/internal/app/warmupcontent/batch.go b/internal/app/warmupcontent/batch.go index ee9fdcbe..185c2226 100644 --- a/internal/app/warmupcontent/batch.go +++ b/internal/app/warmupcontent/batch.go @@ -168,7 +168,7 @@ func (s *service) PollBatches(ctx context.Context) error { // pollBatchJob reconciles a single batch job. func (s *service) pollBatchJob(ctx context.Context, job *models.WarmupGenerationJob) error { - status, outputFileID, counts, err := s.gen.GetBatch(ctx, job.BatchID) + status, outputFileID, errorFileID, counts, err := s.gen.GetBatch(ctx, job.BatchID) if err != nil { return err } @@ -177,7 +177,14 @@ func (s *service) pollBatchJob(ctx context.Context, job *models.WarmupGeneration switch status { case "completed": job.BatchOutputFileID = outputFileID - return s.ingestBatch(ctx, job, outputFileID, counts) + // A batch whose requests all failed completes with no output file; the + // refusals are in the error file, which has the same JSONL shape. Read + // it instead of failing on the empty id, or the reason is never seen. + resultsFileID := outputFileID + if resultsFileID == "" { + resultsFileID = errorFileID + } + return s.ingestBatch(ctx, job, resultsFileID, counts) case "failed", "expired", "cancelled": now := time.Now() job.Status = "failed" @@ -287,6 +294,11 @@ func (s *service) ingestBatch(ctx context.Context, job *models.WarmupGenerationJ job.Status = "failed" if job.Error == "" { job.Error = fmt.Sprintf("all %d batch results failed", job.FailedCount) + // One line's reason is worth more than the count: when every + // request is refused it is the same reason for all of them. + if reason := firstResultError(results); reason != "" { + job.Error += ": " + reason + } } } if err := s.repo.UpdateGenerationJob(ctx, job); err != nil { @@ -355,3 +367,14 @@ func (s *service) CancelBatch(ctx context.Context, jobID uuid.UUID) error { } return s.repo.UpdateGenerationJob(ctx, job) } + +// firstResultError returns the first non-empty per-line error in a batch's +// results, used to name the reason a fully failed batch produced nothing. +func firstResultError(results []generation.BatchResult) string { + for i := range results { + if results[i].Err != "" { + return results[i].Err + } + } + return "" +} diff --git a/internal/pkg/generation/batch.go b/internal/pkg/generation/batch.go index 673dea91..fcd193c0 100644 --- a/internal/pkg/generation/batch.go +++ b/internal/pkg/generation/batch.go @@ -112,19 +112,22 @@ func (c *GenerationClient) SubmitBatch(ctx context.Context, requests []BatchRequ return batch.ID, file.ID, nil } -// GetBatch returns the current status, output file ID (when completed), and -// request counts for a batch. -func (c *GenerationClient) GetBatch(ctx context.Context, batchID string) (status, outputFileID string, counts BatchCounts, err error) { +// GetBatch returns the current status, the output file ID (present when at +// least one request succeeded), the error file ID (present when at least one +// failed), and the request counts for a batch. A batch whose requests all +// failed still completes, with output empty and every refusal in the error +// file, so callers must read that one to learn why. +func (c *GenerationClient) GetBatch(ctx context.Context, batchID string) (status, outputFileID, errorFileID string, counts BatchCounts, err error) { batch, err := c.client.Batches.Get(ctx, batchID) if err != nil { - return "", "", BatchCounts{}, err + return "", "", "", BatchCounts{}, err } counts = BatchCounts{ Completed: int(batch.RequestCounts.Completed), Failed: int(batch.RequestCounts.Failed), Total: int(batch.RequestCounts.Total), } - return string(batch.Status), batch.OutputFileID, counts, nil + return string(batch.Status), batch.OutputFileID, batch.ErrorFileID, counts, nil } // CancelBatch requests cancellation of an in-flight batch. @@ -180,7 +183,12 @@ func parseBatchOutputLine(raw string) BatchResult { return res } if line.Response.StatusCode < 200 || line.Response.StatusCode >= 300 { + // An error line carries {"body":{"error":{"message":...}}}, which does + // not fit ChatCompletion; without this the reason is reduced to a code. res.Err = fmt.Sprintf("response status %d", line.Response.StatusCode) + if msg := batchLineErrorMessage(raw); msg != "" { + res.Err += ": " + msg + } return res } if len(line.Response.Body.Choices) == 0 { @@ -204,3 +212,21 @@ type namedReader struct { } func (n namedReader) Name() string { return n.name } + +// batchLineErrorMessage pulls the provider's message out of a failed batch +// line, whose body is an error object rather than a chat completion. +func batchLineErrorMessage(raw string) string { + var line struct { + Response struct { + Body struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } `json:"body"` + } `json:"response"` + } + if err := json.Unmarshal([]byte(raw), &line); err != nil { + return "" + } + return strings.TrimSpace(line.Response.Body.Error.Message) +}