feat: read a fully failed batch's error file instead of reporting "empty output file id", so a generation job that had every request refused names the provider's actual reason in the admin panel rather than an internal guard message

This commit is contained in:
joao-crm
2026-08-25 20:46:27 +00:00
parent 93a2fa0962
commit ac40eca920
2 changed files with 56 additions and 7 deletions
+25 -2
View File
@@ -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 ""
}
+31 -5
View File
@@ -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)
}