diff --git a/ai/application.go b/ai/application.go index ae75cbbb7..1a30fa063 100644 --- a/ai/application.go +++ b/ai/application.go @@ -36,6 +36,10 @@ func (r *Application) Agent(agent contractsai.Agent, options ...contractsai.Opti return NewConversation(r.ctx, agent, provider, model, middlewares), nil } +func (r *Application) Image(prompt string, options ...contractsai.Option) contractsai.ImageRequest { + return NewImageRequest(r.ctx, r, prompt, options...) +} + func (r *Application) putFile(ctx context.Context, file contractsai.StorableFile, options ...contractsai.Option) (contractsai.StoredFileResponse, error) { _, providerName, provider, err := r.resolveProvider(options) if err != nil { @@ -50,6 +54,23 @@ func (r *Application) putFile(ctx context.Context, file contractsai.StorableFile return fileProvider.PutFile(ctx, file) } +func (r *Application) image(ctx context.Context, prompt contractsai.ImagePrompt, options ...contractsai.Option) (contractsai.ImageResponse, error) { + opts, providerName, provider, err := r.resolveProvider(options) + if err != nil { + return nil, err + } + if prompt.Model == "" { + prompt.Model = opts.Model + } + + imageProvider, ok := provider.(contractsai.ImageProvider) + if !ok { + return nil, errors.AIProviderDoesNotSupportImages.Args(providerName) + } + + return imageProvider.Image(ctx, prompt) +} + func (r *Application) resolveProvider(options []contractsai.Option) (*contractsai.Options, string, contractsai.Provider, error) { opts := &contractsai.Options{} for _, option := range options { diff --git a/ai/application_test.go b/ai/application_test.go index 36408184a..67fa64578 100644 --- a/ai/application_test.go +++ b/ai/application_test.go @@ -3,6 +3,7 @@ package ai import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -335,6 +336,129 @@ func TestApplication_putFile(t *testing.T) { } } +func TestApplication_Image(t *testing.T) { + ctx := context.Background() + config := contractsai.Config{ + Default: "default", + Providers: map[string]contractsai.ProviderConfig{ + "default": {Via: mocksai.NewProvider(t)}, + }, + } + + app := NewApplication(ctx, config) + request := app.Image("draw a cat", WithProvider("default"), WithModel("gpt-image-1")) + + req, ok := request.(*imageRequest) + assert.True(t, ok) + assert.Equal(t, ctx, req.ctx) + assert.Equal(t, app, req.app) + assert.Equal(t, "draw a cat", req.prompt) + assert.Equal(t, "default", req.provider) + assert.Equal(t, "gpt-image-1", req.model) + + assert.Same(t, req, request.Square()) + assert.Same(t, req, request.Portrait()) + assert.Same(t, req, request.Landscape()) + assert.Same(t, req, request.Quality(contractsai.ImageQualityHigh)) + assert.Same(t, req, request.Timeout(2*time.Second)) + + attachment := ImageFromByte([]byte("image"), WithMimeType("image/png")) + assert.Same(t, req, request.Attachments(attachment)) + assert.Equal(t, contractsai.ImageSizeLandscape, req.size) + assert.Equal(t, contractsai.ImageQualityHigh, req.quality) + assert.Equal(t, 2*time.Second, req.timeout) + assert.Equal(t, []contractsai.Attachment{attachment}, req.attachments) +} + +func TestImageRequest_Generate(t *testing.T) { + ctx := context.Background() + provider := &applicationImageProviderStub{} + config := contractsai.Config{ + Default: "default", + Providers: map[string]contractsai.ProviderConfig{ + "default": {Via: provider}, + }, + } + + app := NewApplication(context.Background(), config) + attachment := ImageFromByte([]byte("image"), WithMimeType("image/png")) + response := &applicationImageResponseStub{} + provider.response = response + + result, err := app.Image("draw a cat"). + Landscape(). + Quality(contractsai.ImageQualityHigh). + Attachments(attachment). + Timeout(3 * time.Second). + Generate() + + require.NoError(t, err) + assert.Equal(t, response, result) + assert.Equal(t, ctx, provider.ctx) + assert.Equal(t, contractsai.ImagePrompt{ + Prompt: "draw a cat", + Size: contractsai.ImageSizeLandscape, + Quality: contractsai.ImageQualityHigh, + Attachments: []contractsai.Attachment{attachment}, + Timeout: 3 * time.Second, + }, provider.prompt) +} + +func TestApplication_image(t *testing.T) { + tests := []struct { + name string + options []contractsai.Option + setup func() contractsai.Config + expectError error + }{ + { + name: "success", + options: []contractsai.Option{WithProvider("openai"), WithModel("gpt-image-override")}, + setup: func() contractsai.Config { + provider := &applicationImageProviderStub{} + provider.response = &applicationImageResponseStub{} + return contractsai.Config{ + Default: "default", + Providers: map[string]contractsai.ProviderConfig{ + "default": {Via: mocksai.NewProvider(t)}, + "openai": {Via: provider}, + }, + } + }, + }, + { + name: "provider does not support images", + setup: func() contractsai.Config { + return contractsai.Config{ + Default: "default", + Providers: map[string]contractsai.ProviderConfig{ + "default": {Via: mocksai.NewProvider(t)}, + }, + } + }, + expectError: errors.AIProviderDoesNotSupportImages.Args("default"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := NewApplication(context.Background(), tt.setup()) + response, err := app.image(context.Background(), contractsai.ImagePrompt{Prompt: "draw a cat"}, tt.options...) + assert.Equal(t, tt.expectError, err) + if tt.expectError != nil { + assert.Nil(t, response) + return + } + + require.NotNil(t, response) + provider, ok := app.config.Providers["openai"].Via.(*applicationImageProviderStub) + if ok { + assert.Equal(t, "gpt-image-override", provider.prompt.Model) + } + }) + } +} + type applicationTestMiddleware struct{} type uploadTestProvider struct { @@ -353,6 +477,45 @@ func (p uploadTestProvider) PutFile(ctx context.Context, file contractsai.Storab return p.fileProvider.PutFile(ctx, file) } +type applicationImageProviderStub struct { + ctx context.Context + prompt contractsai.ImagePrompt + response contractsai.ImageResponse + err error +} + +func (p *applicationImageProviderStub) Prompt(context.Context, contractsai.AgentPrompt) (contractsai.Response, error) { + return nil, nil +} + +func (p *applicationImageProviderStub) Stream(context.Context, contractsai.AgentPrompt) (contractsai.StreamableResponse, error) { + return nil, nil +} + +func (p *applicationImageProviderStub) Image(ctx context.Context, prompt contractsai.ImagePrompt) (contractsai.ImageResponse, error) { + p.ctx = ctx + p.prompt = prompt + return p.response, p.err +} + +type applicationImageResponseStub struct{} + +func (r *applicationImageResponseStub) Content(context.Context) ([]byte, error) { + return []byte("image"), nil +} + +func (r *applicationImageResponseStub) MimeType() string { return "image/png" } + +func (r *applicationImageResponseStub) Usage() contractsai.Usage { return nil } + +func (r *applicationImageResponseStub) Then(callback func(contractsai.ImageResponse)) contractsai.ImageResponse { + if callback != nil { + callback(r) + } + + return r +} + func (m *applicationTestMiddleware) Handle(ctx context.Context, prompt contractsai.AgentPrompt, next contractsai.Next) (contractsai.Response, error) { response, err := next(ctx, prompt) if err != nil { diff --git a/ai/image/image.go b/ai/image/image.go new file mode 100644 index 000000000..1ad078226 --- /dev/null +++ b/ai/image/image.go @@ -0,0 +1,16 @@ +package image + +import contractsai "github.com/goravel/framework/contracts/ai" + +type Quality = contractsai.ImageQuality +type Size = contractsai.ImageSize + +const ( + QualityLow = contractsai.ImageQualityLow + QualityMedium = contractsai.ImageQualityMedium + QualityHigh = contractsai.ImageQualityHigh + + SizeSquare = contractsai.ImageSizeSquare + SizePortrait = contractsai.ImageSizePortrait + SizeLandscape = contractsai.ImageSizeLandscape +) diff --git a/ai/image_request.go b/ai/image_request.go new file mode 100644 index 000000000..1d3409ee0 --- /dev/null +++ b/ai/image_request.go @@ -0,0 +1,98 @@ +package ai + +import ( + "context" + "time" + + contractsai "github.com/goravel/framework/contracts/ai" +) + +type imageRequest struct { + ctx context.Context + app *Application + prompt string + provider string + model string + size contractsai.ImageSize + quality contractsai.ImageQuality + attachments []contractsai.Attachment + timeout time.Duration +} + +func NewImageRequest(ctx context.Context, app *Application, prompt string, options ...contractsai.Option) contractsai.ImageRequest { + resolvedOptions := &contractsai.Options{} + for _, option := range options { + if option == nil { + continue + } + + option(resolvedOptions) + } + + return &imageRequest{ + ctx: ctx, + app: app, + prompt: prompt, + provider: resolvedOptions.Provider, + model: resolvedOptions.Model, + } +} + +func (r *imageRequest) Model(model string) contractsai.ImageRequest { + r.model = model + return r +} + +func (r *imageRequest) Provider(provider string) contractsai.ImageRequest { + r.provider = provider + return r +} + +func (r *imageRequest) Square() contractsai.ImageRequest { + r.size = contractsai.ImageSizeSquare + return r +} + +func (r *imageRequest) Portrait() contractsai.ImageRequest { + r.size = contractsai.ImageSizePortrait + return r +} + +func (r *imageRequest) Landscape() contractsai.ImageRequest { + r.size = contractsai.ImageSizeLandscape + return r +} + +func (r *imageRequest) Quality(quality contractsai.ImageQuality) contractsai.ImageRequest { + r.quality = quality + return r +} + +func (r *imageRequest) Attachments(attachments ...contractsai.Attachment) contractsai.ImageRequest { + r.attachments = append(r.attachments, filterNilAttachments(attachments)...) + return r +} + +func (r *imageRequest) Timeout(timeout time.Duration) contractsai.ImageRequest { + r.timeout = timeout + return r +} + +func (r *imageRequest) Generate() (contractsai.ImageResponse, error) { + options := make([]contractsai.Option, 0, 2) + if r.provider != "" { + options = append(options, WithProvider(r.provider)) + } + if r.model != "" { + options = append(options, WithModel(r.model)) + } + + return r.app.image(r.ctx, contractsai.ImagePrompt{ + Prompt: r.prompt, + Model: r.model, + Size: r.size, + Quality: r.quality, + Attachments: filterNilAttachments(r.attachments), + Timeout: r.timeout, + }, options...) +} diff --git a/ai/openai/provider.go b/ai/openai/provider.go index 2db128cbb..ffc4e8434 100644 --- a/ai/openai/provider.go +++ b/ai/openai/provider.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "mime" "path/filepath" "strings" @@ -25,6 +26,7 @@ import ( // The OpenAI provider will be moved into a separate package in the future. const DefaultTextModel = "gpt-5.4" +const DefaultImageModel = "gpt-image-2" const providerStateResponseID = "openai.response_id" @@ -47,10 +49,80 @@ func NewOpenAI(config contractsconfig.Config, provider string) (*Provider, error if providerConfig.Models.Text.Default == "" { providerConfig.Models.Text.Default = DefaultTextModel } + if providerConfig.Models.Image.Default == "" { + providerConfig.Models.Image.Default = DefaultImageModel + } return &Provider{client: goopenai.NewClient(opts...), config: providerConfig}, nil } +func (r *Provider) Image(ctx context.Context, prompt contractsai.ImagePrompt) (contractsai.ImageResponse, error) { + if prompt.Prompt == "" { + return nil, errors.AIImagePromptRequired + } + for _, attachment := range prompt.Attachments { + if attachment.Kind() != contractsai.AttachmentKindImage { + return nil, errors.AIImageAttachmentRequired + } + } + + requestOptions := make([]option.RequestOption, 0, 1) + if prompt.Timeout > 0 { + requestOptions = append(requestOptions, option.WithRequestTimeout(prompt.Timeout)) + } + + if len(prompt.Attachments) == 0 { + params := goopenai.ImageGenerateParams{ + Prompt: prompt.Prompt, + Model: goopenai.ImageModel(r.resolveImageModel(prompt.Model)), + } + if size := r.resolveImageGenerateSize(prompt.Size); size != "" { + params.Size = size + } + if quality := r.resolveImageGenerateQuality(prompt.Quality); quality != "" { + params.Quality = quality + } + + response, err := r.client.Images.Generate(ctx, params, requestOptions...) + if err != nil { + return nil, err + } + + return r.parseImageResponse(response) + } + + images := make([]io.Reader, 0, len(prompt.Attachments)) + for _, attachment := range prompt.Attachments { + content, err := attachment.Content(ctx) + if err != nil { + return nil, err + } + + images = append(images, goopenai.File(bytes.NewReader(content), r.uploadFilename(attachment), attachment.MimeType())) + } + + params := goopenai.ImageEditParams{ + Prompt: prompt.Prompt, + Model: goopenai.ImageModel(r.resolveImageModel(prompt.Model)), + Image: goopenai.ImageEditParamsImageUnion{ + OfFileArray: images, + }, + } + if size := r.resolveImageEditSize(prompt.Size); size != "" { + params.Size = size + } + if quality := r.resolveImageEditQuality(prompt.Quality); quality != "" { + params.Quality = quality + } + + response, err := r.client.Images.Edit(ctx, params, requestOptions...) + if err != nil { + return nil, err + } + + return r.parseImageResponse(response) +} + func (r *Provider) Prompt(ctx context.Context, prompt contractsai.AgentPrompt) (contractsai.Response, error) { params, err := r.buildRequest(ctx, prompt) if err != nil { @@ -204,6 +276,66 @@ func (r *Provider) resolveModel(model string) string { return r.config.Models.Text.Default } +func (r *Provider) resolveImageModel(model string) string { + if model != "" { + return model + } + + return r.config.Models.Image.Default +} + +func (r *Provider) resolveImageGenerateSize(size contractsai.ImageSize) goopenai.ImageGenerateParamsSize { + switch size { + case contractsai.ImageSizeSquare: + return goopenai.ImageGenerateParamsSize1024x1024 + case contractsai.ImageSizePortrait: + return goopenai.ImageGenerateParamsSize1024x1536 + case contractsai.ImageSizeLandscape: + return goopenai.ImageGenerateParamsSize1536x1024 + default: + return "" + } +} + +func (r *Provider) resolveImageEditSize(size contractsai.ImageSize) goopenai.ImageEditParamsSize { + switch size { + case contractsai.ImageSizeSquare: + return goopenai.ImageEditParamsSize1024x1024 + case contractsai.ImageSizePortrait: + return goopenai.ImageEditParamsSize1024x1536 + case contractsai.ImageSizeLandscape: + return goopenai.ImageEditParamsSize1536x1024 + default: + return "" + } +} + +func (r *Provider) resolveImageGenerateQuality(quality contractsai.ImageQuality) goopenai.ImageGenerateParamsQuality { + switch quality { + case contractsai.ImageQualityLow: + return goopenai.ImageGenerateParamsQualityLow + case contractsai.ImageQualityMedium: + return goopenai.ImageGenerateParamsQualityMedium + case contractsai.ImageQualityHigh: + return goopenai.ImageGenerateParamsQualityHigh + default: + return "" + } +} + +func (r *Provider) resolveImageEditQuality(quality contractsai.ImageQuality) goopenai.ImageEditParamsQuality { + switch quality { + case contractsai.ImageQualityLow: + return goopenai.ImageEditParamsQualityLow + case contractsai.ImageQualityMedium: + return goopenai.ImageEditParamsQualityMedium + case contractsai.ImageQualityHigh: + return goopenai.ImageEditParamsQualityHigh + default: + return "" + } +} + func (r *Provider) buildRequest(ctx context.Context, prompt contractsai.AgentPrompt) (responses.ResponseNewParams, error) { input, instructions, previousResponseID, err := r.buildInput(ctx, prompt) if err != nil { @@ -498,3 +630,58 @@ func (r *Provider) parseUsage(raw responses.ResponseUsage) *usage { total: int(raw.TotalTokens), } } + +func (r *Provider) parseImageResponse(response *goopenai.ImagesResponse) (contractsai.ImageResponse, error) { + if response == nil || len(response.Data) == 0 { + return nil, errors.AIImageResponseIsEmpty + } + + content, err := r.resolveImageContent(response.Data[0]) + if err != nil { + return nil, err + } + if len(content) == 0 { + return nil, errors.AIImageResponseIsEmpty + } + + mimeType := r.resolveImageMimeType(response.OutputFormat) + if mimeType == "" { + mimeType = "image/png" + } + + return &imageResponse{ + mimeType: mimeType, + content: content, + usage: &usage{ + input: int(response.Usage.InputTokens), + output: int(response.Usage.OutputTokens), + total: int(response.Usage.TotalTokens), + }, + }, nil +} + +func (r *Provider) resolveImageContent(image goopenai.Image) ([]byte, error) { + if image.B64JSON == "" { + return nil, errors.AIImageResponseIsEmpty + } + + content, err := base64.StdEncoding.DecodeString(image.B64JSON) + if err != nil { + return nil, err + } + + return content, nil +} + +func (r *Provider) resolveImageMimeType(format goopenai.ImagesResponseOutputFormat) string { + switch format { + case goopenai.ImagesResponseOutputFormatJPEG: + return "image/jpeg" + case goopenai.ImagesResponseOutputFormatWebP: + return "image/webp" + case goopenai.ImagesResponseOutputFormatPNG: + return "image/png" + default: + return "" + } +} diff --git a/ai/openai/provider_test.go b/ai/openai/provider_test.go index 745d9983d..3277ca37c 100644 --- a/ai/openai/provider_test.go +++ b/ai/openai/provider_test.go @@ -3,12 +3,14 @@ package openai import ( "bytes" "context" + "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" + "time" goopenai "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" @@ -70,6 +72,22 @@ type capturedFileUploadRequest struct { body []byte } +type capturedImageRequest struct { + path string + authorization string + contentType string + body []byte + formValues map[string]string + files []capturedImageFile +} + +type capturedImageFile struct { + fieldName string + fileName string + mimeType string + body []byte +} + type streamUsageSnapshot struct { input int output int @@ -117,22 +135,25 @@ func TestNewOpenAIUnmarshalError(t *testing.T) { expectConfig: func() *contractsai.ProviderConfig { cfg := contractsai.ProviderConfig{Key: "test-key", Url: "http://localhost:1234"} cfg.Models.Text.Default = DefaultTextModel + cfg.Models.Image.Default = DefaultImageModel return &cfg }(), }, { - name: "keeps configured default model", + name: "keeps configured default models", setup: func() { mockConfig.EXPECT().UnmarshalKey("ai.providers.openai", new(contractsai.ProviderConfig)).RunAndReturn(func(_ string, rawVal any) error { cfg := rawVal.(*contractsai.ProviderConfig) cfg.Key = "test-key" cfg.Models.Text.Default = "gpt-custom" + cfg.Models.Image.Default = "gpt-image-custom" return nil }).Once() }, expectConfig: func() *contractsai.ProviderConfig { cfg := contractsai.ProviderConfig{Key: "test-key"} cfg.Models.Text.Default = "gpt-custom" + cfg.Models.Image.Default = "gpt-image-custom" return &cfg }(), }, @@ -156,6 +177,123 @@ func TestNewOpenAIUnmarshalError(t *testing.T) { } } +func TestProviderImage(t *testing.T) { + tests := []struct { + name string + prompt contractsai.ImagePrompt + response string + status int + expectError error + expectPath string + expectForm map[string]string + expectFiles []capturedImageFile + expectMime string + expectContent []byte + }{ + { + name: "generates image with defaults", + prompt: contractsai.ImagePrompt{ + Prompt: "draw a cat", + }, + status: http.StatusOK, + response: imageResponseBody(t, "png", "image-bytes", 11, 7, 18), + expectPath: "/images/generations", + expectForm: map[string]string{ + "prompt": "draw a cat", + "model": "gpt-image-default", + }, + expectMime: "image/png", + expectContent: []byte("image-bytes"), + }, + { + name: "uses explicit quality size and timeout", + prompt: contractsai.ImagePrompt{ + Prompt: "draw a cat", + Model: "gpt-image-override", + Size: contractsai.ImageSizeLandscape, + Quality: contractsai.ImageQualityHigh, + Timeout: 2 * time.Second, + }, + status: http.StatusOK, + response: imageResponseBody(t, "jpeg", "jpeg-bytes", 1, 2, 3), + expectPath: "/images/generations", + expectForm: map[string]string{ + "prompt": "draw a cat", + "model": "gpt-image-override", + "size": "1536x1024", + "quality": "high", + }, + expectMime: "image/jpeg", + expectContent: []byte("jpeg-bytes"), + }, + { + name: "edits image when attachments provided", + prompt: contractsai.ImagePrompt{ + Prompt: "turn this into watercolor", + Size: contractsai.ImageSizePortrait, + Quality: contractsai.ImageQualityMedium, + Attachments: []contractsai.Attachment{namedAttachment{kind: contractsai.AttachmentKindImage, filename: "photo.png", mimeType: "image/png", content: []byte("source-image")}}, + }, + status: http.StatusOK, + response: imageResponseBody(t, "webp", "webp-bytes", 4, 5, 9), + expectPath: "/images/edits", + expectForm: map[string]string{ + "prompt": "turn this into watercolor", + "model": "gpt-image-default", + "size": "1024x1536", + "quality": "medium", + }, + expectFiles: []capturedImageFile{{fieldName: "image[]", fileName: "photo.png", mimeType: "image/png", body: []byte("source-image")}}, + expectMime: "image/webp", + expectContent: []byte("webp-bytes"), + }, + { + name: "returns error for empty prompt", + prompt: contractsai.ImagePrompt{}, + expectError: errors.AIImagePromptRequired, + }, + { + name: "returns error for non image attachment", + prompt: contractsai.ImagePrompt{ + Prompt: "draw a cat", + Attachments: []contractsai.Attachment{namedAttachment{kind: contractsai.AttachmentKindFile, filename: "report.txt", mimeType: "text/plain", content: []byte("report")}}, + }, + expectError: errors.AIImageAttachmentRequired, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + captured := make(chan capturedImageRequest, 1) + server := newImagesServer(t, tt.status, tt.response, captured) + defer server.Close() + + provider := &Provider{client: goopenai.NewClient(option.WithAPIKey("test-key"), option.WithBaseURL(server.URL))} + provider.config.Models.Image.Default = "gpt-image-default" + + response, err := provider.Image(context.Background(), tt.prompt) + assert.Equal(t, tt.expectError, err) + if tt.expectError != nil { + assert.Nil(t, response) + return + } + + require.NotNil(t, response) + content, contentErr := response.Content(context.Background()) + require.NoError(t, contentErr) + assert.Equal(t, tt.expectContent, content) + assert.Equal(t, tt.expectMime, response.MimeType()) + + req, ok := readCapturedImageRequest(t, captured) + require.True(t, ok, "expected image request payload") + assert.Equal(t, tt.expectPath, req.path) + assert.Equal(t, "Bearer test-key", req.authorization) + assert.Equal(t, tt.expectForm, req.formValues) + assert.Equal(t, tt.expectFiles, req.files) + }) + } +} + func TestProviderPrompt(t *testing.T) { type usageCheck struct { input int @@ -1062,6 +1200,88 @@ func readCapturedFileUploadRequest(t *testing.T, captured <-chan capturedFileUpl } } +func newImagesServer(t *testing.T, status int, response string, captured chan<- capturedImageRequest) *httptest.Server { + t.Helper() + + handler := func(w http.ResponseWriter, r *http.Request) { + defer errors.Ignore(r.Body.Close) + + capturedRequest := capturedImageRequest{ + path: r.URL.Path, + authorization: r.Header.Get("Authorization"), + contentType: r.Header.Get("Content-Type"), + } + + if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + reader, err := r.MultipartReader() + require.NoError(t, err) + + capturedRequest.formValues = make(map[string]string) + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + require.NoError(t, err) + + body, readErr := io.ReadAll(part) + require.NoError(t, readErr) + + if part.FileName() != "" { + capturedRequest.files = append(capturedRequest.files, capturedImageFile{ + fieldName: part.FormName(), + fileName: part.FileName(), + mimeType: part.Header.Get("Content-Type"), + body: body, + }) + continue + } + + capturedRequest.formValues[part.FormName()] = string(body) + } + } else { + payload := decodeBodyMap(t, r) + body, err := json.Marshal(payload) + require.NoError(t, err) + capturedRequest.body = body + capturedRequest.formValues = make(map[string]string) + for key, value := range payload { + switch val := value.(type) { + case string: + capturedRequest.formValues[key] = val + } + } + } + + select { + case captured <- capturedRequest: + default: + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(response)) + } + + mux := http.NewServeMux() + mux.HandleFunc("/images/generations", handler) + mux.HandleFunc("/v1/images/generations", handler) + mux.HandleFunc("/images/edits", handler) + mux.HandleFunc("/v1/images/edits", handler) + + return httptest.NewServer(mux) +} + +func readCapturedImageRequest(t *testing.T, captured <-chan capturedImageRequest) (capturedImageRequest, bool) { + t.Helper() + select { + case req := <-captured: + return req, true + default: + return capturedImageRequest{}, false + } +} + func decodeBodyMap(t *testing.T, r *http.Request) map[string]any { t.Helper() body, err := io.ReadAll(r.Body) @@ -1183,6 +1403,28 @@ func responseBody(t *testing.T, text string, output []map[string]any, inputToken return string(encoded) } +func imageResponseBody(t *testing.T, format, content string, inputTokens, outputTokens, totalTokens int) string { + t.Helper() + + body := map[string]any{ + "created": 123, + "output_format": format, + "data": []map[string]any{{ + "b64_json": base64.StdEncoding.EncodeToString([]byte(content)), + }}, + "usage": map[string]any{ + "input_tokens": inputTokens, + "input_tokens_details": map[string]any{"image_tokens": 0, "text_tokens": inputTokens}, + "output_tokens": outputTokens, + "total_tokens": totalTokens, + }, + } + + encoded, err := json.Marshal(body) + require.NoError(t, err) + return string(encoded) +} + func bodySequence(body string) []string { return []string{body} } diff --git a/ai/openai/response.go b/ai/openai/response.go index aa4e9f663..a22b38dfc 100644 --- a/ai/openai/response.go +++ b/ai/openai/response.go @@ -1,6 +1,11 @@ package openai -import contractsai "github.com/goravel/framework/contracts/ai" +import ( + "bytes" + "context" + + contractsai "github.com/goravel/framework/contracts/ai" +) type response struct { text string @@ -12,6 +17,12 @@ type storedFileResponse struct { id string } +type imageResponse struct { + mimeType string + content []byte + usage *usage +} + func (r *response) Text() string { return r.text } func (r *response) Usage() contractsai.Usage { return r.usage } func (r *response) ToolCalls() []contractsai.ToolCall { return r.toolCalls } @@ -27,6 +38,22 @@ func (r *response) Then(callback func(contractsai.Response)) contractsai.Respons func (r *storedFileResponse) ID() string { return r.id } +func (r *imageResponse) Content(context.Context) ([]byte, error) { return bytes.Clone(r.content), nil } + +func (r *imageResponse) MimeType() string { return r.mimeType } + +func (r *imageResponse) Usage() contractsai.Usage { return r.usage } + +func (r *imageResponse) Then(callback func(contractsai.ImageResponse)) contractsai.ImageResponse { + if callback == nil { + return r + } + + callback(r) + + return r +} + type usage struct{ input, output, total int } func (r *usage) Input() int { return r.input } diff --git a/ai/setup/stubs.go b/ai/setup/stubs.go index c98203233..cd8e2827d 100644 --- a/ai/setup/stubs.go +++ b/ai/setup/stubs.go @@ -29,6 +29,14 @@ func init() { "providers": map[string]any{ "openai": map[string]any{ "key": "", + "models": map[string]any{ + "text": map[string]any{ + "default": "", + }, + "image": map[string]any{ + "default": "", + }, + }, "url": "", "via": "", }, diff --git a/contracts/ai/ai.go b/contracts/ai/ai.go index 8557dacbb..039918616 100644 --- a/contracts/ai/ai.go +++ b/contracts/ai/ai.go @@ -6,6 +6,8 @@ import "context" type AI interface { // Agent creates a conversation bound to the resolved driver. Agent(agent Agent, options ...Option) (Conversation, error) + // Image creates a fluent image generation request bound to the resolved driver. + Image(prompt string, options ...Option) ImageRequest // WithContext returns a new AI instance that carries the provided context for all operations. WithContext(ctx context.Context) AI } diff --git a/contracts/ai/config.go b/contracts/ai/config.go index f286f6761..50c54680a 100644 --- a/contracts/ai/config.go +++ b/contracts/ai/config.go @@ -16,4 +16,7 @@ type ModelsConfig struct { Text struct { Default string `json:"default"` } `json:"text"` + Image struct { + Default string `json:"default"` + } `json:"image"` } diff --git a/contracts/ai/image.go b/contracts/ai/image.go new file mode 100644 index 000000000..fab8f995b --- /dev/null +++ b/contracts/ai/image.go @@ -0,0 +1,31 @@ +package ai + +import "time" + +type ImageQuality string + +const ( + ImageQualityLow ImageQuality = "low" + ImageQualityMedium ImageQuality = "medium" + ImageQualityHigh ImageQuality = "high" +) + +type ImageSize string + +const ( + ImageSizeSquare ImageSize = "1024x1024" + ImageSizePortrait ImageSize = "1024x1536" + ImageSizeLandscape ImageSize = "1536x1024" +) + +type ImageRequest interface { + Model(model string) ImageRequest + Provider(provider string) ImageRequest + Square() ImageRequest + Portrait() ImageRequest + Landscape() ImageRequest + Quality(quality ImageQuality) ImageRequest + Attachments(attachments ...Attachment) ImageRequest + Timeout(timeout time.Duration) ImageRequest + Generate() (ImageResponse, error) +} diff --git a/contracts/ai/provider.go b/contracts/ai/provider.go index 9ae743c14..222188780 100644 --- a/contracts/ai/provider.go +++ b/contracts/ai/provider.go @@ -1,6 +1,9 @@ package ai -import "context" +import ( + "context" + "time" +) // ProviderState stores provider-scoped conversation state across prompt calls. type ProviderState interface { @@ -21,6 +24,15 @@ type AgentPrompt struct { ProviderState ProviderState } +type ImagePrompt struct { + Prompt string + Model string + Size ImageSize + Quality ImageQuality + Attachments []Attachment + Timeout time.Duration +} + // Provider defines low-level model interactions (text generation). // Future: extend with TextProvider, ImageProvider, AudioProvider, etc. type Provider interface { @@ -30,6 +42,12 @@ type Provider interface { Stream(ctx context.Context, prompt AgentPrompt) (StreamableResponse, error) } +// ImageProvider is implemented by providers that support image generation. +type ImageProvider interface { + // Image executes an image generation or edit request. + Image(ctx context.Context, prompt ImagePrompt) (ImageResponse, error) +} + // FileProvider is implemented by providers that support storing files before they are referenced by prompts. type FileProvider interface { // PutFile uploads the given file and returns the provider-managed file reference. diff --git a/contracts/ai/response.go b/contracts/ai/response.go index 38d0f8745..245d69d10 100644 --- a/contracts/ai/response.go +++ b/contracts/ai/response.go @@ -1,5 +1,7 @@ package ai +import "context" + // Response exposes generated text and provider metadata. type Response interface { Text() string @@ -17,3 +19,11 @@ type Usage interface { Output() int Total() int } + +// ImageResponse exposes generated image bytes and provider metadata. +type ImageResponse interface { + Content(ctx context.Context) ([]byte, error) + MimeType() string + Usage() Usage + Then(callback func(ImageResponse)) ImageResponse +} diff --git a/errors/list.go b/errors/list.go index 9be0e9fec..05b2a5378 100644 --- a/errors/list.go +++ b/errors/list.go @@ -40,6 +40,10 @@ var ( AIFacadeNotSet = New("ai facade is not initialized") AIAttachmentUrlResponseNotOK = New("ai: attachment url request failed with status %d") AIProviderDoesNotSupportFiles = New("ai: provider %s does not support files") + AIProviderDoesNotSupportImages = New("ai: provider %s does not support images") + AIImageAttachmentRequired = New("ai: image attachments must all be image kind") + AIImagePromptRequired = New("ai: image prompt is required") + AIImageResponseIsEmpty = New("ai: image response is empty") AIResponseIsNil = New("ai: response is nil") AIStreamRunnerRequired = New("ai stream runner is required") AIUnsupportedAttachmentKind = New("ai: unsupported attachment kind %s") diff --git a/mocks/ai/AI.go b/mocks/ai/AI.go index 9daf4a6bf..15918ffd8 100644 --- a/mocks/ai/AI.go +++ b/mocks/ai/AI.go @@ -96,6 +96,69 @@ func (_c *AI_Agent_Call) RunAndReturn(run func(ai.Agent, ...ai.Option) (ai.Conve return _c } +// Image provides a mock function with given fields: prompt, options +func (_m *AI) Image(prompt string, options ...ai.Option) ai.ImageRequest { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, prompt) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Image") + } + + var r0 ai.ImageRequest + if rf, ok := ret.Get(0).(func(string, ...ai.Option) ai.ImageRequest); ok { + r0 = rf(prompt, options...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageRequest) + } + } + + return r0 +} + +// AI_Image_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Image' +type AI_Image_Call struct { + *mock.Call +} + +// Image is a helper method to define mock.On call +// - prompt string +// - options ...ai.Option +func (_e *AI_Expecter) Image(prompt interface{}, options ...interface{}) *AI_Image_Call { + return &AI_Image_Call{Call: _e.mock.On("Image", + append([]interface{}{prompt}, options...)...)} +} + +func (_c *AI_Image_Call) Run(run func(prompt string, options ...ai.Option)) *AI_Image_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]ai.Option, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(ai.Option) + } + } + run(args[0].(string), variadicArgs...) + }) + return _c +} + +func (_c *AI_Image_Call) Return(_a0 ai.ImageRequest) *AI_Image_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *AI_Image_Call) RunAndReturn(run func(string, ...ai.Option) ai.ImageRequest) *AI_Image_Call { + _c.Call.Return(run) + return _c +} + // WithContext provides a mock function with given fields: ctx func (_m *AI) WithContext(ctx context.Context) ai.AI { ret := _m.Called(ctx) diff --git a/mocks/ai/ImageProvider.go b/mocks/ai/ImageProvider.go new file mode 100644 index 000000000..dbbc0d7e9 --- /dev/null +++ b/mocks/ai/ImageProvider.go @@ -0,0 +1,97 @@ +// Code generated by mockery. DO NOT EDIT. + +package ai + +import ( + context "context" + + ai "github.com/goravel/framework/contracts/ai" + + mock "github.com/stretchr/testify/mock" +) + +// ImageProvider is an autogenerated mock type for the ImageProvider type +type ImageProvider struct { + mock.Mock +} + +type ImageProvider_Expecter struct { + mock *mock.Mock +} + +func (_m *ImageProvider) EXPECT() *ImageProvider_Expecter { + return &ImageProvider_Expecter{mock: &_m.Mock} +} + +// Image provides a mock function with given fields: ctx, prompt +func (_m *ImageProvider) Image(ctx context.Context, prompt ai.ImagePrompt) (ai.ImageResponse, error) { + ret := _m.Called(ctx, prompt) + + if len(ret) == 0 { + panic("no return value specified for Image") + } + + var r0 ai.ImageResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, ai.ImagePrompt) (ai.ImageResponse, error)); ok { + return rf(ctx, prompt) + } + if rf, ok := ret.Get(0).(func(context.Context, ai.ImagePrompt) ai.ImageResponse); ok { + r0 = rf(ctx, prompt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, ai.ImagePrompt) error); ok { + r1 = rf(ctx, prompt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ImageProvider_Image_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Image' +type ImageProvider_Image_Call struct { + *mock.Call +} + +// Image is a helper method to define mock.On call +// - ctx context.Context +// - prompt ai.ImagePrompt +func (_e *ImageProvider_Expecter) Image(ctx interface{}, prompt interface{}) *ImageProvider_Image_Call { + return &ImageProvider_Image_Call{Call: _e.mock.On("Image", ctx, prompt)} +} + +func (_c *ImageProvider_Image_Call) Run(run func(ctx context.Context, prompt ai.ImagePrompt)) *ImageProvider_Image_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(ai.ImagePrompt)) + }) + return _c +} + +func (_c *ImageProvider_Image_Call) Return(_a0 ai.ImageResponse, _a1 error) *ImageProvider_Image_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ImageProvider_Image_Call) RunAndReturn(run func(context.Context, ai.ImagePrompt) (ai.ImageResponse, error)) *ImageProvider_Image_Call { + _c.Call.Return(run) + return _c +} + +// NewImageProvider creates a new instance of ImageProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewImageProvider(t interface { + mock.TestingT + Cleanup(func()) +}) *ImageProvider { + mock := &ImageProvider{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/ai/ImageRequest.go b/mocks/ai/ImageRequest.go new file mode 100644 index 000000000..5141e416c --- /dev/null +++ b/mocks/ai/ImageRequest.go @@ -0,0 +1,488 @@ +// Code generated by mockery. DO NOT EDIT. + +package ai + +import ( + ai "github.com/goravel/framework/contracts/ai" + mock "github.com/stretchr/testify/mock" + + time "time" +) + +// ImageRequest is an autogenerated mock type for the ImageRequest type +type ImageRequest struct { + mock.Mock +} + +type ImageRequest_Expecter struct { + mock *mock.Mock +} + +func (_m *ImageRequest) EXPECT() *ImageRequest_Expecter { + return &ImageRequest_Expecter{mock: &_m.Mock} +} + +// Attachments provides a mock function with given fields: attachments +func (_m *ImageRequest) Attachments(attachments ...ai.Attachment) ai.ImageRequest { + _va := make([]interface{}, len(attachments)) + for _i := range attachments { + _va[_i] = attachments[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Attachments") + } + + var r0 ai.ImageRequest + if rf, ok := ret.Get(0).(func(...ai.Attachment) ai.ImageRequest); ok { + r0 = rf(attachments...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageRequest) + } + } + + return r0 +} + +// ImageRequest_Attachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Attachments' +type ImageRequest_Attachments_Call struct { + *mock.Call +} + +// Attachments is a helper method to define mock.On call +// - attachments ...ai.Attachment +func (_e *ImageRequest_Expecter) Attachments(attachments ...interface{}) *ImageRequest_Attachments_Call { + return &ImageRequest_Attachments_Call{Call: _e.mock.On("Attachments", + append([]interface{}{}, attachments...)...)} +} + +func (_c *ImageRequest_Attachments_Call) Run(run func(attachments ...ai.Attachment)) *ImageRequest_Attachments_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]ai.Attachment, len(args)-0) + for i, a := range args[0:] { + if a != nil { + variadicArgs[i] = a.(ai.Attachment) + } + } + run(variadicArgs...) + }) + return _c +} + +func (_c *ImageRequest_Attachments_Call) Return(_a0 ai.ImageRequest) *ImageRequest_Attachments_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ImageRequest_Attachments_Call) RunAndReturn(run func(...ai.Attachment) ai.ImageRequest) *ImageRequest_Attachments_Call { + _c.Call.Return(run) + return _c +} + +// Generate provides a mock function with no fields +func (_m *ImageRequest) Generate() (ai.ImageResponse, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Generate") + } + + var r0 ai.ImageResponse + var r1 error + if rf, ok := ret.Get(0).(func() (ai.ImageResponse, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() ai.ImageResponse); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageResponse) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ImageRequest_Generate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Generate' +type ImageRequest_Generate_Call struct { + *mock.Call +} + +// Generate is a helper method to define mock.On call +func (_e *ImageRequest_Expecter) Generate() *ImageRequest_Generate_Call { + return &ImageRequest_Generate_Call{Call: _e.mock.On("Generate")} +} + +func (_c *ImageRequest_Generate_Call) Run(run func()) *ImageRequest_Generate_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ImageRequest_Generate_Call) Return(_a0 ai.ImageResponse, _a1 error) *ImageRequest_Generate_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ImageRequest_Generate_Call) RunAndReturn(run func() (ai.ImageResponse, error)) *ImageRequest_Generate_Call { + _c.Call.Return(run) + return _c +} + +// Landscape provides a mock function with no fields +func (_m *ImageRequest) Landscape() ai.ImageRequest { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Landscape") + } + + var r0 ai.ImageRequest + if rf, ok := ret.Get(0).(func() ai.ImageRequest); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageRequest) + } + } + + return r0 +} + +// ImageRequest_Landscape_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Landscape' +type ImageRequest_Landscape_Call struct { + *mock.Call +} + +// Landscape is a helper method to define mock.On call +func (_e *ImageRequest_Expecter) Landscape() *ImageRequest_Landscape_Call { + return &ImageRequest_Landscape_Call{Call: _e.mock.On("Landscape")} +} + +func (_c *ImageRequest_Landscape_Call) Run(run func()) *ImageRequest_Landscape_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ImageRequest_Landscape_Call) Return(_a0 ai.ImageRequest) *ImageRequest_Landscape_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ImageRequest_Landscape_Call) RunAndReturn(run func() ai.ImageRequest) *ImageRequest_Landscape_Call { + _c.Call.Return(run) + return _c +} + +// Model provides a mock function with given fields: model +func (_m *ImageRequest) Model(model string) ai.ImageRequest { + ret := _m.Called(model) + + if len(ret) == 0 { + panic("no return value specified for Model") + } + + var r0 ai.ImageRequest + if rf, ok := ret.Get(0).(func(string) ai.ImageRequest); ok { + r0 = rf(model) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageRequest) + } + } + + return r0 +} + +// ImageRequest_Model_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Model' +type ImageRequest_Model_Call struct { + *mock.Call +} + +// Model is a helper method to define mock.On call +// - model string +func (_e *ImageRequest_Expecter) Model(model interface{}) *ImageRequest_Model_Call { + return &ImageRequest_Model_Call{Call: _e.mock.On("Model", model)} +} + +func (_c *ImageRequest_Model_Call) Run(run func(model string)) *ImageRequest_Model_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *ImageRequest_Model_Call) Return(_a0 ai.ImageRequest) *ImageRequest_Model_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ImageRequest_Model_Call) RunAndReturn(run func(string) ai.ImageRequest) *ImageRequest_Model_Call { + _c.Call.Return(run) + return _c +} + +// Portrait provides a mock function with no fields +func (_m *ImageRequest) Portrait() ai.ImageRequest { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Portrait") + } + + var r0 ai.ImageRequest + if rf, ok := ret.Get(0).(func() ai.ImageRequest); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageRequest) + } + } + + return r0 +} + +// ImageRequest_Portrait_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Portrait' +type ImageRequest_Portrait_Call struct { + *mock.Call +} + +// Portrait is a helper method to define mock.On call +func (_e *ImageRequest_Expecter) Portrait() *ImageRequest_Portrait_Call { + return &ImageRequest_Portrait_Call{Call: _e.mock.On("Portrait")} +} + +func (_c *ImageRequest_Portrait_Call) Run(run func()) *ImageRequest_Portrait_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ImageRequest_Portrait_Call) Return(_a0 ai.ImageRequest) *ImageRequest_Portrait_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ImageRequest_Portrait_Call) RunAndReturn(run func() ai.ImageRequest) *ImageRequest_Portrait_Call { + _c.Call.Return(run) + return _c +} + +// Provider provides a mock function with given fields: provider +func (_m *ImageRequest) Provider(provider string) ai.ImageRequest { + ret := _m.Called(provider) + + if len(ret) == 0 { + panic("no return value specified for Provider") + } + + var r0 ai.ImageRequest + if rf, ok := ret.Get(0).(func(string) ai.ImageRequest); ok { + r0 = rf(provider) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageRequest) + } + } + + return r0 +} + +// ImageRequest_Provider_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Provider' +type ImageRequest_Provider_Call struct { + *mock.Call +} + +// Provider is a helper method to define mock.On call +// - provider string +func (_e *ImageRequest_Expecter) Provider(provider interface{}) *ImageRequest_Provider_Call { + return &ImageRequest_Provider_Call{Call: _e.mock.On("Provider", provider)} +} + +func (_c *ImageRequest_Provider_Call) Run(run func(provider string)) *ImageRequest_Provider_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *ImageRequest_Provider_Call) Return(_a0 ai.ImageRequest) *ImageRequest_Provider_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ImageRequest_Provider_Call) RunAndReturn(run func(string) ai.ImageRequest) *ImageRequest_Provider_Call { + _c.Call.Return(run) + return _c +} + +// Quality provides a mock function with given fields: quality +func (_m *ImageRequest) Quality(quality ai.ImageQuality) ai.ImageRequest { + ret := _m.Called(quality) + + if len(ret) == 0 { + panic("no return value specified for Quality") + } + + var r0 ai.ImageRequest + if rf, ok := ret.Get(0).(func(ai.ImageQuality) ai.ImageRequest); ok { + r0 = rf(quality) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageRequest) + } + } + + return r0 +} + +// ImageRequest_Quality_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Quality' +type ImageRequest_Quality_Call struct { + *mock.Call +} + +// Quality is a helper method to define mock.On call +// - quality ai.ImageQuality +func (_e *ImageRequest_Expecter) Quality(quality interface{}) *ImageRequest_Quality_Call { + return &ImageRequest_Quality_Call{Call: _e.mock.On("Quality", quality)} +} + +func (_c *ImageRequest_Quality_Call) Run(run func(quality ai.ImageQuality)) *ImageRequest_Quality_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(ai.ImageQuality)) + }) + return _c +} + +func (_c *ImageRequest_Quality_Call) Return(_a0 ai.ImageRequest) *ImageRequest_Quality_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ImageRequest_Quality_Call) RunAndReturn(run func(ai.ImageQuality) ai.ImageRequest) *ImageRequest_Quality_Call { + _c.Call.Return(run) + return _c +} + +// Square provides a mock function with no fields +func (_m *ImageRequest) Square() ai.ImageRequest { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Square") + } + + var r0 ai.ImageRequest + if rf, ok := ret.Get(0).(func() ai.ImageRequest); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageRequest) + } + } + + return r0 +} + +// ImageRequest_Square_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Square' +type ImageRequest_Square_Call struct { + *mock.Call +} + +// Square is a helper method to define mock.On call +func (_e *ImageRequest_Expecter) Square() *ImageRequest_Square_Call { + return &ImageRequest_Square_Call{Call: _e.mock.On("Square")} +} + +func (_c *ImageRequest_Square_Call) Run(run func()) *ImageRequest_Square_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ImageRequest_Square_Call) Return(_a0 ai.ImageRequest) *ImageRequest_Square_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ImageRequest_Square_Call) RunAndReturn(run func() ai.ImageRequest) *ImageRequest_Square_Call { + _c.Call.Return(run) + return _c +} + +// Timeout provides a mock function with given fields: timeout +func (_m *ImageRequest) Timeout(timeout time.Duration) ai.ImageRequest { + ret := _m.Called(timeout) + + if len(ret) == 0 { + panic("no return value specified for Timeout") + } + + var r0 ai.ImageRequest + if rf, ok := ret.Get(0).(func(time.Duration) ai.ImageRequest); ok { + r0 = rf(timeout) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageRequest) + } + } + + return r0 +} + +// ImageRequest_Timeout_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Timeout' +type ImageRequest_Timeout_Call struct { + *mock.Call +} + +// Timeout is a helper method to define mock.On call +// - timeout time.Duration +func (_e *ImageRequest_Expecter) Timeout(timeout interface{}) *ImageRequest_Timeout_Call { + return &ImageRequest_Timeout_Call{Call: _e.mock.On("Timeout", timeout)} +} + +func (_c *ImageRequest_Timeout_Call) Run(run func(timeout time.Duration)) *ImageRequest_Timeout_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(time.Duration)) + }) + return _c +} + +func (_c *ImageRequest_Timeout_Call) Return(_a0 ai.ImageRequest) *ImageRequest_Timeout_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ImageRequest_Timeout_Call) RunAndReturn(run func(time.Duration) ai.ImageRequest) *ImageRequest_Timeout_Call { + _c.Call.Return(run) + return _c +} + +// NewImageRequest creates a new instance of ImageRequest. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewImageRequest(t interface { + mock.TestingT + Cleanup(func()) +}) *ImageRequest { + mock := &ImageRequest{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/ai/ImageResponse.go b/mocks/ai/ImageResponse.go new file mode 100644 index 000000000..f58bcd25d --- /dev/null +++ b/mocks/ai/ImageResponse.go @@ -0,0 +1,236 @@ +// Code generated by mockery. DO NOT EDIT. + +package ai + +import ( + context "context" + + ai "github.com/goravel/framework/contracts/ai" + + mock "github.com/stretchr/testify/mock" +) + +// ImageResponse is an autogenerated mock type for the ImageResponse type +type ImageResponse struct { + mock.Mock +} + +type ImageResponse_Expecter struct { + mock *mock.Mock +} + +func (_m *ImageResponse) EXPECT() *ImageResponse_Expecter { + return &ImageResponse_Expecter{mock: &_m.Mock} +} + +// Content provides a mock function with given fields: ctx +func (_m *ImageResponse) Content(ctx context.Context) ([]byte, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Content") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) ([]byte, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) []byte); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ImageResponse_Content_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Content' +type ImageResponse_Content_Call struct { + *mock.Call +} + +// Content is a helper method to define mock.On call +// - ctx context.Context +func (_e *ImageResponse_Expecter) Content(ctx interface{}) *ImageResponse_Content_Call { + return &ImageResponse_Content_Call{Call: _e.mock.On("Content", ctx)} +} + +func (_c *ImageResponse_Content_Call) Run(run func(ctx context.Context)) *ImageResponse_Content_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *ImageResponse_Content_Call) Return(_a0 []byte, _a1 error) *ImageResponse_Content_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ImageResponse_Content_Call) RunAndReturn(run func(context.Context) ([]byte, error)) *ImageResponse_Content_Call { + _c.Call.Return(run) + return _c +} + +// MimeType provides a mock function with no fields +func (_m *ImageResponse) MimeType() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MimeType") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// ImageResponse_MimeType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MimeType' +type ImageResponse_MimeType_Call struct { + *mock.Call +} + +// MimeType is a helper method to define mock.On call +func (_e *ImageResponse_Expecter) MimeType() *ImageResponse_MimeType_Call { + return &ImageResponse_MimeType_Call{Call: _e.mock.On("MimeType")} +} + +func (_c *ImageResponse_MimeType_Call) Run(run func()) *ImageResponse_MimeType_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ImageResponse_MimeType_Call) Return(_a0 string) *ImageResponse_MimeType_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ImageResponse_MimeType_Call) RunAndReturn(run func() string) *ImageResponse_MimeType_Call { + _c.Call.Return(run) + return _c +} + +// Then provides a mock function with given fields: callback +func (_m *ImageResponse) Then(callback func(ai.ImageResponse)) ai.ImageResponse { + ret := _m.Called(callback) + + if len(ret) == 0 { + panic("no return value specified for Then") + } + + var r0 ai.ImageResponse + if rf, ok := ret.Get(0).(func(func(ai.ImageResponse)) ai.ImageResponse); ok { + r0 = rf(callback) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.ImageResponse) + } + } + + return r0 +} + +// ImageResponse_Then_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Then' +type ImageResponse_Then_Call struct { + *mock.Call +} + +// Then is a helper method to define mock.On call +// - callback func(ai.ImageResponse) +func (_e *ImageResponse_Expecter) Then(callback interface{}) *ImageResponse_Then_Call { + return &ImageResponse_Then_Call{Call: _e.mock.On("Then", callback)} +} + +func (_c *ImageResponse_Then_Call) Run(run func(callback func(ai.ImageResponse))) *ImageResponse_Then_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(func(ai.ImageResponse))) + }) + return _c +} + +func (_c *ImageResponse_Then_Call) Return(_a0 ai.ImageResponse) *ImageResponse_Then_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ImageResponse_Then_Call) RunAndReturn(run func(func(ai.ImageResponse)) ai.ImageResponse) *ImageResponse_Then_Call { + _c.Call.Return(run) + return _c +} + +// Usage provides a mock function with no fields +func (_m *ImageResponse) Usage() ai.Usage { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Usage") + } + + var r0 ai.Usage + if rf, ok := ret.Get(0).(func() ai.Usage); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ai.Usage) + } + } + + return r0 +} + +// ImageResponse_Usage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Usage' +type ImageResponse_Usage_Call struct { + *mock.Call +} + +// Usage is a helper method to define mock.On call +func (_e *ImageResponse_Expecter) Usage() *ImageResponse_Usage_Call { + return &ImageResponse_Usage_Call{Call: _e.mock.On("Usage")} +} + +func (_c *ImageResponse_Usage_Call) Run(run func()) *ImageResponse_Usage_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ImageResponse_Usage_Call) Return(_a0 ai.Usage) *ImageResponse_Usage_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ImageResponse_Usage_Call) RunAndReturn(run func() ai.Usage) *ImageResponse_Usage_Call { + _c.Call.Return(run) + return _c +} + +// NewImageResponse creates a new instance of ImageResponse. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewImageResponse(t interface { + mock.TestingT + Cleanup(func()) +}) *ImageResponse { + mock := &ImageResponse{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/schedule/application_test.go b/schedule/application_test.go index 050d8d2da..2a4b6dccc 100644 --- a/schedule/application_test.go +++ b/schedule/application_test.go @@ -3,6 +3,7 @@ package schedule import ( "context" "strings" + "sync/atomic" "testing" "time" @@ -13,7 +14,6 @@ import ( mockscache "github.com/goravel/framework/mocks/cache" mocksconsole "github.com/goravel/framework/mocks/console" mockslog "github.com/goravel/framework/mocks/log" - "github.com/goravel/framework/support/env" ) type ApplicationTestSuite struct { @@ -30,20 +30,22 @@ func (s *ApplicationTestSuite) SetupTest() { func (s *ApplicationTestSuite) TestCallAndCommand() { mockArtisan := mocksconsole.NewArtisan(s.T()) - mockArtisan.EXPECT().Call("test --name Goravel argument0 argument1").Return(nil).Times(2) + var commandCall atomic.Int64 + mockArtisan.EXPECT().Call("test --name Goravel argument0 argument1").RunAndReturn(func(string) error { + commandCall.Add(1) + return nil + }).Times(3) mockLog := mockslog.NewLog(s.T()) + var panicCall atomic.Int64 + mockLog.EXPECT().Error("panic", mock.Anything).Run(func(args ...any) { + panicCall.Add(1) + }).Return().Times(3) - if env.IsWindows() { - // The Windows system is not stable when runing the last time - mockLog.EXPECT().Error("panic", mock.Anything).Return() - } else { - mockLog.EXPECT().Error("panic", mock.Anything).Return().Times(4) - } - - immediatelyCall := 0 - delayIfStillRunningCall := 0 - skipIfStillRunningCall := 0 + var immediatelyCall atomic.Int64 + var delayIfStillRunningCall atomic.Int64 + var skipIfStillRunningCall atomic.Int64 + shutdownErr := make(chan error, 1) app := NewApplication(mockArtisan, nil, mockLog, false) app.Register([]schedule.Event{ @@ -51,35 +53,38 @@ func (s *ApplicationTestSuite) TestCallAndCommand() { panic(1) }).Cron("* * * * * *"), app.Call(func() { - immediatelyCall++ + if immediatelyCall.Add(1) == 3 { + go func() { + shutdownErr <- app.Shutdown() + }() + } }).Cron("* * * * * *"), app.Call(func() { - time.Sleep(2 * time.Second) - delayIfStillRunningCall++ + time.Sleep(1100 * time.Millisecond) + delayIfStillRunningCall.Add(1) }).Cron("* * * * * *").DelayIfStillRunning(), app.Call(func() { - time.Sleep(2 * time.Second) - skipIfStillRunningCall++ + time.Sleep(2500 * time.Millisecond) + skipIfStillRunningCall.Add(1) }).Cron("* * * * * *").SkipIfStillRunning(), - app.Command("test --name Goravel argument0 argument1").Cron("*/2 * * * * *"), + app.Command("test --name Goravel argument0 argument1").Cron("* * * * * *"), }) go app.Run() - time.Sleep(4 * time.Second) - - s.NoError(app.Shutdown()) - - if env.IsWindows() { - // The Windows system is not stable when runing the last time - s.True(immediatelyCall >= 3 && immediatelyCall <= 4) - s.True(delayIfStillRunningCall >= 3 && delayIfStillRunningCall <= 4) - s.True(skipIfStillRunningCall >= 1 && skipIfStillRunningCall <= 2) - } else { - s.Equal(4, immediatelyCall) - s.Equal(4, delayIfStillRunningCall) - s.Equal(2, skipIfStillRunningCall) + select { + case err := <-shutdownErr: + s.NoError(err) + case <-time.After(10 * time.Second): + s.NoError(app.Shutdown()) + s.FailNow("timed out waiting for scheduler shutdown") } + + s.Equal(int64(3), immediatelyCall.Load()) + s.Equal(int64(3), delayIfStillRunningCall.Load()) + s.Equal(int64(1), skipIfStillRunningCall.Load()) + s.Equal(int64(3), commandCall.Load()) + s.Equal(int64(3), panicCall.Load()) } func (s *ApplicationTestSuite) TestOnOneServer() {