From ec7662d712dfec16c9f767af7d91761d0e3f9a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E8=8B=92?= <16112591+chen-ran@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:08:19 +0800 Subject: [PATCH] feat(moonshot): add files extraction client --- README.md | 1 + docs/providers.md | 51 +++++ provider/moonshot/files/files.go | 288 ++++++++++++++++++++++++++ provider/moonshot/files/files_test.go | 225 ++++++++++++++++++++ provider/moonshot/files/types.go | 44 ++++ 5 files changed, 609 insertions(+) create mode 100644 provider/moonshot/files/files.go create mode 100644 provider/moonshot/files/files_test.go create mode 100644 provider/moonshot/files/types.go diff --git a/README.md b/README.md index 1528804..4f3073c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A lightweight, idiomatic AI SDK for Go — inspired by [Vercel AI SDK](https://s - **Streaming** — first-class channel-based streaming with fine-grained `StreamPart` types - **Multi-step execution** — automatic tool-call loop with configurable `MaxSteps` - **Rich message types** — text, images, files, reasoning content, tool calls/results +- **Document extraction** — upload files to Moonshot, read extracted text, and manage remote file lifecycle - **Embeddings** — generate embeddings with `Embed` / `EmbedMany`, supports OpenAI and Google providers - **Image generation** — generate and edit images with `GenerateImage` / `EditImage`, supports OpenAI (dall-e, gpt-image) and Alibaba Cloud DashScope (Qwen-Image, Wan) models - **Video generation** — create, poll, and download video jobs with OpenRouter and Ark/ModelArk providers diff --git a/docs/providers.md b/docs/providers.md index 66283c8..fb7cfe8 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -225,6 +225,57 @@ provider := completions.New( ) ``` +## Moonshot File Extraction + +Moonshot's PDF and document question-answering flow is separate from Chat +Completions file content parts. The package implements the transport portion of +the [official Kimi file-based QA flow](https://platform.kimi.com/docs/guide/use-kimi-api-for-file-based-qa): +upload the document with `purpose=file-extract`, read the extracted text, add +that text to the model context, and delete the remote file when it is no longer +needed. + +```go +import ( + "context" + "os" + + moonshotfiles "github.com/memohai/twilight-ai/provider/moonshot/files" +) + +client := moonshotfiles.New( + moonshotfiles.WithAPIKey(os.Getenv("MOONSHOT_API_KEY")), +) + +document, err := os.Open("report.pdf") +if err != nil { + return err +} +defer document.Close() + +uploaded, err := client.UploadForExtraction( + context.Background(), + document, + "report.pdf", + "application/pdf", +) +if err != nil { + return err +} +defer client.Delete(context.Background(), uploaded.ID) // handle the error in production + +content, err := client.Content(context.Background(), uploaded.ID) +if err != nil { + return err +} +// Add content, not uploaded.ID, to the model context. +``` + +`Upload` accepts an `io.Reader` and streams the multipart body, so callers do +not need to buffer a large document. `OpenContent` provides the same streaming +control for extracted text. The package deliberately does not modify the Kimi +Chat Completions adapter: upload caching, context budgeting, trust boundaries, +and cleanup policy belong to the application orchestrating file-based QA. + ## OpenAI Responses Provider The `provider/openai/responses` package provides an implementation for the OpenAI Responses API (`/responses`). This is OpenAI's newer API that offers first-class reasoning support, URL citation annotations, and a flat input format. diff --git a/provider/moonshot/files/files.go b/provider/moonshot/files/files.go new file mode 100644 index 0000000..67f4839 --- /dev/null +++ b/provider/moonshot/files/files.go @@ -0,0 +1,288 @@ +// Package files implements Moonshot's Files API for file extraction and +// remote-file lifecycle management. +package files + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "strings" + + "github.com/memohai/twilight-ai/internal/utils" +) + +const ( + // DefaultBaseURL is the China-region Moonshot API endpoint. + DefaultBaseURL = "https://api.moonshot.cn/v1" + + // MaxContentBytes bounds the convenience Content method. Callers that need + // streaming or a different limit can use OpenContent instead. + MaxContentBytes int64 = 100 * 1024 * 1024 +) + +// Client calls Moonshot's Files API. It is intentionally independent from the +// Chat Completions provider: file extraction is a stateful upload/read/delete +// workflow, not a native message content part. +type Client struct { + apiKey string + baseURL string + httpClient *http.Client +} + +// Option configures a Client. +type Option func(*Client) + +// WithAPIKey configures the Moonshot API key. +func WithAPIKey(apiKey string) Option { + return func(c *Client) { c.apiKey = apiKey } +} + +// WithBaseURL configures the API base URL. +func WithBaseURL(baseURL string) Option { + return func(c *Client) { c.baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") } +} + +// WithHTTPClient configures the HTTP client. +func WithHTTPClient(client *http.Client) Option { + return func(c *Client) { c.httpClient = client } +} + +// New creates a Moonshot Files API client. +func New(options ...Option) *Client { + c := &Client{ + baseURL: DefaultBaseURL, + httpClient: &http.Client{}, + } + for _, option := range options { + option(c) + } + if c.httpClient == nil { + c.httpClient = &http.Client{} + } + return c +} + +// Upload sends a file to POST /files. The multipart body is streamed from the +// supplied reader instead of buffering the whole document in memory. +func (c *Client) Upload(ctx context.Context, params UploadParams) (*File, error) { + if params.Reader == nil { + return nil, errors.New("moonshot files: reader is required") + } + params.Filename = strings.TrimSpace(params.Filename) + if params.Filename == "" { + return nil, errors.New("moonshot files: filename is required") + } + if strings.IndexFunc(params.Filename, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { + return nil, errors.New("moonshot files: filename cannot be encoded in a multipart header") + } + params.Purpose = Purpose(strings.TrimSpace(string(params.Purpose))) + if params.Purpose == "" { + return nil, errors.New("moonshot files: purpose is required") + } + if strings.TrimSpace(params.ContentType) == "" { + params.ContentType = "application/octet-stream" + } + mediaType, mediaTypeParams, err := mime.ParseMediaType(params.ContentType) + if err != nil { + return nil, fmt.Errorf("moonshot files: invalid content type: %w", err) + } + params.ContentType = mime.FormatMediaType(mediaType, mediaTypeParams) + disposition := mime.FormatMediaType("form-data", map[string]string{ + "name": "file", + "filename": params.Filename, + }) + if disposition == "" { + return nil, errors.New("moonshot files: filename cannot be encoded in a multipart header") + } + + bodyReader, bodyWriter := io.Pipe() + multipartWriter := multipart.NewWriter(bodyWriter) + contentType := multipartWriter.FormDataContentType() + go writeUploadBody(bodyWriter, multipartWriter, params, disposition) + + requestURL, err := utils.BuildURL(c.baseURL, "/files") + if err != nil { + _ = bodyReader.Close() + return nil, fmt.Errorf("moonshot files: build upload URL: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bodyReader) + if err != nil { + _ = bodyReader.Close() + return nil, fmt.Errorf("moonshot files: build upload request: %w", err) + } + req.Header.Set("Authorization", utils.BearerToken(c.apiKey)) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("moonshot files: upload request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, readResponseError("upload", resp) + } + + var file File + if err := json.NewDecoder(resp.Body).Decode(&file); err != nil { + return nil, fmt.Errorf("moonshot files: decode upload response: %w", err) + } + return &file, nil +} + +// UploadForExtraction uploads a document with purpose=file-extract. +func (c *Client) UploadForExtraction( + ctx context.Context, + reader io.Reader, + filename string, + contentType string, +) (*File, error) { + return c.Upload(ctx, UploadParams{ + Reader: reader, + Filename: filename, + ContentType: contentType, + Purpose: PurposeFileExtract, + }) +} + +// Retrieve returns metadata for an uploaded file. +func (c *Client) Retrieve(ctx context.Context, fileID string) (*File, error) { + path, err := filePath(fileID, "") + if err != nil { + return nil, err + } + file, err := utils.FetchJSON[File](ctx, c.httpClient, &utils.RequestOptions{ + Method: http.MethodGet, + BaseURL: c.baseURL, + Path: path, + Headers: utils.AuthHeader(c.apiKey), + }) + if err != nil { + return nil, fmt.Errorf("moonshot files: retrieve request failed: %w", err) + } + return file, nil +} + +// OpenContent opens the extracted text returned by +// GET /files/{file_id}/content. The caller must close the returned reader. +func (c *Client) OpenContent(ctx context.Context, fileID string) (io.ReadCloser, error) { + path, err := filePath(fileID, "/content") + if err != nil { + return nil, err + } + resp, err := utils.FetchRaw(ctx, c.httpClient, &utils.RequestOptions{ + Method: http.MethodGet, + BaseURL: c.baseURL, + Path: path, + Headers: map[string]string{ + "Authorization": utils.BearerToken(c.apiKey), + "Accept": "text/plain", + }, + }) + if err != nil { + return nil, fmt.Errorf("moonshot files: content request failed: %w", err) + } + return resp.Body, nil +} + +// Content returns the extracted file content as text. It rejects responses +// larger than MaxContentBytes; use OpenContent when the caller owns streaming +// and its own limit. +func (c *Client) Content(ctx context.Context, fileID string) (string, error) { + reader, err := c.OpenContent(ctx, fileID) + if err != nil { + return "", err + } + defer reader.Close() + + data, err := io.ReadAll(io.LimitReader(reader, MaxContentBytes+1)) + if err != nil { + return "", fmt.Errorf("moonshot files: read content response: %w", err) + } + if int64(len(data)) > MaxContentBytes { + return "", fmt.Errorf("moonshot files: content response exceeds %d bytes", MaxContentBytes) + } + return string(data), nil +} + +// Delete permanently deletes an uploaded file. +func (c *Client) Delete(ctx context.Context, fileID string) (*DeleteResult, error) { + path, err := filePath(fileID, "") + if err != nil { + return nil, err + } + result, err := utils.FetchJSON[DeleteResult](ctx, c.httpClient, &utils.RequestOptions{ + Method: http.MethodDelete, + BaseURL: c.baseURL, + Path: path, + Headers: utils.AuthHeader(c.apiKey), + }) + if err != nil { + return nil, fmt.Errorf("moonshot files: delete request failed: %w", err) + } + return result, nil +} + +func writeUploadBody( + pipeWriter *io.PipeWriter, + writer *multipart.Writer, + params UploadParams, + disposition string, +) { + var writeErr error + defer func() { + if closeErr := writer.Close(); writeErr == nil { + writeErr = closeErr + } + _ = pipeWriter.CloseWithError(writeErr) + }() + + if err := writer.WriteField("purpose", string(params.Purpose)); err != nil { + writeErr = err + return + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", disposition) + header.Set("Content-Type", params.ContentType) + part, err := writer.CreatePart(header) + if err != nil { + writeErr = err + return + } + _, writeErr = io.Copy(part, params.Reader) +} + +func filePath(fileID, suffix string) (string, error) { + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return "", errors.New("moonshot files: file id is required") + } + if strings.ContainsAny(fileID, "/?#") { + return "", errors.New("moonshot files: file id contains path separators") + } + return "/files/" + fileID + suffix, nil +} + +func readResponseError(operation string, resp *http.Response) error { + const maxErrorBytes = 16 * 1024 + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBytes)) + message := strings.TrimSpace(string(body)) + var envelope struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(body, &envelope) == nil && strings.TrimSpace(envelope.Error.Message) != "" { + message = strings.TrimSpace(envelope.Error.Message) + } + if message == "" { + message = http.StatusText(resp.StatusCode) + } + return fmt.Errorf("moonshot files: %s failed with status %d: %s", operation, resp.StatusCode, message) +} diff --git a/provider/moonshot/files/files_test.go b/provider/moonshot/files/files_test.go new file mode 100644 index 0000000..cd36b8b --- /dev/null +++ b/provider/moonshot/files/files_test.go @@ -0,0 +1,225 @@ +package files + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestUploadForExtractionStreamsMultipartFile(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/files" { + t.Fatalf("request = %s %s, want POST /v1/files", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer secret" { + t.Fatalf("Authorization = %q", got) + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("ParseMultipartForm: %v", err) + } + if got := r.FormValue("purpose"); got != "file-extract" { + t.Fatalf("purpose = %q", got) + } + file, header, err := r.FormFile("file") + if err != nil { + t.Fatalf("FormFile: %v", err) + } + defer file.Close() + if header.Filename != "report.pdf" { + t.Fatalf("filename = %q", header.Filename) + } + if got := header.Header.Get("Content-Type"); got != "application/pdf" { + t.Fatalf("file Content-Type = %q", got) + } + data, err := io.ReadAll(file) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if got := string(data); got != "%PDF-test" { + t.Fatalf("file data = %q", got) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"file-1","object":"file","bytes":9,"created_at":123,"filename":"report.pdf","purpose":"file-extract","status":"ready","status_details":""}`)) + })) + defer server.Close() + + client := New(WithAPIKey("secret"), WithBaseURL(server.URL+"/v1/")) + file, err := client.UploadForExtraction( + context.Background(), + strings.NewReader("%PDF-test"), + "report.pdf", + "application/pdf", + ) + if err != nil { + t.Fatalf("UploadForExtraction: %v", err) + } + if file.ID != "file-1" || file.Purpose != PurposeFileExtract || file.Status != "ready" { + t.Fatalf("file = %#v", file) + } +} + +func TestRetrieveContentAndDelete(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer secret" { + t.Fatalf("Authorization = %q", got) + } + switch { + case r.Method == http.MethodGet && r.URL.Path == "/files/file-1": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"file-1","object":"file","filename":"report.pdf","purpose":"file-extract","status":"ready"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/files/file-1/content": + if got := r.Header.Get("Accept"); got != "text/plain" { + t.Fatalf("Accept = %q", got) + } + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("extracted document text")) + case r.Method == http.MethodDelete && r.URL.Path == "/files/file-1": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"file-1","object":"file","deleted":true}`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := New(WithAPIKey("secret"), WithBaseURL(server.URL)) + file, err := client.Retrieve(context.Background(), "file-1") + if err != nil { + t.Fatalf("Retrieve: %v", err) + } + if file.ID != "file-1" || file.Filename != "report.pdf" { + t.Fatalf("file = %#v", file) + } + + content, err := client.Content(context.Background(), "file-1") + if err != nil { + t.Fatalf("Content: %v", err) + } + if content != "extracted document text" { + t.Fatalf("content = %q", content) + } + + deleted, err := client.Delete(context.Background(), "file-1") + if err != nil { + t.Fatalf("Delete: %v", err) + } + if !deleted.Deleted || deleted.ID != "file-1" { + t.Fatalf("delete result = %#v", deleted) + } +} + +func TestUploadReturnsMoonshotErrorMessage(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"message":"unsupported file type","type":"invalid_request_error"}}`)) + })) + defer server.Close() + + client := New(WithBaseURL(server.URL)) + _, err := client.UploadForExtraction( + context.Background(), + strings.NewReader("data"), + "archive.zip", + "application/zip", + ) + if err == nil || !strings.Contains(err.Error(), "status 400: unsupported file type") { + t.Fatalf("error = %v", err) + } +} + +func TestInputValidation(t *testing.T) { + t.Parallel() + + client := New(WithHTTPClient(nil)) + tests := []struct { + name string + call func() error + want string + }{ + { + name: "reader", + call: func() error { + _, err := client.Upload(context.Background(), UploadParams{Filename: "a.pdf", Purpose: PurposeFileExtract}) + return err + }, + want: "reader is required", + }, + { + name: "filename", + call: func() error { + _, err := client.Upload(context.Background(), UploadParams{Reader: strings.NewReader("x"), Purpose: PurposeFileExtract}) + return err + }, + want: "filename is required", + }, + { + name: "filename header", + call: func() error { + _, err := client.Upload(context.Background(), UploadParams{ + Reader: strings.NewReader("x"), + Filename: "a.pdf\r\nX-Injected: true", + Purpose: PurposeFileExtract, + }) + return err + }, + want: "filename cannot be encoded", + }, + { + name: "purpose", + call: func() error { + _, err := client.Upload(context.Background(), UploadParams{Reader: strings.NewReader("x"), Filename: "a.pdf"}) + return err + }, + want: "purpose is required", + }, + { + name: "content type", + call: func() error { + _, err := client.Upload(context.Background(), UploadParams{ + Reader: strings.NewReader("x"), + Filename: "a.pdf", + ContentType: "application/pdf\r\nX-Injected: true", + Purpose: PurposeFileExtract, + }) + return err + }, + want: "invalid content type", + }, + { + name: "file id", + call: func() error { + _, err := client.Content(context.Background(), "") + return err + }, + want: "file id is required", + }, + { + name: "file id separator", + call: func() error { + _, err := client.Delete(context.Background(), "../file") + return err + }, + want: "path separators", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.call() + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} diff --git a/provider/moonshot/files/types.go b/provider/moonshot/files/types.go new file mode 100644 index 0000000..a76f1c0 --- /dev/null +++ b/provider/moonshot/files/types.go @@ -0,0 +1,44 @@ +package files + +import "io" + +// Purpose controls how Moonshot processes an uploaded file. +type Purpose string + +const ( + // PurposeFileExtract asks Moonshot to extract text from the uploaded file. + PurposeFileExtract Purpose = "file-extract" + // PurposeImage marks an upload for an image workflow. + PurposeImage Purpose = "image" + // PurposeVideo marks an upload for a video workflow. + PurposeVideo Purpose = "video" + // PurposeBatch marks an upload for batch processing. + PurposeBatch Purpose = "batch" +) + +// UploadParams describes a multipart file upload. +type UploadParams struct { + Reader io.Reader + Filename string + ContentType string + Purpose Purpose +} + +// File is Moonshot's metadata for an uploaded file. +type File struct { + ID string `json:"id"` + Object string `json:"object"` + Bytes int64 `json:"bytes"` + CreatedAt int64 `json:"created_at"` + Filename string `json:"filename"` + Purpose Purpose `json:"purpose"` + Status string `json:"status"` + StatusDetails string `json:"status_details"` +} + +// DeleteResult is returned after deleting an uploaded file. +type DeleteResult struct { + ID string `json:"id"` + Object string `json:"object"` + Deleted bool `json:"deleted"` +}