mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
264 lines
8.3 KiB
Go
264 lines
8.3 KiB
Go
package generation
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/openai/openai-go/v2"
|
|
)
|
|
|
|
// BatchRequest is one warmup thread to generate via the OpenAI Batch API. The
|
|
// CustomID round-trips through the batch so results can be mapped back to their
|
|
// theme (the batch output is unordered and may drop failed lines).
|
|
type BatchRequest struct {
|
|
CustomID string
|
|
Theme string
|
|
Model string
|
|
MaxMessages int
|
|
}
|
|
|
|
// BatchResult is one parsed line from a completed batch's output file. Exactly
|
|
// one of Conversation or Err is set per line; Err carries a per-line failure so
|
|
// a single bad response never fails the whole ingest.
|
|
type BatchResult struct {
|
|
CustomID string
|
|
Theme string
|
|
Conversation *Conversation
|
|
Err string
|
|
}
|
|
|
|
// BatchCounts mirrors the OpenAI batch request_counts object.
|
|
type BatchCounts struct {
|
|
Completed int
|
|
Failed int
|
|
Total int
|
|
}
|
|
|
|
// batchInputLine is one line of the Batch API JSONL input file. The body is the
|
|
// same chat-completion request the sync path sends, so sync and batch produce
|
|
// identical threads.
|
|
type batchInputLine struct {
|
|
CustomID string `json:"custom_id"`
|
|
Method string `json:"method"`
|
|
URL string `json:"url"`
|
|
Body openai.ChatCompletionNewParams `json:"body"`
|
|
}
|
|
|
|
// batchOutputLine is one line of the Batch API JSONL output file.
|
|
type batchOutputLine struct {
|
|
CustomID string `json:"custom_id"`
|
|
Response *struct {
|
|
StatusCode int `json:"status_code"`
|
|
Body openai.ChatCompletion `json:"body"`
|
|
} `json:"response"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
// SubmitBatch uploads a JSONL of chat-completion requests as a batch input file
|
|
// and creates a batch over the /v1/chat/completions endpoint. It returns the
|
|
// batch ID and the uploaded input file ID. completionWindow is the Batch API
|
|
// processing window (only "24h" is currently accepted by OpenAI; empty defaults
|
|
// to "24h").
|
|
func (c *GenerationClient) SubmitBatch(ctx context.Context, requests []BatchRequest, completionWindow string) (batchID, inputFileID string, err error) {
|
|
if len(requests) == 0 {
|
|
return "", "", fmt.Errorf("submit batch: no requests")
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
for _, r := range requests {
|
|
body := buildConversationParams(r.Theme, r.Model, normalizeMaxMessages(r.MaxMessages))
|
|
line := batchInputLine{
|
|
CustomID: r.CustomID,
|
|
Method: "POST",
|
|
URL: "/v1/chat/completions",
|
|
Body: body,
|
|
}
|
|
if err := enc.Encode(line); err != nil {
|
|
return "", "", fmt.Errorf("submit batch: encode line %s: %w", r.CustomID, err)
|
|
}
|
|
}
|
|
|
|
file, err := c.client.Files.New(ctx, openai.FileNewParams{
|
|
File: namedReader{Reader: bytes.NewReader(buf.Bytes()), name: "warmup_batch.jsonl"},
|
|
Purpose: openai.FilePurposeBatch,
|
|
})
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("submit batch: upload input file: %w", err)
|
|
}
|
|
|
|
window := openai.BatchNewParamsCompletionWindow24h
|
|
if completionWindow != "" {
|
|
window = openai.BatchNewParamsCompletionWindow(completionWindow)
|
|
}
|
|
|
|
batch, err := c.client.Batches.New(ctx, openai.BatchNewParams{
|
|
CompletionWindow: window,
|
|
Endpoint: openai.BatchNewParamsEndpointV1ChatCompletions,
|
|
InputFileID: file.ID,
|
|
})
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("submit batch: create batch: %w", err)
|
|
}
|
|
|
|
return batch.ID, file.ID, nil
|
|
}
|
|
|
|
// BatchState is the provider's view of one batch.
|
|
type BatchState struct {
|
|
Status string
|
|
// A batch whose requests all failed still completes: output empty, refusals in the error file.
|
|
OutputFileID string
|
|
ErrorFileID string
|
|
Counts BatchCounts
|
|
// Batch-level error, set when the batch itself never ran, so no error file exists to read.
|
|
FailureReason string
|
|
}
|
|
|
|
// GetBatch returns the provider's current view of a batch.
|
|
func (c *GenerationClient) GetBatch(ctx context.Context, batchID string) (BatchState, error) {
|
|
batch, err := c.client.Batches.Get(ctx, batchID)
|
|
if err != nil {
|
|
return BatchState{}, err
|
|
}
|
|
return BatchState{
|
|
Status: string(batch.Status),
|
|
OutputFileID: batch.OutputFileID,
|
|
ErrorFileID: batch.ErrorFileID,
|
|
Counts: BatchCounts{
|
|
Completed: int(batch.RequestCounts.Completed),
|
|
Failed: int(batch.RequestCounts.Failed),
|
|
Total: int(batch.RequestCounts.Total),
|
|
},
|
|
FailureReason: batchFailureReason(batch.Errors.Data),
|
|
}, nil
|
|
}
|
|
|
|
// batchFailureReason renders the batch-level errors as one line for a job row.
|
|
func batchFailureReason(errs []openai.BatchError) string {
|
|
// Count only entries that carry a message: a blank one is not a reason to go looking for.
|
|
msgs := make([]string, 0, len(errs))
|
|
for _, e := range errs {
|
|
if msg := strings.TrimSpace(e.Message); msg != "" {
|
|
msgs = append(msgs, msg)
|
|
}
|
|
}
|
|
switch len(msgs) {
|
|
case 0:
|
|
return ""
|
|
case 1:
|
|
return msgs[0]
|
|
default:
|
|
return fmt.Sprintf("%s (+%d more)", msgs[0], len(msgs)-1)
|
|
}
|
|
}
|
|
|
|
// CancelBatch requests cancellation of an in-flight batch.
|
|
func (c *GenerationClient) CancelBatch(ctx context.Context, batchID string) error {
|
|
_, err := c.client.Batches.Cancel(ctx, batchID)
|
|
return err
|
|
}
|
|
|
|
// FetchBatchResults downloads a completed batch's output file and parses each
|
|
// JSONL line back into a Conversation. Per-line failures (HTTP error, malformed
|
|
// body, JSON parse error) are reported on the individual BatchResult rather than
|
|
// failing the whole fetch, so a few bad lines don't discard the good ones.
|
|
func (c *GenerationClient) FetchBatchResults(ctx context.Context, outputFileID string) ([]BatchResult, error) {
|
|
if outputFileID == "" {
|
|
return nil, fmt.Errorf("fetch batch results: empty output file id")
|
|
}
|
|
resp, err := c.client.Files.Content(ctx, outputFileID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch batch results: download output file: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var out []BatchResult
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
// Output bodies can be large; raise the line buffer well above the default 64KiB.
|
|
scanner.Buffer(make([]byte, 0, 1024*1024), 8*1024*1024)
|
|
for scanner.Scan() {
|
|
raw := strings.TrimSpace(scanner.Text())
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
out = append(out, parseBatchOutputLine(raw))
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return out, fmt.Errorf("fetch batch results: read output file: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// parseBatchOutputLine decodes one output JSONL line, tolerating per-line errors.
|
|
func parseBatchOutputLine(raw string) BatchResult {
|
|
var line batchOutputLine
|
|
if err := json.Unmarshal([]byte(raw), &line); err != nil {
|
|
return BatchResult{Err: fmt.Sprintf("parse output line: %v", err)}
|
|
}
|
|
res := BatchResult{CustomID: line.CustomID}
|
|
if line.Error != nil && line.Error.Message != "" {
|
|
res.Err = line.Error.Message
|
|
return res
|
|
}
|
|
if line.Response == nil {
|
|
res.Err = "missing response in output line"
|
|
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 {
|
|
res.Err = "response returned no choices"
|
|
return res
|
|
}
|
|
var conv Conversation
|
|
if err := json.Unmarshal([]byte(line.Response.Body.Choices[0].Message.Content), &conv); err != nil {
|
|
res.Err = fmt.Sprintf("parse conversation: %v", err)
|
|
return res
|
|
}
|
|
res.Conversation = &conv
|
|
return res
|
|
}
|
|
|
|
// namedReader adapts a reader so the multipart upload carries a filename, which
|
|
// the Files endpoint requires for the JSONL input.
|
|
type namedReader struct {
|
|
io.Reader
|
|
name string
|
|
}
|
|
|
|
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)
|
|
}
|