678 lines · 24.6 KB · Go
RawDownload
package executor import (	"bufio"	"bytes"	"context"	"encoding/base64"	"encoding/json"	"fmt"	"io"	"mime"	"mime/multipart"	"net/http"	"strconv"	"strings"	"time" 	"github.com/router-for-me/CLIProxyAPI/v7/internal/config"	"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"	"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"	cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"	cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"	sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"	log "github.com/sirupsen/logrus"	"github.com/tidwall/gjson"	"github.com/tidwall/sjson") const (	codexOpenAIImageSourceFormat = "openai-image"	codexImagesGenerationsPath   = "/v1/images/generations"	codexImagesEditsPath         = "/v1/images/edits"	codexOpenAIImagesMainModel   = "gpt-5.4-mini") type codexOpenAIImagePreparedRequest struct {	Body           []byte	ResponseFormat string	StreamPrefix   string} type codexImageCallResult struct {	Result        string	RevisedPrompt string	OutputFormat  string	Size          string	Background    string	Quality       string} func isCodexOpenAIImageRequest(opts cliproxyexecutor.Options) bool {	if !strings.EqualFold(strings.TrimSpace(opts.SourceFormat.String()), codexOpenAIImageSourceFormat) {		return false	}	return codexIsImagesEndpointPath(helps.PayloadRequestPath(opts))} func codexIsImagesEndpointPath(path string) bool {	path = strings.TrimSpace(path)	if path == codexImagesGenerationsPath || path == codexImagesEditsPath {		return true	}	return strings.HasSuffix(path, codexImagesGenerationsPath) || strings.HasSuffix(path, codexImagesEditsPath)} func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {	prepared, errPrepare := codexPrepareOpenAIImageRequest(req, opts)	if errPrepare != nil {		return resp, errPrepare	} 	apiKey, baseURL := codexCreds(auth)	if baseURL == "" {		baseURL = "https://chatgpt.com/backend-api/codex"	} 	reporter := helps.NewUsageReporter(ctx, e.Identifier(), codexOpenAIImagesMainModel, auth)	defer reporter.TrackFailure(ctx, &err) 	body, errBuild := e.prepareCodexOpenAIImageBody(prepared.Body, req, opts)	if errBuild != nil {		return resp, errBuild	} 	url := strings.TrimSuffix(baseURL, "/") + "/responses"	httpReq, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body)	if errCache != nil {		return resp, errCache	}	applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)	recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body) 	httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)	httpResp, errDo := httpClient.Do(httpReq)	if errDo != nil {		helps.RecordAPIResponseError(ctx, e.cfg, errDo)		return resp, errDo	}	defer func() {		if errClose := httpResp.Body.Close(); errClose != nil {			log.Errorf("codex executor: close response body error: %v", errClose)		}	}() 	helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())	data, errRead := io.ReadAll(httpResp.Body)	if errRead != nil {		helps.RecordAPIResponseError(ctx, e.cfg, errRead)		return resp, errRead	}	helps.AppendAPIResponseChunk(ctx, e.cfg, data)	if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {		helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))		err = newCodexStatusErr(httpResp.StatusCode, data)		return resp, err	} 	outputItemsByIndex := make(map[int64][]byte)	var outputItemsFallback [][]byte	for _, line := range bytes.Split(data, []byte("\n")) {		if !bytes.HasPrefix(line, dataTag) {			continue		}		eventData := bytes.TrimSpace(line[len(dataTag):])		switch gjson.GetBytes(eventData, "type").String() {		case "response.output_item.done":			collectCodexOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)		case "response.completed":			if detail, ok := helps.ParseCodexUsage(eventData); ok {				reporter.Publish(ctx, detail)			}			publishCodexImageToolUsage(ctx, reporter, body, eventData)			completedData := patchCodexCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback)			results, createdAt, usageRaw, firstMeta, errExtract := codexExtractImagesFromResponsesCompleted(completedData)			if errExtract != nil {				return resp, errExtract			}			if len(results) == 0 {				return resp, statusErr{code: http.StatusBadGateway, msg: "upstream did not return image output"}			}			out, errOutput := codexBuildImagesAPIResponse(results, createdAt, usageRaw, firstMeta, prepared.ResponseFormat)			if errOutput != nil {				return resp, errOutput			}			return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil		}	} 	err = statusErr{code: http.StatusGatewayTimeout, msg: "stream error: stream disconnected before completion"}	return resp, err} func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {	prepared, errPrepare := codexPrepareOpenAIImageRequest(req, opts)	if errPrepare != nil {		return nil, errPrepare	} 	apiKey, baseURL := codexCreds(auth)	if baseURL == "" {		baseURL = "https://chatgpt.com/backend-api/codex"	} 	reporter := helps.NewUsageReporter(ctx, e.Identifier(), codexOpenAIImagesMainModel, auth)	defer reporter.TrackFailure(ctx, &err) 	body, errBuild := e.prepareCodexOpenAIImageBody(prepared.Body, req, opts)	if errBuild != nil {		return nil, errBuild	} 	url := strings.TrimSuffix(baseURL, "/") + "/responses"	httpReq, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body)	if errCache != nil {		return nil, errCache	}	applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)	recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body) 	httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)	httpResp, errDo := httpClient.Do(httpReq)	if errDo != nil {		helps.RecordAPIResponseError(ctx, e.cfg, errDo)		return nil, errDo	}	helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())	if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {		data, errRead := io.ReadAll(httpResp.Body)		if errClose := httpResp.Body.Close(); errClose != nil {			log.Errorf("codex executor: close response body error: %v", errClose)		}		if errRead != nil {			helps.RecordAPIResponseError(ctx, e.cfg, errRead)			return nil, errRead		}		helps.AppendAPIResponseChunk(ctx, e.cfg, data)		helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))		err = newCodexStatusErr(httpResp.StatusCode, data)		return nil, err	} 	out := make(chan cliproxyexecutor.StreamChunk)	go func() {		defer close(out)		defer func() {			if errClose := httpResp.Body.Close(); errClose != nil {				log.Errorf("codex executor: close response body error: %v", errClose)			}		}() 		sendPayload := func(payload []byte) bool {			select {			case out <- cliproxyexecutor.StreamChunk{Payload: payload}:				return true			case <-ctx.Done():				return false			}		}		sendError := func(errSend error) bool {			select {			case out <- cliproxyexecutor.StreamChunk{Err: errSend}:				return true			case <-ctx.Done():				return false			}		} 		scanner := bufio.NewScanner(httpResp.Body)		scanner.Buffer(nil, 52_428_800) // 50MB		outputItemsByIndex := make(map[int64][]byte)		var outputItemsFallback [][]byte		for scanner.Scan() {			line := scanner.Bytes()			helps.AppendAPIResponseChunk(ctx, e.cfg, line)			if !bytes.HasPrefix(line, dataTag) {				continue			}			eventData := bytes.TrimSpace(line[len(dataTag):])			switch gjson.GetBytes(eventData, "type").String() {			case "response.output_item.done":				collectCodexOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)			case "response.image_generation_call.partial_image":				frame := codexBuildImagePartialFrame(eventData, prepared.ResponseFormat, prepared.StreamPrefix)				if len(frame) > 0 && !sendPayload(frame) {					return				}			case "response.completed":				if detail, ok := helps.ParseCodexUsage(eventData); ok {					reporter.Publish(ctx, detail)				}				publishCodexImageToolUsage(ctx, reporter, body, eventData)				completedData := patchCodexCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback)				results, _, usageRaw, _, errExtract := codexExtractImagesFromResponsesCompleted(completedData)				if errExtract != nil {					sendError(errExtract)					return				}				if len(results) == 0 {					sendError(statusErr{code: http.StatusBadGateway, msg: "upstream did not return image output"})					return				}				for _, img := range results {					frame := codexBuildImageCompletedFrame(img, usageRaw, prepared.ResponseFormat, prepared.StreamPrefix)					if len(frame) > 0 && !sendPayload(frame) {						return					}				}				return			}		}		if errScan := scanner.Err(); errScan != nil {			helps.RecordAPIResponseError(ctx, e.cfg, errScan)			reporter.PublishFailure(ctx, errScan)			sendError(errScan)		}	}()	return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil} func (e *CodexExecutor) prepareCodexOpenAIImageBody(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) ([]byte, error) {	out := body	var errThinking error	out, errThinking = thinking.ApplyThinking(out, codexOpenAIImagesMainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier())	if errThinking != nil {		return nil, errThinking	} 	requestedModel := helps.PayloadRequestedModel(opts, req.Model)	requestPath := helps.PayloadRequestPath(opts)	out = helps.ApplyPayloadConfigWithRequest(e.cfg, codexOpenAIImagesMainModel, "codex", codexOpenAIImageSourceFormat, "", out, body, requestedModel, requestPath, opts.Headers)	out, _ = sjson.SetBytes(out, "model", codexOpenAIImagesMainModel)	out, _ = sjson.SetBytes(out, "stream", true)	out, _ = sjson.DeleteBytes(out, "previous_response_id")	out, _ = sjson.DeleteBytes(out, "prompt_cache_retention")	out, _ = sjson.DeleteBytes(out, "safety_identifier")	out, _ = sjson.DeleteBytes(out, "stream_options")	return normalizeCodexInstructions(out), nil} func recordCodexOpenAIImageRequest(ctx context.Context, cfg *config.Config, provider string, auth *cliproxyauth.Auth, url string, headers http.Header, body []byte) {	var authID, authLabel, authType, authValue string	if auth != nil {		authID = auth.ID		authLabel = auth.Label		authType, authValue = auth.AccountInfo()	}	helps.RecordAPIRequest(ctx, cfg, helps.UpstreamRequestLog{		URL:       url,		Method:    http.MethodPost,		Headers:   headers,		Body:      body,		Provider:  provider,		AuthID:    authID,		AuthLabel: authLabel,		AuthType:  authType,		AuthValue: authValue,	})} func codexPrepareOpenAIImageRequest(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (codexOpenAIImagePreparedRequest, error) {	path := helps.PayloadRequestPath(opts)	if strings.HasSuffix(path, codexImagesGenerationsPath) {		return codexPrepareOpenAIImageGenerationJSON(req.Payload, req.Model)	}	if !strings.HasSuffix(path, codexImagesEditsPath) {		return codexOpenAIImagePreparedRequest{}, fmt.Errorf("unsupported OpenAI image endpoint path %q", path)	} 	contentType := codexImageContentType(opts.Headers)	mediaType, _, _ := mime.ParseMediaType(contentType)	if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {		return codexPrepareOpenAIImageEditMultipart(req.Payload, req.Model, contentType)	}	return codexPrepareOpenAIImageEditJSON(req.Payload, req.Model)} func codexPrepareOpenAIImageGenerationJSON(rawJSON []byte, routeModel string) (codexOpenAIImagePreparedRequest, error) {	if !json.Valid(rawJSON) {		return codexOpenAIImagePreparedRequest{}, fmt.Errorf("invalid OpenAI image generation request JSON")	}	prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())	tool := codexBuildOpenAIImageTool(rawJSON, routeModel, "generate", []string{"size", "quality", "background", "output_format", "moderation"}, []string{"output_compression", "partial_images"})	body := codexBuildImagesResponsesRequest(prompt, nil, tool)	return codexOpenAIImagePreparedRequest{		Body:           body,		ResponseFormat: codexOpenAIImageResponseFormatFromJSON(rawJSON),		StreamPrefix:   "image_generation",	}, nil} func codexPrepareOpenAIImageEditJSON(rawJSON []byte, routeModel string) (codexOpenAIImagePreparedRequest, error) {	if !json.Valid(rawJSON) {		return codexOpenAIImagePreparedRequest{}, fmt.Errorf("invalid OpenAI image edit request JSON")	}	prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())	images := make([]string, 0)	if imagesResult := gjson.GetBytes(rawJSON, "images"); imagesResult.IsArray() {		for _, img := range imagesResult.Array() {			url := strings.TrimSpace(img.Get("image_url").String())			if url != "" {				images = append(images, url)			}		}	}	tool := codexBuildOpenAIImageTool(rawJSON, routeModel, "edit", []string{"size", "quality", "background", "output_format", "input_fidelity", "moderation"}, []string{"output_compression", "partial_images"})	if mask := strings.TrimSpace(gjson.GetBytes(rawJSON, "mask.image_url").String()); mask != "" {		tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", mask)	}	body := codexBuildImagesResponsesRequest(prompt, images, tool)	return codexOpenAIImagePreparedRequest{		Body:           body,		ResponseFormat: codexOpenAIImageResponseFormatFromJSON(rawJSON),		StreamPrefix:   "image_edit",	}, nil} func codexPrepareOpenAIImageEditMultipart(rawBody []byte, routeModel string, contentType string) (codexOpenAIImagePreparedRequest, error) {	_, params, errMedia := mime.ParseMediaType(contentType)	if errMedia != nil {		return codexOpenAIImagePreparedRequest{}, fmt.Errorf("parse multipart content type failed: %w", errMedia)	}	boundary := strings.TrimSpace(params["boundary"])	if boundary == "" {		return codexOpenAIImagePreparedRequest{}, fmt.Errorf("multipart boundary is required")	}	reader := multipart.NewReader(bytes.NewReader(rawBody), boundary)	form, errForm := reader.ReadForm(32 << 20)	if errForm != nil {		return codexOpenAIImagePreparedRequest{}, fmt.Errorf("parse multipart form failed: %w", errForm)	}	defer func() {		if errRemove := form.RemoveAll(); errRemove != nil {			log.Errorf("codex openai images: remove multipart temp files error: %v", errRemove)		}	}() 	prompt := strings.TrimSpace(codexFormValue(form, "prompt"))	responseFormat := codexNormalizeImageResponseFormat(codexFormValue(form, "response_format"))	tool := []byte(`{"type":"image_generation","action":"edit"}`)	tool, _ = sjson.SetBytes(tool, "model", codexOpenAIImageToolModel(codexFormValue(form, "model"), routeModel))	for _, field := range []string{"size", "quality", "background", "output_format", "input_fidelity", "moderation"} {		if value := strings.TrimSpace(codexFormValue(form, field)); value != "" {			tool, _ = sjson.SetBytes(tool, field, value)		}	}	for _, field := range []string{"output_compression", "partial_images"} {		if value := strings.TrimSpace(codexFormValue(form, field)); value != "" {			if parsed, errParse := strconv.ParseInt(value, 10, 64); errParse == nil {				tool, _ = sjson.SetBytes(tool, field, parsed)			}		}	} 	images := make([]string, 0)	for _, fh := range codexMultipartImageFiles(form) {		dataURL, errData := codexMultipartFileToDataURL(fh)		if errData != nil {			return codexOpenAIImagePreparedRequest{}, errData		}		images = append(images, dataURL)	}	if maskFiles := form.File["mask"]; len(maskFiles) > 0 && maskFiles[0] != nil {		dataURL, errData := codexMultipartFileToDataURL(maskFiles[0])		if errData != nil {			return codexOpenAIImagePreparedRequest{}, errData		}		tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", dataURL)	} 	body := codexBuildImagesResponsesRequest(prompt, images, tool)	return codexOpenAIImagePreparedRequest{		Body:           body,		ResponseFormat: responseFormat,		StreamPrefix:   "image_edit",	}, nil} func codexImageContentType(headers http.Header) string {	if headers == nil {		return ""	}	return strings.TrimSpace(headers.Get("Content-Type"))} func codexOpenAIImageResponseFormatFromJSON(rawJSON []byte) string {	return codexNormalizeImageResponseFormat(gjson.GetBytes(rawJSON, "response_format").String())} func codexNormalizeImageResponseFormat(responseFormat string) string {	if strings.EqualFold(strings.TrimSpace(responseFormat), "url") {		return "url"	}	return "b64_json"} func codexOpenAIImageToolModel(requestModel string, routeModel string) string {	model := strings.TrimSpace(requestModel)	if model == "" {		model = strings.TrimSpace(routeModel)	}	if model == "" {		model = codexDefaultImageToolModel	}	return model} func codexBuildOpenAIImageTool(rawJSON []byte, routeModel string, action string, stringFields []string, numberFields []string) []byte {	tool := []byte(`{"type":"image_generation","action":""}`)	tool, _ = sjson.SetBytes(tool, "action", action)	tool, _ = sjson.SetBytes(tool, "model", codexOpenAIImageToolModel(gjson.GetBytes(rawJSON, "model").String(), routeModel))	for _, field := range stringFields {		if value := strings.TrimSpace(gjson.GetBytes(rawJSON, field).String()); value != "" {			tool, _ = sjson.SetBytes(tool, field, value)		}	}	for _, field := range numberFields {		if value := gjson.GetBytes(rawJSON, field); value.Exists() && value.Type == gjson.Number {			tool, _ = sjson.SetBytes(tool, field, value.Int())		}	}	return tool} func codexBuildImagesResponsesRequest(prompt string, images []string, toolJSON []byte) []byte {	req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`)	req, _ = sjson.SetBytes(req, "model", codexOpenAIImagesMainModel) 	input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`)	input, _ = sjson.SetBytes(input, "0.content.0.text", prompt)	contentIndex := 1	for _, img := range images {		if strings.TrimSpace(img) == "" {			continue		}		part := []byte(`{"type":"input_image","image_url":""}`)		part, _ = sjson.SetBytes(part, "image_url", img)		input, _ = sjson.SetRawBytes(input, fmt.Sprintf("0.content.%d", contentIndex), part)		contentIndex++	}	req, _ = sjson.SetRawBytes(req, "input", input)

Lines 1–500 of 678.