package server import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "log" "mime/multipart" "net/http" "net/textproto" "net/url" "os" "path" "path/filepath" "sort" "strconv" "strings" "time" ) type Config struct { NewAPIBaseURL string NewAPIToken string DefaultImageModel string DefaultVideoModel string PublicBaseURL string DataDir string SQLitePath string SessionSecret string SkillhubDir string } func ConfigFromEnv() Config { cfg := Config{ NewAPIBaseURL: strings.TrimSpace(os.Getenv("POPIART_NEWAPI_BASE_URL")), NewAPIToken: strings.TrimSpace(os.Getenv("POPIART_NEWAPI_TOKEN")), DefaultImageModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_IMAGE_MODEL")), DefaultVideoModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_VIDEO_MODEL")), PublicBaseURL: strings.TrimSpace(os.Getenv("POPIART_PUBLIC_BASE_URL")), DataDir: strings.TrimSpace(os.Getenv("POPIART_DATA_DIR")), SQLitePath: strings.TrimSpace(os.Getenv("POPIART_SQLITE_PATH")), SessionSecret: strings.TrimSpace(os.Getenv("POPIART_SESSION_SECRET")), SkillhubDir: strings.TrimSpace(os.Getenv("POPIART_SKILLHUB_DIR")), } return normalizeConfig(cfg) } func normalizeConfig(cfg Config) Config { if cfg.NewAPIBaseURL == "" { cfg.NewAPIBaseURL = "http://127.0.0.1:3000" } if cfg.DefaultImageModel == "" { cfg.DefaultImageModel = "gemini-3.1-flash-image-preview" } if cfg.DefaultVideoModel == "" { cfg.DefaultVideoModel = "viduq2" } if cfg.DataDir == "" { if strings.TrimSpace(cfg.SQLitePath) != "" { cfg.DataDir = filepath.Dir(cfg.SQLitePath) } else { cfg.DataDir = "./data" } } if cfg.SQLitePath == "" { cfg.SQLitePath = filepath.Join(cfg.DataDir, "popiart.db") } if cfg.SkillhubDir == "" { if info, err := os.Stat("/tmp/Popiart_skillhub"); err == nil && info.IsDir() { cfg.SkillhubDir = "/tmp/Popiart_skillhub" } } return cfg } type newAPIClient struct { baseURL string token string httpClient *http.Client } type imageGenerationResponse struct { Created int64 `json:"created"` Data []struct { B64JSON string `json:"b64_json"` URL string `json:"url"` } `json:"data"` Usage map[string]any `json:"usage"` Error *struct { Code string `json:"code"` Message string `json:"message"` Type string `json:"type"` } `json:"error"` } type geminiImageGenerationResponse struct { Candidates []struct { Content struct { Parts []struct { Text string `json:"text,omitempty"` InlineData *struct { MIMEType string `json:"mimeType"` Data string `json:"data"` } `json:"inlineData,omitempty"` } `json:"parts"` } `json:"content"` } `json:"candidates"` UsageMetadata map[string]any `json:"usageMetadata"` Error *struct { Message string `json:"message"` Status string `json:"status"` Code int `json:"code"` } `json:"error"` } type modelListResponse struct { Data []upstreamModel `json:"data"` Error *struct { Code string `json:"code"` Message string `json:"message"` Type string `json:"type"` } `json:"error"` } type upstreamModel struct { ID string `json:"id"` Object string `json:"object,omitempty"` Created int64 `json:"created,omitempty"` OwnedBy string `json:"owned_by,omitempty"` SupportedEndpointTypes []string `json:"supported_endpoint_types,omitempty"` } type imageEditReference struct { Role string Filename string ContentType string Content []byte URL string } type openAIVideoResponse struct { ID string `json:"id"` TaskID string `json:"task_id,omitempty"` Status string `json:"status"` Progress int `json:"progress,omitempty"` Seconds string `json:"seconds,omitempty"` Size string `json:"size,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` Error *struct { Message string `json:"message"` Code string `json:"code"` } `json:"error,omitempty"` } type taskEnvelopeResponse struct { Code string `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` } type taskDTOResponse struct { TaskID string `json:"task_id"` Status string `json:"status"` Progress string `json:"progress"` ResultURL string `json:"result_url,omitempty"` FailReason string `json:"fail_reason,omitempty"` Data json.RawMessage `json:"data,omitempty"` } type videoTaskResult struct { TaskID string Status string Progress string URL string Format string ErrorCode string ErrorReason string } func newNewAPIClient(cfg Config) *newAPIClient { return &newAPIClient{ baseURL: strings.TrimRight(cfg.NewAPIBaseURL, "/"), token: cfg.NewAPIToken, httpClient: &http.Client{ Timeout: 3 * time.Minute, }, } } func (c *newAPIClient) enabled() bool { return c != nil && c.baseURL != "" } func (c *newAPIClient) authorizedToken(token string) string { token = strings.TrimSpace(token) if token != "" { return token } return strings.TrimSpace(c.token) } func (c *newAPIClient) authorizedImageEditToken(token string) string { token = strings.TrimSpace(c.authorizedToken(token)) if token == "" { return "" } if strings.HasPrefix(token, "sk-") { return token } return "sk-" + token } func (c *newAPIClient) fetchModelCatalog(ctx context.Context, token string) ([]upstreamModel, error) { if !c.enabled() { return nil, errors.New("PopiNewAPI base URL is not configured") } token = c.authorizedToken(token) if token == "" { return nil, errors.New("PopiNewAPI token is not configured") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/models", nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var decoded modelListResponse if err := json.Unmarshal(body, &decoded); err != nil { return nil, fmt.Errorf("decode PopiNewAPI models response: %w", err) } if resp.StatusCode >= 400 { if decoded.Error != nil && decoded.Error.Message != "" { return nil, errors.New(decoded.Error.Message) } return nil, fmt.Errorf("PopiNewAPI returned status %d", resp.StatusCode) } if decoded.Error != nil && decoded.Error.Message != "" { return nil, errors.New(decoded.Error.Message) } return decoded.Data, nil } func (c *newAPIClient) listModels(ctx context.Context, token string) ([]model, error) { items, err := c.fetchModelCatalog(ctx, token) if err != nil { return nil, err } models := make([]model, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, item := range items { normalized, ok := normalizeUpstreamModel(item) if !ok { continue } if _, exists := seen[normalized.ID]; exists { continue } seen[normalized.ID] = struct{}{} models = append(models, normalized) } sort.Slice(models, func(i, j int) bool { if models[i].Type != models[j].Type { return models[i].Type < models[j].Type } if models[i].Provider != models[j].Provider { return models[i].Provider < models[j].Provider } return models[i].ID < models[j].ID }) return models, nil } func (c *newAPIClient) listModelIDs(ctx context.Context, token string) ([]string, error) { items, err := c.fetchModelCatalog(ctx, token) if err != nil { return nil, err } modelIDs := make([]string, 0, len(items)) for _, item := range items { if strings.TrimSpace(item.ID) != "" { modelIDs = append(modelIDs, item.ID) } } return modelIDs, nil } func (c *newAPIClient) verifyKey(ctx context.Context, token string) error { items, err := c.listModelIDs(ctx, token) if err != nil { return err } if len(items) == 0 { return errors.New("PopiNewAPI key is valid but has no available models") } return nil } func normalizeUpstreamModel(item upstreamModel) (model, bool) { modelID := strings.TrimSpace(item.ID) if modelID == "" { return model{}, false } modelType, capabilities := classifyUpstreamModel(item) if modelType == "" { return model{}, false } provider := strings.TrimSpace(item.OwnedBy) if provider == "" || provider == "custom" { provider = inferProviderFallback(modelID) } return model{ ID: modelID, Name: modelID, Type: modelType, Provider: provider, Capabilities: capabilities, Pricing: map[string]any{ "unit": modelType, "rate": "upstream", }, }, true } func inferProviderFallback(modelID string) string { lowerID := strings.ToLower(strings.TrimSpace(modelID)) switch { case strings.Contains(lowerID, "vidu"): return "vidu" default: return "custom" } } func classifyUpstreamModel(item upstreamModel) (string, []string) { if modelType, capabilities := classifySupportedEndpointTypes(item.SupportedEndpointTypes); modelType != "" { return modelType, capabilities } return classifyModelIDFallback(item.ID) } func classifySupportedEndpointTypes(endpointTypes []string) (string, []string) { hasVideo := false hasImage := false for _, endpointType := range endpointTypes { switch strings.ToLower(strings.TrimSpace(endpointType)) { case "openai-video": hasVideo = true case "image-generation": hasImage = true } } switch { case hasVideo: return "video", []string{"text2video", "image2video"} case hasImage: return "image", []string{"text2image", "img2img"} default: return "", nil } } func classifyModelIDFallback(modelID string) (string, []string) { lowerID := strings.ToLower(strings.TrimSpace(modelID)) switch { case strings.Contains(lowerID, "sora"), strings.Contains(lowerID, "veo"), strings.Contains(lowerID, "vidu"), strings.Contains(lowerID, "runway"), strings.Contains(lowerID, "kling"), strings.Contains(lowerID, "pixverse"), strings.Contains(lowerID, "hailuo"), strings.Contains(lowerID, "hunyuan-video"), strings.Contains(lowerID, "video"), strings.Contains(lowerID, "s2v"), strings.Contains(lowerID, "t2v"), strings.Contains(lowerID, "i2v"): return "video", []string{"text2video", "image2video"} case strings.Contains(lowerID, "image"), strings.Contains(lowerID, "seedream"), strings.Contains(lowerID, "flux"), strings.Contains(lowerID, "recraft"), strings.Contains(lowerID, "midjourney"), strings.Contains(lowerID, "stable-diffusion"), strings.Contains(lowerID, "sdxl"), strings.Contains(lowerID, "imagen"): return "image", []string{"text2image", "img2img"} default: return "", nil } } func (c *newAPIClient) generateImageRefs(ctx context.Context, token, modelID string, input map[string]any) ([]resultRef, map[string]any, error) { if !c.enabled() { return nil, nil, errors.New("PopiNewAPI base URL is not configured") } if useGeminiImageGenerateContent(modelID) { return c.generateGeminiImageRefs(ctx, token, modelID, input, nil) } if useSeedreamImageGenerations(modelID) { return c.generateSeedreamImageRefs(ctx, token, modelID, input, nil) } if useMiniMaxImageGenerations(modelID) { return c.generateMiniMaxImageRefs(ctx, token, modelID, input, nil) } token = c.authorizedToken(token) if token == "" { return nil, nil, errors.New("PopiNewAPI token is not configured") } prompt, _ := input["prompt"].(string) prompt = strings.TrimSpace(prompt) if prompt == "" { return nil, nil, errors.New("prompt is required") } payload := map[string]any{ "model": modelID, "prompt": prompt, "n": 1, } if value, ok := input["size"]; ok { payload["size"] = value } if value, ok := input["quality"]; ok { payload["quality"] = value } if value, ok := input["background"]; ok { payload["background"] = value } if value, ok := input["response_format"]; ok { payload["response_format"] = value } body, err := json.Marshal(payload) if err != nil { return nil, nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/images/generations", bytes.NewReader(body)) if err != nil { return nil, nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, nil, err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, err } return decodeImageGenerationResponse(resp.StatusCode, respBody, modelID) } func (c *newAPIClient) generateEditedImageRefs(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) ([]resultRef, map[string]any, error) { if !c.enabled() { return nil, nil, errors.New("PopiNewAPI base URL is not configured") } if len(refs) == 0 { return nil, nil, errors.New("reference image content is required") } if useGeminiImageGenerateContent(modelID) { return c.generateGeminiImageRefs(ctx, token, modelID, input, refs) } if useSeedreamImageGenerations(modelID) { return c.generateSeedreamImageRefs(ctx, token, modelID, input, refs) } if useMiniMaxImageGenerations(modelID) { return c.generateMiniMaxImageRefs(ctx, token, modelID, input, refs) } token = c.authorizedImageEditToken(token) if token == "" { return nil, nil, errors.New("PopiNewAPI token is not configured") } prompt, _ := input["prompt"].(string) prompt = strings.TrimSpace(prompt) if prompt == "" { return nil, nil, errors.New("prompt is required") } ref := refs[0] if len(ref.Content) == 0 { return nil, nil, errors.New("reference image content is required") } var body bytes.Buffer writer := multipart.NewWriter(&body) if err := writer.WriteField("model", modelID); err != nil { return nil, nil, err } if err := writer.WriteField("prompt", prompt); err != nil { return nil, nil, err } if value, ok := input["size"]; ok { if text := strings.TrimSpace(fmt.Sprint(value)); text != "" { if err := writer.WriteField("size", text); err != nil { return nil, nil, err } } } filename := strings.TrimSpace(ref.Filename) if filename == "" { filename = "reference" + extensionFromContentType(ref.ContentType) } contentType := strings.TrimSpace(ref.ContentType) if contentType == "" { contentType = http.DetectContentType(ref.Content) } header := textproto.MIMEHeader{} header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="image"; filename="%s"`, filename)) header.Set("Content-Type", contentType) part, err := writer.CreatePart(header) if err != nil { return nil, nil, err } if _, err := part.Write(ref.Content); err != nil { return nil, nil, err } if err := writer.Close(); err != nil { return nil, nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/images/edits", &body) if err != nil { return nil, nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := c.httpClient.Do(req) if err != nil { return nil, nil, err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, err } return decodeImageGenerationResponse(resp.StatusCode, respBody, modelID) } func (c *newAPIClient) generateSeedreamImageRefs(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) ([]resultRef, map[string]any, error) { if !c.enabled() { return nil, nil, errors.New("PopiNewAPI base URL is not configured") } token = c.authorizedToken(token) if token == "" { return nil, nil, errors.New("PopiNewAPI token is not configured") } prompt := strings.TrimSpace(stringValue(input["prompt"])) if prompt == "" { return nil, nil, errors.New("prompt is required") } payload := map[string]any{ "model": modelID, "prompt": prompt, "response_format": defaultString(strings.TrimSpace(stringValue(input["response_format"])), "url"), } size, err := resolveSeedreamImageSize(modelID, input) if err != nil { return nil, nil, err } if size != "" { payload["size"] = size } if value, ok := input["seed"]; ok { payload["seed"] = value } if value, ok := input["watermark"]; ok { payload["watermark"] = value } if value, ok := input["sequential_image_generation"]; ok { payload["sequential_image_generation"] = value } if value, ok := input["sequential_image_generation_options"]; ok { payload["sequential_image_generation_options"] = value } if len(refs) > 0 { imageInput, err := encodeSeedreamReferenceInputs(refs) if err != nil { return nil, nil, err } payload["image"] = imageInput } log.Printf( "popiartServer: Seedream image request model=%s has_image=%t image_type=%s image_count=%d size=%v", modelID, payload["image"] != nil, describeImagePayloadType(payload["image"]), countImagePayloadItems(payload["image"]), payload["size"], ) body, err := json.Marshal(payload) if err != nil { return nil, nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/images/generations", bytes.NewReader(body)) if err != nil { return nil, nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, nil, err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, err } return decodeImageGenerationResponse(resp.StatusCode, respBody, modelID) } func (c *newAPIClient) generateMiniMaxImageRefs(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) ([]resultRef, map[string]any, error) { if !c.enabled() { return nil, nil, errors.New("PopiNewAPI base URL is not configured") } token = c.authorizedToken(token) if token == "" { return nil, nil, errors.New("PopiNewAPI token is not configured") } prompt := strings.TrimSpace(stringValue(input["prompt"])) if prompt == "" { return nil, nil, errors.New("prompt is required") } payload := map[string]any{ "model": modelID, "prompt": prompt, "response_format": defaultString(strings.TrimSpace(stringValue(input["response_format"])), "url"), } if value, ok := input["size"]; ok { payload["size"] = value } if value, ok := input["aspect_ratio"]; ok { payload["aspect_ratio"] = normalizeImageAspectRatio(stringValue(value)) } if value, ok := input["n"]; ok { payload["n"] = value } if value, ok := input["watermark"]; ok { payload["watermark"] = value } if value, ok := input["seed"]; ok { payload["seed"] = value } if value, ok := input["style"]; ok { payload["style"] = value } if value, ok := input["prompt_optimizer"]; ok { payload["prompt_optimizer"] = value } if value, ok := input["subject_reference"]; ok { payload["subject_reference"] = value } if len(refs) > 0 { imageInput, err := encodeImageReferenceInputs(refs) if err != nil { return nil, nil, err } payload["image"] = imageInput } else if value, ok := input["image"]; ok { payload["image"] = value } else if value, ok := input["image_url"]; ok { payload["image"] = value } else if value, ok := input["reference_image_url"]; ok { payload["image"] = value } body, err := json.Marshal(payload) if err != nil { return nil, nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/images/generations", bytes.NewReader(body)) if err != nil { return nil, nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, nil, err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, err } return decodeImageGenerationResponse(resp.StatusCode, respBody, modelID) } func (c *newAPIClient) generateGeminiImageRefs(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) ([]resultRef, map[string]any, error) { if !c.enabled() { return nil, nil, errors.New("PopiNewAPI base URL is not configured") } token = c.authorizedToken(token) if token == "" { return nil, nil, errors.New("PopiNewAPI token is not configured") } prompt := strings.TrimSpace(stringValue(input["prompt"])) if prompt == "" { return nil, nil, errors.New("prompt is required") } parts := make([]map[string]any, 0, 1+len(refs)) parts = append(parts, map[string]any{"text": prompt}) for index, ref := range refs { if len(ref.Content) == 0 { return nil, nil, errors.New("reference image content is required") } if label := geminiReferenceLabel(ref, index); label != "" { parts = append(parts, map[string]any{"text": label}) } contentType := strings.TrimSpace(ref.ContentType) if contentType == "" { contentType = http.DetectContentType(ref.Content) } parts = append(parts, map[string]any{ "inlineData": map[string]any{ "mimeType": contentType, "data": base64.StdEncoding.EncodeToString(ref.Content), }, }) } generationConfig := map[string]any{ "responseModalities": []string{"IMAGE"}, } imageConfig := map[string]any{} if aspectRatio := resolveGeminiAspectRatio(input); aspectRatio != "" { imageConfig["aspectRatio"] = aspectRatio } if imageSize := resolveGeminiImageSize(input); imageSize != "" { imageConfig["imageSize"] = imageSize } payload := map[string]any{ "contents": []map[string]any{ {"parts": parts}, }, "generationConfig": generationConfig, } if len(imageConfig) > 0 { generationConfig["imageConfig"] = imageConfig } log.Printf( "popiartServer: Gemini image request model=%s parts=%d refs=%d image_config=%v", modelID, len(parts), len(refs), imageConfig, ) body, err := json.Marshal(payload) if err != nil { return nil, nil, err } endpoint := c.baseURL + "/v1beta/models/" + url.PathEscape(modelID) + ":generateContent" req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, nil, err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, err } return decodeGeminiImageGenerationResponse(resp.StatusCode, respBody, modelID) } func (c *newAPIClient) submitImageToVideoTask(ctx context.Context, token, modelID string, input map[string]any, ref imageEditReference) (string, error) { if !c.enabled() { return "", errors.New("PopiNewAPI base URL is not configured") } token = c.authorizedToken(token) if token == "" { return "", errors.New("PopiNewAPI token is not configured") } prompt := strings.TrimSpace(stringValue(input["prompt"])) if prompt == "" { prompt = "Generate a short polished image-to-video clip from the provided reference image." } if strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelID)), "vidu") && strings.TrimSpace(ref.URL) != "" { return c.submitImageToVideoTaskByURL(ctx, token, modelID, input, ref, prompt) } if len(ref.Content) == 0 { return "", errors.New("reference image content is required") } var body bytes.Buffer writer := multipart.NewWriter(&body) if err := writer.WriteField("model", modelID); err != nil { return "", err } if err := writer.WriteField("prompt", prompt); err != nil { return "", err } seconds := strings.TrimSpace(resolveVideoDurationSeconds(input)) if seconds == "" { seconds = "4" } if err := writer.WriteField("seconds", seconds); err != nil { return "", err } if size := resolveVideoSize(modelID, input, ref); size != "" { if err := writer.WriteField("size", size); err != nil { return "", err } } filename := strings.TrimSpace(ref.Filename) if filename == "" { filename = "input_reference" + extensionFromContentType(ref.ContentType) } contentType := strings.TrimSpace(ref.ContentType) if contentType == "" { contentType = http.DetectContentType(ref.Content) } header := textproto.MIMEHeader{} header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="input_reference"; filename="%s"`, filename)) header.Set("Content-Type", contentType) part, err := writer.CreatePart(header) if err != nil { return "", err } if _, err := part.Write(ref.Content); err != nil { return "", err } if err := writer.Close(); err != nil { return "", err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/videos", &body) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := c.httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return "", err } var decoded openAIVideoResponse if err := json.Unmarshal(respBody, &decoded); err != nil { return "", fmt.Errorf("decode PopiNewAPI video submit response: %w", err) } if resp.StatusCode >= 400 { return "", decodeTaskAPIError(respBody, resp.StatusCode) } if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" { return "", errors.New(decoded.Error.Message) } taskID := strings.TrimSpace(decoded.ID) if taskID == "" { taskID = strings.TrimSpace(decoded.TaskID) } if taskID == "" { return "", errors.New("PopiNewAPI returned no task id") } return taskID, nil } func geminiReferenceLabel(ref imageEditReference, index int) string { switch strings.ToLower(strings.TrimSpace(ref.Role)) { case "source": return fmt.Sprintf("Image %d is the source scene. Keep the scene layout, camera framing, main action, and spatial relationships from this image.", index+1) case "identity": return fmt.Sprintf("Image %d is the identity reference. Keep the character face, hair, accessories, and recognizability from this image.", index+1) case "style": return fmt.Sprintf("Image %d is the style reference. Apply only the visual style, palette, texture, and illustration treatment from this image. Do not change the character identity because of this image.", index+1) case "reference": return fmt.Sprintf("Image %d is an additional reference. Use it only for supporting details explicitly requested in the prompt.", index+1) default: return "" } } func (c *newAPIClient) submitImageToVideoTaskByURL(ctx context.Context, token, modelID string, input map[string]any, ref imageEditReference, prompt string) (string, error) { payload := map[string]any{ "model": modelID, "prompt": prompt, "images": []string{strings.TrimSpace(ref.URL)}, "duration": 5, } if duration := strings.TrimSpace(resolveVideoDurationSeconds(input)); duration != "" { if parsed, err := strconv.Atoi(duration); err == nil && parsed > 0 { payload["duration"] = parsed } } if size := resolveVideoSize(modelID, input, ref); size != "" { payload["size"] = size } if aspectRatio := resolveVideoAspectRatio(input, ref); aspectRatio != "" { payload["metadata"] = map[string]any{ "aspect_ratio": aspectRatio, } } body, err := json.Marshal(payload) if err != nil { return "", err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/videos", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return "", err } var decoded openAIVideoResponse if err := json.Unmarshal(respBody, &decoded); err != nil { return "", fmt.Errorf("decode PopiNewAPI video submit response: %w", err) } if resp.StatusCode >= 400 { return "", decodeTaskAPIError(respBody, resp.StatusCode) } if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" { return "", errors.New(decoded.Error.Message) } taskID := strings.TrimSpace(decoded.ID) if taskID == "" { taskID = strings.TrimSpace(decoded.TaskID) } if taskID == "" { return "", errors.New("PopiNewAPI returned no task id") } return taskID, nil } func (c *newAPIClient) fetchVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) { if !c.enabled() { return nil, errors.New("PopiNewAPI base URL is not configured") } token = c.authorizedToken(token) if token == "" { return nil, errors.New("PopiNewAPI token is not configured") } result, err := c.fetchOpenAIVideoTask(ctx, token, taskID) if err == nil { return result, nil } fallback, fallbackErr := c.fetchGenericVideoTask(ctx, token, taskID) if fallbackErr == nil { return fallback, nil } return nil, err } func (c *newAPIClient) fetchOpenAIVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/videos/"+taskID, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode >= 400 { return nil, decodeTaskAPIError(body, resp.StatusCode) } var decoded openAIVideoResponse if err := json.Unmarshal(body, &decoded); err != nil { return nil, fmt.Errorf("decode PopiNewAPI openai video fetch response: %w", err) } result := &videoTaskResult{ TaskID: defaultString(strings.TrimSpace(decoded.ID), strings.TrimSpace(decoded.TaskID)), Status: normalizeVideoTaskStatus(decoded.Status), Progress: normalizeVideoProgress(decoded.Progress, ""), } if decoded.Metadata != nil { if text := strings.TrimSpace(stringValue(decoded.Metadata["url"])); text != "" { result.URL = text } if text := strings.TrimSpace(stringValue(decoded.Metadata["format"])); text != "" { result.Format = text } } if decoded.Error != nil { result.ErrorCode = strings.TrimSpace(decoded.Error.Code) result.ErrorReason = strings.TrimSpace(decoded.Error.Message) } if result.TaskID == "" { return nil, errors.New("PopiNewAPI returned no task id") } return result, nil } func (c *newAPIClient) fetchGenericVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/video/generations/"+taskID, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode >= 400 { return nil, decodeTaskAPIError(body, resp.StatusCode) } var env taskEnvelopeResponse if err := json.Unmarshal(body, &env); err != nil { return nil, fmt.Errorf("decode PopiNewAPI task envelope: %w", err) } if strings.TrimSpace(env.Code) != "" && !strings.EqualFold(env.Code, "success") { return nil, errors.New(defaultString(strings.TrimSpace(env.Message), "PopiNewAPI video task fetch failed")) } var task taskDTOResponse if err := json.Unmarshal(env.Data, &task); err == nil && strings.TrimSpace(task.TaskID) != "" { return &videoTaskResult{ TaskID: strings.TrimSpace(task.TaskID), Status: normalizeVideoTaskStatus(task.Status), Progress: normalizeVideoProgress(0, task.Progress), URL: strings.TrimSpace(task.ResultURL), ErrorReason: strings.TrimSpace(task.FailReason), }, nil } var simple struct { TaskID string `json:"task_id"` Status string `json:"status"` URL string `json:"url"` Format string `json:"format"` Progress string `json:"progress"` Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(env.Data, &simple); err != nil { return nil, fmt.Errorf("decode PopiNewAPI generic task payload: %w", err) } result := &videoTaskResult{ TaskID: strings.TrimSpace(simple.TaskID), Status: normalizeVideoTaskStatus(simple.Status), URL: strings.TrimSpace(simple.URL), Format: strings.TrimSpace(simple.Format), Progress: normalizeVideoProgress(0, simple.Progress), } if simple.Error != nil { result.ErrorCode = strings.TrimSpace(simple.Error.Code) result.ErrorReason = strings.TrimSpace(simple.Error.Message) } if result.TaskID == "" { result.TaskID = taskID } return result, nil } func decodeImageGenerationResponse(statusCode int, body []byte, nameHint string) ([]resultRef, map[string]any, error) { var decoded imageGenerationResponse if err := json.Unmarshal(body, &decoded); err != nil { return nil, nil, fmt.Errorf("decode PopiNewAPI response: %w", err) } if statusCode >= 400 { if decoded.Error != nil && decoded.Error.Message != "" { return nil, decoded.Usage, errors.New(decoded.Error.Message) } return nil, decoded.Usage, fmt.Errorf("PopiNewAPI returned status %d", statusCode) } if decoded.Error != nil && decoded.Error.Message != "" { return nil, decoded.Usage, errors.New(decoded.Error.Message) } if len(decoded.Data) == 0 { return nil, decoded.Usage, errors.New("PopiNewAPI returned no image data") } first := decoded.Data[0] if first.B64JSON != "" { content, err := base64.StdEncoding.DecodeString(first.B64JSON) if err != nil { return nil, decoded.Usage, fmt.Errorf("decode b64 image: %w", err) } contentType := http.DetectContentType(content) return []resultRef{ { Kind: "data_url", DataURL: "data:" + contentType + ";base64," + first.B64JSON, Filename: fmt.Sprintf("%s-%d%s", sanitizeFilename(nameHint), time.Now().UTC().Unix(), extensionFromContentType(contentType)), ContentType: contentType, SizeBytes: int64(len(content)), }, }, decoded.Usage, nil } if first.URL != "" { ref := resultRef{ Kind: "url", URL: first.URL, Filename: filenameFromURL(first.URL, fmt.Sprintf("%s-%d", sanitizeFilename(nameHint), time.Now().UTC().Unix())), } if strings.HasPrefix(first.URL, "data:") { ref.Kind = "data_url" ref.DataURL = first.URL ref.URL = "" } return []resultRef{ref}, decoded.Usage, nil } return nil, decoded.Usage, errors.New("PopiNewAPI returned neither b64_json nor url") } func decodeGeminiImageGenerationResponse(statusCode int, body []byte, nameHint string) ([]resultRef, map[string]any, error) { var decoded geminiImageGenerationResponse if err := json.Unmarshal(body, &decoded); err != nil { return nil, nil, fmt.Errorf("decode Gemini image response: %w", err) } if statusCode >= 400 { if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" { return nil, decoded.UsageMetadata, errors.New(strings.TrimSpace(decoded.Error.Message)) } return nil, decoded.UsageMetadata, fmt.Errorf("Gemini image generation returned status %d", statusCode) } if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" { return nil, decoded.UsageMetadata, errors.New(strings.TrimSpace(decoded.Error.Message)) } if len(decoded.Candidates) == 0 { return nil, decoded.UsageMetadata, errors.New("Gemini image generation returned no candidates") } for _, part := range decoded.Candidates[0].Content.Parts { if part.InlineData == nil || strings.TrimSpace(part.InlineData.Data) == "" { continue } content, err := base64.StdEncoding.DecodeString(part.InlineData.Data) if err != nil { return nil, decoded.UsageMetadata, fmt.Errorf("decode Gemini inline image: %w", err) } contentType := strings.TrimSpace(part.InlineData.MIMEType) if contentType == "" { contentType = http.DetectContentType(content) } return []resultRef{ { Kind: "data_url", DataURL: "data:" + contentType + ";base64," + part.InlineData.Data, Filename: fmt.Sprintf("%s-%d%s", sanitizeFilename(nameHint), time.Now().UTC().Unix(), extensionFromContentType(contentType)), ContentType: contentType, SizeBytes: int64(len(content)), }, }, decoded.UsageMetadata, nil } return nil, decoded.UsageMetadata, errors.New("Gemini image generation returned no inline image data") } func useGeminiImageGenerateContent(modelID string) bool { lowerID := strings.ToLower(strings.TrimSpace(modelID)) return strings.Contains(lowerID, "gemini") || strings.Contains(lowerID, "banana") } func useSeedreamImageGenerations(modelID string) bool { lowerID := strings.ToLower(strings.TrimSpace(modelID)) return strings.Contains(lowerID, "seedream") } func useMiniMaxImageGenerations(modelID string) bool { lowerID := strings.ToLower(strings.TrimSpace(modelID)) return lowerID == "image-01" || lowerID == "image-01-live" } func useMiniMaxVideoGenerations(modelID string) bool { lowerID := strings.ToLower(strings.TrimSpace(modelID)) return strings.HasPrefix(lowerID, "t2v-") || strings.HasPrefix(lowerID, "i2v-") || strings.HasPrefix(lowerID, "s2v-") || strings.Contains(lowerID, "minimax-hailuo") } func resolveGeminiAspectRatio(input map[string]any) string { if aspectRatio := normalizeImageAspectRatio(stringValue(input["aspect_ratio"])); aspectRatio != "" { if _, ok := seedreamAspectRatioSizes[aspectRatio]; ok { return aspectRatio } } for _, value := range []string{ stringValue(input["size"]), stringValue(input["resolution"]), } { if aspectRatio := deriveAspectRatioFromImageSize(value); aspectRatio != "" { if _, ok := seedreamAspectRatioSizes[aspectRatio]; ok { return aspectRatio } } } return "" } func resolveGeminiImageSize(input map[string]any) string { for _, value := range []string{ stringValue(input["resolution"]), stringValue(input["size"]), } { if imageSize := geminiImageSizeFromValue(value); imageSize != "" { return imageSize } } return "" } func resolveSeedreamImageSize(modelID string, input map[string]any) (string, error) { for _, raw := range []string{ stringValue(input["resolution"]), stringValue(input["size"]), } { value := normalizeImageSizeToken(raw) if value == "" { continue } if supportedSeedreamSize(modelID, value) { return value, nil } return "", fmt.Errorf("unsupported seedream size %q for model %s; use preset sizes only", value, modelID) } if aspectRatio := normalizeImageAspectRatio(stringValue(input["aspect_ratio"])); aspectRatio != "" { return "", fmt.Errorf("seedream model %s requires preset size values (2K/3K/4K); aspect_ratio %q is not supported", modelID, aspectRatio) } return "2K", nil } func supportedSeedreamSizes(modelID string) map[string]struct{} { lowerID := strings.ToLower(strings.TrimSpace(modelID)) if strings.Contains(lowerID, "5-0") { return map[string]struct{}{ "2K": {}, "3K": {}, } } return map[string]struct{}{ "2K": {}, "4K": {}, } } func encodeImageReferenceInputs(refs []imageEditReference) (any, error) { encoded := make([]string, 0, len(refs)) for _, ref := range refs { value, err := encodeImageReference(ref) if err != nil { return nil, err } encoded = append(encoded, value) } if len(encoded) == 1 { return encoded[0], nil } return encoded, nil } func encodeSeedreamReferenceInputs(refs []imageEditReference) (any, error) { return encodeImageReferenceInputs(refs) } func describeImagePayloadType(value any) string { switch value.(type) { case nil: return "missing" case string: return "string" case []string: return "string_array" case []any: return "array" default: return fmt.Sprintf("%T", value) } } func countImagePayloadItems(value any) int { switch typed := value.(type) { case nil: return 0 case string: if strings.TrimSpace(typed) == "" { return 0 } return 1 case []string: return len(typed) case []any: return len(typed) default: return 1 } } func encodeImageReference(ref imageEditReference) (string, error) { if rawURL := strings.TrimSpace(ref.URL); rawURL != "" { return rawURL, nil } if len(ref.Content) == 0 { return "", errors.New("reference image content is required") } contentType := strings.TrimSpace(ref.ContentType) if contentType == "" { contentType = http.DetectContentType(ref.Content) } return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(ref.Content), nil } func encodeSeedreamReference(ref imageEditReference) (string, error) { return encodeImageReference(ref) } func (c *newAPIClient) openResultRef(ctx context.Context, token string, ref resultRef) (string, int64, io.ReadCloser, error) { switch ref.Kind { case "local_path": file, err := os.Open(ref.LocalPath) if err != nil { return "", 0, nil, err } return defaultString(strings.TrimSpace(ref.ContentType), "application/octet-stream"), ref.SizeBytes, file, nil case "data_url": contentType, content, err := decodeDataURL(ref.DataURL) if err != nil { return "", 0, nil, err } return contentType, int64(len(content)), io.NopCloser(bytes.NewReader(content)), nil case "url": req, err := http.NewRequestWithContext(ctx, http.MethodGet, ref.URL, nil) if err != nil { return "", 0, nil, err } if authToken := c.authorizedToken(token); authToken != "" && shouldAttachAuthHeader(c.baseURL, ref.URL) { req.Header.Set("Authorization", "Bearer "+authToken) } resp, err := c.httpClient.Do(req) if err != nil { return "", 0, nil, err } if resp.StatusCode >= 400 { defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return "", 0, nil, fmt.Errorf("download upstream artifact failed: %s", strings.TrimSpace(string(body))) } contentType := defaultString(strings.TrimSpace(resp.Header.Get("Content-Type")), defaultString(ref.ContentType, "application/octet-stream")) sizeBytes := resp.ContentLength if sizeBytes <= 0 { sizeBytes = ref.SizeBytes } return contentType, sizeBytes, resp.Body, nil default: return "", 0, nil, fmt.Errorf("unsupported result ref kind: %s", ref.Kind) } } func decodeDataURL(value string) (string, []byte, error) { if !strings.HasPrefix(value, "data:") { return "", nil, errors.New("invalid data url") } comma := strings.Index(value, ",") if comma <= 5 { return "", nil, errors.New("invalid data url payload") } header := value[5:comma] payload := value[comma+1:] contentType := "text/plain" if semi := strings.Index(header, ";"); semi >= 0 { if strings.TrimSpace(header[:semi]) != "" { contentType = strings.TrimSpace(header[:semi]) } if !strings.Contains(header[semi+1:], "base64") { return "", nil, errors.New("unsupported non-base64 data url") } } else if strings.TrimSpace(header) != "" { contentType = strings.TrimSpace(header) } content, err := base64.StdEncoding.DecodeString(payload) if err != nil { return "", nil, fmt.Errorf("decode data url payload: %w", err) } return contentType, content, nil } func sanitizeFilename(value string) string { value = strings.TrimSpace(value) if value == "" { return "artifact" } replacer := strings.NewReplacer("/", "-", "\\", "-", " ", "-", ":", "-") return replacer.Replace(value) } func extensionFromContentType(contentType string) string { switch contentType { case "image/png": return ".png" case "image/webp": return ".webp" case "image/gif": return ".gif" case "video/mp4": return ".mp4" case "video/quicktime": return ".mov" case "video/webm": return ".webm" default: return ".jpg" } } func filenameFromURL(rawURL, fallback string) string { if parsed, err := http.NewRequest(http.MethodGet, rawURL, nil); err == nil { base := path.Base(parsed.URL.Path) if base != "" && base != "." && base != "/" { return base } } return fallback } func resolveVideoDurationSeconds(input map[string]any) string { if input == nil { return "" } for _, value := range []any{input["seconds"], input["duration_s"], input["duration"]} { switch typed := value.(type) { case string: typed = strings.TrimSpace(typed) if typed != "" { return typed } case int: if typed > 0 { return strconv.Itoa(typed) } case int32: if typed > 0 { return strconv.FormatInt(int64(typed), 10) } case int64: if typed > 0 { return strconv.FormatInt(typed, 10) } case float64: if typed > 0 { return strconv.Itoa(int(typed)) } } } return "" } func resolveVideoSize(modelID string, input map[string]any, ref imageEditReference) string { size := resolveBaseVideoSize(input, ref) if inferProviderFallback(modelID) == "vidu" { return normalizeViduResolution(size) } return size } func resolveMiniMaxVideoResolution(input map[string]any, refs []imageEditReference) string { if input != nil { for _, raw := range []string{ stringValue(input["resolution"]), stringValue(input["size"]), } { value := strings.ToUpper(strings.TrimSpace(raw)) switch value { case "512P", "720P", "768P", "1080P": return value } switch strings.TrimSpace(raw) { case "512x512": return "512P" case "720x1280", "1280x720": return "720P" case "768x768": return "768P" case "1080x1920", "1920x1080": return "1080P" } switch { case strings.Contains(value, "1080"): return "1080P" case strings.Contains(value, "768"): return "768P" case strings.Contains(value, "720"): return "720P" case strings.Contains(value, "512"): return "512P" } } } if len(refs) > 0 { return "720P" } return "720P" } func resolveMiniMaxVideoDuration(input map[string]any) int { duration := 0 for _, value := range []any{input["duration"], input["duration_s"], input["seconds"]} { switch typed := value.(type) { case int: duration = typed case int32: duration = int(typed) case int64: duration = int(typed) case float64: duration = int(typed) case string: parsed, err := strconv.Atoi(strings.TrimSpace(typed)) if err == nil { duration = parsed } } if duration > 0 { break } } if duration <= 0 { return 6 } return duration } func (c *newAPIClient) submitMiniMaxVideoTask(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) (string, error) { if !c.enabled() { return "", errors.New("PopiNewAPI base URL is not configured") } token = c.authorizedToken(token) if token == "" { return "", errors.New("PopiNewAPI token is not configured") } prompt := strings.TrimSpace(stringValue(input["prompt"])) if prompt == "" { return "", errors.New("prompt is required") } payload := map[string]any{ "model": modelID, "prompt": prompt, "size": resolveMiniMaxVideoResolution(input, refs), "duration": resolveMiniMaxVideoDuration(input), } if len(refs) > 0 { images := make([]string, 0, len(refs)) for _, ref := range refs { encoded, err := encodeImageReference(ref) if err != nil { return "", err } images = append(images, encoded) } payload["images"] = images } metadata := map[string]any{} for _, key := range []string{"prompt_optimizer", "fast_pretreatment", "callback_url", "aigc_watermark"} { if value, ok := input[key]; ok { metadata[key] = value } } if len(metadata) > 0 { payload["metadata"] = metadata } body, err := json.Marshal(payload) if err != nil { return "", err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/video/generations", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode >= 400 { return "", decodeTaskAPIError(respBody, resp.StatusCode) } var decoded openAIVideoResponse if err := json.Unmarshal(respBody, &decoded); err == nil { taskID := strings.TrimSpace(decoded.ID) if taskID == "" { taskID = strings.TrimSpace(decoded.TaskID) } if taskID != "" { return taskID, nil } } var envelope taskEnvelopeResponse if err := json.Unmarshal(respBody, &envelope); err == nil && len(envelope.Data) > 0 { var task taskDTOResponse if err := json.Unmarshal(envelope.Data, &task); err == nil && strings.TrimSpace(task.TaskID) != "" { return strings.TrimSpace(task.TaskID), nil } } return "", errors.New("PopiNewAPI returned no task id") } func resolveVideoAspectRatio(input map[string]any, ref imageEditReference) string { if input != nil { if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" { return aspectRatio } } if len(ref.Content) > 0 { cfg, _, err := image.DecodeConfig(bytes.NewReader(ref.Content)) if err == nil && cfg.Width > 0 && cfg.Height > 0 { if cfg.Width >= cfg.Height { return "16:9" } return "9:16" } } return "" } func resolveBaseVideoSize(input map[string]any, ref imageEditReference) string { if input != nil { if size := strings.TrimSpace(stringValue(input["size"])); size != "" { return size } switch strings.TrimSpace(stringValue(input["aspect_ratio"])) { case "16:9": return "1280x720" case "9:16": return "720x1280" } } if len(ref.Content) > 0 { cfg, _, err := image.DecodeConfig(bytes.NewReader(ref.Content)) if err == nil && cfg.Width > 0 && cfg.Height > 0 { if cfg.Width >= cfg.Height { return "1280x720" } return "720x1280" } } return "720x1280" } func normalizeViduResolution(size string) string { switch strings.ToLower(strings.TrimSpace(size)) { case "360p", "540p", "720p", "1080p": return strings.ToLower(strings.TrimSpace(size)) case "1920x1080", "1080x1920": return "1080p" default: return "720p" } } func normalizeVideoTaskStatus(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case "queued", "pending", "submitted": return "queued" case "processing", "in_progress", "running": return "in_progress" case "completed", "done", "success", "succeeded": return "completed" case "failed", "failure", "cancelled", "canceled": return "failed" default: return "" } } func normalizeVideoProgress(progress int, fallback string) string { if progress > 0 { return fmt.Sprintf("%d%%", progress) } fallback = strings.TrimSpace(fallback) if fallback != "" { return fallback } return "" } func decodeTaskAPIError(body []byte, statusCode int) error { var taskErr struct { Code string `json:"code"` Message string `json:"message"` Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(body, &taskErr); err == nil { if taskErr.Error != nil && strings.TrimSpace(taskErr.Error.Message) != "" { return errors.New(taskErr.Error.Message) } if strings.TrimSpace(taskErr.Message) != "" { return errors.New(taskErr.Message) } } return fmt.Errorf("PopiNewAPI returned status %d", statusCode) } func shouldAttachAuthHeader(baseURL, rawURL string) bool { base, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil { return strings.HasPrefix(rawURL, baseURL+"/") } target, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { return strings.HasPrefix(rawURL, baseURL+"/") } if target.Scheme == "" || target.Host == "" { return true } if strings.EqualFold(base.Host, target.Host) { return true } if equivalentLocalHosts(base.Hostname(), target.Hostname()) && base.Port() == target.Port() { return true } return false } func equivalentLocalHosts(a, b string) bool { a = strings.ToLower(strings.TrimSpace(a)) b = strings.ToLower(strings.TrimSpace(b)) if a == b { return true } return (a == "localhost" && b == "127.0.0.1") || (a == "127.0.0.1" && b == "localhost") }