add image generation tool
This commit is contained in:
parent
eb4e187550
commit
31a771b1f7
5 changed files with 637 additions and 0 deletions
|
|
@ -215,6 +215,14 @@ func registerSharedTools(
|
|||
agent.Tools.Register(loadImageTool)
|
||||
}
|
||||
|
||||
if cfg.Tools.IsToolEnabled("image_generate") {
|
||||
imageModel := cfg.Agents.Defaults.ImageModel
|
||||
if imageModel == "" {
|
||||
imageModel = "gpt-image-2"
|
||||
}
|
||||
agent.Tools.Register(tools.NewImageGenerateTool(agent.Workspace, imageModel, nil))
|
||||
}
|
||||
|
||||
// Skill discovery and installation tools
|
||||
skills_enabled := cfg.Tools.IsToolEnabled("skills")
|
||||
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
||||
|
|
|
|||
|
|
@ -827,6 +827,7 @@ type ToolsConfig struct {
|
|||
EditFile ToolConfig `json:"edit_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
||||
FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
||||
I2C ToolConfig `json:"i2c" yaml:"-" envPrefix:"PICOCLAW_TOOLS_I2C_"`
|
||||
ImageGenerate ToolConfig `json:"image_generate" yaml:"-" envPrefix:"PICOCLAW_TOOLS_IMAGE_GENERATE_"`
|
||||
InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||
ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||
Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
||||
|
|
@ -1551,6 +1552,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
|||
return t.FindSkills.Enabled
|
||||
case "i2c":
|
||||
return t.I2C.Enabled
|
||||
case "image_generate":
|
||||
return t.ImageGenerate.Enabled
|
||||
case "install_skill":
|
||||
return t.InstallSkill.Enabled
|
||||
case "list_dir":
|
||||
|
|
|
|||
|
|
@ -397,6 +397,9 @@ func DefaultConfig() *Config {
|
|||
SendFile: ToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
ImageGenerate: ToolConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
SendTTS: ToolConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
|
|
|
|||
516
pkg/tools/image_generate.go
Normal file
516
pkg/tools/image_generate.go
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
oauthprovider "github.com/sipeed/picoclaw/pkg/providers/oauth"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultImageGenerationModel = "gpt-image-2"
|
||||
defaultImageGenerationBaseURL = "https://chatgpt.com/backend-api/codex"
|
||||
defaultImageGenerationSize = "1024x1024"
|
||||
defaultImageGenerationTimeout = 180 * time.Second
|
||||
maxImageGenerationResults = 4
|
||||
maxImageGenerationSSEBytes = 64 * 1024 * 1024
|
||||
maxImageGenerationEvents = 512
|
||||
)
|
||||
|
||||
// ImageGenerateTool generates images through OpenAI/Codex OAuth and returns
|
||||
// generated files through the MediaStore outbound media pipeline.
|
||||
type ImageGenerateTool struct {
|
||||
workspace string
|
||||
model string
|
||||
provider imageGenerationProvider
|
||||
mediaStore media.MediaStore
|
||||
}
|
||||
|
||||
type ImageGenerateToolOption func(*ImageGenerateTool)
|
||||
|
||||
type imageGenerationProvider interface {
|
||||
ID() string
|
||||
DefaultModel() string
|
||||
GenerateImages(ctx context.Context, req imageGenerationRequest) ([]generatedImage, error)
|
||||
}
|
||||
|
||||
func WithImageGenerateBaseURL(baseURL string) ImageGenerateToolOption {
|
||||
return func(t *ImageGenerateTool) {
|
||||
if provider, ok := t.provider.(*openAICodexImageGenerationProvider); ok {
|
||||
provider.baseURL = baseURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithImageGenerateHTTPClient(client *http.Client) ImageGenerateToolOption {
|
||||
return func(t *ImageGenerateTool) {
|
||||
if provider, ok := t.provider.(*openAICodexImageGenerationProvider); ok {
|
||||
provider.httpClient = client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithImageGenerateTokenSource(source func() (string, string, error)) ImageGenerateToolOption {
|
||||
return func(t *ImageGenerateTool) {
|
||||
if provider, ok := t.provider.(*openAICodexImageGenerationProvider); ok {
|
||||
provider.tokenSource = source
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithImageGenerationProvider(provider imageGenerationProvider) ImageGenerateToolOption {
|
||||
return func(t *ImageGenerateTool) {
|
||||
if provider != nil {
|
||||
t.provider = provider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewImageGenerateTool(
|
||||
workspace string,
|
||||
model string,
|
||||
store media.MediaStore,
|
||||
options ...ImageGenerateToolOption,
|
||||
) *ImageGenerateTool {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = defaultImageGenerationModel
|
||||
}
|
||||
provider := newOpenAICodexImageGenerationProvider()
|
||||
tool := &ImageGenerateTool{
|
||||
workspace: workspace,
|
||||
model: stripProviderPrefix(model),
|
||||
provider: provider,
|
||||
mediaStore: store,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(tool)
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func (t *ImageGenerateTool) SetMediaStore(store media.MediaStore) {
|
||||
t.mediaStore = store
|
||||
}
|
||||
|
||||
func (t *ImageGenerateTool) Name() string { return "image_generate" }
|
||||
|
||||
func (t *ImageGenerateTool) Description() string {
|
||||
return `Generate an image from a prompt and send it to the current chat.
|
||||
|
||||
Uses OpenAI/Codex OAuth with gpt-image-2 by default. Use this when the user asks to create an image, infographic, diagram, poster, visual summary, or other generated raster artwork.`
|
||||
}
|
||||
|
||||
func (t *ImageGenerateTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"prompt": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Image generation prompt.",
|
||||
},
|
||||
"size": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Output size. Defaults to 1024x1024. Supported examples: 1024x1024, 1536x1024, 1024x1536, 2048x2048, 3840x2160.",
|
||||
},
|
||||
"quality": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"low", "medium", "high", "auto"},
|
||||
"description": "Optional quality hint.",
|
||||
},
|
||||
"output_format": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"png", "jpeg", "webp"},
|
||||
"description": "Output image format. Defaults to png.",
|
||||
},
|
||||
"count": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Number of images to generate, 1-4. Defaults to 1.",
|
||||
},
|
||||
},
|
||||
"required": []string{"prompt"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ImageGenerateTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
prompt, _ := args["prompt"].(string)
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
return ErrorResult("prompt is required")
|
||||
}
|
||||
if t.mediaStore == nil {
|
||||
return ErrorResult("media store not configured")
|
||||
}
|
||||
if t.provider == nil {
|
||||
return ErrorResult("image generation provider not configured")
|
||||
}
|
||||
|
||||
req := imageGenerationRequest{
|
||||
Prompt: prompt,
|
||||
Model: t.model,
|
||||
Size: readStringDefault(args, "size", defaultImageGenerationSize),
|
||||
Quality: readStringDefault(args, "quality", ""),
|
||||
OutputFormat: readStringDefault(args, "output_format", "png"),
|
||||
Count: readImageCount(args["count"]),
|
||||
}
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
req.Model = t.provider.DefaultModel()
|
||||
}
|
||||
images, err := t.provider.GenerateImages(ctx, req)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("image generation failed: %v", err)).WithError(err)
|
||||
}
|
||||
if len(images) == 0 {
|
||||
return ErrorResult("image generation returned no images")
|
||||
}
|
||||
|
||||
refs := make([]string, 0, len(images))
|
||||
paths := make([]string, 0, len(images))
|
||||
scope := t.mediaScope(ctx)
|
||||
for i, image := range images {
|
||||
path, err := writeGeneratedImage(image, i)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to write generated image: %v", err)).WithError(err)
|
||||
}
|
||||
ref, err := t.mediaStore.Store(path, media.MediaMeta{
|
||||
Filename: filepath.Base(path),
|
||||
ContentType: image.MimeType,
|
||||
Source: "tool:image_generate",
|
||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||
}, scope)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to register generated image: %v", err)).WithError(err)
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
paths = append(paths, path)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Generated %d image(s) with %s via %s.", len(refs), req.Model, t.provider.ID())
|
||||
result := MediaResult(message, refs).WithResponseHandled()
|
||||
result.ArtifactTags = make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
result.ArtifactTags = append(result.ArtifactTags, "[file:"+path+"]")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type imageGenerationRequest struct {
|
||||
Prompt string
|
||||
Model string
|
||||
Size string
|
||||
Quality string
|
||||
OutputFormat string
|
||||
Count int
|
||||
}
|
||||
|
||||
type generatedImage struct {
|
||||
Data []byte
|
||||
MimeType string
|
||||
Ext string
|
||||
}
|
||||
|
||||
type openAICodexImageGenerationProvider struct {
|
||||
baseURL string
|
||||
timeout time.Duration
|
||||
httpClient *http.Client
|
||||
tokenSource func() (accessToken, accountID string, err error)
|
||||
}
|
||||
|
||||
func newOpenAICodexImageGenerationProvider() *openAICodexImageGenerationProvider {
|
||||
return &openAICodexImageGenerationProvider{
|
||||
baseURL: defaultImageGenerationBaseURL,
|
||||
timeout: defaultImageGenerationTimeout,
|
||||
httpClient: http.DefaultClient,
|
||||
tokenSource: oauthprovider.CreateCodexTokenSource(),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *openAICodexImageGenerationProvider) ID() string { return "openai-codex" }
|
||||
|
||||
func (p *openAICodexImageGenerationProvider) DefaultModel() string {
|
||||
return defaultImageGenerationModel
|
||||
}
|
||||
|
||||
func (p *openAICodexImageGenerationProvider) GenerateImages(
|
||||
ctx context.Context,
|
||||
req imageGenerationRequest,
|
||||
) ([]generatedImage, error) {
|
||||
accessToken, accountID, err := p.tokenSource()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OpenAI/Codex OAuth not configured: %w", err)
|
||||
}
|
||||
|
||||
timeout := p.timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultImageGenerationTimeout
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
images := make([]generatedImage, 0, req.Count)
|
||||
for i := 0; i < req.Count; i++ {
|
||||
body, err := json.Marshal(buildCodexImageRequest(req))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(
|
||||
callCtx,
|
||||
http.MethodPost,
|
||||
strings.TrimRight(p.baseURL, "/")+"/responses",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
httpReq.Header.Set("originator", "codex_cli_rs")
|
||||
httpReq.Header.Set("OpenAI-Beta", "responses=experimental")
|
||||
if strings.TrimSpace(accountID) != "" {
|
||||
httpReq.Header.Set("Chatgpt-Account-Id", accountID)
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eventImages, readErr := parseCodexImageSSE(resp.Body, req.OutputFormat)
|
||||
closeErr := resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("codex image request failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, closeErr
|
||||
}
|
||||
images = append(images, eventImages...)
|
||||
}
|
||||
if len(images) > maxImageGenerationResults {
|
||||
images = images[:maxImageGenerationResults]
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func buildCodexImageRequest(req imageGenerationRequest) map[string]any {
|
||||
tool := map[string]any{
|
||||
"type": "image_generation",
|
||||
"model": req.Model,
|
||||
"size": req.Size,
|
||||
}
|
||||
if req.Quality != "" {
|
||||
tool["quality"] = req.Quality
|
||||
}
|
||||
if req.OutputFormat != "" {
|
||||
tool["output_format"] = req.OutputFormat
|
||||
}
|
||||
return map[string]any{
|
||||
"model": "gpt-5.4",
|
||||
"input": []map[string]any{{
|
||||
"role": "user",
|
||||
"content": []map[string]any{{
|
||||
"type": "input_text",
|
||||
"text": req.Prompt,
|
||||
}},
|
||||
}},
|
||||
"instructions": "You are an image generation assistant.",
|
||||
"tools": []map[string]any{tool},
|
||||
"tool_choice": map[string]any{"type": "image_generation"},
|
||||
"stream": true,
|
||||
"store": false,
|
||||
}
|
||||
}
|
||||
|
||||
func parseCodexImageSSE(r io.Reader, outputFormat string) ([]generatedImage, error) {
|
||||
reader := bufio.NewReader(r)
|
||||
var totalBytes int
|
||||
var events int
|
||||
var images []generatedImage
|
||||
var completedImages []generatedImage
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if len(line) > 0 {
|
||||
totalBytes += len(line)
|
||||
if totalBytes > maxImageGenerationSSEBytes {
|
||||
return nil, fmt.Errorf("codex image response exceeded size limit")
|
||||
}
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
events++
|
||||
if events > maxImageGenerationEvents {
|
||||
return nil, fmt.Errorf("codex image response exceeded event limit")
|
||||
}
|
||||
eventImages, eventCompletedImages, parseErr := parseCodexImageEvent(
|
||||
strings.TrimSpace(strings.TrimPrefix(line, "data: ")),
|
||||
outputFormat,
|
||||
)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
images = append(images, eventImages...)
|
||||
completedImages = append(completedImages, eventCompletedImages...)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(images) > 0 {
|
||||
return images, nil
|
||||
}
|
||||
return completedImages, nil
|
||||
}
|
||||
|
||||
func parseCodexImageEvent(data string, outputFormat string) ([]generatedImage, []generatedImage, error) {
|
||||
if data == "" || data == "[DONE]" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
eventType, _ := event["type"].(string)
|
||||
if eventType == "response.failed" || eventType == "error" {
|
||||
return nil, nil, fmt.Errorf("codex image generation failed")
|
||||
}
|
||||
|
||||
var itemImages []generatedImage
|
||||
if eventType == "response.output_item.done" {
|
||||
if item, _ := event["item"].(map[string]any); item != nil {
|
||||
if image, ok, err := imageFromCodexItem(item, outputFormat); err != nil {
|
||||
return nil, nil, err
|
||||
} else if ok {
|
||||
itemImages = append(itemImages, image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var completedImages []generatedImage
|
||||
if eventType == "response.completed" {
|
||||
if response, _ := event["response"].(map[string]any); response != nil {
|
||||
if output, _ := response["output"].([]any); output != nil {
|
||||
for _, raw := range output {
|
||||
item, _ := raw.(map[string]any)
|
||||
if image, ok, err := imageFromCodexItem(item, outputFormat); err != nil {
|
||||
return nil, nil, err
|
||||
} else if ok {
|
||||
completedImages = append(completedImages, image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return itemImages, completedImages, nil
|
||||
}
|
||||
|
||||
func imageFromCodexItem(item map[string]any, outputFormat string) (generatedImage, bool, error) {
|
||||
if item == nil || item["type"] != "image_generation_call" {
|
||||
return generatedImage{}, false, nil
|
||||
}
|
||||
payload, _ := item["result"].(string)
|
||||
if payload == "" {
|
||||
return generatedImage{}, false, nil
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return generatedImage{}, false, err
|
||||
}
|
||||
mime, ext := imageMimeAndExtension(outputFormat)
|
||||
return generatedImage{Data: data, MimeType: mime, Ext: ext}, true, nil
|
||||
}
|
||||
|
||||
func writeGeneratedImage(image generatedImage, index int) (string, error) {
|
||||
dir, err := os.MkdirTemp("", "picoclaw-image-generate-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := fmt.Sprintf("image-%d-%s.%s", index+1, uuid.NewString(), image.Ext)
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, image.Data, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (t *ImageGenerateTool) mediaScope(ctx context.Context) string {
|
||||
parts := []string{"tool:image_generate"}
|
||||
if channel := ToolChannel(ctx); channel != "" {
|
||||
parts = append(parts, channel)
|
||||
}
|
||||
if chatID := ToolChatID(ctx); chatID != "" {
|
||||
parts = append(parts, chatID)
|
||||
}
|
||||
if sessionKey := ToolSessionKey(ctx); sessionKey != "" {
|
||||
parts = append(parts, sessionKey)
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func readStringDefault(args map[string]any, key string, fallback string) string {
|
||||
value, _ := args[key].(string)
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func readImageCount(raw any) int {
|
||||
count := 1
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
count = v
|
||||
case float64:
|
||||
count = int(v)
|
||||
case json.Number:
|
||||
if parsed, err := v.Int64(); err == nil {
|
||||
count = int(parsed)
|
||||
}
|
||||
}
|
||||
if count < 1 {
|
||||
return 1
|
||||
}
|
||||
if count > maxImageGenerationResults {
|
||||
return maxImageGenerationResults
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func stripProviderPrefix(model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
if strings.HasPrefix(model, "openai/") {
|
||||
return strings.TrimPrefix(model, "openai/")
|
||||
}
|
||||
if strings.HasPrefix(model, "openai-codex/") {
|
||||
return strings.TrimPrefix(model, "openai-codex/")
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func imageMimeAndExtension(outputFormat string) (string, string) {
|
||||
switch strings.ToLower(strings.TrimSpace(outputFormat)) {
|
||||
case "jpeg", "jpg":
|
||||
return "image/jpeg", "jpg"
|
||||
case "webp":
|
||||
return "image/webp", "webp"
|
||||
default:
|
||||
return "image/png", "png"
|
||||
}
|
||||
}
|
||||
107
pkg/tools/image_generate_test.go
Normal file
107
pkg/tools/image_generate_test.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
)
|
||||
|
||||
func TestImageGenerateToolCodexOAuthRequestAndMediaResult(t *testing.T) {
|
||||
var captured map[string]any
|
||||
var gotAuth string
|
||||
var gotAccount string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotAccount = r.Header.Get("Chatgpt-Account-Id")
|
||||
if r.URL.Path != "/responses" {
|
||||
t.Fatalf("path = %q, want /responses", r.URL.Path)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
payload := base64.StdEncoding.EncodeToString([]byte("fake-png"))
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"type":"image_generation_call","result":"` + payload + `"}}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
tool := NewImageGenerateTool(
|
||||
t.TempDir(),
|
||||
"openai/gpt-image-2",
|
||||
store,
|
||||
WithImageGenerateBaseURL(server.URL),
|
||||
WithImageGenerateTokenSource(func() (string, string, error) {
|
||||
return "test-token", "acct-123", nil
|
||||
}),
|
||||
)
|
||||
|
||||
result := tool.Execute(
|
||||
WithToolContext(t.Context(), "telegram", "chat-1"),
|
||||
map[string]any{
|
||||
"prompt": "make a tiny icon",
|
||||
"size": "1536x1024",
|
||||
"quality": "medium",
|
||||
"output_format": "png",
|
||||
},
|
||||
)
|
||||
if result.IsError {
|
||||
t.Fatalf("Execute returned error: %s", result.ContentForLLM())
|
||||
}
|
||||
if !result.ResponseHandled {
|
||||
t.Fatal("ResponseHandled = false, want true")
|
||||
}
|
||||
if len(result.Media) != 1 {
|
||||
t.Fatalf("media refs = %d, want 1", len(result.Media))
|
||||
}
|
||||
path, err := store.Resolve(result.Media[0])
|
||||
if err != nil {
|
||||
t.Fatalf("resolve media: %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer test-token" {
|
||||
t.Fatalf("Authorization = %q, want Bearer test-token", gotAuth)
|
||||
}
|
||||
if gotAccount != "acct-123" {
|
||||
t.Fatalf("Chatgpt-Account-Id = %q, want acct-123", gotAccount)
|
||||
}
|
||||
if captured["model"] != "gpt-5.4" {
|
||||
t.Fatalf("request model = %v, want gpt-5.4", captured["model"])
|
||||
}
|
||||
toolsRaw := captured["tools"].([]any)
|
||||
imageTool := toolsRaw[0].(map[string]any)
|
||||
if imageTool["model"] != "gpt-image-2" {
|
||||
t.Fatalf("image model = %v, want gpt-image-2", imageTool["model"])
|
||||
}
|
||||
if imageTool["size"] != "1536x1024" {
|
||||
t.Fatalf("size = %v, want 1536x1024", imageTool["size"])
|
||||
}
|
||||
if imageTool["quality"] != "medium" {
|
||||
t.Fatalf("quality = %v, want medium", imageTool["quality"])
|
||||
}
|
||||
if path == "" {
|
||||
t.Fatal("generated media path is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCodexImageSSECompletedResponseFallback(t *testing.T) {
|
||||
payload := base64.StdEncoding.EncodeToString([]byte("fake-png"))
|
||||
body := `data: {"type":"response.completed","response":{"output":[{"type":"image_generation_call","result":"` + payload + `"}]}}` + "\n\n"
|
||||
|
||||
images, err := parseCodexImageSSE(strings.NewReader(body), "png")
|
||||
if err != nil {
|
||||
t.Fatalf("parseCodexImageSSE: %v", err)
|
||||
}
|
||||
if len(images) != 1 {
|
||||
t.Fatalf("images = %d, want 1", len(images))
|
||||
}
|
||||
if string(images[0].Data) != "fake-png" {
|
||||
t.Fatalf("image data = %q, want fake-png", string(images[0].Data))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue