From d66fd83835b61778495bfef8e5f0e95f7817283f Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sun, 26 Apr 2026 22:42:36 +0800 Subject: [PATCH 01/45] chore: ignore stray local config and root artifacts --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index bbc5717e4727..d9845c0aca2c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ data/ .test token_estimator_test.go skills-lock.json +.cla + +# repo-root screenshot artifacts and openapi dumps (not nested) +/*.png +/*.openapi.json From 3b708fc96367e5e2cf3b104970048537feb7b7a3 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Thu, 30 Apr 2026 14:58:25 +0800 Subject: [PATCH 02/45] fix: aggregate SSE upstream into chat.completion JSON for non-stream clients (Co-Authored-By: Claude Opus 4.7 ) --- relay/channel/openai/chat_via_responses.go | 231 +++++++++++++++++++++ relay/chat_completions_via_responses.go | 20 +- 2 files changed, 249 insertions(+), 2 deletions(-) diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go index 2c0752275daa..01937c315f29 100644 --- a/relay/channel/openai/chat_via_responses.go +++ b/relay/channel/openai/chat_via_responses.go @@ -548,3 +548,234 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo } return usage, nil } + +// OaiResponsesSSEToChatJSON handles the case where the client requested a +// non-streaming chat completion but the upstream /v1/responses endpoint +// returned an SSE stream (common for reasoning models). It parses the SSE +// events, accumulates output text / tool calls / usage, builds a single +// dto.OpenAITextResponse, and writes it to the client as one JSON body. +// +// This avoids the bug where, in the original code path, when upstream returns +// SSE for a non-stream client request, raw SSE chunks (with empty choices) get +// forwarded directly to the client. +func OaiResponsesSSEToChatJSON(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + if resp == nil || resp.Body == nil { + return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + defer service.CloseResponseBodyGracefully(resp) + + responseId := helper.GetResponseID(c) + createAt := time.Now().Unix() + model := info.UpstreamModelName + + var ( + usage = &dto.Usage{} + outputText strings.Builder + usageText strings.Builder + streamErr *types.NewAPIError + ) + + toolCallIndexByID := make(map[string]int) + toolCallNameByID := make(map[string]string) + toolCallArgsByID := make(map[string]string) + toolCallOrder := make([]string, 0) + toolCallCanonicalIDByItemID := make(map[string]string) + + registerToolCall := func(callID string) { + if _, ok := toolCallIndexByID[callID]; ok { + return + } + toolCallIndexByID[callID] = len(toolCallOrder) + toolCallOrder = append(toolCallOrder, callID) + } + + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { + if streamErr != nil { + sr.Stop(streamErr) + return + } + + var streamResp dto.ResponsesStreamResponse + if err := common.UnmarshalJsonStr(data, &streamResp); err != nil { + logger.LogError(c, "failed to unmarshal responses stream event: "+err.Error()) + return + } + + switch streamResp.Type { + case "response.created": + if streamResp.Response != nil { + if streamResp.Response.Model != "" { + model = streamResp.Response.Model + } + if streamResp.Response.CreatedAt != 0 { + createAt = int64(streamResp.Response.CreatedAt) + } + } + + case "response.output_text.delta": + if streamResp.Delta != "" { + outputText.WriteString(streamResp.Delta) + usageText.WriteString(streamResp.Delta) + } + + case "response.output_item.added", "response.output_item.done": + if streamResp.Item == nil || streamResp.Item.Type != "function_call" { + break + } + itemID := strings.TrimSpace(streamResp.Item.ID) + callID := strings.TrimSpace(streamResp.Item.CallId) + if callID == "" { + callID = itemID + } + if itemID != "" && callID != "" { + toolCallCanonicalIDByItemID[itemID] = callID + } + if callID == "" { + break + } + registerToolCall(callID) + if name := strings.TrimSpace(streamResp.Item.Name); name != "" { + toolCallNameByID[callID] = name + usageText.WriteString(name) + } + if args := streamResp.Item.ArgumentsString(); args != "" { + toolCallArgsByID[callID] = args + } + + case "response.function_call_arguments.delta": + itemID := strings.TrimSpace(streamResp.ItemID) + callID := toolCallCanonicalIDByItemID[itemID] + if callID == "" { + callID = itemID + } + if callID == "" { + break + } + registerToolCall(callID) + toolCallArgsByID[callID] += streamResp.Delta + usageText.WriteString(streamResp.Delta) + + case "response.completed": + if streamResp.Response != nil { + if streamResp.Response.Model != "" { + model = streamResp.Response.Model + } + if streamResp.Response.CreatedAt != 0 { + createAt = int64(streamResp.Response.CreatedAt) + } + if streamResp.Response.Usage != nil { + if streamResp.Response.Usage.InputTokens != 0 { + usage.PromptTokens = streamResp.Response.Usage.InputTokens + usage.InputTokens = streamResp.Response.Usage.InputTokens + } + if streamResp.Response.Usage.OutputTokens != 0 { + usage.CompletionTokens = streamResp.Response.Usage.OutputTokens + usage.OutputTokens = streamResp.Response.Usage.OutputTokens + } + if streamResp.Response.Usage.TotalTokens != 0 { + usage.TotalTokens = streamResp.Response.Usage.TotalTokens + } else { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + if streamResp.Response.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = streamResp.Response.Usage.InputTokensDetails.CachedTokens + usage.PromptTokensDetails.ImageTokens = streamResp.Response.Usage.InputTokensDetails.ImageTokens + usage.PromptTokensDetails.AudioTokens = streamResp.Response.Usage.InputTokensDetails.AudioTokens + } + if streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens != 0 { + usage.CompletionTokenDetails.ReasoningTokens = streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens + } + } + } + + case "response.error", "response.failed": + if streamResp.Response != nil { + if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" { + streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError) + sr.Stop(streamErr) + return + } + } + streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError) + sr.Stop(streamErr) + return + + default: + } + }) + + if streamErr != nil { + return nil, streamErr + } + + if usage.TotalTokens == 0 { + usage = service.ResponseText2Usage(c, usageText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + } + + sawToolCall := len(toolCallOrder) > 0 + finishReason := "stop" + if sawToolCall && outputText.Len() == 0 { + finishReason = "tool_calls" + } + + msg := dto.Message{ + Role: "assistant", + Content: outputText.String(), + } + + if sawToolCall { + toolCalls := make([]dto.ToolCallResponse, 0, len(toolCallOrder)) + for _, callID := range toolCallOrder { + tc := dto.ToolCallResponse{ + ID: callID, + Type: "function", + Function: dto.FunctionResponse{ + Name: toolCallNameByID[callID], + Arguments: toolCallArgsByID[callID], + }, + } + toolCalls = append(toolCalls, tc) + } + toolCallsBytes, err := common.Marshal(toolCalls) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + } + msg.ToolCalls = toolCallsBytes + } + + chatResp := &dto.OpenAITextResponse{ + Id: responseId, + Model: model, + Object: "chat.completion", + Created: createAt, + Choices: []dto.OpenAITextResponseChoice{ + { + Index: 0, + Message: msg, + FinishReason: finishReason, + }, + }, + Usage: *usage, + } + + var ( + responseBody []byte + err error + ) + switch info.RelayFormat { + case types.RelayFormatClaude: + claudeResp := service.ResponseOpenAI2Claude(chatResp, info) + responseBody, err = common.Marshal(claudeResp) + case types.RelayFormatGemini: + geminiResp := service.ResponseOpenAI2Gemini(chatResp, info) + responseBody, err = common.Marshal(geminiResp) + default: + responseBody, err = common.Marshal(chatResp) + } + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + } + + service.IOCopyBytesGracefully(c, resp, responseBody) + return usage, nil +} diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index 7a2eb9aa6dac..d325d265ace4 100644 --- a/relay/chat_completions_via_responses.go +++ b/relay/chat_completions_via_responses.go @@ -139,14 +139,17 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad statusCodeMappingStr := c.GetString("status_code_mapping") httpResp = resp.(*http.Response) - info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + clientWantsStream := info.IsStream + upstreamIsSSE := strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + info.IsStream = clientWantsStream || upstreamIsSSE if httpResp.StatusCode != http.StatusOK { newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false) service.ResetStatusCode(newApiErr, statusCodeMappingStr) return nil, newApiErr } - if info.IsStream { + if clientWantsStream { + // Client requested stream — forward as SSE regardless of upstream format. usage, newApiErr := openaichannel.OaiResponsesToChatStreamHandler(c, info, httpResp) if newApiErr != nil { service.ResetStatusCode(newApiErr, statusCodeMappingStr) @@ -155,6 +158,19 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad return usage, nil } + if upstreamIsSSE { + // Client wants non-stream JSON, but upstream returned SSE (common for + // reasoning models). Buffer and aggregate the SSE stream into a full + // chat.completion JSON before writing to the client. + info.IsStream = false + usage, newApiErr := openaichannel.OaiResponsesSSEToChatJSON(c, info, httpResp) + if newApiErr != nil { + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return nil, newApiErr + } + return usage, nil + } + usage, newApiErr := openaichannel.OaiResponsesToChatHandler(c, info, httpResp) if newApiErr != nil { service.ResetStatusCode(newApiErr, statusCodeMappingStr) From 99b276f5f6bc5395ad0ddbd97c671e8439814568 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Fri, 1 May 2026 14:21:18 +0800 Subject: [PATCH 03/45] feat(cf-worker): default moderation:"low" for image generation --- scripts/cf-worker/worker.js | 998 ++++++++++++++++++++++++++++++++++++ 1 file changed, 998 insertions(+) create mode 100644 scripts/cf-worker/worker.js diff --git a/scripts/cf-worker/worker.js b/scripts/cf-worker/worker.js new file mode 100644 index 000000000000..516f0c49ec09 --- /dev/null +++ b/scripts/cf-worker/worker.js @@ -0,0 +1,998 @@ +// image-relay Cloudflare Worker +// Proxies /v1/* to an upstream Responses-API gateway; rewrites base64 image +// fields (both non-streaming JSON and streaming SSE) into public URLs served +// from IMAGE_BASE (e.g. an R2 custom domain). +// +// Bindings (env): +// IMAGES R2 bucket binding (required) +// IMAGE_BASE string, e.g. "https://cdn.opwan.ai" (required to rewrite) +// RELAY_KEY string, required Bearer token for /v1/* (optional) +// UPSTREAM_KEY string, real upstream key swapped in before forwarding (optional) +// UPSTREAM_URL string, override hardcoded upstream (optional; defaults to xixiapi.cc) + +const UPSTREAM = 'https://xixiapi.cc'; + +function getUpstream(env) { + return (env?.UPSTREAM_URL || UPSTREAM).replace(/\/$/, ''); +} + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + + if (url.pathname.startsWith('/img/')) { + return serveImage(url.pathname.slice(5), env); + } + + if (url.pathname === '/healthz') { + return new Response('image-relay: ok\n', { + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }); + } + + if (url.pathname === '/') { + return rootInfo(env); + } + + if (url.pathname.startsWith('/v1/')) { + const authError = checkAuth(request, env); + if (authError) return authError; + + // Adapter: classic Images API → Responses API upstream + if ( + request.method === 'POST' && + url.pathname === '/v1/images/generations' + ) { + return handleImagesGenerations(request, env); + } + if ( + request.method === 'POST' && + url.pathname === '/v1/images/edits' + ) { + return handleImagesEdits(request, env); + } + + // Self-healing: if a caller (e.g. new-api) sends an image-generation + // model through /v1/chat/completions, transparently route it through + // the image pipeline instead of letting it fall through to the upstream + // chat endpoint (which doesn't support image models). + if ( + request.method === 'POST' && + url.pathname === '/v1/chat/completions' + ) { + const bodyText = await request.text(); + let parsed = null; + try { parsed = JSON.parse(bodyText); } catch {} + if (parsed && isImageModel(parsed.model)) { + return handleChatCompletionsAsImage(parsed, request, env); + } + // Not an image model — reconstruct request and passthrough. + const passReq = new Request(request, { body: bodyText }); + return proxyAndMaybeRewrite(passReq, url, env, ctx); + } + + return proxyAndMaybeRewrite(request, url, env, ctx); + } + + return new Response('not found\n', { status: 404 }); + }, +}; + +function rootInfo(env) { + const info = { + service: 'image-relay', + upstream: getUpstream(env), + image_base: env.IMAGE_BASE || null, + auth_required: Boolean(env.RELAY_KEY), + endpoints: { + health: 'GET /healthz', + proxy: 'POST /v1/responses (Authorization: Bearer )', + images_fallback: 'GET /img/ (only when IMAGE_BASE is unset)', + }, + }; + return new Response(JSON.stringify(info, null, 2) + '\n', { + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }); +} + +function checkAuth(request, env) { + if (!env.RELAY_KEY) return null; + const auth = request.headers.get('authorization') || ''; + const provided = auth.replace(/^Bearer\s+/i, '').trim(); + if (provided !== env.RELAY_KEY) { + console.error('auth: invalid relay key'); + return new Response( + JSON.stringify({ + error: { + message: 'Invalid relay key', + type: 'authentication_error', + }, + }), + { + status: 401, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + }, + } + ); + } + return null; +} + +async function serveImage(key, env) { + const obj = await env.IMAGES.get(key); + if (!obj) return new Response('not found\n', { status: 404 }); + const headers = new Headers(); + headers.set( + 'content-type', + obj.httpMetadata?.contentType || 'application/octet-stream' + ); + headers.set('cache-control', 'public, max-age=31536000, immutable'); + headers.set('etag', obj.httpEtag); + return new Response(obj.body, { headers }); +} + +async function proxyAndMaybeRewrite(request, url, env, ctx) { + const upstreamUrl = getUpstream(env) + url.pathname + url.search; + const upstreamHeaders = new Headers(request.headers); + + // Swap the relay key for the real upstream key (when configured) + if (env.UPSTREAM_KEY) { + upstreamHeaders.set('authorization', `Bearer ${env.UPSTREAM_KEY}`); + } + + // Strip CF-injected hop headers so upstream sees a clean request + for (const h of [ + 'cf-connecting-ip', + 'cf-ipcountry', + 'cf-ray', + 'cf-visitor', + 'cf-ew-via', + 'x-forwarded-for', + 'x-real-ip', + ]) { + upstreamHeaders.delete(h); + } + + // Buffer the request body upfront so retries can resend it (a one-shot + // ReadableStream cannot be replayed). Body for /v1/* is small JSON, so + // buffering is cheap. + let bodyBuffer = null; + if (request.method !== 'GET' && request.method !== 'HEAD') { + try { + bodyBuffer = await request.arrayBuffer(); + } catch { + bodyBuffer = null; + } + } + + // For POST /v1/responses with image_generation tool(s), inject default + // moderation:"low" if the client didn't set it explicitly. + if ( + request.method === 'POST' && + url.pathname === '/v1/responses' && + bodyBuffer + ) { + bodyBuffer = injectImageModerationDefault(bodyBuffer); + } + + const upstreamResp = await fetchWithFastFailRetry(upstreamUrl, { + method: request.method, + headers: upstreamHeaders, + body: bodyBuffer, + }); + const ct = (upstreamResp.headers.get('content-type') || '').toLowerCase(); + + const isResponsesPost = + request.method === 'POST' && url.pathname === '/v1/responses'; + + if (isResponsesPost && upstreamResp.ok) { + if (ct.includes('text/event-stream')) { + return rewriteSSE(upstreamResp, env, ctx); + } + if (ct.includes('application/json')) { + return rewriteJsonResponse(upstreamResp, url, env); + } + } + + // Pass-through: errors, /v1/images/*, anything that's not /v1/responses JSON + const passHeaders = new Headers(upstreamResp.headers); + passHeaders.set('access-control-allow-origin', '*'); + return new Response(upstreamResp.body, { + status: upstreamResp.status, + headers: passHeaders, + }); +} + +async function rewriteJsonResponse(upstreamResp, url, env) { + const data = await upstreamResp.json(); + // If IMAGE_BASE is bound (e.g. R2 custom domain), use it. Otherwise serve + // from this Worker's own /img/* path. + const imageBase = (env.IMAGE_BASE || `${url.protocol}//${url.host}/img`).replace(/\/$/, ''); + let rewritten = 0; + + if (Array.isArray(data.output)) { + for (const item of data.output) { + if ( + item && + item.type === 'image_generation_call' && + typeof item.result === 'string' && + item.result.length > 100 + ) { + const ext = inferExt(item.output_format, item.result); + const key = await uploadToR2(item.result, ext, env); + item.result = `${imageBase}/${key}`; + if (item.status === 'generating') item.status = 'completed'; + rewritten++; + } + } + } + + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + 'x-relay-rewritten-count': String(rewritten), + }, + }); +} + +async function uploadToR2(b64, ext, env) { + const binary = base64ToBytes(b64); + const hash = await sha256Hex(binary); + const key = `images/${hash}.${ext}`; + const existing = await env.IMAGES.head(key); + if (!existing) { + const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`; + await env.IMAGES.put(key, binary, { + httpMetadata: { contentType: mime }, + }); + } + return key; +} + +function inferExt(claimed, b64) { + const c = (claimed || '').toLowerCase(); + if (c === 'jpeg' || c === 'jpg') return 'jpg'; + if (c === 'png') return 'png'; + if (c === 'webp') return 'webp'; + const head = b64.slice(0, 8); + if (head.startsWith('iVBOR')) return 'png'; + if (head.startsWith('/9j/')) return 'jpg'; + if (head.startsWith('UklG')) return 'webp'; + if (head.startsWith('R0lGOD')) return 'gif'; + return 'png'; +} + +function base64ToBytes(b64) { + const bin = atob(b64); + const len = bin.length; + const arr = new Uint8Array(len); + for (let i = 0; i < len; i++) arr[i] = bin.charCodeAt(i); + return arr; +} + +async function sha256Hex(buf) { + const hash = await crypto.subtle.digest('SHA-256', buf); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +// Wraps fetch with fast-fail retry. Only retries on 502/503/504 that come back +// quickly (< fastFailMs) — those are upstream gateway-layer rejects (rate +// limit, transient overload) where the upstream model never started running, +// so re-issuing is safe and cheap. Slow failures (model already burned cycles) +// are returned as-is to avoid double-charging compute. +// +// init.body MUST be a re-readable value (string / Uint8Array / FormData), not +// a one-shot ReadableStream, otherwise the retry attempt sends an empty body. +async function fetchWithFastFailRetry(url, init, opts) { + const FAST_FAIL_MS = opts?.fastFailMs ?? 10000; + const MAX_ATTEMPTS = opts?.maxAttempts ?? 3; + const RETRY_STATUSES = opts?.retryStatuses ?? new Set([502, 503, 504]); + + let lastResp; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const start = Date.now(); + lastResp = await fetch(url, init); + const elapsed = Date.now() - start; + + if (lastResp.ok) return lastResp; + if (!RETRY_STATUSES.has(lastResp.status)) return lastResp; + if (elapsed >= FAST_FAIL_MS) return lastResp; + if (attempt === MAX_ATTEMPTS) return lastResp; + + // Drain body so the connection can be reused + try { await lastResp.body?.cancel(); } catch {} + + const backoffMs = 300 * attempt; + console.error( + `retry ${attempt + 1}/${MAX_ATTEMPTS} after fast fail status=${lastResp.status} elapsed=${elapsed}ms` + ); + await new Promise((r) => setTimeout(r, backoffMs)); + } + return lastResp; +} + +// ---- Classic /v1/images/generations adapter ------------------------------- +// +// Translates classic OpenAI Images-API requests into the Responses-API shape +// upstream, then re-shapes the upstream output back into the classic +// `{created, data: [{url|b64_json}]}` envelope so existing OpenAI SDK clients +// (and new-api's image-generation billing path) work unchanged. + +// Detects model names that should be routed through the image pipeline, +// even when the caller arrives via /v1/chat/completions. +function isImageModel(model) { + if (!model || typeof model !== 'string') return false; + const m = model.toLowerCase(); + return ( + m.startsWith('gpt-image') || + m.startsWith('dall-e') || + m.startsWith('flux') || + m.startsWith('sd-') || + m.startsWith('stable-') || + m.includes('-image-') || + m.endsWith('-image') + ); +} + +function extractPromptFromMessages(messages) { + if (!Array.isArray(messages)) return ''; + // Take the last user message; fall back to concatenation of all user texts. + const userMsgs = messages.filter((m) => m && m.role === 'user'); + const target = userMsgs.length ? userMsgs[userMsgs.length - 1] : messages[messages.length - 1]; + if (!target) return ''; + if (typeof target.content === 'string') return target.content; + if (Array.isArray(target.content)) { + return target.content + .filter((p) => p && p.type === 'text' && typeof p.text === 'string') + .map((p) => p.text) + .join(' '); + } + return ''; +} + +// Bridges /v1/chat/completions → image pipeline → chat.completion envelope. +// The assistant message content embeds the generated image as a Markdown +// image link (most chat UIs render it natively). When the original request +// asked for stream:true, the response is delivered as a minimal SSE stream +// so streaming clients don't break. +async function handleChatCompletionsAsImage(reqBody, originalRequest, env) { + const prompt = extractPromptFromMessages(reqBody.messages); + if (!prompt) { + return jsonError(400, 'no user prompt found in messages'); + } + + // Forge an Images API request and reuse the existing handler. + const imageReq = { + model: reqBody.model, + prompt, + size: reqBody.size || '1024x1024', + output_format: reqBody.output_format || 'jpeg', + output_compression: + reqBody.output_compression !== undefined ? reqBody.output_compression : 85, + response_format: 'url', + }; + if (reqBody.background) imageReq.background = reqBody.background; + if (reqBody.quality) imageReq.quality = reqBody.quality; + if (reqBody.moderation) imageReq.moderation = reqBody.moderation; + + const syntheticRequest = new Request(originalRequest.url, { + method: 'POST', + headers: originalRequest.headers, + body: JSON.stringify(imageReq), + }); + + const imageResp = await handleImagesGenerations(syntheticRequest, env); + + if (!imageResp.ok) { + // Pass through upstream error as-is. + return imageResp; + } + + let imageJson; + try { + imageJson = await imageResp.json(); + } catch { + return jsonError(502, 'image handler returned non-json'); + } + + const first = (imageJson.data && imageJson.data[0]) || {}; + const url = typeof first.url === 'string' ? first.url : ''; + const b64 = typeof first.b64_json === 'string' ? first.b64_json : ''; + const revised = typeof first.revised_prompt === 'string' ? first.revised_prompt : ''; + + let content = ''; + if (url) { + content = `![image](${url})`; + } else if (b64) { + const ext = inferExt(imageJson.output_format, b64); + const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`; + content = `![image](data:${mime};base64,${b64})`; + } else { + content = '(image generation produced no result)'; + } + if (revised) { + content = content + `\n\n_Revised prompt: ${revised}_`; + } + + const chatId = 'chatcmpl-' + (crypto.randomUUID ? crypto.randomUUID().replace(/-/g, '') : Date.now().toString(36)); + const created = Math.floor(Date.now() / 1000); + const model = imageJson.model || reqBody.model; + + const usage = imageJson.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; + + // Streaming clients: emit a tiny SSE sequence (role / content / stop / DONE). + if (reqBody.stream) { + const enc = new TextEncoder(); + const body = + sseChunk({ + id: chatId, + object: 'chat.completion.chunk', + created, + model, + choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }], + }) + + sseChunk({ + id: chatId, + object: 'chat.completion.chunk', + created, + model, + choices: [{ index: 0, delta: { content }, finish_reason: null }], + }) + + sseChunk({ + id: chatId, + object: 'chat.completion.chunk', + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + }) + + 'data: [DONE]\n\n'; + return new Response(enc.encode(body), { + status: 200, + headers: { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache, no-transform', + 'access-control-allow-origin': '*', + 'x-relay-mode': 'chat-as-image-sse', + }, + }); + } + + const chatResp = { + id: chatId, + object: 'chat.completion', + created, + model, + choices: [ + { + index: 0, + message: { role: 'assistant', content }, + finish_reason: 'stop', + }, + ], + usage, + }; + + return new Response(JSON.stringify(chatResp), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + 'x-relay-mode': 'chat-as-image', + }, + }); +} + +function sseChunk(obj) { + return 'data: ' + JSON.stringify(obj) + '\n\n'; +} + +// Walks a JSON request body and ensures every image_generation tool has +// moderation set; defaults to "low". Returns a new ArrayBuffer if mutated, +// otherwise returns the input untouched. +function injectImageModerationDefault(bodyBuffer) { + try { + const text = new TextDecoder().decode(bodyBuffer); + const obj = JSON.parse(text); + if (!obj || !Array.isArray(obj.tools)) return bodyBuffer; + let mutated = false; + for (const t of obj.tools) { + if (t && t.type === 'image_generation' && !t.moderation) { + t.moderation = 'low'; + mutated = true; + } + } + if (!mutated) return bodyBuffer; + return new TextEncoder().encode(JSON.stringify(obj)).buffer; + } catch { + return bodyBuffer; + } +} + +function jsonError(status, message) { + return new Response( + JSON.stringify({ error: { message, type: 'invalid_request_error' } }), + { + status, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + }, + } + ); +} + +async function handleImagesGenerations(request, env) { + let reqBody; + try { + reqBody = await request.json(); + } catch { + return jsonError(400, 'Invalid JSON body'); + } + + // Map classic params → image_generation tool config (only forward set fields) + const tool = { type: 'image_generation' }; + if (reqBody.size) tool.size = reqBody.size; + if (reqBody.quality) tool.quality = reqBody.quality; + if (reqBody.output_format) tool.output_format = reqBody.output_format; + if (reqBody.output_compression !== undefined) { + tool.output_compression = reqBody.output_compression; + } + if (reqBody.background) tool.background = reqBody.background; + // Default moderation to "low" for the lowest false-positive rate; client can + // still override by passing moderation explicitly. + tool.moderation = reqBody.moderation || 'low'; + + const responsesBody = { + model: reqBody.model || 'gpt-image-2', + input: reqBody.prompt ?? reqBody.input ?? '', + tools: [tool], + }; + + const upstreamHeaders = new Headers(); + upstreamHeaders.set('content-type', 'application/json'); + if (env.UPSTREAM_KEY) { + upstreamHeaders.set('authorization', `Bearer ${env.UPSTREAM_KEY}`); + } else { + const incoming = request.headers.get('authorization'); + if (incoming) upstreamHeaders.set('authorization', incoming); + } + + const upstreamResp = await fetchWithFastFailRetry(getUpstream(env) + '/v1/responses', { + method: 'POST', + headers: upstreamHeaders, + body: JSON.stringify(responsesBody), + }); + + if (!upstreamResp.ok) { + const errBody = await upstreamResp.text(); + console.error( + `images/generations upstream ${upstreamResp.status}: ${errBody.slice(0, 200)}` + ); + return new Response(errBody, { + status: upstreamResp.status, + headers: { + 'content-type': upstreamResp.headers.get('content-type') || 'text/plain', + 'access-control-allow-origin': '*', + }, + }); + } + + const responsesData = await upstreamResp.json(); + const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); + const wantB64 = reqBody.response_format === 'b64_json'; + const originalPrompt = String(reqBody.prompt ?? reqBody.input ?? ''); + + const data = []; + let firstFormat; + let firstSize; + for (const item of responsesData.output || []) { + if ( + item?.type === 'image_generation_call' && + typeof item.result === 'string' && + item.result.length > 100 + ) { + if (!firstFormat) firstFormat = item.output_format; + if (!firstSize) firstSize = item.size; + const entry = {}; + if (wantB64) { + entry.b64_json = item.result; + } else { + const ext = inferExt(item.output_format, item.result); + const key = await uploadToR2(item.result, ext, env); + entry.url = imageBase + ? `${imageBase}/${key}` + : `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${item.result}`; + } + // revised_prompt fallback chain: image_generation_call.revised_prompt + // (the canonical Responses-API location) → message text → original prompt + const revised = + (typeof item.revised_prompt === 'string' && item.revised_prompt) || + extractMessageText(responsesData) || + originalPrompt; + if (revised) entry.revised_prompt = revised; + data.push(entry); + } + } + + const out = { + created: Math.floor(Date.now() / 1000), + data, + background: responsesData.background || reqBody.background || 'auto', + output_format: reqBody.output_format || firstFormat || 'png', + quality: reqBody.quality || 'auto', + size: reqBody.size || firstSize || '1024x1024', + usage: responsesData.usage, + model: responsesData.model || reqBody.model, + }; + + return new Response(JSON.stringify(out), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + 'x-relay-mode': 'images-via-responses', + 'x-relay-rewritten-count': String(data.length), + }, + }); +} + +// ---- Classic /v1/images/edits adapter ------------------------------------- +// +// Translates classic OpenAI Images-Edits requests (multipart/form-data with +// an image file and a text prompt) into the Responses-API multimodal shape +// upstream, then re-shapes the response back into the classic Images-API +// envelope. xixiapi rejects inline-base64 webp on /v1/responses, so webp +// uploads are short-circuited with a clear 400. + +async function handleImagesEdits(request, env) { + let formData; + try { + formData = await request.formData(); + } catch { + return jsonError(400, 'Invalid multipart/form-data body'); + } + + const imageField = formData.get('image'); + const prompt = formData.get('prompt'); + if (!imageField || typeof imageField === 'string') { + return jsonError(400, '"image" field is required and must be a file'); + } + if (!prompt || typeof prompt !== 'string') { + return jsonError(400, '"prompt" field is required'); + } + + const imageType = (imageField.type || 'image/png').toLowerCase(); + if (imageType.includes('webp')) { + return jsonError( + 400, + 'webp inline base64 is not supported by the upstream Responses API; please convert the source image to png or jpeg before uploading' + ); + } + if ( + !imageType.includes('png') && + !imageType.includes('jpeg') && + !imageType.includes('jpg') + ) { + return jsonError(400, `unsupported image type "${imageType}", use png or jpeg`); + } + + const arrayBuffer = await imageField.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + const b64 = bytesToBase64(bytes); + const mime = imageType.includes('jpeg') || imageType.includes('jpg') ? 'image/jpeg' : 'image/png'; + const dataUri = `data:${mime};base64,${b64}`; + + const model = String(formData.get('model') || 'gpt-image-2'); + const size = formData.get('size'); + const quality = formData.get('quality'); + const outputFormat = formData.get('output_format'); + const outputCompression = formData.get('output_compression'); + const background = formData.get('background'); + const moderation = formData.get('moderation'); + const responseFormat = formData.get('response_format'); + + // Build /v1/responses tool config from form fields + const tool = { type: 'image_generation' }; + if (size) tool.size = String(size); + if (quality) tool.quality = String(quality); + if (outputFormat) tool.output_format = String(outputFormat); + if ( + outputCompression !== null && + outputCompression !== undefined && + outputCompression !== '' + ) { + const n = parseInt(String(outputCompression), 10); + if (!Number.isNaN(n)) tool.output_compression = n; + } + if (background) tool.background = String(background); + // Default moderation to "low" for the lowest false-positive rate. + tool.moderation = moderation ? String(moderation) : 'low'; + + const responsesBody = { + model, + input: [ + { + role: 'user', + content: [ + { type: 'input_text', text: String(prompt) }, + { type: 'input_image', image_url: dataUri }, + ], + }, + ], + tools: [tool], + }; + + const upstreamHeaders = new Headers(); + upstreamHeaders.set('content-type', 'application/json'); + if (env.UPSTREAM_KEY) { + upstreamHeaders.set('authorization', `Bearer ${env.UPSTREAM_KEY}`); + } else { + const incoming = request.headers.get('authorization'); + if (incoming) upstreamHeaders.set('authorization', incoming); + } + + const upstreamResp = await fetchWithFastFailRetry(getUpstream(env) + '/v1/responses', { + method: 'POST', + headers: upstreamHeaders, + body: JSON.stringify(responsesBody), + }); + + if (!upstreamResp.ok) { + const errBody = await upstreamResp.text(); + console.error( + `images/edits upstream ${upstreamResp.status}: ${errBody.slice(0, 200)}` + ); + return new Response(errBody, { + status: upstreamResp.status, + headers: { + 'content-type': upstreamResp.headers.get('content-type') || 'text/plain', + 'access-control-allow-origin': '*', + }, + }); + } + + const responsesData = await upstreamResp.json(); + const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); + const wantB64 = responseFormat === 'b64_json'; + const originalPrompt = String(prompt); + + const data = []; + let firstFormat; + let firstSize; + for (const item of responsesData.output || []) { + if ( + item?.type === 'image_generation_call' && + typeof item.result === 'string' && + item.result.length > 100 + ) { + if (!firstFormat) firstFormat = item.output_format; + if (!firstSize) firstSize = item.size; + const entry = {}; + if (wantB64) { + entry.b64_json = item.result; + } else { + const ext = inferExt(item.output_format, item.result); + const key = await uploadToR2(item.result, ext, env); + entry.url = imageBase + ? `${imageBase}/${key}` + : `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${item.result}`; + } + const revised = + (typeof item.revised_prompt === 'string' && item.revised_prompt) || + extractMessageText(responsesData) || + originalPrompt; + if (revised) entry.revised_prompt = revised; + data.push(entry); + } + } + + const out = { + created: Math.floor(Date.now() / 1000), + data, + background: responsesData.background || (background ? String(background) : 'auto'), + output_format: outputFormat ? String(outputFormat) : firstFormat || 'png', + quality: quality ? String(quality) : 'auto', + size: size ? String(size) : firstSize || '1024x1024', + usage: responsesData.usage, + model: responsesData.model || model, + }; + + return new Response(JSON.stringify(out), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + 'x-relay-mode': 'edits-via-responses', + 'x-relay-rewritten-count': String(data.length), + }, + }); +} + +function bytesToBase64(bytes) { + let bin = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + } + return btoa(bin); +} + +// Pulls the first non-empty `output_text` from any message item in a +// /v1/responses payload. Used as one of the fallbacks for revised_prompt +// when adapting back to the classic Images API envelope. +function extractMessageText(responsesData) { + for (const item of responsesData.output || []) { + if (item?.type === 'message' && Array.isArray(item.content)) { + for (const part of item.content) { + if ( + part?.type === 'output_text' && + typeof part.text === 'string' && + part.text.length > 0 + ) { + return part.text; + } + } + } + } + return ''; +} + +// ---- SSE rewrite ---------------------------------------------------------- +// +// For streaming /v1/responses (stream=true), parse the SSE event stream on +// the fly. Any event carrying base64 image data has its payload uploaded to +// R2 and the base64 field replaced with a public URL pointing to IMAGE_BASE. +// Other event types are forwarded unchanged. + +function rewriteSSE(upstreamResp, env, ctx) { + const { readable, writable } = new TransformStream(); + ctx.waitUntil(processSSEStream(upstreamResp.body, writable, env)); + const headers = new Headers(); + headers.set('content-type', 'text/event-stream; charset=utf-8'); + headers.set('cache-control', 'no-cache, no-transform'); + headers.set('connection', 'keep-alive'); + headers.set('access-control-allow-origin', '*'); + headers.set('x-relay-mode', 'sse'); + return new Response(readable, { status: 200, headers }); +} + +async function processSSEStream(upstreamBody, writable, env) { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const reader = upstreamBody.getReader(); + const writer = writable.getWriter(); + + // Per-request state: image_generation_call items completed so far. Used to + // refill response.completed.response.output when upstream sends it empty. + const state = { collectedItems: [] }; + + let buffer = ''; + let modified = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const eventText = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const result = await processSSEEvent(eventText, env, state); + if (result.changed) modified++; + await writer.write(encoder.encode(result.text + '\n\n')); + } + } + buffer += decoder.decode(); + if (buffer.trim().length > 0) { + const result = await processSSEEvent(buffer, env, state); + await writer.write(encoder.encode(result.text)); + } + if (modified > 0) console.log(`SSE: rewrote ${modified} event(s)`); + } catch (e) { + console.error('SSE rewrite error:', e instanceof Error ? e.message : String(e)); + } finally { + try { await writer.close(); } catch {} + } +} + +async function processSSEEvent(eventText, env, state) { + const lines = eventText.split('\n'); + let dataIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('data: ')) { + dataIdx = i; + break; + } + } + if (dataIdx < 0) return { text: eventText, changed: false }; + + const dataStr = lines[dataIdx].slice(6); + let obj; + try { obj = JSON.parse(dataStr); } catch { return { text: eventText, changed: false }; } + + const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); + if (!imageBase) return { text: eventText, changed: false }; + + let changed = false; + + // partial_image — bulky preview frame + if ( + obj.type === 'response.image_generation_call.partial_image' && + typeof obj.partial_image_b64 === 'string' && + obj.partial_image_b64.length > 100 + ) { + const ext = inferExt(obj.output_format, obj.partial_image_b64); + const key = await uploadToR2(obj.partial_image_b64, ext, env); + obj.partial_image_url = `${imageBase}/${key}`; + delete obj.partial_image_b64; + changed = true; + } + + // output_item.done — completed image (terminal frame) + if ( + obj.type === 'response.output_item.done' && + obj.item?.type === 'image_generation_call' && + typeof obj.item.result === 'string' && + obj.item.result.length > 100 + ) { + const ext = inferExt(obj.item.output_format, obj.item.result); + const key = await uploadToR2(obj.item.result, ext, env); + obj.item.result = `${imageBase}/${key}`; + if (obj.item.status === 'generating') obj.item.status = 'completed'; + changed = true; + } + + // Track completed image-generation items so we can refill response.completed + // when upstream leaves response.output as []. Use a shallow clone to avoid + // accidental mutation if the same item is touched again later. + if ( + obj.type === 'response.output_item.done' && + obj.item?.type === 'image_generation_call' && + state && + Array.isArray(state.collectedItems) + ) { + state.collectedItems.push(JSON.parse(JSON.stringify(obj.item))); + } + + // response.completed — rewrite any base64 still inside, fix status, and + // refill response.output if upstream sent it empty. + if (obj.type === 'response.completed' && obj.response) { + if (Array.isArray(obj.response.output)) { + for (const item of obj.response.output) { + if ( + item?.type === 'image_generation_call' && + typeof item.result === 'string' && + item.result.length > 100 + ) { + const ext = inferExt(item.output_format, item.result); + const key = await uploadToR2(item.result, ext, env); + item.result = `${imageBase}/${key}`; + if (item.status === 'generating') item.status = 'completed'; + changed = true; + } + } + if ( + obj.response.output.length === 0 && + state && + Array.isArray(state.collectedItems) && + state.collectedItems.length > 0 + ) { + obj.response.output = state.collectedItems.slice(); + changed = true; + } + } + } + + if (!changed) return { text: eventText, changed: false }; + lines[dataIdx] = 'data: ' + JSON.stringify(obj); + return { text: lines.join('\n'), changed: true }; +} From b78e7db37069181fe49d0c60f35787afb4cdac7b Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Mon, 4 May 2026 03:08:42 +0800 Subject: [PATCH 04/45] feat(cf-worker): multi-image edits + URL fallback + non-stream aggregation --- scripts/cf-worker/worker.js | 519 +++++++++++++++++++++++++++++++----- 1 file changed, 445 insertions(+), 74 deletions(-) diff --git a/scripts/cf-worker/worker.js b/scripts/cf-worker/worker.js index 516f0c49ec09..4694af183175 100644 --- a/scripts/cf-worker/worker.js +++ b/scripts/cf-worker/worker.js @@ -172,14 +172,79 @@ async function proxyAndMaybeRewrite(request, url, env, ctx) { // For POST /v1/responses with image_generation tool(s), inject default // moderation:"low" if the client didn't set it explicitly. - if ( - request.method === 'POST' && - url.pathname === '/v1/responses' && - bodyBuffer - ) { + const isResponsesPost = + request.method === 'POST' && url.pathname === '/v1/responses'; + + if (isResponsesPost && bodyBuffer) { bodyBuffer = injectImageModerationDefault(bodyBuffer); } + // For non-streaming /v1/responses calls that include an image_generation + // tool, force the upstream call into SSE and aggregate. Image generation + // routinely takes 60-180s and would otherwise hit CF's 100s subrequest + // timeout, returning 524 to the client. + if (isResponsesPost && bodyBuffer) { + let parsed = null; + try { + parsed = JSON.parse(new TextDecoder().decode(bodyBuffer)); + } catch {} + + // URL → data:URI fallback. Many SDKs / users pass an http(s) URL for + // input_image.image_url, but several upstream Responses-API providers + // only accept inline base64. Inline before forwarding so the client + // doesn't have to know which upstream is configured. Re-encode bodyBuffer + // when any URL is inlined so the streaming-passthrough path also sees it. + if (parsed) { + try { + const { inlined } = await inlineInputImageUrls(parsed); + if (inlined > 0) { + bodyBuffer = new TextEncoder().encode(JSON.stringify(parsed)).buffer; + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const sep = msg.lastIndexOf('|'); + const status = sep >= 0 ? parseInt(msg.slice(sep + 1), 10) || 400 : 400; + const text = sep >= 0 ? msg.slice(0, sep) : msg; + return jsonError(status, `input_image url inline failed: ${text}`); + } + } + + const hasImageTool = + parsed && Array.isArray(parsed.tools) && + parsed.tools.some((t) => t && t.type === 'image_generation'); + const clientWantsStream = parsed?.stream === true; + if (hasImageTool && !clientWantsStream) { + const result = await postResponsesAggregated(upstreamUrl, upstreamHeaders, parsed, env); + if (result.ok) { + return new Response(JSON.stringify(result.response), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + 'x-relay-mode': 'aggregated', + }, + }); + } + if (result.errorPayload) { + return new Response(JSON.stringify({ error: result.errorPayload }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + 'x-relay-mode': 'aggregated-error', + }, + }); + } + return new Response(result.rawBody, { + status: result.status, + headers: { + 'content-type': result.contentType, + 'access-control-allow-origin': '*', + }, + }); + } + } + const upstreamResp = await fetchWithFastFailRetry(upstreamUrl, { method: request.method, headers: upstreamHeaders, @@ -187,9 +252,6 @@ async function proxyAndMaybeRewrite(request, url, env, ctx) { }); const ct = (upstreamResp.headers.get('content-type') || '').toLowerCase(); - const isResponsesPost = - request.method === 'POST' && url.pathname === '/v1/responses'; - if (isResponsesPost && upstreamResp.ok) { if (ct.includes('text/event-stream')) { return rewriteSSE(upstreamResp, env, ctx); @@ -284,6 +346,294 @@ async function sha256Hex(buf) { .join(''); } +// Limits for inline-image inputs. CF Workers have a 128MB CPU/memory budget +// per request; 25MB matches OpenAI's per-image cap, and 16 matches their +// images-edits batch limit. +const MAX_IMAGE_BYTES = 25 * 1024 * 1024; +const MAX_IMAGES_PER_REQUEST = 16; +const ALLOWED_IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp']; + +function pickMimeFromType(rawType) { + const t = (rawType || '').toLowerCase(); + if (t.includes('webp')) return 'image/webp'; + if (t.includes('jpeg') || t.includes('jpg')) return 'image/jpeg'; + if (t.includes('png')) return 'image/png'; + return null; +} + +// Resolves an image input — File/Blob, http(s) URL string, or pre-formed +// data: URI — into a data:image/;base64,... string. Validates type and +// caps size at MAX_IMAGE_BYTES. Returns { dataUri, mime } on success, throws +// Error('msg|status') where status is the HTTP status to surface. +async function normalizeImageInput(value) { + // Pre-formed data URI: validate prefix and pass through unchanged. + if (typeof value === 'string' && value.startsWith('data:')) { + const m = /^data:(image\/(?:png|jpeg|webp))(?:;[^,]*)?,/i.exec(value); + if (!m) throw new Error('image data URI must be image/png|jpeg|webp|400'); + return { dataUri: value, mime: m[1].toLowerCase() }; + } + + // http(s) URL: fetch, validate, base64-encode. + if (typeof value === 'string' && /^https?:\/\//i.test(value)) { + let resp; + try { + resp = await fetch(value, { + method: 'GET', + redirect: 'follow', + cf: { cacheEverything: false }, + }); + } catch (e) { + throw new Error(`failed to fetch image url: ${e instanceof Error ? e.message : String(e)}|400`); + } + if (!resp.ok) { + throw new Error(`image url returned ${resp.status}|400`); + } + const declaredType = resp.headers.get('content-type') || ''; + const declaredLen = parseInt(resp.headers.get('content-length') || '0', 10); + if (declaredLen && declaredLen > MAX_IMAGE_BYTES) { + throw new Error(`image url too large: ${declaredLen} bytes (max ${MAX_IMAGE_BYTES})|400`); + } + const buf = new Uint8Array(await resp.arrayBuffer()); + if (buf.length > MAX_IMAGE_BYTES) { + throw new Error(`image url too large: ${buf.length} bytes (max ${MAX_IMAGE_BYTES})|400`); + } + const mime = + pickMimeFromType(declaredType) || + pickMimeFromType('image/' + (inferExt('', bytesToBase64(buf.subarray(0, 12))) || '')); + if (!mime) { + throw new Error(`image url content-type unsupported: "${declaredType}"|400`); + } + return { dataUri: `data:${mime};base64,${bytesToBase64(buf)}`, mime }; + } + + // File / Blob from multipart. + if (value && typeof value === 'object' && typeof value.arrayBuffer === 'function') { + const mime = pickMimeFromType(value.type); + if (!mime) { + throw new Error(`unsupported image type "${value.type || 'unknown'}", use png, jpeg, or webp|400`); + } + const buf = new Uint8Array(await value.arrayBuffer()); + if (buf.length === 0) throw new Error('empty image file|400'); + if (buf.length > MAX_IMAGE_BYTES) { + throw new Error(`image too large: ${buf.length} bytes (max ${MAX_IMAGE_BYTES})|400`); + } + return { dataUri: `data:${mime};base64,${bytesToBase64(buf)}`, mime }; + } + + throw new Error('image input must be a file, http(s) URL, or data: URI|400'); +} + +// Collects all image-like form fields used by the OpenAI Images-Edits API. +// Supports three idioms used by SDKs in the wild: +// - repeated 'image' fields (canonical) +// - 'image[]' suffix (some Python SDKs) +// - 'image[0]', 'image[1]', ... numbered (some JS SDKs) +function collectImageFields(formData) { + const out = []; + for (const v of formData.getAll('image')) out.push(v); + for (const v of formData.getAll('image[]')) out.push(v); + // Numbered: image[0], image[1], ... + const numbered = []; + for (const [k, v] of formData.entries()) { + const m = /^image\[(\d+)\]$/.exec(k); + if (m) numbered.push({ idx: parseInt(m[1], 10), v }); + } + numbered.sort((a, b) => a.idx - b.idx); + for (const n of numbered) out.push(n.v); + // Drop empty / blank-string entries. + return out.filter( + (v) => v !== null && v !== undefined && v !== '' && v !== 'undefined' + ); +} + +// Walks a /v1/responses request body and inlines any input_image.image_url +// that points at an http(s) URL. Returns the same object (mutated) plus the +// number of URLs that were inlined (for diagnostics). Errors propagate. +async function inlineInputImageUrls(body) { + if (!body || !Array.isArray(body.input)) return { body, inlined: 0 }; + let inlined = 0; + for (const item of body.input) { + if (!item || !Array.isArray(item.content)) continue; + for (const part of item.content) { + if ( + part && + part.type === 'input_image' && + typeof part.image_url === 'string' && + /^https?:\/\//i.test(part.image_url) + ) { + const { dataUri } = await normalizeImageInput(part.image_url); + part.image_url = dataUri; + inlined++; + } + } + } + return { body, inlined }; +} + +// Forces stream:true on a /v1/responses POST body, consumes the upstream SSE +// in this worker, and returns a single aggregated JSON response object +// (mirroring what upstream would have sent for a non-streaming call). +// +// Why: image-generation calls routinely take 60-180s. CF subrequests have a +// 100s read timeout, so non-streaming POSTs to slow upstreams reliably 524. +// SSE streams keep the connection alive via incremental events and dodge the +// timeout, then we reassemble. +// +// Side effects: same R2 uploads as rewriteSSE / rewriteJsonResponse — base64 +// image_generation_call.result is rewritten to a CDN URL inline. +async function postResponsesAggregated(upstreamUrl, headers, parsedBody, env) { + const merged = { ...parsedBody, stream: true }; + const upstreamResp = await fetchWithFastFailRetry(upstreamUrl, { + method: 'POST', + headers, + body: JSON.stringify(merged), + }); + + if (!upstreamResp.ok) { + const errBody = await upstreamResp.text(); + return { + ok: false, + status: upstreamResp.status, + contentType: upstreamResp.headers.get('content-type') || 'text/plain', + rawBody: errBody, + }; + } + + const ct = (upstreamResp.headers.get('content-type') || '').toLowerCase(); + const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); + + // Upstream ignored stream:true and gave back JSON anyway — handle as before. + if (!ct.includes('text/event-stream')) { + const data = await upstreamResp.json(); + if (imageBase && Array.isArray(data.output)) { + for (const item of data.output) { + if ( + item?.type === 'image_generation_call' && + typeof item.result === 'string' && + item.result.length > 100 + ) { + const ext = inferExt(item.output_format, item.result); + const key = await uploadToR2(item.result, ext, env); + item.result = `${imageBase}/${key}`; + if (item.status === 'generating') item.status = 'completed'; + } + } + } + return { ok: true, status: 200, response: data }; + } + + const decoder = new TextDecoder(); + const reader = upstreamResp.body.getReader(); + const state = { collectedItems: [] }; + let finalResponse = null; + let errorPayload = null; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const eventText = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const consumed = await consumeAggregatedEvent(eventText, env, state, imageBase); + if (consumed.finalResponse) finalResponse = consumed.finalResponse; + if (consumed.error) errorPayload = consumed.error; + } + } + buffer += decoder.decode(); + if (buffer.trim().length > 0) { + const consumed = await consumeAggregatedEvent(buffer, env, state, imageBase); + if (consumed.finalResponse) finalResponse = consumed.finalResponse; + if (consumed.error) errorPayload = consumed.error; + } + + if (finalResponse) { + return { ok: true, status: 200, response: finalResponse }; + } + if (errorPayload) { + return { ok: false, status: 200, errorPayload }; + } + return { + ok: false, + status: 502, + errorPayload: { message: 'upstream stream ended without response.completed', type: 'upstream_error' }, + }; +} + +// Parses one SSE event from an aggregated upstream stream. Tracks +// image_generation_call items as they complete (so a final +// response.completed with output:[] can be backfilled), uploads their base64 +// payload to R2, and surfaces any terminal error event. +async function consumeAggregatedEvent(eventText, env, state, imageBase) { + const lines = eventText.split('\n'); + let dataIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('data: ')) { dataIdx = i; break; } + } + if (dataIdx < 0) return {}; + const dataStr = lines[dataIdx].slice(6); + let obj; + try { obj = JSON.parse(dataStr); } catch { return {}; } + + // Skip preview frames; only the terminal item carries the canonical result. + if (obj.type === 'response.image_generation_call.partial_image') return {}; + + if ( + obj.type === 'response.output_item.done' && + obj.item?.type === 'image_generation_call' + ) { + if ( + typeof obj.item.result === 'string' && + obj.item.result.length > 100 && + imageBase + ) { + const ext = inferExt(obj.item.output_format, obj.item.result); + const key = await uploadToR2(obj.item.result, ext, env); + obj.item.result = `${imageBase}/${key}`; + if (obj.item.status === 'generating') obj.item.status = 'completed'; + } + state.collectedItems.push(JSON.parse(JSON.stringify(obj.item))); + return {}; + } + + if (obj.type === 'response.completed' && obj.response) { + const resp = obj.response; + if (Array.isArray(resp.output)) { + for (const item of resp.output) { + if ( + item?.type === 'image_generation_call' && + typeof item.result === 'string' && + item.result.length > 100 && + imageBase + ) { + const ext = inferExt(item.output_format, item.result); + const key = await uploadToR2(item.result, ext, env); + item.result = `${imageBase}/${key}`; + if (item.status === 'generating') item.status = 'completed'; + } + } + if (resp.output.length === 0 && state.collectedItems.length > 0) { + resp.output = state.collectedItems.slice(); + } + } + return { finalResponse: resp }; + } + + if (obj.type === 'response.failed' || obj.type === 'response.incomplete') { + return { + error: + obj.response?.error || + { message: `upstream ${obj.type}`, type: obj.type }, + }; + } + if (obj.type === 'error') { + return { error: obj.error || { message: 'upstream error', type: 'error' } }; + } + return {}; +} + // Wraps fetch with fast-fail retry. Only retries on 502/503/504 that come back // quickly (< fastFailMs) — those are upstream gateway-layer rejects (rate // limit, transient overload) where the upstream model never started running, @@ -566,27 +916,39 @@ async function handleImagesGenerations(request, env) { if (incoming) upstreamHeaders.set('authorization', incoming); } - const upstreamResp = await fetchWithFastFailRetry(getUpstream(env) + '/v1/responses', { - method: 'POST', - headers: upstreamHeaders, - body: JSON.stringify(responsesBody), - }); + const aggregated = await postResponsesAggregated( + getUpstream(env) + '/v1/responses', + upstreamHeaders, + responsesBody, + env + ); - if (!upstreamResp.ok) { - const errBody = await upstreamResp.text(); + if (!aggregated.ok) { + if (aggregated.errorPayload) { + console.error( + `images/generations upstream error: ${JSON.stringify(aggregated.errorPayload).slice(0, 300)}` + ); + return new Response(JSON.stringify({ error: aggregated.errorPayload }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + }, + }); + } console.error( - `images/generations upstream ${upstreamResp.status}: ${errBody.slice(0, 200)}` + `images/generations upstream ${aggregated.status}: ${(aggregated.rawBody || '').slice(0, 200)}` ); - return new Response(errBody, { - status: upstreamResp.status, + return new Response(aggregated.rawBody || '', { + status: aggregated.status, headers: { - 'content-type': upstreamResp.headers.get('content-type') || 'text/plain', + 'content-type': aggregated.contentType || 'text/plain', 'access-control-allow-origin': '*', }, }); } - const responsesData = await upstreamResp.json(); + const responsesData = aggregated.response; const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); const wantB64 = reqBody.response_format === 'b64_json'; const originalPrompt = String(reqBody.prompt ?? reqBody.input ?? ''); @@ -598,22 +960,25 @@ async function handleImagesGenerations(request, env) { if ( item?.type === 'image_generation_call' && typeof item.result === 'string' && - item.result.length > 100 + item.result.length > 0 ) { if (!firstFormat) firstFormat = item.output_format; if (!firstSize) firstSize = item.size; const entry = {}; - if (wantB64) { + // After aggregation, item.result is already a CDN URL (R2-uploaded by + // postResponsesAggregated). For wantB64 mode we'd need the raw base64, + // which has been replaced — fall back to returning the URL instead. + const looksLikeUrl = /^https?:\/\//i.test(item.result); + if (wantB64 && !looksLikeUrl) { entry.b64_json = item.result; + } else if (looksLikeUrl) { + entry.url = item.result; } else { const ext = inferExt(item.output_format, item.result); - const key = await uploadToR2(item.result, ext, env); entry.url = imageBase - ? `${imageBase}/${key}` + ? `${imageBase}/${ext}` : `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${item.result}`; } - // revised_prompt fallback chain: image_generation_call.revised_prompt - // (the canonical Responses-API location) → message text → original prompt const revised = (typeof item.revised_prompt === 'string' && item.revised_prompt) || extractMessageText(responsesData) || @@ -650,8 +1015,7 @@ async function handleImagesGenerations(request, env) { // Translates classic OpenAI Images-Edits requests (multipart/form-data with // an image file and a text prompt) into the Responses-API multimodal shape // upstream, then re-shapes the response back into the classic Images-API -// envelope. xixiapi rejects inline-base64 webp on /v1/responses, so webp -// uploads are short-circuited with a clear 400. +// envelope. async function handleImagesEdits(request, env) { let formData; @@ -661,35 +1025,32 @@ async function handleImagesEdits(request, env) { return jsonError(400, 'Invalid multipart/form-data body'); } - const imageField = formData.get('image'); + const imageFields = collectImageFields(formData); const prompt = formData.get('prompt'); - if (!imageField || typeof imageField === 'string') { - return jsonError(400, '"image" field is required and must be a file'); - } - if (!prompt || typeof prompt !== 'string') { - return jsonError(400, '"prompt" field is required'); + if (imageFields.length === 0) { + return jsonError(400, '"image" field is required (file, http(s) URL, or data: URI)'); } - - const imageType = (imageField.type || 'image/png').toLowerCase(); - if (imageType.includes('webp')) { + if (imageFields.length > MAX_IMAGES_PER_REQUEST) { return jsonError( 400, - 'webp inline base64 is not supported by the upstream Responses API; please convert the source image to png or jpeg before uploading' + `too many images: ${imageFields.length} (max ${MAX_IMAGES_PER_REQUEST})` ); } - if ( - !imageType.includes('png') && - !imageType.includes('jpeg') && - !imageType.includes('jpg') - ) { - return jsonError(400, `unsupported image type "${imageType}", use png or jpeg`); + if (!prompt || typeof prompt !== 'string') { + return jsonError(400, '"prompt" field is required'); } - const arrayBuffer = await imageField.arrayBuffer(); - const bytes = new Uint8Array(arrayBuffer); - const b64 = bytesToBase64(bytes); - const mime = imageType.includes('jpeg') || imageType.includes('jpg') ? 'image/jpeg' : 'image/png'; - const dataUri = `data:${mime};base64,${b64}`; + // Normalize all images in parallel; surface the first failing one. + let imageDataUris; + try { + imageDataUris = await Promise.all(imageFields.map((v) => normalizeImageInput(v))); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const sep = msg.lastIndexOf('|'); + const status = sep >= 0 ? parseInt(msg.slice(sep + 1), 10) || 400 : 400; + const text = sep >= 0 ? msg.slice(0, sep) : msg; + return jsonError(status, text); + } const model = String(formData.get('model') || 'gpt-image-2'); const size = formData.get('size'); @@ -717,17 +1078,13 @@ async function handleImagesEdits(request, env) { // Default moderation to "low" for the lowest false-positive rate. tool.moderation = moderation ? String(moderation) : 'low'; + const userContent = [{ type: 'input_text', text: String(prompt) }]; + for (const { dataUri } of imageDataUris) { + userContent.push({ type: 'input_image', image_url: dataUri }); + } const responsesBody = { model, - input: [ - { - role: 'user', - content: [ - { type: 'input_text', text: String(prompt) }, - { type: 'input_image', image_url: dataUri }, - ], - }, - ], + input: [{ role: 'user', content: userContent }], tools: [tool], }; @@ -740,27 +1097,39 @@ async function handleImagesEdits(request, env) { if (incoming) upstreamHeaders.set('authorization', incoming); } - const upstreamResp = await fetchWithFastFailRetry(getUpstream(env) + '/v1/responses', { - method: 'POST', - headers: upstreamHeaders, - body: JSON.stringify(responsesBody), - }); + const aggregated = await postResponsesAggregated( + getUpstream(env) + '/v1/responses', + upstreamHeaders, + responsesBody, + env + ); - if (!upstreamResp.ok) { - const errBody = await upstreamResp.text(); + if (!aggregated.ok) { + if (aggregated.errorPayload) { + console.error( + `images/edits upstream error: ${JSON.stringify(aggregated.errorPayload).slice(0, 300)}` + ); + return new Response(JSON.stringify({ error: aggregated.errorPayload }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + }, + }); + } console.error( - `images/edits upstream ${upstreamResp.status}: ${errBody.slice(0, 200)}` + `images/edits upstream ${aggregated.status}: ${(aggregated.rawBody || '').slice(0, 200)}` ); - return new Response(errBody, { - status: upstreamResp.status, + return new Response(aggregated.rawBody || '', { + status: aggregated.status, headers: { - 'content-type': upstreamResp.headers.get('content-type') || 'text/plain', + 'content-type': aggregated.contentType || 'text/plain', 'access-control-allow-origin': '*', }, }); } - const responsesData = await upstreamResp.json(); + const responsesData = aggregated.response; const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); const wantB64 = responseFormat === 'b64_json'; const originalPrompt = String(prompt); @@ -772,18 +1141,20 @@ async function handleImagesEdits(request, env) { if ( item?.type === 'image_generation_call' && typeof item.result === 'string' && - item.result.length > 100 + item.result.length > 0 ) { if (!firstFormat) firstFormat = item.output_format; if (!firstSize) firstSize = item.size; const entry = {}; - if (wantB64) { + const looksLikeUrl = /^https?:\/\//i.test(item.result); + if (wantB64 && !looksLikeUrl) { entry.b64_json = item.result; + } else if (looksLikeUrl) { + entry.url = item.result; } else { const ext = inferExt(item.output_format, item.result); - const key = await uploadToR2(item.result, ext, env); entry.url = imageBase - ? `${imageBase}/${key}` + ? `${imageBase}/${ext}` : `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${item.result}`; } const revised = From 7cb678c7e981810a36727933a62e828d63fea55f Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Wed, 6 May 2026 16:19:18 +0800 Subject: [PATCH 05/45] fix(cf-worker): yield in base64 helpers to dodge 2s isolate-CPU limit --- scripts/cf-worker/worker.js | 43 ++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/scripts/cf-worker/worker.js b/scripts/cf-worker/worker.js index 4694af183175..77855ff97ba5 100644 --- a/scripts/cf-worker/worker.js +++ b/scripts/cf-worker/worker.js @@ -305,7 +305,7 @@ async function rewriteJsonResponse(upstreamResp, url, env) { } async function uploadToR2(b64, ext, env) { - const binary = base64ToBytes(b64); + const binary = await base64ToBytes(b64); const hash = await sha256Hex(binary); const key = `images/${hash}.${ext}`; const existing = await env.IMAGES.head(key); @@ -331,11 +331,26 @@ function inferExt(claimed, b64) { return 'png'; } -function base64ToBytes(b64) { +// Yields control back to the runtime. Workers' isolate has a hidden ~2s +// synchronous-CPU ceiling; any unbroken JS loop past it is killed with +// `exceededCpu`. Yielding between chunks resets that window so a multi-MB +// base64 decode can run within the 30s per-request CPU budget. +const yieldNow = () => + typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function' + ? scheduler.yield() + : new Promise((r) => setTimeout(r, 0)); + +const BASE64_CHUNK = 1 << 18; // 256 KiB per yield + +async function base64ToBytes(b64) { const bin = atob(b64); const len = bin.length; const arr = new Uint8Array(len); - for (let i = 0; i < len; i++) arr[i] = bin.charCodeAt(i); + for (let off = 0; off < len; off += BASE64_CHUNK) { + const end = Math.min(off + BASE64_CHUNK, len); + for (let i = off; i < end; i++) arr[i] = bin.charCodeAt(i); + if (end < len) await yieldNow(); + } return arr; } @@ -397,13 +412,15 @@ async function normalizeImageInput(value) { if (buf.length > MAX_IMAGE_BYTES) { throw new Error(`image url too large: ${buf.length} bytes (max ${MAX_IMAGE_BYTES})|400`); } - const mime = - pickMimeFromType(declaredType) || - pickMimeFromType('image/' + (inferExt('', bytesToBase64(buf.subarray(0, 12))) || '')); + let mime = pickMimeFromType(declaredType); + if (!mime) { + const headB64 = await bytesToBase64(buf.subarray(0, 12)); + mime = pickMimeFromType('image/' + (inferExt('', headB64) || '')); + } if (!mime) { throw new Error(`image url content-type unsupported: "${declaredType}"|400`); } - return { dataUri: `data:${mime};base64,${bytesToBase64(buf)}`, mime }; + return { dataUri: `data:${mime};base64,${await bytesToBase64(buf)}`, mime }; } // File / Blob from multipart. @@ -417,7 +434,7 @@ async function normalizeImageInput(value) { if (buf.length > MAX_IMAGE_BYTES) { throw new Error(`image too large: ${buf.length} bytes (max ${MAX_IMAGE_BYTES})|400`); } - return { dataUri: `data:${mime};base64,${bytesToBase64(buf)}`, mime }; + return { dataUri: `data:${mime};base64,${await bytesToBase64(buf)}`, mime }; } throw new Error('image input must be a file, http(s) URL, or data: URI|400'); @@ -1188,11 +1205,17 @@ async function handleImagesEdits(request, env) { }); } -function bytesToBase64(bytes) { +async function bytesToBase64(bytes) { let bin = ''; - const chunk = 0x8000; + const chunk = 0x8000; // 32 KiB per fromCharCode.apply call + const yieldEvery = 64; // yield every ~2 MiB of input + let sinceYield = 0; for (let i = 0; i < bytes.length; i += chunk) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + if (++sinceYield >= yieldEvery && i + chunk < bytes.length) { + sinceYield = 0; + await yieldNow(); + } } return btoa(bin); } From 41203cf596541ccd7e3be427e92d8d1ac1342e0f Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Wed, 6 May 2026 17:19:21 +0800 Subject: [PATCH 06/45] fix(cf-worker): SSE buffer yield + skip duplicate R2 upload in response.completed --- scripts/cf-worker/worker.js | 39 +++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/scripts/cf-worker/worker.js b/scripts/cf-worker/worker.js index 77855ff97ba5..4d4cc5ada092 100644 --- a/scripts/cf-worker/worker.js +++ b/scripts/cf-worker/worker.js @@ -541,27 +541,33 @@ async function postResponsesAggregated(upstreamUrl, headers, parsedBody, env) { const decoder = new TextDecoder(); const reader = upstreamResp.body.getReader(); - const state = { collectedItems: [] }; + const state = { collectedItems: [], processedKeys: new Set() }; let finalResponse = null; let errorPayload = null; - let buffer = ''; + // Use an array of chunks to avoid O(n²) cons-string flatten that occurs when + // appending hundreds of 16KB network chunks to a growing 30MB+ buffer. Only + // join when we need to scan for an event boundary. + let pending = ''; while (true) { const { done, value } = await reader.read(); if (done) break; - buffer += decoder.decode(value, { stream: true }); + pending += decoder.decode(value, { stream: true }); + // Yield to break the sync window before scanning a potentially huge buffer. + await yieldNow(); let idx; - while ((idx = buffer.indexOf('\n\n')) >= 0) { - const eventText = buffer.slice(0, idx); - buffer = buffer.slice(idx + 2); + while ((idx = pending.indexOf('\n\n')) >= 0) { + const eventText = pending.slice(0, idx); + pending = pending.slice(idx + 2); + await yieldNow(); const consumed = await consumeAggregatedEvent(eventText, env, state, imageBase); if (consumed.finalResponse) finalResponse = consumed.finalResponse; if (consumed.error) errorPayload = consumed.error; } } - buffer += decoder.decode(); - if (buffer.trim().length > 0) { - const consumed = await consumeAggregatedEvent(buffer, env, state, imageBase); + pending += decoder.decode(); + if (pending.trim().length > 0) { + const consumed = await consumeAggregatedEvent(pending, env, state, imageBase); if (consumed.finalResponse) finalResponse = consumed.finalResponse; if (consumed.error) errorPayload = consumed.error; } @@ -610,14 +616,20 @@ async function consumeAggregatedEvent(eventText, env, state, imageBase) { const key = await uploadToR2(obj.item.result, ext, env); obj.item.result = `${imageBase}/${key}`; if (obj.item.status === 'generating') obj.item.status = 'completed'; + if (state && state.processedKeys instanceof Set) state.processedKeys.add(key); } - state.collectedItems.push(JSON.parse(JSON.stringify(obj.item))); + // Shallow copy is enough now that result has been replaced with a short URL. + state.collectedItems.push({ ...obj.item }); return {}; } if (obj.type === 'response.completed' && obj.response) { const resp = obj.response; if (Array.isArray(resp.output)) { + // For each completed item: if we already uploaded this exact bytes via + // output_item.done (matched by sha256-derived key), reuse the URL — don't + // re-decode the giant base64 a second time. This is what was burning the + // 2s isolate CPU window. for (const item of resp.output) { if ( item?.type === 'image_generation_call' && @@ -626,6 +638,13 @@ async function consumeAggregatedEvent(eventText, env, state, imageBase) { imageBase ) { const ext = inferExt(item.output_format, item.result); + // Try to short-circuit by matching to a previously-uploaded item. + const prior = state.collectedItems.find((c) => c.id === item.id); + if (prior && typeof prior.result === 'string' && prior.result.startsWith(imageBase)) { + item.result = prior.result; + if (item.status === 'generating') item.status = 'completed'; + continue; + } const key = await uploadToR2(item.result, ext, env); item.result = `${imageBase}/${key}`; if (item.status === 'generating') item.status = 'completed'; From 71ea9d85a0ed21221bcb78f24f24b4effbab2cc0 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Wed, 6 May 2026 17:36:02 +0800 Subject: [PATCH 07/45] refactor(cf-worker): remove non-stream SSE aggregator --- scripts/cf-worker/worker.js | 312 ++++-------------------------------- 1 file changed, 32 insertions(+), 280 deletions(-) diff --git a/scripts/cf-worker/worker.js b/scripts/cf-worker/worker.js index 4d4cc5ada092..ffb4df4f0e81 100644 --- a/scripts/cf-worker/worker.js +++ b/scripts/cf-worker/worker.js @@ -209,40 +209,6 @@ async function proxyAndMaybeRewrite(request, url, env, ctx) { } } - const hasImageTool = - parsed && Array.isArray(parsed.tools) && - parsed.tools.some((t) => t && t.type === 'image_generation'); - const clientWantsStream = parsed?.stream === true; - if (hasImageTool && !clientWantsStream) { - const result = await postResponsesAggregated(upstreamUrl, upstreamHeaders, parsed, env); - if (result.ok) { - return new Response(JSON.stringify(result.response), { - status: 200, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - 'x-relay-mode': 'aggregated', - }, - }); - } - if (result.errorPayload) { - return new Response(JSON.stringify({ error: result.errorPayload }), { - status: 200, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - 'x-relay-mode': 'aggregated-error', - }, - }); - } - return new Response(result.rawBody, { - status: result.status, - headers: { - 'content-type': result.contentType, - 'access-control-allow-origin': '*', - }, - }); - } } const upstreamResp = await fetchWithFastFailRetry(upstreamUrl, { @@ -487,189 +453,6 @@ async function inlineInputImageUrls(body) { return { body, inlined }; } -// Forces stream:true on a /v1/responses POST body, consumes the upstream SSE -// in this worker, and returns a single aggregated JSON response object -// (mirroring what upstream would have sent for a non-streaming call). -// -// Why: image-generation calls routinely take 60-180s. CF subrequests have a -// 100s read timeout, so non-streaming POSTs to slow upstreams reliably 524. -// SSE streams keep the connection alive via incremental events and dodge the -// timeout, then we reassemble. -// -// Side effects: same R2 uploads as rewriteSSE / rewriteJsonResponse — base64 -// image_generation_call.result is rewritten to a CDN URL inline. -async function postResponsesAggregated(upstreamUrl, headers, parsedBody, env) { - const merged = { ...parsedBody, stream: true }; - const upstreamResp = await fetchWithFastFailRetry(upstreamUrl, { - method: 'POST', - headers, - body: JSON.stringify(merged), - }); - - if (!upstreamResp.ok) { - const errBody = await upstreamResp.text(); - return { - ok: false, - status: upstreamResp.status, - contentType: upstreamResp.headers.get('content-type') || 'text/plain', - rawBody: errBody, - }; - } - - const ct = (upstreamResp.headers.get('content-type') || '').toLowerCase(); - const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); - - // Upstream ignored stream:true and gave back JSON anyway — handle as before. - if (!ct.includes('text/event-stream')) { - const data = await upstreamResp.json(); - if (imageBase && Array.isArray(data.output)) { - for (const item of data.output) { - if ( - item?.type === 'image_generation_call' && - typeof item.result === 'string' && - item.result.length > 100 - ) { - const ext = inferExt(item.output_format, item.result); - const key = await uploadToR2(item.result, ext, env); - item.result = `${imageBase}/${key}`; - if (item.status === 'generating') item.status = 'completed'; - } - } - } - return { ok: true, status: 200, response: data }; - } - - const decoder = new TextDecoder(); - const reader = upstreamResp.body.getReader(); - const state = { collectedItems: [], processedKeys: new Set() }; - let finalResponse = null; - let errorPayload = null; - // Use an array of chunks to avoid O(n²) cons-string flatten that occurs when - // appending hundreds of 16KB network chunks to a growing 30MB+ buffer. Only - // join when we need to scan for an event boundary. - let pending = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - pending += decoder.decode(value, { stream: true }); - // Yield to break the sync window before scanning a potentially huge buffer. - await yieldNow(); - let idx; - while ((idx = pending.indexOf('\n\n')) >= 0) { - const eventText = pending.slice(0, idx); - pending = pending.slice(idx + 2); - await yieldNow(); - const consumed = await consumeAggregatedEvent(eventText, env, state, imageBase); - if (consumed.finalResponse) finalResponse = consumed.finalResponse; - if (consumed.error) errorPayload = consumed.error; - } - } - pending += decoder.decode(); - if (pending.trim().length > 0) { - const consumed = await consumeAggregatedEvent(pending, env, state, imageBase); - if (consumed.finalResponse) finalResponse = consumed.finalResponse; - if (consumed.error) errorPayload = consumed.error; - } - - if (finalResponse) { - return { ok: true, status: 200, response: finalResponse }; - } - if (errorPayload) { - return { ok: false, status: 200, errorPayload }; - } - return { - ok: false, - status: 502, - errorPayload: { message: 'upstream stream ended without response.completed', type: 'upstream_error' }, - }; -} - -// Parses one SSE event from an aggregated upstream stream. Tracks -// image_generation_call items as they complete (so a final -// response.completed with output:[] can be backfilled), uploads their base64 -// payload to R2, and surfaces any terminal error event. -async function consumeAggregatedEvent(eventText, env, state, imageBase) { - const lines = eventText.split('\n'); - let dataIdx = -1; - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('data: ')) { dataIdx = i; break; } - } - if (dataIdx < 0) return {}; - const dataStr = lines[dataIdx].slice(6); - let obj; - try { obj = JSON.parse(dataStr); } catch { return {}; } - - // Skip preview frames; only the terminal item carries the canonical result. - if (obj.type === 'response.image_generation_call.partial_image') return {}; - - if ( - obj.type === 'response.output_item.done' && - obj.item?.type === 'image_generation_call' - ) { - if ( - typeof obj.item.result === 'string' && - obj.item.result.length > 100 && - imageBase - ) { - const ext = inferExt(obj.item.output_format, obj.item.result); - const key = await uploadToR2(obj.item.result, ext, env); - obj.item.result = `${imageBase}/${key}`; - if (obj.item.status === 'generating') obj.item.status = 'completed'; - if (state && state.processedKeys instanceof Set) state.processedKeys.add(key); - } - // Shallow copy is enough now that result has been replaced with a short URL. - state.collectedItems.push({ ...obj.item }); - return {}; - } - - if (obj.type === 'response.completed' && obj.response) { - const resp = obj.response; - if (Array.isArray(resp.output)) { - // For each completed item: if we already uploaded this exact bytes via - // output_item.done (matched by sha256-derived key), reuse the URL — don't - // re-decode the giant base64 a second time. This is what was burning the - // 2s isolate CPU window. - for (const item of resp.output) { - if ( - item?.type === 'image_generation_call' && - typeof item.result === 'string' && - item.result.length > 100 && - imageBase - ) { - const ext = inferExt(item.output_format, item.result); - // Try to short-circuit by matching to a previously-uploaded item. - const prior = state.collectedItems.find((c) => c.id === item.id); - if (prior && typeof prior.result === 'string' && prior.result.startsWith(imageBase)) { - item.result = prior.result; - if (item.status === 'generating') item.status = 'completed'; - continue; - } - const key = await uploadToR2(item.result, ext, env); - item.result = `${imageBase}/${key}`; - if (item.status === 'generating') item.status = 'completed'; - } - } - if (resp.output.length === 0 && state.collectedItems.length > 0) { - resp.output = state.collectedItems.slice(); - } - } - return { finalResponse: resp }; - } - - if (obj.type === 'response.failed' || obj.type === 'response.incomplete') { - return { - error: - obj.response?.error || - { message: `upstream ${obj.type}`, type: obj.type }, - }; - } - if (obj.type === 'error') { - return { error: obj.error || { message: 'upstream error', type: 'error' } }; - } - return {}; -} - // Wraps fetch with fast-fail retry. Only retries on 502/503/504 that come back // quickly (< fastFailMs) — those are upstream gateway-layer rejects (rate // limit, transient overload) where the upstream model never started running, @@ -952,39 +735,27 @@ async function handleImagesGenerations(request, env) { if (incoming) upstreamHeaders.set('authorization', incoming); } - const aggregated = await postResponsesAggregated( - getUpstream(env) + '/v1/responses', - upstreamHeaders, - responsesBody, - env - ); + const upstreamResp = await fetchWithFastFailRetry(getUpstream(env) + '/v1/responses', { + method: 'POST', + headers: upstreamHeaders, + body: JSON.stringify(responsesBody), + }); - if (!aggregated.ok) { - if (aggregated.errorPayload) { - console.error( - `images/generations upstream error: ${JSON.stringify(aggregated.errorPayload).slice(0, 300)}` - ); - return new Response(JSON.stringify({ error: aggregated.errorPayload }), { - status: 200, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - }, - }); - } + if (!upstreamResp.ok) { + const errBody = await upstreamResp.text(); console.error( - `images/generations upstream ${aggregated.status}: ${(aggregated.rawBody || '').slice(0, 200)}` + `images/generations upstream ${upstreamResp.status}: ${errBody.slice(0, 200)}` ); - return new Response(aggregated.rawBody || '', { - status: aggregated.status, + return new Response(errBody, { + status: upstreamResp.status, headers: { - 'content-type': aggregated.contentType || 'text/plain', + 'content-type': upstreamResp.headers.get('content-type') || 'text/plain', 'access-control-allow-origin': '*', }, }); } - const responsesData = aggregated.response; + const responsesData = await upstreamResp.json(); const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); const wantB64 = reqBody.response_format === 'b64_json'; const originalPrompt = String(reqBody.prompt ?? reqBody.input ?? ''); @@ -996,23 +767,18 @@ async function handleImagesGenerations(request, env) { if ( item?.type === 'image_generation_call' && typeof item.result === 'string' && - item.result.length > 0 + item.result.length > 100 ) { if (!firstFormat) firstFormat = item.output_format; if (!firstSize) firstSize = item.size; const entry = {}; - // After aggregation, item.result is already a CDN URL (R2-uploaded by - // postResponsesAggregated). For wantB64 mode we'd need the raw base64, - // which has been replaced — fall back to returning the URL instead. - const looksLikeUrl = /^https?:\/\//i.test(item.result); - if (wantB64 && !looksLikeUrl) { + if (wantB64) { entry.b64_json = item.result; - } else if (looksLikeUrl) { - entry.url = item.result; } else { const ext = inferExt(item.output_format, item.result); + const key = await uploadToR2(item.result, ext, env); entry.url = imageBase - ? `${imageBase}/${ext}` + ? `${imageBase}/${key}` : `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${item.result}`; } const revised = @@ -1133,39 +899,27 @@ async function handleImagesEdits(request, env) { if (incoming) upstreamHeaders.set('authorization', incoming); } - const aggregated = await postResponsesAggregated( - getUpstream(env) + '/v1/responses', - upstreamHeaders, - responsesBody, - env - ); + const upstreamResp = await fetchWithFastFailRetry(getUpstream(env) + '/v1/responses', { + method: 'POST', + headers: upstreamHeaders, + body: JSON.stringify(responsesBody), + }); - if (!aggregated.ok) { - if (aggregated.errorPayload) { - console.error( - `images/edits upstream error: ${JSON.stringify(aggregated.errorPayload).slice(0, 300)}` - ); - return new Response(JSON.stringify({ error: aggregated.errorPayload }), { - status: 200, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - }, - }); - } + if (!upstreamResp.ok) { + const errBody = await upstreamResp.text(); console.error( - `images/edits upstream ${aggregated.status}: ${(aggregated.rawBody || '').slice(0, 200)}` + `images/edits upstream ${upstreamResp.status}: ${errBody.slice(0, 200)}` ); - return new Response(aggregated.rawBody || '', { - status: aggregated.status, + return new Response(errBody, { + status: upstreamResp.status, headers: { - 'content-type': aggregated.contentType || 'text/plain', + 'content-type': upstreamResp.headers.get('content-type') || 'text/plain', 'access-control-allow-origin': '*', }, }); } - const responsesData = aggregated.response; + const responsesData = await upstreamResp.json(); const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); const wantB64 = responseFormat === 'b64_json'; const originalPrompt = String(prompt); @@ -1177,20 +931,18 @@ async function handleImagesEdits(request, env) { if ( item?.type === 'image_generation_call' && typeof item.result === 'string' && - item.result.length > 0 + item.result.length > 100 ) { if (!firstFormat) firstFormat = item.output_format; if (!firstSize) firstSize = item.size; const entry = {}; - const looksLikeUrl = /^https?:\/\//i.test(item.result); - if (wantB64 && !looksLikeUrl) { + if (wantB64) { entry.b64_json = item.result; - } else if (looksLikeUrl) { - entry.url = item.result; } else { const ext = inferExt(item.output_format, item.result); + const key = await uploadToR2(item.result, ext, env); entry.url = imageBase - ? `${imageBase}/${ext}` + ? `${imageBase}/${key}` : `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${item.result}`; } const revised = From 2d7b64e9d6116c2f2a5f9c7272929153b1d3a45d Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Wed, 6 May 2026 18:07:31 +0800 Subject: [PATCH 08/45] feat(image-edits): accept URL / data:URI text fields alongside file uploads The OpenAI image-edits adaptor previously only checked mf.File['image'], returning 'image is required' when clients sent image= or image= as multipart text fields. The downstream cf-worker already handles these formats via collectImageFields + normalizeImageInput, but new-api was rejecting them at the gateway. This change: - Forwards all mf.Value text fields (including image / image[] / image[N]) to the outgoing multipart body so worker can fetch/inline them. - Only rejects when no file uploads AND no http(s):// or data: text values are present. - Improves the error message to enumerate the three supported input modes. --- relay/channel/openai/adaptor.go | 134 +++++++++++++++++--------------- 1 file changed, 72 insertions(+), 62 deletions(-) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 6941ca54a732..bc337c0bb3ef 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -442,90 +442,103 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf } mf = c.Request.MultipartForm } + if mf == nil { + return nil, errors.New("no multipart form data found") + } - // 写入所有非文件字段 - if mf != nil { - for key, values := range mf.Value { - if key == "model" { - continue - } - for _, value := range values { - writer.WriteField(key, value) - } + // 写入所有非文件字段。image=URL 和 image=data:URI 这类文本形式的图片输入 + // 也通过这个循环原样转发给下游 worker,由 worker 的 collectImageFields + // + normalizeImageInput 负责 fetch / inline。 + for key, values := range mf.Value { + if key == "model" { + continue + } + for _, value := range values { + writer.WriteField(key, value) } } - if mf != nil && mf.File != nil { - // Check if "image" field exists in any form, including array notation - var imageFiles []*multipart.FileHeader - var exists bool - - // First check for standard "image" field - if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 { - // If not found, check for "image[]" field - if imageFiles, exists = mf.File["image[]"]; !exists || len(imageFiles) == 0 { - // If still not found, iterate through all fields to find any that start with "image[" - foundArrayImages := false - for fieldName, files := range mf.File { - if strings.HasPrefix(fieldName, "image[") && len(files) > 0 { - foundArrayImages = true - imageFiles = append(imageFiles, files...) - } - } - - // If no image fields found at all - if !foundArrayImages && (len(imageFiles) == 0) { - return nil, errors.New("image is required") + // 收集 image 二进制文件:image / image[] / image[N] + var imageFiles []*multipart.FileHeader + if mf.File != nil { + if files, ok := mf.File["image"]; ok && len(files) > 0 { + imageFiles = files + } else if files, ok := mf.File["image[]"]; ok && len(files) > 0 { + imageFiles = files + } else { + for fieldName, files := range mf.File { + if strings.HasPrefix(fieldName, "image[") && len(files) > 0 { + imageFiles = append(imageFiles, files...) } } } + } - // Process all image files - for i, fileHeader := range imageFiles { - file, err := fileHeader.Open() - if err != nil { - return nil, fmt.Errorf("failed to open image file %d: %w", i, err) + // 检测 image 文本字段(URL / data:URI)。这些值已经被上面的 mf.Value + // 循环写入 outgoing multipart,这里只判断"是否存在合法图源"。 + hasImageTextField := false + for key, values := range mf.Value { + if key != "image" && key != "image[]" && !strings.HasPrefix(key, "image[") { + continue + } + for _, v := range values { + if v == "" { + continue } - - // If multiple images, use image[] as the field name - fieldName := "image" - if len(imageFiles) > 1 { - fieldName = "image[]" + if strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") || strings.HasPrefix(v, "data:") { + hasImageTextField = true + break } + } + if hasImageTextField { + break + } + } - // Determine MIME type based on file extension - mimeType := detectImageMimeType(fileHeader.Filename) + if len(imageFiles) == 0 && !hasImageTextField { + return nil, errors.New("image is required (file upload, http(s) URL, or data: URI)") + } - // Create a form file with the appropriate content type - h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename)) - h.Set("Content-Type", mimeType) + // 处理二进制 file 上传 + for i, fileHeader := range imageFiles { + file, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("failed to open image file %d: %w", i, err) + } - part, err := writer.CreatePart(h) - if err != nil { - return nil, fmt.Errorf("create form part failed for image %d: %w", i, err) - } + fieldName := "image" + if len(imageFiles) > 1 { + fieldName = "image[]" + } - if _, err := io.Copy(part, file); err != nil { - return nil, fmt.Errorf("copy file failed for image %d: %w", i, err) - } + mimeType := detectImageMimeType(fileHeader.Filename) + + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename)) + h.Set("Content-Type", mimeType) + + part, err := writer.CreatePart(h) + if err != nil { + return nil, fmt.Errorf("create form part failed for image %d: %w", i, err) + } - // 复制完立即关闭,避免在循环内使用 defer 占用资源 - _ = file.Close() + if _, err := io.Copy(part, file); err != nil { + return nil, fmt.Errorf("copy file failed for image %d: %w", i, err) } - // Handle mask file if present + _ = file.Close() + } + + // Mask file(可选) + if mf.File != nil { if maskFiles, exists := mf.File["mask"]; exists && len(maskFiles) > 0 { maskFile, err := maskFiles[0].Open() if err != nil { return nil, errors.New("failed to open mask file") } - // 复制完立即关闭,避免在循环内使用 defer 占用资源 - // Determine MIME type for mask file mimeType := detectImageMimeType(maskFiles[0].Filename) - // Create a form file with the appropriate content type h := make(textproto.MIMEHeader) h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename)) h.Set("Content-Type", mimeType) @@ -540,11 +553,8 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf } _ = maskFile.Close() } - } else { - return nil, errors.New("no multipart form data found") } - // 关闭 multipart 编写器以设置分界线 writer.Close() c.Request.Header.Set("Content-Type", writer.FormDataContentType()) return &requestBody, nil From a26828584dfc3c0bf68850aebc7e2aa5bd4883be Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Thu, 7 May 2026 11:01:00 +0800 Subject: [PATCH 09/45] fix(image-edits): early-flush gateway TTFB + worker buildImages helper - Gateway: in OpenaiHandlerWithUsage (image edits/generations only), flush headers + a single whitespace byte BEFORE io.ReadAll. The upstream model can hold the connection 60-120s; without an early byte, CF in front of api.opwan.ai returns 524 at the 100s mark. JSON parsers ignore leading whitespace so the eventual envelope still parses cleanly on the client. - Worker: extract buildImagesDataFromResponses helper shared by handleImagesEdits and handleImagesGenerations to avoid duplicating the image_generation_call iteration / R2 upload / envelope-shaping block. Thread ctx through the image handlers in case future fixes need waitUntil-style hooks. --- relay/channel/openai/relay-openai.go | 28 +++++- scripts/cf-worker/worker.js | 123 ++++++++++++--------------- 2 files changed, 82 insertions(+), 69 deletions(-) diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index a85751844c0b..a51ce2f33738 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -560,6 +560,25 @@ func preConsumeUsage(ctx *gin.Context, info *relaycommon.RelayInfo, usage *dto.R func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { defer service.CloseResponseBodyGracefully(resp) + // Image edits/generations: upstream model can take 60-120s. Flush response + // headers + a single whitespace byte BEFORE reading the body so the CF + // edge in front of this gateway sees TTFB within its 100s window. JSON + // parsers ignore leading whitespace, so the eventual envelope still parses + // cleanly on the client. + earlyFlushed := false + if c.Writer != nil && resp != nil && resp.StatusCode == http.StatusOK { + for k, v := range resp.Header { + if k == "Content-Length" || k == "Transfer-Encoding" { + continue + } + c.Writer.Header().Set(k, v[0]) + } + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write([]byte(" ")) + c.Writer.Flush() + earlyFlushed = true + } + responseBody, err := io.ReadAll(resp.Body) if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) @@ -571,8 +590,13 @@ func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *h return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - // 写入新的 response body - service.IOCopyBytesGracefully(c, resp, responseBody) + if earlyFlushed { + // Headers + leading byte already on the wire; stream the body. + _, _ = c.Writer.Write(responseBody) + c.Writer.Flush() + } else { + service.IOCopyBytesGracefully(c, resp, responseBody) + } // Once we've written to the client, we should not return errors anymore // because the upstream has already consumed resources and returned content diff --git a/scripts/cf-worker/worker.js b/scripts/cf-worker/worker.js index ffb4df4f0e81..c8caadf1c11f 100644 --- a/scripts/cf-worker/worker.js +++ b/scripts/cf-worker/worker.js @@ -43,13 +43,13 @@ export default { request.method === 'POST' && url.pathname === '/v1/images/generations' ) { - return handleImagesGenerations(request, env); + return handleImagesGenerations(request, env, ctx); } if ( request.method === 'POST' && url.pathname === '/v1/images/edits' ) { - return handleImagesEdits(request, env); + return handleImagesEdits(request, env, ctx); } // Self-healing: if a caller (e.g. new-api) sends an image-generation @@ -64,7 +64,7 @@ export default { let parsed = null; try { parsed = JSON.parse(bodyText); } catch {} if (parsed && isImageModel(parsed.model)) { - return handleChatCompletionsAsImage(parsed, request, env); + return handleChatCompletionsAsImage(parsed, request, env, ctx); } // Not an image model — reconstruct request and passthrough. const passReq = new Request(request, { body: bodyText }); @@ -496,6 +496,45 @@ async function fetchWithFastFailRetry(url, init, opts) { // `{created, data: [{url|b64_json}]}` envelope so existing OpenAI SDK clients // (and new-api's image-generation billing path) work unchanged. +// Builds the {data, ...} portion of an Images-API envelope from a /v1/responses +// "response" object. Uploads any base64 image_generation_call.result to R2 and +// rewrites it as a public URL when IMAGE_BASE is configured. +async function buildImagesDataFromResponses(responsesData, opts, env) { + const { wantB64, originalPrompt } = opts; + const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); + const data = []; + let firstFormat; + let firstSize; + + for (const item of responsesData.output || []) { + if ( + item?.type === 'image_generation_call' && + typeof item.result === 'string' && + item.result.length > 100 + ) { + if (!firstFormat) firstFormat = item.output_format; + if (!firstSize) firstSize = item.size; + const entry = {}; + if (wantB64) { + entry.b64_json = item.result; + } else { + const ext = inferExt(item.output_format, item.result); + const key = await uploadToR2(item.result, ext, env); + entry.url = imageBase + ? `${imageBase}/${key}` + : `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${item.result}`; + } + const revised = + (typeof item.revised_prompt === 'string' && item.revised_prompt) || + extractMessageText(responsesData) || + originalPrompt; + if (revised) entry.revised_prompt = revised; + data.push(entry); + } + } + return { data, firstFormat, firstSize }; +} + // Detects model names that should be routed through the image pipeline, // even when the caller arrives via /v1/chat/completions. function isImageModel(model) { @@ -533,7 +572,7 @@ function extractPromptFromMessages(messages) { // image link (most chat UIs render it natively). When the original request // asked for stream:true, the response is delivered as a minimal SSE stream // so streaming clients don't break. -async function handleChatCompletionsAsImage(reqBody, originalRequest, env) { +async function handleChatCompletionsAsImage(reqBody, originalRequest, env, ctx) { const prompt = extractPromptFromMessages(reqBody.messages); if (!prompt) { return jsonError(400, 'no user prompt found in messages'); @@ -559,7 +598,7 @@ async function handleChatCompletionsAsImage(reqBody, originalRequest, env) { body: JSON.stringify(imageReq), }); - const imageResp = await handleImagesGenerations(syntheticRequest, env); + const imageResp = await handleImagesGenerations(syntheticRequest, env, ctx); if (!imageResp.ok) { // Pass through upstream error as-is. @@ -699,7 +738,7 @@ function jsonError(status, message) { ); } -async function handleImagesGenerations(request, env) { +async function handleImagesGenerations(request, env, ctx) { let reqBody; try { reqBody = await request.json(); @@ -756,39 +795,14 @@ async function handleImagesGenerations(request, env) { } const responsesData = await upstreamResp.json(); - const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); const wantB64 = reqBody.response_format === 'b64_json'; const originalPrompt = String(reqBody.prompt ?? reqBody.input ?? ''); - const data = []; - let firstFormat; - let firstSize; - for (const item of responsesData.output || []) { - if ( - item?.type === 'image_generation_call' && - typeof item.result === 'string' && - item.result.length > 100 - ) { - if (!firstFormat) firstFormat = item.output_format; - if (!firstSize) firstSize = item.size; - const entry = {}; - if (wantB64) { - entry.b64_json = item.result; - } else { - const ext = inferExt(item.output_format, item.result); - const key = await uploadToR2(item.result, ext, env); - entry.url = imageBase - ? `${imageBase}/${key}` - : `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${item.result}`; - } - const revised = - (typeof item.revised_prompt === 'string' && item.revised_prompt) || - extractMessageText(responsesData) || - originalPrompt; - if (revised) entry.revised_prompt = revised; - data.push(entry); - } - } + const { data, firstFormat, firstSize } = await buildImagesDataFromResponses( + responsesData, + { wantB64, originalPrompt }, + env + ); const out = { created: Math.floor(Date.now() / 1000), @@ -819,7 +833,7 @@ async function handleImagesGenerations(request, env) { // upstream, then re-shapes the response back into the classic Images-API // envelope. -async function handleImagesEdits(request, env) { +async function handleImagesEdits(request, env, ctx) { let formData; try { formData = await request.formData(); @@ -920,39 +934,14 @@ async function handleImagesEdits(request, env) { } const responsesData = await upstreamResp.json(); - const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); const wantB64 = responseFormat === 'b64_json'; const originalPrompt = String(prompt); - const data = []; - let firstFormat; - let firstSize; - for (const item of responsesData.output || []) { - if ( - item?.type === 'image_generation_call' && - typeof item.result === 'string' && - item.result.length > 100 - ) { - if (!firstFormat) firstFormat = item.output_format; - if (!firstSize) firstSize = item.size; - const entry = {}; - if (wantB64) { - entry.b64_json = item.result; - } else { - const ext = inferExt(item.output_format, item.result); - const key = await uploadToR2(item.result, ext, env); - entry.url = imageBase - ? `${imageBase}/${key}` - : `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${item.result}`; - } - const revised = - (typeof item.revised_prompt === 'string' && item.revised_prompt) || - extractMessageText(responsesData) || - originalPrompt; - if (revised) entry.revised_prompt = revised; - data.push(entry); - } - } + const { data, firstFormat, firstSize } = await buildImagesDataFromResponses( + responsesData, + { wantB64, originalPrompt }, + env + ); const out = { created: Math.floor(Date.now() / 1000), From 10fa10964d6adfe9b863c96926aca3e265653bdb Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Thu, 7 May 2026 11:54:58 +0800 Subject: [PATCH 10/45] fix(cf-worker): infer image ext from magic bytes, not upstream's claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream gpt-image-2 accepts output_format=webp but silently returns PNG bytes. The previous inferExt(claimed, b64) trusted `claimed` first, producing R2 URLs ending in .webp whose body was actually PNG — breaking webp-aware clients that key off content-type or extension. - inferExt now sniffs the b64 magic bytes first (iVBOR/9j//UklG/R0lGOD) and only falls back to the claimed format if the bytes are unknown. - Envelope output_format now reflects the actual format detected, so callers see what they really got instead of what they asked for. - firstFormat is derived from the inferred ext, not item.output_format. Net effect: webp requests still return whatever upstream produces (PNG in practice), but URLs and metadata now match the real bytes. --- scripts/cf-worker/worker.js | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/scripts/cf-worker/worker.js b/scripts/cf-worker/worker.js index c8caadf1c11f..2f28f59aeb7a 100644 --- a/scripts/cf-worker/worker.js +++ b/scripts/cf-worker/worker.js @@ -284,16 +284,20 @@ async function uploadToR2(b64, ext, env) { return key; } +// Determines the file extension for an image payload. Magic-byte sniffing +// wins over `claimed` because upstream sometimes silently returns PNG bytes +// while still echoing back the requested output_format (e.g. webp). Trusting +// `claimed` blindly results in .webp/.jpg URLs whose body is actually PNG. function inferExt(claimed, b64) { - const c = (claimed || '').toLowerCase(); - if (c === 'jpeg' || c === 'jpg') return 'jpg'; - if (c === 'png') return 'png'; - if (c === 'webp') return 'webp'; - const head = b64.slice(0, 8); + const head = (b64 || '').slice(0, 8); if (head.startsWith('iVBOR')) return 'png'; if (head.startsWith('/9j/')) return 'jpg'; if (head.startsWith('UklG')) return 'webp'; if (head.startsWith('R0lGOD')) return 'gif'; + const c = (claimed || '').toLowerCase(); + if (c === 'jpeg' || c === 'jpg') return 'jpg'; + if (c === 'png') return 'png'; + if (c === 'webp') return 'webp'; return 'png'; } @@ -512,13 +516,16 @@ async function buildImagesDataFromResponses(responsesData, opts, env) { typeof item.result === 'string' && item.result.length > 100 ) { - if (!firstFormat) firstFormat = item.output_format; + const ext = inferExt(item.output_format, item.result); + // Use the format inferred from actual bytes — upstream sometimes echoes + // the requested output_format while silently returning PNG, so trusting + // item.output_format would mismatch the file the client downloads. + if (!firstFormat) firstFormat = ext === 'jpg' ? 'jpeg' : ext; if (!firstSize) firstSize = item.size; const entry = {}; if (wantB64) { entry.b64_json = item.result; } else { - const ext = inferExt(item.output_format, item.result); const key = await uploadToR2(item.result, ext, env); entry.url = imageBase ? `${imageBase}/${key}` @@ -808,7 +815,7 @@ async function handleImagesGenerations(request, env, ctx) { created: Math.floor(Date.now() / 1000), data, background: responsesData.background || reqBody.background || 'auto', - output_format: reqBody.output_format || firstFormat || 'png', + output_format: firstFormat || reqBody.output_format || 'png', quality: reqBody.quality || 'auto', size: reqBody.size || firstSize || '1024x1024', usage: responsesData.usage, @@ -947,7 +954,7 @@ async function handleImagesEdits(request, env, ctx) { created: Math.floor(Date.now() / 1000), data, background: responsesData.background || (background ? String(background) : 'auto'), - output_format: outputFormat ? String(outputFormat) : firstFormat || 'png', + output_format: firstFormat || (outputFormat ? String(outputFormat) : 'png'), quality: quality ? String(quality) : 'auto', size: size ? String(size) : firstSize || '1024x1024', usage: responsesData.usage, From 6a209286f6cd6387d45fb61f0c5e592a21ed2d9a Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Thu, 7 May 2026 15:36:00 +0800 Subject: [PATCH 11/45] docs(image-edits): publish xixiapi gpt-image-2 spec + Apifox tweaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add docs/xixiapi-gpt-image-2.md — the upstream spec we measured against (size constraints, format/quality matrix, edit mode behavior, perf data). - Update new-api.Apifox.json /v1/images/edits description: image field forms, multi-image syntax, webp output downgrade, retry guidance. - Add /tmp to .gitignore so local stress-test artifacts stay out. --- .gitignore | 1 + docs/xixiapi-gpt-image-2.md | 422 + new-api.Apifox.json | 18146 ++++++++++++++++++++++++++++++++++ 3 files changed, 18569 insertions(+) create mode 100644 docs/xixiapi-gpt-image-2.md create mode 100644 new-api.Apifox.json diff --git a/.gitignore b/.gitignore index d9845c0aca2c..d69f497e7f34 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ skills-lock.json # repo-root screenshot artifacts and openapi dumps (not nested) /*.png /*.openapi.json +/tmp \ No newline at end of file diff --git a/docs/xixiapi-gpt-image-2.md b/docs/xixiapi-gpt-image-2.md new file mode 100644 index 000000000000..2a9e6de32677 --- /dev/null +++ b/docs/xixiapi-gpt-image-2.md @@ -0,0 +1,422 @@ +# xixiapi.cc — `gpt-image-2` 图像生成接口文档 + +> 本文档基于对 `https://xixiapi.cc` 第三方网关的实测整理(2026-04-27),记录 `gpt-image-2` 模型在 OpenAI Responses API 形态下的请求/响应规格、参数支持矩阵与已知差异。 + +--- + +## 1. 接口端点 + +| 项 | 值 | +|----|----| +| **Method** | `POST` | +| **URL** | `https://xixiapi.cc/v1/responses` | +| **Content-Type** | `application/json` | +| **Auth** | `Authorization: Bearer ` | +| **协议** | HTTP/1.1 + 可选 SSE(`Accept: text/event-stream`) | + +--- + +## 2. 请求体(顶层) + +```json +{ + "model": "gpt-image-2", + "input": "A cute orange cat sitting on a windowsill", + "stream": false, + "tools": [ + { + "type": "image_generation", + "size": "1536x1024", + "quality": "high", + "output_format": "jpeg", + "output_compression": 95, + "background": "auto", + "moderation": "auto", + "partial_images": 0 + } + ] +} +``` + +### 2.1 顶层字段 + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|------|------|------|------|------| +| `model` | string | ✅ | — | 固定 `gpt-image-2` | +| `input` | string | ✅ | — | 提示词 | +| `stream` | bool | ❌ | `false` | `true` 时返回 SSE,**强烈推荐**(TTFB ~5s) | +| `tools` | array | ✅ | — | 必须包含一个 `{"type":"image_generation"}` | + +### 2.2 `image_generation` 工具字段 + +| 字段 | 类型 | 默认 | 取值 | 说明 | +|------|------|------|------|------| +| `type` | string | — | `"image_generation"` | 必填 | +| `size` | string | `"auto"` | `"WxH"` | **见 §3 约束** | +| `quality` | string | `"auto"` | `low` / `medium` / `high` / `auto` | `low` 用于草图 | +| `output_format` | string | `"png"` | `png` / `jpeg` | ⚠️ `webp` 会被网关静默忽略,实际返回 PNG | +| `output_compression` | int | `100` | `0-100` | 仅对 `jpeg` 有效;推荐 q=95 视觉无损 | +| `background` | string | `"auto"` | `auto` / `opaque` | ⚠️ **不支持 `transparent`** | +| `moderation` | string | `"auto"` | `low` / `auto` | `low` 略快 | +| `partial_images` | int | `0` | `0-3` | 仅 `stream=true` 有意义,**实际帧数由模型自决,可能少于请求值** | + +### 2.3 已知不支持的字段 + +| 字段 | 行为 | 替代方案 | +|------|------|----------| +| `n: 2`(单次多张) | 502 | 串行多次调用 | +| `background: "transparent"` | 502 | 用 PNG + alpha 后处理 | +| `output_format: "webp"` | 静默忽略,返回 PNG | 服务端转码 | +| `input_fidelity`(任意取值/位置) | 502 | 不可用,见 §10.3 | +| 编辑模式 base64 内联 webp | 502(超时后) | 服务端转 png/jpeg 再传,见 §10.2 | + +--- + +## 3. `size` 参数约束(强制,违反一律 502) + +满足以下**全部** 4 条: + +1. **每条边长 ≤ 3840 px** +2. **每条边长是 16 的倍数** +3. **长边 : 短边 ≤ 3 : 1** +4. **总像素数 ∈ [655,360, 8,294,400]** + +### 3.1 已实测可用尺寸 + +| 比例 | 尺寸 | 像素 | 用途 | +|------|------|------|------| +| 1:1 | `1024x1024` | 1.05 M | 头像/方图 | +| 1:1 | `2048x2048` | 4.19 M | 2K 方图 | +| 3:2 | `1536x1024` | 1.57 M | 摄影横构图 | +| 2:3 | `1024x1536` | 1.57 M | 摄影竖构图 | +| 4:3 | `1216x912` | 1.11 M | 老式横屏 | +| 3:4 | `912x1216` | 1.11 M | 老式竖屏 | +| 5:4 | `1280x1024` | 1.31 M | — | +| 16:9 | `2048x1152` | 2.36 M | 2K 横 | +| 9:16 | `1152x2048` | 2.36 M | 2K 竖 | +| **16:9** | **`3840x2160`** | **8.29 M** | **4K 横(像素上限)** | +| 9:16 | `2160x3840` | 8.29 M | 4K 竖 | +| 21:9 | `2688x1152` | 3.10 M | 超宽影院 | +| 9:21 | `1152x2688` | 3.10 M | 超长竖 | +| 3:1 | `2400x800` | 1.92 M | 极限横幅 | +| 1:3 | `800x2400` | 1.92 M | 极限竖幅 | + +### 3.2 故意违规的实测结果 + +| 违反规则 | 测试值 | HTTP | +|----------|--------|------| +| 长短比 > 3:1 | `2800x800`(3.5:1) | 502 | +| 边长非 16 倍数 | `1023x1024` | 502 | +| 像素 < 655,360 | `800x800` | 502 | +| 像素 > 8,294,400 | `3840x2240` | 502 | + +--- + +## 4. 响应结构(非流式 `stream=false`) + +### 4.1 顶层字段(节选高频字段,完整字段见 §4.3) + +```json +{ + "id": "resp_0822c8eb7fc79e530169ee...", + "object": "response", + "created_at": 1745700000, + "completed_at": 1745700060, + "model": "gpt-image-2", + "status": "completed", + "output": [ + { + "id": "ig_0822c8eb...", + "type": "image_generation_call", + "status": "completed", + "result": "", + "background": "opaque", + "output_format": "jpeg", + "quality": "high", + "size": "3840x2160" + }, + { + "id": "msg_0822c8eb...", + "type": "message", + "status": "completed", + "content": [ + { "type": "output_text", "text": "<模型对图像的简短描述>" } + ] + } + ], + "usage": { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0 }, + "tool_usage": { "image_generation": { "...": "..." } }, + "error": null +} +``` + +### 4.2 `output[]` 元素类型 + +| `type` | 关键字段 | 说明 | +|--------|----------|------| +| `image_generation_call` | `result`(base64)、`size`、`output_format`、`quality`、`background` | 主图,**base64 路径:`output[?(@.type=='image_generation_call')].result`** | +| `message` | `content[].text` | 模型对图像的文字简介(可忽略) | + +### 4.3 完整顶层字段清单(实测全集) + +``` +id, object, model, status, created_at, completed_at, +output, error, incomplete_details, +instructions, max_output_tokens, max_tool_calls, +metadata, moderation, parallel_tool_calls, +previous_response_id, prompt_cache_key, prompt_cache_retention, +reasoning, safety_identifier, service_tier, store, +temperature, text, tool_choice, tool_usage, tools, +top_logprobs, top_p, truncation, usage, user, +background, frequency_penalty, presence_penalty +``` + +--- + +## 5. 响应结构(流式 `stream=true`) + +请求需带 `Accept: text/event-stream`,响应为 SSE。 + +### 5.1 事件序列 + +| 顺序 | `event` 类型 | 说明 | +|------|--------------|------| +| 1 | `response.created` | TTFB ~4s | +| 2 | `response.in_progress` | | +| 3 | `response.output_item.added` | image_generation_call 占位 | +| 4 | `response.image_generation_call.in_progress` | | +| 5 | `response.image_generation_call.generating` | | +| 6 | `response.image_generation_call.partial_image` | **含 `partial_image_b64`(base64)**;0~N 帧 | +| 7 | `response.output_item.done` | 含完整 `result` base64 | +| 8 | `response.output_item.added` (msg) | 文本输出占位 | +| 9 | `response.content_part.added` / `output_text.done` / `content_part.done` | 文本部分 | +| 10 | `response.completed` | 整个 response 副本 | + +### 5.2 partial_image 事件示例 + +``` +event: response.image_generation_call.partial_image +data: { + "type": "response.image_generation_call.partial_image", + "item_id": "ig_xxx", + "output_index": 0, + "sequence_number": 5, + "partial_image_index": 0, + "partial_image_b64": "", + "size": "3840x2160", + "quality": "high", + "output_format": "jpeg", + "background": "opaque" +} +``` + +### 5.3 最终图位置 + +最终完整图同时出现在两处,任选其一即可: + +1. `response.output_item.done` 事件 → `data.item.result` +2. `response.completed` 事件 → `data.response.output[?(@.type=='image_generation_call')].result` + +--- + +## 6. 错误响应 + +| HTTP | Body | 触发条件 | +|------|------|----------| +| `200` | 正常 JSON / SSE | — | +| `502` | `error code: 502`(纯文本 15 字节,**无详细信息**) | 参数违规 / 上游超时 / `n>1` / `background: transparent` | + +⚠️ 502 不区分错误类型,**建议在中转层做客户端预校验**(`size` 4 条约束 + 不支持参数白名单),把 502 转成清晰的 400 报错。 + +--- + +## 7. 性能参考(实测) + +| 配置 | TTFB | 总耗时 | 响应体积 | 图片体积 | +|------|------|--------|----------|----------| +| 1024×1024 jpeg q75 | — | ~20s | 195 KB | 145 KB | +| 1536×1024 jpeg q90 | — | ~50s | 235 KB | 175 KB | +| 1536×1024 png(默认) | — | ~56s | 2.89 MB | 2.17 MB | +| 3840×2160 jpeg q95 high | — | ~61s | 1.43 MB | 1.0 MB | +| 3840×2160 png high | — | ~103s | 15.2 MB | 11 MB | +| **3840×2160 jpeg q95 stream** | **~4.2s** | ~61s | 3.4 MB(含多帧) | 1.0 MB | + +### 关键结论 + +- **流式把 TTFB 从 60s+ 降到 4s**,客户端体感速度提升一个数量级 +- **JPEG q=95 视觉无损,响应体积只有 PNG 的 1/12**(4K 场景) +- **PNG 在 4K + high 时需要 ≥120s 读超时**,Cloudflare 等 CDN 默认 100s 会掐断 +- 任何大图都建议优先用 JPEG + +--- + +## 8. 快速示例 + +### 8.1 最简调用(curl) + +```bash +curl -X POST https://xixiapi.cc/v1/responses \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-2", + "input": "a cute orange cat", + "tools": [{"type": "image_generation", "size": "1024x1024"}] + }' +``` + +### 8.2 推荐生产配置(4K JPEG + 流式) + +```bash +curl -N -X POST https://xixiapi.cc/v1/responses \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{ + "model": "gpt-image-2", + "input": "a cinematic snow leopard at dawn", + "stream": true, + "tools": [{ + "type": "image_generation", + "size": "3840x2160", + "quality": "high", + "output_format": "jpeg", + "output_compression": 95, + "partial_images": 2 + }] + }' +``` + +### 8.3 解码 base64 → 图片(Python) + +```python +import json, base64, requests + +resp = requests.post( + "https://xixiapi.cc/v1/responses", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={ + "model": "gpt-image-2", + "input": "a red apple", + "tools": [{"type": "image_generation", "size": "1024x1024", + "output_format": "jpeg", "output_compression": 90}], + }, + timeout=180, +) +data = resp.json() +for item in data["output"]: + if item.get("type") == "image_generation_call": + with open("out.jpg", "wb") as f: + f.write(base64.b64decode(item["result"])) + break +``` + +--- + +## 9. 与官方 OpenAI Responses API 的差异 + +| 项 | 官方 OpenAI | xixiapi.cc | +|----|------------|------------| +| 模型名 | `gpt-image-1` | `gpt-image-2`(第三方版本) | +| 错误信息 | 详细 JSON 错误体 | 502 + 15 字节文本 | +| `webp` 输出 | 支持 | 静默忽略,返回 PNG | +| `n` 参数 | 支持(部分模型) | 502 | +| `background: transparent` | 支持(`gpt-image-1`) | 502(`gpt-image-2` 本身就不支持) | +| 其他 Responses API 顶层字段 | 标准 | 完全兼容 | + +--- + +## 10. 编辑模式 (Image Editing) + +`gpt-image-2` 支持两种工作模式,通过 `input` 字段的形态切换: + +| 模式 | `input` 类型 | 用途 | +|------|--------------|------| +| **生成** (text → image) | string | 凭空创作,见 §2 | +| **编辑** (image + text → image) | 多模态 array | 基于现有图修改 | + +### 10.1 编辑请求结构 + +```json +{ + "model": "gpt-image-2", + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "Add a small red Christmas hat on top, keep everything else the same"}, + {"type": "input_image", "image_url": "https://example.com/source.jpg"} + ] + } + ], + "tools": [ + { + "type": "image_generation", + "size": "1024x1024", + "output_format": "jpeg", + "output_compression": 90 + } + ] +} +``` + +### 10.2 输入图来源(`image_url`) + +支持两种形式: + +| 来源 | 输入图格式 | 实测结果 | 备注 | +|------|------------|----------|------| +| **HTTPS URL** | png / jpeg / webp | ✅ 可用 | 网关自己抓取,**省客户端上行带宽** | +| **base64 data URI** | png | ✅ 可用 | `data:image/png;base64,...` | +| **base64 data URI** | jpeg | ✅ 可用 | `data:image/jpeg;base64,...` | +| **base64 data URI** | webp | ❌ 502(72s 后) | webp 仅 URL 形式可用,**不能内联** | + +### 10.3 ⚠️ `input_fidelity` 在 xixiapi 上不支持 + +OpenAI 官方 Responses API 提供 `input_fidelity: low/high` 用于控制"保留原图程度",但 xixiapi 网关**完全拒收**该参数(任意位置 / 任意拼写均失败): + +| 测试位置 / 拼写 | 结果 | +|------------------|------| +| `tools[0].input_fidelity: "high"` | 502(~4s) | +| `tools[0].input_fidelity: "low"` | 502(~4s) | +| `content[].input_fidelity: ...` | 502 | +| 顶层 `input_fidelity: ...` | 502 | +| `tools[0].fidelity: "high"`(改名) | 502 | +| `tools[0].image_fidelity: "high"`(改名) | 502 | + +**保真度由模型默认行为决定,客户端无法控制。** 如需"严格保留原图、只动局部",建议在 prompt 里明确写出来,例如 `keep everything else exactly the same`。 + +### 10.4 性能参考(实测,1024×1024 输出 JPEG q=85~90) + +| 输入方式 | 输入大小 | 耗时 | 输出大小 | +|----------|----------|------|----------| +| HTTPS URL(webp 1200×1200) | 73 KB | ~68 s | 100 KB | +| base64 PNG(原图 1200×1200) | 1.0 MB | ~57 s | 102 KB | +| base64 JPEG(原图 1200×1200) | 173 KB | ~63 s | 101 KB | + +### 10.5 推荐实现策略(中转层) + +``` +客户端上传图 → 你的中转 + ↓ + ┌─ 客户端给 URL 且公网可达 → 直接透传 URL 给 xixiapi(最省带宽) + ├─ 客户端给 base64 webp → 服务端转 PNG 再传(webp 内联必 502) + └─ 客户端给 base64 png/jpeg → 直接透传 + ↓ + 不要附加 input_fidelity(传了必 502) +``` + +--- + +## 11. 已知问题与 TODO + +- [ ] `error_code: 502` 信息缺失,建议中转层增加参数预校验函数 +- [ ] `webp` 静默降级为 PNG —— 需要服务端二次编码才能真正出 webp +- [ ] `partial_images` 实际帧数由模型决定,客户端不能假设固定帧数 +- [ ] 4K PNG + high 在默认 100s 超时下会被中间代理断流,需统一调到 ≥120s +- [ ] **编辑模式 webp 输入歧视**:URL 可,base64 不可 —— 中转层应统一在 base64 路径上转码 +- [ ] **编辑模式 `input_fidelity` 缺失**:无法精确控制"保留原图程度",仅能靠 prompt 文案引导 + +--- + +*文档版本:1.0 — 基于 2026-04-27 实测数据* diff --git a/new-api.Apifox.json b/new-api.Apifox.json new file mode 100644 index 000000000000..f33b00e6083f --- /dev/null +++ b/new-api.Apifox.json @@ -0,0 +1,18146 @@ +{ + "apifoxProject": "1.0.0", + "$schema": { + "app": "apifox", + "type": "project", + "version": "1.2.0" + }, + "info": { + "name": "new-api", + "description": "", + "mockRule": { + "rules": [], + "enableSystemRule": true + } + }, + "projectSetting": { + "id": "8135451", + "auth": {}, + "securityScheme": {}, + "gateway": [], + "language": "zh-CN", + "apiStatuses": [ + "developing", + "testing", + "released", + "deprecated" + ], + "mockSettings": {}, + "preProcessors": [], + "postProcessors": [], + "advancedSettings": { + "enableJsonc": false, + "enableBigint": false, + "responseValidate": true, + "enableTestScenarioSetting": false, + "enableYAPICompatScript": false, + "isDefaultUrlEncoding": 2, + "publishedDocUrlRules": { + "defaultRule": "RESOURCE_KEY_ONLY", + "resourceKeyStandard": "NEW" + } + }, + "initialDisabledMockIds": [], + "servers": [ + { + "id": "default", + "name": "默认服务", + "moduleId": 7492826 + } + ], + "cloudMock": { + "security": "free", + "enable": false, + "tokenKey": "apifoxToken" + } + }, + "apiCollection": [ + { + "name": "根目录", + "id": 82732829, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "fTBVBrxz8mfkvNC9u6Gmq", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "fTBVBrxz8mfkvNC9u6Gmq", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "fTBVBrxz8mfkvNC9u6Gmq": { + "1107990": [] + } + } + }, + "parentId": 0, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "methodAndPath", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "SHARED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "文字(Chat)", + "id": 84639670, + "auth": {}, + "securityScheme": {}, + "parentId": 0, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "gpt-5.5", + "id": 84639673, + "auth": {}, + "securityScheme": {}, + "parentId": 84639670, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "对话生成 / 联网搜索 / 推理(Responses API · 推荐)", + "api": { + "id": "452168119", + "method": "post", + "path": "/v1/responses", + "parameters": { + "query": [], + "path": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "xFXdYDUt9iCR0h1_lgjnd", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "xFXdYDUt9iCR0h1_lgjnd", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "xFXdYDUt9iCR0h1_lgjnd": { + "1107990": [] + } + } + }, + "commonParameters": { + "query": [], + "body": [], + "cookie": [], + "header": [] + }, + "responses": [ + { + "id": "150846094", + "code": "200", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/270308755" + }, + "itemSchema": {}, + "description": "成功", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "193760176", + "code": "401", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "认证失败 - API Key 无效或缺失", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "124855772", + "code": "429", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "限流 - 请求过快或额度耗尽", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "127727443", + "code": "500", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "上游错误 - 厂商 API 异常或 new-api 内部错误", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + } + ], + "responseExamples": [ + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Incorrect API key provided\",\n \"type\": \"invalid_request_error\",\n \"code\": \"invalid_api_key\"\n }\n}", + "responseId": "193760176", + "description": "", + "oasKey": "", + "oasExtensions": "" + }, + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Rate limit reached for requests\",\n \"type\": \"rate_limit_error\",\n \"code\": \"rate_limit_exceeded\"\n }\n}", + "responseId": "124855772", + "description": "", + "oasKey": "", + "oasExtensions": "" + } + ], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "$ref": "#/definitions/270308754" + }, + "required": true, + "mediaType": "application/json", + "examples": [ + { + "name": "基础对话", + "value": "{\n \"model\": \"gpt-5.5\",\n \"input\": \"用一句话解释熵增定律。\",\n \"max_output_tokens\": 500\n}", + "oasKey": "basic", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "🌐 联网搜索", + "value": "{\n \"model\": \"gpt-5.5\",\n \"input\": \"2025 年 Python 最新稳定版是哪个?请附上来源链接。\",\n \"tools\": [\n {\n \"type\": \"web_search\"\n }\n ],\n \"max_output_tokens\": 2000\n}", + "oasKey": "web_search", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "📏 详细输出(verbosity=high)", + "value": "{\n \"model\": \"gpt-5.5\",\n \"input\": \"讲讲 Transformer 神经网络的工作原理。\",\n \"text\": {\n \"verbosity\": \"high\"\n },\n \"max_output_tokens\": 3000\n}", + "oasKey": "verbosity_high", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "🧠 深度推理 + 摘要", + "value": "{\n \"model\": \"gpt-5.5\",\n \"input\": \"证明:根号 2 是无理数。\",\n \"reasoning\": {\n \"effort\": \"high\",\n \"summary\": \"concise\"\n },\n \"max_output_tokens\": 4096\n}", + "oasKey": "reasoning_high", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "📐 严格结构化输出", + "value": "{\n \"model\": \"gpt-5.5\",\n \"input\": \"提取人物信息:张三,30 岁,工程师。\",\n \"text\": {\n \"format\": {\n \"type\": \"json_schema\",\n \"name\": \"person\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"age\": {\n \"type\": \"integer\"\n },\n \"job\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"age\",\n \"job\"\n ],\n \"additionalProperties\": false\n },\n \"strict\": true\n }\n },\n \"max_output_tokens\": 500\n}", + "oasKey": "json_schema_strict", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "💬 系统指令", + "value": "{\n \"model\": \"gpt-5.5\",\n \"input\": \"Translate to French: Hello world\",\n \"instructions\": \"你是专业翻译助手,只输出译文,不加任何解释或前后缀。\",\n \"max_output_tokens\": 200\n}", + "oasKey": "instructions", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "🔧 工具调用(function calling)", + "value": "{\n \"model\": \"gpt-5.5\",\n \"input\": \"上海现在天气怎么样?\",\n \"tools\": [\n {\n \"type\": \"function\",\n \"name\": \"get_weather\",\n \"description\": \"获取指定城市的实时天气\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"city\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"city\"\n ]\n }\n }\n ],\n \"tool_choice\": \"auto\",\n \"max_output_tokens\": 500\n}", + "oasKey": "function_calling", + "oasExtensions": "", + "mediaType": "application/json" + } + ], + "oasExtensions": "" + }, + "description": "`model` 填 `\"gpt-5.5\"`。仅在此路径生效的关键能力:\n- 🌐 **联网搜索** `tools=[{\"type\":\"web_search\"}]`\n- 📏 **verbosity** `text.verbosity` (low/medium/high)\n- 📐 **严格 JSON** `text.format.type=json_schema` + `strict=true`\n- 💬 **系统指令** `instructions`\n- 🧠 **推理控制** `reasoning.effort` + `reasoning.summary`\n\n`max_output_tokens` 对长输出不严格截断。", + "tags": [ + "文字(Chat)/gpt-5.5" + ], + "status": "released", + "serverId": "", + "operationId": "responsesGpt55", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "{\"x-apifox-overrides\":{\"path\":\"/v1/responses\"}}", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + }, + { + "name": "对话生成(chat/completions 兼容入口)", + "api": { + "id": "452168120", + "method": "post", + "path": "/v1/chat/completions", + "parameters": { + "query": [], + "path": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "Xy3yoSdZQDP1scV1-CgOz", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "Xy3yoSdZQDP1scV1-CgOz", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "Xy3yoSdZQDP1scV1-CgOz": { + "1107990": [] + } + } + }, + "commonParameters": { + "query": [], + "body": [], + "cookie": [], + "header": [] + }, + "responses": [ + { + "id": "140978647", + "code": "200", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079091" + }, + "itemSchema": {}, + "description": "成功(非流式 JSON 响应)", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "178516982", + "code": "401", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "认证失败 - API Key 无效或缺失", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "188560214", + "code": "429", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "限流 - 请求过快或额度耗尽", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "107055595", + "code": "500", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "上游错误 - 厂商 API 异常或 new-api 内部错误", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + } + ], + "responseExamples": [ + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Incorrect API key provided\",\n \"type\": \"invalid_request_error\",\n \"code\": \"invalid_api_key\"\n }\n}", + "responseId": "178516982", + "description": "", + "oasKey": "", + "oasExtensions": "" + }, + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Rate limit reached for requests\",\n \"type\": \"rate_limit_error\",\n \"code\": \"rate_limit_exceeded\"\n }\n}", + "responseId": "188560214", + "description": "", + "oasKey": "", + "oasExtensions": "" + } + ], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "$ref": "#/definitions/270308752" + }, + "required": true, + "mediaType": "application/json", + "examples": [ + { + "name": "基础对话", + "value": "{\n \"model\": \"gpt-5.5\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"你是简洁的助手,回答尽量短。\"\n },\n {\n \"role\": \"user\",\n \"content\": \"用一句话解释熵增定律。\"\n }\n ],\n \"max_completion_tokens\": 500\n}", + "oasKey": "basic", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "深度推理(reasoning_effort)", + "value": "{\n \"model\": \"gpt-5.5\",\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"证明:根号 2 是无理数。\"\n }\n ],\n \"reasoning_effort\": \"high\",\n \"max_completion_tokens\": 4096\n}", + "oasKey": "reasoning", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "工具调用(function calling)", + "value": "{\n \"model\": \"gpt-5.5\",\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"杭州今天天气怎么样?\"\n }\n ],\n \"tools\": [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"get_weather\",\n \"description\": \"获取指定城市的天气\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"city\": {\n \"type\": \"string\",\n \"description\": \"城市名\"\n }\n },\n \"required\": [\n \"city\"\n ]\n }\n }\n }\n ],\n \"tool_choice\": \"auto\",\n \"max_completion_tokens\": 500\n}", + "oasKey": "tool_calling", + "oasExtensions": "", + "mediaType": "application/json" + } + ], + "oasExtensions": "" + }, + "description": "OpenAI SDK 兼容入口。**高级场景请改用 `/v1/responses`** —— `verbosity` / `web_search` / `json_schema strict` 仅在 Responses 路径生效。", + "tags": [ + "文字(Chat)/gpt-5.5" + ], + "status": "released", + "serverId": "", + "operationId": "chatGpt55", + "sourceUrl": "", + "ordering": 12, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "{\"x-apifox-overrides\":{\"path\":\"/v1/chat/completions\"}}", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + } + ] + }, + { + "name": "gpt-5.4", + "id": 84639674, + "auth": {}, + "securityScheme": {}, + "parentId": 84639670, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "对话生成 / 联网搜索 / 推理(Responses API · 推荐)", + "api": { + "id": "452168121", + "method": "post", + "path": "/v1/responses", + "parameters": { + "query": [], + "path": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "A8_wVQp_Y_VFFxmXZLxHS", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "A8_wVQp_Y_VFFxmXZLxHS", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "A8_wVQp_Y_VFFxmXZLxHS": { + "1107990": [] + } + } + }, + "commonParameters": { + "query": [], + "body": [], + "cookie": [], + "header": [] + }, + "responses": [ + { + "id": "190573872", + "code": "200", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/270308755" + }, + "itemSchema": {}, + "description": "成功", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "191628910", + "code": "401", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "认证失败 - API Key 无效或缺失", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "120604267", + "code": "429", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "限流 - 请求过快或额度耗尽", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "183740950", + "code": "500", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "上游错误 - 厂商 API 异常或 new-api 内部错误", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + } + ], + "responseExamples": [ + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Incorrect API key provided\",\n \"type\": \"invalid_request_error\",\n \"code\": \"invalid_api_key\"\n }\n}", + "responseId": "191628910", + "description": "", + "oasKey": "", + "oasExtensions": "" + }, + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Rate limit reached for requests\",\n \"type\": \"rate_limit_error\",\n \"code\": \"rate_limit_exceeded\"\n }\n}", + "responseId": "120604267", + "description": "", + "oasKey": "", + "oasExtensions": "" + } + ], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "$ref": "#/definitions/270308754" + }, + "required": true, + "mediaType": "application/json", + "examples": [ + { + "name": "基础对话", + "value": "{\n \"model\": \"gpt-5.4\",\n \"input\": \"用一句话解释熵增定律。\",\n \"max_output_tokens\": 500\n}", + "oasKey": "basic", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "🌐 联网搜索", + "value": "{\n \"model\": \"gpt-5.4\",\n \"input\": \"2025 年 Python 最新稳定版是哪个?请附上来源链接。\",\n \"tools\": [\n {\n \"type\": \"web_search\"\n }\n ],\n \"max_output_tokens\": 2000\n}", + "oasKey": "web_search", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "📏 详细输出(verbosity=high)", + "value": "{\n \"model\": \"gpt-5.4\",\n \"input\": \"讲讲 Transformer 神经网络的工作原理。\",\n \"text\": {\n \"verbosity\": \"high\"\n },\n \"max_output_tokens\": 3000\n}", + "oasKey": "verbosity_high", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "🧠 深度推理 + 摘要", + "value": "{\n \"model\": \"gpt-5.4\",\n \"input\": \"证明:根号 2 是无理数。\",\n \"reasoning\": {\n \"effort\": \"high\",\n \"summary\": \"concise\"\n },\n \"max_output_tokens\": 4096\n}", + "oasKey": "reasoning_high", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "📐 严格结构化输出", + "value": "{\n \"model\": \"gpt-5.4\",\n \"input\": \"提取人物信息:张三,30 岁,工程师。\",\n \"text\": {\n \"format\": {\n \"type\": \"json_schema\",\n \"name\": \"person\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"age\": {\n \"type\": \"integer\"\n },\n \"job\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"age\",\n \"job\"\n ],\n \"additionalProperties\": false\n },\n \"strict\": true\n }\n },\n \"max_output_tokens\": 500\n}", + "oasKey": "json_schema_strict", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "💬 系统指令", + "value": "{\n \"model\": \"gpt-5.4\",\n \"input\": \"Translate to French: Hello world\",\n \"instructions\": \"你是专业翻译助手,只输出译文,不加任何解释或前后缀。\",\n \"max_output_tokens\": 200\n}", + "oasKey": "instructions", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "🔧 工具调用(function calling)", + "value": "{\n \"model\": \"gpt-5.4\",\n \"input\": \"上海现在天气怎么样?\",\n \"tools\": [\n {\n \"type\": \"function\",\n \"name\": \"get_weather\",\n \"description\": \"获取指定城市的实时天气\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"city\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"city\"\n ]\n }\n }\n ],\n \"tool_choice\": \"auto\",\n \"max_output_tokens\": 500\n}", + "oasKey": "function_calling", + "oasExtensions": "", + "mediaType": "application/json" + } + ], + "oasExtensions": "" + }, + "description": "`model` 填 `\"gpt-5.4\"`。仅在此路径生效的关键能力:\n- 🌐 **联网搜索** `tools=[{\"type\":\"web_search\"}]`\n- 📏 **verbosity** `text.verbosity` (low/medium/high)\n- 📐 **严格 JSON** `text.format.type=json_schema` + `strict=true`\n- 💬 **系统指令** `instructions`\n- 🧠 **推理控制** `reasoning.effort` + `reasoning.summary`\n\n`max_output_tokens` 对长输出不严格截断。", + "tags": [ + "文字(Chat)/gpt-5.4" + ], + "status": "released", + "serverId": "", + "operationId": "responsesGpt54", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "{\"x-apifox-overrides\":{\"path\":\"/v1/responses\"}}", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + }, + { + "name": "对话生成(chat/completions 兼容入口)", + "api": { + "id": "452168122", + "method": "post", + "path": "/v1/chat/completions", + "parameters": { + "query": [], + "path": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "O8I5crDnOqDAJtD-xYv9N", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "O8I5crDnOqDAJtD-xYv9N", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "O8I5crDnOqDAJtD-xYv9N": { + "1107990": [] + } + } + }, + "commonParameters": { + "query": [], + "body": [], + "cookie": [], + "header": [] + }, + "responses": [ + { + "id": "104938613", + "code": "200", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079091" + }, + "itemSchema": {}, + "description": "成功(非流式 JSON 响应)", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "187792005", + "code": "401", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "认证失败 - API Key 无效或缺失", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "139816290", + "code": "429", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "限流 - 请求过快或额度耗尽", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "153847945", + "code": "500", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "上游错误 - 厂商 API 异常或 new-api 内部错误", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + } + ], + "responseExamples": [ + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Incorrect API key provided\",\n \"type\": \"invalid_request_error\",\n \"code\": \"invalid_api_key\"\n }\n}", + "responseId": "187792005", + "description": "", + "oasKey": "", + "oasExtensions": "" + }, + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Rate limit reached for requests\",\n \"type\": \"rate_limit_error\",\n \"code\": \"rate_limit_exceeded\"\n }\n}", + "responseId": "139816290", + "description": "", + "oasKey": "", + "oasExtensions": "" + } + ], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "$ref": "#/definitions/270308752" + }, + "required": true, + "mediaType": "application/json", + "examples": [ + { + "name": "基础对话", + "value": "{\n \"model\": \"gpt-5.4\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"你是简洁的助手,回答尽量短。\"\n },\n {\n \"role\": \"user\",\n \"content\": \"用一句话解释熵增定律。\"\n }\n ],\n \"max_completion_tokens\": 500\n}", + "oasKey": "basic", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "深度推理(reasoning_effort)", + "value": "{\n \"model\": \"gpt-5.4\",\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"证明:根号 2 是无理数。\"\n }\n ],\n \"reasoning_effort\": \"high\",\n \"max_completion_tokens\": 4096\n}", + "oasKey": "reasoning", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "工具调用(function calling)", + "value": "{\n \"model\": \"gpt-5.4\",\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"杭州今天天气怎么样?\"\n }\n ],\n \"tools\": [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"get_weather\",\n \"description\": \"获取指定城市的天气\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"city\": {\n \"type\": \"string\",\n \"description\": \"城市名\"\n }\n },\n \"required\": [\n \"city\"\n ]\n }\n }\n }\n ],\n \"tool_choice\": \"auto\",\n \"max_completion_tokens\": 500\n}", + "oasKey": "tool_calling", + "oasExtensions": "", + "mediaType": "application/json" + } + ], + "oasExtensions": "" + }, + "description": "OpenAI SDK 兼容入口。**高级场景请改用 `/v1/responses`** —— `verbosity` / `web_search` / `json_schema strict` 仅在 Responses 路径生效。\n> gpt-5.4 比 gpt-5.5 上下文稍小、推理略弱,但成本更低。", + "tags": [ + "文字(Chat)/gpt-5.4" + ], + "status": "released", + "serverId": "", + "operationId": "chatGpt54", + "sourceUrl": "", + "ordering": 12, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "{\"x-apifox-overrides\":{\"path\":\"/v1/chat/completions\"}}", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + } + ] + }, + { + "name": "claude-sonnet-4.6", + "id": 84639675, + "auth": {}, + "securityScheme": {}, + "parentId": 84639670, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "对话生成(Anthropic 原生)", + "api": { + "id": "452168123", + "method": "post", + "path": "/v1/messages", + "parameters": { + "query": [], + "path": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "io1JsnMQtQvwVjo-AESeH", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "io1JsnMQtQvwVjo-AESeH", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "io1JsnMQtQvwVjo-AESeH": { + "1107990": [] + } + } + }, + "commonParameters": { + "query": [], + "body": [], + "cookie": [], + "header": [] + }, + "responses": [ + { + "id": "112001656", + "code": "200", + "name": "", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "mediaType": "", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [], + "properties": {} + }, + "required": true, + "mediaType": "application/json", + "examples": [ + { + "value": "{\n \"model\": \"claude-sonnet-4-6\",\n \"max_tokens\": 1024,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Hello, Claude\"\n }\n ]\n}", + "mediaType": "application/json", + "description": "" + } + ], + "oasExtensions": "" + }, + "description": "Anthropic 原生 Messages 格式。`model` 字段填 `\"claude-sonnet-4-6\"` 等。", + "tags": [ + "文字(Chat)/claude-sonnet-4.6" + ], + "status": "released", + "serverId": "", + "operationId": "chatClaudeSonnet46", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "{\"x-apifox-overrides\":{\"path\":\"/v1/messages\"}}", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + } + ] + }, + { + "name": "gemini-3-pro", + "id": 84639676, + "auth": {}, + "securityScheme": {}, + "parentId": 84639670, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "对话生成(Gemini 原生)", + "api": { + "id": "452168124", + "method": "post", + "path": "/v1beta/models/gemini-3-pro:generateContent", + "parameters": { + "path": [ + { + "id": "model#0", + "name": "model", + "required": true, + "enable": true, + "description": "", + "example": "", + "type": "string", + "schema": { + "type": "string", + "default": "gemini-3-pro" + } + } + ], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "EzNMz7OmCydiAmP8tYM1O", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "EzNMz7OmCydiAmP8tYM1O", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "EzNMz7OmCydiAmP8tYM1O": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "122727330", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [] + }, + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [], + "examples": [ + { + "value": "{\n \"contents\": [\n {\n \"role\": \"user\",\n \"parts\": [\n {\n \"text\": \"Hello, Gemini\"\n }\n ]\n }\n ]\n}", + "mediaType": "application/json", + "description": "" + } + ] + }, + "description": "```\nPOST https://api.opwan.ai/v1beta/models/{model}:generateContent\n```\nGoogle 原生 GenerateContent 格式。`{model}` 路径参数填 `gemini-3-pro` 等。", + "tags": [ + "文字(Chat)/gemini-3-pro" + ], + "status": "released", + "serverId": "", + "operationId": "chatGemini3Pro", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + } + ] + }, + { + "name": "通用", + "id": 84639677, + "auth": {}, + "securityScheme": {}, + "parentId": 84639670, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "对话生成", + "api": { + "id": "452168125", + "method": "post", + "path": "/v1/chat/completions", + "parameters": { + "path": [], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "2jiIvsiwl43_QAFV7cj0V", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "2jiIvsiwl43_QAFV7cj0V", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "2jiIvsiwl43_QAFV7cj0V": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "168974525", + "code": "200", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079091" + }, + "itemSchema": {}, + "description": "成功", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "$ref": "#/definitions/263079092" + }, + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [], + "examples": [ + { + "value": "{\n \"model\": \"<任意 OpenAI 兼容模型>\",\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Hello!\"\n }\n ],\n \"stream\": false\n}", + "mediaType": "application/json", + "description": "" + } + ] + }, + "description": "```\nPOST https://api.opwan.ai/v1/chat/completions\n```\n\n`model` 字段填 `\"<任意 OpenAI 兼容模型>\"`——任何 OpenAI 兼容格式的模型都可用此通用入口。", + "tags": [ + "文字(Chat)/通用" + ], + "status": "released", + "serverId": "", + "operationId": "chatGeneric", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + }, + { + "name": "对话生成(Responses API 通用入口)", + "api": { + "id": "452168126", + "method": "post", + "path": "/v1/responses", + "parameters": { + "path": [], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "jzA78ul2VM1GFbqXstm0Y", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "jzA78ul2VM1GFbqXstm0Y", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "jzA78ul2VM1GFbqXstm0Y": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "118998953", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [] + }, + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [], + "examples": [ + { + "value": "{\n \"model\": \"gpt-5.5\",\n \"input\": \"Hello!\"\n}", + "mediaType": "application/json", + "description": "" + } + ] + }, + "description": "```\nPOST https://api.opwan.ai/v1/responses\n```\n\nOpenAI 新版 Responses API 格式,跟 chat/completions 平行。任意 OpenAI 系模型均可。", + "tags": [ + "文字(Chat)/通用" + ], + "status": "released", + "serverId": "", + "operationId": "chatResponses", + "sourceUrl": "", + "ordering": 12, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + } + ] + } + ] + }, + { + "name": "图像(Images)", + "id": 84639671, + "auth": {}, + "securityScheme": {}, + "parentId": 0, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "gpt-image-2", + "id": 84639678, + "auth": {}, + "securityScheme": {}, + "parentId": 84639671, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "图像生成", + "api": { + "id": "452168127", + "method": "post", + "path": "/v1/images/generations", + "parameters": { + "query": [], + "path": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "oIKIL0Llpj-ypcmowGUCQ", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "oIKIL0Llpj-ypcmowGUCQ", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "oIKIL0Llpj-ypcmowGUCQ": { + "1107990": [] + } + } + }, + "commonParameters": { + "query": [], + "body": [], + "cookie": [], + "header": [] + }, + "responses": [ + { + "id": "102966136", + "code": "200", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079107" + }, + "itemSchema": {}, + "description": "成功", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "144195035", + "code": "401", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "认证失败 - API Key 无效或缺失", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "116810433", + "code": "429", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "限流 - 请求过快或额度耗尽", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + }, + { + "id": "140667846", + "code": "500", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "上游错误 - 厂商 API 异常或 new-api 内部错误", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "" + } + ], + "responseExamples": [ + { + "name": "成功示例", + "data": "{\n \"created\": 1730000000,\n \"data\": [\n {\n \"url\": \"https://cdn.opwan.ai/images/.png\",\n \"revised_prompt\": \"...\"\n }\n ]\n}", + "responseId": "102966136", + "description": "", + "oasKey": "", + "oasExtensions": "" + }, + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Incorrect API key provided\",\n \"type\": \"invalid_request_error\",\n \"code\": \"invalid_api_key\"\n }\n}", + "responseId": "144195035", + "description": "", + "oasKey": "", + "oasExtensions": "" + }, + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Rate limit reached for requests\",\n \"type\": \"rate_limit_error\",\n \"code\": \"rate_limit_exceeded\"\n }\n}", + "responseId": "116810433", + "description": "", + "oasKey": "", + "oasExtensions": "" + } + ], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "$ref": "#/definitions/270308753" + }, + "required": true, + "mediaType": "application/json", + "examples": [ + { + "name": "基础生成(1024×1024)", + "value": "{\n \"model\": \"gpt-image-2\",\n \"prompt\": \"窗台上一只可爱的猫,电影感打光\",\n \"size\": \"1024x1024\",\n \"quality\": \"high\"\n}", + "oasKey": "basic", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "4K 横屏 (3840×2160)", + "value": "{\n \"model\": \"gpt-image-2\",\n \"prompt\": \"海上日落,写实风格,4K 细节\",\n \"size\": \"3840x2160\",\n \"quality\": \"high\",\n \"output_format\": \"png\"\n}", + "oasKey": "high_resolution_4k", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "高压缩 JPEG(适合移动端)", + "value": "{\n \"model\": \"gpt-image-2\",\n \"prompt\": \"海上日落\",\n \"size\": \"1536x864\",\n \"output_format\": \"jpeg\",\n \"output_compression\": 75,\n \"quality\": \"medium\"\n}", + "oasKey": "compressed_jpeg", + "oasExtensions": "", + "mediaType": "application/json" + } + ], + "oasExtensions": "" + }, + "description": "`model` = `\"gpt-image-2\"`。参数 enum 详见 schema。`size` 仅支持 15 个官方安全尺寸(1K/2K/4K × 5 个比例)+ `auto`。", + "tags": [ + "图像(Images)/gpt-image-2" + ], + "status": "released", + "serverId": "", + "operationId": "imageGptImage2Generate", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "{\"x-apifox-overrides\":{\"path\":\"/v1/images/generations\"}}", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + }, + { + "name": "图像编辑", + "api": { + "id": "452168128", + "method": "post", + "path": "/v1/images/edits", + "parameters": { + "query": [], + "path": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "JQIAJ7zz4P5lrFACVnRXX", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "JQIAJ7zz4P5lrFACVnRXX", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "JQIAJ7zz4P5lrFACVnRXX": { + "1107990": [] + } + } + }, + "commonParameters": { + "query": [], + "body": [], + "cookie": [], + "header": [] + }, + "responses": [ + { + "id": "resp-200-default", + "code": "200", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079107" + }, + "itemSchema": {}, + "description": "成功", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-200-default", + "name": "Status 200", + "description": "成功", + "mockBy": "static", + "data": "{\n \"created\": 1714723200,\n \"data\": [\n {\n \"url\": \"https://cdn.opwan.ai/images/13cd3af24cfe6372870ef644543e62148eba30b2d2389f902385e4f63dd59021.jpg\",\n \"revised_prompt\": \"A watercolor-bordered version of the source image with soft pastel edges\"\n }\n ],\n \"background\": \"auto\",\n \"output_format\": \"jpeg\",\n \"quality\": \"auto\",\n \"size\": \"1024x1024\",\n \"usage\": {\n \"input_tokens\": 1826,\n \"input_tokens_details\": {\n \"cached_tokens\": 0\n },\n \"output_tokens\": 91,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 1917\n },\n \"model\": \"gpt-5.4-mini-2026-03-17\"\n}" + } + ] + }, + { + "id": "resp-200-审核拒绝", + "code": "200", + "name": "审核拒绝", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "上游审核拦截,需检查 `error`。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-200-审核拒绝", + "name": "审核拒绝", + "description": "上游审核拦截,需检查 `error`。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"type\": \"image_generation_user_error\",\n \"code\": \"moderation_blocked\",\n \"message\": \"Your request was rejected by the safety system. If you believe this is an error, contact us at help.openai.com and include the request ID . safety_violations=[sexual].\"\n }\n}" + } + ] + }, + { + "id": "resp-400-参数错误", + "code": "400", + "name": "参数错误", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "缺字段 / 类型不支持 / 超大 / URL 拉取失败 / 图数量超限。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-400-参数错误", + "name": "参数错误", + "description": "缺字段 / 类型不支持 / 超大 / URL 拉取失败 / 图数量超限。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"message\": \"unsupported image type \\\"image/gif\\\", use png, jpeg, or webp\",\n \"type\": \"invalid_request_error\"\n }\n}" + } + ] + }, + { + "id": "resp-401-default", + "code": "401", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "API key 无效。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-401-default", + "name": "Status 401", + "description": "API key 无效。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"message\": \"Invalid relay key\",\n \"type\": \"authentication_error\",\n \"param\": null,\n \"code\": null\n }\n}" + } + ] + }, + { + "id": "resp-429-default", + "code": "429", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "限流。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-429-default", + "name": "Status 429", + "description": "限流。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"message\": \"rate limit exceeded\",\n \"type\": \"rate_limit_error\"\n }\n}" + } + ] + }, + { + "id": "resp-500-default", + "code": "500", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "网关内部错误。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-500-default", + "name": "Status 500", + "description": "网关内部错误。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"message\": \"internal server error\",\n \"type\": \"server_error\"\n }\n}" + } + ] + }, + { + "id": "resp-502-上游错误", + "code": "502", + "name": "上游错误", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "上游 SSE 中断 / 通信失败。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-502-上游错误", + "name": "上游错误", + "description": "上游 SSE 中断 / 通信失败。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"message\": \"upstream stream ended without response.completed\",\n \"type\": \"upstream_error\"\n }\n}" + } + ] + } + ], + "responseExamples": [ + { + "name": "成功示例", + "data": "{\n \"created\": 1730000000,\n \"data\": [\n {\n \"url\": \"https://cdn.opwan.ai/images/.png\",\n \"revised_prompt\": \"...\"\n }\n ]\n}", + "responseId": "183038182", + "description": "", + "oasKey": "", + "oasExtensions": "" + }, + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Incorrect API key provided\",\n \"type\": \"invalid_request_error\",\n \"code\": \"invalid_api_key\"\n }\n}", + "responseId": "196466997", + "description": "", + "oasKey": "", + "oasExtensions": "" + }, + { + "name": "成功示例", + "data": "{\n \"error\": {\n \"message\": \"Rate limit reached for requests\",\n \"type\": \"rate_limit_error\",\n \"code\": \"rate_limit_exceeded\"\n }\n}", + "responseId": "191148505", + "description": "", + "oasKey": "", + "oasExtensions": "" + } + ], + "requestBody": { + "type": "multipart/form-data", + "parameters": [ + { + "id": "param-model", + "name": "model", + "required": true, + "enable": true, + "description": "模型 ID。当前生产推荐 `gpt-image-2`。", + "example": "gpt-image-2", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "gpt-image-2", + "gpt-image-1" + ], + "examples": [ + "gpt-image-2" + ] + } + }, + { + "id": "param-image", + "name": "image", + "required": true, + "enable": true, + "description": "源图。文件 / URL / `data:image/...;base64,...`。可重复或用 `image[]` / `image[N]` 传多图(≤16 张,单图 ≤25MB)。", + "example": "", + "type": "file", + "contentType": "image/png, image/jpeg, image/webp", + "schema": { + "oneOf": [ + { + "type": "string", + "format": "binary" + }, + { + "type": "string", + "format": "uri", + "pattern": "^https?://" + }, + { + "type": "string", + "pattern": "^data:image/(png|jpeg|webp);base64," + } + ], + "description": "源图。文件 / URL / data:URI 三选一。可重复多次(≤16 张,单图 ≤25MB,png/jpeg/webp)。" + } + }, + { + "id": "param-prompt", + "name": "prompt", + "required": true, + "enable": true, + "description": "描述要如何修改图像。", + "example": "add a watercolor border around this image", + "type": "string", + "schema": { + "type": "string", + "maxLength": 32000, + "description": "描述要如何修改图像。" + } + }, + { + "id": "param-size", + "name": "size", + "required": false, + "enable": true, + "description": "输出尺寸。不传时上游可能输出 ~1254×1254。", + "example": "1024x1024", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "1024x1024", + "1536x864", + "864x1536", + "1024x1360", + "1360x1024", + "1440x1440", + "2048x1152", + "1152x2048", + "1248x1664", + "1664x1248", + "2880x2880", + "3840x2160", + "2160x3840", + "2448x3264", + "3264x2448", + "auto" + ], + "description": "输出尺寸。不传时上游可能输出 ~1254×1254。" + } + }, + { + "id": "param-quality", + "name": "quality", + "required": false, + "enable": true, + "description": "渲染质量。high 最贵(surcharge ~$0.25),low 最便宜(~$0.011)。", + "example": "auto", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "auto", + "low", + "medium", + "high" + ], + "default": "auto" + } + }, + { + "id": "param-output-format", + "name": "output_format", + "required": false, + "enable": true, + "description": "输出图像格式。webp 当前会被降级为 PNG。", + "example": "jpeg", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "png", + "jpeg", + "webp" + ], + "default": "png" + } + }, + { + "id": "param-output-compression", + "name": "output_compression", + "required": false, + "enable": true, + "description": "JPEG/WebP 压缩等级 0-100(仅对 jpeg/webp 生效)。", + "example": 85, + "type": "integer", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "default": 100 + } + }, + { + "id": "param-background", + "name": "background", + "required": false, + "enable": true, + "description": "`opaque` 不透明 / `auto` 模型自定(不支持 `transparent`)。", + "example": "auto", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "opaque", + "auto" + ], + "default": "auto", + "description": "`opaque` 不透明 / `auto` 模型自定(不支持 `transparent`)。" + } + }, + { + "id": "param-response-format", + "name": "response_format", + "required": false, + "enable": true, + "description": "`url` 返 CDN 链接(默认) / `b64_json` 返 base64。", + "example": "url", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "url", + "b64_json" + ], + "default": "url", + "description": "`url` 返 CDN 链接(默认) / `b64_json` 返 base64。" + } + }, + { + "id": "param-moderation", + "name": "moderation", + "required": false, + "enable": true, + "description": "`auto` 严格 / `low` 宽松(默认)。", + "example": "low", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "auto", + "low" + ], + "default": "low", + "description": "`auto` 严格 / `low` 宽松(默认)。" + } + }, + { + "id": "param-user", + "name": "user", + "required": false, + "enable": true, + "description": "终端用户 ID(透传给上游用于审计)。", + "example": "", + "type": "string", + "schema": { + "type": "string" + } + } + ], + "required": true, + "mediaType": "", + "examples": [], + "oasExtensions": "" + }, + "description": "编辑 / 重绘已有图像。\n\n- `image`: 文件 / http(s) URL / `data:image/...;base64,...`。不接受裸 base64 或 `image_url` 字段名\n- 多图: `image` 重复 / `image[]` / `image[N]`,最多 16 张,单图 ≤ 25 MB\n- 格式: png / jpeg / webp(webp 输出当前会降级为 PNG)\n- 耗时 15-180s,建议 timeout ≥ 240s;并发下偶发 524,建议重试\n- 默认 `moderation: low`\n", + "tags": [ + "图像(Images)/gpt-image-2" + ], + "status": "released", + "serverId": "", + "operationId": "imageGptImage2Edit", + "sourceUrl": "", + "ordering": 12, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [ + { + "id": "sample-curl-single", + "name": "curl", + "language": "curl", + "code": "curl -X POST \"$BASE/v1/images/edits\" \\\n -H \"Authorization: Bearer $API_KEY\" \\\n -F \"model=gpt-image-2\" \\\n -F \"image=@photo.png\" \\\n -F \"prompt=add a watercolor border\"\n" + }, + { + "id": "sample-curl-multi", + "name": "curl · 多图", + "language": "curl", + "code": "curl -X POST \"$BASE/v1/images/edits\" \\\n -H \"Authorization: Bearer $API_KEY\" \\\n -F \"model=gpt-image-2\" \\\n -F \"image=@a.png\" \\\n -F \"image=@b.png\" \\\n -F \"prompt=blend these two images\"\n" + } + ], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "{\"x-apifox-overrides\":{\"path\":\"/v1/images/edits\"}}", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "name": "编辑图像 / Edit Images (gpt-image-2)" + } + } + ] + }, + { + "name": "gemini-3.1-pro-image", + "id": 84639679, + "auth": {}, + "securityScheme": {}, + "parentId": 84639671, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "图像生成(Gemini 原生)", + "api": { + "id": "452168129", + "method": "post", + "path": "/v1beta/models/gemini-3.1-pro-image:generateContent", + "parameters": { + "path": [ + { + "id": "model#0", + "name": "model", + "required": true, + "enable": true, + "description": "", + "example": "", + "type": "string", + "schema": { + "type": "string", + "default": "gemini-3.1-pro-image" + } + } + ], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "LEUJWAKlWQ1bIKflnOUtV", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "LEUJWAKlWQ1bIKflnOUtV", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "LEUJWAKlWQ1bIKflnOUtV": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "161204864", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [] + }, + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [], + "examples": [ + { + "value": "{\n \"contents\": [\n {\n \"role\": \"user\",\n \"parts\": [\n {\n \"text\": \"draw a cat\"\n }\n ]\n }\n ],\n \"generationConfig\": {\n \"responseModalities\": [\n \"IMAGE\"\n ]\n }\n}", + "mediaType": "application/json", + "description": "" + } + ] + }, + "description": "**真实请求路径:POST /v1beta/models/{model}:generateContent**\n\nGoogle 原生格式生成图像。`{model}` 填 `\"gemini-3.1-pro-image\"`", + "tags": [ + "图像(Images)/gemini-3.1-pro-image" + ], + "status": "released", + "serverId": "", + "operationId": "imageGemini31ProImage", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + } + ] + }, + { + "name": "nano-banana", + "id": 84639680, + "auth": {}, + "securityScheme": {}, + "parentId": 84639671, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "图像生成(Gemini 原生)", + "api": { + "id": "452168130", + "method": "post", + "path": "/v1beta/models/nano-banana:generateContent", + "parameters": { + "path": [ + { + "id": "model#0", + "name": "model", + "required": true, + "enable": true, + "description": "", + "example": "", + "type": "string", + "schema": { + "type": "string", + "default": "gemini-2.5-flash-image-preview" + } + } + ], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "aN0UfcBVGHIME4ecfiKFs", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "aN0UfcBVGHIME4ecfiKFs", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "aN0UfcBVGHIME4ecfiKFs": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "197199346", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [] + }, + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [], + "examples": [ + { + "value": "{\n \"contents\": [\n {\n \"role\": \"user\",\n \"parts\": [\n {\n \"text\": \"draw a cat\"\n }\n ]\n }\n ],\n \"generationConfig\": {\n \"responseModalities\": [\n \"IMAGE\"\n ]\n }\n}", + "mediaType": "application/json", + "description": "" + } + ] + }, + "description": "**真实请求路径:POST /v1beta/models/{model}:generateContent**\n\nGoogle 原生格式生成图像。`{model}` 填 `\"gemini-2.5-flash-image-preview\"`(Nano Banana 是 Google `gemini-2.5-flash-image-preview` 的代号)", + "tags": [ + "图像(Images)/nano-banana" + ], + "status": "released", + "serverId": "", + "operationId": "imageNanoBanana", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + } + ] + }, + { + "name": "通用", + "id": 84639681, + "auth": {}, + "securityScheme": {}, + "parentId": 84639671, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "图像生成", + "api": { + "id": "452168131", + "method": "post", + "path": "/v1/images/generations", + "parameters": { + "path": [], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "dfR6xYsNCeqH5Q61gL_CJ", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "dfR6xYsNCeqH5Q61gL_CJ", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "dfR6xYsNCeqH5Q61gL_CJ": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "193951029", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [] + }, + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [], + "examples": [ + { + "value": "{\n \"model\": \"<任意模型名>\",\n \"prompt\": \"a cat\",\n \"n\": 1,\n \"size\": \"1024x1024\"\n}", + "mediaType": "application/json", + "description": "" + } + ] + }, + "description": "```\nPOST https://api.opwan.ai/v1/images/generations\n```\n\n`model` 字段填 `\"<任意模型名>\"`", + "tags": [ + "图像(Images)/通用" + ], + "status": "released", + "serverId": "", + "operationId": "imageGenericGenerate", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + }, + { + "name": "图像编辑", + "api": { + "id": "452168132", + "method": "post", + "path": "/v1/images/edits", + "parameters": { + "path": [], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "FoekLfFZc0O_ng9sRdBro", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "FoekLfFZc0O_ng9sRdBro", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "FoekLfFZc0O_ng9sRdBro": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "resp-200-default", + "code": "200", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079107" + }, + "itemSchema": {}, + "description": "成功", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-200-default", + "name": "Status 200", + "description": "成功", + "mockBy": "static", + "data": "{\n \"created\": 1714723200,\n \"data\": [\n {\n \"url\": \"https://cdn.opwan.ai/images/13cd3af24cfe6372870ef644543e62148eba30b2d2389f902385e4f63dd59021.jpg\",\n \"revised_prompt\": \"A watercolor-bordered version of the source image with soft pastel edges\"\n }\n ],\n \"background\": \"auto\",\n \"output_format\": \"jpeg\",\n \"quality\": \"auto\",\n \"size\": \"1024x1024\",\n \"usage\": {\n \"input_tokens\": 1826,\n \"input_tokens_details\": {\n \"cached_tokens\": 0\n },\n \"output_tokens\": 91,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 1917\n },\n \"model\": \"gpt-5.4-mini-2026-03-17\"\n}" + } + ] + }, + { + "id": "resp-200-审核拒绝", + "code": "200", + "name": "审核拒绝", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "上游审核拦截,需检查 `error`。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-200-审核拒绝", + "name": "审核拒绝", + "description": "上游审核拦截,需检查 `error`。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"type\": \"image_generation_user_error\",\n \"code\": \"moderation_blocked\",\n \"message\": \"Your request was rejected by the safety system. If you believe this is an error, contact us at help.openai.com and include the request ID . safety_violations=[sexual].\"\n }\n}" + } + ] + }, + { + "id": "resp-400-参数错误", + "code": "400", + "name": "参数错误", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "缺字段 / 类型不支持 / 超大 / URL 拉取失败 / 图数量超限。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-400-参数错误", + "name": "参数错误", + "description": "缺字段 / 类型不支持 / 超大 / URL 拉取失败 / 图数量超限。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"message\": \"unsupported image type \\\"image/gif\\\", use png, jpeg, or webp\",\n \"type\": \"invalid_request_error\"\n }\n}" + } + ] + }, + { + "id": "resp-401-default", + "code": "401", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "API key 无效。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-401-default", + "name": "Status 401", + "description": "API key 无效。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"message\": \"Invalid relay key\",\n \"type\": \"authentication_error\",\n \"param\": null,\n \"code\": null\n }\n}" + } + ] + }, + { + "id": "resp-429-default", + "code": "429", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "限流。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-429-default", + "name": "Status 429", + "description": "限流。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"message\": \"rate limit exceeded\",\n \"type\": \"rate_limit_error\"\n }\n}" + } + ] + }, + { + "id": "resp-500-default", + "code": "500", + "name": "", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "网关内部错误。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-500-default", + "name": "Status 500", + "description": "网关内部错误。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"message\": \"internal server error\",\n \"type\": \"server_error\"\n }\n}" + } + ] + }, + { + "id": "resp-502-上游错误", + "code": "502", + "name": "上游错误", + "headers": [], + "jsonSchema": { + "$ref": "#/definitions/263079085" + }, + "itemSchema": {}, + "description": "上游 SSE 中断 / 通信失败。", + "contentType": "json", + "mediaType": "application/json", + "oasExtensions": "", + "mocks": [ + { + "id": "mock-502-上游错误", + "name": "上游错误", + "description": "上游 SSE 中断 / 通信失败。", + "mockBy": "static", + "data": "{\n \"error\": {\n \"message\": \"upstream stream ended without response.completed\",\n \"type\": \"upstream_error\"\n }\n}" + } + ] + } + ], + "responseExamples": [], + "requestBody": { + "type": "multipart/form-data", + "parameters": [ + { + "id": "param-model", + "name": "model", + "required": true, + "enable": true, + "description": "模型 ID。当前生产推荐 `gpt-image-2`。", + "example": "gpt-image-2", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "gpt-image-2", + "gpt-image-1" + ], + "examples": [ + "gpt-image-2" + ] + } + }, + { + "id": "param-image", + "name": "image", + "required": true, + "enable": true, + "description": "源图。文件 / URL / data:URI 三选一。可重复多次(≤16 张,单图 ≤25MB,png/jpeg/webp)。", + "example": "", + "type": "file", + "contentType": "image/png, image/jpeg, image/webp", + "schema": { + "oneOf": [ + { + "type": "string", + "format": "binary" + }, + { + "type": "string", + "format": "uri", + "pattern": "^https?://" + }, + { + "type": "string", + "pattern": "^data:image/(png|jpeg|webp);base64," + } + ], + "description": "源图。文件 / URL / data:URI 三选一。可重复多次(≤16 张,单图 ≤25MB,png/jpeg/webp)。" + } + }, + { + "id": "param-prompt", + "name": "prompt", + "required": true, + "enable": true, + "description": "描述要如何修改图像。", + "example": "add a watercolor border around this image", + "type": "string", + "schema": { + "type": "string", + "maxLength": 32000, + "description": "描述要如何修改图像。" + } + }, + { + "id": "param-size", + "name": "size", + "required": false, + "enable": true, + "description": "输出尺寸。不传时上游可能输出 ~1254×1254。", + "example": "1024x1024", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "1024x1024", + "1536x864", + "864x1536", + "1024x1360", + "1360x1024", + "1440x1440", + "2048x1152", + "1152x2048", + "1248x1664", + "1664x1248", + "2880x2880", + "3840x2160", + "2160x3840", + "2448x3264", + "3264x2448", + "auto" + ], + "description": "输出尺寸。不传时上游可能输出 ~1254×1254。" + } + }, + { + "id": "param-quality", + "name": "quality", + "required": false, + "enable": true, + "description": "渲染质量。high 最贵(surcharge ~$0.25),low 最便宜(~$0.011)。", + "example": "auto", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "auto", + "low", + "medium", + "high" + ], + "default": "auto" + } + }, + { + "id": "param-output-format", + "name": "output_format", + "required": false, + "enable": true, + "description": "输出图像格式。", + "example": "jpeg", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "png", + "jpeg", + "webp" + ], + "default": "png" + } + }, + { + "id": "param-output-compression", + "name": "output_compression", + "required": false, + "enable": true, + "description": "JPEG/WebP 压缩等级 0-100(仅对 jpeg/webp 生效)。", + "example": 85, + "type": "integer", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "default": 100 + } + }, + { + "id": "param-background", + "name": "background", + "required": false, + "enable": true, + "description": "`opaque` 不透明 / `auto` 模型自定(不支持 `transparent`)。", + "example": "auto", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "opaque", + "auto" + ], + "default": "auto", + "description": "`opaque` 不透明 / `auto` 模型自定(不支持 `transparent`)。" + } + }, + { + "id": "param-response-format", + "name": "response_format", + "required": false, + "enable": true, + "description": "`url` 返 CDN 链接(默认) / `b64_json` 返 base64。", + "example": "url", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "url", + "b64_json" + ], + "default": "url", + "description": "`url` 返 CDN 链接(默认) / `b64_json` 返 base64。" + } + }, + { + "id": "param-moderation", + "name": "moderation", + "required": false, + "enable": true, + "description": "`auto` 严格 / `low` 宽松(默认)。", + "example": "low", + "type": "string", + "schema": { + "type": "string", + "enum": [ + "auto", + "low" + ], + "default": "low", + "description": "`auto` 严格 / `low` 宽松(默认)。" + } + }, + { + "id": "param-user", + "name": "user", + "required": false, + "enable": true, + "description": "终端用户 ID(透传给上游用于审计)。", + "example": "", + "type": "string", + "schema": { + "type": "string" + } + } + ], + "required": true, + "mediaType": "", + "examples": [], + "oasExtensions": "" + }, + "description": "编辑 / 重绘已有图像。\n\n- `image` 字段接受**文件 / URL / data:URI** 三种形式\n- 多图:`image` 重复 / `image[]` / `image[0]、image[1]...` 都支持,最多 16 张,单图 ≤ 25 MB(png/jpeg/webp)\n- 耗时 15-180s,建议 timeout ≥ 240s\n- 默认 `moderation: low`,触发审核会返回 HTTP 200 + `error` 信封\n", + "tags": [ + "图像(Images)/通用" + ], + "status": "released", + "serverId": "", + "operationId": "imageGenericEdit", + "sourceUrl": "", + "ordering": 12, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [ + { + "id": "sample-curl-single", + "name": "curl", + "language": "curl", + "code": "curl -X POST \"$BASE/v1/images/edits\" \\\n -H \"Authorization: Bearer $API_KEY\" \\\n -F \"model=gpt-image-2\" \\\n -F \"image=@photo.png\" \\\n -F \"prompt=add a watercolor border\"\n" + }, + { + "id": "sample-curl-multi", + "name": "curl · 多图", + "language": "curl", + "code": "curl -X POST \"$BASE/v1/images/edits\" \\\n -H \"Authorization: Bearer $API_KEY\" \\\n -F \"model=gpt-image-2\" \\\n -F \"image=@a.png\" \\\n -F \"image=@b.png\" \\\n -F \"prompt=blend these two images\"\n" + } + ], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "name": "编辑图像 / Edit Images" + } + } + ] + } + ] + }, + { + "name": "视频(Videos)", + "id": 84639672, + "auth": {}, + "securityScheme": {}, + "parentId": 0, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "可灵 Kling", + "id": 84639682, + "auth": {}, + "securityScheme": {}, + "parentId": 84639672, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "文生视频(创建任务)", + "api": { + "id": "452168133", + "method": "post", + "path": "/kling/v1/videos/text2video", + "parameters": { + "path": [], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "i04IhdPdhQZkXVJKhIv_U", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "i04IhdPdhQZkXVJKhIv_U", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "i04IhdPdhQZkXVJKhIv_U": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "144063543", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [] + }, + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [], + "examples": [ + { + "value": "{\n \"model_name\": \"kling-v1\",\n \"prompt\": \"A cat walking in the snow\",\n \"duration\": \"5\",\n \"aspect_ratio\": \"16:9\"\n}", + "mediaType": "application/json", + "description": "" + } + ] + }, + "description": "**真实请求路径:POST /kling/v1/videos/text2video**", + "tags": [ + "视频(Videos)/可灵 Kling" + ], + "status": "released", + "serverId": "", + "operationId": "videoKlingText2Video", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + }, + { + "name": "文生视频任务状态", + "api": { + "id": "452168134", + "method": "get", + "path": "/kling/v1/videos/text2video/{task_id}", + "parameters": { + "path": [ + { + "id": "task_id#0", + "name": "task_id", + "required": true, + "enable": true, + "description": "", + "example": "", + "type": "string", + "schema": { + "type": "string" + } + } + ], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "aIPdcKKuE0N99j50ucDds", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "aIPdcKKuE0N99j50ucDds", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "aIPdcKKuE0N99j50ucDds": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "187325236", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "none", + "parameters": [], + "required": false, + "additionalContentTypes": [], + "oasExtensions": "" + }, + "description": "**真实请求路径:GET /kling/v1/videos/text2video/{task_id}**", + "tags": [ + "视频(Videos)/可灵 Kling" + ], + "status": "released", + "serverId": "", + "operationId": "videoKlingText2VideoStatus", + "sourceUrl": "", + "ordering": 12, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + }, + { + "name": "图生视频(创建任务)", + "api": { + "id": "452168135", + "method": "post", + "path": "/kling/v1/videos/image2video", + "parameters": { + "path": [], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "GlwtqP-144y-xTPY_9qPD", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "GlwtqP-144y-xTPY_9qPD", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "GlwtqP-144y-xTPY_9qPD": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "127762316", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [] + }, + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [], + "examples": [ + { + "value": "{\n \"model_name\": \"kling-v1\",\n \"image\": \"https://example.com/cat.jpg\",\n \"prompt\": \"make it walk\"\n}", + "mediaType": "application/json", + "description": "" + } + ] + }, + "description": "**真实请求路径:POST /kling/v1/videos/image2video**", + "tags": [ + "视频(Videos)/可灵 Kling" + ], + "status": "released", + "serverId": "", + "operationId": "videoKlingImage2Video", + "sourceUrl": "", + "ordering": 18, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + }, + { + "name": "图生视频任务状态", + "api": { + "id": "452168136", + "method": "get", + "path": "/kling/v1/videos/image2video/{task_id}", + "parameters": { + "path": [ + { + "id": "task_id#0", + "name": "task_id", + "required": true, + "enable": true, + "description": "", + "example": "", + "type": "string", + "schema": { + "type": "string" + } + } + ], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "Pgt68vCzi-v3Y3JtL-U1t", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "Pgt68vCzi-v3Y3JtL-U1t", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "Pgt68vCzi-v3Y3JtL-U1t": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "106360787", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "none", + "parameters": [], + "required": false, + "additionalContentTypes": [], + "oasExtensions": "" + }, + "description": "**真实请求路径:GET /kling/v1/videos/image2video/{task_id}**", + "tags": [ + "视频(Videos)/可灵 Kling" + ], + "status": "released", + "serverId": "", + "operationId": "videoKlingImage2VideoStatus", + "sourceUrl": "", + "ordering": 24, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + } + ] + }, + { + "name": "即梦 + Seedance", + "id": 84639683, + "auth": {}, + "securityScheme": {}, + "parentId": 84639672, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "视频生成(即梦原生格式)", + "api": { + "id": "452168137", + "method": "post", + "path": "/jimeng/", + "parameters": { + "path": [], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "xIpXcILzO-eGwOLXsMyNL", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "xIpXcILzO-eGwOLXsMyNL", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "xIpXcILzO-eGwOLXsMyNL": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "184314805", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [] + }, + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [], + "examples": [ + { + "value": "{\n \"Action\": \"CVSync2AsyncSubmitTask\",\n \"Version\": \"2022-08-31\",\n \"req_key\": \"jimeng_vgfm_t2v_l20\",\n \"prompt\": \"a cat\"\n}", + "mediaType": "application/json", + "description": "" + } + ] + }, + "description": "**真实请求路径:POST /jimeng/**\n\n字节跳动即梦官方 API 透传,对应火山方舟 `CVSync2AsyncSubmitTask` / `CVSync2AsyncGetResult`。", + "tags": [ + "视频(Videos)/即梦 + Seedance" + ], + "status": "released", + "serverId": "", + "operationId": "videoJimengNative", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + }, + { + "name": "视频生成(统一接口)", + "api": { + "id": "452168138", + "method": "post", + "path": "/v1/video/generations", + "parameters": { + "path": [], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "6cc4_WW5qzL8djpXzWZ7s", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "6cc4_WW5qzL8djpXzWZ7s", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "6cc4_WW5qzL8djpXzWZ7s": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "198686995", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [] + }, + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [], + "examples": [ + { + "value": "{\n \"model\": \"seedance-1-pro\",\n \"prompt\": \"a cat in a garden\",\n \"duration\": 5\n}", + "mediaType": "application/json", + "description": "" + } + ] + }, + "description": "**真实请求路径:POST /v1/video/generations**\n\n字节 Seedance 通过统一异步任务接口调用,`model` 字段填 `\"seedance-1-pro\"` 等具体型号。", + "tags": [ + "视频(Videos)/即梦 + Seedance" + ], + "status": "released", + "serverId": "", + "operationId": "videoSeedance", + "sourceUrl": "", + "ordering": 12, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + } + ] + }, + { + "name": "海螺 Hailuo", + "id": 84639684, + "auth": {}, + "securityScheme": {}, + "parentId": 84639672, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "视频生成", + "api": { + "id": "452168139", + "method": "post", + "path": "/v1/video/generations", + "parameters": { + "path": [], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "Hxt4qds0dhqkrG6MLMaNX", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "Hxt4qds0dhqkrG6MLMaNX", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "Hxt4qds0dhqkrG6MLMaNX": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "121529115", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [] + }, + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [], + "examples": [ + { + "value": "{\n \"model\": \"MiniMax-Hailuo-2.3\",\n \"prompt\": \"a cat dancing\",\n \"duration\": 6\n}", + "mediaType": "application/json", + "description": "" + } + ] + }, + "description": "**真实请求路径:POST /v1/video/generations**\n\nMiniMax 海螺通过统一异步任务接口调用。`model` 字段可填:`\"MiniMax-Hailuo-2.3\"`、`\"MiniMax-Hailuo-2.3-Fast\"`、`\"MiniMax-Hailuo-02\"`。", + "tags": [ + "视频(Videos)/海螺 Hailuo" + ], + "status": "released", + "serverId": "", + "operationId": "videoHailuo", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + } + ] + }, + { + "name": "通用", + "id": 84639685, + "auth": {}, + "securityScheme": {}, + "parentId": 84639672, + "serverId": "", + "description": "", + "identityPattern": { + "httpApi": { + "type": "inherit", + "bodyType": "", + "fields": [] + } + }, + "shareSettings": {}, + "visibility": "INHERITED", + "moduleId": 7492826, + "preProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "postProcessors": [ + { + "id": "inheritProcessors", + "type": "inheritProcessors", + "data": {} + } + ], + "inheritPostProcessors": {}, + "inheritPreProcessors": {}, + "items": [ + { + "name": "创建视频生成任务", + "api": { + "id": "452168140", + "method": "post", + "path": "/v1/video/generations", + "parameters": { + "path": [], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "fwXwYV7-cmh10HHjfbXrr", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "fwXwYV7-cmh10HHjfbXrr", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "fwXwYV7-cmh10HHjfbXrr": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "104663893", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "application/json", + "parameters": [], + "jsonSchema": { + "type": "object", + "x-apifox-orders": [] + }, + "examples": [ + { + "name": "海螺 Hailuo", + "value": "{\n \"model\": \"MiniMax-Hailuo-2.3\",\n \"prompt\": \"一只猫在跳舞\",\n \"duration\": 6\n}", + "oasKey": "hailuo", + "oasExtensions": "", + "mediaType": "application/json" + }, + { + "name": "字节 Seedance", + "value": "{\n \"model\": \"seedance-1-pro\",\n \"prompt\": \"花园里的小猫\",\n \"duration\": 5\n}", + "oasKey": "seedance", + "oasExtensions": "", + "mediaType": "application/json" + } + ], + "mediaType": "application/json", + "oasExtensions": "", + "required": true, + "additionalContentTypes": [] + }, + "description": "统一异步视频入口。`model` 字段决定走哪个厂商适配器。", + "tags": [ + "视频(Videos)/通用" + ], + "status": "released", + "serverId": "", + "operationId": "videoUnifiedCreate", + "sourceUrl": "", + "ordering": 6, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + }, + { + "name": "获取视频生成任务状态", + "api": { + "id": "452168141", + "method": "get", + "path": "/v1/video/generations/{task_id}", + "parameters": { + "path": [ + { + "id": "task_id#0", + "name": "task_id", + "required": true, + "enable": true, + "description": "", + "example": "", + "type": "string", + "schema": { + "type": "string" + } + } + ], + "query": [], + "cookie": [], + "header": [] + }, + "auth": { + "type": "securityscheme" + }, + "securityScheme": { + "schemeGroups": [ + { + "id": "Y_9B8MDb1JkQF7yUiXFQV", + "schemeIds": [ + 1107990 + ] + } + ], + "required": true, + "use": { + "id": "Y_9B8MDb1JkQF7yUiXFQV", + "configs": { + "1107990": { + "authConfigs": { + "x-apifox": { + "token": "{{bearerToken}}" + } + } + } + } + }, + "scopes": { + "Y_9B8MDb1JkQF7yUiXFQV": { + "1107990": [] + } + } + }, + "commonParameters": {}, + "responses": [ + { + "id": "126255148", + "code": "200", + "headers": [], + "jsonSchema": { + "type": "object", + "properties": {} + }, + "itemSchema": {}, + "description": "成功", + "contentType": "noContent", + "oasExtensions": "" + } + ], + "responseExamples": [], + "requestBody": { + "type": "none", + "parameters": [], + "required": false, + "additionalContentTypes": [], + "oasExtensions": "" + }, + "description": "**真实请求路径:GET /v1/video/generations/{task_id}**", + "tags": [ + "视频(Videos)/通用" + ], + "status": "released", + "serverId": "", + "operationId": "videoUnifiedStatus", + "sourceUrl": "", + "ordering": 12, + "cases": [], + "mocks": [], + "customApiFields": "{}", + "advancedSettings": { + "disabledSystemHeaders": {} + }, + "mockScript": {}, + "codeSamples": [], + "commonResponseStatus": {}, + "responseChildren": [], + "visibility": "INHERITED", + "moduleId": 7492826, + "oasExtensions": "", + "type": "http", + "preProcessors": [], + "postProcessors": [], + "inheritPostProcessors": {}, + "inheritPreProcessors": {} + } + } + ] + } + ] + } + ] + } + ], + "socketCollection": [], + "docCollection": [], + "webSocketCollection": [], + "socketIOCollection": [], + "mcpClientCollection": [], + "responseCollection": [ + { + "_databaseId": 8870783, + "updatedAt": "2026-04-10T18:51:35.000Z", + "name": "根目录", + "type": "root", + "children": [], + "moduleId": 7492826, + "parentId": 0, + "id": 8870783, + "ordering": [], + "items": [] + } + ], + "schemaCollection": [ + { + "id": 19122385, + "name": "根目录", + "visibility": "SHARED", + "moduleId": 7492826, + "items": [ + { + "id": 19122420, + "name": "Schemas", + "visibility": "INTERNAL", + "moduleId": 7492826, + "items": [] + }, + { + "name": "User", + "displayName": "", + "id": "#/definitions/263079075", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "role": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "group": { + "type": "string" + }, + "quota": { + "type": "integer" + }, + "used_quota": { + "type": "integer" + }, + "request_count": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "id", + "username", + "display_name", + "role", + "status", + "email", + "group", + "quota", + "used_quota", + "request_count" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "Log", + "displayName": "", + "id": "#/definitions/263079076", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "type": { + "type": "integer" + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "id", + "user_id", + "type", + "content", + "created_at" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "Model", + "displayName": "", + "id": "#/definitions/263079077", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "模型 ID", + "examples": [ + "gpt-4" + ] + }, + "object": { + "type": "string", + "description": "对象类型", + "examples": [ + "model" + ] + }, + "created": { + "type": "integer", + "description": "创建时间戳" + }, + "owned_by": { + "type": "string", + "description": "模型所有者", + "examples": [ + "openai" + ] + } + }, + "x-apifox-orders": [ + "id", + "object", + "created", + "owned_by" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "Token", + "displayName": "", + "id": "#/definitions/263079078", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "key": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "expired_time": { + "type": "integer" + }, + "remain_quota": { + "type": "integer" + }, + "unlimited_quota": { + "type": "boolean" + } + }, + "x-apifox-orders": [ + "id", + "user_id", + "name", + "key", + "status", + "expired_time", + "remain_quota", + "unlimited_quota" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "Usage", + "displayName": "", + "id": "#/definitions/263079079", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "prompt_tokens": { + "type": "integer", + "description": "提示词 Token 数" + }, + "completion_tokens": { + "type": "integer", + "description": "补全 Token 数" + }, + "total_tokens": { + "type": "integer", + "description": "总 Token 数" + }, + "prompt_tokens_details": { + "type": "object", + "properties": { + "cached_tokens": { + "type": "integer" + }, + "text_tokens": { + "type": "integer" + }, + "audio_tokens": { + "type": "integer" + }, + "image_tokens": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "cached_tokens", + "text_tokens", + "audio_tokens", + "image_tokens" + ] + }, + "completion_tokens_details": { + "type": "object", + "properties": { + "text_tokens": { + "type": "integer" + }, + "audio_tokens": { + "type": "integer" + }, + "reasoning_tokens": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "text_tokens", + "audio_tokens", + "reasoning_tokens" + ] + } + }, + "x-apifox-orders": [ + "prompt_tokens", + "completion_tokens", + "total_tokens", + "prompt_tokens_details", + "completion_tokens_details" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "PageInfo", + "displayName": "", + "id": "#/definitions/263079080", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "items": { + "type": "array", + "items": {} + } + }, + "x-apifox-orders": [ + "page", + "page_size", + "total", + "items" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "Channel", + "displayName": "", + "id": "#/definitions/263079081", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "models": { + "type": "string" + }, + "groups": { + "type": "string" + }, + "priority": { + "type": "integer" + }, + "weight": { + "type": "integer" + }, + "base_url": { + "type": "string" + }, + "tag": { + "type": "string" + } + }, + "x-apifox-orders": [ + "id", + "name", + "type", + "status", + "models", + "groups", + "priority", + "weight", + "base_url", + "tag" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "Redemption", + "displayName": "", + "id": "#/definitions/263079082", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "key": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "quota": { + "type": "integer" + }, + "created_time": { + "type": "integer" + }, + "redeemed_time": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "id", + "name", + "key", + "status", + "quota", + "created_time", + "redeemed_time" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ApiResponse", + "displayName": "", + "id": "#/definitions/263079083", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "data": {} + }, + "x-apifox-orders": [ + "success", + "message", + "data" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ModelsResponse", + "displayName": "", + "id": "#/definitions/263079084", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "examples": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/263079077" + } + } + }, + "x-apifox-orders": [ + "object", + "data" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ErrorResponse", + "displayName": "", + "id": "#/definitions/263079085", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "错误信息" + }, + "type": { + "type": "string", + "description": "错误类型" + }, + "param": { + "type": [ + "string", + "null" + ], + "description": "相关参数" + }, + "code": { + "type": [ + "string", + "null" + ], + "description": "错误代码" + } + }, + "x-apifox-orders": [ + "message", + "type", + "param", + "code" + ] + } + }, + "x-apifox-orders": [ + "error" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "Message", + "displayName": "", + "id": "#/definitions/263079086", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "role", + "content" + ], + "properties": { + "role": { + "type": "string", + "enum": [ + "system", + "user", + "assistant", + "tool", + "developer" + ], + "description": "消息角色" + }, + "content": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/263079087" + } + } + ], + "description": "消息内容" + }, + "name": { + "type": "string", + "description": "发送者名称" + }, + "tool_calls": { + "type": "array", + "items": { + "$ref": "#/definitions/263079089" + } + }, + "tool_call_id": { + "type": "string", + "description": "工具调用 ID(用于 tool 角色消息)" + }, + "reasoning_content": { + "type": "string", + "description": "推理内容" + } + }, + "x-apifox-orders": [ + "role", + "content", + "name", + "tool_calls", + "tool_call_id", + "reasoning_content" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "MessageContent", + "displayName": "", + "id": "#/definitions/263079087", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "image_url", + "input_audio", + "file", + "video_url" + ] + }, + "text": { + "type": "string" + }, + "image_url": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "图片 URL 或 base64" + }, + "detail": { + "type": "string", + "enum": [ + "low", + "high", + "auto" + ] + } + }, + "x-apifox-orders": [ + "url", + "detail" + ] + }, + "input_audio": { + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "Base64 编码的音频数据" + }, + "format": { + "type": "string", + "enum": [ + "wav", + "mp3" + ] + } + }, + "x-apifox-orders": [ + "data", + "format" + ] + }, + "file": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "file_data": { + "type": "string" + }, + "file_id": { + "type": "string" + } + }, + "x-apifox-orders": [ + "filename", + "file_data", + "file_id" + ] + }, + "video_url": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "x-apifox-orders": [ + "url" + ] + } + }, + "x-apifox-orders": [ + "type", + "text", + "image_url", + "input_audio", + "file", + "video_url" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "Tool", + "displayName": "", + "id": "#/definitions/263079088", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "examples": [ + "function" + ] + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parameters": { + "type": "object", + "description": "JSON Schema 格式的参数定义", + "properties": {}, + "x-apifox-orders": [] + } + }, + "x-apifox-orders": [ + "name", + "description", + "parameters" + ] + } + }, + "x-apifox-orders": [ + "type", + "function" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ToolCall", + "displayName": "", + "id": "#/definitions/263079089", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "examples": [ + "function" + ] + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "arguments": { + "type": "string" + } + }, + "x-apifox-orders": [ + "name", + "arguments" + ] + } + }, + "x-apifox-orders": [ + "id", + "type", + "function" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "GeminiModelsResponse", + "displayName": "", + "id": "#/definitions/263079090", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "examples": [ + "models/gemini-pro" + ] + }, + "version": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "description": { + "type": "string" + }, + "inputTokenLimit": { + "type": "integer" + }, + "outputTokenLimit": { + "type": "integer" + }, + "supportedGenerationMethods": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "x-apifox-orders": [ + "name", + "version", + "displayName", + "description", + "inputTokenLimit", + "outputTokenLimit", + "supportedGenerationMethods" + ] + } + } + }, + "x-apifox-orders": [ + "models" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ChatCompletionResponse", + "displayName": "", + "id": "#/definitions/263079091", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "examples": [ + "chat.completion" + ] + }, + "created": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer" + }, + "message": { + "$ref": "#/definitions/263079086" + }, + "finish_reason": { + "type": "string", + "enum": [ + "stop", + "length", + "tool_calls", + "content_filter" + ] + } + }, + "x-apifox-orders": [ + "index", + "message", + "finish_reason" + ] + } + }, + "usage": { + "$ref": "#/definitions/263079079" + }, + "system_fingerprint": { + "type": "string" + } + }, + "x-apifox-orders": [ + "id", + "object", + "created", + "model", + "choices", + "usage", + "system_fingerprint" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ChatCompletionRequest", + "displayName": "", + "id": "#/definitions/263079092", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "model", + "messages" + ], + "properties": { + "model": { + "type": "string", + "description": "模型 ID", + "examples": [ + "gpt-4" + ] + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/263079086" + }, + "description": "对话消息列表" + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2, + "default": 1, + "description": "采样温度" + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 1, + "description": "核采样参数" + }, + "n": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "生成数量" + }, + "stream": { + "type": "boolean", + "default": false, + "description": "是否流式响应" + }, + "stream_options": { + "type": "object", + "properties": { + "include_usage": { + "type": "boolean" + } + }, + "x-apifox-orders": [ + "include_usage" + ] + }, + "stop": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "停止序列" + }, + "max_tokens": { + "type": "integer", + "description": "最大生成 Token 数" + }, + "max_completion_tokens": { + "type": "integer", + "description": "最大补全 Token 数" + }, + "presence_penalty": { + "type": "number", + "minimum": -2, + "maximum": 2, + "default": 0 + }, + "frequency_penalty": { + "type": "number", + "minimum": -2, + "maximum": 2, + "default": 0 + }, + "logit_bias": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "properties": {}, + "x-apifox-orders": [] + }, + "user": { + "type": "string" + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/definitions/263079088" + } + }, + "tool_choice": { + "oneOf": [ + { + "type": "string", + "enum": [ + "none", + "auto", + "required" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "x-apifox-orders": [ + "name" + ] + } + }, + "x-apifox-orders": [ + "type", + "function" + ] + } + ] + }, + "response_format": { + "$ref": "#/definitions/263079096" + }, + "seed": { + "type": "integer" + }, + "reasoning_effort": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "description": "推理强度 (用于支持推理的模型)" + }, + "modalities": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "text", + "audio" + ] + } + }, + "audio": { + "type": "object", + "properties": { + "voice": { + "type": "string" + }, + "format": { + "type": "string" + } + }, + "x-apifox-orders": [ + "voice", + "format" + ] + } + }, + "x-apifox-orders": [ + "model", + "messages", + "temperature", + "top_p", + "n", + "stream", + "stream_options", + "stop", + "max_tokens", + "max_completion_tokens", + "presence_penalty", + "frequency_penalty", + "logit_bias", + "user", + "tools", + "tool_choice", + "response_format", + "seed", + "reasoning_effort", + "modalities", + "audio" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ChatCompletionStreamResponse", + "displayName": "", + "id": "#/definitions/263079093", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "examples": [ + "chat.completion.chunk" + ] + }, + "created": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer" + }, + "delta": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "content": { + "type": "string" + }, + "reasoning_content": { + "type": "string" + }, + "tool_calls": { + "type": "array", + "items": { + "$ref": "#/definitions/263079089" + } + } + }, + "x-apifox-orders": [ + "role", + "content", + "reasoning_content", + "tool_calls" + ] + }, + "finish_reason": { + "type": [ + "string", + "null" + ] + } + }, + "x-apifox-orders": [ + "index", + "delta", + "finish_reason" + ] + } + }, + "usage": { + "$ref": "#/definitions/263079079" + } + }, + "x-apifox-orders": [ + "id", + "object", + "created", + "model", + "choices", + "usage" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "CompletionRequest", + "displayName": "", + "id": "#/definitions/263079094", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "model", + "prompt" + ], + "properties": { + "model": { + "type": "string" + }, + "prompt": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "max_tokens": { + "type": "integer" + }, + "temperature": { + "type": "number" + }, + "top_p": { + "type": "number" + }, + "n": { + "type": "integer" + }, + "stream": { + "type": "boolean" + }, + "stop": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "suffix": { + "type": "string" + }, + "echo": { + "type": "boolean" + } + }, + "x-apifox-orders": [ + "model", + "prompt", + "max_tokens", + "temperature", + "top_p", + "n", + "stream", + "stop", + "suffix", + "echo" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "CompletionResponse", + "displayName": "", + "id": "#/definitions/263079095", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "examples": [ + "text_completion" + ] + }, + "created": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "index": { + "type": "integer" + }, + "finish_reason": { + "type": "string" + } + }, + "x-apifox-orders": [ + "text", + "index", + "finish_reason" + ] + } + }, + "usage": { + "$ref": "#/definitions/263079079" + } + }, + "x-apifox-orders": [ + "id", + "object", + "created", + "model", + "choices", + "usage" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ResponseFormat", + "displayName": "", + "id": "#/definitions/263079096", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "json_object", + "json_schema" + ] + }, + "json_schema": { + "type": "object", + "description": "JSON Schema 定义", + "properties": {}, + "x-apifox-orders": [] + } + }, + "x-apifox-orders": [ + "type", + "json_schema" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ResponsesRequest", + "displayName": "", + "id": "#/definitions/263079097", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "model" + ], + "properties": { + "model": { + "type": "string" + }, + "input": { + "description": "输入内容,可以是字符串或消息数组", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + } + ] + }, + "instructions": { + "type": "string" + }, + "max_output_tokens": { + "type": "integer" + }, + "temperature": { + "type": "number" + }, + "top_p": { + "type": "number" + }, + "stream": { + "type": "boolean" + }, + "tools": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + }, + "tool_choice": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + ] + }, + "reasoning": { + "type": "object", + "properties": { + "effort": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "summary": { + "type": "string" + } + }, + "x-apifox-orders": [ + "effort", + "summary" + ] + }, + "previous_response_id": { + "type": "string" + }, + "truncation": { + "type": "string", + "enum": [ + "auto", + "disabled" + ] + } + }, + "x-apifox-orders": [ + "model", + "input", + "instructions", + "max_output_tokens", + "temperature", + "top_p", + "stream", + "tools", + "tool_choice", + "reasoning", + "previous_response_id", + "truncation" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ResponsesResponse", + "displayName": "", + "id": "#/definitions/263079098", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "examples": [ + "response" + ] + }, + "created_at": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "completed", + "failed", + "in_progress", + "incomplete" + ] + }, + "model": { + "type": "string" + }, + "output": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "role": { + "type": "string" + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "x-apifox-orders": [ + "type", + "text" + ] + } + } + }, + "x-apifox-orders": [ + "type", + "id", + "status", + "role", + "content" + ] + } + }, + "usage": { + "$ref": "#/definitions/263079079" + } + }, + "x-apifox-orders": [ + "id", + "object", + "created_at", + "status", + "model", + "output", + "usage" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ResponsesStreamResponse", + "displayName": "", + "id": "#/definitions/263079099", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "response": { + "$ref": "#/definitions/263079098" + }, + "delta": { + "type": "string" + }, + "item": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + }, + "x-apifox-orders": [ + "type", + "response", + "delta", + "item" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ClaudeRequest", + "displayName": "", + "id": "#/definitions/263079100", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "model", + "messages", + "max_tokens" + ], + "properties": { + "model": { + "type": "string", + "examples": [ + "claude-3-opus-20240229" + ] + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/263079101" + } + }, + "system": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + } + ] + }, + "max_tokens": { + "type": "integer", + "minimum": 1 + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "top_p": { + "type": "number" + }, + "top_k": { + "type": "integer" + }, + "stream": { + "type": "boolean" + }, + "stop_sequences": { + "type": "array", + "items": { + "type": "string" + } + }, + "tools": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "input_schema": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + }, + "x-apifox-orders": [ + "name", + "description", + "input_schema" + ] + } + }, + "tool_choice": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "auto", + "any", + "tool" + ] + }, + "name": { + "type": "string" + } + }, + "x-apifox-orders": [ + "type", + "name" + ] + } + ] + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budget_tokens": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "type", + "budget_tokens" + ] + }, + "metadata": { + "type": "object", + "properties": { + "user_id": { + "type": "string" + } + }, + "x-apifox-orders": [ + "user_id" + ] + } + }, + "x-apifox-orders": [ + "model", + "messages", + "system", + "max_tokens", + "temperature", + "top_p", + "top_k", + "stream", + "stop_sequences", + "tools", + "tool_choice", + "thinking", + "metadata" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ClaudeMessage", + "displayName": "", + "id": "#/definitions/263079101", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "role", + "content" + ], + "properties": { + "role": { + "type": "string", + "enum": [ + "user", + "assistant" + ] + }, + "content": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "image", + "tool_use", + "tool_result" + ] + }, + "text": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "base64", + "url" + ] + }, + "media_type": { + "type": "string" + }, + "data": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "x-apifox-orders": [ + "type", + "media_type", + "data", + "url" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "input": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + }, + "tool_use_id": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "x-apifox-orders": [ + "type", + "text", + "source", + "id", + "name", + "input", + "tool_use_id", + "content" + ] + } + } + ] + } + }, + "x-apifox-orders": [ + "role", + "content" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ClaudeResponse", + "displayName": "", + "id": "#/definitions/263079102", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "examples": [ + "message" + ] + }, + "role": { + "type": "string", + "examples": [ + "assistant" + ] + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "x-apifox-orders": [ + "type", + "text" + ] + } + }, + "model": { + "type": "string" + }, + "stop_reason": { + "type": "string", + "enum": [ + "end_turn", + "max_tokens", + "stop_sequence", + "tool_use" + ] + }, + "usage": { + "type": "object", + "properties": { + "input_tokens": { + "type": "integer" + }, + "output_tokens": { + "type": "integer" + }, + "cache_creation_input_tokens": { + "type": "integer" + }, + "cache_read_input_tokens": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "input_tokens", + "output_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens" + ] + } + }, + "x-apifox-orders": [ + "id", + "type", + "role", + "content", + "model", + "stop_reason", + "usage" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "EmbeddingRequest", + "displayName": "", + "id": "#/definitions/263079103", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "model", + "input" + ], + "properties": { + "model": { + "type": "string", + "examples": [ + "text-embedding-ada-002" + ] + }, + "input": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "要嵌入的文本" + }, + "encoding_format": { + "type": "string", + "enum": [ + "float", + "base64" + ], + "default": "float" + }, + "dimensions": { + "type": "integer", + "description": "输出向量维度" + } + }, + "x-apifox-orders": [ + "model", + "input", + "encoding_format", + "dimensions" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "EmbeddingResponse", + "displayName": "", + "id": "#/definitions/263079104", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "examples": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "object": { + "type": "string", + "examples": [ + "embedding" + ] + }, + "index": { + "type": "integer" + }, + "embedding": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "x-apifox-orders": [ + "object", + "index", + "embedding" + ] + } + }, + "model": { + "type": "string" + }, + "usage": { + "type": "object", + "properties": { + "prompt_tokens": { + "type": "integer" + }, + "total_tokens": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "prompt_tokens", + "total_tokens" + ] + } + }, + "x-apifox-orders": [ + "object", + "data", + "model", + "usage" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ImageGenerationRequest", + "displayName": "", + "id": "#/definitions/263079105", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "prompt" + ], + "properties": { + "model": { + "type": "string", + "examples": [ + "dall-e-3" + ] + }, + "prompt": { + "type": "string", + "description": "图像描述" + }, + "n": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 1 + }, + "size": { + "type": "string", + "enum": [ + "256x256", + "512x512", + "1024x1024", + "1792x1024", + "1024x1792" + ], + "default": "1024x1024" + }, + "quality": { + "type": "string", + "enum": [ + "standard", + "hd" + ], + "default": "standard" + }, + "style": { + "type": "string", + "enum": [ + "vivid", + "natural" + ], + "default": "vivid" + }, + "response_format": { + "type": "string", + "enum": [ + "url", + "b64_json" + ], + "default": "url" + }, + "user": { + "type": "string" + } + }, + "x-apifox-orders": [ + "model", + "prompt", + "n", + "size", + "quality", + "style", + "response_format", + "user" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ImageEditRequest", + "displayName": "", + "id": "#/definitions/263079106", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "image", + "prompt" + ], + "properties": { + "image": { + "type": "string", + "format": "binary" + }, + "mask": { + "type": "string", + "format": "binary" + }, + "prompt": { + "type": "string" + }, + "model": { + "type": "string" + }, + "n": { + "type": "integer" + }, + "size": { + "type": "string" + }, + "response_format": { + "type": "string" + } + }, + "x-apifox-orders": [ + "image", + "mask", + "prompt", + "model", + "n", + "size", + "response_format" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ImageResponse", + "displayName": "", + "id": "#/definitions/263079107", + "description": "图像生成 / 编辑响应。", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "created", + "data" + ], + "properties": { + "created": { + "type": "integer", + "description": "Unix 秒" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "CDN 链接" + }, + "b64_json": { + "type": "string", + "description": "base64(仅 response_format=b64_json)" + }, + "revised_prompt": { + "type": "string", + "description": "模型改写后的 prompt" + } + }, + "required": [ + "url" + ], + "x-apifox-orders": [ + "url", + "b64_json", + "revised_prompt" + ] + } + }, + "background": { + "type": "string", + "enum": [ + "auto", + "opaque" + ] + }, + "output_format": { + "type": "string", + "enum": [ + "png", + "jpeg", + "webp" + ] + }, + "quality": { + "type": "string", + "enum": [ + "auto", + "low", + "medium", + "high" + ] + }, + "size": { + "type": "string" + }, + "usage": { + "type": "object", + "properties": { + "input_tokens": { + "type": "integer" + }, + "output_tokens": { + "type": "integer" + }, + "total_tokens": { + "type": "integer" + } + } + }, + "model": { + "type": "string" + } + }, + "x-apifox-orders": [ + "created", + "data", + "background", + "output_format", + "quality", + "size", + "usage", + "model" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "AudioTranscriptionRequest", + "displayName": "", + "id": "#/definitions/263079108", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "file", + "model" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "音频文件" + }, + "model": { + "type": "string", + "examples": [ + "whisper-1" + ] + }, + "language": { + "type": "string", + "description": "ISO-639-1 语言代码" + }, + "prompt": { + "type": "string" + }, + "response_format": { + "type": "string", + "enum": [ + "json", + "text", + "srt", + "verbose_json", + "vtt" + ], + "default": "json" + }, + "temperature": { + "type": "number" + }, + "timestamp_granularities": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "word", + "segment" + ] + } + } + }, + "x-apifox-orders": [ + "file", + "model", + "language", + "prompt", + "response_format", + "temperature", + "timestamp_granularities" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "AudioTranslationRequest", + "displayName": "", + "id": "#/definitions/263079109", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "file", + "model" + ], + "properties": { + "file": { + "type": "string", + "format": "binary" + }, + "model": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "response_format": { + "type": "string" + }, + "temperature": { + "type": "number" + } + }, + "x-apifox-orders": [ + "file", + "model", + "prompt", + "response_format", + "temperature" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "AudioTranscriptionResponse", + "displayName": "", + "id": "#/definitions/263079110", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "x-apifox-orders": [ + "text" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "SpeechRequest", + "displayName": "", + "id": "#/definitions/263079111", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "model", + "input", + "voice" + ], + "properties": { + "model": { + "type": "string", + "examples": [ + "tts-1" + ] + }, + "input": { + "type": "string", + "description": "要转换的文本", + "maxLength": 4096 + }, + "voice": { + "type": "string", + "enum": [ + "alloy", + "echo", + "fable", + "onyx", + "nova", + "shimmer" + ] + }, + "response_format": { + "type": "string", + "enum": [ + "mp3", + "opus", + "aac", + "flac", + "wav", + "pcm" + ], + "default": "mp3" + }, + "speed": { + "type": "number", + "minimum": 0.25, + "maximum": 4, + "default": 1 + } + }, + "x-apifox-orders": [ + "model", + "input", + "voice", + "response_format", + "speed" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "RerankRequest", + "displayName": "", + "id": "#/definitions/263079112", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "model", + "query", + "documents" + ], + "properties": { + "model": { + "type": "string", + "examples": [ + "rerank-english-v2.0" + ] + }, + "query": { + "type": "string", + "description": "查询文本" + }, + "documents": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + ] + }, + "description": "要重排序的文档列表" + }, + "top_n": { + "type": "integer", + "description": "返回前 N 个结果" + }, + "return_documents": { + "type": "boolean", + "default": false + } + }, + "x-apifox-orders": [ + "model", + "query", + "documents", + "top_n", + "return_documents" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "RerankResponse", + "displayName": "", + "id": "#/definitions/263079113", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer" + }, + "relevance_score": { + "type": "number" + }, + "document": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + }, + "x-apifox-orders": [ + "index", + "relevance_score", + "document" + ] + } + }, + "meta": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + }, + "x-apifox-orders": [ + "id", + "results", + "meta" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "VideoRequest", + "displayName": "", + "id": "#/definitions/263079114", + "description": "视频生成请求", + "schema": { + "jsonSchema": { + "type": "object", + "description": "视频生成请求", + "properties": { + "model": { + "type": "string", + "description": "模型/风格 ID", + "examples": [ + "kling-v1" + ] + }, + "prompt": { + "type": "string", + "description": "文本描述提示词", + "examples": [ + "宇航员站起身走了" + ] + }, + "image": { + "type": "string", + "description": "图片输入 (URL 或 Base64)", + "examples": [ + "https://example.com/image.jpg" + ] + }, + "duration": { + "type": "number", + "description": "视频时长(秒)", + "examples": [ + 5 + ] + }, + "width": { + "type": "integer", + "description": "视频宽度", + "examples": [ + 1280 + ] + }, + "height": { + "type": "integer", + "description": "视频高度", + "examples": [ + 720 + ] + }, + "fps": { + "type": "integer", + "description": "视频帧率", + "examples": [ + 30 + ] + }, + "seed": { + "type": "integer", + "description": "随机种子", + "examples": [ + 20231234 + ] + }, + "n": { + "type": "integer", + "description": "生成视频数量", + "examples": [ + 1 + ] + }, + "response_format": { + "type": "string", + "description": "响应格式", + "examples": [ + "url" + ] + }, + "user": { + "type": "string", + "description": "用户标识", + "examples": [ + "user-1234" + ] + }, + "metadata": { + "type": "object", + "description": "扩展参数 (如 negative_prompt, style, quality_level 等)", + "additionalProperties": true, + "properties": {}, + "x-apifox-orders": [] + } + }, + "x-apifox-orders": [ + "model", + "prompt", + "image", + "duration", + "width", + "height", + "fps", + "seed", + "n", + "response_format", + "user", + "metadata" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ModerationRequest", + "displayName": "", + "id": "#/definitions/263079115", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "input" + ], + "properties": { + "input": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "model": { + "type": "string", + "examples": [ + "text-moderation-latest" + ] + } + }, + "x-apifox-orders": [ + "input", + "model" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "VideoResponse", + "displayName": "", + "id": "#/definitions/263079116", + "description": "视频生成任务提交响应", + "schema": { + "jsonSchema": { + "type": "object", + "description": "视频生成任务提交响应", + "properties": { + "task_id": { + "type": "string", + "description": "任务 ID", + "examples": [ + "abcd1234efgh" + ] + }, + "status": { + "type": "string", + "description": "任务状态", + "examples": [ + "queued" + ] + } + }, + "x-apifox-orders": [ + "task_id", + "status" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "ModerationResponse", + "displayName": "", + "id": "#/definitions/263079117", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "flagged": { + "type": "boolean" + }, + "categories": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + }, + "category_scores": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + }, + "x-apifox-orders": [ + "flagged", + "categories", + "category_scores" + ] + } + } + }, + "x-apifox-orders": [ + "id", + "model", + "results" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "VideoTaskResponse", + "displayName": "", + "id": "#/definitions/263079118", + "description": "视频任务状态查询响应", + "schema": { + "jsonSchema": { + "type": "object", + "description": "视频任务状态查询响应", + "properties": { + "task_id": { + "type": "string", + "description": "任务 ID", + "examples": [ + "abcd1234efgh" + ] + }, + "status": { + "type": "string", + "description": "任务状态", + "enum": [ + "queued", + "in_progress", + "completed", + "failed" + ], + "examples": [ + "completed" + ] + }, + "url": { + "type": "string", + "description": "视频资源 URL(成功时)", + "examples": [ + "https://example.com/video.mp4" + ] + }, + "format": { + "type": "string", + "description": "视频格式", + "examples": [ + "mp4" + ] + }, + "metadata": { + "$ref": "#/definitions/263079120" + }, + "error": { + "$ref": "#/definitions/263079121" + } + }, + "x-apifox-orders": [ + "task_id", + "status", + "url", + "format", + "metadata", + "error" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "GeminiRequest", + "displayName": "", + "id": "#/definitions/263079119", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "contents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "user", + "model" + ] + }, + "parts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "inlineData": { + "type": "object", + "properties": { + "mimeType": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "x-apifox-orders": [ + "mimeType", + "data" + ] + } + }, + "x-apifox-orders": [ + "text", + "inlineData" + ] + } + } + }, + "x-apifox-orders": [ + "role", + "parts" + ] + } + }, + "generationConfig": { + "type": "object", + "properties": { + "temperature": { + "type": "number" + }, + "topP": { + "type": "number" + }, + "topK": { + "type": "integer" + }, + "maxOutputTokens": { + "type": "integer" + }, + "stopSequences": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "x-apifox-orders": [ + "temperature", + "topP", + "topK", + "maxOutputTokens", + "stopSequences" + ] + }, + "safetySettings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "threshold": { + "type": "string" + } + }, + "x-apifox-orders": [ + "category", + "threshold" + ] + } + }, + "tools": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + }, + "systemInstruction": { + "type": "object", + "properties": { + "parts": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + } + }, + "x-apifox-orders": [ + "parts" + ] + } + }, + "x-apifox-orders": [ + "contents", + "generationConfig", + "safetySettings", + "tools", + "systemInstruction" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "VideoTaskMetadata", + "displayName": "", + "id": "#/definitions/263079120", + "description": "视频任务元数据", + "schema": { + "jsonSchema": { + "type": "object", + "description": "视频任务元数据", + "properties": { + "duration": { + "type": "number", + "description": "实际生成的视频时长", + "examples": [ + 5 + ] + }, + "fps": { + "type": "integer", + "description": "实际帧率", + "examples": [ + 30 + ] + }, + "width": { + "type": "integer", + "description": "实际宽度", + "examples": [ + 1280 + ] + }, + "height": { + "type": "integer", + "description": "实际高度", + "examples": [ + 720 + ] + }, + "seed": { + "type": "integer", + "description": "使用的随机种子", + "examples": [ + 20231234 + ] + } + }, + "x-apifox-orders": [ + "duration", + "fps", + "width", + "height", + "seed" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "VideoTaskError", + "displayName": "", + "id": "#/definitions/263079121", + "description": "视频任务错误信息", + "schema": { + "jsonSchema": { + "type": "object", + "description": "视频任务错误信息", + "properties": { + "code": { + "type": "integer", + "description": "错误码" + }, + "message": { + "type": "string", + "description": "错误信息" + } + }, + "x-apifox-orders": [ + "code", + "message" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "GeminiResponse", + "displayName": "", + "id": "#/definitions/263079122", + "description": "", + "schema": { + "jsonSchema": { + "type": "object", + "properties": { + "candidates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + } + }, + "x-apifox-orders": [ + "role", + "parts" + ] + }, + "finishReason": { + "type": "string" + }, + "safetyRatings": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "x-apifox-orders": [] + } + } + }, + "x-apifox-orders": [ + "content", + "finishReason", + "safetyRatings" + ] + } + }, + "usageMetadata": { + "type": "object", + "properties": { + "promptTokenCount": { + "type": "integer" + }, + "candidatesTokenCount": { + "type": "integer" + }, + "totalTokenCount": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "promptTokenCount", + "candidatesTokenCount", + "totalTokenCount" + ] + } + }, + "x-apifox-orders": [ + "candidates", + "usageMetadata" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "OpenAIVideo", + "displayName": "", + "id": "#/definitions/263079123", + "description": "OpenAI 兼容的视频对象", + "schema": { + "jsonSchema": { + "type": "object", + "description": "OpenAI 兼容的视频对象", + "properties": { + "id": { + "type": "string", + "description": "视频 ID", + "examples": [ + "video-abc123" + ] + }, + "task_id": { + "type": "string", + "description": "任务 ID (兼容旧接口)", + "deprecated": true + }, + "object": { + "type": "string", + "description": "对象类型", + "examples": [ + "video" + ] + }, + "model": { + "type": "string", + "description": "使用的模型", + "examples": [ + "sora" + ] + }, + "status": { + "type": "string", + "description": "任务状态", + "enum": [ + "queued", + "in_progress", + "completed", + "failed" + ], + "examples": [ + "completed" + ] + }, + "progress": { + "type": "integer", + "description": "进度百分比", + "examples": [ + 100 + ] + }, + "created_at": { + "type": "integer", + "description": "创建时间戳" + }, + "completed_at": { + "type": "integer", + "description": "完成时间戳" + }, + "expires_at": { + "type": "integer", + "description": "过期时间戳" + }, + "seconds": { + "type": "string", + "description": "视频时长" + }, + "size": { + "type": "string", + "description": "视频尺寸" + }, + "remixed_from_video_id": { + "type": "string", + "description": "源视频 ID(如果是基于其他视频生成)" + }, + "error": { + "$ref": "#/definitions/263079124" + }, + "metadata": { + "type": "object", + "description": "额外元数据", + "additionalProperties": true, + "properties": {}, + "x-apifox-orders": [] + } + }, + "x-apifox-orders": [ + "id", + "task_id", + "object", + "model", + "status", + "progress", + "created_at", + "completed_at", + "expires_at", + "seconds", + "size", + "remixed_from_video_id", + "error", + "metadata" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "OpenAIVideoError", + "displayName": "", + "id": "#/definitions/263079124", + "description": "OpenAI 视频错误信息", + "schema": { + "jsonSchema": { + "type": "object", + "description": "OpenAI 视频错误信息", + "properties": { + "message": { + "type": "string", + "description": "错误信息" + }, + "code": { + "type": "string", + "description": "错误码" + } + }, + "x-apifox-orders": [ + "message", + "code" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "Gpt5ChatRequest", + "displayName": "", + "id": "#/definitions/270308752", + "description": "gpt-5.x 聊天请求体(chat/completions 兼容入口)", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "model", + "messages" + ], + "description": "gpt-5.x 聊天请求体(chat/completions 兼容入口)", + "properties": { + "model": { + "type": "string", + "description": "模型名,如 gpt-5.5 / gpt-5.4", + "examples": [ + "gpt-5.5" + ] + }, + "messages": { + "type": "array", + "description": "对话上下文。content 可为 string 或多模态对象数组。", + "items": { + "$ref": "#/definitions/263079086" + } + }, + "stream": { + "type": "boolean", + "description": "是否流式(SSE)返回。默认 false。", + "default": false + }, + "stream_options": { + "type": "object", + "description": "仅当 stream=true 时生效", + "properties": { + "include_usage": { + "type": "boolean", + "description": "在最后一个 chunk 包含 token 用量" + } + }, + "x-apifox-orders": [ + "include_usage" + ] + }, + "max_tokens": { + "type": "integer", + "minimum": 1, + "description": "(旧)最大 token 数。gpt-5 系建议改用 max_completion_tokens。" + }, + "max_completion_tokens": { + "type": "integer", + "minimum": 1, + "description": "最大补全 token 数。reasoning 模型下含 reasoning tokens,建议 ≥200。长回复不严格截断。" + }, + "reasoning_effort": { + "type": "string", + "enum": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "description": "推理强度。none=禁用思考,xhigh=最强。high/xhigh 显著提升质量但延迟和 token 开销更高。" + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2, + "description": "采样温度,0=确定性输出(配合 seed 可复现),2=高随机。默认 1。" + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "核采样 top-p。和 temperature 二选一。" + }, + "frequency_penalty": { + "type": "number", + "minimum": -2, + "maximum": 2, + "description": "频率惩罚(-2 到 2)" + }, + "presence_penalty": { + "type": "number", + "minimum": -2, + "maximum": 2, + "description": "话题惩罚(-2 到 2)" + }, + "response_format": { + "type": "object", + "description": "结构化输出。chat/completions 入口仅支持 text/json_object;严格 schema 请用 /v1/responses。", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "json_object" + ] + } + }, + "x-apifox-orders": [ + "type" + ] + }, + "seed": { + "type": "integer", + "description": "采样种子。temperature=0 + 相同 seed 可复现相同输出。" + }, + "tools": { + "type": "array", + "description": "可调用的工具列表(function calling)", + "items": { + "type": "object", + "required": [ + "type", + "function" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "function" + ] + }, + "function": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parameters": { + "type": "object", + "x-apifox-orders": [] + } + }, + "x-apifox-orders": [ + "name", + "description", + "parameters" + ] + } + }, + "x-apifox-orders": [ + "type", + "function" + ] + } + }, + "tool_choice": { + "description": "auto / none / required / 或具体函数对象", + "oneOf": [ + { + "type": "string", + "enum": [ + "auto", + "none", + "required" + ] + }, + { + "type": "object", + "x-apifox-orders": [] + } + ] + }, + "parallel_tool_calls": { + "type": "boolean", + "description": "允许并行调用多个工具。设为 false 时单次最多 1 个 tool_call。默认 true。" + }, + "prompt_cache_key": { + "type": "string", + "description": "提示缓存键。prompt ≥ 1024 tokens 时第二次调用命中缓存,可显著降低成本。" + }, + "user": { + "type": "string", + "description": "终端用户 ID(passthrough,便于审计)" + }, + "safety_identifier": { + "type": "string", + "description": "安全审计标识(passthrough)" + }, + "store": { + "type": "boolean", + "description": "是否在上游存储该请求/响应(passthrough)" + }, + "metadata": { + "type": "object", + "description": "自定义键值对元数据(passthrough)", + "x-apifox-orders": [] + } + }, + "x-apifox-orders": [ + "model", + "messages", + "stream", + "stream_options", + "max_tokens", + "max_completion_tokens", + "reasoning_effort", + "temperature", + "top_p", + "frequency_penalty", + "presence_penalty", + "response_format", + "seed", + "tools", + "tool_choice", + "parallel_tool_calls", + "prompt_cache_key", + "user", + "safety_identifier", + "store", + "metadata" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "GptImage2Request", + "displayName": "", + "id": "#/definitions/270308753", + "description": "gpt-image-2 图像生成/编辑请求体", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "model", + "prompt" + ], + "description": "gpt-image-2 图像生成/编辑请求体", + "properties": { + "model": { + "type": "string", + "description": "模型名,固定 gpt-image-2", + "examples": [ + "gpt-image-2" + ] + }, + "prompt": { + "type": "string", + "description": "图像描述(必填)" + }, + "size": { + "type": "string", + "description": "图像尺寸。仅支持官方 15 个安全尺寸(1K/2K/4K × 5 个比例)+ auto。其他尺寸会被上游 reject('exceeds pixel budget')。", + "enum": [ + "1024x1024", + "1536x864", + "864x1536", + "1024x1360", + "1360x1024", + "1440x1440", + "2048x1152", + "1152x2048", + "1248x1664", + "1664x1248", + "2880x2880", + "3840x2160", + "2160x3840", + "2448x3264", + "3264x2448", + "auto" + ], + "examples": [ + "1024x1024" + ] + }, + "quality": { + "type": "string", + "description": "质量等级。auto 由模型自动选择。", + "enum": [ + "auto", + "low", + "medium", + "high" + ] + }, + "background": { + "type": "string", + "description": "背景。", + "enum": [ + "opaque", + "auto" + ] + }, + "moderation": { + "type": "string", + "description": "审核强度。", + "enum": [ + "low" + ] + }, + "output_format": { + "type": "string", + "description": "输出格式", + "enum": [ + "png", + "jpeg", + "webp" + ] + }, + "output_compression": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "JPEG/WebP 压缩等级(0-100)" + }, + "response_format": { + "type": "string", + "description": "响应格式。url 返回 CDN 链接,b64_json 返回 base64。", + "enum": [ + "url", + "b64_json" + ] + }, + "style": { + "type": "string", + "description": "风格(passthrough)", + "enum": [ + "vivid", + "natural" + ] + }, + "user": { + "type": "string", + "description": "终端用户 ID(passthrough)" + } + }, + "x-apifox-orders": [ + "model", + "prompt", + "size", + "quality", + "background", + "moderation", + "output_format", + "output_compression", + "response_format", + "style", + "user" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "Gpt5ResponsesRequest", + "displayName": "", + "id": "#/definitions/270308754", + "description": "gpt-5.x Responses API 请求体(主路径)", + "schema": { + "jsonSchema": { + "type": "object", + "required": [ + "model", + "input" + ], + "description": "gpt-5.x Responses API 请求体(主路径)", + "properties": { + "model": { + "type": "string", + "description": "模型名,如 gpt-5.5 / gpt-5.4", + "examples": [ + "gpt-5.5" + ] + }, + "input": { + "description": "输入内容。可为 string 或 input items 数组。", + "oneOf": [ + { + "type": "string", + "examples": [ + "Say hello" + ] + }, + { + "type": "array", + "items": { + "type": "object", + "required": [ + "role", + "content" + ], + "properties": { + "role": { + "type": "string", + "enum": [ + "user", + "assistant", + "developer", + "system" + ] + }, + "content": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "input_text" + ] + }, + "text": { + "type": "string" + } + }, + "x-apifox-orders": [ + "type", + "text" + ] + } + } + ] + } + }, + "x-apifox-orders": [ + "role", + "content" + ] + } + } + ] + }, + "instructions": { + "type": "string", + "description": "系统指令(system prompt 等价物)" + }, + "stream": { + "type": "boolean", + "description": "流式 SSE 返回。默认 false。", + "default": false + }, + "reasoning": { + "type": "object", + "description": "推理控制", + "properties": { + "effort": { + "type": "string", + "enum": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "description": "推理强度" + }, + "summary": { + "type": "string", + "enum": [ + "null", + "concise", + "detailed" + ], + "description": "推理摘要详细度" + } + }, + "x-apifox-orders": [ + "effort", + "summary" + ] + }, + "text": { + "type": "object", + "description": "文本输出控制 — verbosity 在这里!", + "properties": { + "format": { + "description": "结构化输出格式", + "oneOf": [ + { + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + } + }, + "x-apifox-orders": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "type": "string", + "enum": [ + "json_object" + ] + } + }, + "x-apifox-orders": [ + "type" + ], + "type": "object" + }, + { + "required": [ + "type", + "name", + "schema" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "json_schema" + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "schema": { + "type": "object", + "x-apifox-orders": [] + }, + "strict": { + "type": "boolean" + } + }, + "x-apifox-orders": [ + "type", + "name", + "description", + "schema", + "strict" + ], + "type": "object" + } + ], + "x-apifox-orders": [] + }, + "verbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "description": "回复详尽度(low vs high 长度差异显著)" + } + }, + "x-apifox-orders": [ + "format", + "verbosity" + ] + }, + "tools": { + "type": "array", + "description": "工具列表。支持 function 和 web_search。", + "items": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "function" + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parameters": { + "type": "object", + "x-apifox-orders": [] + }, + "strict": { + "type": "boolean" + } + }, + "x-apifox-orders": [ + "type", + "name", + "description", + "parameters", + "strict" + ] + }, + { + "type": "object", + "required": [ + "type" + ], + "description": "联网搜索工具(响应 tool_usage.web_search.num_requests 显示触发次数)", + "properties": { + "type": { + "type": "string", + "enum": [ + "web_search" + ] + } + }, + "x-apifox-orders": [ + "type" + ] + } + ] + } + }, + "tool_choice": { + "description": "auto / none / required / 或具体函数对象", + "oneOf": [ + { + "type": "string", + "enum": [ + "auto", + "none", + "required" + ] + }, + { + "type": "object", + "x-apifox-orders": [] + } + ] + }, + "parallel_tool_calls": { + "type": "boolean", + "description": "允许并行工具调用。false 时单次最多 1 个。" + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2, + "description": "采样温度。Responses API 路径无 seed,temperature=0 不保证确定性。" + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "核采样 top-p" + }, + "frequency_penalty": { + "type": "number", + "minimum": -2, + "maximum": 2 + }, + "presence_penalty": { + "type": "number", + "minimum": -2, + "maximum": 2 + }, + "prompt_cache_key": { + "type": "string", + "description": "提示缓存键。prompt ≥ 1024 tokens 时第二次调用 cached_tokens 显著上涨。" + }, + "safety_identifier": { + "type": "string", + "description": "安全审计标识(passthrough)" + }, + "store": { + "type": "boolean", + "description": "上游是否存储(passthrough)" + }, + "service_tier": { + "type": "string", + "enum": [ + "auto", + "default", + "flex" + ], + "description": "服务层级(passthrough)" + } + }, + "x-apifox-orders": [ + "model", + "input", + "instructions", + "stream", + "reasoning", + "text", + "tools", + "tool_choice", + "parallel_tool_calls", + "temperature", + "top_p", + "frequency_penalty", + "presence_penalty", + "prompt_cache_key", + "safety_identifier", + "store", + "service_tier" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + }, + { + "name": "Gpt5ResponsesResponse", + "displayName": "", + "id": "#/definitions/270308755", + "description": "/v1/responses 响应体", + "schema": { + "jsonSchema": { + "type": "object", + "description": "/v1/responses 响应体", + "properties": { + "id": { + "type": "string", + "examples": [ + "resp_0bb166b0ad54c7c50169f436031cbc8196b157b0eeb60d8eed" + ] + }, + "object": { + "type": "string", + "enum": [ + "response" + ] + }, + "status": { + "type": "string", + "enum": [ + "completed", + "in_progress", + "failed", + "incomplete" + ] + }, + "created_at": { + "type": "integer" + }, + "completed_at": { + "type": [ + "integer", + "null" + ] + }, + "model": { + "type": "string" + }, + "output": { + "type": "array", + "description": "输出事件序列。可能包含 message / function_call / web_search_call 等", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "message", + "function_call", + "web_search_call", + "reasoning" + ] + }, + "role": { + "type": "string" + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "output_text" + ] + }, + "text": { + "type": "string" + } + }, + "x-apifox-orders": [ + "type", + "text" + ] + } + }, + "name": { + "type": "string" + }, + "arguments": { + "type": "string" + } + }, + "x-apifox-orders": [ + "type", + "role", + "content", + "name", + "arguments" + ] + } + }, + "reasoning": { + "type": "object", + "properties": { + "effort": { + "type": "string" + }, + "summary": { + "type": [ + "string", + "null" + ] + } + }, + "x-apifox-orders": [ + "effort", + "summary" + ] + }, + "text": { + "type": "object", + "properties": { + "format": { + "type": "object", + "x-apifox-orders": [] + }, + "verbosity": { + "type": "string" + } + }, + "x-apifox-orders": [ + "format", + "verbosity" + ] + }, + "tool_usage": { + "type": "object", + "description": "工具用量。web_search.num_requests > 0 表示真触发了联网搜索", + "properties": { + "web_search": { + "type": "object", + "properties": { + "num_requests": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "num_requests" + ] + }, + "image_gen": { + "type": "object", + "properties": { + "input_tokens": { + "type": "integer" + }, + "output_tokens": { + "type": "integer" + }, + "total_tokens": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "input_tokens", + "output_tokens", + "total_tokens" + ] + } + }, + "x-apifox-orders": [ + "web_search", + "image_gen" + ] + }, + "usage": { + "type": "object", + "properties": { + "input_tokens": { + "type": "integer" + }, + "output_tokens": { + "type": "integer" + }, + "output_tokens_details": { + "type": "object", + "properties": { + "reasoning_tokens": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "reasoning_tokens" + ] + }, + "total_tokens": { + "type": "integer" + } + }, + "x-apifox-orders": [ + "input_tokens", + "output_tokens", + "output_tokens_details", + "total_tokens" + ] + } + }, + "x-apifox-orders": [ + "id", + "object", + "status", + "created_at", + "completed_at", + "model", + "output", + "reasoning", + "text", + "tool_usage", + "usage" + ] + } + }, + "visibility": "INHERITED", + "moduleId": 7492826 + } + ] + } + ], + "securitySchemeCollection": [ + { + "id": 3945863, + "moduleId": 7492826, + "name": "根目录", + "items": [ + { + "id": 1107512, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107513, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107514, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107515, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107516, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107517, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107518, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107519, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107520, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107521, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107522, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107523, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107524, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107525, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107526, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107527, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107528, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107529, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107530, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107531, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107532, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107533, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107534, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107535, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107536, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107537, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107538, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107539, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107540, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107541, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107542, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107543, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107544, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107545, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107546, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107547, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107548, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107549, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107550, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107551, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107552, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107553, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107554, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107555, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107556, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107557, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107558, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107559, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107560, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107561, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107562, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107563, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107564, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107565, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107566, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107567, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107568, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107569, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107570, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107571, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107572, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107573, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107574, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107575, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107576, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107577, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107578, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107579, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107580, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107581, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107582, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107583, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107584, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107585, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107586, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107587, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107588, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107589, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107590, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107591, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107592, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107593, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107594, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107595, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107596, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107597, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107598, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107599, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107600, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107601, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107602, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107603, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107604, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107605, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107606, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107607, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107608, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107609, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107610, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107611, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107612, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107613, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107614, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107615, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107616, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107617, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107618, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107619, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107620, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107621, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107622, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107623, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107624, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107625, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107626, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107627, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107628, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107629, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107630, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107631, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107632, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107633, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107634, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107635, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107636, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107637, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107638, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107639, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107640, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107641, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107642, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107643, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107644, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107645, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107646, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107647, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107648, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107649, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107650, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107651, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107652, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107653, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107654, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107655, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107656, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107657, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107658, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107659, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107660, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107661, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107662, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107663, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107664, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107665, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107666, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107667, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107668, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107669, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107670, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107671, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107672, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107673, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107674, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107675, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107676, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107677, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107678, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107679, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107680, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107681, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107682, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107683, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107684, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107685, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107686, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107687, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107688, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107689, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107690, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107691, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107692, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107693, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107694, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107695, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107696, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107697, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107698, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107699, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107700, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107701, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107702, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107703, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107704, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107705, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107706, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107707, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107708, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107709, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107710, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107711, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107712, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107713, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107714, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107715, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107716, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107717, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107718, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107719, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107720, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107721, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107722, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107723, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107724, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107725, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107726, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107727, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107728, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107729, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107730, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107731, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107732, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107733, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107734, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107735, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107736, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107737, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107738, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107739, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107740, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107741, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107742, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107743, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107744, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107745, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107746, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107747, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "" + }, + { + "id": "" + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107748, + "name": "Combination", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "", + "authConfigs": { + "group": [ + { + "id": 1107991 + }, + { + "id": 1107993 + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107749, + "name": "Combination1", + "moduleId": 7492826, + "authType": "combination", + "oasAuthType": "", + "authConfigs": { + "group": [ + { + "id": 1107992 + }, + { + "id": 1107993 + } + ] + }, + "guidanceConfigs": {} + }, + { + "id": 1107750, + "name": "Combination2", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107751, + "name": "Combination11", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107752, + "name": "Combination3", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107753, + "name": "Combination12", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107754, + "name": "Combination4", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107755, + "name": "Combination13", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107756, + "name": "Combination5", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107757, + "name": "Combination14", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107758, + "name": "Combination6", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107759, + "name": "Combination15", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107760, + "name": "Combination7", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107761, + "name": "Combination16", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107762, + "name": "Combination8", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107763, + "name": "Combination17", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107764, + "name": "Combination9", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107765, + "name": "Combination18", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107766, + "name": "Combination10", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107767, + "name": "Combination19", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107768, + "name": "Combination20", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107769, + "name": "Combination110", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107770, + "name": "Combination21", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107771, + "name": "Combination111", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107772, + "name": "Combination22", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107773, + "name": "Combination112", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107774, + "name": "Combination23", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107775, + "name": "Combination113", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107776, + "name": "Combination24", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107777, + "name": "Combination114", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107778, + "name": "Combination25", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107779, + "name": "Combination115", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107780, + "name": "Combination26", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107781, + "name": "Combination116", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107782, + "name": "Combination27", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107783, + "name": "Combination117", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107784, + "name": "Combination28", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107785, + "name": "Combination118", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107786, + "name": "Combination29", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107787, + "name": "Combination119", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107788, + "name": "Combination30", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107789, + "name": "Combination120", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107790, + "name": "Combination31", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107791, + "name": "Combination121", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107792, + "name": "Combination32", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107793, + "name": "Combination122", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107794, + "name": "Combination33", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107795, + "name": "Combination123", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107796, + "name": "Combination34", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107797, + "name": "Combination124", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107798, + "name": "Combination35", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107799, + "name": "Combination125", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107800, + "name": "Combination36", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107801, + "name": "Combination126", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107802, + "name": "Combination37", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107803, + "name": "Combination127", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107804, + "name": "Combination38", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107805, + "name": "Combination128", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107806, + "name": "Combination39", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107807, + "name": "Combination129", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107808, + "name": "Combination40", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107809, + "name": "Combination130", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107810, + "name": "Combination41", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107811, + "name": "Combination131", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107812, + "name": "Combination42", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107813, + "name": "Combination132", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107814, + "name": "Combination43", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107815, + "name": "Combination133", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107816, + "name": "Combination44", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107817, + "name": "Combination134", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107818, + "name": "Combination45", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107819, + "name": "Combination135", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107820, + "name": "Combination46", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107821, + "name": "Combination136", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107822, + "name": "Combination47", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107823, + "name": "Combination137", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107824, + "name": "Combination48", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107825, + "name": "Combination138", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107826, + "name": "Combination49", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107827, + "name": "Combination139", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107828, + "name": "Combination50", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107829, + "name": "Combination140", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107830, + "name": "Combination51", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107831, + "name": "Combination141", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107832, + "name": "Combination52", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107833, + "name": "Combination142", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107834, + "name": "Combination53", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107835, + "name": "Combination143", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107836, + "name": "Combination54", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107837, + "name": "Combination144", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107838, + "name": "Combination55", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107839, + "name": "Combination145", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107840, + "name": "Combination56", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107841, + "name": "Combination146", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107842, + "name": "Combination57", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107843, + "name": "Combination147", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107844, + "name": "Combination58", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107845, + "name": "Combination148", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107846, + "name": "Combination59", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107847, + "name": "Combination149", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107848, + "name": "Combination60", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107849, + "name": "Combination150", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107850, + "name": "Combination61", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107851, + "name": "Combination151", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107852, + "name": "Combination62", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107853, + "name": "Combination152", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107854, + "name": "Combination63", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107855, + "name": "Combination153", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107856, + "name": "Combination64", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107857, + "name": "Combination154", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107858, + "name": "Combination65", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107859, + "name": "Combination155", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107860, + "name": "Combination66", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107861, + "name": "Combination156", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107862, + "name": "Combination67", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107863, + "name": "Combination157", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107864, + "name": "Combination68", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107865, + "name": "Combination158", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107866, + "name": "Combination69", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107867, + "name": "Combination159", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107868, + "name": "Combination70", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107869, + "name": "Combination160", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107870, + "name": "Combination71", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107871, + "name": "Combination161", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107872, + "name": "Combination72", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107873, + "name": "Combination162", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107874, + "name": "Combination73", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107875, + "name": "Combination163", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107876, + "name": "Combination74", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107877, + "name": "Combination164", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107878, + "name": "Combination75", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107879, + "name": "Combination165", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107880, + "name": "Combination76", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107881, + "name": "Combination166", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107882, + "name": "Combination77", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107883, + "name": "Combination167", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107884, + "name": "Combination78", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107885, + "name": "Combination168", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107886, + "name": "Combination79", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107887, + "name": "Combination169", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107888, + "name": "Combination80", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107889, + "name": "Combination170", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107890, + "name": "Combination81", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107891, + "name": "Combination171", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107892, + "name": "Combination82", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107893, + "name": "Combination172", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107894, + "name": "Combination83", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107895, + "name": "Combination173", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107896, + "name": "Combination84", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107897, + "name": "Combination174", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107898, + "name": "Combination85", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107899, + "name": "Combination175", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107900, + "name": "Combination86", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107901, + "name": "Combination176", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107902, + "name": "Combination87", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107903, + "name": "Combination177", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107904, + "name": "Combination88", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107905, + "name": "Combination178", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107906, + "name": "Combination89", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107907, + "name": "Combination179", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107908, + "name": "Combination90", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107909, + "name": "Combination180", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107910, + "name": "Combination91", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107911, + "name": "Combination181", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107912, + "name": "Combination92", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107913, + "name": "Combination182", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107914, + "name": "Combination93", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107915, + "name": "Combination183", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107916, + "name": "Combination94", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107917, + "name": "Combination184", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107918, + "name": "Combination95", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107919, + "name": "Combination185", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107920, + "name": "Combination96", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107921, + "name": "Combination186", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107922, + "name": "Combination97", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107923, + "name": "Combination187", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107924, + "name": "Combination98", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107925, + "name": "Combination188", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107926, + "name": "Combination99", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107927, + "name": "Combination189", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107928, + "name": "Combination100", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107929, + "name": "Combination190", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107930, + "name": "Combination101", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107931, + "name": "Combination191", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107932, + "name": "Combination102", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107933, + "name": "Combination192", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107934, + "name": "Combination103", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107935, + "name": "Combination193", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107936, + "name": "Combination104", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107937, + "name": "Combination194", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107938, + "name": "Combination105", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107939, + "name": "Combination195", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107940, + "name": "Combination106", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107941, + "name": "Combination196", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107942, + "name": "Combination107", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107943, + "name": "Combination197", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107944, + "name": "Combination108", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107945, + "name": "Combination198", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107946, + "name": "Combination109", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107947, + "name": "Combination199", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107948, + "name": "Combination200", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107949, + "name": "Combination1100", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107950, + "name": "Combination201", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107951, + "name": "Combination1101", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107952, + "name": "Combination202", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107953, + "name": "Combination1102", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107954, + "name": "Combination203", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107955, + "name": "Combination1103", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107956, + "name": "Combination204", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107957, + "name": "Combination1104", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107958, + "name": "Combination205", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107959, + "name": "Combination1105", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107960, + "name": "Combination206", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107961, + "name": "Combination1106", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107962, + "name": "Combination207", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107963, + "name": "Combination1107", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107964, + "name": "Combination208", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107965, + "name": "Combination1108", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107966, + "name": "Combination209", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107967, + "name": "Combination1109", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107968, + "name": "Combination210", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107969, + "name": "Combination1110", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107970, + "name": "Combination211", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107971, + "name": "Combination1111", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107972, + "name": "Combination212", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107973, + "name": "Combination1112", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107974, + "name": "Combination213", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107975, + "name": "Combination1113", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107976, + "name": "Combination214", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107977, + "name": "Combination1114", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107978, + "name": "Combination215", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107979, + "name": "Combination1115", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107980, + "name": "Combination216", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107981, + "name": "Combination1116", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107982, + "name": "Combination217", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107983, + "name": "Combination1117", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107984, + "name": "Combination218", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107985, + "name": "Combination1118", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107986, + "name": "Combination219", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107987, + "name": "Combination1119", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107988, + "name": "Combination220", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107989, + "name": "Combination1120", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107991, + "name": "SessionAuth", + "moduleId": 7492826, + "authType": "apikey", + "oasAuthType": "apiKey", + "authConfigs": { + "type": "apiKey", + "in": "cookie", + "name": "session", + "description": "Session认证,通过登录接口获取" + }, + "guidanceConfigs": {} + }, + { + "id": 1107992, + "name": "AccessToken", + "moduleId": 7492826, + "authType": "apikey", + "oasAuthType": "apiKey", + "authConfigs": { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "Access Token认证,格式: Bearer {access_token},通过 /api/user/token 接口生成" + }, + "guidanceConfigs": {} + }, + { + "id": 1107993, + "name": "NewApiUser", + "moduleId": 7492826, + "authType": "apikey", + "oasAuthType": "apiKey", + "authConfigs": { + "type": "apiKey", + "in": "header", + "name": "New-Api-User", + "description": "用户ID请求头,必须与当前登录用户ID匹配,使用Session或AccessToken认证时必须提供" + }, + "guidanceConfigs": {} + }, + { + "id": 1107994, + "name": "Combination", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107995, + "name": "Combination1", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107996, + "name": "Combination", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107997, + "name": "Combination1", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107998, + "name": "Bearer", + "moduleId": 7492826, + "authType": "bearer", + "oasAuthType": "http", + "authConfigs": { + "type": "http", + "scheme": "bearer" + }, + "guidanceConfigs": {} + }, + { + "id": 1107999, + "name": "Combination", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "SessionAuth" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1108000, + "name": "Combination1", + "moduleId": 7492826, + "authType": "customize", + "oasAuthType": "combination", + "authConfigs": { + "group": [ + { + "id": "AccessToken" + }, + { + "id": "NewApiUser" + } + ], + "type": "combination" + }, + "guidanceConfigs": {} + }, + { + "id": 1107990, + "name": "BearerAuth", + "moduleId": 7492826, + "authType": "bearer", + "oasAuthType": "http", + "authConfigs": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "API Key", + "description": "在 Authorization 头部以 `Bearer sk-xxx` 形式传递 new-api 的访问令牌。" + }, + "guidanceConfigs": {} + } + ] + } + ], + "oasComponentCollection": [], + "requestCollection": [ + { + "name": "根目录", + "children": [], + "ordering": [ + "requestFolder.8707937" + ], + "items": [] + } + ], + "apiTestCaseCollection": [ + { + "id": 0, + "name": "Root", + "children": [], + "items": [] + } + ], + "testCaseReferences": [], + "environments": [], + "commonScripts": [], + "databaseConnections": [], + "globalVariables": [], + "commonParameters": null, + "customFunctions": [], + "projectTestCaseCategories": [ + { + "id": 8863091, + "name": "正向", + "description": "有效的输入和正确的预期结果,代表典型的用法。" + }, + { + "id": 8863092, + "name": "负向", + "description": "消极的、无效的输入,如缺少字段、错误的类型、不支持的方法等。" + }, + { + "id": 8863093, + "name": "边界值", + "description": "极端或边缘条件,如最大字符串长度、空值、零、null等" + }, + { + "id": 8863094, + "name": "安全性", + "description": "可能暴露API安全性风险的测试用例。" + }, + { + "id": 8863095, + "name": "其他", + "description": "其他未归属的测试用例。" + } + ], + "projectTestCaseTags": [ + { + "id": 38609440, + "name": "仅传必要字段", + "description": "仅发送具有有效值的必填字段,省略所有可选字段,以测试最小有效请求。" + }, + { + "id": 38609441, + "name": "语义合法", + "description": "使用语法正确且在业务逻辑或域上下文中也有意义的值。" + }, + { + "id": 38609442, + "name": "覆盖枚举组合", + "description": "对所有枚举型参数,应用正交设计系统地构建参数及其枚举值的测试用例组合,专用于测试枚举覆盖率" + }, + { + "id": 38609443, + "name": "其他正向", + "description": "提供符合API规范的真实有效的输入,产生正确和预期的结果。" + }, + { + "id": 38609444, + "name": "缺失必填字段", + "description": "在请求主体或参数中省略一个或多个必需字段。" + }, + { + "id": 38609445, + "name": "无效值", + "description": "提供的值类型和格式正确,但在逻辑上对该字段无效。" + }, + { + "id": 38609446, + "name": "类型错误", + "description": "使用数据类型不正确的值,例如发送字符串而不是整数。" + }, + { + "id": 38609447, + "name": "格式错误", + "description": "以不正确的格式提供值,例如,错误的日期模式,格式错误的电子邮件地址。" + }, + { + "id": 38609448, + "name": "语义非法", + "description": "使用语法正确且格式正确的值,这些值在逻辑上不可能或违反域规则。" + }, + { + "id": 38609449, + "name": "其他负向", + "description": "提供无效或意外的输入,应触发错误处理或拒绝API。" + }, + { + "id": 38609450, + "name": "Null", + "description": "必须是 JSON 字面量 `null`(不是空的,也不是 \"null\")。" + }, + { + "id": 38609451, + "name": "零值", + "description": "必须是数字字面值0。" + }, + { + "id": 38609452, + "name": "空值", + "description": "必须是\"\"。" + }, + { + "id": 38609453, + "name": "极大值", + "description": "Max/min值 - 为数字或日期字段提供精确的最小值和最大值。" + }, + { + "id": 38609454, + "name": "极小值", + "description": "Max/min值 - 为数字或日期字段提供精确的最小值和最大值。" + }, + { + "id": 38609455, + "name": "超出最大边界值", + "description": "过长/过短字符串 - 提供超出允许的最小或最大边界的值。" + }, + { + "id": 38609456, + "name": "超出最小边界值", + "description": "过长/过短字符串 - 提供超出允许的最小或最大边界的值。" + }, + { + "id": 38609457, + "name": "字符串过长", + "description": "超出范围的值 - 提供大于允许的最大长度或小于要求的最小长度的字符串。" + }, + { + "id": 38609458, + "name": "字符串过短", + "description": "超出范围的值 - 提供大于允许的最大长度或小于要求的最小长度的字符串。" + }, + { + "id": 38609459, + "name": "对象级别授权缺失", + "description": "对象级别授权缺失 - 用户在没有适当授权的情况下访问或操作数据对象。" + }, + { + "id": 38609460, + "name": "访问控制", + "description": "基于角色、权限或属性来限制用户能访问哪些资源、能执行哪些操作" + }, + { + "id": 38609461, + "name": "认证失败", + "description": "认证失败 - 认证薄弱、配置错误或被绕过,导致未授权访问。" + }, + { + "id": 38609462, + "name": "SQL注入", + "description": "在输入字段中插入恶意SQL代码,测试是否存在SQL注入漏洞。" + }, + { + "id": 38609463, + "name": "XSS注入", + "description": "注入JavaScript或HTML标签来测试跨站点脚本漏洞。" + }, + { + "id": 38609464, + "name": "模糊输入", + "description": "发送大型或随机生成的有效载荷,以测试输入的有效性和稳定性。" + }, + { + "id": 38609465, + "name": "命令行注入", + "description": "将shell命令插入输入以测试命令执行漏洞。" + }, + { + "id": 38609466, + "name": "JSON注入", + "description": "插入恶意或意外的JSON结构来测试解析和验证。" + }, + { + "id": 38609467, + "name": "NoSQL注入", + "description": "提供NoSQL特定的恶意查询或操作符来测试注入漏洞。" + }, + { + "id": 38609468, + "name": "其他", + "description": "其他未归属的测试用例。" + } + ], + "projectAssociations": [], + "moduleSettings": [ + { + "id": "7492826", + "name": "默认模块", + "description": "⚠️ **本文档为 Apifox 文档源,不可用于 SDK 生成器。**\n\n为方便按模型查阅,本文档使用**虚构路径**区分不同模型/厂商。**真实请求路径请看每个接口 description 第一行。**\n\n项目主页:[new-api by QuantumNous](https://github.com/QuantumNous/new-api)", + "moduleVariables": [], + "openApiInfo": {} + } + ] +} \ No newline at end of file From 99142304b8172753c88dda6081451bd5b924446d Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Thu, 7 May 2026 15:41:38 +0800 Subject: [PATCH 12/45] chore(gitignore): ignore scripts/ top-level research notes and probe scripts --- .gitignore | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d69f497e7f34..460ad822af08 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,9 @@ skills-lock.json # repo-root screenshot artifacts and openapi dumps (not nested) /*.png /*.openapi.json -/tmp \ No newline at end of file +/tmp + +# scripts/ top-level: research notes & probe scripts (cf-worker/ subdir stays tracked) +/scripts/*.md +/scripts/*.py +/scripts/*.sh \ No newline at end of file From 907b4e115f57dccc3f6045c3967fa0f429abe09a Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sun, 10 May 2026 12:46:20 +0800 Subject: [PATCH 13/45] feat(image-stream): in-process Go SSE aggregator for gpt-image-* generations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes /v1/images/generations requests for any gpt-image-* model through a new in-process handler that: 1. Re-shapes the OpenAI Images-API request into a /v1/responses payload with stream:true 2. POSTs directly to the configured channel base_url (no worker hop) 3. Aggregates the SSE stream in Go, skipping huge partial_image events 4. Uploads the final image to R2 via the Cloudflare REST API (single authenticated PUT; no S3 SDK / aws-sdk-go needed) 5. Builds the classic Images-API envelope and writes it to the client 6. Triggers the standard quota-consume billing path An "early flush" emits response headers + a single whitespace byte before the upstream call so the CF edge in front of api.opwan.ai sees TTFB inside its 100s window even though the upstream model takes 60-150s. JSON parsers ignore leading whitespace so the eventual body parses cleanly. R2 access is configured via four env vars: CLOUDFLARE_R2_API_TOKEN, _ACCOUNT_ID, _BUCKET, _PUBLIC_BASE Gating is at the relay/image_handler.go layer and currently fires only on RelayModeImagesGenerations + gpt-image-* model. /v1/images/edits continues through the existing worker-relayed path until Phase 3. Layout: relay/channel/openai/image_stream/ handler.go — orchestration: build request, post, aggregate, upload, write r2.go — R2 PUT via Cloudflare REST API + magic-byte ext sniffing sse.go — SSE aggregator (skips partial_image, captures completed) request.go — /v1/responses payload builder for generations --- relay/channel/openai/image_stream/handler.go | 265 +++++++++++++++++++ relay/channel/openai/image_stream/r2.go | 140 ++++++++++ relay/channel/openai/image_stream/request.go | 85 ++++++ relay/channel/openai/image_stream/sse.go | 137 ++++++++++ relay/image_handler.go | 17 ++ 5 files changed, 644 insertions(+) create mode 100644 relay/channel/openai/image_stream/handler.go create mode 100644 relay/channel/openai/image_stream/r2.go create mode 100644 relay/channel/openai/image_stream/request.go create mode 100644 relay/channel/openai/image_stream/sse.go diff --git a/relay/channel/openai/image_stream/handler.go b/relay/channel/openai/image_stream/handler.go new file mode 100644 index 000000000000..1b50bcb277c3 --- /dev/null +++ b/relay/channel/openai/image_stream/handler.go @@ -0,0 +1,265 @@ +package image_stream + +// Entry point for gpt-image-* model requests on the /v1/images/{generations,edits} +// classic OpenAI surface. Bypasses the standard adaptor.DoRequest path and +// instead: +// +// 1. Re-shapes the request into a /v1/responses + stream:true payload +// 2. Calls the configured upstream channel directly +// 3. Aggregates the SSE stream in Go (skipping huge partial_image events) +// 4. Uploads the final image bytes to R2 (or returns b64_json inline) +// 5. Builds the OpenAI Images-API envelope and writes it +// 6. Triggers billing +// +// An "early flush" is emitted before the upstream call so the CF edge in +// front of the gateway sees a TTFB byte well within its 100s window even +// though the upstream model takes 60-150s to produce the image. + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// IsGptImageModel returns true for the gpt-image-* family. Used as the +// gating predicate at the upper relay layer. +func IsGptImageModel(model string) bool { + return strings.HasPrefix(strings.ToLower(model), "gpt-image-") +} + +// HandleImageStream is the Phase-2 entry point. It currently supports +// /v1/images/generations only; /v1/images/edits will land in Phase 3. +func HandleImageStream(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ImageRequest) *types.NewAPIError { + if info.RelayMode != relayconstant.RelayModeImagesGenerations { + return types.NewError( + fmt.Errorf("image_stream: relay mode %d not yet supported (only generations)", info.RelayMode), + types.ErrorCodeInvalidApiType, + types.ErrOptionWithSkipRetry(), + ) + } + if info.ChannelBaseUrl == "" { + return types.NewError( + errors.New("image_stream: channel base_url is empty"), + types.ErrorCodeInvalidApiType, + types.ErrOptionWithSkipRetry(), + ) + } + if req.Prompt == "" { + return types.NewErrorWithStatusCode( + errors.New("prompt is required"), + types.ErrorCodeInvalidRequest, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) + } + + upstreamReq := buildGenerationsRequest(req, info.UpstreamModelName) + body, err := common.Marshal(upstreamReq) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + url := strings.TrimRight(info.ChannelBaseUrl, "/") + "/v1/responses" + if common.DebugEnabled { + logger.LogDebug(c, fmt.Sprintf("image_stream: POST %s body=%dB", url, len(body))) + } + + // Early flush: write headers + a single space byte to satisfy the CF + // edge's 100s TTFB before we begin the long upstream call. JSON parsers + // ignore leading whitespace, so the eventual body still parses cleanly. + earlyFlushHeaders(c) + + // We give the upstream up to 5 minutes — enough headroom for 4K + high + // quality (60-150s observed). Once headers arrive the request is no + // longer subject to that ceiling, only the per-stream timeout below. + httpClient := &http.Client{Timeout: 5 * time.Minute} + httpReq, err := http.NewRequestWithContext(c.Request.Context(), "POST", url, bytes.NewReader(body)) + if err != nil { + return types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithSkipRetry()) + } + httpReq.Header.Set("Authorization", "Bearer "+info.ApiKey) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + + upstreamResp, err := httpClient.Do(httpReq) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusBadGateway) + } + defer upstreamResp.Body.Close() + + if upstreamResp.StatusCode != http.StatusOK { + errBody, _ := io.ReadAll(io.LimitReader(upstreamResp.Body, 4096)) + writeError(c, upstreamResp.StatusCode, fmt.Sprintf("upstream %d: %s", upstreamResp.StatusCode, string(errBody))) + return types.NewError( + fmt.Errorf("upstream returned %d: %s", upstreamResp.StatusCode, string(errBody)), + types.ErrorCodeBadResponse, + types.ErrOptionWithSkipRetry(), + ) + } + + aggregated, err := AggregateResponseStream(upstreamResp.Body) + if err != nil { + writeError(c, http.StatusBadGateway, err.Error()) + return types.NewError(err, types.ErrorCodeBadResponseBody, types.ErrOptionWithSkipRetry()) + } + + envelope, buildErr := buildImagesResponse(c.Request.Context(), aggregated, req) + if buildErr != nil { + writeError(c, http.StatusInternalServerError, buildErr.Error()) + return types.NewError(buildErr, types.ErrorCodeBadResponseBody, types.ErrOptionWithSkipRetry()) + } + + envelopeJSON, err := common.Marshal(envelope) + if err != nil { + writeError(c, http.StatusInternalServerError, err.Error()) + return types.NewError(err, types.ErrorCodeBadResponseBody, types.ErrOptionWithSkipRetry()) + } + if _, werr := c.Writer.Write(envelopeJSON); werr != nil { + logger.LogError(c, fmt.Sprintf("image_stream: write envelope: %s", werr.Error())) + } + c.Writer.Flush() + + applyBilling(c, info, aggregated, req) + return nil +} + +// earlyFlushHeaders pushes the response headers + a leading whitespace byte +// to the client. The space is ignored by JSON parsers (whitespace is allowed +// before the document) but resets the CF edge's TTFB clock so it doesn't +// 524 us at 100s while the upstream model is still working. +func earlyFlushHeaders(c *gin.Context) { + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + c.Writer.Header().Set("Cache-Control", "no-store") + c.Writer.WriteHeader(http.StatusOK) + _, _ = c.Writer.Write([]byte(" ")) + c.Writer.Flush() +} + +// writeError writes a JSON error envelope. Headers may already have been +// flushed (status 200), in which case status is whatever we said earlier; +// the body still carries an `error` object so OpenAI clients see it. +func writeError(c *gin.Context, status int, msg string) { + if !c.Writer.Written() { + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + c.Writer.WriteHeader(status) + } + body, _ := common.Marshal(map[string]any{ + "error": map[string]any{ + "message": msg, + "type": "upstream_error", + "code": "image_stream_failed", + }, + }) + _, _ = c.Writer.Write(body) + c.Writer.Flush() +} + +// buildImagesResponse turns the aggregated /v1/responses payload into the +// classic OpenAI Images-API envelope. For each image_generation_call item, +// either uploads the bytes to R2 (returning a public URL) or surfaces the +// base64 inline as `b64_json` if the caller asked for that or R2 isn't +// configured. +func buildImagesResponse(ctx context.Context, agg *UpstreamResponse, req *dto.ImageRequest) (*dto.ImageResponse, error) { + r2 := LoadR2Config() + wantB64 := req.ResponseFormat == "b64_json" || !r2.Enabled() + + out := &dto.ImageResponse{ + Created: time.Now().Unix(), + } + + for _, item := range agg.Output { + if item.Type != "image_generation_call" || len(item.Result) < 100 { + continue + } + entry := dto.ImageData{} + if wantB64 { + entry.B64Json = item.Result + } else { + raw, err := base64.StdEncoding.DecodeString(item.Result) + if err != nil { + return nil, fmt.Errorf("decode image base64: %w", err) + } + url, _, err := r2.PutImageDeduped(ctx, raw, item.OutputFormat) + if err != nil { + return nil, fmt.Errorf("R2 upload: %w", err) + } + entry.Url = url + } + if item.RevisedPrompt != "" { + entry.RevisedPrompt = item.RevisedPrompt + } else if req.Prompt != "" { + entry.RevisedPrompt = req.Prompt + } + out.Data = append(out.Data, entry) + } + + if len(out.Data) == 0 { + return nil, errors.New("upstream produced no image_generation_call output") + } + return out, nil +} + +// applyBilling triggers the standard quota-consume path so this endpoint +// integrates with the same billing system as everything else. Falls back to +// PromptTokens=1/TotalTokens=1 if upstream gave us nothing usable, matching +// the existing image-handler behavior. +func applyBilling(c *gin.Context, info *relaycommon.RelayInfo, agg *UpstreamResponse, req *dto.ImageRequest) { + usage := &dto.Usage{} + if agg.Usage != nil { + usage = agg.Usage + } + if usage.TotalTokens == 0 { + usage.TotalTokens = 1 + } + if usage.PromptTokens == 0 { + usage.PromptTokens = 1 + } + + imageN := uint(1) + if req.N != nil { + imageN = *req.N + } + if info.PriceData.UsePrice { + if _, hasN := info.PriceData.OtherRatios["n"]; !hasN { + info.PriceData.AddOtherRatio("n", float64(imageN)) + } + } + + quality := "standard" + if req.Quality == "hd" { + quality = "hd" + } + var logContent []string + if req.Size != "" { + logContent = append(logContent, fmt.Sprintf("大小 %s", req.Size)) + } + if quality != "" { + logContent = append(logContent, fmt.Sprintf("品质 %s", quality)) + } + if imageN > 0 { + logContent = append(logContent, fmt.Sprintf("生成数量 %d", imageN)) + } + logContent = append(logContent, "image_stream") + + service.PostTextConsumeQuota(c, info, usage, logContent) +} + +// silence unused-import warnings until Phase 3 lands the edits path +var _ = constant.ContextKeyChannelBaseUrl diff --git a/relay/channel/openai/image_stream/r2.go b/relay/channel/openai/image_stream/r2.go new file mode 100644 index 000000000000..2458d08536e2 --- /dev/null +++ b/relay/channel/openai/image_stream/r2.go @@ -0,0 +1,140 @@ +package image_stream + +// R2 upload via the Cloudflare REST API. +// +// We avoid the S3 protocol (and aws-sdk-go-v2) entirely: a single authenticated +// HTTP PUT to /accounts/{id}/r2/buckets/{bucket}/objects/{key} is all that's +// needed. Configuration comes from four env vars set on the gateway: +// +// CLOUDFLARE_R2_API_TOKEN CF API token with R2 object write permission +// CLOUDFLARE_R2_ACCOUNT_ID CF account ID +// CLOUDFLARE_R2_BUCKET bucket name (e.g. "image-cache") +// CLOUDFLARE_R2_PUBLIC_BASE public base URL for built URLs (e.g. "https://cdn.opwan.ai") +// +// If any of these are missing, R2 upload is disabled and the caller should +// fall back to inline base64 / data:URI delivery. + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" +) + +type R2Config struct { + APIToken string + AccountID string + Bucket string + PublicBase string +} + +func LoadR2Config() R2Config { + return R2Config{ + APIToken: common.GetEnvOrDefaultString("CLOUDFLARE_R2_API_TOKEN", ""), + AccountID: common.GetEnvOrDefaultString("CLOUDFLARE_R2_ACCOUNT_ID", ""), + Bucket: common.GetEnvOrDefaultString("CLOUDFLARE_R2_BUCKET", ""), + PublicBase: strings.TrimRight(common.GetEnvOrDefaultString("CLOUDFLARE_R2_PUBLIC_BASE", ""), "/"), + } +} + +func (c R2Config) Enabled() bool { + return c.APIToken != "" && c.AccountID != "" && c.Bucket != "" +} + +// MimeForExt returns the Content-Type that should be set on the R2 object so +// browsers and image tags pick the right decoder. +func MimeForExt(ext string) string { + switch ext { + case "jpg", "jpeg": + return "image/jpeg" + case "webp": + return "image/webp" + case "gif": + return "image/gif" + default: + return "image/png" + } +} + +// InferImageExt sniffs the magic bytes first, falling back to the upstream's +// claimed format. Upstream sometimes echoes back output_format=webp while +// silently returning PNG bytes, so trusting `claimed` blindly produces .webp +// URLs whose body is actually PNG. +func InferImageExt(claimed string, head []byte) string { + if len(head) >= 8 { + if head[0] == 0x89 && head[1] == 'P' && head[2] == 'N' && head[3] == 'G' { + return "png" + } + if head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF { + return "jpg" + } + if string(head[:4]) == "RIFF" && len(head) >= 12 && string(head[8:12]) == "WEBP" { + return "webp" + } + if string(head[:6]) == "GIF87a" || string(head[:6]) == "GIF89a" { + return "gif" + } + } + switch strings.ToLower(claimed) { + case "jpeg", "jpg": + return "jpg" + case "webp": + return "webp" + case "gif": + return "gif" + default: + return "png" + } +} + +// PutObject uploads `body` to R2 under `key` and returns the public URL. +func (c R2Config) PutObject(ctx context.Context, key string, contentType string, body []byte) (string, error) { + if !c.Enabled() { + return "", fmt.Errorf("R2 not configured (set CLOUDFLARE_R2_API_TOKEN/ACCOUNT_ID/BUCKET)") + } + url := fmt.Sprintf( + "https://api.cloudflare.com/client/v4/accounts/%s/r2/buckets/%s/objects/%s", + c.AccountID, c.Bucket, key, + ) + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, "PUT", url, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+c.APIToken) + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("R2 PUT failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return "", fmt.Errorf("R2 PUT %d: %s", resp.StatusCode, string(errBody)) + } + if c.PublicBase == "" { + return "", fmt.Errorf("R2 public base URL not set") + } + return c.PublicBase + "/" + key, nil +} + +// PutImageDeduped sha256-keys the bytes, uploads under images/., +// and returns the public URL. Using the content hash as the key means +// re-running an identical generation reuses the same R2 object instead of +// burning storage on duplicates. +func (c R2Config) PutImageDeduped(ctx context.Context, raw []byte, claimedFormat string) (string, string, error) { + ext := InferImageExt(claimedFormat, raw) + hash := sha256.Sum256(raw) + key := "images/" + hex.EncodeToString(hash[:]) + "." + ext + url, err := c.PutObject(ctx, key, MimeForExt(ext), raw) + return url, ext, err +} diff --git a/relay/channel/openai/image_stream/request.go b/relay/channel/openai/image_stream/request.go new file mode 100644 index 000000000000..b871fd64055e --- /dev/null +++ b/relay/channel/openai/image_stream/request.go @@ -0,0 +1,85 @@ +package image_stream + +// Builders that turn an OpenAI-shaped Images-API request into the +// /v1/responses payload our upstream channel speaks. The image_generation +// tool field is the bridge between the two surface shapes. + +import ( + "encoding/json" + + "github.com/QuantumNous/new-api/dto" +) + +type imageGenerationTool struct { + Type string `json:"type"` + Size string `json:"size,omitempty"` + Quality string `json:"quality,omitempty"` + OutputFormat string `json:"output_format,omitempty"` + OutputCompression any `json:"output_compression,omitempty"` + Background string `json:"background,omitempty"` + Moderation string `json:"moderation,omitempty"` +} + +type responsesRequest struct { + Model string `json:"model"` + Input any `json:"input"` + Tools []imageGenerationTool `json:"tools"` + Stream bool `json:"stream"` +} + +// rawString unwraps json.RawMessage values that were stored as JSON strings +// into the plain string they represent. ImageRequest stores user-typed +// fields like Background/Moderation as RawMessage so they can be either +// `"opaque"` or omitted; the upstream tool field expects bare strings. +func rawString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + // fall through — caller probably passed e.g. a number; render as raw + return string(raw) + } + return s +} + +// buildGenerationsRequest converts the classic /v1/images/generations +// request shape into a /v1/responses payload with stream:true. The simple +// case: input is the prompt string. +func buildGenerationsRequest(req *dto.ImageRequest, modelOverride string) responsesRequest { + tool := imageGenerationTool{Type: "image_generation"} + if req.Size != "" { + tool.Size = req.Size + } + if req.Quality != "" { + tool.Quality = req.Quality + } + if of := rawString(req.OutputFormat); of != "" { + tool.OutputFormat = of + } + if oc := req.OutputCompression; len(oc) > 0 { + tool.OutputCompression = json.RawMessage(oc) + } + if bg := rawString(req.Background); bg != "" { + tool.Background = bg + } + if mod := rawString(req.Moderation); mod != "" { + tool.Moderation = mod + } else { + // Match the worker's behavior — default to "low" so casual prompts + // don't hit upstream's overly strict default safety filter. + tool.Moderation = "low" + } + + model := req.Model + if modelOverride != "" { + model = modelOverride + } + + return responsesRequest{ + Model: model, + Input: req.Prompt, + Tools: []imageGenerationTool{tool}, + Stream: true, + } +} diff --git a/relay/channel/openai/image_stream/sse.go b/relay/channel/openai/image_stream/sse.go new file mode 100644 index 000000000000..0dfe4addd055 --- /dev/null +++ b/relay/channel/openai/image_stream/sse.go @@ -0,0 +1,137 @@ +package image_stream + +// SSE aggregator for upstream /v1/responses stream:true responses. +// +// We bypass any partial_image events (each carries multi-MB base64 noise we +// don't need) and capture only the events that contain final state: +// - response.output_item.done : the actual image_generation_call result +// - response.completed : usage + final response shell +// in_progress / created snapshots are kept as a fallback for usage data when +// completed never arrives. + +import ( + "bufio" + "fmt" + "io" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +const ( + maxSSELineSize = 32 << 20 // 32 MiB — image_generation_call.result is ~2-15 MiB at 4K + initSSEBufBytes = 1 << 20 // 1 MiB initial buffer +) + +// UpstreamItem mirrors the relevant fields of an output_item in a +// /v1/responses payload. Only image_generation_call items are interesting +// for our envelope. +type UpstreamItem struct { + Type string `json:"type"` + Result string `json:"result,omitempty"` + OutputFormat string `json:"output_format,omitempty"` + Size string `json:"size,omitempty"` + RevisedPrompt string `json:"revised_prompt,omitempty"` + Status string `json:"status,omitempty"` +} + +// UpstreamResponse is the slice of /v1/responses we use. The SDK doesn't +// model the image-generation tool fully; we keep this shape narrow to avoid +// fighting upstream schema drift. +type UpstreamResponse struct { + Model string `json:"model,omitempty"` + Background string `json:"background,omitempty"` + Output []UpstreamItem `json:"output,omitempty"` + Usage *dto.Usage `json:"usage,omitempty"` +} + +// AggregateResponseStream reads an SSE event stream and returns the final +// upstream response object. Returns an error if the stream contains an +// "error" or "response.failed" event, or if no terminal event is observed. +func AggregateResponseStream(body io.Reader) (*UpstreamResponse, error) { + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, initSSEBufBytes), maxSSELineSize) + + var snapshot *UpstreamResponse + var collected []UpstreamItem + var seenCompleted bool + + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimSpace(line[6:]) + if data == "" || data == "[DONE]" { + continue + } + // Cheap pre-filter: partial_image events carry a multi-MB base64 + // payload and we never need them for the final envelope. + if strings.Contains(data, `"partial_image_b64"`) { + continue + } + + var probe struct { + Type string `json:"type"` + } + if err := common.UnmarshalJsonStr(data, &probe); err != nil { + continue + } + + switch probe.Type { + case "response.output_item.done": + var ev struct { + Item *UpstreamItem `json:"item"` + } + if err := common.UnmarshalJsonStr(data, &ev); err == nil && ev.Item != nil { + collected = append(collected, *ev.Item) + } + case "response.completed": + var ev struct { + Response *UpstreamResponse `json:"response"` + } + if err := common.UnmarshalJsonStr(data, &ev); err == nil && ev.Response != nil { + snapshot = ev.Response + seenCompleted = true + } + case "response.in_progress", "response.created": + if snapshot == nil { + var ev struct { + Response *UpstreamResponse `json:"response"` + } + if err := common.UnmarshalJsonStr(data, &ev); err == nil { + snapshot = ev.Response + } + } + case "error", "response.failed": + var ev struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + _ = common.UnmarshalJsonStr(data, &ev) + if ev.Error.Message == "" { + return nil, fmt.Errorf("upstream error event") + } + return nil, fmt.Errorf("upstream error: %s", ev.Error.Message) + } + + if seenCompleted { + break + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("SSE scan: %w", err) + } + + if snapshot == nil { + snapshot = &UpstreamResponse{} + } + // If completed.response.output came back empty (some upstreams do this), + // splice in the items we collected from output_item.done events. + if len(snapshot.Output) == 0 { + snapshot.Output = collected + } + return snapshot, nil +} diff --git a/relay/image_handler.go b/relay/image_handler.go index e986dd897e65..999d720264d9 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -11,7 +11,9 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/relay/channel/openai/image_stream" relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/model_setting" @@ -38,6 +40,21 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } + // Model gating — gpt-image-* family (currently gpt-image-2) goes through + // the in-process Go SSE aggregator instead of the standard worker-relayed + // path. This exists because gpt-image-2 at large sizes routinely takes + // 60-150s upstream, which the worker→client chain can't survive without + // a stream-aggregating layer that holds the connection open with early + // flushes. + // + // Phase 2 ships /v1/images/generations only; Phase 3 will add edits. + // Until then, gpt-image-* edit requests continue through the existing + // worker-relayed path. + if info.RelayMode == relayconstant.RelayModeImagesGenerations && + image_stream.IsGptImageModel(request.Model) { + return image_stream.HandleImageStream(c, info, request) + } + adaptor := GetAdaptor(info.ApiType) if adaptor == nil { return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) From 3017254aae902929b13c442e27f9e6c64b157507 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sun, 10 May 2026 13:08:06 +0800 Subject: [PATCH 14/45] feat(image-stream): /v1/images/edits via Go SSE aggregator (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds multipart/form-data handling for /v1/images/edits when the model matches the gpt-image-* family. The handler now dispatches by relay mode and shares the upstream-call / SSE aggregation / R2 upload flow: generations → buildGenerationsRequest (input is the prompt string) edits → buildEditsRequest (input is a user message with input_text + N input_image content parts) input_normalizer.go centralizes image-source handling: - multipart file uploads (image=@/path/file.png) - http(s) URLs (image=https://...) - data:URIs (image=data:image/png;base64,...) - multi-image syntaxes: image / image[] / image[N] - max 16 images per request, ≤25 MiB each, png/jpeg/webp only - magic-byte mime sniffing wins over declared Content-Type so upstream sees the right type when clients lie about it Other fixes in this commit: - Add writeError() calls on the early-error paths (NewRequestWithContext, httpClient.Do failure) so clients see a JSON error envelope instead of a hung connection that closes after just the early-flush whitespace. - Drop unused constant import + dead `var _` placeholder from handler.go. - Gating in image_handler.go now matches both ImagesGenerations and ImagesEdits when the model is gpt-image-*. Validated Phase 2 in production: 1024×1024 medium ✓ 18s 1.2 MB PNG 2560×1440 high ✓ 62s 7.5 MB PNG 2048×2048 high ✓ 215s 9.9 MB PNG (broke past CF 100s) 4K UHD high ✓ 67s, structured upstream error (retryable) 12-concurrent stress on generations: 7 ✓ + 5 503 (upstream overload), zero CF 524 timeouts — the architectural goal of this rewrite. --- relay/channel/openai/image_stream/handler.go | 104 +++++-- .../openai/image_stream/input_normalizer.go | 259 ++++++++++++++++++ relay/channel/openai/image_stream/request.go | 53 ++++ relay/image_handler.go | 9 +- 4 files changed, 400 insertions(+), 25 deletions(-) create mode 100644 relay/channel/openai/image_stream/input_normalizer.go diff --git a/relay/channel/openai/image_stream/handler.go b/relay/channel/openai/image_stream/handler.go index 1b50bcb277c3..feae79cabd2f 100644 --- a/relay/channel/openai/image_stream/handler.go +++ b/relay/channel/openai/image_stream/handler.go @@ -19,6 +19,7 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -27,7 +28,6 @@ import ( "time" "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" @@ -44,16 +44,12 @@ func IsGptImageModel(model string) bool { return strings.HasPrefix(strings.ToLower(model), "gpt-image-") } -// HandleImageStream is the Phase-2 entry point. It currently supports -// /v1/images/generations only; /v1/images/edits will land in Phase 3. +// HandleImageStream is the entry point for both /v1/images/generations and +// /v1/images/edits when the request model matches the gpt-image-* family. +// The two relay modes share everything except request building, so the +// outer flow (early flush → upstream POST → SSE aggregate → envelope → +// billing) is centralized below. func HandleImageStream(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ImageRequest) *types.NewAPIError { - if info.RelayMode != relayconstant.RelayModeImagesGenerations { - return types.NewError( - fmt.Errorf("image_stream: relay mode %d not yet supported (only generations)", info.RelayMode), - types.ErrorCodeInvalidApiType, - types.ErrOptionWithSkipRetry(), - ) - } if info.ChannelBaseUrl == "" { return types.NewError( errors.New("image_stream: channel base_url is empty"), @@ -61,16 +57,86 @@ func HandleImageStream(c *gin.Context, info *relaycommon.RelayInfo, req *dto.Ima types.ErrOptionWithSkipRetry(), ) } - if req.Prompt == "" { - return types.NewErrorWithStatusCode( - errors.New("prompt is required"), - types.ErrorCodeInvalidRequest, - http.StatusBadRequest, + + var upstreamReq responsesRequest + switch info.RelayMode { + case relayconstant.RelayModeImagesGenerations: + if req.Prompt == "" { + return types.NewErrorWithStatusCode( + errors.New("prompt is required"), + types.ErrorCodeInvalidRequest, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) + } + upstreamReq = buildGenerationsRequest(req, info.UpstreamModelName) + + case relayconstant.RelayModeImagesEdits: + if !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + return types.NewErrorWithStatusCode( + errors.New("image_stream: edits requires multipart/form-data"), + types.ErrorCodeInvalidRequest, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) + } + mf := c.Request.MultipartForm + if mf == nil { + if _, err := c.MultipartForm(); err != nil { + return types.NewErrorWithStatusCode( + fmt.Errorf("parse multipart form: %w", err), + types.ErrorCodeInvalidRequest, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) + } + mf = c.Request.MultipartForm + } + images, err := CollectAndNormalizeImages(c.Request.Context(), mf) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + // Pull all the optional tool params out of the multipart form. They + // live alongside `image` and aren't reflected in dto.ImageRequest's + // fields for /v1/images/edits. + formGet := func(key string) string { + if vs := mf.Value[key]; len(vs) > 0 { + return strings.TrimSpace(vs[0]) + } + return "" + } + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + prompt = formGet("prompt") + } + if prompt == "" { + return types.NewErrorWithStatusCode( + errors.New("prompt is required"), + types.ErrorCodeInvalidRequest, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) + } + var outputCompression any + if oc := formGet("output_compression"); oc != "" { + outputCompression = json.RawMessage(oc) + } + upstreamReq = buildEditsRequest( + prompt, images, + req.Model, info.UpstreamModelName, + formGet("size"), formGet("quality"), + formGet("output_format"), formGet("background"), formGet("moderation"), + outputCompression, + ) + + default: + return types.NewError( + fmt.Errorf("image_stream: unsupported relay mode %d", info.RelayMode), + types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry(), ) } - upstreamReq := buildGenerationsRequest(req, info.UpstreamModelName) body, err := common.Marshal(upstreamReq) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) @@ -78,7 +144,7 @@ func HandleImageStream(c *gin.Context, info *relaycommon.RelayInfo, req *dto.Ima url := strings.TrimRight(info.ChannelBaseUrl, "/") + "/v1/responses" if common.DebugEnabled { - logger.LogDebug(c, fmt.Sprintf("image_stream: POST %s body=%dB", url, len(body))) + logger.LogDebug(c, fmt.Sprintf("image_stream: POST %s body=%dB mode=%d", url, len(body), info.RelayMode)) } // Early flush: write headers + a single space byte to satisfy the CF @@ -92,6 +158,7 @@ func HandleImageStream(c *gin.Context, info *relaycommon.RelayInfo, req *dto.Ima httpClient := &http.Client{Timeout: 5 * time.Minute} httpReq, err := http.NewRequestWithContext(c.Request.Context(), "POST", url, bytes.NewReader(body)) if err != nil { + writeError(c, http.StatusInternalServerError, err.Error()) return types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithSkipRetry()) } httpReq.Header.Set("Authorization", "Bearer "+info.ApiKey) @@ -100,6 +167,7 @@ func HandleImageStream(c *gin.Context, info *relaycommon.RelayInfo, req *dto.Ima upstreamResp, err := httpClient.Do(httpReq) if err != nil { + writeError(c, http.StatusBadGateway, err.Error()) return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusBadGateway) } defer upstreamResp.Body.Close() @@ -261,5 +329,3 @@ func applyBilling(c *gin.Context, info *relaycommon.RelayInfo, agg *UpstreamResp service.PostTextConsumeQuota(c, info, usage, logContent) } -// silence unused-import warnings until Phase 3 lands the edits path -var _ = constant.ContextKeyChannelBaseUrl diff --git a/relay/channel/openai/image_stream/input_normalizer.go b/relay/channel/openai/image_stream/input_normalizer.go new file mode 100644 index 000000000000..38746eb6bbc2 --- /dev/null +++ b/relay/channel/openai/image_stream/input_normalizer.go @@ -0,0 +1,259 @@ +package image_stream + +// Image-input normalization for /v1/images/edits multipart requests. +// +// Three accepted forms in the `image` / `image[]` / `image[N]` fields: +// - multipart file upload → read bytes, detect mime by magic, b64-encode +// - http(s) URL → fetch, validate content-type, b64-encode +// - data:image/...;base64,... → pass through after format check +// Anything else is rejected with a 400. + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "path" + "regexp" + "strings" + "time" +) + +const ( + maxImageBytes = 25 * 1024 * 1024 // 25 MiB per image + maxImagesPerRequest = 16 + imageFetchTimeoutSec = 20 +) + +var dataURIPrefix = regexp.MustCompile(`^data:(image/(?:png|jpeg|jpg|webp))(?:;[^,]*)?,`) + +// NormalizedImage carries the data:URI form of an input image plus its mime, +// ready to be embedded in a /v1/responses input_image content part. +type NormalizedImage struct { + DataURI string + Mime string +} + +// CollectAndNormalizeImages walks the multipart form, extracts every +// image / image[] / image[N] entry (file or text), and returns the list of +// normalized data:URIs in the order encountered. Returns an error suitable +// for surfacing as a 400 if no valid image source exists or a fetch fails. +func CollectAndNormalizeImages(ctx context.Context, mf *multipart.Form) ([]NormalizedImage, error) { + if mf == nil { + return nil, errors.New("no multipart form data") + } + + type pendingFile struct { + fieldName string + index int + fh *multipart.FileHeader + } + type pendingValue struct { + fieldName string + index int + raw string + } + + var files []pendingFile + var values []pendingValue + + for fieldName, fhs := range mf.File { + if !isImageFieldName(fieldName) { + continue + } + for i, fh := range fhs { + files = append(files, pendingFile{fieldName, i, fh}) + } + } + for fieldName, vals := range mf.Value { + if !isImageFieldName(fieldName) { + continue + } + for i, v := range vals { + if v == "" { + continue + } + values = append(values, pendingValue{fieldName, i, v}) + } + } + + if len(files)+len(values) == 0 { + return nil, errors.New(`"image" field is required (file upload, http(s) URL, or data: URI)`) + } + if len(files)+len(values) > maxImagesPerRequest { + return nil, fmt.Errorf("too many images: %d (max %d)", len(files)+len(values), maxImagesPerRequest) + } + + var out []NormalizedImage + + for _, pf := range files { + ni, err := normalizeFile(pf.fh) + if err != nil { + return nil, fmt.Errorf("image #%s[%d]: %w", pf.fieldName, pf.index, err) + } + out = append(out, ni) + } + for _, pv := range values { + ni, err := normalizeStringValue(ctx, pv.raw) + if err != nil { + return nil, fmt.Errorf("image #%s[%d]: %w", pv.fieldName, pv.index, err) + } + out = append(out, ni) + } + return out, nil +} + +func isImageFieldName(name string) bool { + if name == "image" || name == "image[]" { + return true + } + return strings.HasPrefix(name, "image[") +} + +func normalizeStringValue(ctx context.Context, raw string) (NormalizedImage, error) { + switch { + case strings.HasPrefix(raw, "data:"): + return normalizeDataURI(raw) + case strings.HasPrefix(raw, "http://"), strings.HasPrefix(raw, "https://"): + return fetchAndNormalize(ctx, raw) + default: + return NormalizedImage{}, errors.New("unrecognized image source (expected file, http(s) URL, or data:URI)") + } +} + +func normalizeDataURI(raw string) (NormalizedImage, error) { + m := dataURIPrefix.FindStringSubmatch(raw) + if m == nil { + return NormalizedImage{}, errors.New("data URI must be image/png|jpeg|webp") + } + mime := strings.ToLower(m[1]) + if mime == "image/jpg" { + mime = "image/jpeg" + } + return NormalizedImage{DataURI: raw, Mime: mime}, nil +} + +func fetchAndNormalize(ctx context.Context, url string) (NormalizedImage, error) { + fetchCtx, cancel := context.WithTimeout(ctx, imageFetchTimeoutSec*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(fetchCtx, "GET", url, nil) + if err != nil { + return NormalizedImage{}, fmt.Errorf("build url request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return NormalizedImage{}, fmt.Errorf("fetch image url: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return NormalizedImage{}, fmt.Errorf("image url returned %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1)) + if err != nil { + return NormalizedImage{}, fmt.Errorf("read image body: %w", err) + } + if len(body) > maxImageBytes { + return NormalizedImage{}, fmt.Errorf("image too large: >%d bytes", maxImageBytes) + } + + mime := pickMime(resp.Header.Get("Content-Type"), body) + if mime == "" { + return NormalizedImage{}, errors.New("image url content-type unsupported (need png/jpeg/webp)") + } + return NormalizedImage{ + DataURI: "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(body), + Mime: mime, + }, nil +} + +func normalizeFile(fh *multipart.FileHeader) (NormalizedImage, error) { + if fh.Size > maxImageBytes { + return NormalizedImage{}, fmt.Errorf("image too large: %d bytes (max %d)", fh.Size, maxImageBytes) + } + f, err := fh.Open() + if err != nil { + return NormalizedImage{}, fmt.Errorf("open uploaded file: %w", err) + } + defer f.Close() + + body, err := io.ReadAll(io.LimitReader(f, maxImageBytes+1)) + if err != nil { + return NormalizedImage{}, fmt.Errorf("read uploaded file: %w", err) + } + if len(body) > maxImageBytes { + return NormalizedImage{}, fmt.Errorf("image too large: >%d bytes", maxImageBytes) + } + if len(body) == 0 { + return NormalizedImage{}, errors.New("empty image file") + } + + mime := pickMime(fh.Header.Get("Content-Type"), body) + if mime == "" { + // Fall back to filename extension as a last resort + mime = mimeFromExt(fh.Filename) + } + if mime == "" { + return NormalizedImage{}, errors.New("unsupported image type (need png/jpeg/webp)") + } + return NormalizedImage{ + DataURI: "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(body), + Mime: mime, + }, nil +} + +// pickMime decides on a mime type using both the declared Content-Type and +// the actual magic bytes. Magic bytes win when they conflict — clients +// occasionally lie about Content-Type. +func pickMime(declaredCT string, body []byte) string { + declared := strings.ToLower(declaredCT) + declared = strings.TrimSpace(strings.SplitN(declared, ";", 2)[0]) + + switch sniffMagic(body) { + case "png": + return "image/png" + case "jpg": + return "image/jpeg" + case "webp": + return "image/webp" + } + + switch declared { + case "image/png": + return "image/png" + case "image/jpeg", "image/jpg": + return "image/jpeg" + case "image/webp": + return "image/webp" + } + return "" +} + +func sniffMagic(b []byte) string { + if len(b) >= 8 && b[0] == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G' { + return "png" + } + if len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF { + return "jpg" + } + if len(b) >= 12 && bytes.Equal(b[0:4], []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP")) { + return "webp" + } + return "" +} + +func mimeFromExt(filename string) string { + switch strings.ToLower(path.Ext(filename)) { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".webp": + return "image/webp" + } + return "" +} diff --git a/relay/channel/openai/image_stream/request.go b/relay/channel/openai/image_stream/request.go index b871fd64055e..c62717cd66a6 100644 --- a/relay/channel/openai/image_stream/request.go +++ b/relay/channel/openai/image_stream/request.go @@ -43,6 +43,59 @@ func rawString(raw json.RawMessage) string { return s } +// buildEditsRequest converts /v1/images/edits multipart input into a +// /v1/responses payload. The user message is an array of content parts: +// { "type": "input_text", "text": } +// followed by one or more +// { "type": "input_image", "image_url": } +// parts — one per normalized image source (file, URL, or pre-formed data:URI). +func buildEditsRequest(prompt string, images []NormalizedImage, model, modelOverride, size, quality, outputFormat, background, moderation string, outputCompression any) responsesRequest { + tool := imageGenerationTool{Type: "image_generation"} + if size != "" { + tool.Size = size + } + if quality != "" { + tool.Quality = quality + } + if outputFormat != "" { + tool.OutputFormat = outputFormat + } + if outputCompression != nil { + tool.OutputCompression = outputCompression + } + if background != "" { + tool.Background = background + } + if moderation != "" { + tool.Moderation = moderation + } else { + tool.Moderation = "low" + } + + content := []map[string]any{ + {"type": "input_text", "text": prompt}, + } + for _, img := range images { + content = append(content, map[string]any{ + "type": "input_image", + "image_url": img.DataURI, + }) + } + + if modelOverride != "" { + model = modelOverride + } + + return responsesRequest{ + Model: model, + Input: []map[string]any{ + {"role": "user", "content": content}, + }, + Tools: []imageGenerationTool{tool}, + Stream: true, + } +} + // buildGenerationsRequest converts the classic /v1/images/generations // request shape into a /v1/responses payload with stream:true. The simple // case: input is the prompt string. diff --git a/relay/image_handler.go b/relay/image_handler.go index 999d720264d9..c97fde29ad12 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -46,12 +46,9 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type // 60-150s upstream, which the worker→client chain can't survive without // a stream-aggregating layer that holds the connection open with early // flushes. - // - // Phase 2 ships /v1/images/generations only; Phase 3 will add edits. - // Until then, gpt-image-* edit requests continue through the existing - // worker-relayed path. - if info.RelayMode == relayconstant.RelayModeImagesGenerations && - image_stream.IsGptImageModel(request.Model) { + if image_stream.IsGptImageModel(request.Model) && + (info.RelayMode == relayconstant.RelayModeImagesGenerations || + info.RelayMode == relayconstant.RelayModeImagesEdits) { return image_stream.HandleImageStream(c, info, request) } From b7f0828b3b020b70ba9ef09ed9196e378f49e27f Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sun, 10 May 2026 13:33:05 +0800 Subject: [PATCH 15/45] refactor(image-stream): richer envelope (output_format/size/usage/model) --- relay/channel/openai/image_stream/handler.go | 57 +++++++++++++++++--- relay/channel/openai/image_stream/r2.go | 11 +++- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/relay/channel/openai/image_stream/handler.go b/relay/channel/openai/image_stream/handler.go index feae79cabd2f..cf4e5c62a59a 100644 --- a/relay/channel/openai/image_stream/handler.go +++ b/relay/channel/openai/image_stream/handler.go @@ -239,32 +239,67 @@ func writeError(c *gin.Context, status int, msg string) { c.Writer.Flush() } +// imageEnvelope is the JSON shape we write back to the client. It extends +// dto.ImageResponse with the optional fields the worker used to surface +// (output_format, size, quality, background, model, usage). Clients depend +// on output_format to know whether webp was honoured or silently demoted. +type imageEnvelope struct { + Created int64 `json:"created"` + Data []dto.ImageData `json:"data"` + Background string `json:"background,omitempty"` + OutputFormat string `json:"output_format,omitempty"` + Quality string `json:"quality,omitempty"` + Size string `json:"size,omitempty"` + Model string `json:"model,omitempty"` + Usage *dto.Usage `json:"usage,omitempty"` +} + // buildImagesResponse turns the aggregated /v1/responses payload into the // classic OpenAI Images-API envelope. For each image_generation_call item, // either uploads the bytes to R2 (returning a public URL) or surfaces the // base64 inline as `b64_json` if the caller asked for that or R2 isn't // configured. -func buildImagesResponse(ctx context.Context, agg *UpstreamResponse, req *dto.ImageRequest) (*dto.ImageResponse, error) { +func buildImagesResponse(ctx context.Context, agg *UpstreamResponse, req *dto.ImageRequest) (*imageEnvelope, error) { r2 := LoadR2Config() wantB64 := req.ResponseFormat == "b64_json" || !r2.Enabled() - out := &dto.ImageResponse{ + out := &imageEnvelope{ Created: time.Now().Unix(), + Model: agg.Model, + Usage: agg.Usage, } + var firstFormat, firstSize string for _, item := range agg.Output { if item.Type != "image_generation_call" || len(item.Result) < 100 { continue } + // Use the magic-byte sniffed extension as authoritative format — + // upstream sometimes claims webp but returns PNG, so trusting + // item.OutputFormat would mislabel. + raw, err := base64.StdEncoding.DecodeString(item.Result) + if err != nil { + return nil, fmt.Errorf("decode image base64: %w", err) + } + ext := InferImageExt(item.OutputFormat, raw) + actualFormat := ext + if ext == "jpg" { + actualFormat = "jpeg" + } + if firstFormat == "" { + firstFormat = actualFormat + } + if firstSize == "" { + firstSize = item.Size + } + entry := dto.ImageData{} if wantB64 { entry.B64Json = item.Result } else { - raw, err := base64.StdEncoding.DecodeString(item.Result) - if err != nil { - return nil, fmt.Errorf("decode image base64: %w", err) - } - url, _, err := r2.PutImageDeduped(ctx, raw, item.OutputFormat) + url, err := r2.PutObject(ctx, + "images/"+sha256HexBytes(raw)+"."+ext, + MimeForExt(ext), raw) if err != nil { return nil, fmt.Errorf("R2 upload: %w", err) } @@ -281,6 +316,14 @@ func buildImagesResponse(ctx context.Context, agg *UpstreamResponse, req *dto.Im if len(out.Data) == 0 { return nil, errors.New("upstream produced no image_generation_call output") } + out.OutputFormat = firstFormat + out.Size = firstSize + if agg.Background != "" { + out.Background = agg.Background + } + if req.Quality != "" { + out.Quality = req.Quality + } return out, nil } diff --git a/relay/channel/openai/image_stream/r2.go b/relay/channel/openai/image_stream/r2.go index 2458d08536e2..8d4d3d9037e6 100644 --- a/relay/channel/openai/image_stream/r2.go +++ b/relay/channel/openai/image_stream/r2.go @@ -133,8 +133,15 @@ func (c R2Config) PutObject(ctx context.Context, key string, contentType string, // burning storage on duplicates. func (c R2Config) PutImageDeduped(ctx context.Context, raw []byte, claimedFormat string) (string, string, error) { ext := InferImageExt(claimedFormat, raw) - hash := sha256.Sum256(raw) - key := "images/" + hex.EncodeToString(hash[:]) + "." + ext + key := "images/" + sha256HexBytes(raw) + "." + ext url, err := c.PutObject(ctx, key, MimeForExt(ext), raw) return url, ext, err } + +// sha256HexBytes returns the hex-encoded sha256 digest of `raw`. Exported +// (lowercase but reused across files in the same package) so the envelope +// builder can compute keys without re-importing crypto/sha256. +func sha256HexBytes(raw []byte) string { + h := sha256.Sum256(raw) + return hex.EncodeToString(h[:]) +} From 351b64fc01fd49374e1bbecaf9f003f54268f035 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sun, 10 May 2026 13:48:12 +0800 Subject: [PATCH 16/45] =?UTF-8?q?chore(cf-worker):=20remove=20worker.js=20?= =?UTF-8?q?=E2=80=94=20image=20relay=20now=20in-process=20Go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4 Cloudflare Workers (image-relay, -lx, -luc, -md) have been deleted from the CF account. All gpt-image-* traffic now flows through the new in-process Go SSE aggregator (relay/channel/openai/image_stream). Other models (dall-e-*, etc.) continue through the standard adaptor path without any worker hop. --- scripts/cf-worker/worker.js | 1159 ----------------------------------- 1 file changed, 1159 deletions(-) delete mode 100644 scripts/cf-worker/worker.js diff --git a/scripts/cf-worker/worker.js b/scripts/cf-worker/worker.js deleted file mode 100644 index 2f28f59aeb7a..000000000000 --- a/scripts/cf-worker/worker.js +++ /dev/null @@ -1,1159 +0,0 @@ -// image-relay Cloudflare Worker -// Proxies /v1/* to an upstream Responses-API gateway; rewrites base64 image -// fields (both non-streaming JSON and streaming SSE) into public URLs served -// from IMAGE_BASE (e.g. an R2 custom domain). -// -// Bindings (env): -// IMAGES R2 bucket binding (required) -// IMAGE_BASE string, e.g. "https://cdn.opwan.ai" (required to rewrite) -// RELAY_KEY string, required Bearer token for /v1/* (optional) -// UPSTREAM_KEY string, real upstream key swapped in before forwarding (optional) -// UPSTREAM_URL string, override hardcoded upstream (optional; defaults to xixiapi.cc) - -const UPSTREAM = 'https://xixiapi.cc'; - -function getUpstream(env) { - return (env?.UPSTREAM_URL || UPSTREAM).replace(/\/$/, ''); -} - -export default { - async fetch(request, env, ctx) { - const url = new URL(request.url); - - if (url.pathname.startsWith('/img/')) { - return serveImage(url.pathname.slice(5), env); - } - - if (url.pathname === '/healthz') { - return new Response('image-relay: ok\n', { - headers: { 'content-type': 'text/plain; charset=utf-8' }, - }); - } - - if (url.pathname === '/') { - return rootInfo(env); - } - - if (url.pathname.startsWith('/v1/')) { - const authError = checkAuth(request, env); - if (authError) return authError; - - // Adapter: classic Images API → Responses API upstream - if ( - request.method === 'POST' && - url.pathname === '/v1/images/generations' - ) { - return handleImagesGenerations(request, env, ctx); - } - if ( - request.method === 'POST' && - url.pathname === '/v1/images/edits' - ) { - return handleImagesEdits(request, env, ctx); - } - - // Self-healing: if a caller (e.g. new-api) sends an image-generation - // model through /v1/chat/completions, transparently route it through - // the image pipeline instead of letting it fall through to the upstream - // chat endpoint (which doesn't support image models). - if ( - request.method === 'POST' && - url.pathname === '/v1/chat/completions' - ) { - const bodyText = await request.text(); - let parsed = null; - try { parsed = JSON.parse(bodyText); } catch {} - if (parsed && isImageModel(parsed.model)) { - return handleChatCompletionsAsImage(parsed, request, env, ctx); - } - // Not an image model — reconstruct request and passthrough. - const passReq = new Request(request, { body: bodyText }); - return proxyAndMaybeRewrite(passReq, url, env, ctx); - } - - return proxyAndMaybeRewrite(request, url, env, ctx); - } - - return new Response('not found\n', { status: 404 }); - }, -}; - -function rootInfo(env) { - const info = { - service: 'image-relay', - upstream: getUpstream(env), - image_base: env.IMAGE_BASE || null, - auth_required: Boolean(env.RELAY_KEY), - endpoints: { - health: 'GET /healthz', - proxy: 'POST /v1/responses (Authorization: Bearer )', - images_fallback: 'GET /img/ (only when IMAGE_BASE is unset)', - }, - }; - return new Response(JSON.stringify(info, null, 2) + '\n', { - headers: { - 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store', - }, - }); -} - -function checkAuth(request, env) { - if (!env.RELAY_KEY) return null; - const auth = request.headers.get('authorization') || ''; - const provided = auth.replace(/^Bearer\s+/i, '').trim(); - if (provided !== env.RELAY_KEY) { - console.error('auth: invalid relay key'); - return new Response( - JSON.stringify({ - error: { - message: 'Invalid relay key', - type: 'authentication_error', - }, - }), - { - status: 401, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - }, - } - ); - } - return null; -} - -async function serveImage(key, env) { - const obj = await env.IMAGES.get(key); - if (!obj) return new Response('not found\n', { status: 404 }); - const headers = new Headers(); - headers.set( - 'content-type', - obj.httpMetadata?.contentType || 'application/octet-stream' - ); - headers.set('cache-control', 'public, max-age=31536000, immutable'); - headers.set('etag', obj.httpEtag); - return new Response(obj.body, { headers }); -} - -async function proxyAndMaybeRewrite(request, url, env, ctx) { - const upstreamUrl = getUpstream(env) + url.pathname + url.search; - const upstreamHeaders = new Headers(request.headers); - - // Swap the relay key for the real upstream key (when configured) - if (env.UPSTREAM_KEY) { - upstreamHeaders.set('authorization', `Bearer ${env.UPSTREAM_KEY}`); - } - - // Strip CF-injected hop headers so upstream sees a clean request - for (const h of [ - 'cf-connecting-ip', - 'cf-ipcountry', - 'cf-ray', - 'cf-visitor', - 'cf-ew-via', - 'x-forwarded-for', - 'x-real-ip', - ]) { - upstreamHeaders.delete(h); - } - - // Buffer the request body upfront so retries can resend it (a one-shot - // ReadableStream cannot be replayed). Body for /v1/* is small JSON, so - // buffering is cheap. - let bodyBuffer = null; - if (request.method !== 'GET' && request.method !== 'HEAD') { - try { - bodyBuffer = await request.arrayBuffer(); - } catch { - bodyBuffer = null; - } - } - - // For POST /v1/responses with image_generation tool(s), inject default - // moderation:"low" if the client didn't set it explicitly. - const isResponsesPost = - request.method === 'POST' && url.pathname === '/v1/responses'; - - if (isResponsesPost && bodyBuffer) { - bodyBuffer = injectImageModerationDefault(bodyBuffer); - } - - // For non-streaming /v1/responses calls that include an image_generation - // tool, force the upstream call into SSE and aggregate. Image generation - // routinely takes 60-180s and would otherwise hit CF's 100s subrequest - // timeout, returning 524 to the client. - if (isResponsesPost && bodyBuffer) { - let parsed = null; - try { - parsed = JSON.parse(new TextDecoder().decode(bodyBuffer)); - } catch {} - - // URL → data:URI fallback. Many SDKs / users pass an http(s) URL for - // input_image.image_url, but several upstream Responses-API providers - // only accept inline base64. Inline before forwarding so the client - // doesn't have to know which upstream is configured. Re-encode bodyBuffer - // when any URL is inlined so the streaming-passthrough path also sees it. - if (parsed) { - try { - const { inlined } = await inlineInputImageUrls(parsed); - if (inlined > 0) { - bodyBuffer = new TextEncoder().encode(JSON.stringify(parsed)).buffer; - } - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const sep = msg.lastIndexOf('|'); - const status = sep >= 0 ? parseInt(msg.slice(sep + 1), 10) || 400 : 400; - const text = sep >= 0 ? msg.slice(0, sep) : msg; - return jsonError(status, `input_image url inline failed: ${text}`); - } - } - - } - - const upstreamResp = await fetchWithFastFailRetry(upstreamUrl, { - method: request.method, - headers: upstreamHeaders, - body: bodyBuffer, - }); - const ct = (upstreamResp.headers.get('content-type') || '').toLowerCase(); - - if (isResponsesPost && upstreamResp.ok) { - if (ct.includes('text/event-stream')) { - return rewriteSSE(upstreamResp, env, ctx); - } - if (ct.includes('application/json')) { - return rewriteJsonResponse(upstreamResp, url, env); - } - } - - // Pass-through: errors, /v1/images/*, anything that's not /v1/responses JSON - const passHeaders = new Headers(upstreamResp.headers); - passHeaders.set('access-control-allow-origin', '*'); - return new Response(upstreamResp.body, { - status: upstreamResp.status, - headers: passHeaders, - }); -} - -async function rewriteJsonResponse(upstreamResp, url, env) { - const data = await upstreamResp.json(); - // If IMAGE_BASE is bound (e.g. R2 custom domain), use it. Otherwise serve - // from this Worker's own /img/* path. - const imageBase = (env.IMAGE_BASE || `${url.protocol}//${url.host}/img`).replace(/\/$/, ''); - let rewritten = 0; - - if (Array.isArray(data.output)) { - for (const item of data.output) { - if ( - item && - item.type === 'image_generation_call' && - typeof item.result === 'string' && - item.result.length > 100 - ) { - const ext = inferExt(item.output_format, item.result); - const key = await uploadToR2(item.result, ext, env); - item.result = `${imageBase}/${key}`; - if (item.status === 'generating') item.status = 'completed'; - rewritten++; - } - } - } - - return new Response(JSON.stringify(data), { - status: 200, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - 'x-relay-rewritten-count': String(rewritten), - }, - }); -} - -async function uploadToR2(b64, ext, env) { - const binary = await base64ToBytes(b64); - const hash = await sha256Hex(binary); - const key = `images/${hash}.${ext}`; - const existing = await env.IMAGES.head(key); - if (!existing) { - const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`; - await env.IMAGES.put(key, binary, { - httpMetadata: { contentType: mime }, - }); - } - return key; -} - -// Determines the file extension for an image payload. Magic-byte sniffing -// wins over `claimed` because upstream sometimes silently returns PNG bytes -// while still echoing back the requested output_format (e.g. webp). Trusting -// `claimed` blindly results in .webp/.jpg URLs whose body is actually PNG. -function inferExt(claimed, b64) { - const head = (b64 || '').slice(0, 8); - if (head.startsWith('iVBOR')) return 'png'; - if (head.startsWith('/9j/')) return 'jpg'; - if (head.startsWith('UklG')) return 'webp'; - if (head.startsWith('R0lGOD')) return 'gif'; - const c = (claimed || '').toLowerCase(); - if (c === 'jpeg' || c === 'jpg') return 'jpg'; - if (c === 'png') return 'png'; - if (c === 'webp') return 'webp'; - return 'png'; -} - -// Yields control back to the runtime. Workers' isolate has a hidden ~2s -// synchronous-CPU ceiling; any unbroken JS loop past it is killed with -// `exceededCpu`. Yielding between chunks resets that window so a multi-MB -// base64 decode can run within the 30s per-request CPU budget. -const yieldNow = () => - typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function' - ? scheduler.yield() - : new Promise((r) => setTimeout(r, 0)); - -const BASE64_CHUNK = 1 << 18; // 256 KiB per yield - -async function base64ToBytes(b64) { - const bin = atob(b64); - const len = bin.length; - const arr = new Uint8Array(len); - for (let off = 0; off < len; off += BASE64_CHUNK) { - const end = Math.min(off + BASE64_CHUNK, len); - for (let i = off; i < end; i++) arr[i] = bin.charCodeAt(i); - if (end < len) await yieldNow(); - } - return arr; -} - -async function sha256Hex(buf) { - const hash = await crypto.subtle.digest('SHA-256', buf); - return Array.from(new Uint8Array(hash)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} - -// Limits for inline-image inputs. CF Workers have a 128MB CPU/memory budget -// per request; 25MB matches OpenAI's per-image cap, and 16 matches their -// images-edits batch limit. -const MAX_IMAGE_BYTES = 25 * 1024 * 1024; -const MAX_IMAGES_PER_REQUEST = 16; -const ALLOWED_IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp']; - -function pickMimeFromType(rawType) { - const t = (rawType || '').toLowerCase(); - if (t.includes('webp')) return 'image/webp'; - if (t.includes('jpeg') || t.includes('jpg')) return 'image/jpeg'; - if (t.includes('png')) return 'image/png'; - return null; -} - -// Resolves an image input — File/Blob, http(s) URL string, or pre-formed -// data: URI — into a data:image/;base64,... string. Validates type and -// caps size at MAX_IMAGE_BYTES. Returns { dataUri, mime } on success, throws -// Error('msg|status') where status is the HTTP status to surface. -async function normalizeImageInput(value) { - // Pre-formed data URI: validate prefix and pass through unchanged. - if (typeof value === 'string' && value.startsWith('data:')) { - const m = /^data:(image\/(?:png|jpeg|webp))(?:;[^,]*)?,/i.exec(value); - if (!m) throw new Error('image data URI must be image/png|jpeg|webp|400'); - return { dataUri: value, mime: m[1].toLowerCase() }; - } - - // http(s) URL: fetch, validate, base64-encode. - if (typeof value === 'string' && /^https?:\/\//i.test(value)) { - let resp; - try { - resp = await fetch(value, { - method: 'GET', - redirect: 'follow', - cf: { cacheEverything: false }, - }); - } catch (e) { - throw new Error(`failed to fetch image url: ${e instanceof Error ? e.message : String(e)}|400`); - } - if (!resp.ok) { - throw new Error(`image url returned ${resp.status}|400`); - } - const declaredType = resp.headers.get('content-type') || ''; - const declaredLen = parseInt(resp.headers.get('content-length') || '0', 10); - if (declaredLen && declaredLen > MAX_IMAGE_BYTES) { - throw new Error(`image url too large: ${declaredLen} bytes (max ${MAX_IMAGE_BYTES})|400`); - } - const buf = new Uint8Array(await resp.arrayBuffer()); - if (buf.length > MAX_IMAGE_BYTES) { - throw new Error(`image url too large: ${buf.length} bytes (max ${MAX_IMAGE_BYTES})|400`); - } - let mime = pickMimeFromType(declaredType); - if (!mime) { - const headB64 = await bytesToBase64(buf.subarray(0, 12)); - mime = pickMimeFromType('image/' + (inferExt('', headB64) || '')); - } - if (!mime) { - throw new Error(`image url content-type unsupported: "${declaredType}"|400`); - } - return { dataUri: `data:${mime};base64,${await bytesToBase64(buf)}`, mime }; - } - - // File / Blob from multipart. - if (value && typeof value === 'object' && typeof value.arrayBuffer === 'function') { - const mime = pickMimeFromType(value.type); - if (!mime) { - throw new Error(`unsupported image type "${value.type || 'unknown'}", use png, jpeg, or webp|400`); - } - const buf = new Uint8Array(await value.arrayBuffer()); - if (buf.length === 0) throw new Error('empty image file|400'); - if (buf.length > MAX_IMAGE_BYTES) { - throw new Error(`image too large: ${buf.length} bytes (max ${MAX_IMAGE_BYTES})|400`); - } - return { dataUri: `data:${mime};base64,${await bytesToBase64(buf)}`, mime }; - } - - throw new Error('image input must be a file, http(s) URL, or data: URI|400'); -} - -// Collects all image-like form fields used by the OpenAI Images-Edits API. -// Supports three idioms used by SDKs in the wild: -// - repeated 'image' fields (canonical) -// - 'image[]' suffix (some Python SDKs) -// - 'image[0]', 'image[1]', ... numbered (some JS SDKs) -function collectImageFields(formData) { - const out = []; - for (const v of formData.getAll('image')) out.push(v); - for (const v of formData.getAll('image[]')) out.push(v); - // Numbered: image[0], image[1], ... - const numbered = []; - for (const [k, v] of formData.entries()) { - const m = /^image\[(\d+)\]$/.exec(k); - if (m) numbered.push({ idx: parseInt(m[1], 10), v }); - } - numbered.sort((a, b) => a.idx - b.idx); - for (const n of numbered) out.push(n.v); - // Drop empty / blank-string entries. - return out.filter( - (v) => v !== null && v !== undefined && v !== '' && v !== 'undefined' - ); -} - -// Walks a /v1/responses request body and inlines any input_image.image_url -// that points at an http(s) URL. Returns the same object (mutated) plus the -// number of URLs that were inlined (for diagnostics). Errors propagate. -async function inlineInputImageUrls(body) { - if (!body || !Array.isArray(body.input)) return { body, inlined: 0 }; - let inlined = 0; - for (const item of body.input) { - if (!item || !Array.isArray(item.content)) continue; - for (const part of item.content) { - if ( - part && - part.type === 'input_image' && - typeof part.image_url === 'string' && - /^https?:\/\//i.test(part.image_url) - ) { - const { dataUri } = await normalizeImageInput(part.image_url); - part.image_url = dataUri; - inlined++; - } - } - } - return { body, inlined }; -} - -// Wraps fetch with fast-fail retry. Only retries on 502/503/504 that come back -// quickly (< fastFailMs) — those are upstream gateway-layer rejects (rate -// limit, transient overload) where the upstream model never started running, -// so re-issuing is safe and cheap. Slow failures (model already burned cycles) -// are returned as-is to avoid double-charging compute. -// -// init.body MUST be a re-readable value (string / Uint8Array / FormData), not -// a one-shot ReadableStream, otherwise the retry attempt sends an empty body. -async function fetchWithFastFailRetry(url, init, opts) { - const FAST_FAIL_MS = opts?.fastFailMs ?? 10000; - const MAX_ATTEMPTS = opts?.maxAttempts ?? 3; - const RETRY_STATUSES = opts?.retryStatuses ?? new Set([502, 503, 504]); - - let lastResp; - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - const start = Date.now(); - lastResp = await fetch(url, init); - const elapsed = Date.now() - start; - - if (lastResp.ok) return lastResp; - if (!RETRY_STATUSES.has(lastResp.status)) return lastResp; - if (elapsed >= FAST_FAIL_MS) return lastResp; - if (attempt === MAX_ATTEMPTS) return lastResp; - - // Drain body so the connection can be reused - try { await lastResp.body?.cancel(); } catch {} - - const backoffMs = 300 * attempt; - console.error( - `retry ${attempt + 1}/${MAX_ATTEMPTS} after fast fail status=${lastResp.status} elapsed=${elapsed}ms` - ); - await new Promise((r) => setTimeout(r, backoffMs)); - } - return lastResp; -} - -// ---- Classic /v1/images/generations adapter ------------------------------- -// -// Translates classic OpenAI Images-API requests into the Responses-API shape -// upstream, then re-shapes the upstream output back into the classic -// `{created, data: [{url|b64_json}]}` envelope so existing OpenAI SDK clients -// (and new-api's image-generation billing path) work unchanged. - -// Builds the {data, ...} portion of an Images-API envelope from a /v1/responses -// "response" object. Uploads any base64 image_generation_call.result to R2 and -// rewrites it as a public URL when IMAGE_BASE is configured. -async function buildImagesDataFromResponses(responsesData, opts, env) { - const { wantB64, originalPrompt } = opts; - const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); - const data = []; - let firstFormat; - let firstSize; - - for (const item of responsesData.output || []) { - if ( - item?.type === 'image_generation_call' && - typeof item.result === 'string' && - item.result.length > 100 - ) { - const ext = inferExt(item.output_format, item.result); - // Use the format inferred from actual bytes — upstream sometimes echoes - // the requested output_format while silently returning PNG, so trusting - // item.output_format would mismatch the file the client downloads. - if (!firstFormat) firstFormat = ext === 'jpg' ? 'jpeg' : ext; - if (!firstSize) firstSize = item.size; - const entry = {}; - if (wantB64) { - entry.b64_json = item.result; - } else { - const key = await uploadToR2(item.result, ext, env); - entry.url = imageBase - ? `${imageBase}/${key}` - : `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${item.result}`; - } - const revised = - (typeof item.revised_prompt === 'string' && item.revised_prompt) || - extractMessageText(responsesData) || - originalPrompt; - if (revised) entry.revised_prompt = revised; - data.push(entry); - } - } - return { data, firstFormat, firstSize }; -} - -// Detects model names that should be routed through the image pipeline, -// even when the caller arrives via /v1/chat/completions. -function isImageModel(model) { - if (!model || typeof model !== 'string') return false; - const m = model.toLowerCase(); - return ( - m.startsWith('gpt-image') || - m.startsWith('dall-e') || - m.startsWith('flux') || - m.startsWith('sd-') || - m.startsWith('stable-') || - m.includes('-image-') || - m.endsWith('-image') - ); -} - -function extractPromptFromMessages(messages) { - if (!Array.isArray(messages)) return ''; - // Take the last user message; fall back to concatenation of all user texts. - const userMsgs = messages.filter((m) => m && m.role === 'user'); - const target = userMsgs.length ? userMsgs[userMsgs.length - 1] : messages[messages.length - 1]; - if (!target) return ''; - if (typeof target.content === 'string') return target.content; - if (Array.isArray(target.content)) { - return target.content - .filter((p) => p && p.type === 'text' && typeof p.text === 'string') - .map((p) => p.text) - .join(' '); - } - return ''; -} - -// Bridges /v1/chat/completions → image pipeline → chat.completion envelope. -// The assistant message content embeds the generated image as a Markdown -// image link (most chat UIs render it natively). When the original request -// asked for stream:true, the response is delivered as a minimal SSE stream -// so streaming clients don't break. -async function handleChatCompletionsAsImage(reqBody, originalRequest, env, ctx) { - const prompt = extractPromptFromMessages(reqBody.messages); - if (!prompt) { - return jsonError(400, 'no user prompt found in messages'); - } - - // Forge an Images API request and reuse the existing handler. - const imageReq = { - model: reqBody.model, - prompt, - size: reqBody.size || '1024x1024', - output_format: reqBody.output_format || 'jpeg', - output_compression: - reqBody.output_compression !== undefined ? reqBody.output_compression : 85, - response_format: 'url', - }; - if (reqBody.background) imageReq.background = reqBody.background; - if (reqBody.quality) imageReq.quality = reqBody.quality; - if (reqBody.moderation) imageReq.moderation = reqBody.moderation; - - const syntheticRequest = new Request(originalRequest.url, { - method: 'POST', - headers: originalRequest.headers, - body: JSON.stringify(imageReq), - }); - - const imageResp = await handleImagesGenerations(syntheticRequest, env, ctx); - - if (!imageResp.ok) { - // Pass through upstream error as-is. - return imageResp; - } - - let imageJson; - try { - imageJson = await imageResp.json(); - } catch { - return jsonError(502, 'image handler returned non-json'); - } - - const first = (imageJson.data && imageJson.data[0]) || {}; - const url = typeof first.url === 'string' ? first.url : ''; - const b64 = typeof first.b64_json === 'string' ? first.b64_json : ''; - const revised = typeof first.revised_prompt === 'string' ? first.revised_prompt : ''; - - let content = ''; - if (url) { - content = `![image](${url})`; - } else if (b64) { - const ext = inferExt(imageJson.output_format, b64); - const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`; - content = `![image](data:${mime};base64,${b64})`; - } else { - content = '(image generation produced no result)'; - } - if (revised) { - content = content + `\n\n_Revised prompt: ${revised}_`; - } - - const chatId = 'chatcmpl-' + (crypto.randomUUID ? crypto.randomUUID().replace(/-/g, '') : Date.now().toString(36)); - const created = Math.floor(Date.now() / 1000); - const model = imageJson.model || reqBody.model; - - const usage = imageJson.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; - - // Streaming clients: emit a tiny SSE sequence (role / content / stop / DONE). - if (reqBody.stream) { - const enc = new TextEncoder(); - const body = - sseChunk({ - id: chatId, - object: 'chat.completion.chunk', - created, - model, - choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }], - }) + - sseChunk({ - id: chatId, - object: 'chat.completion.chunk', - created, - model, - choices: [{ index: 0, delta: { content }, finish_reason: null }], - }) + - sseChunk({ - id: chatId, - object: 'chat.completion.chunk', - created, - model, - choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], - }) + - 'data: [DONE]\n\n'; - return new Response(enc.encode(body), { - status: 200, - headers: { - 'content-type': 'text/event-stream; charset=utf-8', - 'cache-control': 'no-cache, no-transform', - 'access-control-allow-origin': '*', - 'x-relay-mode': 'chat-as-image-sse', - }, - }); - } - - const chatResp = { - id: chatId, - object: 'chat.completion', - created, - model, - choices: [ - { - index: 0, - message: { role: 'assistant', content }, - finish_reason: 'stop', - }, - ], - usage, - }; - - return new Response(JSON.stringify(chatResp), { - status: 200, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - 'x-relay-mode': 'chat-as-image', - }, - }); -} - -function sseChunk(obj) { - return 'data: ' + JSON.stringify(obj) + '\n\n'; -} - -// Walks a JSON request body and ensures every image_generation tool has -// moderation set; defaults to "low". Returns a new ArrayBuffer if mutated, -// otherwise returns the input untouched. -function injectImageModerationDefault(bodyBuffer) { - try { - const text = new TextDecoder().decode(bodyBuffer); - const obj = JSON.parse(text); - if (!obj || !Array.isArray(obj.tools)) return bodyBuffer; - let mutated = false; - for (const t of obj.tools) { - if (t && t.type === 'image_generation' && !t.moderation) { - t.moderation = 'low'; - mutated = true; - } - } - if (!mutated) return bodyBuffer; - return new TextEncoder().encode(JSON.stringify(obj)).buffer; - } catch { - return bodyBuffer; - } -} - -function jsonError(status, message) { - return new Response( - JSON.stringify({ error: { message, type: 'invalid_request_error' } }), - { - status, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - }, - } - ); -} - -async function handleImagesGenerations(request, env, ctx) { - let reqBody; - try { - reqBody = await request.json(); - } catch { - return jsonError(400, 'Invalid JSON body'); - } - - // Map classic params → image_generation tool config (only forward set fields) - const tool = { type: 'image_generation' }; - if (reqBody.size) tool.size = reqBody.size; - if (reqBody.quality) tool.quality = reqBody.quality; - if (reqBody.output_format) tool.output_format = reqBody.output_format; - if (reqBody.output_compression !== undefined) { - tool.output_compression = reqBody.output_compression; - } - if (reqBody.background) tool.background = reqBody.background; - // Default moderation to "low" for the lowest false-positive rate; client can - // still override by passing moderation explicitly. - tool.moderation = reqBody.moderation || 'low'; - - const responsesBody = { - model: reqBody.model || 'gpt-image-2', - input: reqBody.prompt ?? reqBody.input ?? '', - tools: [tool], - }; - - const upstreamHeaders = new Headers(); - upstreamHeaders.set('content-type', 'application/json'); - if (env.UPSTREAM_KEY) { - upstreamHeaders.set('authorization', `Bearer ${env.UPSTREAM_KEY}`); - } else { - const incoming = request.headers.get('authorization'); - if (incoming) upstreamHeaders.set('authorization', incoming); - } - - const upstreamResp = await fetchWithFastFailRetry(getUpstream(env) + '/v1/responses', { - method: 'POST', - headers: upstreamHeaders, - body: JSON.stringify(responsesBody), - }); - - if (!upstreamResp.ok) { - const errBody = await upstreamResp.text(); - console.error( - `images/generations upstream ${upstreamResp.status}: ${errBody.slice(0, 200)}` - ); - return new Response(errBody, { - status: upstreamResp.status, - headers: { - 'content-type': upstreamResp.headers.get('content-type') || 'text/plain', - 'access-control-allow-origin': '*', - }, - }); - } - - const responsesData = await upstreamResp.json(); - const wantB64 = reqBody.response_format === 'b64_json'; - const originalPrompt = String(reqBody.prompt ?? reqBody.input ?? ''); - - const { data, firstFormat, firstSize } = await buildImagesDataFromResponses( - responsesData, - { wantB64, originalPrompt }, - env - ); - - const out = { - created: Math.floor(Date.now() / 1000), - data, - background: responsesData.background || reqBody.background || 'auto', - output_format: firstFormat || reqBody.output_format || 'png', - quality: reqBody.quality || 'auto', - size: reqBody.size || firstSize || '1024x1024', - usage: responsesData.usage, - model: responsesData.model || reqBody.model, - }; - - return new Response(JSON.stringify(out), { - status: 200, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - 'x-relay-mode': 'images-via-responses', - 'x-relay-rewritten-count': String(data.length), - }, - }); -} - -// ---- Classic /v1/images/edits adapter ------------------------------------- -// -// Translates classic OpenAI Images-Edits requests (multipart/form-data with -// an image file and a text prompt) into the Responses-API multimodal shape -// upstream, then re-shapes the response back into the classic Images-API -// envelope. - -async function handleImagesEdits(request, env, ctx) { - let formData; - try { - formData = await request.formData(); - } catch { - return jsonError(400, 'Invalid multipart/form-data body'); - } - - const imageFields = collectImageFields(formData); - const prompt = formData.get('prompt'); - if (imageFields.length === 0) { - return jsonError(400, '"image" field is required (file, http(s) URL, or data: URI)'); - } - if (imageFields.length > MAX_IMAGES_PER_REQUEST) { - return jsonError( - 400, - `too many images: ${imageFields.length} (max ${MAX_IMAGES_PER_REQUEST})` - ); - } - if (!prompt || typeof prompt !== 'string') { - return jsonError(400, '"prompt" field is required'); - } - - // Normalize all images in parallel; surface the first failing one. - let imageDataUris; - try { - imageDataUris = await Promise.all(imageFields.map((v) => normalizeImageInput(v))); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const sep = msg.lastIndexOf('|'); - const status = sep >= 0 ? parseInt(msg.slice(sep + 1), 10) || 400 : 400; - const text = sep >= 0 ? msg.slice(0, sep) : msg; - return jsonError(status, text); - } - - const model = String(formData.get('model') || 'gpt-image-2'); - const size = formData.get('size'); - const quality = formData.get('quality'); - const outputFormat = formData.get('output_format'); - const outputCompression = formData.get('output_compression'); - const background = formData.get('background'); - const moderation = formData.get('moderation'); - const responseFormat = formData.get('response_format'); - - // Build /v1/responses tool config from form fields - const tool = { type: 'image_generation' }; - if (size) tool.size = String(size); - if (quality) tool.quality = String(quality); - if (outputFormat) tool.output_format = String(outputFormat); - if ( - outputCompression !== null && - outputCompression !== undefined && - outputCompression !== '' - ) { - const n = parseInt(String(outputCompression), 10); - if (!Number.isNaN(n)) tool.output_compression = n; - } - if (background) tool.background = String(background); - // Default moderation to "low" for the lowest false-positive rate. - tool.moderation = moderation ? String(moderation) : 'low'; - - const userContent = [{ type: 'input_text', text: String(prompt) }]; - for (const { dataUri } of imageDataUris) { - userContent.push({ type: 'input_image', image_url: dataUri }); - } - const responsesBody = { - model, - input: [{ role: 'user', content: userContent }], - tools: [tool], - }; - - const upstreamHeaders = new Headers(); - upstreamHeaders.set('content-type', 'application/json'); - if (env.UPSTREAM_KEY) { - upstreamHeaders.set('authorization', `Bearer ${env.UPSTREAM_KEY}`); - } else { - const incoming = request.headers.get('authorization'); - if (incoming) upstreamHeaders.set('authorization', incoming); - } - - const upstreamResp = await fetchWithFastFailRetry(getUpstream(env) + '/v1/responses', { - method: 'POST', - headers: upstreamHeaders, - body: JSON.stringify(responsesBody), - }); - - if (!upstreamResp.ok) { - const errBody = await upstreamResp.text(); - console.error( - `images/edits upstream ${upstreamResp.status}: ${errBody.slice(0, 200)}` - ); - return new Response(errBody, { - status: upstreamResp.status, - headers: { - 'content-type': upstreamResp.headers.get('content-type') || 'text/plain', - 'access-control-allow-origin': '*', - }, - }); - } - - const responsesData = await upstreamResp.json(); - const wantB64 = responseFormat === 'b64_json'; - const originalPrompt = String(prompt); - - const { data, firstFormat, firstSize } = await buildImagesDataFromResponses( - responsesData, - { wantB64, originalPrompt }, - env - ); - - const out = { - created: Math.floor(Date.now() / 1000), - data, - background: responsesData.background || (background ? String(background) : 'auto'), - output_format: firstFormat || (outputFormat ? String(outputFormat) : 'png'), - quality: quality ? String(quality) : 'auto', - size: size ? String(size) : firstSize || '1024x1024', - usage: responsesData.usage, - model: responsesData.model || model, - }; - - return new Response(JSON.stringify(out), { - status: 200, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - 'x-relay-mode': 'edits-via-responses', - 'x-relay-rewritten-count': String(data.length), - }, - }); -} - -async function bytesToBase64(bytes) { - let bin = ''; - const chunk = 0x8000; // 32 KiB per fromCharCode.apply call - const yieldEvery = 64; // yield every ~2 MiB of input - let sinceYield = 0; - for (let i = 0; i < bytes.length; i += chunk) { - bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); - if (++sinceYield >= yieldEvery && i + chunk < bytes.length) { - sinceYield = 0; - await yieldNow(); - } - } - return btoa(bin); -} - -// Pulls the first non-empty `output_text` from any message item in a -// /v1/responses payload. Used as one of the fallbacks for revised_prompt -// when adapting back to the classic Images API envelope. -function extractMessageText(responsesData) { - for (const item of responsesData.output || []) { - if (item?.type === 'message' && Array.isArray(item.content)) { - for (const part of item.content) { - if ( - part?.type === 'output_text' && - typeof part.text === 'string' && - part.text.length > 0 - ) { - return part.text; - } - } - } - } - return ''; -} - -// ---- SSE rewrite ---------------------------------------------------------- -// -// For streaming /v1/responses (stream=true), parse the SSE event stream on -// the fly. Any event carrying base64 image data has its payload uploaded to -// R2 and the base64 field replaced with a public URL pointing to IMAGE_BASE. -// Other event types are forwarded unchanged. - -function rewriteSSE(upstreamResp, env, ctx) { - const { readable, writable } = new TransformStream(); - ctx.waitUntil(processSSEStream(upstreamResp.body, writable, env)); - const headers = new Headers(); - headers.set('content-type', 'text/event-stream; charset=utf-8'); - headers.set('cache-control', 'no-cache, no-transform'); - headers.set('connection', 'keep-alive'); - headers.set('access-control-allow-origin', '*'); - headers.set('x-relay-mode', 'sse'); - return new Response(readable, { status: 200, headers }); -} - -async function processSSEStream(upstreamBody, writable, env) { - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - const reader = upstreamBody.getReader(); - const writer = writable.getWriter(); - - // Per-request state: image_generation_call items completed so far. Used to - // refill response.completed.response.output when upstream sends it empty. - const state = { collectedItems: [] }; - - let buffer = ''; - let modified = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let idx; - while ((idx = buffer.indexOf('\n\n')) >= 0) { - const eventText = buffer.slice(0, idx); - buffer = buffer.slice(idx + 2); - const result = await processSSEEvent(eventText, env, state); - if (result.changed) modified++; - await writer.write(encoder.encode(result.text + '\n\n')); - } - } - buffer += decoder.decode(); - if (buffer.trim().length > 0) { - const result = await processSSEEvent(buffer, env, state); - await writer.write(encoder.encode(result.text)); - } - if (modified > 0) console.log(`SSE: rewrote ${modified} event(s)`); - } catch (e) { - console.error('SSE rewrite error:', e instanceof Error ? e.message : String(e)); - } finally { - try { await writer.close(); } catch {} - } -} - -async function processSSEEvent(eventText, env, state) { - const lines = eventText.split('\n'); - let dataIdx = -1; - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('data: ')) { - dataIdx = i; - break; - } - } - if (dataIdx < 0) return { text: eventText, changed: false }; - - const dataStr = lines[dataIdx].slice(6); - let obj; - try { obj = JSON.parse(dataStr); } catch { return { text: eventText, changed: false }; } - - const imageBase = (env.IMAGE_BASE || '').replace(/\/$/, ''); - if (!imageBase) return { text: eventText, changed: false }; - - let changed = false; - - // partial_image — bulky preview frame - if ( - obj.type === 'response.image_generation_call.partial_image' && - typeof obj.partial_image_b64 === 'string' && - obj.partial_image_b64.length > 100 - ) { - const ext = inferExt(obj.output_format, obj.partial_image_b64); - const key = await uploadToR2(obj.partial_image_b64, ext, env); - obj.partial_image_url = `${imageBase}/${key}`; - delete obj.partial_image_b64; - changed = true; - } - - // output_item.done — completed image (terminal frame) - if ( - obj.type === 'response.output_item.done' && - obj.item?.type === 'image_generation_call' && - typeof obj.item.result === 'string' && - obj.item.result.length > 100 - ) { - const ext = inferExt(obj.item.output_format, obj.item.result); - const key = await uploadToR2(obj.item.result, ext, env); - obj.item.result = `${imageBase}/${key}`; - if (obj.item.status === 'generating') obj.item.status = 'completed'; - changed = true; - } - - // Track completed image-generation items so we can refill response.completed - // when upstream leaves response.output as []. Use a shallow clone to avoid - // accidental mutation if the same item is touched again later. - if ( - obj.type === 'response.output_item.done' && - obj.item?.type === 'image_generation_call' && - state && - Array.isArray(state.collectedItems) - ) { - state.collectedItems.push(JSON.parse(JSON.stringify(obj.item))); - } - - // response.completed — rewrite any base64 still inside, fix status, and - // refill response.output if upstream sent it empty. - if (obj.type === 'response.completed' && obj.response) { - if (Array.isArray(obj.response.output)) { - for (const item of obj.response.output) { - if ( - item?.type === 'image_generation_call' && - typeof item.result === 'string' && - item.result.length > 100 - ) { - const ext = inferExt(item.output_format, item.result); - const key = await uploadToR2(item.result, ext, env); - item.result = `${imageBase}/${key}`; - if (item.status === 'generating') item.status = 'completed'; - changed = true; - } - } - if ( - obj.response.output.length === 0 && - state && - Array.isArray(state.collectedItems) && - state.collectedItems.length > 0 - ) { - obj.response.output = state.collectedItems.slice(); - changed = true; - } - } - } - - if (!changed) return { text: eventText, changed: false }; - lines[dataIdx] = 'data: ' + JSON.stringify(obj); - return { text: lines.join('\n'), changed: true }; -} From 00b5724233d3f1ed1ab49fe77a65f321ae66a5ed Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sun, 10 May 2026 14:30:42 +0800 Subject: [PATCH 17/45] fix(image-stream): merge tool_usage.image_gen into billing usage The upstream /v1/responses payload splits cost across two places: - response.usage LLM reasoning only (~40-200 tokens) - tool_usage.image_gen.* actual image cost (often thousands) Previously the handler forwarded only response.usage, so new-api's record_consume_log showed prompt_tokens=1 / completion_tokens=0 even when a real 4K image was rendered. mergeUsage() now sums both halves and mirrors the responses-API counters (input_tokens/output_tokens) into the legacy PromptTokens/CompletionTokens fields the billing path keys off, plus per-modality details (image_tokens / text_tokens) so logs can attribute cost correctly. Also surfaces the merged usage in the response envelope so clients see realistic numbers, not just the reasoning slice. --- relay/channel/openai/image_stream/handler.go | 49 ++++++++++++++++++-- relay/channel/openai/image_stream/sse.go | 29 ++++++++++-- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/relay/channel/openai/image_stream/handler.go b/relay/channel/openai/image_stream/handler.go index cf4e5c62a59a..2491d01467ce 100644 --- a/relay/channel/openai/image_stream/handler.go +++ b/relay/channel/openai/image_stream/handler.go @@ -266,7 +266,7 @@ func buildImagesResponse(ctx context.Context, agg *UpstreamResponse, req *dto.Im out := &imageEnvelope{ Created: time.Now().Unix(), Model: agg.Model, - Usage: agg.Usage, + Usage: mergeUsage(agg), } var firstFormat, firstSize string @@ -327,15 +327,54 @@ func buildImagesResponse(ctx context.Context, agg *UpstreamResponse, req *dto.Im return out, nil } +// mergeUsage flattens upstream's split usage representation into the single +// dto.Usage shape new-api's billing path expects. /v1/responses splits the +// cost across two fields: +// - response.usage — LLM reasoning only (40-200 tokens) +// - tool_usage.image_gen.* — image cost (often thousands of tokens) +// Forwarding only response.usage means logs show prompt/completion tokens +// near 0 even when an actual high-res image was rendered. We merge both. +func mergeUsage(agg *UpstreamResponse) *dto.Usage { + u := &dto.Usage{} + if agg.Usage != nil { + u = agg.Usage + } + if agg.ToolUsage != nil && agg.ToolUsage.ImageGen != nil { + ig := agg.ToolUsage.ImageGen + // Add image-gen input/output to the running totals. + u.InputTokens += ig.InputTokens + u.OutputTokens += ig.OutputTokens + u.TotalTokens += ig.TotalTokens + // Surface details so per-modality logs can attribute image cost. + if u.InputTokensDetails == nil { + u.InputTokensDetails = &dto.InputTokenDetails{} + } + u.InputTokensDetails.ImageTokens += ig.InputTokensDetails.ImageTokens + u.InputTokensDetails.TextTokens += ig.InputTokensDetails.TextTokens + u.CompletionTokenDetails.ImageTokens += ig.OutputTokensDetails.ImageTokens + u.CompletionTokenDetails.TextTokens += ig.OutputTokensDetails.TextTokens + } + // new-api's billing path keys off PromptTokens/CompletionTokens (the + // legacy chat-style names). Mirror the responses-API counts into them + // so log_consume_log surfaces real numbers instead of zeros. + if u.PromptTokens == 0 && u.InputTokens > 0 { + u.PromptTokens = u.InputTokens + } + if u.CompletionTokens == 0 && u.OutputTokens > 0 { + u.CompletionTokens = u.OutputTokens + } + if u.TotalTokens == 0 { + u.TotalTokens = u.PromptTokens + u.CompletionTokens + } + return u +} + // applyBilling triggers the standard quota-consume path so this endpoint // integrates with the same billing system as everything else. Falls back to // PromptTokens=1/TotalTokens=1 if upstream gave us nothing usable, matching // the existing image-handler behavior. func applyBilling(c *gin.Context, info *relaycommon.RelayInfo, agg *UpstreamResponse, req *dto.ImageRequest) { - usage := &dto.Usage{} - if agg.Usage != nil { - usage = agg.Usage - } + usage := mergeUsage(agg) if usage.TotalTokens == 0 { usage.TotalTokens = 1 } diff --git a/relay/channel/openai/image_stream/sse.go b/relay/channel/openai/image_stream/sse.go index 0dfe4addd055..bee98dc1031c 100644 --- a/relay/channel/openai/image_stream/sse.go +++ b/relay/channel/openai/image_stream/sse.go @@ -36,14 +36,35 @@ type UpstreamItem struct { Status string `json:"status,omitempty"` } +// UpstreamToolUsage carries image_gen costs reported in tool_usage.image_gen. +// Upstream surfaces image_tokens here (typically thousands per render), +// while response.usage only reports the LLM reasoning slice (~40-200 tokens). +// Merging the two is what makes billing reflect actual cost. +type UpstreamToolUsage struct { + ImageGen *struct { + InputTokens int `json:"input_tokens"` + InputTokensDetails struct { + ImageTokens int `json:"image_tokens"` + TextTokens int `json:"text_tokens"` + } `json:"input_tokens_details"` + OutputTokens int `json:"output_tokens"` + OutputTokensDetails struct { + ImageTokens int `json:"image_tokens"` + TextTokens int `json:"text_tokens"` + } `json:"output_tokens_details"` + TotalTokens int `json:"total_tokens"` + } `json:"image_gen,omitempty"` +} + // UpstreamResponse is the slice of /v1/responses we use. The SDK doesn't // model the image-generation tool fully; we keep this shape narrow to avoid // fighting upstream schema drift. type UpstreamResponse struct { - Model string `json:"model,omitempty"` - Background string `json:"background,omitempty"` - Output []UpstreamItem `json:"output,omitempty"` - Usage *dto.Usage `json:"usage,omitempty"` + Model string `json:"model,omitempty"` + Background string `json:"background,omitempty"` + Output []UpstreamItem `json:"output,omitempty"` + Usage *dto.Usage `json:"usage,omitempty"` + ToolUsage *UpstreamToolUsage `json:"tool_usage,omitempty"` } // AggregateResponseStream reads an SSE event stream and returns the final From 45a1a56eb5d2c4d658865acab53c0f3916969330 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sun, 10 May 2026 14:37:38 +0800 Subject: [PATCH 18/45] debug(image-stream): unconditional log of upstream usage/tool_usage on completed --- relay/channel/openai/image_stream/sse.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/relay/channel/openai/image_stream/sse.go b/relay/channel/openai/image_stream/sse.go index bee98dc1031c..17e5c6d88872 100644 --- a/relay/channel/openai/image_stream/sse.go +++ b/relay/channel/openai/image_stream/sse.go @@ -115,6 +115,17 @@ func AggregateResponseStream(body io.Reader) (*UpstreamResponse, error) { if err := common.UnmarshalJsonStr(data, &ev); err == nil && ev.Response != nil { snapshot = ev.Response seenCompleted = true + usageInfo := "" + if ev.Response.Usage != nil { + usageInfo = fmt.Sprintf("input=%d output=%d", ev.Response.Usage.InputTokens, ev.Response.Usage.OutputTokens) + } + toolUsageInfo := "" + if ev.Response.ToolUsage != nil && ev.Response.ToolUsage.ImageGen != nil { + ig := ev.Response.ToolUsage.ImageGen + toolUsageInfo = fmt.Sprintf("img_gen{input=%d output=%d image_tokens=%d}", + ig.InputTokens, ig.OutputTokens, ig.OutputTokensDetails.ImageTokens) + } + common.SysLog(fmt.Sprintf("image_stream completed: usage=%s tool=%s", usageInfo, toolUsageInfo)) } case "response.in_progress", "response.created": if snapshot == nil { From b55d5b02f2f452c4f6fc9010dbb2d7ab378cf1e5 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sun, 10 May 2026 14:45:01 +0800 Subject: [PATCH 19/45] debug: log unmarshal errors on response.completed event --- relay/channel/openai/image_stream/sse.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/relay/channel/openai/image_stream/sse.go b/relay/channel/openai/image_stream/sse.go index 17e5c6d88872..310c6143e061 100644 --- a/relay/channel/openai/image_stream/sse.go +++ b/relay/channel/openai/image_stream/sse.go @@ -112,7 +112,12 @@ func AggregateResponseStream(body io.Reader) (*UpstreamResponse, error) { var ev struct { Response *UpstreamResponse `json:"response"` } - if err := common.UnmarshalJsonStr(data, &ev); err == nil && ev.Response != nil { + if err := common.UnmarshalJsonStr(data, &ev); err != nil { + common.SysError(fmt.Sprintf("image_stream completed unmarshal err: %s data_len=%d data_head=%s", + err.Error(), len(data), data[:min(len(data), 200)])) + } else if ev.Response == nil { + common.SysError("image_stream completed: ev.Response is nil after unmarshal") + } else { snapshot = ev.Response seenCompleted = true usageInfo := "" From b393360e1b5fc365196640776da2098a9037d310 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sun, 10 May 2026 14:51:02 +0800 Subject: [PATCH 20/45] debug: log every response.completed event seen by parser --- relay/channel/openai/image_stream/sse.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/relay/channel/openai/image_stream/sse.go b/relay/channel/openai/image_stream/sse.go index 310c6143e061..537657ef0401 100644 --- a/relay/channel/openai/image_stream/sse.go +++ b/relay/channel/openai/image_stream/sse.go @@ -99,6 +99,9 @@ func AggregateResponseStream(body io.Reader) (*UpstreamResponse, error) { if err := common.UnmarshalJsonStr(data, &probe); err != nil { continue } + if probe.Type == "response.completed" || probe.Type == "response.failed" || probe.Type == "error" { + common.SysLog(fmt.Sprintf("image_stream sse event: type=%s data_len=%d head=%s", probe.Type, len(data), data[:min(len(data), 200)])) + } switch probe.Type { case "response.output_item.done": From 3ed3c0ef10bf4092103754a65655dc3d91b5a6ef Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sun, 10 May 2026 14:55:36 +0800 Subject: [PATCH 21/45] debug: log SSE pump end --- relay/channel/openai/image_stream/sse.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/relay/channel/openai/image_stream/sse.go b/relay/channel/openai/image_stream/sse.go index 537657ef0401..aa8ac2267db4 100644 --- a/relay/channel/openai/image_stream/sse.go +++ b/relay/channel/openai/image_stream/sse.go @@ -161,8 +161,11 @@ func AggregateResponseStream(body io.Reader) (*UpstreamResponse, error) { break } } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("SSE scan: %w", err) + scanErr := scanner.Err() + common.SysLog(fmt.Sprintf("image_stream sse done: seenCompleted=%v collected=%d snapshot_nil=%v scan_err=%v", + seenCompleted, len(collected), snapshot == nil, scanErr)) + if scanErr != nil { + return nil, fmt.Errorf("SSE scan: %w", scanErr) } if snapshot == nil { From afe9fdb6594eec60ef2cf5bea31383233bb09758 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sun, 10 May 2026 15:00:10 +0800 Subject: [PATCH 22/45] fix(image-stream): drop strict typing for upstream background field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream returns response.background as a bool (`false`) in some events and as a string (`"opaque"`) in others. Declaring it as string failed the whole response.completed unmarshal with: json: cannot unmarshal bool into Go struct field UpstreamResponse.response.background of type string That single field's type mismatch was silently dropping the entire response.completed event — leaving snapshot.Usage / snapshot.ToolUsage both nil, so billing logged prompt_tokens=1 / completion_tokens=0 even on successful generations. Removed the field from UpstreamResponse since the envelope doesn't need it (the client controls background on input). Usage and tool_usage now flow through correctly. --- relay/channel/openai/image_stream/handler.go | 3 --- relay/channel/openai/image_stream/sse.go | 14 +++++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/relay/channel/openai/image_stream/handler.go b/relay/channel/openai/image_stream/handler.go index 2491d01467ce..6e0df4a0216e 100644 --- a/relay/channel/openai/image_stream/handler.go +++ b/relay/channel/openai/image_stream/handler.go @@ -318,9 +318,6 @@ func buildImagesResponse(ctx context.Context, agg *UpstreamResponse, req *dto.Im } out.OutputFormat = firstFormat out.Size = firstSize - if agg.Background != "" { - out.Background = agg.Background - } if req.Quality != "" { out.Quality = req.Quality } diff --git a/relay/channel/openai/image_stream/sse.go b/relay/channel/openai/image_stream/sse.go index aa8ac2267db4..424b63d368ec 100644 --- a/relay/channel/openai/image_stream/sse.go +++ b/relay/channel/openai/image_stream/sse.go @@ -59,12 +59,16 @@ type UpstreamToolUsage struct { // UpstreamResponse is the slice of /v1/responses we use. The SDK doesn't // model the image-generation tool fully; we keep this shape narrow to avoid // fighting upstream schema drift. +// +// `background` is intentionally omitted — upstream sometimes sends it as +// bool (`false`), sometimes as string (`"opaque"`), and our envelope doesn't +// need it. Adding a strict type for it would fail the entire unmarshal of +// response.completed and silently zero out usage/tool_usage billing. type UpstreamResponse struct { - Model string `json:"model,omitempty"` - Background string `json:"background,omitempty"` - Output []UpstreamItem `json:"output,omitempty"` - Usage *dto.Usage `json:"usage,omitempty"` - ToolUsage *UpstreamToolUsage `json:"tool_usage,omitempty"` + Model string `json:"model,omitempty"` + Output []UpstreamItem `json:"output,omitempty"` + Usage *dto.Usage `json:"usage,omitempty"` + ToolUsage *UpstreamToolUsage `json:"tool_usage,omitempty"` } // AggregateResponseStream reads an SSE event stream and returns the final From faae9637f95f246c6d5df77d0b1ce92bb384c62f Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Fri, 15 May 2026 17:25:45 +0800 Subject: [PATCH 23/45] chore: refresh website icons --- web/classic/public/favicon.ico | Bin 15406 -> 8428 bytes web/classic/public/logo.png | Bin 9597 -> 26543 bytes web/default/public/favicon.ico | Bin 15406 -> 8428 bytes web/default/public/logo.png | Bin 9597 -> 26543 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/web/classic/public/favicon.ico b/web/classic/public/favicon.ico index ab5f17bcdb35e96cf7673ca8fa7ba8d6a33bd7ce..8e76c5a7fb405f4ae281b5315ed47520d43a4031 100644 GIT binary patch literal 8428 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 diff --git a/web/classic/public/logo.png b/web/classic/public/logo.png index 851556f62db58d4267e096a39d2e9de88e6688c5..59eb730f4023e29db3e43545f71ae81c5374d76b 100644 GIT binary patch literal 26543 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∓M? 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 aG3a-DviBvqQ0G6M3ZNjPDqSUM7WzNIJEblF diff --git a/web/default/public/favicon.ico b/web/default/public/favicon.ico index ab5f17bcdb35e96cf7673ca8fa7ba8d6a33bd7ce..8e76c5a7fb405f4ae281b5315ed47520d43a4031 100644 GIT binary patch literal 8428 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 diff --git a/web/default/public/logo.png b/web/default/public/logo.png index 851556f62db58d4267e096a39d2e9de88e6688c5..59eb730f4023e29db3e43545f71ae81c5374d76b 100644 GIT binary patch literal 26543 zcmV)ZK&!urP)*Oc{YBg-8;AK?%lrH)k<3RRkI~owk$V{F)lK=0Re29vGWCEk^nK~gXGT_@+I&Q z0@yf-fdIj@&~X85Y%p%PTe2-#Z6&R=yV_oEn>+RYd(WAfduHy;y)$?3%EHOn-`(l$ zoM+y7-g4fEW~jq4bTe(hWP;bWV{3?t1J{ajgxr&dLO%B4Yd$pUT7fC_qss} zvw%{`s59L#PQrD=o--m1}jRn|NZJ$?-f-rxgQaLPOYX#}4 zLN)3IB;!v2)bl#_)lh&=mSkOM5Hn`Q*BkWKIT!l#Z_IQ5dTgupdMDi)QR{Kc0!w7( zH6W#H0k9EbeZ9^TW_#U^TS!}R*khOl0${!ANv-=XR71`eltvBrP^&_@;Pz_f6aaHQ zfLsqLU2n4;bCMg^sKfetNbH4>)`5>%NNH4Wsmz=rQuT8A z%mRSf@0@95oQ?LS0=5rPHfeO;ea;GR_JD%kjKmWV0GSJ3C!bSa6Mk z9!oZ=nyN4{Mc1;~yv}igF+IIs9~pr?;9m!W(5S~f)J>Wi3wW+->w|6&b5d0A8?@zO_*gB47r})XqJU4J+m__+97NG)t4gj%^5E2kV z5mdRz2qA?!#%!b*Lh*LJuXB+eiNv);f>8$thqdwXY1jvLWyo*5o&YuKbe!M1O6#j_ z3w2CAO;rnJk*JqMt_b3`wl*}5F)|LofgihM$)*U)iqXS|5AX<0F=i!l0M_2+XEtu! z&>!1&_L)mtThozLL#n5(wSAEyODfCpO^r>BD-{LsijZL#$j}X>8##J=Ge9U~!Rzdb(NC{l+n4lmA(4cMGE)iN=JK~Xukr*GJ79V@; z_bNgeW6QahH|>1GMV+Uev3XN-OY`DXGO>migl<9LTNuPNMxr7S&m4zz0Isef0JC8r zr0WF4x(#Xr8HSD+mc`8h`@p`MrXfj^kfy4$MY%XB%koe*E4?x^J#%c&OE2wu;?XBg z?0WY8DTGjw+INiHU?o%JgT>~^>dpyuTC{Lw>3^r&&Ar>(hX*XoN)jr{3Q|>7!xEPWK?c}^Vc;nRSO764l?lL0+dU<= zwT}+l$!lRC13(Y^0L=lA3qk}ThC%sU7RhpPI5U@da%_C;*~cGw{K;Sb*Vm3BOz68& zUtg~*2uxw$zQY>f+oRQ_c9`W;8q|SIAt#A1&j;9k+rgw}OXE%FpD(UzUzwKWd?Oc) zB~IJCspEokcbwJT+r1+yMmHrBsRY!EQ~&_W05nWR3r#9?nVy~+Ppw?OvgzVCUbKGAx-}Oyq*B`w$pq#g`CJ|;s-iO>bdkYgQD`c3WD2#5 z|KVE3pw=`3Xa^|ua;jm@6T0CBwI_hfCA~wM>p(?7g6qK78N_fPy){ikxqR-#+|2AF zd-uHZi+}&pSDr&?Dl3Z1QEzWgVgLSDOp{ERm84eSE+p3uvTfdgUT81}-R8evOAAW_ znT?8Vb933`8Cy2BUv~MWJ9?KaetjaD*cypMP(hNAEENGth+zQ0@B`4miL9wfC&-Km zY*GMVnyQt6Rt99fjCNE5YB{J)z|-e2&kHXU2DhpjnxX5usxb`5u>vn(nv~0CA3HuY zc=z|d{k;bU4?Q~pw(}J$mKP2lJdih^ppwLPFW0i4*K(M3LAKQl==p-VUQ*d-9t17s z$l6s`UDv?J;vLgdGl|Q0zH!;>&Oi73*7o*`o0}V#sfvcO*&MK6V5ckq*#a2zxGTt))Z8c-0HVHw7%5(ik#m2Obe)T5G=CRD^gvZ4l~9X_Bg zZKq9q{`w;{n3h0vs=8*HYRMxJz;?EiqO-+l$sF;4sTdv-^W%H(Yx3o6&=kp3U zDl~>+9JIn>7uFKJ-j8~e!0ZA!1!yQBp%t|p)V9Q>EgfEzBM|sY)Wb_E>wZ&{j;NZZ z^I#+41vED|yKCUUfiHjaU%&J?Lg`}Xij}z&2M^^uKuy)!yyt@~`>m_GtrDQ?3Fi3# zn_35fK}{@3<(8J!+?8*=q-SDyGX1)XUf+Aw)tBGc-rjm4fGVHM1GQjSmNhLEr877I zNlV%aQkvq@Zsm}s9hgt6I#M*%Y3cAa(FNt(-uqcUn?GyLsUNrr&(?IF7dUu;>FMeF z9(nMA&p-P6dyY1Bt~FR$nVy=;sR+p&LcDGUNZC*7ng*Q@v#rv)irf4E{c3{QXa6=+ zd6`r$d&|3=Wu9N)lzyA2+fawoV!UCxuerF13Q zL1EtpH2f4rL#nQMfZB^%xTUn00_bDu@Zrw7hjjMbrfHx&A|V=Oiv?+FbY%2j@A&Fh z?n1I!?Ca~#4GcVyMW}@%%C;v3p_fkyP??v~FR|O4a`SLx0mVe0aF!P1jtp z_OeSa{)@J@^clIF1a4@Zn_D*xhY$Wd_{gI(Qv@ZPVrSM6*nH9^6?AKt-(mH24R$>_`OVGMUHz`1s@h@X)V* zxM$Jwt@`kxV^c(sgr*>`3aH(HUMMivNHI!^- z7!w=j<}!pQv!L<0&fo$tHK`({DL`BMn(M$djj+cXq$oa|VOS1po4`w9iUEfhZ27Py ztWGI&x{l*P;7q!zfkOueG^r}F{o(zP9TUP-G~Lw#V7=Vjr>ZLW+qGCM&dZ8CJ9y;C zXTEvI=YHPUu@NDWnVvnmOQ8fy7y#FUWBaWu+eQQW6a+J-7%C`!$#mnA&i2mc8~@k) z-oIq=;%j8Nh-6vTIgVq&AA%1gCOOxWI_=8V#9P|B0d4bUEBJ^7UJk(JIk0ju*!7$* zpB3N^#gQhAnRrH!?L4m9LICK5JA(ElmGe6JO2F()6v{bJJ67#{)6S2yv^1`t znau*gz$s=U8T4t?|Nxs@c10Dg7|`(ohb^vY==^Qc>+G%1RGnm{wVqyA1=y0njYR z$00%vqVz1w1u@w{{Bse)PxJ5^>bj8^1b&ERj0rIsJr3t@YKR|C#l@_qA$DkNyk}xM z(?30%-73k-DuIt+&m@3b*Hm5Afqw%Z_Xo0L*(ITA>22x(b}@8a6Jv2sQRLZ|UwrB2 zAK!Jy6J32<)RBSXlO<4BwOOB3P*(!H^O{6N_+S zg{~W|q8P)0`q-iY99U6-i{fj-JiHyUsxq2}UllB=i6KPC$}71(gILZo$}kwL#X&Ho|w(#(<5WkD@VqscFbk-TX`YE7K=qB7bW~EYMRE@6VPTI zX<)WyIZhiIJofi@e&w^@Xz$$+85b&hd;7>#p25*CuVhQNWgO01gsaJkyBb*kL#rr0uaG65|77G zE}u69o_{J9&W6BEF6bl09{jO5^?d#RZF|>?`ms4J}@}Ca^Jw<6_Yb_ z+klN>U~AwyA;9*O)ILg~U$J;~GS$G1j-L4O7ytfKpX}&ZqpIqVAE?br@toE&@3maE z&JE~#*_lIt8H&!dwXw!EfA+qW0OpV0`d1%awrt6zGc&U)!yul4_vF}q{e7IEkH z=HaK*bySr7039Hnqq6H;*Kq6z!$P+#MJZ-G+gpG6`m;~FZ{NW2#ZSMm=em5MxK1ug zps>R3te@#FD4@MS-Po~IC7c?zTEIT}poZo*<6!3vW_mu5WQM?(jwPLKJ*^$>|M~r| zeP(fQ?^Bl3_e4Q5wiguTeK9fC zkj>?hrYfM*;mg<-ks!fIlyYfa&{OKdc33Bq-r?SH8$pAUD#dc(E?F9E0zQINMV?!; zeDQyrwRznWk3F?(`>wqQZ_ss=l8X|U9Kp=TxGf%G0PO(v=-5wg|MHhU)zsW7j}4BE zI!$_kKwT?`_EZYF1qQQ^otfABw`^$H^XzZ6KRmhX(`Rhm_~+x}(+bNlf~EM8BTWEX zuy!eaZp#MD0>R+HmK={ErC59{Au@N$D$^w?#!WFX(wEB>a1U-!w%};nLx`)$w&|5P zG95nZ-e%P+(HI@nz}laD_DhOksKBH$zD->26#|nCh<}ETv{;cu#=u!y*4?>map%Yn zfBE1|Q!|+z;AO>1qsz<}7SLv;D9MIK;nRDsq^4$YJVK09 zR7HwKB3}_WQqau zvyLkRv+nGQMN~)zyUSaf8}7UI^4I<6<4^C}{KPXaeMr}JQBh=wOX95KYAo=9(*$1N zj~+exnQz?o5C6G%@#)gBW5cruDI7waUT08OR0_jbIKtSMD@f)ky>DY*V&A^UqhI*) z*SBvuWAon@ixM;f4vwJXDy-Wq2@X0y-}%`G`xaO+lF1~esOrInnD9Zy(8e;7`V~%y zT$az}^g_O1fB}#Ov;Ci{QqOC<_xGq%+p1!ngL+x$d!}PqOA_8g@X&`^N0&Yeu5&np zs<0F=3|3PWLsMjZY+~lJJHGXkue7%`kKJ(Hl^+yik%JsBfXCJJU)Ef}Q^~2SVl3`m z{I^%V{R7*N9orM_=;%lgFzaq#LnWMrH3yfi3+nc+<>VeQ2VE`}kFj!5x;LHVZY{|A_PkzZLx5@Ag(J?V5cQ_Y1cAnI}u&l*QPEdFgvbGadAh_8pkfqtI;%wIuKd>-d={@ zIta0fT+|drZNK}0N5AmsAD`QP%lqDTYgb$Iy$r__H|0D)4Q*%IH;s*_t^exz7r$xs z%*@ebV`B`GR-y`^p05xsE0%?$8gKS|4v+Z?U^bYxwhlv@Md_xtuJ{fA>wTXWMX|A1 zlmKh^vxt1UL6J4?-X$hK)pP@#36Y4vAWgl!G0A^xCaZmn<%Jt$xo8xNMeyTsC0bBA zpBpUBYdYnYuXMHmYrb6Na@N7nOUIl0dx5!hFV3r%R+NLebWY@YY3zIAz}pJHeFMjD z_}X1R``EkQw)0!3tzUUNnEoIFcV3`Ihy^Jv2$9C^J9d0N-qaFD(P*0RXfQrh1$7|6 zR@evQIEdT3M)RB#%)p!5+S1KqgD>je`{55h-o2=6Lnf0^v3agc+PEYW4MM7I!Obf~ zBETYwQJ%R~h_J6rXS9Feg~*w6vvc5E2H|1alC4sL9Ze1BYdI-Lb?IEPhPYo4=Nm!x zBX1S;-rDCVqd&-JUxx{?9L$Dw-|lT@qpNaJo1V>{`NeKpdk5w&&Sfhzz4ZW7 zdVl|#)Sf+$G2j0FFRxy+X5~9)re{>dAigBs%u?1sPJmgG6AY?iECx2?VoKz1g&?+E zLH%dMFg=;foQ8AOI)LJopr%#N$3<>Y1eaVk{H3#f4T^49Uj*VEba7%E_@!ga$1c5e zuKTg~wXRkH)KEOvktwGtvL+RaJ>UM}FaJ3z3d(hF-FYj^az&2i*m;7QFBI}>dq>AR zufOTmt49W&WtJ@IO_h{G3lsL}1wq(BU{F_53d1OYu4Ap_C8R7_vWY)*=!N(PKl17R zv(MT7cakLOs-~HMb5^{QJGwz_Hj1w4q8MX!!#JLd3m=wLZD~Q)KCkI|v>@dnPJ^@H zO9@U2WE8T>KK-FqdD`xq_+-H|_ALKeFfqHJI*n zU5kG2NB4dHz`-L+-+$ekJ{*lkj?Wj=L`hWjWy_cT-G!IFt^dS{qw&VZIPZs#8ZhR2 zY>?2=8kImO@R@0DKB2j8) z=1?keBk>5rkB(-u(|DQNY!jY+4p&+OkL%m_MPG~FU#G5xpM#_;v+CBaW70_g;yr{X zpL_YQpWC&6?I-^7eSaN^2qW_WHPAd&k&S31+OYM^Gyl#oG(i-bno4}yDs@v59V<;u z)OW{u@Mcb5zkWmFrI#Lx{pgnu-E{Vjvpz99JEO8J%M(*_$(9UVn##R@rAuNfl&%|* zNCYD9q^6Yk;apzsXZXkm^Z6WvmYG(JD#b$zlKyHwINyE!ivQL*hf#!>0-M7Yd~DS2so%P?<@t2Ks%V~r(%09^9XRlE{K~iAxMc0R)i*=nylxm=i8Gfz*E+q9 zmeh90r)Ka14;BllAr|>qHY=?{obbVXE(u0XxV1E`tSIcN5f?}~KiiuGq2qc;Se|;bsj^(v9pN>dtO?z(FD<6D*_r6uv zzjf!wIF^+dj`K2-2P=lbL10uzNBhlN&$(pD?Cf|vola4XyiiIa-4ecqmeBLGZdh^6 z_(`yIUbK8kdg9o=*l!+w^tPqTmR_8ho5Q^ypfRD+@xkWHZ#4>8XP(vs0mNl+Ja0q< zflUa^#~7ZK=W@yyAVgPHRh$~B;-k_$)%=gC0Q3=5U>FmG8()k@P$VKi^fhj9=tEe} zW$BlL;>L8~lKtY4mq*3Dze{>RNkW$_ANFxhN>HL-^s*xw&!j5W@E5v{`#<7N2%3)T z6sCnj9%V9FG%-1a@_7kMZ47Ygns60{VL@6O@4V*nj}{8W$WQKj@N=YpC8i3YCAHa; zoST}Qy8pJ%f9{sn){esP@Yr-oN<;C?`%z6gdrE3QK-U0fr*vMmYH4);{$~^a^rdfJ zbnRQ-{PkQe3t|)x>42q}1JpQ#AMu>IZ2q(3H&{lCi6X}u>Q@t@xOcW7e?il85&0i29D$YjPOiww4$g;;5j50a_GpBqi80RA)^CeR!gfr5VwcF2F3Tj zxdCD19I=jQT>mFu=Oop=8B4=aiaRE*f};kqVj09ZJGBEA(O1T5x266RyEr6uAG zoLbEPs;#x@p^?eC+gO$tabg)395U9;n39cd8}pyzsZE*zV20A1Zba$ER7ncDt0_eB ztZ9c@ReoZlD%W?s9==CGy~(OsUjia6)+6wrzqG*f0 z{BL)C;>PQ*`S)l(6ZaimEu{am^-qFTX7%bZBYe`{Hfod>D zM@K_qc67gX^M^kC-j>$Z-aZ_$}@KqWHqg7%zLug z961+dU2xc&%^P&go5~VS5`$@mfm)gxUK3!30uowNb+murI9lD;gHj0*b7#;`9C6~< zTEe1&tfIwTZK%Dq2}NT`Vx25G0Nkf%HC@&BymI6{$A(7J+t1i=2g`u^UW1fIh=oj7 z$yBQM!Z%+0-r3nft*y;ZN{6;jmu}c&V(Ury@SN_N30nNUfBXPqp4xud>KD=;FV#{8SVt|901;eRKymd zI4FPt{2=i7$%KfaW_BpI%idM64O7$f);qubqZ@9y>78F?7?d$hrJ-&ZdkA~F$y`DUh2=w|uI8w?}hi2lL9`N$=$t*w0psenhBqCInNzHIjLw`l_Ip&f~G z#X|m-uGXeU$0oCXp=#ig20T+}bz3%EKb)UgRf+eHAz*HK4T2ef45M!)V=?SJ$>a){ zf>e7>!2c|!DFr+%T@cSUd<@{}C%-*3K6Nf15tqO?DR7^*s}_r25Br?0Bodkd>BHdst!V=0*9vELUmIdEl1ULY zH6+lHUou=J4?WLp7icw4$Ofx##|e&%OA(v%ZaQJ|Ie*cR)fJ z+FM($N+vs#QdWqSOX;eopr?U2Gzuo2puXRl zmlp=DUD1mMhsV&!_za3g1y5=RMT~H0VDJPQJ~4(S#>a4au@Dc!>Y8diw|nnlt z*w)hY2ae?dL*aVZG7JYji4v*g85ixmYRmNSA*Qi6L4}uAmD0|K55b{bCvbzANN0mj z#SoIXZD(%2k{1LMs^!sWz%lHoG$)vGD5Fx8wI!V`KR9%3^v%&|ETw6X7{y>9Bd2A- zpp&oA00$d3NE@8kQPog$b0hS0e$7f|+ZzrZLRmrS#w1$a+ld}~<|XX<2HSKr!XrT- zKbQxv?CyO>&?|?Ip}`}Eak?`r{2*Y#Ll+o`O?~Hwcfa}CofrK;hqSBJsjF9kL5on| z@}*Y-b{b+WE-CE>b?F{lQ^!2@R+Min6*|V*Dx^zIb2Dd*?>)?K-gZexsv&hjA)m)R zxJf;+cFkjh8}_h(7H%zDmoso4Vx-BBilUIoOUmU2Qovn~BQ@|yt-66VdD5Ag*iOvO z+qV7GiqB|BB~e>*lWEhgy;#22vQM&?H#*dL$-M_Dj=gBRg4OKia=sv8Ktp8R)4TSe zfx%(4sG}7%B;(i)KQ)^{$A-soos6FtLQ|6yND#nk2DvbyBRmQvr3VIwF7N1Q{bhSg z`q7E0+4I2(30%Mgxu4S;t&bPoRArupoEd%GQpb=#0 zsIL00@ctM+|0^BqT;=i}QC~nv90Pfx8yg$Z>J`1Hv%LlFKYSb=JTi=h0hDM&zyM#f zd@&lISc1MkJAL4U{byJ2s>sOHxI#T4Qzh2JaY|Unhf@e$pMz$`$j!edO!%rbnqM~gc|DY?m$=W zyaavwyZ?o+#e4RZu2>+Osd4DY@tto^r|(UtlDlWJ`OPq*Kp6Ug-yy`H*4CCw5K8^1 zn2Q3YWHeFc90Wg3QQ(qCb((` zGo;haWDD4x3`rL8AU7(U$HL5GoiKyz#>Ai9{-)lJ6VNVqfwOn%#cv+{jiA zI1G55<9Jq8H3I!yo`jZG8=_zJYLZ(yOG@a|9=3!toO@4^ePzX&?MEkHJ5)X9=u_uMKDQMzMOX zz`07jS?C@rcuK8Xs+eaUbAw!sfSpfo>0|=+j;N) z! zSv>+mpsTZEs~It8*mh5|cZpVL8RZhnR(Y}0h zg&mvXlfnS*t+TTY_xLfaP|%7GO72e44Aj}th7!rR`I3gNG8R&)=OwxCmHh*~E0-;P z0EW;A-CYYOc4AO-W7Ae(`f`DZ0JglPbmcSQoAPWKU#d}(&gnEl#|DqG8@F80o=C*c zBIA4LoH-(@O#|@QXnwA4WUx|EQWmu~J$3BF#2Z24kW}98W{<1MI;xYj;k*$C451~muuMtF6_;G~KLf^^_3^YG)(zVV#xr$1%rngSn>P$x01 z9no0ytfgzuXrG+Ox{h}{TRf$-JNP(5 zEhh*m$f9qJylVKQoTa<@L)U!V;8xy?S^#bK)IN0N7*@XED`5#n{J>^|V{g0NOP&7o zQ{Z;bm(dAoy$^_Ku;M9Qk5s2t3+&K2O$ju;-(&Z^0~FE zS1x_Tw67asJ@`T-!gh9doQ9BuM83(DTBEA7rA7{Iy)i6n$OQu~8cjE+x8U5ucsMf3 zl3|;)sL`IB%b?-vZOsk)C#EyoEzxC}t(;~oXBK-$MtfK8ijWma6|+Ahg-CqHE?vjItBU4H!||a0IIG#AYLGs* zFYP_FedD^-`!yH^G^FQ{0n3JNpoTDgexwf z(=UqE+a*A8?`2zMtUo*Ugfcx^3CGm0LG+z%?fj%osA+9by>}g@4?Ch==&+!76#e=(mOM}^UjxvB# z`@88}7|CqZ56n;)L)Q#N)jIYb99XottK(^Gy$g2<7SjCk0^i)XW@(osP2pti_Eujh zUA{iRr=gsTx3{)(2ub>;P5q~fVqAbYW;=Mu@`zCq+AXPYz>%sL>8ALevB|l$9E&~h zBxg1yaoyvO(Cch!LQ|Xoby&8C>1Qh4C-DSY-ac9)yE`kC+sLWdZYpe+gvwguqLgyc zIY4U(0xv1;+TZaKxFoa@fM(PY%vL2r8Zorzm4j$qz?;j2*gcbKo5UWSh}?L zbih_;d%K4#y?S_Q1rF_bFkYv~0$hwuBvZX+V>(Q2j(NRo9Uk{XE*9m^mgb?^Y<`28 zUybn_1od;mu))hDHancg!Gn<_kO41d6*7ztc3Sj}+ z0eH*W;g-;5f=60OMMeNH*XBg6R2aCAq2cij>({IpGLRvIdnwer!!VGz1;yj>UM#Vd z2v-X>d8g^ahdVn(zLA-e;bM_kOk4p0K`_!Vr1&Viisgy7jDXcMvsU&}I>y5eT*9ZJD2G=x9e7Ym*CZ!KOkr{oEnl;_k>~jy z2-gJZY6Z%8Y3t4(F~*A#VPH0sPZ@}9P*v6D&yF;+E(OfNG0pL@X(0O9p({ydd(mY< zDW$qCAEl~9dCH&9%bQ8p4W3%dZmI2}AvCvk*xT)WOUG4vVxww>%&Ra^VVb5j42?{t zni`S=VRO|(t(cyfY5VS;UMTbf{4 z)TY3Ik^tzkhA3B}$R`~84pAyD9vB^;?u9P#=1{**MW<}2V5c41YEbue${yLX_R1G5 zYYe6>4&LqqYARO6^I2Vhq;pAIb%mn`Y0%O@cZ1pEd~|Bvz?|7w5I_U8@L0C{4ju08 zY;QdfK2ixJv<7i4ZH>*HiNaKlr7>5AMV}HnZm=iW%^)Nh8&BJ~Sm1aOMq>Nss*=Sb7xm?qj{VK9t zY-vo5=L*syoSfTo%9K$Kw{)}=M1T8lseV05g$A`Hp4;Y4OIT0k&q)O=@M>(Oar_5wG}w@c>Bs*h`+g)W3g&tw)Y>s>UC45MgKG^SDz*kdVlfiD9(Kj>Et4Jr6?GrA^n z2xYD0d*occ${`e;@#tx5ln6L&G%&f0D2iCBrxMA<@B%{51%?AFg>J_U>n3>d8)wA` zlh|J93c?6HdmL`CD65^OK0z!TrnRBeSCGo`YF3Y}_S(R+II!hp_N?dXO+h)MK{NqF22QyN<~H{1!< z1Tg?yr~b0;$c- zJqE3JFMC-;{5*-~*TSuH8I{e@)anJ2(PRu6EfV1{k*wu-cut<@(+EX*7+5vz@!#3N`Ni^H^EXEXa_Wc$$bH&tkuDlfF3_(JRaRQG&P{$Og+CI_HR;L=lk)hg22?mr3hAPC%` z_M#>pz_uJJW%uv9a1tbQiE=3wrKX%m8|;@~HM-qA>mimL%*hOVht zO3|jfngLzK=BKM*JT@!X%C`Zr#MY=zmejVEvBC&^84p&YNRk{kBek%2b}Nq-pe*ic zhjx}aag;cuLqgZqSR|6uQnpIxcmHhHxEY&aN#!yVpW8a&C6T@8h1*T;2D8__`!4u^ z*{(oT-vZ5z7r0JICp zwA3cg2MJZo{c314?65JdHWo0=uto=v%OtjI-j1%TVCg7}zH^!K+=^kKVuT0tp9ro? z#cjT`ZPN3wa#)2(S4h4BP8EtPTYxh2M`$$tfMuX4m?Bvck>t6kL^<%RwQNq!2Q)+Q zNE}4)Y^<(&vXnb`9ush0Qo4*sQ{kcXye)yP^SZ%JZ*zo#mIJ-kVtCA1v$eeNuST(i zXP8nmpy`M)tQMMPI@=BnpKc1&G=~PkuBtE;FQFhdy>JaxRyHLI_LOdLa-Q(~*@7;1 zqif+Qha-g31I)Cez0DUWHO4prc^5WpN5y%k6EN=`e@3 zKy5axolyuGB(0XJ2ys!&TxZuR2sj&V&@Bs|Kfrh&<$6piMZrxO!e z%BW>wA^SoorkSP-*GGUyaWyNxZ}SDEuR%YD9dzY>F84a~Sg&9sD-g*Fr%=uc2 z@#=GC18lD>LelCLOU5UrrdOMpoC1Fg4Dd#m3n!sjj?)n;sS3yAH^ERoibbIsz^*3ab8I*XMr2v7nE&siL}%(~$rq3tKL95>X*6W>R`CUb)s z&OoC4D9YjJ)r&Wx@Sn8=}I~y_yQ3ZXiyUHMX|wf~4(LRh55K5Uq5t){WSpHeKH|sBwZr;M1ChimHM~5oH)7tLZ385?T6yxtdaF zZp4y~9z@co_-!ZBl~U5^W4tJbkD^!+%pMeDK3v)B>5wbalWTTi3&S9u=0H6$z9ErHA7*L*RrH zs;0C+FJY4Sr-EemkkoW_VKCZD-gAJEW-! z0D!n}Xo_K(=_reb?ePPP9>+47Ofedb3ZuEAybeC|RVhbB2@U2z2w(NT?~0O}aalRs z9R+AfY)1BZQVti0-4uj&n4nh^%uKZ%YAp;(#-pRNnM{-zjKe)Bh!9ZD=jJj)#PW-= zb~3CSBE&LFDr5&S49MnAKuEzmGAtV%93E{;CSt>rGZ~PKxcg2}P~!>J z9+(3|mQWu+2g=uB#-)_mp((I>Sve|kX*ac4P$2s=jds987E9!4XVb+<-1~b6Xl4-nvKCjFU@J1dYt3=Wh#su>k1gMeso-h`Ge7q z(9CS6ud5}UVGLtVH;gnWhGq<@BQVJ`D9JM0IV3ayszeUMdc!OUjbB9a5Kg7g9prEB z<3}@Ijbc=LW*f|Pzdz_}$8a1w*WJ~UnVHM>nE`0Q6+)2EJdXfVfUQM~Rv4KKHkd3d zph4^8xQK>OQ7%ZugH0@ZE;fjJ?@JojO6v|(#O!;P)6_+hqHxJ%eE;lR?o2$wrqzqq zFDS{yr7dqB6e3;0s#2T$A;CeW1ol%3t(V9}Ik5G@-0#&Y;UqIt_sc`uHKmgKB}w55 z#o{8fEB{tpZ`Fws5dxJ<6RE4`GvC4=jk9aT; zmg6HYy)w|<+1B#n^lbJ_82j0Zx~m3edO#ej*q*r>r4OwXI=(J0Y3ymnJJpM^Ff@a{ z0&wQ;MQty>^vc0*1~Cz+%}|sUXU@_QU<$BhVl0#mx@HbNIXR^vlxB|{8GODdNeVVz z1Uwc1py@hJ`@VnRaDV^mzP+lVz~`Q|c{G*R!&C!Md$+ z2p-%WLU4B{xI4ibr*UmugFC^4yE_d8hoA`!H0~DMEw~-_KKFh&zhJ$iYL2x=&6@LB zX$YO&3If&RZ{2k&Yb$TTOHA0nvcgwh=G!xIZ*YFdR)>_)EYDL@Fnu4MX{;%W{|Cea zoMJO{4F^l4TXJ$;d{E4`cU&}f`LBSH1dRB+M&F@nvviqaZzHLw!-6zvp}+yG&AJn5 zUVLs+oJKc+G||W9a*C*Gec$*LSnHg3>3TbPoW}HQSRj4*?VfKmFdn&TYM2_p<}cRAwd2Cg;mN1u$?u33H_1kXkaA$O&rkDUejY*gLY zXNa3sVJEaLlj|yE57hXyAz<85rIsg6*hi|qNVz>8diPBL=`{H;lQgzI1Ld~5=A_+s z?10ihC7_mjTxlPoMWhw>@Oxu9n6*!zwN1-nwX%Qoss?pE2Uoh7ov{fWiJPtKaQfXJ zClmA<`H_9c75Du^7TwAiu3R8>0Z_quMss>1YggqF5#BX<1E4Peh8E4 z$7$f;b&$I&yE9PuKaad#E@b-H08a$R0$YaC8Ycw1JSR6+zvG5zj3+#GMhFmNB#gx+ z8`<=5Y|+l}mhp9^Ccf_d9#cY|iA{Y&yQ zN^if2@1)lDkknU@eqoW~m$tZ%4 zOzburuj=XlNknW)jA_EvO(3!F=ELJ*j((XshaxtVwDln(FNLo11etck`bx%Q=YW%} z7MbCT{G$8Ob5v24EmJz=!jcH0XA;rRq97B7e2)TeJ#55>t2Hm*>;Ys3a{uq9>=)3$ z#{7^EH?63^R{ltq*087H5U2T5CeBPqS20>>HG>ws!A!12prXQwDrLJ>nsF<--VD@8 zuv$Vmy+pgTew1S<*l7d+k!ujBZy+McF!Y@ym9`fpnp zcq(!nNa%P(tK<|@iiD=6B(W%PBoq{qxG7qU6tS?hC1M}TBL?{@}Nsz z100tew)&U0=^KUk8x%w4X}{9aS2k)V%Eros z?SZ4Ce*^eSvL|Z$@dn>7^ObFyNI#X8 z_UgG&0cz+W`}*DmdhFaY?)_*Wl;5dKAnw#aNkV-3%VL6Ps=v18@;DBDvBvA6@A%3PZufj%6#U+ zjvL8Nv-8to`{g#=?{}xT2gtqN*r$sf6plxL5CPSi1S_T%XDW`2(_>KBoowXCq8*Z9 z(=hp=zIk+QOlv6b=3@TFHEAllJi}UriV)x~J=ZJfhE)9L$7Re_4YM8yv{hu%J>19% zrvW$q_DV~OJBiTOWC$M0kROrC8^5!BR>siyoL*ubw|7?JX?k945&jFmH}R9!Ld75> z$)43#!S&eU&OgE8VqEd}0$h*R=HjR!Z-b0ZHC^?>&%>UG!L-9Yf!W*O_TDdZ7IIRV!?Q5<7v>POOY=gnr(;G`;tOgE#_bvvVP#>MgNgev`>})DW7-pHgY^at;KTe*cjQ_2^z&W2B_Fy< zcs>6$^>**?PwsFnl^0Jj`cn?bD!FFW9c`y@@1Od-=2bKGG+F>b|OfGwfu-Dyp#h>zhDCko@;H*X~ z0S_S(D-xuVhLYLg=@Su!|9>{q(H>eC(MJ&jpQcWn%*&v+YTqb-z&9rjjE`lR;|V(> z3=BcTO-7E<0kX`EXL3>h&ns{RJ2N-UBK6U*=bV+$K@CPskSi{z%+y&A8J?;jh3&R~u4$y1+ zwD-II5Gl31xutL1hIj0=&l}Fc)Fs{yRmkM7I{4>D0D$6%^Ckf-FCb<$|fJwuQF{oif1M z+}C$+Uy28ZPj=aC*w1=l{mx0h%@?TQH|DILzE2buX?w|nclawFaXzk(kQ-l;Kj`%w z-dejwVojPvO7 z@D4El)hA8vcyOS&fngc?^wZasVb)S8+N@5h^hh}OJw_-%iKbNK`am?YysYd^DQ;*h zMB1w~{KXqac9w@Bj9bTeEoJ$DQG=4Mts0kWS{j010lPt ztSqTaesNcnLm^T-_7Xs>4Z_Cj#O?`vK12p2X5zCR7E$8 zhdm0&I++`qW?nKs>jz5`8$=6>-x1oH%0_SNq&<|0pXUF#egCN}O;D5MBGOjTe51#2 z5;VT>cAn7_n9Wih>(W8*O-&x^oYnn@7vqM-B(CU^?&u6C8PFZuzzYwPJx&PSgfcF- z>{A)E{zU6O%NZAtg-hHU%6T*$);dx$hb1oSt-#H6y%R{cp#2zlpAvNQYS?p|o8J|5 zKR8$_!5eoWn&JYOlBfH#bKTy(@vz6gp7&c(usbQ2*@}QZ0uf+^4Zpnd{ci~bAyYul z{jl~LzD1C}z!QeVI)$NN9NBdG5NYfF68@Lr`b*)0^D_AIc!-8CC$bLxDlW+`Yn#v6 z;CgOmp?>LU{QTt^Evs(3YdW1El0GAi9Ph5hLfy-Y9|u>VgptJ7BfbBlCshl7Js^Dk zAIL)EoZG=uunRpX`m1Lb8bp@d7Acdk)RJ z6LTIgl13TuTBIc{eso^*??(`LVLcK3F$0`O@lw<+Zk6^(r0UdrKh zu!3S7a2=kYSr?-g6mH`EH3?>TGJGE4Ac6xj(tjq8ZQ+4TBs8_HJ~pmo8)mVy;a!mo zaI|Qn2Fgd6E!ngAy^NSQRy?oeuRbBd%f3Zm>*4&7+yOs$>xvIpawWX?9~Bv*x{2?A zl5~HSlSlOu2Q_rQL*`?sC|5{ObfMBPHK&fX`r?duF$ech4iaMWk-C77auef`))E7a z`UXAMS!3b`jCi!-?dScITP#H-0n%C+103LYgocWW>RQmszW81L@Gxe#dZvfG%_6&@ zD!-9-1`>}7Zxviepr#N7;Zl3Mzxj46cL#g>$yKLdf=X4D;;dD;yWiu3Xb@sCCUFuxB+xw8H;FII>1&dGDCS|K&}?!|8PH^RE-I5@TRQnEG6OG1 zp@8@!Y=%)tZ6=vt5 ze3!y*zf!tn-A`K&AwJh-ylweCt~ams0Z-+CSr6mE?;b6l;Wk99IMG*f31lx!rUljY zfXTS6IC(tXlK|%IOt@fTXOKo06QBo`04qad6_s;5P6dD2_wm7b?Q+if9lRz&jBC*) z8IgzqD45@RvZ1=n-spUq(KGUY9mwXdX(#W}&!oyg3U4J{Si_qi(A{k**DpN(5oc?n z7;GW>DU_0rB{Mn{A=GCeYo$=N(5Wz?u+AQl8_Wsj1q*^jO<@`~Da?Ou$$zk1V(i7^ zZ6RYe89H^iyn?0*8#LANV_k8PX!4D4#ND1~sl5cfqtr&wzBhcvQp}KyLPU#XVK#od z-+jB!7|+%{6?J!i>y@Z;kXJxlLt+)PjJH<`%?7<>ihs|43oukOAQ{T&8tShh#>V*L zmgBa!2l~nOpNSlqH8BviFsOe?7)obLufQY0pp|wv$+lK`V1D5x8#;~<^}M~HdX%^I z&FhMegPo;ao2wG@-oU!0c+c{1i%d47kIgfbepV-pb(>r`f7~V1tJC-0$z3q={7*gc z#6QFXbry>6kbnjvV&P zLX|?t!uZ14!tuh>Tp@kzvy=E1I~-zzth~jI-IPtZ{+IaC8l#9 zUAy{t_!;uMc89(Ex6T0K{;$oQcY#SeH}HunryxWIA&ofKCFR?ppx*7)HutOoJXEb_ zOJ>$wl`&Zz?%ZrUZ@6X7+Ao4t7im5wqWWHp8XjVy`i(*KlW;QFd$v z0aCIkYvp4)#cRn)NRzgu93K< zguwLR&tTU7=yB=jZugcsaPbO}yNa$bV6WR@rbXU%SiUsN;*%=6%X`T>;xwDj`8gpi zww><@Y}{vV<5JT%JUH6l>pjW)#>2z4{%^*QvO{MI>>0naQp@_VmhZ;tlEbo-Xn~kO z+WB~eCcd#Y$9G-7@%VcLExAi-24DVID`h6E1A#*I+pYlkxP5Km?c{00yQsNYZOpO^ zRX2MQAf#7DVuB-7@GwE^v@9oCA}U$>g29lTJK^=OjxX@`MVLaI&->)9FWZqR`ns_s zq>xHX;=gmRvT6)_ofbJBd1po`?y80l|Xc zK=2?05F&_A?G5|qAKw3Uw}6H~3pB`RF}FMixA71K6Q+A^fMS?>YF2qU#W9mNHhjn6 zZ#(jz!RxbAa{@dUQm>(uFHdT$CLKFLR{PfYgB);KL9niqZY+@o*`$=6mygxwtL~dF z3olrru3CkRcajqVn|^y}F9s=#NKRA{Tlxu+97`dw?5lw`Z-1<40Z$WZ&RLmRYq0HC zkQ1(x=X&e^3msTG>zVn>T}<1oOFF>TZ@k+7m)E}pyoC((?IAeQn4`yh!i5*E=I8U| zQGvuaQ$CONJ~|l6J@K|X5cqFjs|H(;E(_*a5z{t1UPm|^xostU3VeZDtmFrtmEIjj z+4lX$HnUtw2A!+56L~M%-p{*=gi%kt!ysRR^e3NZ-PzX*NR<^AT-YNzMA-FtaDLN? ziwWJPtn@yYsvTgv#nrj z*D90QrS&Ul_J;RsVrR!P_r}v2|C)xH?7oU1L_nbUawzYvRgI+ebS8mA! zJY4xc)qYEW@y1p~DIG~)(Sln=7`(TZnA~mvsxl0|yf9~M1pPjq4777_<9djFqUiCo z^mqoD7IFJo57Y+&$OqK_{w~=>au)+|93o=ub!f)N%wuG^{e(3J97!tJX5I@LbxEvE zErC?{xRGTwH!VT%sl-yFZa>Ro?7*#Hk(TYacrRzM+PA)*P&r?Uz`s9YM~2HLv_Zum z*ka@esNiP}u{^n868fXor1}{9SR%L+-sx;&GwbWmI}a;u>%H$D`}SmagUzmm+If$; zxtkklSB-#R$3q;blep;L!EbGNeqZxJWQaP`#ojddOnU$gI`3BcfLKg1FB04l^;6s0 zJfVBng0iQyw)Pahsw(&x0ZTpfNWVrF`7BzPJ?49VT0l3J!$#BPQeEBc(n!9q*^QDP zmQ#MtkJF48H~uk~l4`;PuMvRW7=r%k@t*rblh3h{M6@e|PSiXU10#BBR9#7HJMYF` zPsH`Mb>=ocE{<31^5l#a83}1aJ!!{8WW8NCh$n)&_35sjOwPMmNpoKE=p;CWS2=6$mur|DR%REwmy_U&l^9( z)p5PgC+!i;Gh!@#^gg+I` zYFP-_JOPo;2kBtb#N&8u(>$CJXfDBhLPsL<=1jRu^+r>EUESTh)xWtq{U%>2h@8BEmJarO0DZ)}@H9Q5o~4t3M)iLqP|#>` ze~9qYE+L@fVZ>|g(%b9K_W9qxq}(sXG;@sS{qf!DbR)7uY&vNYMrm%|uvsQ`ls6Zg zyFQ^=-anFSb1xhFUk+{v0WC+6YtQEmOC}#*>ilZq`ddc*6El|q7xyGavTLBU;^5%6 z3P$C?>FIc6`i&>0@#-As(&8!|EX=q?{6OeH?ZY>+a1I=z1v1@V!GTaU0g70>j2pJB zh9d-%q@_yY%f(UP*j9fb{d?PR%w~^k%nh+JQZf!`oXuF}Nzb0?GsW@X#*!B-QM{l3 zJYbRUfAN}s>U?e--h}(f0jq#A)1G@Oyo|(2LE~M2f(QQk)yPX3$v$;∓M? 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 aG3a-DviBvqQ0G6M3ZNjPDqSUM7WzNIJEblF From 8b9a28c3b2ec3346eb55e5f8576be329b436571b Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Fri, 15 May 2026 20:21:17 +0800 Subject: [PATCH 24/45] fix: bust default logo cache --- web/classic/index.html | 2 +- web/classic/src/helpers/data.js | 8 +++++++- web/classic/src/helpers/utils.jsx | 4 +++- web/default/index.html | 2 +- web/default/src/hooks/use-system-config.ts | 6 +++++- web/default/src/lib/constants.ts | 2 +- web/default/src/main.tsx | 9 +++++++-- 7 files changed, 25 insertions(+), 8 deletions(-) diff --git a/web/classic/index.html b/web/classic/index.html index d6bd2433ea08..41bc08d663b9 100644 --- a/web/classic/index.html +++ b/web/classic/index.html @@ -2,7 +2,7 @@ - + . For commercial licensing, please contact support@quantumnous.com */ +const DEFAULT_LOGO = '/logo.png?v=20260515'; + +function normalizeLogoUrl(logo) { + return !logo || logo === '/logo.png' ? DEFAULT_LOGO : logo; +} + export function setStatusData(data) { localStorage.setItem('status', JSON.stringify(data)); localStorage.setItem('system_name', data.system_name); - localStorage.setItem('logo', data.logo); + localStorage.setItem('logo', normalizeLogoUrl(data.logo)); localStorage.setItem('footer_html', data.footer_html); localStorage.setItem('quota_per_unit', data.quota_per_unit); // 兼容:保留旧字段,同时写入新的额度展示类型 diff --git a/web/classic/src/helpers/utils.jsx b/web/classic/src/helpers/utils.jsx index 7c7e63c73740..622f46cacf0d 100644 --- a/web/classic/src/helpers/utils.jsx +++ b/web/classic/src/helpers/utils.jsx @@ -28,6 +28,8 @@ import { import { TABLE_COMPACT_MODES_KEY } from '../constants'; import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile'; +export const DEFAULT_LOGO = '/logo.png?v=20260515'; + const HTMLToastContent = ({ htmlContent }) => { return
; }; @@ -54,7 +56,7 @@ export function getSystemName() { export function getLogo() { let logo = localStorage.getItem('logo'); - if (!logo) return '/logo.png'; + if (!logo || logo === '/logo.png') return DEFAULT_LOGO; return logo; } diff --git a/web/default/index.html b/web/default/index.html index ed041d27f886..bbf0ebe82440 100644 --- a/web/default/index.html +++ b/web/default/index.html @@ -2,7 +2,7 @@ - + diff --git a/web/default/src/hooks/use-system-config.ts b/web/default/src/hooks/use-system-config.ts index 256bd7d541a8..a3ebbc3f6728 100644 --- a/web/default/src/hooks/use-system-config.ts +++ b/web/default/src/hooks/use-system-config.ts @@ -58,6 +58,10 @@ function toNumber(value: unknown, fallback: number): number { return fallback } +function normalizeLogoUrl(logo: string | undefined): string { + return !logo || logo === '/logo.png' ? DEFAULT_LOGO : logo +} + /** * Map `/api/status` response data to our persisted system config structure */ @@ -93,7 +97,7 @@ export function mapStatusDataToConfig( return { systemName: data.system_name || DEFAULT_SYSTEM_NAME, - logo: data.logo || DEFAULT_LOGO, + logo: normalizeLogoUrl(data.logo), footerHtml: data.footer_html, demoSiteEnabled: data.demo_site_enabled, displayTokenStatEnabled: data.display_token_stat_enabled, diff --git a/web/default/src/lib/constants.ts b/web/default/src/lib/constants.ts index 0f0c178a5b22..fbf540661250 100644 --- a/web/default/src/lib/constants.ts +++ b/web/default/src/lib/constants.ts @@ -22,7 +22,7 @@ For commercial licensing, please contact support@quantumnous.com // System Configuration Defaults export const DEFAULT_SYSTEM_NAME = 'New API' -export const DEFAULT_LOGO = '/logo.png' +export const DEFAULT_LOGO = '/logo.png?v=20260515' // LocalStorage Keys export const STORAGE_KEYS = { diff --git a/web/default/src/main.tsx b/web/default/src/main.tsx index b53f00f35077..edeab6e0080f 100644 --- a/web/default/src/main.tsx +++ b/web/default/src/main.tsx @@ -29,6 +29,7 @@ import i18next from 'i18next' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth-store' import { getStatus } from '@/lib/api' +import { DEFAULT_LOGO } from '@/lib/constants' import '@/lib/dayjs' import { applyFaviconToDom } from '@/lib/dom-utils' import { handleServerError } from '@/lib/handle-server-error' @@ -43,6 +44,10 @@ import './styles/index.css' // Ensure VChart theme is initialized before any chart mounts (prevents white default theme flash) // VChart theme is driven by our ThemeProvider (html.light/html.dark) via per-chart `theme` prop. +const normalizeLogoUrl = (logo: unknown) => + typeof logo === 'string' && logo && logo !== '/logo.png' + ? logo + : DEFAULT_LOGO const queryClient = new QueryClient({ defaultOptions: { @@ -126,7 +131,7 @@ const rootElement = document.getElementById('root')! if (saved) { const s = JSON.parse(saved) if (s?.system_name) apply(s.system_name) - if (s?.logo) applyFaviconToDom(s.logo) + applyFaviconToDom(normalizeLogoUrl(s?.logo)) } } catch { /* empty */ @@ -142,7 +147,7 @@ const rootElement = document.getElementById('root')! /* empty */ } } - if (s?.logo) applyFaviconToDom(s.logo as string) + applyFaviconToDom(normalizeLogoUrl(s?.logo)) }) .catch(() => { /* empty */ From 8744c10c28307310ad5174aa8f2e435fadd501c9 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Fri, 15 May 2026 21:00:46 +0800 Subject: [PATCH 25/45] fix: migrate persisted header logo --- web/default/src/stores/system-config-store.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/web/default/src/stores/system-config-store.ts b/web/default/src/stores/system-config-store.ts index 76997ef81041..b97f1678c4f9 100644 --- a/web/default/src/stores/system-config-store.ts +++ b/web/default/src/stores/system-config-store.ts @@ -55,6 +55,12 @@ export const DEFAULT_CURRENCY_CONFIG: CurrencyConfig = { customCurrencyExchangeRate: 1, } +function normalizeLogoUrl(logo: unknown): string { + return typeof logo === 'string' && logo && logo !== '/logo.png' + ? logo + : DEFAULT_LOGO +} + interface SystemConfigState { config: SystemConfig loading: boolean @@ -83,6 +89,10 @@ export const useSystemConfigStore = create()( config: { ...state.config, ...newConfig, + logo: + 'logo' in newConfig + ? normalizeLogoUrl(newConfig.logo) + : state.config.logo, currency: { ...state.config.currency, ...(newConfig.currency ?? {}), @@ -94,6 +104,27 @@ export const useSystemConfigStore = create()( }), { name: 'system-config-storage', + merge: (persistedState, currentState) => { + const persisted = persistedState as Partial | null + const config = { + ...currentState.config, + ...(persisted?.config ?? {}), + } + + return { + ...currentState, + ...persisted, + config: { + ...config, + logo: normalizeLogoUrl(config.logo), + currency: { + ...currentState.config.currency, + ...(persisted?.config?.currency ?? {}), + }, + }, + loadedLogoUrl: normalizeLogoUrl(persisted?.loadedLogoUrl), + } + }, partialize: (state) => ({ config: state.config, loadedLogoUrl: state.loadedLogoUrl, From 467840ed800d560bd3924e7b79b53fbd747b5302 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sat, 16 May 2026 10:19:47 +0800 Subject: [PATCH 26/45] docs: specify official model metadata alignment --- ...ial-api-model-metadata-alignment-design.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-16-official-api-model-metadata-alignment-design.md diff --git a/docs/superpowers/specs/2026-05-16-official-api-model-metadata-alignment-design.md b/docs/superpowers/specs/2026-05-16-official-api-model-metadata-alignment-design.md new file mode 100644 index 000000000000..a7fc5d3673ef --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-official-api-model-metadata-alignment-design.md @@ -0,0 +1,125 @@ +# Official API Model Metadata Alignment Design + +## Context + +The live test site at `https://api.opwan.ai/` currently has model metadata that was partly imported or edited from mixed sources. Some entries may describe upstream aliases, preview names, or channel-specific models as if they were official API models. The user selected the strict source-of-truth policy: only exact model IDs present in official API model documentation should be treated as confirmed. + +This design covers a conservative audit and update process for live model metadata. It does not cover code changes, channel routing, keys, pricing, users, balances, payment settings, subscriptions, logs, or vendor deletion. + +## Goal + +Align live model metadata with official API model documentation only. + +The result should be: + +- Every visible model has an exact official API documentation source. +- Context window, max output, modality, and capability tags match the official API documentation. +- Models without an exact official API documentation match are hidden and marked unconfirmed. +- No non-model operational data is changed. + +## Source Of Truth + +Use only official API model documentation pages as authoritative sources for model metadata. + +For OpenAI models, use official OpenAI API model documentation such as `developers.openai.com/api/docs/models/...` or equivalent OpenAI API docs pages. + +For Anthropic or Claude models, use official Anthropic/Claude API model documentation pages that list model IDs and model capability metadata. + +Do not use provider blogs, release posts, third-party API marketplaces, upstream channel names, marketing pages, community posts, or inferred family relationships as authoritative sources for confirmed metadata. + +## Classification + +Each live model is classified into one of three states. + +`official_api_exact`: The exact live `model_name` appears in official API model documentation. The model may remain visible only when it is already intended to be sold on the site, and metadata can be filled from the official documentation. + +`official_api_family_only`: The official API documentation contains a related family or nearby model name, but not the exact live `model_name`. The model is treated as unconfirmed and hidden unless the user explicitly approves alias display later. + +`not_in_official_api_docs`: No exact official API documentation match exists. The model is hidden and marked unconfirmed. + +## Fields In Scope + +Only these model metadata fields may be changed: + +- `description` +- `tags` +- `vendor_id` +- `icon` +- `sync_official` +- `status`, only when hiding an unconfirmed model or keeping a confirmed model visible + +The update process must preserve unrelated model fields unless a field is explicitly listed above. + +## Fields And Systems Out Of Scope + +Do not change: + +- channels +- channel keys +- users +- balances +- token quotas +- billing ratios +- model ratios +- group ratios +- payment settings +- subscriptions +- logs +- route weights +- vendor records +- API tokens +- bound channel configuration + +Vendor deletion is explicitly out of scope. Empty vendors created by earlier sync activity should be left alone unless the user separately approves a vendor cleanup task. + +## Data Flow + +1. Fetch the current live model list from the management API. +2. For each model, look up the exact `model_name` in official API model documentation. +3. Build a difference table with current live metadata, official metadata, classification, and proposed action. +4. Present the difference table to the user before any live write. +5. After approval, update only approved model metadata fields. +6. Verify the public pricing page and management model list after writes. + +## Update Rules + +For `official_api_exact` models: + +- Set `vendor_id` and `icon` to the confirmed provider. +- Set `description` to a concise factual summary from official API docs. +- Set `tags` to reflect official capability facts only, including modality, tools, context window, and max output when documented. +- Set `sync_official=1`. +- Keep `status=1` only if the model has an exact official API documentation match and is intended to remain visible. Do not unhide a currently hidden model solely because it is official. + +For `official_api_family_only` models: + +- Set `sync_official=0`. +- Set `status=0`. +- Use a non-promotional description stating that the exact model ID is not confirmed in official API model documentation. + +For `not_in_official_api_docs` models: + +- Set `sync_official=0`. +- Set `status=0`. +- Use a non-promotional description stating that the model is not confirmed in official API model documentation. + +## Error Handling + +If official documentation is unavailable or ambiguous for a model, classify the model as unconfirmed for this pass and do not keep it visible based on inference. + +If any live update request fails, stop further writes, report the successful and failed model IDs, and re-fetch live state before deciding next steps. + +If a model has active bound channels but no official API documentation match, do not alter the channels. Hide only the model metadata entry. + +## Verification + +After any approved write, verify: + +- The management model list shows expected `vendor_id`, `status`, `sync_official`, description, and tags. +- The public pricing endpoint does not show any unconfirmed model. +- Every visible model has an exact official API documentation source. +- No channel, pricing, user, balance, payment, subscription, or vendor mutation was performed as part of the task. + +## User Review Gate + +The user must review the difference table before live writes. Approval to this design does not approve live metadata writes by itself. From 305a9aaa343519ece1f8e4ea68cd0c92d1ab5f86 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sat, 16 May 2026 17:15:28 +0800 Subject: [PATCH 27/45] fix: correct pricing model metadata display --- .../components/model-details-quick-stats.tsx | 12 +- .../pricing/lib/model-metadata.test.ts | 64 ++++++ .../features/pricing/lib/model-metadata.ts | 185 +++++++++++++----- 3 files changed, 203 insertions(+), 58 deletions(-) create mode 100644 web/default/src/features/pricing/lib/model-metadata.test.ts diff --git a/web/default/src/features/pricing/components/model-details-quick-stats.tsx b/web/default/src/features/pricing/components/model-details-quick-stats.tsx index c249157d1b39..cfe8ad57fa77 100644 --- a/web/default/src/features/pricing/components/model-details-quick-stats.tsx +++ b/web/default/src/features/pricing/components/model-details-quick-stats.tsx @@ -49,17 +49,19 @@ function buildStats( metadata: ModelMetadata, t: (key: string) => string ): Stat[] { - const stats: Stat[] = [ - { + const stats: Stat[] = [] + + if (metadata.context_length) { + stats.push({ key: 'context', icon: Layers, label: t('Context'), value: formatTokenCount(metadata.context_length), hint: t('Maximum input window'), - }, - ] + }) + } - if (metadata.max_output_tokens > 0) { + if (metadata.max_output_tokens && metadata.max_output_tokens > 0) { stats.push({ key: 'max-output', icon: Maximize2, diff --git a/web/default/src/features/pricing/lib/model-metadata.test.ts b/web/default/src/features/pricing/lib/model-metadata.test.ts new file mode 100644 index 000000000000..6c8ca3a029d1 --- /dev/null +++ b/web/default/src/features/pricing/lib/model-metadata.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { inferModelMetadata } from './model-metadata' +import type { PricingModel } from '../types' + +function pricingModel(overrides: Partial): PricingModel { + return { + id: 1, + model_name: 'unknown-model', + quota_type: 0, + model_ratio: 1, + completion_ratio: 1, + enable_groups: [], + supported_endpoint_types: ['anthropic', 'openai'], + ...overrides, + } +} + +describe('inferModelMetadata', () => { + test('parses token limits from model descriptions before name heuristics', () => { + const metadata = inferModelMetadata( + pricingModel({ + model_name: 'gpt-5.5', + description: + 'GPT-5.5 is OpenAI\'s newest frontier API model for the most complex professional work, with text and image input, text output, a 1,050,000-token context window, and up to 128,000 output tokens.', + supported_endpoint_types: ['openai'], + }) + ) + + assert.equal(metadata.context_length, 1_050_000) + assert.equal(metadata.max_output_tokens, 128_000) + assert.equal(metadata.knowledge_cutoff, undefined) + assert.equal(metadata.release_date, undefined) + }) + + test('uses known Claude metadata instead of random fallback buckets', () => { + const sonnet = inferModelMetadata( + pricingModel({ model_name: 'claude-sonnet-4-6' }) + ) + assert.equal(sonnet.context_length, 1_000_000) + assert.equal(sonnet.max_output_tokens, 64_000) + + const opus = inferModelMetadata( + pricingModel({ model_name: 'claude-opus-4-7' }) + ) + assert.equal(opus.context_length, 1_000_000) + assert.equal(opus.max_output_tokens, 128_000) + + const haiku = inferModelMetadata( + pricingModel({ model_name: 'claude-haiku-4-5-20251001' }) + ) + assert.equal(haiku.context_length, 200_000) + assert.equal(haiku.max_output_tokens, 64_000) + }) + + test('does not invent release or knowledge dates without explicit metadata', () => { + const metadata = inferModelMetadata( + pricingModel({ model_name: 'unknown-vendor-model' }) + ) + + assert.equal(metadata.knowledge_cutoff, undefined) + assert.equal(metadata.release_date, undefined) + }) +}) diff --git a/web/default/src/features/pricing/lib/model-metadata.ts b/web/default/src/features/pricing/lib/model-metadata.ts index 5042b292210d..43bb8eaf24a2 100644 --- a/web/default/src/features/pricing/lib/model-metadata.ts +++ b/web/default/src/features/pricing/lib/model-metadata.ts @@ -25,10 +25,10 @@ import { hashStringToSeed, seededRandom } from './seed' // // The backend does not currently return `context_length`, `max_output_tokens`, // `knowledge_cutoff`, `release_date`, `parameter_count`, or modality/capability -// flags for a model. Until it does, we infer reasonable values client-side -// from the data we already have (endpoint types, ratios, tags, model name) -// and fall back to a deterministic mock seeded from the model name so that -// every render of the same model shows the same numbers. +// flags for a model. Until it does, we infer safe operational metadata from +// endpoint types, ratios, tags, and model names. Dates are only shown when they +// are explicit because invented lifecycle dates are more misleading than empty +// fields. // // When the backend starts returning these fields, callers should prefer the // explicit values on `model.*` and only fall back to the inferred ones. @@ -76,20 +76,6 @@ const CODE_NAME_PATTERNS = [/code/i, /-coder/i] const WEB_SEARCH_PATTERNS = [/web[-_ ]?search/i, /-online/i, /perplexity/i] -const KNOWLEDGE_CUTOFFS = [ - '2023-04', - '2023-10', - '2023-12', - '2024-04', - '2024-06', - '2024-08', - '2024-10', - '2024-12', - '2025-02', - '2025-04', - '2025-08', -] - const PARAM_BUCKETS = [ '1.5B', '3B', @@ -102,10 +88,73 @@ const PARAM_BUCKETS = [ '405B', ] -const CONTEXT_BUCKETS = [ - 8_192, 16_384, 32_768, 65_536, 128_000, 200_000, 1_000_000, -] -const MAX_OUTPUT_BUCKETS = [2_048, 4_096, 8_192, 16_384, 32_768, 65_536] +const MODEL_METADATA_OVERRIDES: Record< + string, + Partial< + Pick< + ModelMetadata, + | 'context_length' + | 'max_output_tokens' + | 'knowledge_cutoff' + | 'release_date' + | 'input_modalities' + | 'output_modalities' + | 'capabilities' + > + > +> = { + 'claude-sonnet-4-6': { + context_length: 1_000_000, + max_output_tokens: 64_000, + input_modalities: ['text', 'image'], + output_modalities: ['text'], + capabilities: [ + 'streaming', + 'system_prompt', + 'function_calling', + 'tools', + 'json_mode', + 'structured_output', + 'vision', + 'reasoning', + 'code_interpreter', + ], + }, + 'claude-opus-4-7': { + context_length: 1_000_000, + max_output_tokens: 128_000, + input_modalities: ['text', 'image'], + output_modalities: ['text'], + capabilities: [ + 'streaming', + 'system_prompt', + 'function_calling', + 'tools', + 'json_mode', + 'structured_output', + 'vision', + 'reasoning', + 'code_interpreter', + ], + }, + 'claude-haiku-4-5-20251001': { + context_length: 200_000, + max_output_tokens: 64_000, + input_modalities: ['text', 'image'], + output_modalities: ['text'], + capabilities: [ + 'streaming', + 'system_prompt', + 'function_calling', + 'tools', + 'json_mode', + 'structured_output', + 'vision', + 'reasoning', + 'code_interpreter', + ], + }, +} const TAG_TO_CAPABILITY: Record = { vision: 'vision', @@ -249,11 +298,43 @@ function ordered(modalities: Set): Modality[] { return order.filter((m) => modalities.has(m)) } +function parseTokenCount(value: string): number { + const normalized = value.replace(/,/g, '').trim().toLowerCase() + const match = normalized.match(/^(\d+(?:\.\d+)?)(m|k)?$/) + if (!match) return 0 + + const number = Number(match[1]) + if (!Number.isFinite(number)) return 0 + if (match[2] === 'm') return Math.round(number * 1_000_000) + if (match[2] === 'k') return Math.round(number * 1_000) + return Math.round(number) +} + +function inferLimitsFromDescription( + description?: string +): { context?: number; maxOutput?: number } { + if (!description) return {} + + const contextMatch = description.match( + /([\d,.]+(?:\s*[mk])?)\s*[- ]?token\s+context\s+window/i + ) + const outputMatch = description.match( + /(?:up to|maximum of|max(?:imum)?(?: output)?(?: tokens)?(?: of)?)\s+([\d,.]+(?:\s*[mk])?)\s+(?:output\s+)?tokens/i + ) + + const context = contextMatch ? parseTokenCount(contextMatch[1]) : 0 + const maxOutput = outputMatch ? parseTokenCount(outputMatch[1]) : 0 + + return { + context: context > 0 ? context : undefined, + maxOutput: maxOutput > 0 ? maxOutput : undefined, + } +} + function inferContextAndOutputs( name: string, - rand: () => number, endpoints: string[] -): { context: number; maxOutput: number } { +): { context?: number; maxOutput?: number } { if (endpoints.includes('embeddings') || endpoints.includes('jina-rerank')) { return { context: 8_192, maxOutput: 0 } } @@ -285,30 +366,14 @@ function inferContextAndOutputs( return { context: 16_384, maxOutput: 4_096 } } - const context = pickFromBuckets(CONTEXT_BUCKETS, rand) - const maxOutput = Math.min(context, pickFromBuckets(MAX_OUTPUT_BUCKETS, rand)) - return { context, maxOutput } -} - -function inferReleaseAndCutoff(rand: () => number): { - release: string - cutoff: string -} { - const cutoff = pickFromBuckets(KNOWLEDGE_CUTOFFS, rand) - const [year, month] = cutoff.split('-').map(Number) - const offsetMonths = 4 + Math.floor(rand() * 6) - const releaseMonth = month + offsetMonths - const releaseYear = year + Math.floor((releaseMonth - 1) / 12) - const finalMonth = ((releaseMonth - 1) % 12) + 1 - const release = `${releaseYear}-${String(finalMonth).padStart(2, '0')}-15` - return { release, cutoff } + return {} } export type ModelMetadata = { - context_length: number - max_output_tokens: number - knowledge_cutoff: string - release_date: string + context_length?: number + max_output_tokens?: number + knowledge_cutoff?: string + release_date?: string parameter_count: string input_modalities: Modality[] output_modalities: Modality[] @@ -324,23 +389,37 @@ export function inferModelMetadata(model: PricingModel): ModelMetadata { const rand = seededRandom(hashStringToSeed(name)) const tags = parseModelTags(model.tags) const endpoints = model.supported_endpoint_types || [] + const override = MODEL_METADATA_OVERRIDES[name.toLowerCase()] const inputs = - model.input_modalities ?? inferInputModalities(model, tags, endpoints, name) + model.input_modalities ?? + override?.input_modalities ?? + inferInputModalities(model, tags, endpoints, name) const outputs = - model.output_modalities ?? inferOutputModalities(model, endpoints, name) + model.output_modalities ?? + override?.output_modalities ?? + inferOutputModalities(model, endpoints, name) const capabilities = model.capabilities ?? + override?.capabilities ?? inferCapabilities(model, tags, endpoints, name, outputs, inputs) - const fallback = inferContextAndOutputs(name, rand, endpoints) - const cutoffAndRelease = inferReleaseAndCutoff(rand) + const descriptionLimits = inferLimitsFromDescription(model.description) + const fallback = inferContextAndOutputs(name, endpoints) return { - context_length: model.context_length ?? fallback.context, - max_output_tokens: model.max_output_tokens ?? fallback.maxOutput, - knowledge_cutoff: model.knowledge_cutoff ?? cutoffAndRelease.cutoff, - release_date: model.release_date ?? cutoffAndRelease.release, + context_length: + model.context_length ?? + override?.context_length ?? + descriptionLimits.context ?? + fallback.context, + max_output_tokens: + model.max_output_tokens ?? + override?.max_output_tokens ?? + descriptionLimits.maxOutput ?? + fallback.maxOutput, + knowledge_cutoff: model.knowledge_cutoff ?? override?.knowledge_cutoff, + release_date: model.release_date ?? override?.release_date, parameter_count: model.parameter_count ?? pickFromBuckets(PARAM_BUCKETS, rand), input_modalities: inputs, From 2d5d8de108e01d3e83489235a455f63da0bdfe12 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sat, 16 May 2026 17:35:52 +0800 Subject: [PATCH 28/45] fix: preserve token count precision --- .../src/features/pricing/lib/model-metadata.test.ts | 8 +++++++- web/default/src/features/pricing/lib/model-metadata.ts | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/web/default/src/features/pricing/lib/model-metadata.test.ts b/web/default/src/features/pricing/lib/model-metadata.test.ts index 6c8ca3a029d1..cc5553463c07 100644 --- a/web/default/src/features/pricing/lib/model-metadata.test.ts +++ b/web/default/src/features/pricing/lib/model-metadata.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' -import { inferModelMetadata } from './model-metadata' +import { formatTokenCount, inferModelMetadata } from './model-metadata' import type { PricingModel } from '../types' function pricingModel(overrides: Partial): PricingModel { @@ -17,6 +17,12 @@ function pricingModel(overrides: Partial): PricingModel { } describe('inferModelMetadata', () => { + test('formats million-token windows without hiding meaningful precision', () => { + assert.equal(formatTokenCount(1_000_000), '1M') + assert.equal(formatTokenCount(1_050_000), '1.05M') + assert.equal(formatTokenCount(1_500_000), '1.5M') + }) + test('parses token limits from model descriptions before name heuristics', () => { const metadata = inferModelMetadata( pricingModel({ diff --git a/web/default/src/features/pricing/lib/model-metadata.ts b/web/default/src/features/pricing/lib/model-metadata.ts index 43bb8eaf24a2..cc9178190641 100644 --- a/web/default/src/features/pricing/lib/model-metadata.ts +++ b/web/default/src/features/pricing/lib/model-metadata.ts @@ -432,12 +432,16 @@ const TOKEN_FORMAT = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1, }) +const MILLION_TOKEN_FORMAT = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, +}) + /** Format a token count compactly: 128_000 → "128K", 1_000_000 → "1M". */ export function formatTokenCount(tokens: number): string { if (!Number.isFinite(tokens) || tokens <= 0) return '—' if (tokens >= 1_000_000) { const value = tokens / 1_000_000 - return `${TOKEN_FORMAT.format(value)}M` + return `${MILLION_TOKEN_FORMAT.format(value)}M` } if (tokens >= 1_000) { const value = tokens / 1_000 From 948f953a5f03136338e9788c5f01386c0677c23f Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Sat, 16 May 2026 22:04:19 +0800 Subject: [PATCH 29/45] feat: show official price savings on model square --- .../pricing/components/model-card-grid.tsx | 2 + .../pricing/components/model-card.tsx | 31 +++++- .../pricing/components/pricing-columns.tsx | 25 +++++ .../pricing/components/pricing-table.tsx | 3 + .../pricing/hooks/use-pricing-data.ts | 14 +++ web/default/src/features/pricing/index.tsx | 3 + .../src/features/pricing/lib/price.test.ts | 92 +++++++++++++++++ web/default/src/features/pricing/lib/price.ts | 99 +++++++++++++++++++ web/default/src/i18n/locales/en.json | 1 + web/default/src/i18n/locales/fr.json | 1 + web/default/src/i18n/locales/ja.json | 1 + web/default/src/i18n/locales/ru.json | 1 + web/default/src/i18n/locales/vi.json | 1 + web/default/src/i18n/locales/zh.json | 1 + web/default/src/i18n/static-keys.ts | 1 + 15 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 web/default/src/features/pricing/lib/price.test.ts diff --git a/web/default/src/features/pricing/components/model-card-grid.tsx b/web/default/src/features/pricing/components/model-card-grid.tsx index 2c8932dc74ce..21b007f7f04d 100644 --- a/web/default/src/features/pricing/components/model-card-grid.tsx +++ b/web/default/src/features/pricing/components/model-card-grid.tsx @@ -32,6 +32,7 @@ export interface ModelCardGridProps { onModelClick: (modelName: string) => void priceRate?: number usdExchangeRate?: number + officialUsdExchangeRate?: number tokenUnit?: TokenUnit showRechargePrice?: boolean } @@ -78,6 +79,7 @@ export function ModelCardGrid(props: ModelCardGridProps) { tokenUnit={tokenUnit} priceRate={props.priceRate} usdExchangeRate={props.usdExchangeRate} + officialUsdExchangeRate={props.officialUsdExchangeRate} showRechargePrice={props.showRechargePrice} perf={perfMap.get(model.model_name || '')} onClick={() => props.onModelClick(model.model_name || '')} diff --git a/web/default/src/features/pricing/components/model-card.tsx b/web/default/src/features/pricing/components/model-card.tsx index a8d792bc87e6..72edbca24545 100644 --- a/web/default/src/features/pricing/components/model-card.tsx +++ b/web/default/src/features/pricing/components/model-card.tsx @@ -30,7 +30,12 @@ import { } from '../lib/dynamic-price' import { parseTags } from '../lib/filters' import { isTokenBasedModel } from '../lib/model-helpers' -import { formatPrice, formatRequestPrice } from '../lib/price' +import { + calculateOfficialSavings, + formatPrice, + formatRequestPrice, + formatSavingsPercent, +} from '../lib/price' import type { PricingModel, TokenUnit } from '../types' import { ModelPerfBadge, type ModelPerfBadgeData } from './model-perf-badge' @@ -39,6 +44,7 @@ export interface ModelCardProps { onClick: () => void priceRate?: number usdExchangeRate?: number + officialUsdExchangeRate?: number tokenUnit?: TokenUnit showRechargePrice?: boolean perf?: ModelPerfBadgeData @@ -50,6 +56,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT const priceRate = props.priceRate ?? 1 const usdExchangeRate = props.usdExchangeRate ?? 1 + const officialUsdExchangeRate = props.officialUsdExchangeRate ?? priceRate const showRechargePrice = props.showRechargePrice ?? false const isTokenBased = isTokenBasedModel(props.model) const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M' @@ -73,6 +80,16 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { groupRatioMultiplier: getDynamicDisplayGroupRatio(props.model), }) : null + const savings = calculateOfficialSavings(props.model, { + priceRate, + usdExchangeRate, + officialUsdExchangeRate, + }) + const savingsLabel = savings + ? t('Save {{percent}}%', { + percent: formatSavingsPercent(savings.percent), + }) + : null const primaryGroup = groups[0] const bottomTags = [...endpoints.slice(0, 2), ...tags.slice(0, 2)] @@ -107,7 +124,17 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {

{props.model.model_name}

-
+ {savingsLabel && ( +
+ {savingsLabel} +
+ )} +
{dynamicSummary ? ( dynamicSummary.isSpecialExpression ? ( diff --git a/web/default/src/features/pricing/components/pricing-columns.tsx b/web/default/src/features/pricing/components/pricing-columns.tsx index c6f26406f2b4..7560474fe2b4 100644 --- a/web/default/src/features/pricing/components/pricing-columns.tsx +++ b/web/default/src/features/pricing/components/pricing-columns.tsx @@ -35,8 +35,10 @@ import { import { parseTags } from '../lib/filters' import { isTokenBasedModel } from '../lib/model-helpers' import { + calculateOfficialSavings, formatPrice, formatRequestPrice, + formatSavingsPercent, stripTrailingZeros, } from '../lib/price' import type { PricingModel, TokenUnit } from '../types' @@ -49,6 +51,7 @@ export interface PricingColumnsOptions { tokenUnit?: TokenUnit priceRate?: number usdExchangeRate?: number + officialUsdExchangeRate?: number showRechargePrice?: boolean } @@ -102,11 +105,30 @@ export function usePricingColumns( tokenUnit = DEFAULT_TOKEN_UNIT, priceRate = 1, usdExchangeRate = 1, + officialUsdExchangeRate = priceRate, showRechargePrice = false, } = options const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M' + const renderSavingsBadge = (model: PricingModel) => { + const savings = calculateOfficialSavings(model, { + priceRate, + usdExchangeRate, + officialUsdExchangeRate, + }) + + if (!savings) return null + + return ( +
+ {t('Save {{percent}}%', { + percent: formatSavingsPercent(savings.percent), + })} +
+ ) + } + return [ // Model column { @@ -212,6 +234,7 @@ export function usePricingColumns( count: dynamicSummary.tierCount, })}`}
+ {renderSavingsBadge(model)}
) } @@ -250,6 +273,7 @@ export function usePricingColumns(
/ {tokenUnitLabel} tokens
+ {renderSavingsBadge(model)}
) } @@ -269,6 +293,7 @@ export function usePricingColumns(
/ {t('request')}
+ {renderSavingsBadge(model)} ) }, diff --git a/web/default/src/features/pricing/components/pricing-table.tsx b/web/default/src/features/pricing/components/pricing-table.tsx index 49d65e0f08b3..c20a9144bb0c 100644 --- a/web/default/src/features/pricing/components/pricing-table.tsx +++ b/web/default/src/features/pricing/components/pricing-table.tsx @@ -44,6 +44,7 @@ export interface PricingTableProps { isLoading?: boolean priceRate?: number usdExchangeRate?: number + officialUsdExchangeRate?: number tokenUnit?: TokenUnit showRechargePrice?: boolean onModelClick?: (modelName: string) => void @@ -56,6 +57,7 @@ export function PricingTable(props: PricingTableProps) { isLoading = false, priceRate = 1, usdExchangeRate = 1, + officialUsdExchangeRate = usdExchangeRate, tokenUnit = DEFAULT_TOKEN_UNIT, showRechargePrice = false, onModelClick, @@ -70,6 +72,7 @@ export function PricingTable(props: PricingTableProps) { tokenUnit, priceRate, usdExchangeRate, + officialUsdExchangeRate, showRechargePrice, }) diff --git a/web/default/src/features/pricing/hooks/use-pricing-data.ts b/web/default/src/features/pricing/hooks/use-pricing-data.ts index 914f6e63da51..bbca1238c378 100644 --- a/web/default/src/features/pricing/hooks/use-pricing-data.ts +++ b/web/default/src/features/pricing/hooks/use-pricing-data.ts @@ -39,6 +39,19 @@ export function usePricingData() { () => Math.max((status?.usd_exchange_rate as number) ?? priceRate, 0.001), [status?.usd_exchange_rate, priceRate] ) + const officialUsdExchangeRate = useMemo(() => { + const stripeUnitPrice = Number(status?.stripe_unit_price) + if (Number.isFinite(stripeUnitPrice) && stripeUnitPrice > 0) { + return stripeUnitPrice + } + + const paymentPrice = Number(status?.price) + if (Number.isFinite(paymentPrice) && paymentPrice > 0) { + return paymentPrice + } + + return usdExchangeRate + }, [status?.stripe_unit_price, status?.price, usdExchangeRate]) const models = useMemo(() => { if (!data?.data || !data?.vendors) return [] @@ -72,5 +85,6 @@ export function usePricingData() { refetch, priceRate, usdExchangeRate, + officialUsdExchangeRate, } } diff --git a/web/default/src/features/pricing/index.tsx b/web/default/src/features/pricing/index.tsx index d857c36e874d..6abc81c181f5 100644 --- a/web/default/src/features/pricing/index.tsx +++ b/web/default/src/features/pricing/index.tsx @@ -50,6 +50,7 @@ export function Pricing() { isLoading, priceRate, usdExchangeRate, + officialUsdExchangeRate, } = usePricingData() const { @@ -126,6 +127,7 @@ export function Pricing() { onModelClick={handleModelClick} priceRate={priceRate} usdExchangeRate={usdExchangeRate} + officialUsdExchangeRate={officialUsdExchangeRate} tokenUnit={tokenUnit} showRechargePrice={showRechargePrice} /> @@ -137,6 +139,7 @@ export function Pricing() { models={filteredModels} priceRate={priceRate} usdExchangeRate={usdExchangeRate} + officialUsdExchangeRate={officialUsdExchangeRate} tokenUnit={tokenUnit} showRechargePrice={showRechargePrice} onModelClick={handleModelClick} diff --git a/web/default/src/features/pricing/lib/price.test.ts b/web/default/src/features/pricing/lib/price.test.ts new file mode 100644 index 000000000000..6dcebf43ed9c --- /dev/null +++ b/web/default/src/features/pricing/lib/price.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { + calculateOfficialSavings, + formatSavingsPercent, +} from './price' +import type { PricingModel } from '../types' + +function pricingModel(overrides: Partial): PricingModel { + return { + id: 1, + model_name: 'gpt-5.5', + quota_type: 0, + model_ratio: 2.5, + completion_ratio: 6, + enable_groups: ['gpt pro'], + group_ratio: { 'gpt pro': 0.2 }, + ...overrides, + } +} + +describe('calculateOfficialSavings', () => { + test('compares grouped RMB price against official USD price converted to RMB', () => { + const savings = calculateOfficialSavings(pricingModel({}), { + usdExchangeRate: 1, + officialUsdExchangeRate: 8, + }) + + assert.equal(savings?.group, 'gpt pro') + assert.equal(savings?.groupRatio, 0.2) + assert.equal(formatSavingsPercent(savings?.percent ?? 0), '97.5') + }) + + test('uses the cheapest enabled group for model square savings', () => { + const savings = calculateOfficialSavings( + pricingModel({ + model_name: 'claude-sonnet-4-6', + model_ratio: 1.5, + completion_ratio: 5, + enable_groups: ['cc max', 'c antigravity'], + group_ratio: { + 'cc max': 1.8, + 'c antigravity': 0.5, + }, + }), + { + usdExchangeRate: 1, + officialUsdExchangeRate: 8, + } + ) + + assert.equal(savings?.group, 'c antigravity') + assert.equal(savings?.groupRatio, 0.5) + assert.equal(formatSavingsPercent(savings?.percent ?? 0), '93.75') + }) + + test('handles per-request models with the same currency conversion rule', () => { + const savings = calculateOfficialSavings( + pricingModel({ + model_name: 'gpt-image-2', + quota_type: 1, + model_ratio: 0, + completion_ratio: 0, + model_price: 0.25, + enable_groups: ['gpt pro'], + group_ratio: { 'gpt pro': 0.2 }, + }), + { + usdExchangeRate: 1, + officialUsdExchangeRate: 8, + } + ) + + assert.equal(savings?.group, 'gpt pro') + assert.equal(formatSavingsPercent(savings?.percent ?? 0), '97.5') + }) + + test('returns no badge when no positive savings exist', () => { + const savings = calculateOfficialSavings( + pricingModel({ + enable_groups: ['standard'], + group_ratio: { standard: 1 }, + }), + { + usdExchangeRate: 8, + officialUsdExchangeRate: 8, + } + ) + + assert.equal(savings, null) + }) +}) diff --git a/web/default/src/features/pricing/lib/price.ts b/web/default/src/features/pricing/lib/price.ts index decbd5978cef..5f7eeece831a 100644 --- a/web/default/src/features/pricing/lib/price.ts +++ b/web/default/src/features/pricing/lib/price.ts @@ -73,6 +73,105 @@ function getMinGroupRatio( return minRatio === Number.POSITIVE_INFINITY ? 1 : minRatio } +type CheapestGroupRatio = { + group: string + ratio: number +} + +export type OfficialSavings = { + percent: number + group: string + groupRatio: number +} + +export type OfficialSavingsOptions = { + priceRate?: number + usdExchangeRate?: number + officialUsdExchangeRate?: number +} + +function getCheapestEnabledGroupRatio( + enableGroups: string[], + groupRatio: Record +): CheapestGroupRatio | null { + if (enableGroups.length === 0) return null + + let cheapest: CheapestGroupRatio | null = null + + for (const group of enableGroups) { + const rawRatio = groupRatio[group] + const ratio = rawRatio === undefined ? 1 : Number(rawRatio) + if (!Number.isFinite(ratio) || ratio <= 0) continue + + if (!cheapest || ratio < cheapest.ratio) { + cheapest = { group, ratio } + } + } + + return cheapest +} + +function getPositiveNumber(value: number | null | undefined): number | null { + if (value == null) return null + const number = Number(value) + return Number.isFinite(number) && number > 0 ? number : null +} + +function hasComparablePrice(model: PricingModel): boolean { + if (model.billing_mode === 'tiered_expr') { + return false + } + + if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) { + return getPositiveNumber(model.model_price) !== null + } + + return ( + model.quota_type === QUOTA_TYPE_VALUES.TOKEN && + getPositiveNumber(model.model_ratio) !== null + ) +} + +export function calculateOfficialSavings( + model: PricingModel, + options: OfficialSavingsOptions = {} +): OfficialSavings | null { + if (!hasComparablePrice(model)) return null + + const enableGroups = Array.isArray(model.enable_groups) + ? model.enable_groups + : [] + const groupRatio = model.group_ratio || {} + const cheapestGroup = getCheapestEnabledGroupRatio(enableGroups, groupRatio) + if (!cheapestGroup) return null + + const localUsdPriceRate = + getPositiveNumber(options.priceRate) ?? + getPositiveNumber(options.usdExchangeRate) ?? + 1 + const officialUsdExchangeRate = + getPositiveNumber(options.officialUsdExchangeRate) ?? + getPositiveNumber(options.usdExchangeRate) ?? + localUsdPriceRate + + const relativePrice = + (cheapestGroup.ratio * localUsdPriceRate) / officialUsdExchangeRate + const percent = (1 - relativePrice) * 100 + + if (!Number.isFinite(percent) || percent <= 0) return null + + return { + percent: Math.min(percent, 100), + group: cheapestGroup.group, + groupRatio: cheapestGroup.ratio, + } +} + +export function formatSavingsPercent(percent: number): string { + if (!Number.isFinite(percent)) return '' + return percent.toFixed(2).replace(/\.?0+$/, '') +} + /** * Calculate token price in USD. * diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 7e34ec0a3525..4e6d561221d1 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -3409,6 +3409,7 @@ "Save tool prices": "Save tool prices", "Save Waffo Pancake settings": "Save Waffo Pancake settings", "Save Worker settings": "Save Worker settings", + "Save {{percent}}%": "Save {{percent}}%", "Saved successfully": "Saved successfully", "Saving...": "Saving...", "Scan QR Code": "Scan QR Code", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 0a682ccbb98a..ec0a5038c9b2 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -3409,6 +3409,7 @@ "Save tool prices": "Enregistrer les prix des outils", "Save Waffo Pancake settings": "Enregistrer les paramètres Waffo Pancake", "Save Worker settings": "Enregistrer les paramètres Worker", + "Save {{percent}}%": "Économisez {{percent}} %", "Saved successfully": "Enregistré avec succès", "Saving...": "Enregistrement en cours...", "Scan QR Code": "Scanner le code QR", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index bbacbda5a589..395a4d1c2d0a 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -3409,6 +3409,7 @@ "Save tool prices": "ツール価格を保存", "Save Waffo Pancake settings": "Waffo Pancake 設定を保存", "Save Worker settings": "Worker設定を保存", + "Save {{percent}}%": "{{percent}}% お得", "Saved successfully": "保存しました", "Saving...": "保存中...", "Scan QR Code": "QRコードをスキャン", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 2022e5624677..147b2ad9f90e 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -3409,6 +3409,7 @@ "Save tool prices": "Сохранить цены инструментов", "Save Waffo Pancake settings": "Сохранить настройки Waffo Pancake", "Save Worker settings": "Сохранить настройки Worker", + "Save {{percent}}%": "Экономия {{percent}}%", "Saved successfully": "Сохранено успешно", "Saving...": "Сохранение...", "Scan QR Code": "Сканировать QR-код", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 66f458f31e87..e4bd7859d213 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -3409,6 +3409,7 @@ "Save tool prices": "Lưu giá công cụ", "Save Waffo Pancake settings": "Lưu cài đặt Waffo Pancake", "Save Worker settings": "Lưu cài đặt Worker", + "Save {{percent}}%": "Tiết kiệm {{percent}}%", "Saved successfully": "Lưu thành công", "Saving...": "Đang lưu...", "Scan QR Code": "Quét mã QR", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 1d2e86754fa3..007b26b0c81d 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -3409,6 +3409,7 @@ "Save tool prices": "保存工具价格", "Save Waffo Pancake settings": "保存 Waffo Pancake 设置", "Save Worker settings": "保存 Worker 设置", + "Save {{percent}}%": "省 {{percent}}%", "Saved successfully": "保存成功", "Saving...": "正在保存...", "Scan QR Code": "扫描二维码", diff --git a/web/default/src/i18n/static-keys.ts b/web/default/src/i18n/static-keys.ts index 32200f085f9c..d77209923177 100644 --- a/web/default/src/i18n/static-keys.ts +++ b/web/default/src/i18n/static-keys.ts @@ -64,6 +64,7 @@ export const STATIC_I18N_KEYS = [ 'All Tags', 'More...', 'Less', + 'Save {{percent}}%', // Roles 'Super Admin', From 420a1663722a7f4785859d70195282eadb3e4d39 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Mon, 25 May 2026 11:46:23 +0800 Subject: [PATCH 30/45] fix(relay/responses): synthesize terminal SSE event when upstream cuts off Codex CLI and openai-python helpers raise "stream disconnected before completion: stream closed before response.completed" when the upstream Responses stream ends without emitting response.{completed,failed, incomplete}. Reasoning-heavy models (gpt-5.x) hit this regularly: long silent reasoning windows trigger STREAMING_TIMEOUT, and upstream providers occasionally drop the connection mid-stream. OaiResponsesStreamHandler now tracks whether a terminal event arrived and synthesizes one at scanner exit: response.completed (with local usage estimate) for graceful EOF with any output, response.failed (with EndReason as error.code) otherwise. Client-gone path is a no-op. The synthesized payload uses map[string]any so we emit the exact wire fields Codex's Rust parser reads (id + usage.{input,output,total}_tokens plus details) without going through dto.IncompleteDetails (whose JSON tag is misnamed "reasoning" instead of "reason"). --- relay/channel/openai/relay_responses.go | 23 ++ relay/channel/openai/responses_fallback.go | 295 +++++++++++++++ .../channel/openai/responses_fallback_test.go | 346 ++++++++++++++++++ 3 files changed, 664 insertions(+) create mode 100644 relay/channel/openai/responses_fallback.go create mode 100644 relay/channel/openai/responses_fallback_test.go diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 2665b8d027e9..284560b8b385 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -78,6 +78,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp var usage = &dto.Usage{} var responseTextBuilder strings.Builder + streamCtx := newResponsesStreamCtx() helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { @@ -88,6 +89,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp sr.Error(err) return } + streamCtx.observe(streamResponse) sendResponsesStreamData(c, streamResponse, data) switch streamResponse.Type { case "response.completed": @@ -130,6 +132,20 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp } }) + // Synthesize a terminal event if upstream never emitted one. This prevents + // the Codex CLI (and similar clients) from raising + // "stream disconnected before completion: stream closed before response.completed" + // and entering a retry loop. The synthesizer decides between + // response.completed (graceful EOF with partial output) and + // response.failed (timeout / scanner error / no output) based on + // info.StreamStatus. + if streamCtx.shouldSynthesize(c, info) { + if synthUsage := streamCtx.emitTerminal(c, info); synthUsage != nil { + usage = synthUsage + } + logger.LogInfo(c, fmt.Sprintf("synthesized responses terminal event (status=%s)", streamStatusSummary(info))) + } + if usage.CompletionTokens == 0 { // 计算输出文本的 token 数量 tempStr := responseTextBuilder.String() @@ -148,3 +164,10 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp return usage, nil } + +func streamStatusSummary(info *relaycommon.RelayInfo) string { + if info == nil || info.StreamStatus == nil { + return "unknown" + } + return info.StreamStatus.Summary() +} diff --git a/relay/channel/openai/responses_fallback.go b/relay/channel/openai/responses_fallback.go new file mode 100644 index 000000000000..831bd03c03cb --- /dev/null +++ b/relay/channel/openai/responses_fallback.go @@ -0,0 +1,295 @@ +package openai + +// Responses API SSE fallback: synthesize a terminal event when the upstream +// stream closes without ever emitting response.completed / response.failed / +// response.incomplete. +// +// Background: the OpenAI Codex CLI (and openai-python helpers) treat the +// absence of a terminal event as a hard error ("stream disconnected before +// completion: stream closed before response.completed"), then retry the turn +// up to 5 times. Reasoning-heavy models (gpt-5.x family) are the most +// affected because long silent reasoning windows are easy targets for +// gateway-level idle timeouts. When the upstream forgets to emit a terminal +// event (a known OpenAI bug — codex#3267, codex#14753), or when we ourselves +// have to cut the connection (STREAMING_TIMEOUT, scanner error, ping fail), +// we synthesize one so the client gets a clean termination. + +import ( + "fmt" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" +) + +// responsesStreamCtx accumulates everything we need to synthesize a faithful +// terminal event if the upstream never sends one. +type responsesStreamCtx struct { + seenTerminal bool // response.{completed,failed,incomplete} arrived from upstream + responseID string + model string + createdAt int64 + outputTextLen int // len of accumulated output_text — for "had any output?" branch + reasoningTextLen int // len of accumulated reasoning_text — for usage estimation + outputText strings.Builder + reasoningText strings.Builder + usage *dto.Usage // any usage observed upstream (incomplete/in_progress sometimes carry partial usage) +} + +func newResponsesStreamCtx() *responsesStreamCtx { + return &responsesStreamCtx{} +} + +// observe inspects one parsed upstream SSE event and updates state. +// Call this before the existing switch-case in the handler so the snapshot +// is up-to-date when synthesis runs. +func (ctx *responsesStreamCtx) observe(ev dto.ResponsesStreamResponse) { + switch ev.Type { + case "response.completed", "response.failed", "response.incomplete": + ctx.seenTerminal = true + case "response.output_text.delta": + if ev.Delta != "" { + ctx.outputText.WriteString(ev.Delta) + ctx.outputTextLen += len(ev.Delta) + } + case "response.reasoning_text.delta", "response.reasoning_summary_text.delta": + if ev.Delta != "" { + ctx.reasoningText.WriteString(ev.Delta) + ctx.reasoningTextLen += len(ev.Delta) + } + } + + if ev.Response != nil { + if ev.Response.ID != "" { + ctx.responseID = ev.Response.ID + } + if ev.Response.Model != "" { + ctx.model = ev.Response.Model + } + if ev.Response.CreatedAt != 0 { + ctx.createdAt = int64(ev.Response.CreatedAt) + } + if ev.Response.Usage != nil { + ctx.usage = ev.Response.Usage + } + } +} + +// shouldSynthesize decides whether to emit a synthetic terminal event. +// Skip if upstream already terminated, or if the client is gone (nothing to +// write to), or if the writer cannot accept more bytes. +func (ctx *responsesStreamCtx) shouldSynthesize(c *gin.Context, info *relaycommon.RelayInfo) bool { + if ctx.seenTerminal { + return false + } + if c == nil || c.Request == nil { + return false + } + if c.Request.Context().Err() != nil { + return false + } + if info != nil && info.StreamStatus != nil && + info.StreamStatus.EndReason == relaycommon.StreamEndReasonClientGone { + return false + } + return true +} + +// emitTerminal writes a synthesized response.completed or response.failed +// event to the SSE response. Returns the usage that callers should use for +// billing. +// +// Decision: if any output (text or reasoning) was produced AND the stream +// ended normally (EOF / [DONE] / handler-stop), emit response.completed so +// the client preserves partial output. Otherwise emit response.failed with a +// diagnostic message reflecting the EndReason. +func (ctx *responsesStreamCtx) emitTerminal(c *gin.Context, info *relaycommon.RelayInfo) *dto.Usage { + usage := ctx.buildUsage(info) + responseID := ctx.resolveResponseID(c) + model := ctx.resolveModel(info) + createdAt := ctx.resolveCreatedAt() + + normalEnd := info == nil || info.StreamStatus == nil || info.StreamStatus.IsNormalEnd() + hadOutput := ctx.outputTextLen > 0 || ctx.reasoningTextLen > 0 + + if normalEnd && hadOutput { + ctx.writeCompletedEvent(c, responseID, model, createdAt, usage) + } else { + ctx.writeFailedEvent(c, responseID, model, createdAt, usage, info) + } + return usage +} + +func (ctx *responsesStreamCtx) buildUsage(info *relaycommon.RelayInfo) *dto.Usage { + // Prefer any upstream-reported usage we managed to capture, then fall back + // to a local estimate from the accumulated text. + if ctx.usage != nil { + u := *ctx.usage + return &u + } + + model := ctx.resolveModel(info) + outputTokens := service.CountTextToken(ctx.outputText.String(), model) + reasoningTokens := service.CountTextToken(ctx.reasoningText.String(), model) + completion := outputTokens + reasoningTokens + + prompt := 0 + if info != nil { + prompt = info.GetEstimatePromptTokens() + } + + return &dto.Usage{ + PromptTokens: prompt, + CompletionTokens: completion, + TotalTokens: prompt + completion, + InputTokens: prompt, + OutputTokens: completion, + CompletionTokenDetails: dto.OutputTokenDetails{ + ReasoningTokens: reasoningTokens, + }, + } +} + +func (ctx *responsesStreamCtx) resolveResponseID(c *gin.Context) string { + if ctx.responseID != "" { + return ctx.responseID + } + return helper.GetResponseID(c) +} + +func (ctx *responsesStreamCtx) resolveModel(info *relaycommon.RelayInfo) string { + if ctx.model != "" { + return ctx.model + } + if info != nil { + return info.UpstreamModelName + } + return "" +} + +func (ctx *responsesStreamCtx) resolveCreatedAt() int64 { + if ctx.createdAt != 0 { + return ctx.createdAt + } + return time.Now().Unix() +} + +// writeCompletedEvent emits a response.completed event with the minimum +// shape required by Codex (id + usage) and the optional fields most other +// clients consult (model, created_at, status, output=[]). +func (ctx *responsesStreamCtx) writeCompletedEvent(c *gin.Context, id, model string, createdAt int64, usage *dto.Usage) { + response := map[string]any{ + "id": id, + "object": "response", + "status": "completed", + "model": model, + "created_at": createdAt, + "output": []any{}, + "usage": usageToResponsesPayload(usage), + } + ctx.writeSyntheticEvent(c, "response.completed", response) +} + +// writeFailedEvent emits a response.failed event with an error object that +// Codex's parser maps to a human-readable error message. +func (ctx *responsesStreamCtx) writeFailedEvent(c *gin.Context, id, model string, createdAt int64, usage *dto.Usage, info *relaycommon.RelayInfo) { + message := "upstream stream interrupted" + code := "stream_disconnect" + if info != nil && info.StreamStatus != nil { + summary := info.StreamStatus.Summary() + if summary != "" { + message = "upstream stream interrupted: " + summary + } + if info.StreamStatus.EndReason != "" { + code = string(info.StreamStatus.EndReason) + } + } + response := map[string]any{ + "id": id, + "object": "response", + "status": "failed", + "model": model, + "created_at": createdAt, + "output": []any{}, + "error": map[string]any{ + "type": "stream_error", + "code": code, + "message": message, + }, + "usage": usageToResponsesPayload(usage), + } + ctx.writeSyntheticEvent(c, "response.failed", response) +} + +// writeSyntheticEvent serializes the payload and writes the +// `event:` + `data:` SSE pair using the same format helper.ResponseChunkData +// uses for upstream-passthrough events. +func (ctx *responsesStreamCtx) writeSyntheticEvent(c *gin.Context, eventType string, response map[string]any) { + payload := map[string]any{ + "type": eventType, + "response": response, + } + data, err := common.Marshal(payload) + if err != nil { + logger.LogError(c, fmt.Sprintf("synthesize %s: marshal failed: %s", eventType, err.Error())) + return + } + + syntheticEvent := dto.ResponsesStreamResponse{Type: eventType} + sendResponsesStreamData(c, syntheticEvent, string(data)) +} + +// usageToResponsesPayload converts an internal dto.Usage into the JSON shape +// the Responses API uses (input_tokens / output_tokens / total_tokens with +// nested details), which is what Codex's ResponseCompletedUsage deserializer +// reads. +func usageToResponsesPayload(usage *dto.Usage) map[string]any { + if usage == nil { + return map[string]any{ + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + } + } + + input := usage.InputTokens + if input == 0 { + input = usage.PromptTokens + } + output := usage.OutputTokens + if output == 0 { + output = usage.CompletionTokens + } + total := usage.TotalTokens + if total == 0 { + total = input + output + } + + payload := map[string]any{ + "input_tokens": input, + "output_tokens": output, + "total_tokens": total, + } + if usage.InputTokensDetails != nil { + payload["input_tokens_details"] = map[string]any{ + "cached_tokens": usage.InputTokensDetails.CachedTokens, + } + } else if usage.PromptTokensDetails.CachedTokens != 0 { + payload["input_tokens_details"] = map[string]any{ + "cached_tokens": usage.PromptTokensDetails.CachedTokens, + } + } + if usage.CompletionTokenDetails.ReasoningTokens != 0 { + payload["output_tokens_details"] = map[string]any{ + "reasoning_tokens": usage.CompletionTokenDetails.ReasoningTokens, + } + } + return payload +} diff --git a/relay/channel/openai/responses_fallback_test.go b/relay/channel/openai/responses_fallback_test.go new file mode 100644 index 000000000000..bb58c53d1900 --- /dev/null +++ b/relay/channel/openai/responses_fallback_test.go @@ -0,0 +1,346 @@ +package openai + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + gin.SetMode(gin.TestMode) + // CountTextToken requires the cl100k_base tokenizer; production callers + // rely on common/init.go running this at startup. Tests bypass that path. + service.InitTokenEncoders() +} + +func setupResponsesTest(t *testing.T, body io.Reader) (*gin.Context, *http.Response, *relaycommon.RelayInfo, *httptest.ResponseRecorder) { + t.Helper() + + oldTimeout := constant.StreamingTimeout + constant.StreamingTimeout = 30 + t.Cleanup(func() { + constant.StreamingTimeout = oldTimeout + }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + common.SetContextKey(c, common.RequestIdKey, "test-req-id") + + resp := &http.Response{ + Body: io.NopCloser(body), + } + + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-5.5"}, + RelayFormat: types.RelayFormatOpenAI, + } + info.SetEstimatePromptTokens(100) + + return c, resp, info, recorder +} + +// extractSyntheticEvent walks the recorded SSE output and returns the +// event-name + JSON pair from the LAST `event: response.* / data: {...}` +// block. Returns empty strings if no such block exists. +func extractSyntheticEvent(t *testing.T, recorder *httptest.ResponseRecorder) (string, string) { + t.Helper() + body := recorder.Body.String() + + lines := strings.Split(body, "\n") + var lastEvent, lastData string + for i := 0; i < len(lines); i++ { + line := lines[i] + if strings.HasPrefix(line, "event: ") { + lastEvent = strings.TrimPrefix(line, "event: ") + lastEvent = strings.TrimSpace(lastEvent) + } else if strings.HasPrefix(line, "data: ") { + lastData = strings.TrimPrefix(line, "data: ") + } + } + return lastEvent, lastData +} + +// -------- observe() tests (pure state, no HTTP) -------- + +func TestResponsesStreamCtx_ObserveTerminalEvents(t *testing.T) { + t.Parallel() + + for _, terminal := range []string{"response.completed", "response.failed", "response.incomplete"} { + ctx := newResponsesStreamCtx() + ctx.observe(dto.ResponsesStreamResponse{Type: terminal}) + assert.True(t, ctx.seenTerminal, "%s must set seenTerminal", terminal) + } +} + +func TestResponsesStreamCtx_ObserveNonTerminalEvents(t *testing.T) { + t.Parallel() + + ctx := newResponsesStreamCtx() + ctx.observe(dto.ResponsesStreamResponse{Type: "response.created"}) + ctx.observe(dto.ResponsesStreamResponse{Type: "response.in_progress"}) + ctx.observe(dto.ResponsesStreamResponse{Type: "response.output_text.delta", Delta: "hello"}) + + assert.False(t, ctx.seenTerminal) + assert.Equal(t, len("hello"), ctx.outputTextLen) + assert.Equal(t, "hello", ctx.outputText.String()) +} + +func TestResponsesStreamCtx_ObserveAccumulatesReasoning(t *testing.T) { + t.Parallel() + + ctx := newResponsesStreamCtx() + ctx.observe(dto.ResponsesStreamResponse{Type: "response.reasoning_text.delta", Delta: "think "}) + ctx.observe(dto.ResponsesStreamResponse{Type: "response.reasoning_summary_text.delta", Delta: "summary"}) + + assert.Equal(t, len("think summary"), ctx.reasoningTextLen) + assert.Equal(t, "think summary", ctx.reasoningText.String()) + assert.Zero(t, ctx.outputTextLen) +} + +func TestResponsesStreamCtx_ObserveSnapshotsResponseMetadata(t *testing.T) { + t.Parallel() + + ctx := newResponsesStreamCtx() + ctx.observe(dto.ResponsesStreamResponse{ + Type: "response.created", + Response: &dto.OpenAIResponsesResponse{ + ID: "resp_abc", + Model: "gpt-5.5-2026-03-01", + CreatedAt: 1700000000, + }, + }) + ctx.observe(dto.ResponsesStreamResponse{ + Type: "response.in_progress", + Response: &dto.OpenAIResponsesResponse{ + Usage: &dto.Usage{InputTokens: 42, OutputTokens: 7, TotalTokens: 49}, + }, + }) + + assert.Equal(t, "resp_abc", ctx.responseID) + assert.Equal(t, "gpt-5.5-2026-03-01", ctx.model) + assert.Equal(t, int64(1700000000), ctx.createdAt) + require.NotNil(t, ctx.usage) + assert.Equal(t, 42, ctx.usage.InputTokens) +} + +// -------- shouldSynthesize() decision tests -------- + +func TestResponsesStreamCtx_ShouldSynthesize_SkipsWhenTerminalSeen(t *testing.T) { + t.Parallel() + c, _, info, _ := setupResponsesTest(t, strings.NewReader("")) + info.StreamStatus = relaycommon.NewStreamStatus() + info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonEOF, nil) + + ctx := newResponsesStreamCtx() + ctx.observe(dto.ResponsesStreamResponse{Type: "response.completed"}) + + assert.False(t, ctx.shouldSynthesize(c, info)) +} + +func TestResponsesStreamCtx_ShouldSynthesize_SkipsWhenClientGone(t *testing.T) { + t.Parallel() + c, _, info, _ := setupResponsesTest(t, strings.NewReader("")) + info.StreamStatus = relaycommon.NewStreamStatus() + info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, fmt.Errorf("ctx canceled")) + + ctx := newResponsesStreamCtx() + assert.False(t, ctx.shouldSynthesize(c, info)) +} + +func TestResponsesStreamCtx_ShouldSynthesize_TrueOnEOFWithoutTerminal(t *testing.T) { + t.Parallel() + c, _, info, _ := setupResponsesTest(t, strings.NewReader("")) + info.StreamStatus = relaycommon.NewStreamStatus() + info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonEOF, nil) + + ctx := newResponsesStreamCtx() + assert.True(t, ctx.shouldSynthesize(c, info)) +} + +// -------- emitTerminal() output shape tests -------- + +func TestResponsesStreamCtx_EmitTerminal_CompletedOnGracefulEOFWithOutput(t *testing.T) { + t.Parallel() + c, _, info, recorder := setupResponsesTest(t, strings.NewReader("")) + info.StreamStatus = relaycommon.NewStreamStatus() + info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonEOF, nil) + + ctx := newResponsesStreamCtx() + ctx.observe(dto.ResponsesStreamResponse{ + Type: "response.created", + Response: &dto.OpenAIResponsesResponse{ID: "resp_test", Model: "gpt-5.5"}, + }) + ctx.observe(dto.ResponsesStreamResponse{Type: "response.output_text.delta", Delta: "hello world"}) + + usage := ctx.emitTerminal(c, info) + require.NotNil(t, usage) + assert.Greater(t, usage.CompletionTokens, 0, "should estimate output tokens locally") + assert.Equal(t, 100, usage.PromptTokens, "prompt tokens come from estimate") + + eventName, dataJSON := extractSyntheticEvent(t, recorder) + assert.Equal(t, "response.completed", eventName) + require.NotEmpty(t, dataJSON, "must write data JSON") + + var payload map[string]any + require.NoError(t, common.UnmarshalJsonStr(dataJSON, &payload)) + assert.Equal(t, "response.completed", payload["type"]) + response, ok := payload["response"].(map[string]any) + require.True(t, ok, "response object must be present") + assert.Equal(t, "resp_test", response["id"]) + assert.Equal(t, "completed", response["status"]) + usagePayload, ok := response["usage"].(map[string]any) + require.True(t, ok, "usage must be present in synthesized event (Codex requires it)") + assert.Contains(t, usagePayload, "input_tokens") + assert.Contains(t, usagePayload, "output_tokens") + assert.Contains(t, usagePayload, "total_tokens") +} + +func TestResponsesStreamCtx_EmitTerminal_FailedOnTimeout(t *testing.T) { + t.Parallel() + c, _, info, recorder := setupResponsesTest(t, strings.NewReader("")) + info.StreamStatus = relaycommon.NewStreamStatus() + info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonTimeout, nil) + + ctx := newResponsesStreamCtx() + ctx.observe(dto.ResponsesStreamResponse{Type: "response.output_text.delta", Delta: "partial"}) + + usage := ctx.emitTerminal(c, info) + require.NotNil(t, usage) + + eventName, dataJSON := extractSyntheticEvent(t, recorder) + assert.Equal(t, "response.failed", eventName, "non-normal end emits response.failed") + + var payload map[string]any + require.NoError(t, common.UnmarshalJsonStr(dataJSON, &payload)) + response := payload["response"].(map[string]any) + assert.Equal(t, "failed", response["status"]) + errObj, ok := response["error"].(map[string]any) + require.True(t, ok, "failed event must carry error object") + assert.Equal(t, "stream_error", errObj["type"]) + assert.Equal(t, string(relaycommon.StreamEndReasonTimeout), errObj["code"]) + msg, _ := errObj["message"].(string) + assert.Contains(t, msg, "timeout", "error message should reflect EndReason summary") +} + +func TestResponsesStreamCtx_EmitTerminal_FailedWhenNoOutput(t *testing.T) { + t.Parallel() + c, _, info, recorder := setupResponsesTest(t, strings.NewReader("")) + info.StreamStatus = relaycommon.NewStreamStatus() + info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonEOF, nil) + + ctx := newResponsesStreamCtx() + // EOF without any output/reasoning deltas — synthesize failed, not completed + ctx.emitTerminal(c, info) + + eventName, _ := extractSyntheticEvent(t, recorder) + assert.Equal(t, "response.failed", eventName, "no output => failed even on graceful EOF") +} + +func TestResponsesStreamCtx_EmitTerminal_PrefersUpstreamUsage(t *testing.T) { + t.Parallel() + c, _, info, recorder := setupResponsesTest(t, strings.NewReader("")) + info.StreamStatus = relaycommon.NewStreamStatus() + info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonEOF, nil) + + ctx := newResponsesStreamCtx() + ctx.observe(dto.ResponsesStreamResponse{ + Type: "response.in_progress", + Response: &dto.OpenAIResponsesResponse{ + ID: "resp_x", + Usage: &dto.Usage{InputTokens: 999, OutputTokens: 888, TotalTokens: 1887}, + }, + }) + ctx.observe(dto.ResponsesStreamResponse{Type: "response.output_text.delta", Delta: "hi"}) + + ctx.emitTerminal(c, info) + + _, dataJSON := extractSyntheticEvent(t, recorder) + var payload map[string]any + require.NoError(t, common.UnmarshalJsonStr(dataJSON, &payload)) + usagePayload := payload["response"].(map[string]any)["usage"].(map[string]any) + assert.EqualValues(t, 999, usagePayload["input_tokens"]) + assert.EqualValues(t, 888, usagePayload["output_tokens"]) + assert.EqualValues(t, 1887, usagePayload["total_tokens"]) +} + +func TestResponsesStreamCtx_EmitTerminal_ReasoningCountsAsOutput(t *testing.T) { + t.Parallel() + c, _, info, recorder := setupResponsesTest(t, strings.NewReader("")) + info.StreamStatus = relaycommon.NewStreamStatus() + info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonEOF, nil) + + ctx := newResponsesStreamCtx() + // Only reasoning deltas — no visible output. We still want response.completed + // because the client/Codex should preserve reasoning state for the next turn. + ctx.observe(dto.ResponsesStreamResponse{Type: "response.reasoning_text.delta", Delta: "thinking about this..."}) + + ctx.emitTerminal(c, info) + + eventName, _ := extractSyntheticEvent(t, recorder) + assert.Equal(t, "response.completed", eventName) +} + +// -------- Usage payload shape (matches Codex's ResponseCompletedUsage) -------- + +func TestUsageToResponsesPayload_AllFieldsForCodex(t *testing.T) { + t.Parallel() + + usage := &dto.Usage{ + InputTokens: 120, + OutputTokens: 30, + TotalTokens: 150, + InputTokensDetails: &dto.InputTokenDetails{ + CachedTokens: 80, + }, + CompletionTokenDetails: dto.OutputTokenDetails{ + ReasoningTokens: 22, + }, + } + + payload := usageToResponsesPayload(usage) + assert.EqualValues(t, 120, payload["input_tokens"]) + assert.EqualValues(t, 30, payload["output_tokens"]) + assert.EqualValues(t, 150, payload["total_tokens"]) + + inputDetails, ok := payload["input_tokens_details"].(map[string]any) + require.True(t, ok) + assert.EqualValues(t, 80, inputDetails["cached_tokens"]) + + outputDetails, ok := payload["output_tokens_details"].(map[string]any) + require.True(t, ok) + assert.EqualValues(t, 22, outputDetails["reasoning_tokens"]) +} + +func TestUsageToResponsesPayload_NilSafe(t *testing.T) { + t.Parallel() + payload := usageToResponsesPayload(nil) + assert.EqualValues(t, 0, payload["input_tokens"]) + assert.EqualValues(t, 0, payload["output_tokens"]) + assert.EqualValues(t, 0, payload["total_tokens"]) +} + +func TestUsageToResponsesPayload_FallsBackToPromptCompletionTokens(t *testing.T) { + t.Parallel() + // Some upstream paths populate PromptTokens/CompletionTokens but leave + // InputTokens/OutputTokens at zero — make sure we don't write zeroes. + usage := &dto.Usage{PromptTokens: 50, CompletionTokens: 12} + payload := usageToResponsesPayload(usage) + assert.EqualValues(t, 50, payload["input_tokens"]) + assert.EqualValues(t, 12, payload["output_tokens"]) + assert.EqualValues(t, 62, payload["total_tokens"]) +} From 7ec88422db22f1204909cc5396e65d29d501ca26 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Mon, 25 May 2026 15:29:13 +0800 Subject: [PATCH 31/45] debug(distributor): dump body head when JSON parse fails Codex CLI is hitting POST /v1/responses and getting 400 "invalid JSON request body" but a manually-crafted request with the same key and a minimal Responses-API body succeeds. Log the first 512 bytes of the offending body plus Content-Type so we can see what the client actually sends. Will be reverted once diagnosed. --- middleware/distributor.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/middleware/distributor.go b/middleware/distributor.go index 771719b98b01..0e0b226b40c7 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -14,6 +14,7 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/service" @@ -198,6 +199,14 @@ func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) { return nil, err } if !gjson.ValidBytes(requestBody) { + // TEMPORARY: dump first 512 bytes of the offending body so we can see + // what clients are actually sending. Remove once diagnosed. + head := requestBody + if len(head) > 512 { + head = head[:512] + } + logger.LogError(c, fmt.Sprintf("invalid JSON body: ct=%q len=%d head=%q", + c.Request.Header.Get("Content-Type"), len(requestBody), string(head))) return nil, errors.New("invalid JSON request body") } From 6de0adf1bfe1933a4fbbdca2bb69896e38eb12f2 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Mon, 25 May 2026 15:37:01 +0800 Subject: [PATCH 32/45] fix(middleware): decompress zstd-encoded request bodies Codex CLI 0.133+ sends POST /v1/responses request bodies with Content-Encoding: zstd. DecompressRequestMiddleware only handled gzip and br, so the raw zstd bytes (magic 28 B5 2F FD) reached the JSON parser and every request 400'd with "invalid JSON request body". Add a zstd branch using klauspost/compress/zstd, already in the module graph as an indirect dependency. Also drop the temporary body-dump log in the distributor now that the cause is confirmed. --- go.mod | 2 +- go.sum | 2 -- middleware/distributor.go | 9 --------- middleware/gzip.go | 20 ++++++++++++++++++++ 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 672c7418a82f..5d844abb6368 100644 --- a/go.mod +++ b/go.mod @@ -105,7 +105,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.0 github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index e16f7e20f554..7ab9b8216dcb 100644 --- a/go.sum +++ b/go.sum @@ -308,8 +308,6 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/waffo-com/waffo-go v1.3.1 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7fKPw= github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g= -github.com/waffo-com/waffo-pancake-sdk-go v0.1.1 h1:YOI7+3zTBlTB7Ou6+ZXnJV2JvW/ag9d7CwE/TxH3Hls= -github.com/waffo-com/waffo-pancake-sdk-go v0.1.1/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI= github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 h1:cCSgccM66p7feTtgRqUUGT50tYQOhahsoPXavd+ib1U= github.com/waffo-com/waffo-pancake-sdk-go v0.2.0/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= diff --git a/middleware/distributor.go b/middleware/distributor.go index 0e0b226b40c7..771719b98b01 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -14,7 +14,6 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/i18n" - "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/service" @@ -199,14 +198,6 @@ func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) { return nil, err } if !gjson.ValidBytes(requestBody) { - // TEMPORARY: dump first 512 bytes of the offending body so we can see - // what clients are actually sending. Remove once diagnosed. - head := requestBody - if len(head) > 512 { - head = head[:512] - } - logger.LogError(c, fmt.Sprintf("invalid JSON body: ct=%q len=%d head=%q", - c.Request.Header.Get("Content-Type"), len(requestBody), string(head))) return nil, errors.New("invalid JSON request body") } diff --git a/middleware/gzip.go b/middleware/gzip.go index 5e5682532f7a..8080a6bb5f1e 100644 --- a/middleware/gzip.go +++ b/middleware/gzip.go @@ -8,6 +8,7 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/andybalholm/brotli" "github.com/gin-gonic/gin" + "github.com/klauspost/compress/zstd" ) type readCloser struct { @@ -65,6 +66,25 @@ func DecompressRequestMiddleware() gin.HandlerFunc { }, }) c.Request.Header.Del("Content-Encoding") + case "zstd": + // Codex CLI 0.133+ sends Responses API request bodies with + // Content-Encoding: zstd. Without this branch the raw compressed + // bytes reach the JSON parser and the request 400s with + // "invalid JSON request body". + zstdReader, err := zstd.NewReader(origBody) + if err != nil { + _ = origBody.Close() + c.AbortWithStatus(http.StatusBadRequest) + return + } + c.Request.Body = wrapMaxBytes(&readCloser{ + Reader: zstdReader, + closeFn: func() error { + zstdReader.Close() + return origBody.Close() + }, + }) + c.Request.Header.Del("Content-Encoding") default: // Even for uncompressed bodies, enforce a max size to avoid huge request allocations. c.Request.Body = wrapMaxBytes(origBody) From 8f50f01ead4f3887052c16e5cfa2f6871a58ea18 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Mon, 25 May 2026 16:44:27 +0800 Subject: [PATCH 33/45] feat(distributor): COMPACT_USE_BASE_MODEL bypasses compact suffix rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new-api auto-rewrites /v1/responses/compact requests' model field to -openai-compact so admins can route and bill compact traffic separately. That's overhead when the upstream relay treats compact and regular Responses calls identically (xtokenmirror, hostcentral, most codex-compatible proxies) — every channel then needs to declare the suffixed variant in its model list AND a *-openai-compact price entry. When COMPACT_USE_BASE_MODEL=true, skip the rewrite so compact traffic routes via the base model's channel and price config. Default behavior preserved. --- middleware/distributor.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/middleware/distributor.go b/middleware/distributor.go index 771719b98b01..546744598728 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "os" "slices" "strconv" "strings" @@ -391,7 +392,17 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { } if strings.HasPrefix(c.Request.URL.Path, "/v1/responses/compact") && modelRequest.Model != "" { - modelRequest.Model = ratio_setting.WithCompactModelSuffix(modelRequest.Model) + // Codex CLI's /v1/responses/compact endpoint sends `model: ` and + // new-api conventionally rewrites it to `-openai-compact` so + // admins can route compact traffic to a different channel and bill it + // separately. Set COMPACT_USE_BASE_MODEL=true to skip the rewrite — + // useful when the upstream relay does not differentiate compact from + // regular Responses calls (e.g. xtokenmirror, hostcentral), removing + // the need to declare `*-openai-compact` in every channel's model + // list and price table. + if os.Getenv("COMPACT_USE_BASE_MODEL") != "true" { + modelRequest.Model = ratio_setting.WithCompactModelSuffix(modelRequest.Model) + } } return &modelRequest, shouldSelectChannel, nil } From 24a6441754165576cfbaa81d70773df09a40dd6c Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Wed, 27 May 2026 14:29:50 +0800 Subject: [PATCH 34/45] fix(relay): cooldown depleted channels Temporarily cool down balance-exhausted upstream channels and strip incompatible max_output_tokens per channel so retries avoid known-bad routes without permanently disabling channels. Co-Authored-By: Claude Opus 4.7 --- controller/model_list_test.go | 1 + controller/relay.go | 4 + dto/channel_settings.go | 1 + middleware/distributor.go | 2 +- middleware/distributor_cooldown_test.go | 75 +++++++++++++ model/ability.go | 82 +++++++++----- model/channel_cache.go | 27 +++++ model/channel_cooldown.go | 55 +++++++++ model/channel_cooldown_test.go | 29 +++++ model/channel_selection_test.go | 104 ++++++++++++++++++ relay/common/relay_info.go | 32 +++++- ..._disabled_fields_max_output_tokens_test.go | 53 +++++++++ service/channel.go | 3 + service/channel_affinity.go | 8 ++ service/channel_cooldown.go | 42 +++++++ service/channel_cooldown_test.go | 49 +++++++++ service/channel_disable_cooldown_test.go | 24 ++++ 17 files changed, 562 insertions(+), 29 deletions(-) create mode 100644 middleware/distributor_cooldown_test.go create mode 100644 model/channel_cooldown.go create mode 100644 model/channel_cooldown_test.go create mode 100644 model/channel_selection_test.go create mode 100644 relay/common/remove_disabled_fields_max_output_tokens_test.go create mode 100644 service/channel_cooldown.go create mode 100644 service/channel_cooldown_test.go create mode 100644 service/channel_disable_cooldown_test.go diff --git a/controller/model_list_test.go b/controller/model_list_test.go index 97d27cae5c6c..3a0667840738 100644 --- a/controller/model_list_test.go +++ b/controller/model_list_test.go @@ -224,6 +224,7 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) { recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default") common.SetContextKey(ctx, constant.ContextKeyTokenModelLimitEnabled, true) common.SetContextKey(ctx, constant.ContextKeyTokenModelLimit, map[string]bool{ "zz-token-tiered-visible-model": true, diff --git a/controller/relay.go b/controller/relay.go index 5e2db44c25a4..3d0892edbcdf 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -357,6 +357,10 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error())) // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况 // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously + if service.ShouldCooldownChannel(err) { + service.CooldownChannel(channelError, err) + } + if service.ShouldDisableChannel(err) && channelError.AutoBan { gopool.Go(func() { service.DisableChannel(channelError, err.ErrorWithStatusCode()) diff --git a/dto/channel_settings.go b/dto/channel_settings.go index b6a1ab9f7138..c0008c31064b 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -32,6 +32,7 @@ type ChannelOtherSettings struct { AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规 AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式) AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) + DisableMaxOutputTokens bool `json:"disable_max_output_tokens,omitempty"` // 是否禁用 max_output_tokens 透传(用于不兼容该参数的上游) DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护) AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"` diff --git a/middleware/distributor.go b/middleware/distributor.go index 546744598728..16cf621500fa 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -104,7 +104,7 @@ func Distribute() func(c *gin.Context) { if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { preferred, err := model.CacheGetChannel(preferredChannelID) - if err == nil && preferred != nil { + if err == nil && preferred != nil && !model.IsChannelCoolingDown(preferred.Id) { if preferred.Status != common.ChannelStatusEnabled { if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorAffinityChannelDisabled)) diff --git a/middleware/distributor_cooldown_test.go b/middleware/distributor_cooldown_test.go new file mode 100644 index 000000000000..5f793128d952 --- /dev/null +++ b/middleware/distributor_cooldown_test.go @@ -0,0 +1,75 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestDistributeSkipsCoolingPreferredAffinityChannel(t *testing.T) { + gin.SetMode(gin.TestMode) + model.ClearChannelCacheForTest() + + oldMemoryCacheEnabled := common.MemoryCacheEnabled + common.MemoryCacheEnabled = true + t.Cleanup(func() { + common.MemoryCacheEnabled = oldMemoryCacheEnabled + model.ClearChannelCacheForTest() + }) + + priority := int64(10) + weight := uint(0) + preferred := &model.Channel{Id: 17, Type: 1, Key: "key-17", Status: common.ChannelStatusEnabled, Name: "preferred", Weight: &weight, Priority: &priority, Models: "gpt-5.5", Group: "default"} + fallback := &model.Channel{Id: 29, Type: 1, Key: "key-29", Status: common.ChannelStatusEnabled, Name: "fallback", Weight: &weight, Priority: &priority, Models: "gpt-5.5", Group: "default"} + model.SetChannelCacheForTest(map[int]*model.Channel{17: preferred, 29: fallback}, map[string]map[string][]int{"default": {"gpt-5.5": {17, 29}}}) + model.CooldownChannel(17, "Insufficient account balance", time.Minute) + t.Cleanup(func() { + model.CooldownChannel(17, "expired", -time.Second) + }) + + rule := operation_setting.ChannelAffinityRule{ + Name: "cooling-affinity-test", + ModelRegex: []string{"^gpt-5\\.5$"}, + PathRegex: []string{"/v1/responses"}, + KeySources: []operation_setting.ChannelAffinityKeySource{{Type: "request_header", Key: "X-Affinity-Key"}}, + IncludeRuleName: true, + } + affinityValue := "cooling-affinity-hit" + cacheKeySuffix := service.BuildChannelAffinityCacheKeySuffixForTest(rule, "gpt-5.5", "default", affinityValue) + cache := service.GetChannelAffinityCacheForTest() + require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 17, time.Minute)) + t.Cleanup(func() { + _, _ = cache.DeleteMany([]string{cacheKeySuffix}) + }) + + setting := operation_setting.GetChannelAffinitySetting() + originalRules := setting.Rules + setting.Rules = append([]operation_setting.ChannelAffinityRule{rule}, originalRules...) + t.Cleanup(func() { + setting.Rules = originalRules + }) + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5"}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Header.Set("X-Affinity-Key", affinityValue) + common.SetContextKey(ctx, constant.ContextKeyUsingGroup, "default") + common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default") + + Distribute()(ctx) + + channelId, ok := common.GetContextKey(ctx, constant.ContextKeyChannelId) + require.True(t, ok) + require.Equal(t, 29, channelId) +} diff --git a/model/ability.go b/model/ability.go index 1d7c53fa5805..91b731eca464 100644 --- a/model/ability.go +++ b/model/ability.go @@ -3,6 +3,7 @@ package model import ( "errors" "fmt" + "sort" "strings" "sync" @@ -106,40 +107,71 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) { func GetChannel(group string, model string, retry int) (*Channel, error) { var abilities []Ability - var err error = nil - channelQuery, err := getChannelQuery(group, model, retry) + err := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true). + Order("priority DESC"). + Order("weight DESC"). + Find(&abilities).Error if err != nil { return nil, err } - if common.UsingSQLite || common.UsingPostgreSQL { - err = channelQuery.Order("weight DESC").Find(&abilities).Error - } else { - err = channelQuery.Order("weight DESC").Find(&abilities).Error + + availableAbilities := make([]Ability, 0, len(abilities)) + uniquePriorities := make(map[int]bool) + for _, ability := range abilities { + if IsChannelCoolingDown(ability.ChannelId) { + continue + } + availableAbilities = append(availableAbilities, ability) + priority := int(0) + if ability.Priority != nil { + priority = int(*ability.Priority) + } + uniquePriorities[priority] = true } - if err != nil { - return nil, err + if len(availableAbilities) == 0 { + return nil, nil } - channel := Channel{} - if len(abilities) > 0 { - // Randomly choose one - weightSum := uint(0) - for _, ability_ := range abilities { - weightSum += ability_.Weight + 10 + + var sortedUniquePriorities []int + for priority := range uniquePriorities { + sortedUniquePriorities = append(sortedUniquePriorities, priority) + } + sort.Sort(sort.Reverse(sort.IntSlice(sortedUniquePriorities))) + + if retry >= len(sortedUniquePriorities) { + retry = len(sortedUniquePriorities) - 1 + } + targetPriority := sortedUniquePriorities[retry] + + weightSum := uint(0) + var targetAbilities []Ability + for _, ability := range availableAbilities { + priority := int(0) + if ability.Priority != nil { + priority = int(*ability.Priority) } - // Randomly choose one - weight := common.GetRandomInt(int(weightSum)) - for _, ability_ := range abilities { - weight -= int(ability_.Weight) + 10 - //log.Printf("weight: %d, ability weight: %d", weight, *ability_.Weight) - if weight <= 0 { - channel.Id = ability_.ChannelId - break - } + if priority != targetPriority { + continue } - } else { + targetAbilities = append(targetAbilities, ability) + weightSum += ability.Weight + 10 + } + if len(targetAbilities) == 0 { return nil, nil } - err = DB.First(&channel, "id = ?", channel.Id).Error + + channelId := targetAbilities[0].ChannelId + weight := common.GetRandomInt(int(weightSum)) + for _, ability := range targetAbilities { + weight -= int(ability.Weight) + 10 + if weight <= 0 { + channelId = ability.ChannelId + break + } + } + + channel := Channel{} + err = DB.First(&channel, "id = ?", channelId).Error return &channel, err } diff --git a/model/channel_cache.go b/model/channel_cache.go index 03740d2cd3ab..55c9bc3db353 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -118,6 +118,9 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, if len(channels) == 1 { if channel, ok := channelsIDM[channels[0]]; ok { + if IsChannelCoolingDown(channel.Id) { + return nil, nil + } return channel, nil } return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0]) @@ -126,11 +129,18 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, uniquePriorities := make(map[int]bool) for _, channelId := range channels { if channel, ok := channelsIDM[channelId]; ok { + if IsChannelCoolingDown(channel.Id) { + continue + } uniquePriorities[int(channel.GetPriority())] = true } else { return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId) } } + if len(uniquePriorities) == 0 { + return nil, nil + } + var sortedUniquePriorities []int for priority := range uniquePriorities { sortedUniquePriorities = append(sortedUniquePriorities, priority) @@ -147,6 +157,9 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, var targetChannels []*Channel for _, channelId := range channels { if channel, ok := channelsIDM[channelId]; ok { + if IsChannelCoolingDown(channel.Id) { + continue + } if channel.GetPriority() == targetPriority { sumWeight += channel.GetWeight() targetChannels = append(targetChannels, channel) @@ -191,6 +204,20 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, return nil, errors.New("channel not found") } +func SetChannelCacheForTest(channels map[int]*Channel, groupModelChannels map[string]map[string][]int) { + channelSyncLock.Lock() + defer channelSyncLock.Unlock() + channelsIDM = channels + group2model2channels = groupModelChannels +} + +func ClearChannelCacheForTest() { + channelSyncLock.Lock() + defer channelSyncLock.Unlock() + channelsIDM = nil + group2model2channels = nil +} + func CacheGetChannel(id int) (*Channel, error) { if !common.MemoryCacheEnabled { return GetChannelById(id, true) diff --git a/model/channel_cooldown.go b/model/channel_cooldown.go new file mode 100644 index 000000000000..3710d05d242d --- /dev/null +++ b/model/channel_cooldown.go @@ -0,0 +1,55 @@ +package model + +import ( + "sync" + "time" +) + +type channelCooldown struct { + reason string + expires time.Time +} + +var channelCooldowns = struct { + sync.RWMutex + items map[int]channelCooldown +}{items: make(map[int]channelCooldown)} + +func CooldownChannel(channelId int, reason string, duration time.Duration) { + channelCooldowns.Lock() + defer channelCooldowns.Unlock() + + channelCooldowns.items[channelId] = channelCooldown{ + reason: reason, + expires: time.Now().Add(duration), + } +} + +func IsChannelCoolingDown(channelId int) bool { + channelCooldowns.RLock() + cooldown, ok := channelCooldowns.items[channelId] + channelCooldowns.RUnlock() + if !ok { + return false + } + if time.Now().Before(cooldown.expires) { + return true + } + + channelCooldowns.Lock() + if current, ok := channelCooldowns.items[channelId]; ok && !time.Now().Before(current.expires) { + delete(channelCooldowns.items, channelId) + } + channelCooldowns.Unlock() + return false +} + +func clearChannelCooldownsForTest() { + channelCooldowns.Lock() + defer channelCooldowns.Unlock() + channelCooldowns.items = make(map[int]channelCooldown) +} + +func ClearChannelCooldownsForTest() { + clearChannelCooldownsForTest() +} diff --git a/model/channel_cooldown_test.go b/model/channel_cooldown_test.go new file mode 100644 index 000000000000..fc25363c0313 --- /dev/null +++ b/model/channel_cooldown_test.go @@ -0,0 +1,29 @@ +package model + +import ( + "testing" + "time" +) + +func TestChannelCooldownSkipsChannelUntilExpiry(t *testing.T) { + clearChannelCooldownsForTest() + + CooldownChannel(17, "Insufficient account balance", time.Minute) + + if !IsChannelCoolingDown(17) { + t.Fatalf("expected channel 17 to be cooling down") + } + if IsChannelCoolingDown(29) { + t.Fatalf("expected channel 29 to remain available") + } +} + +func TestChannelCooldownExpires(t *testing.T) { + clearChannelCooldownsForTest() + + CooldownChannel(17, "Insufficient account balance", -time.Second) + + if IsChannelCoolingDown(17) { + t.Fatalf("expected expired cooldown to be cleared") + } +} diff --git a/model/channel_selection_test.go b/model/channel_selection_test.go new file mode 100644 index 000000000000..ebf18c778721 --- /dev/null +++ b/model/channel_selection_test.go @@ -0,0 +1,104 @@ +package model + +import ( + "fmt" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func setupChannelSelectionTestDB(t *testing.T) { + t.Helper() + + oldDB := DB + oldMemoryCacheEnabled := common.MemoryCacheEnabled + oldUsingSQLite := common.UsingSQLite + oldUsingPostgreSQL := common.UsingPostgreSQL + oldUsingMySQL := common.UsingMySQL + + common.MemoryCacheEnabled = false + common.UsingSQLite = true + common.UsingPostgreSQL = false + common.UsingMySQL = false + initCol() + + dsn := fmt.Sprintf("file:channel-selection-%d?mode=memory&cache=shared", time.Now().UnixNano()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite test db: %v", err) + } + DB = db + if err := DB.AutoMigrate(&Channel{}, &Ability{}); err != nil { + t.Fatalf("migrate test db: %v", err) + } + clearChannelCooldownsForTest() + + t.Cleanup(func() { + clearChannelCooldownsForTest() + DB = oldDB + common.MemoryCacheEnabled = oldMemoryCacheEnabled + common.UsingSQLite = oldUsingSQLite + common.UsingPostgreSQL = oldUsingPostgreSQL + common.UsingMySQL = oldUsingMySQL + initCol() + }) +} + +func TestGetChannelSkipsCoolingChannelWithoutMemoryCache(t *testing.T) { + setupChannelSelectionTestDB(t) + + priority := int64(10) + weight := uint(0) + channels := []Channel{ + {Id: 17, Type: 1, Key: "key-17", Status: common.ChannelStatusEnabled, Name: "cooling", Weight: &weight, Priority: &priority, Models: "gpt-5.5", Group: "default"}, + {Id: 29, Type: 1, Key: "key-29", Status: common.ChannelStatusEnabled, Name: "available", Weight: &weight, Priority: &priority, Models: "gpt-5.5", Group: "default"}, + } + if err := DB.Create(&channels).Error; err != nil { + t.Fatalf("seed channels: %v", err) + } + abilities := []Ability{ + {Group: "default", Model: "gpt-5.5", ChannelId: 17, Enabled: true, Priority: &priority, Weight: weight}, + {Group: "default", Model: "gpt-5.5", ChannelId: 29, Enabled: true, Priority: &priority, Weight: weight}, + } + if err := DB.Create(&abilities).Error; err != nil { + t.Fatalf("seed abilities: %v", err) + } + + CooldownChannel(17, "Insufficient account balance", time.Minute) + + channel, err := GetChannel("default", "gpt-5.5", 0) + if err != nil { + t.Fatalf("GetChannel returned error: %v", err) + } + if channel == nil || channel.Id != 29 { + t.Fatalf("expected channel 29, got %#v", channel) + } +} + +func TestGetChannelReturnsNilWhenAllCandidatesCoolingWithoutMemoryCache(t *testing.T) { + setupChannelSelectionTestDB(t) + + priority := int64(10) + weight := uint(0) + channel := Channel{Id: 17, Type: 1, Key: "key-17", Status: common.ChannelStatusEnabled, Name: "cooling", Weight: &weight, Priority: &priority, Models: "gpt-5.5", Group: "default"} + if err := DB.Create(&channel).Error; err != nil { + t.Fatalf("seed channel: %v", err) + } + ability := Ability{Group: "default", Model: "gpt-5.5", ChannelId: 17, Enabled: true, Priority: &priority, Weight: weight} + if err := DB.Create(&ability).Error; err != nil { + t.Fatalf("seed ability: %v", err) + } + + CooldownChannel(17, "Insufficient account balance", time.Minute) + + selected, err := GetChannel("default", "gpt-5.5", 0) + if err != nil { + t.Fatalf("GetChannel returned error: %v", err) + } + if selected != nil { + t.Fatalf("expected no channel, got %#v", selected) + } +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 2f7afd398599..a6caf3f64b51 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -788,10 +788,13 @@ func FailTaskInfo(reason string) *TaskInfo { // speed: Claude 推理速度模式字段(仅 Claude 支持,默认过滤) // store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用) // safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私) -// stream_options.include_obfuscation: 响应流混淆控制字段(仅 OpenAI Responses API 支持) +// max_output_tokens: Responses 输出上限字段,部分 OpenAI-compatible 上游不支持,可按渠道禁用 func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings, channelPassThroughEnabled bool) ([]byte, error) { if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelPassThroughEnabled { - return jsonData, nil + if !channelOtherSettings.DisableMaxOutputTokens || !gjson.GetBytes(jsonData, "max_output_tokens").Exists() { + return jsonData, nil + } + return removeDisabledField(jsonData, "max_output_tokens") } if !hasRemovableDisabledField(jsonData, channelOtherSettings) { return jsonData, nil @@ -854,6 +857,27 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther } } + if channelOtherSettings.DisableMaxOutputTokens { + if _, exists := data["max_output_tokens"]; exists { + delete(data, "max_output_tokens") + } + } + + jsonDataAfter, err := common.Marshal(data) + if err != nil { + common.SysError("RemoveDisabledFields Marshal error :" + err.Error()) + return jsonData, nil + } + return jsonDataAfter, nil +} + +func removeDisabledField(jsonData []byte, field string) ([]byte, error) { + var data map[string]interface{} + if err := common.Unmarshal(jsonData, &data); err != nil { + common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error()) + return jsonData, nil + } + delete(data, field) jsonDataAfter, err := common.Marshal(data) if err != nil { common.SysError("RemoveDisabledFields Marshal error :" + err.Error()) @@ -871,6 +895,7 @@ func hasRemovableDisabledField(jsonData []byte, channelOtherSettings dto.Channel "store", "safety_identifier", "stream_options.include_obfuscation", + "max_output_tokens", ) return (!channelOtherSettings.AllowServiceTier && values[0].Exists()) || @@ -878,7 +903,8 @@ func hasRemovableDisabledField(jsonData []byte, channelOtherSettings dto.Channel (!channelOtherSettings.AllowSpeed && values[2].Exists()) || (channelOtherSettings.DisableStore && values[3].Exists()) || (!channelOtherSettings.AllowSafetyIdentifier && values[4].Exists()) || - (!channelOtherSettings.AllowIncludeObfuscation && values[5].Exists()) + (!channelOtherSettings.AllowIncludeObfuscation && values[5].Exists()) || + (channelOtherSettings.DisableMaxOutputTokens && values[6].Exists()) } // RemoveGeminiDisabledFields removes disabled fields from Gemini request JSON data diff --git a/relay/common/remove_disabled_fields_max_output_tokens_test.go b/relay/common/remove_disabled_fields_max_output_tokens_test.go new file mode 100644 index 000000000000..ed63253e3066 --- /dev/null +++ b/relay/common/remove_disabled_fields_max_output_tokens_test.go @@ -0,0 +1,53 @@ +package common + +import ( + "testing" + + "github.com/QuantumNous/new-api/dto" + "github.com/tidwall/gjson" +) + +func TestRemoveDisabledFieldsRemovesMaxOutputTokensWhenDisabled(t *testing.T) { + input := []byte(`{"model":"gpt-5.5","max_output_tokens":1024,"input":"hello"}`) + + out, err := RemoveDisabledFields(input, dto.ChannelOtherSettings{DisableMaxOutputTokens: true}, false) + if err != nil { + t.Fatalf("RemoveDisabledFields returned error: %v", err) + } + + if gjson.GetBytes(out, "max_output_tokens").Exists() { + t.Fatalf("expected max_output_tokens to be removed, got %s", out) + } + if !gjson.GetBytes(out, "input").Exists() { + t.Fatalf("expected unrelated fields to remain, got %s", out) + } +} + +func TestRemoveDisabledFieldsRemovesMaxOutputTokensWhenPassThroughEnabled(t *testing.T) { + input := []byte(`{"model":"gpt-5.5","max_output_tokens":1024,"service_tier":"flex","input":"hello"}`) + + out, err := RemoveDisabledFields(input, dto.ChannelOtherSettings{DisableMaxOutputTokens: true}, true) + if err != nil { + t.Fatalf("RemoveDisabledFields returned error: %v", err) + } + + if gjson.GetBytes(out, "max_output_tokens").Exists() { + t.Fatalf("expected max_output_tokens to be removed, got %s", out) + } + if !gjson.GetBytes(out, "service_tier").Exists() { + t.Fatalf("expected pass-through fields to remain, got %s", out) + } +} + +func TestRemoveDisabledFieldsKeepsMaxOutputTokensByDefault(t *testing.T) { + input := []byte(`{"model":"gpt-5.5","max_output_tokens":1024,"input":"hello"}`) + + out, err := RemoveDisabledFields(input, dto.ChannelOtherSettings{}, false) + if err != nil { + t.Fatalf("RemoveDisabledFields returned error: %v", err) + } + + if !gjson.GetBytes(out, "max_output_tokens").Exists() { + t.Fatalf("expected max_output_tokens to remain, got %s", out) + } +} diff --git a/service/channel.go b/service/channel.go index 3fde6e207b68..b21abb285e27 100644 --- a/service/channel.go +++ b/service/channel.go @@ -55,6 +55,9 @@ func ShouldDisableChannel(err *types.NewAPIError) bool { if types.IsSkipRetryError(err) { return false } + if ShouldCooldownChannel(err) { + return false + } if operation_setting.ShouldDisableByStatusCode(err.StatusCode) { return true } diff --git a/service/channel_affinity.go b/service/channel_affinity.go index f16c350bb14e..1b289d52be66 100644 --- a/service/channel_affinity.go +++ b/service/channel_affinity.go @@ -547,6 +547,14 @@ func ApplyChannelAffinityOverrideTemplate(c *gin.Context, paramOverride map[stri return mergedParam, true } +func GetChannelAffinityCacheForTest() *cachex.HybridCache[int] { + return getChannelAffinityCache() +} + +func BuildChannelAffinityCacheKeySuffixForTest(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string { + return buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue) +} + func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (int, bool) { setting := operation_setting.GetChannelAffinitySetting() if setting == nil || !setting.Enabled { diff --git a/service/channel_cooldown.go b/service/channel_cooldown.go new file mode 100644 index 000000000000..1f121311accb --- /dev/null +++ b/service/channel_cooldown.go @@ -0,0 +1,42 @@ +package service + +import ( + "fmt" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/types" +) + +const ChannelCooldownDuration = 30 * time.Minute + +var channelCooldownKeywords = []string{ + "insufficient account balance", + "insufficient balance", + "insufficient_quota", + "your credit balance is too low", + "余额不足", +} + +func ShouldCooldownChannel(err *types.NewAPIError) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error() + " " + string(err.GetErrorCode())) + for _, keyword := range channelCooldownKeywords { + if strings.Contains(message, keyword) { + return true + } + } + return false +} + +func CooldownChannel(channelError types.ChannelError, err *types.NewAPIError) { + if !ShouldCooldownChannel(err) { + return + } + common.SysLog(fmt.Sprintf("通道冷却:#%d,持续 %s,原因:%s", channelError.ChannelId, ChannelCooldownDuration, err.Error())) + model.CooldownChannel(channelError.ChannelId, err.Error(), ChannelCooldownDuration) +} diff --git a/service/channel_cooldown_test.go b/service/channel_cooldown_test.go new file mode 100644 index 000000000000..7089d1901fba --- /dev/null +++ b/service/channel_cooldown_test.go @@ -0,0 +1,49 @@ +package service + +import ( + "errors" + "net/http" + "testing" + + "github.com/QuantumNous/new-api/types" +) + +func TestShouldCooldownChannelForBalanceError(t *testing.T) { + err := types.NewErrorWithStatusCode(errors.New("Insufficient account balance"), types.ErrorCodeBadResponseStatusCode, http.StatusForbidden) + + if !ShouldCooldownChannel(err) { + t.Fatalf("expected balance error to trigger channel cooldown") + } +} + +func TestShouldCooldownChannelForChineseBalanceError(t *testing.T) { + err := types.NewErrorWithStatusCode(errors.New("账户余额不足"), types.ErrorCodeBadResponseStatusCode, http.StatusForbidden) + + if !ShouldCooldownChannel(err) { + t.Fatalf("expected Chinese balance error to trigger channel cooldown") + } +} + +func TestShouldCooldownChannelForLowCreditBalanceError(t *testing.T) { + err := types.NewErrorWithStatusCode(errors.New("Your credit balance is too low"), types.ErrorCodeBadResponseStatusCode, http.StatusForbidden) + + if !ShouldCooldownChannel(err) { + t.Fatalf("expected low credit balance error to trigger channel cooldown") + } +} + +func TestShouldCooldownChannelForInsufficientQuotaCode(t *testing.T) { + err := types.NewOpenAIError(errors.New("You exceeded your current quota"), types.ErrorCode("insufficient_quota"), http.StatusTooManyRequests) + + if !ShouldCooldownChannel(err) { + t.Fatalf("expected insufficient_quota error code to trigger channel cooldown") + } +} + +func TestShouldCooldownChannelIgnoresUnrelatedError(t *testing.T) { + err := types.NewErrorWithStatusCode(errors.New("unsupported parameter: max_output_tokens"), types.ErrorCodeBadResponseStatusCode, http.StatusBadRequest) + + if ShouldCooldownChannel(err) { + t.Fatalf("expected unrelated bad request to skip channel cooldown") + } +} diff --git a/service/channel_disable_cooldown_test.go b/service/channel_disable_cooldown_test.go new file mode 100644 index 000000000000..fc41abfc21b6 --- /dev/null +++ b/service/channel_disable_cooldown_test.go @@ -0,0 +1,24 @@ +package service + +import ( + "errors" + "net/http" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/types" +) + +func TestShouldDisableChannelIgnoresCooldownBalanceError(t *testing.T) { + oldAutomaticDisableChannelEnabled := common.AutomaticDisableChannelEnabled + common.AutomaticDisableChannelEnabled = true + t.Cleanup(func() { + common.AutomaticDisableChannelEnabled = oldAutomaticDisableChannelEnabled + }) + + err := types.NewErrorWithStatusCode(errors.New("Insufficient account balance"), types.ErrorCodeBadResponseStatusCode, http.StatusForbidden) + + if ShouldDisableChannel(err) { + t.Fatalf("expected balance error to cooldown without permanent auto-disable") + } +} From 29d5a782d2bc921379a5cf08dfe56779bf68fe4b Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Thu, 28 May 2026 10:57:38 +0800 Subject: [PATCH 35/45] fix(relay): respect compact base model in billing Keep compact responses billing on the base upstream model when COMPACT_USE_BASE_MODEL is enabled, and add stream termination diagnostics for client_gone investigations. Co-Authored-By: Claude Opus 4.7 --- controller/channel-test.go | 2 +- middleware/distributor.go | 3 +- relay/common/stream_status.go | 93 +++++++++++++- relay/common/stream_status_test.go | 30 ++++- relay/helper/model_mapped.go | 10 +- relay/helper/model_mapped_test.go | 159 ++++++++++++++++++++++++ relay/helper/stream_result.go | 4 +- relay/helper/stream_scanner.go | 38 ++++-- relay/helper/stream_scanner_test.go | 126 +++++++++++++++++++ service/log_info_generate.go | 37 ++++-- setting/ratio_setting/compact_suffix.go | 9 +- 11 files changed, 475 insertions(+), 36 deletions(-) create mode 100644 relay/helper/model_mapped_test.go diff --git a/controller/channel-test.go b/controller/channel-test.go index b225585ed7a3..b9b08c689633 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -132,7 +132,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string, requestPath = "/v1/responses/compact" } } - if strings.HasPrefix(requestPath, "/v1/responses/compact") { + if strings.HasPrefix(requestPath, "/v1/responses/compact") && !ratio_setting.CompactUseBaseModel() { testModel = ratio_setting.WithCompactModelSuffix(testModel) } diff --git a/middleware/distributor.go b/middleware/distributor.go index 16cf621500fa..75bbdab4dc60 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "net/http" - "os" "slices" "strconv" "strings" @@ -400,7 +399,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { // regular Responses calls (e.g. xtokenmirror, hostcentral), removing // the need to declare `*-openai-compact` in every channel's model // list and price table. - if os.Getenv("COMPACT_USE_BASE_MODEL") != "true" { + if !ratio_setting.CompactUseBaseModel() { modelRequest.Model = ratio_setting.WithCompactModelSuffix(modelRequest.Model) } } diff --git a/relay/common/stream_status.go b/relay/common/stream_status.go index 57b0bb973b2e..b186e7fb9633 100644 --- a/relay/common/stream_status.go +++ b/relay/common/stream_status.go @@ -28,10 +28,29 @@ type StreamErrorEntry struct { Timestamp time.Time } +type StreamSnapshot struct { + EndReason StreamEndReason + EndError error + EndSource string + StartedAt time.Time + EndedAt time.Time + FirstDataAt time.Time + LastDataAt time.Time + UpstreamStatusCode int + Errors []StreamErrorEntry + ErrorCount int +} + type StreamStatus struct { - EndReason StreamEndReason - EndError error - endOnce sync.Once + EndReason StreamEndReason + EndError error + EndSource string + StartedAt time.Time + EndedAt time.Time + FirstDataAt time.Time + LastDataAt time.Time + UpstreamStatusCode int + endOnce sync.Once mu sync.Mutex Errors []StreamErrorEntry @@ -39,19 +58,40 @@ type StreamStatus struct { } func NewStreamStatus() *StreamStatus { - return &StreamStatus{} + return &StreamStatus{StartedAt: time.Now()} } func (s *StreamStatus) SetEndReason(reason StreamEndReason, err error) { + s.SetEndReasonWithSource(reason, err, "") +} + +func (s *StreamStatus) SetEndReasonWithSource(reason StreamEndReason, err error, source string) { if s == nil { return } s.endOnce.Do(func() { + s.mu.Lock() + defer s.mu.Unlock() s.EndReason = reason s.EndError = err + s.EndSource = source + s.EndedAt = time.Now() }) } +func (s *StreamStatus) RecordDataReceived() { + if s == nil { + return + } + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + if s.FirstDataAt.IsZero() { + s.FirstDataAt = now + } + s.LastDataAt = now +} + func (s *StreamStatus) RecordError(msg string) { if s == nil { return @@ -85,10 +125,34 @@ func (s *StreamStatus) TotalErrorCount() int { return s.ErrorCount } +func (s *StreamStatus) Snapshot() StreamSnapshot { + if s == nil { + return StreamSnapshot{} + } + s.mu.Lock() + defer s.mu.Unlock() + errors := make([]StreamErrorEntry, len(s.Errors)) + copy(errors, s.Errors) + return StreamSnapshot{ + EndReason: s.EndReason, + EndError: s.EndError, + EndSource: s.EndSource, + StartedAt: s.StartedAt, + EndedAt: s.EndedAt, + FirstDataAt: s.FirstDataAt, + LastDataAt: s.LastDataAt, + UpstreamStatusCode: s.UpstreamStatusCode, + Errors: errors, + ErrorCount: s.ErrorCount, + } +} + func (s *StreamStatus) IsNormalEnd() bool { if s == nil { return true } + s.mu.Lock() + defer s.mu.Unlock() return s.EndReason == StreamEndReasonDone || s.EndReason == StreamEndReasonEOF || s.EndReason == StreamEndReasonHandlerStop @@ -99,11 +163,30 @@ func (s *StreamStatus) Summary() string { return "StreamStatus" } b := &strings.Builder{} + s.mu.Lock() fmt.Fprintf(b, "reason=%s", s.EndReason) + if s.EndSource != "" { + fmt.Fprintf(b, " source=%s", s.EndSource) + } if s.EndError != nil { fmt.Fprintf(b, " end_error=%q", s.EndError.Error()) } - s.mu.Lock() + if !s.StartedAt.IsZero() { + endAt := s.EndedAt + if endAt.IsZero() { + endAt = time.Now() + } + fmt.Fprintf(b, " elapsed_ms=%d", endAt.Sub(s.StartedAt).Milliseconds()) + } + if s.UpstreamStatusCode != 0 { + fmt.Fprintf(b, " upstream_status=%d", s.UpstreamStatusCode) + } + if !s.FirstDataAt.IsZero() && !s.StartedAt.IsZero() { + fmt.Fprintf(b, " first_data_ms=%d", s.FirstDataAt.Sub(s.StartedAt).Milliseconds()) + } + if !s.LastDataAt.IsZero() && !s.StartedAt.IsZero() { + fmt.Fprintf(b, " last_data_ms=%d", s.LastDataAt.Sub(s.StartedAt).Milliseconds()) + } if s.ErrorCount > 0 { fmt.Fprintf(b, " soft_errors=%d", s.ErrorCount) } diff --git a/relay/common/stream_status_test.go b/relay/common/stream_status_test.go index 4a31cb79fbcf..05e6114cd823 100644 --- a/relay/common/stream_status_test.go +++ b/relay/common/stream_status_test.go @@ -157,6 +157,30 @@ func TestStreamStatus_IsNormalEnd_NilSafe(t *testing.T) { assert.True(t, s.IsNormalEnd()) } +func TestStreamStatus_SetEndReasonWithSource(t *testing.T) { + t.Parallel() + s := NewStreamStatus() + + s.SetEndReasonWithSource(StreamEndReasonClientGone, fmt.Errorf("context canceled"), "main_context_done") + + assert.Equal(t, StreamEndReasonClientGone, s.EndReason) + assert.Equal(t, "main_context_done", s.EndSource) + assert.False(t, s.EndedAt.IsZero()) +} + +func TestStreamStatus_RecordDataReceived(t *testing.T) { + t.Parallel() + s := NewStreamStatus() + + s.RecordDataReceived() + firstDataAt := s.FirstDataAt + s.RecordDataReceived() + + assert.False(t, firstDataAt.IsZero()) + assert.Equal(t, firstDataAt, s.FirstDataAt) + assert.False(t, s.LastDataAt.IsZero()) +} + func TestStreamStatus_Summary(t *testing.T) { t.Parallel() @@ -167,11 +191,15 @@ func TestStreamStatus_Summary(t *testing.T) { assert.NotContains(t, summary, "soft_errors") s2 := NewStreamStatus() - s2.SetEndReason(StreamEndReasonTimeout, nil) + s2.SetEndReasonWithSource(StreamEndReasonTimeout, nil, "timeout") + s2.RecordDataReceived() s2.RecordError("bad json") s2.RecordError("write failed") summary2 := s2.Summary() assert.Contains(t, summary2, "reason=timeout") + assert.Contains(t, summary2, "source=timeout") + assert.Contains(t, summary2, "elapsed_ms=") + assert.Contains(t, summary2, "first_data_ms=") assert.Contains(t, summary2, "soft_errors=2") } diff --git a/relay/helper/model_mapped.go b/relay/helper/model_mapped.go index 5d6efa094865..c1c42296de63 100644 --- a/relay/helper/model_mapped.go +++ b/relay/helper/model_mapped.go @@ -1,11 +1,11 @@ package helper import ( - "encoding/json" "errors" "fmt" "strings" + rootcommon "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" @@ -29,7 +29,7 @@ func ModelMappedHelper(c *gin.Context, info *common.RelayInfo, request dto.Reque modelMapping := c.GetString("model_mapping") if modelMapping != "" && modelMapping != "{}" { modelMap := make(map[string]string) - err := json.Unmarshal([]byte(modelMapping), &modelMap) + err := rootcommon.UnmarshalJsonStr(modelMapping, &modelMap) if err != nil { return fmt.Errorf("unmarshal_model_mapping_failed") } @@ -72,7 +72,11 @@ func ModelMappedHelper(c *gin.Context, info *common.RelayInfo, request dto.Reque finalUpstreamModelName = info.UpstreamModelName } info.UpstreamModelName = finalUpstreamModelName - info.OriginModelName = ratio_setting.WithCompactModelSuffix(finalUpstreamModelName) + if ratio_setting.CompactUseBaseModel() { + info.OriginModelName = finalUpstreamModelName + } else { + info.OriginModelName = ratio_setting.WithCompactModelSuffix(finalUpstreamModelName) + } } if request != nil { request.SetModelName(info.UpstreamModelName) diff --git a/relay/helper/model_mapped_test.go b/relay/helper/model_mapped_test.go new file mode 100644 index 000000000000..00a2194e7805 --- /dev/null +++ b/relay/helper/model_mapped_test.go @@ -0,0 +1,159 @@ +package helper + +import ( + "os" + "testing" + + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/gin-gonic/gin" +) + +func TestModelMappedHelperResponsesCompactUsesBaseModelWhenConfigured(t *testing.T) { + setCompactUseBaseModelForTest(t, "true") + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeResponsesCompact, + OriginModelName: "gpt-5.5", + } + request := &dto.OpenAIResponsesRequest{Model: "gpt-5.5"} + + if err := ModelMappedHelper(ctx, info, request); err != nil { + t.Fatalf("ModelMappedHelper returned error: %v", err) + } + + if info.OriginModelName != "gpt-5.5" { + t.Fatalf("expected origin model to remain base model, got %q", info.OriginModelName) + } + if info.UpstreamModelName != "gpt-5.5" { + t.Fatalf("expected upstream model to be base model, got %q", info.UpstreamModelName) + } + if request.Model != "gpt-5.5" { + t.Fatalf("expected request model to be base model, got %q", request.Model) + } +} + +func TestModelMappedHelperResponsesCompactNormalizesSuffixedModelWhenConfigured(t *testing.T) { + setCompactUseBaseModelForTest(t, "true") + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeResponsesCompact, + OriginModelName: "gpt-5.5-openai-compact", + } + request := &dto.OpenAIResponsesRequest{Model: "gpt-5.5-openai-compact"} + + if err := ModelMappedHelper(ctx, info, request); err != nil { + t.Fatalf("ModelMappedHelper returned error: %v", err) + } + + if info.OriginModelName != "gpt-5.5" { + t.Fatalf("expected origin model to normalize to base model, got %q", info.OriginModelName) + } + if info.UpstreamModelName != "gpt-5.5" { + t.Fatalf("expected upstream model to normalize to base model, got %q", info.UpstreamModelName) + } + if request.Model != "gpt-5.5" { + t.Fatalf("expected request model to normalize to base model, got %q", request.Model) + } +} + +func TestModelMappedHelperResponsesCompactKeepsSuffixedModelByDefault(t *testing.T) { + setCompactUseBaseModelForTest(t, "") + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeResponsesCompact, + OriginModelName: "gpt-5.5", + } + request := &dto.OpenAIResponsesRequest{Model: "gpt-5.5"} + + if err := ModelMappedHelper(ctx, info, request); err != nil { + t.Fatalf("ModelMappedHelper returned error: %v", err) + } + + if info.OriginModelName != "gpt-5.5-openai-compact" { + t.Fatalf("expected origin model to use compact suffix, got %q", info.OriginModelName) + } + if info.UpstreamModelName != "gpt-5.5" { + t.Fatalf("expected upstream model to remain base model, got %q", info.UpstreamModelName) + } + if request.Model != "gpt-5.5" { + t.Fatalf("expected request model to remain base model, got %q", request.Model) + } +} + +func TestModelMappedHelperResponsesCompactMappedModelUsesBaseWhenConfigured(t *testing.T) { + setCompactUseBaseModelForTest(t, "true") + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("model_mapping", `{"gpt-5.5":"gpt-5.5-upstream"}`) + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeResponsesCompact, + OriginModelName: "gpt-5.5", + } + request := &dto.OpenAIResponsesRequest{Model: "gpt-5.5"} + + if err := ModelMappedHelper(ctx, info, request); err != nil { + t.Fatalf("ModelMappedHelper returned error: %v", err) + } + + if info.OriginModelName != "gpt-5.5-upstream" { + t.Fatalf("expected origin model to use mapped upstream model, got %q", info.OriginModelName) + } + if info.UpstreamModelName != "gpt-5.5-upstream" { + t.Fatalf("expected upstream model to use mapped upstream model, got %q", info.UpstreamModelName) + } + if request.Model != "gpt-5.5-upstream" { + t.Fatalf("expected request model to use mapped upstream model, got %q", request.Model) + } +} + +func TestModelMappedHelperResponsesCompactMappedModelKeepsSuffixByDefault(t *testing.T) { + setCompactUseBaseModelForTest(t, "") + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("model_mapping", `{"gpt-5.5":"gpt-5.5-upstream"}`) + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeResponsesCompact, + OriginModelName: "gpt-5.5", + } + request := &dto.OpenAIResponsesRequest{Model: "gpt-5.5"} + + if err := ModelMappedHelper(ctx, info, request); err != nil { + t.Fatalf("ModelMappedHelper returned error: %v", err) + } + + if info.OriginModelName != "gpt-5.5-upstream-openai-compact" { + t.Fatalf("expected origin model to use mapped compact suffix, got %q", info.OriginModelName) + } + if info.UpstreamModelName != "gpt-5.5-upstream" { + t.Fatalf("expected upstream model to use mapped upstream model, got %q", info.UpstreamModelName) + } + if request.Model != "gpt-5.5-upstream" { + t.Fatalf("expected request model to use mapped upstream model, got %q", request.Model) + } +} + +func setCompactUseBaseModelForTest(t *testing.T, value string) { + t.Helper() + oldValue, hadValue := os.LookupEnv("COMPACT_USE_BASE_MODEL") + if value == "" { + if err := os.Unsetenv("COMPACT_USE_BASE_MODEL"); err != nil { + t.Fatalf("unset COMPACT_USE_BASE_MODEL: %v", err) + } + } else if err := os.Setenv("COMPACT_USE_BASE_MODEL", value); err != nil { + t.Fatalf("set COMPACT_USE_BASE_MODEL: %v", err) + } + t.Cleanup(func() { + if hadValue { + if err := os.Setenv("COMPACT_USE_BASE_MODEL", oldValue); err != nil { + t.Fatalf("restore COMPACT_USE_BASE_MODEL: %v", err) + } + } else if err := os.Unsetenv("COMPACT_USE_BASE_MODEL"); err != nil { + t.Fatalf("restore COMPACT_USE_BASE_MODEL: %v", err) + } + }) +} diff --git a/relay/helper/stream_result.go b/relay/helper/stream_result.go index aa77e8039a68..a722a3ed80b4 100644 --- a/relay/helper/stream_result.go +++ b/relay/helper/stream_result.go @@ -30,14 +30,14 @@ func (r *StreamResult) Stop(err error) { if err != nil { r.status.RecordError(err.Error()) } - r.status.SetEndReason(relaycommon.StreamEndReasonHandlerStop, err) + r.status.SetEndReasonWithSource(relaycommon.StreamEndReasonHandlerStop, err, "handler_stop") r.stopped = true } // Done signals that the handler has finished processing normally // (e.g., Dify "message_end"). The stream stops after this chunk. func (r *StreamResult) Done() { - r.status.SetEndReason(relaycommon.StreamEndReasonDone, nil) + r.status.SetEndReasonWithSource(relaycommon.StreamEndReasonDone, nil, "handler_done") r.stopped = true } diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index 1d44b80443cd..033f3b022d2c 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -40,8 +40,16 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon return } - // 无条件新建 StreamStatus - info.StreamStatus = relaycommon.NewStreamStatus() + if info.StreamStatus != nil { + previousErrors := info.StreamStatus.Errors + previousErrorCount := info.StreamStatus.ErrorCount + info.StreamStatus = relaycommon.NewStreamStatus() + info.StreamStatus.Errors = previousErrors + info.StreamStatus.ErrorCount = previousErrorCount + } else { + info.StreamStatus = relaycommon.NewStreamStatus() + } + info.StreamStatus.UpstreamStatusCode = resp.StatusCode // 确保响应体总是被关闭 defer func() { @@ -51,6 +59,9 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon }() streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second + if streamingTimeout <= 0 { + streamingTimeout = 30 * time.Second + } var ( stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞 @@ -121,7 +132,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon wg.Done() if r := recover(); r != nil { logger.LogError(c, fmt.Sprintf("ping goroutine panic: %v", r)) - info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("ping panic: %v", r)) + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonPanic, fmt.Errorf("ping panic: %v", r), "ping_panic") common.SafeSendBool(stopChan, true) } logger.LogDebug(c, "ping goroutine exited") @@ -147,13 +158,13 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon case err := <-done: if err != nil { logger.LogError(c, "ping data error: "+err.Error()) - info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, err) + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonPingFail, err, "ping_write") return } logger.LogDebug(c, "ping data sent") case <-time.After(10 * time.Second): logger.LogError(c, "ping data send timeout") - info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, fmt.Errorf("ping send timeout")) + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonPingFail, fmt.Errorf("ping send timeout"), "ping_timeout") return case <-ctx.Done(): return @@ -183,7 +194,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon wg.Done() if r := recover(); r != nil { logger.LogError(c, fmt.Sprintf("data handler goroutine panic: %v", r)) - info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("handler panic: %v", r)) + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonPanic, fmt.Errorf("handler panic: %v", r), "handler_panic") } common.SafeSendBool(stopChan, true) }() @@ -207,7 +218,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon wg.Done() if r := recover(); r != nil { logger.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r)) - info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("scanner panic: %v", r)) + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonPanic, fmt.Errorf("scanner panic: %v", r), "scanner_panic") } common.SafeSendBool(stopChan, true) logger.LogDebug(c, "scanner goroutine exited") @@ -221,7 +232,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon case <-ctx.Done(): return case <-c.Request.Context().Done(): - info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, c.Request.Context().Err()) + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonClientGone, c.Request.Context().Err(), "scanner_context_done") return default: } @@ -243,6 +254,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon } if !strings.HasPrefix(data, "[DONE]") { info.SetFirstResponseTime() + info.StreamStatus.RecordDataReceived() info.ReceivedResponseCount++ select { @@ -253,7 +265,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon return } } else { - info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil) + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonDone, nil, "scanner_done") logger.LogDebug(c, "received [DONE], stopping scanner") return } @@ -262,20 +274,20 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon if err := scanner.Err(); err != nil { if err != io.EOF { logger.LogError(c, "scanner error: "+err.Error()) - info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonScannerErr, err) + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonScannerErr, err, "scanner_error") } } - info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonEOF, nil) + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonEOF, nil, "scanner_eof") }) // 主循环等待完成或超时 select { case <-ticker.C: - info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonTimeout, nil) + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonTimeout, nil, "timeout") case <-stopChan: // EndReason already set by the goroutine that triggered stopChan case <-c.Request.Context().Done(): - info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, c.Request.Context().Err()) + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonClientGone, c.Request.Context().Err(), "main_context_done") } if info.StreamStatus.IsNormalEnd() && !info.StreamStatus.HasErrors() { diff --git a/relay/helper/stream_scanner_test.go b/relay/helper/stream_scanner_test.go index 9d6f3bb49123..b27a0ead8dee 100644 --- a/relay/helper/stream_scanner_test.go +++ b/relay/helper/stream_scanner_test.go @@ -1,6 +1,7 @@ package helper import ( + "context" "fmt" "io" "net/http" @@ -545,6 +546,131 @@ func TestStreamScannerHandler_StreamStatus_Timeout(t *testing.T) { assert.False(t, info.StreamStatus.IsNormalEnd()) } +func TestStreamScannerHandler_StreamStatus_ClientGoneBeforeUpstreamData(t *testing.T) { + oldTimeout := constant.StreamingTimeout + constant.StreamingTimeout = 30 + t.Cleanup(func() { constant.StreamingTimeout = oldTimeout }) + + pr, pw := io.Pipe() + defer pw.Close() + defer pr.Close() + ctx, cancel := context.WithCancel(context.Background()) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx) + resp := &http.Response{Body: pr, StatusCode: http.StatusOK} + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} + + done := make(chan struct{}) + go func() { + StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {}) + close(done) + }() + time.Sleep(10 * time.Millisecond) + cancel() + + select { + case <-done: + case <-time.After(7 * time.Second): + t.Fatal("timed out waiting for client_gone") + } + + require.NotNil(t, info.StreamStatus) + assert.Equal(t, relaycommon.StreamEndReasonClientGone, info.StreamStatus.EndReason) + assert.Equal(t, 0, info.ReceivedResponseCount) + assert.NotEmpty(t, info.StreamStatus.EndSource) +} + +func TestStreamScannerHandler_StreamStatus_EmptyBodyEOFWithoutData(t *testing.T) { + c, resp, info := setupStreamTest(t, strings.NewReader("")) + + StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {}) + + require.NotNil(t, info.StreamStatus) + assert.Equal(t, relaycommon.StreamEndReasonEOF, info.StreamStatus.EndReason) + assert.Equal(t, "scanner_eof", info.StreamStatus.EndSource) + assert.Equal(t, 0, info.ReceivedResponseCount) +} + +func TestStreamScannerHandler_StreamStatus_TimeoutWithoutClientCancel(t *testing.T) { + oldTimeout := constant.StreamingTimeout + constant.StreamingTimeout = 1 + t.Cleanup(func() { constant.StreamingTimeout = oldTimeout }) + + pr, pw := io.Pipe() + defer pw.Close() + defer pr.Close() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + resp := &http.Response{Body: pr, StatusCode: http.StatusOK} + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} + + done := make(chan struct{}) + go func() { + StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {}) + close(done) + }() + + select { + case <-done: + case <-time.After(7 * time.Second): + t.Fatal("timed out waiting for stream timeout") + } + + require.NotNil(t, info.StreamStatus) + assert.Equal(t, relaycommon.StreamEndReasonTimeout, info.StreamStatus.EndReason) + assert.Equal(t, "timeout", info.StreamStatus.EndSource) + assert.Equal(t, 0, info.ReceivedResponseCount) +} + +func TestStreamScannerHandler_StreamStatus_ClientGoneAfterUpstreamData(t *testing.T) { + oldTimeout := constant.StreamingTimeout + constant.StreamingTimeout = 30 + t.Cleanup(func() { constant.StreamingTimeout = oldTimeout }) + + ctx, cancel := context.WithCancel(context.Background()) + pr, pw := io.Pipe() + defer pw.Close() + defer pr.Close() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx) + resp := &http.Response{Body: pr, StatusCode: http.StatusOK} + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} + + dataHandled := make(chan struct{}) + done := make(chan struct{}) + go func() { + StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) { + close(dataHandled) + }) + close(done) + }() + + _, err := fmt.Fprint(pw, "data: {\"id\":1}\n") + require.NoError(t, err) + select { + case <-dataHandled: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for first data") + } + time.Sleep(10 * time.Millisecond) + cancel() + + select { + case <-done: + case <-time.After(7 * time.Second): + t.Fatal("timed out waiting for client_gone after data") + } + + require.NotNil(t, info.StreamStatus) + assert.Equal(t, relaycommon.StreamEndReasonClientGone, info.StreamStatus.EndReason) + assert.Equal(t, 1, info.ReceivedResponseCount) + assert.Contains(t, info.StreamStatus.Summary(), "first_data_ms=") +} + func TestStreamScannerHandler_StreamStatus_SoftErrors(t *testing.T) { t.Parallel() diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 54448d59d673..22066fcbcf4d 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -3,6 +3,7 @@ package service import ( "encoding/base64" "strings" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" @@ -94,21 +95,41 @@ func appendStreamStatus(relayInfo *relaycommon.RelayInfo, other map[string]inter return } ss := relayInfo.StreamStatus + snapshot := ss.Snapshot() status := "ok" - if !ss.IsNormalEnd() || ss.HasErrors() { + if !ss.IsNormalEnd() || snapshot.ErrorCount > 0 { status = "error" } streamInfo := map[string]interface{}{ "status": status, - "end_reason": string(ss.EndReason), + "end_reason": string(snapshot.EndReason), } - if ss.EndError != nil { - streamInfo["end_error"] = ss.EndError.Error() + if snapshot.EndSource != "" { + streamInfo["source"] = snapshot.EndSource } - if ss.ErrorCount > 0 { - streamInfo["error_count"] = ss.ErrorCount - messages := make([]string, 0, len(ss.Errors)) - for _, e := range ss.Errors { + if snapshot.EndError != nil { + streamInfo["end_error"] = snapshot.EndError.Error() + } + if !snapshot.StartedAt.IsZero() { + endAt := snapshot.EndedAt + if endAt.IsZero() { + endAt = time.Now() + } + streamInfo["elapsed_ms"] = endAt.Sub(snapshot.StartedAt).Milliseconds() + } + if snapshot.UpstreamStatusCode != 0 { + streamInfo["upstream_status"] = snapshot.UpstreamStatusCode + } + if !snapshot.FirstDataAt.IsZero() && !snapshot.StartedAt.IsZero() { + streamInfo["first_data_ms"] = snapshot.FirstDataAt.Sub(snapshot.StartedAt).Milliseconds() + } + if !snapshot.LastDataAt.IsZero() && !snapshot.StartedAt.IsZero() { + streamInfo["last_data_ms"] = snapshot.LastDataAt.Sub(snapshot.StartedAt).Milliseconds() + } + if snapshot.ErrorCount > 0 { + streamInfo["error_count"] = snapshot.ErrorCount + messages := make([]string, 0, len(snapshot.Errors)) + for _, e := range snapshot.Errors { messages = append(messages, e.Message) } streamInfo["errors"] = messages diff --git a/setting/ratio_setting/compact_suffix.go b/setting/ratio_setting/compact_suffix.go index 2d2fe3c34bb9..5d20064e98e1 100644 --- a/setting/ratio_setting/compact_suffix.go +++ b/setting/ratio_setting/compact_suffix.go @@ -1,10 +1,17 @@ package ratio_setting -import "strings" +import ( + "os" + "strings" +) const CompactModelSuffix = "-openai-compact" const CompactWildcardModelKey = "*" + CompactModelSuffix +func CompactUseBaseModel() bool { + return os.Getenv("COMPACT_USE_BASE_MODEL") == "true" +} + func WithCompactModelSuffix(modelName string) string { if strings.HasSuffix(modelName, CompactModelSuffix) { return modelName From 534660efb8e51ca8201293a5fa943359d6b6d369 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Thu, 28 May 2026 12:39:47 +0800 Subject: [PATCH 36/45] fix(relay): cooldown unstable stream channels Automatically cool a channel for one hour when repeated stream transport failures indicate intermittent upstream instability, while ignoring normal client cancellations. Co-Authored-By: Claude Opus 4.7 --- relay/claude_handler.go | 1 + relay/compatible_handler.go | 2 + relay/responses_handler.go | 1 + service/channel_stream_quality.go | 153 ++++++++++++++++++++++++ service/channel_stream_quality_test.go | 156 +++++++++++++++++++++++++ 5 files changed, 313 insertions(+) create mode 100644 service/channel_stream_quality.go create mode 100644 service/channel_stream_quality_test.go diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 7ec934f9af6c..ed77ef3451a2 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -207,6 +207,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + service.ObserveStreamChannelQuality(info) if newAPIError != nil { // reset status code 重置状态码 service.ResetStatusCode(newAPIError, statusCodeMappingStr) diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index a68cfe730f60..fe178eb7e211 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -77,6 +77,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) { applySystemPromptIfNeeded(c, info, request) usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request) + service.ObserveStreamChannelQuality(info) if newApiErr != nil { return newApiErr } @@ -205,6 +206,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types } usage, newApiErr := adaptor.DoResponse(c, httpResp, info) + service.ObserveStreamChannelQuality(info) if newApiErr != nil { // reset status code 重置状态码 service.ResetStatusCode(newApiErr, statusCodeMappingStr) diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 010c38bba865..0f45370f0ce7 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -133,6 +133,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * } usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + service.ObserveStreamChannelQuality(info) if newAPIError != nil { // reset status code 重置状态码 service.ResetStatusCode(newAPIError, statusCodeMappingStr) diff --git a/service/channel_stream_quality.go b/service/channel_stream_quality.go new file mode 100644 index 000000000000..f69f8cff4ed3 --- /dev/null +++ b/service/channel_stream_quality.go @@ -0,0 +1,153 @@ +package service + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" +) + +const ( + StreamChannelCooldownDuration = time.Hour + streamQualityWindow = 5 * time.Minute + streamQualityFailureThreshold = 5 +) + +type streamChannelQualityKey struct { + channelId int + modelName string +} + +type streamChannelQualityState struct { + failures []time.Time +} + +var streamChannelQuality = struct { + sync.Mutex + items map[streamChannelQualityKey]streamChannelQualityState +}{items: make(map[streamChannelQualityKey]streamChannelQualityState)} + +func ObserveStreamChannelQuality(relayInfo *relaycommon.RelayInfo) { + if relayInfo == nil || relayInfo.IsChannelTest || relayInfo.StreamStatus == nil || relayInfo.ChannelId == 0 { + return + } + reason := streamInstabilityReason(relayInfo) + if reason == "" { + return + } + modelName := relayInfo.OriginModelName + if modelName == "" { + modelName = relayInfo.UpstreamModelName + } + if modelName == "" { + return + } + failureCount := recordStreamChannelFailure(relayInfo.ChannelId, modelName) + if failureCount < streamQualityFailureThreshold { + return + } + + cooldownReason := fmt.Sprintf("stream_unstable model=%s failures=%d/%s reason=%s", modelName, failureCount, streamQualityWindow, reason) + common.SysLog(fmt.Sprintf("通道冷却:#%d,持续 %s,原因:%s", relayInfo.ChannelId, StreamChannelCooldownDuration, cooldownReason)) + model.CooldownChannel(relayInfo.ChannelId, cooldownReason, StreamChannelCooldownDuration) + clearStreamChannelFailures(relayInfo.ChannelId, modelName) +} + +func streamInstabilityReason(relayInfo *relaycommon.RelayInfo) string { + snapshot := relayInfo.StreamStatus.Snapshot() + switch snapshot.EndReason { + case relaycommon.StreamEndReasonScannerErr, relaycommon.StreamEndReasonPingFail, relaycommon.StreamEndReasonTimeout: + return string(snapshot.EndReason) + case relaycommon.StreamEndReasonClientGone: + if isStreamTransportError(snapshot.EndError) || hasStreamTransportError(snapshot.Errors) { + return "client_gone_transport_error" + } + } + if snapshot.ErrorCount > 0 && hasStreamTransportError(snapshot.Errors) { + return "stream_soft_transport_error" + } + return "" +} + +func hasStreamTransportError(errors []relaycommon.StreamErrorEntry) bool { + for _, entry := range errors { + if isStreamTransportErrorText(entry.Message) { + return true + } + } + return false +} + +func isStreamTransportError(err error) bool { + return err != nil && isStreamTransportErrorText(err.Error()) +} + +func isStreamTransportErrorText(message string) bool { + message = strings.ToLower(message) + return strings.Contains(message, "http2: response body closed") || + strings.Contains(message, "unexpected eof") || + strings.Contains(message, "malformed") || + strings.Contains(message, "empty response") || + strings.Contains(message, "invalid character") || + strings.Contains(message, "cannot unmarshal") || + strings.Contains(message, "unexpected end of json") || + strings.Contains(message, "connection reset") || + strings.Contains(message, "broken pipe") || + strings.Contains(message, "stream error") || + strings.Contains(message, "read/write") +} + +func recordStreamChannelFailure(channelId int, modelName string) int { + now := time.Now() + cutoff := now.Add(-streamQualityWindow) + key := streamChannelQualityKey{channelId: channelId, modelName: modelName} + + streamChannelQuality.Lock() + defer streamChannelQuality.Unlock() + + pruneExpiredStreamChannelFailures(cutoff) + state := streamChannelQuality.items[key] + failures := state.failures[:0] + for _, failureAt := range state.failures { + if failureAt.After(cutoff) { + failures = append(failures, failureAt) + } + } + failures = append(failures, now) + state.failures = failures + streamChannelQuality.items[key] = state + return len(failures) +} + +func pruneExpiredStreamChannelFailures(cutoff time.Time) { + for key, state := range streamChannelQuality.items { + failures := state.failures[:0] + for _, failureAt := range state.failures { + if failureAt.After(cutoff) { + failures = append(failures, failureAt) + } + } + if len(failures) == 0 { + delete(streamChannelQuality.items, key) + continue + } + state.failures = failures + streamChannelQuality.items[key] = state + } +} + +func clearStreamChannelFailures(channelId int, modelName string) { + streamChannelQuality.Lock() + defer streamChannelQuality.Unlock() + delete(streamChannelQuality.items, streamChannelQualityKey{channelId: channelId, modelName: modelName}) +} + +func clearStreamChannelQualityForTest() { + streamChannelQuality.Lock() + defer streamChannelQuality.Unlock() + streamChannelQuality.items = make(map[streamChannelQualityKey]streamChannelQualityState) +} diff --git a/service/channel_stream_quality_test.go b/service/channel_stream_quality_test.go new file mode 100644 index 000000000000..8680eadd0352 --- /dev/null +++ b/service/channel_stream_quality_test.go @@ -0,0 +1,156 @@ +package service + +import ( + "fmt" + "testing" + + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" +) + +func TestObserveStreamChannelQualityCoolsAfterRepeatedTimeouts(t *testing.T) { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + t.Cleanup(func() { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + }) + + for i := 0; i < streamQualityFailureThreshold-1; i++ { + ObserveStreamChannelQuality(newStreamQualityRelayInfo(12, "gpt-5.5", relaycommon.StreamEndReasonTimeout, 0, nil)) + if model.IsChannelCoolingDown(12) { + t.Fatalf("channel cooled before threshold at failure %d", i+1) + } + } + + ObserveStreamChannelQuality(newStreamQualityRelayInfo(12, "gpt-5.5", relaycommon.StreamEndReasonTimeout, 0, nil)) + + if !model.IsChannelCoolingDown(12) { + t.Fatalf("expected channel to cool down after repeated stream timeouts") + } +} + +func TestObserveStreamChannelQualityIgnoresNormalClientGoneAfterData(t *testing.T) { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + t.Cleanup(func() { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + }) + + for i := 0; i < streamQualityFailureThreshold+1; i++ { + ObserveStreamChannelQuality(newStreamQualityRelayInfo(12, "gpt-5.5", relaycommon.StreamEndReasonClientGone, 10, nil)) + } + + if model.IsChannelCoolingDown(12) { + t.Fatalf("expected normal client_gone after data to avoid channel cooldown") + } +} + +func TestObserveStreamChannelQualityIgnoresClientGoneBeforeData(t *testing.T) { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + t.Cleanup(func() { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + }) + + for i := 0; i < streamQualityFailureThreshold+1; i++ { + ObserveStreamChannelQuality(newStreamQualityRelayInfo(17, "gpt-5.5", relaycommon.StreamEndReasonClientGone, 0, nil)) + } + + if model.IsChannelCoolingDown(17) { + t.Fatalf("expected client_gone before data without transport error to avoid channel cooldown") + } +} + +func TestObserveStreamChannelQualityCoolsTransportErrors(t *testing.T) { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + t.Cleanup(func() { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + }) + + for i := 0; i < streamQualityFailureThreshold; i++ { + ObserveStreamChannelQuality(newStreamQualityRelayInfo(19, "gpt-5.5", relaycommon.StreamEndReasonClientGone, 20, []string{"http2: response body closed"})) + } + + if !model.IsChannelCoolingDown(19) { + t.Fatalf("expected repeated stream transport errors to cool channel") + } +} + +func TestObserveStreamChannelQualityCoolsClientGoneTerminalTransportError(t *testing.T) { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + t.Cleanup(func() { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + }) + + for i := 0; i < streamQualityFailureThreshold; i++ { + ObserveStreamChannelQuality(newStreamQualityRelayInfoWithEndError(21, "gpt-5.5", relaycommon.StreamEndReasonClientGone, 20, "connection reset by peer", nil)) + } + + if !model.IsChannelCoolingDown(21) { + t.Fatalf("expected repeated terminal transport errors to cool channel") + } +} + +func TestObserveStreamChannelQualityCoolsSoftMalformedErrors(t *testing.T) { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + t.Cleanup(func() { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + }) + + for i := 0; i < streamQualityFailureThreshold; i++ { + ObserveStreamChannelQuality(newStreamQualityRelayInfo(22, "gpt-5.5", relaycommon.StreamEndReasonEOF, 20, []string{"invalid character '<' looking for beginning of value"})) + } + + if !model.IsChannelCoolingDown(22) { + t.Fatalf("expected repeated malformed stream chunks to cool channel") + } +} + +func TestObserveStreamChannelQualityTracksModelSeparately(t *testing.T) { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + t.Cleanup(func() { + model.ClearChannelCooldownsForTest() + clearStreamChannelQualityForTest() + }) + + for i := 0; i < streamQualityFailureThreshold-1; i++ { + ObserveStreamChannelQuality(newStreamQualityRelayInfo(12, "gpt-5.5", relaycommon.StreamEndReasonTimeout, 0, nil)) + ObserveStreamChannelQuality(newStreamQualityRelayInfo(12, "gpt-5.4", relaycommon.StreamEndReasonTimeout, 0, nil)) + } + + if model.IsChannelCoolingDown(12) { + t.Fatalf("expected per-model failures to stay below threshold") + } +} + +func newStreamQualityRelayInfo(channelId int, modelName string, reason relaycommon.StreamEndReason, received int, messages []string) *relaycommon.RelayInfo { + return newStreamQualityRelayInfoWithEndError(channelId, modelName, reason, received, fmt.Sprintf("stream ended: %s", reason), messages) +} + +func newStreamQualityRelayInfoWithEndError(channelId int, modelName string, reason relaycommon.StreamEndReason, received int, endError string, messages []string) *relaycommon.RelayInfo { + status := relaycommon.NewStreamStatus() + status.SetEndReason(reason, fmt.Errorf("%s", endError)) + if received > 0 { + status.RecordDataReceived() + } + for _, message := range messages { + status.RecordError(message) + } + return &relaycommon.RelayInfo{ + IsStream: true, + OriginModelName: modelName, + ReceivedResponseCount: received, + StreamStatus: status, + ChannelMeta: &relaycommon.ChannelMeta{ChannelId: channelId}, + } +} From 18b51dc96c28b6aa4af41a4c8f461d25139088b8 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Thu, 28 May 2026 16:59:07 +0800 Subject: [PATCH 37/45] fix(relay): cooldown bad upstream stream terminals Convert malformed OpenAI-compatible Claude terminal streams into upstream channel errors, patch Responses terminal output shape for Codex, and temporarily cool unstable upstream channels without penalizing client/auth errors. Co-Authored-By: Claude Opus 4.7 --- controller/claude_count_tokens.go | 66 +++++++++++++++++ controller/claude_count_tokens_test.go | 42 +++++++++++ controller/relay.go | 2 + model/option.go | 2 +- relay/channel/openai/helper.go | 33 +++++++++ relay/channel/openai/relay-openai.go | 20 +++++- .../openai/relay_openai_stream_claude_test.go | 70 +++++++++++++++++++ relay/channel/openai/relay_responses.go | 2 +- relay/channel/openai/responses_fallback.go | 29 ++++++++ .../channel/openai/responses_fallback_test.go | 46 ++++++++++++ router/relay-router.go | 3 + service/channel_affinity_template_test.go | 42 +++++++++++ service/channel_cooldown.go | 63 ++++++++++++++++- service/channel_disable_cooldown_test.go | 40 +++++++++++ service/token_counter.go | 7 ++ .../channel_affinity_setting.go | 2 +- 16 files changed, 462 insertions(+), 7 deletions(-) create mode 100644 controller/claude_count_tokens.go create mode 100644 controller/claude_count_tokens_test.go create mode 100644 relay/channel/openai/relay_openai_stream_claude_test.go diff --git a/controller/claude_count_tokens.go b/controller/claude_count_tokens.go new file mode 100644 index 000000000000..9098301ca3a0 --- /dev/null +++ b/controller/claude_count_tokens.go @@ -0,0 +1,66 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func ClaudeMessagesCountTokens(c *gin.Context) { + request := &dto.ClaudeRequest{} + if err := common.UnmarshalBodyReusable(c, request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "type": "error", + "error": types.ClaudeError{ + Type: "invalid_request_error", + Message: err.Error(), + }, + }) + return + } + if request.Messages == nil || len(request.Messages) == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "type": "error", + "error": types.ClaudeError{ + Type: "invalid_request_error", + Message: "field messages is required", + }, + }) + return + } + if request.Model == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "type": "error", + "error": types.ClaudeError{ + Type: "invalid_request_error", + Message: "field model is required", + }, + }) + return + } + + common.SetContextKey(c, constant.ContextKeyOriginalModel, request.Model) + count, err := service.EstimateRequestTokenAlways(c, request.GetTokenCountMeta(), &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatClaude, + IsStream: false, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "type": "error", + "error": types.ClaudeError{ + Type: "api_error", + Message: err.Error(), + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{"input_tokens": count}) +} diff --git a/controller/claude_count_tokens_test.go b/controller/claude_count_tokens_test.go new file mode 100644 index 000000000000..67e7c199d783 --- /dev/null +++ b/controller/claude_count_tokens_test.go @@ -0,0 +1,42 @@ +package controller + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestClaudeMessagesCountTokensReturnsInputTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + service.InitTokenEncoders() + + body := map[string]any{ + "model": "gpt-5.5", + "messages": []map[string]any{ + { + "role": "user", + "content": "hello from claude cli", + }, + }, + } + payload, err := common.Marshal(body) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", bytes.NewReader(payload)) + ctx.Request.Header.Set("Content-Type", "application/json") + + ClaudeMessagesCountTokens(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + var response map[string]int + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.Greater(t, response["input_tokens"], 0) +} diff --git a/controller/relay.go b/controller/relay.go index 3d0892edbcdf..365cdd529ed3 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -359,6 +359,8 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously if service.ShouldCooldownChannel(err) { service.CooldownChannel(channelError, err) + } else if service.ShouldCooldownChannelForUpstreamError(err) { + service.CooldownChannelForUpstreamError(channelError, err) } if service.ShouldDisableChannel(err) && channelError.AutoBan { diff --git a/model/option.go b/model/option.go index ed1af72ebb12..c094a5697201 100644 --- a/model/option.go +++ b/model/option.go @@ -189,7 +189,7 @@ func loadOptionsFromDatabase() { for _, option := range options { err := updateOptionMap(option.Key, option.Value) if err != nil { - common.SysLog("failed to update option map: " + err.Error()) + common.SysLog("failed to update option map key " + option.Key + ": " + err.Error()) } } } diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index 1a01d06da6dc..ab4c2cdf8bb8 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -115,6 +115,39 @@ func processCompletionsStreamResponse(streamResponse dto.CompletionsStreamRespon } } +func isOpenAIStreamTerminalChunk(data string) bool { + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { + return false + } + return streamResponse.IsFinished() || service.ValidUsage(streamResponse.Usage) +} + +func synthesizeOpenAIStreamTerminalChunk(c *gin.Context, responseId string, createdAt int64, model string, usage *dto.Usage) string { + if responseId == "" { + responseId = helper.GetResponseID(c) + } + if usage == nil { + usage = &dto.Usage{} + } + finishReason := "stop" + response := dto.ChatCompletionsStreamResponse{ + Id: responseId, + Object: "chat.completion.chunk", + Created: createdAt, + Model: model, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, FinishReason: &finishReason}, + }, + Usage: usage, + } + data, err := common.Marshal(response) + if err != nil { + return "" + } + return string(data) +} + func handleLastResponse(lastStreamData string, responseId *string, createAt *int64, systemFingerprint *string, model *string, usage **dto.Usage, containStreamUsage *bool, info *relaycommon.RelayInfo, diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 5bacd919c0ff..ffe87e4cc297 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -166,9 +166,18 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re // 处理最后的响应 shouldSendLastResp := true - if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage, - &containStreamUsage, info, &shouldSendLastResp); err != nil { - logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData)) + lastResponseErr := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage, + &containStreamUsage, info, &shouldSendLastResp) + if lastResponseErr != nil { + logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", lastResponseErr.Error(), lastStreamData)) + if info.RelayFormat != types.RelayFormatOpenAI { + if responseTextBuilder.Len() == 0 && toolCount == 0 { + return nil, types.NewOpenAIError(lastResponseErr, types.ErrorCodeBadResponseBody, http.StatusBadGateway) + } + } + } + if lastResponseErr == nil && info.RelayFormat != types.RelayFormatOpenAI && !isOpenAIStreamTerminalChunk(lastStreamData) { + return nil, types.NewOpenAIError(fmt.Errorf("upstream stream ended without a terminal response event"), types.ErrorCodeBadResponseBody, http.StatusBadGateway) } if info.RelayFormat == types.RelayFormatOpenAI { @@ -182,6 +191,11 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re usage.CompletionTokens += toolCount * 7 } + if lastResponseErr != nil && info.RelayFormat != types.RelayFormatOpenAI { + lastStreamData = synthesizeOpenAIStreamTerminalChunk(c, responseId, createAt, model, usage) + containStreamUsage = true + } + applyUsagePostProcessing(info, usage, common.StringToByteSlice(lastStreamData)) HandleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage) diff --git a/relay/channel/openai/relay_openai_stream_claude_test.go b/relay/channel/openai/relay_openai_stream_claude_test.go new file mode 100644 index 000000000000..62e3d8ee3086 --- /dev/null +++ b/relay/channel/openai/relay_openai_stream_claude_test.go @@ -0,0 +1,70 @@ +package openai + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func setupOpenAICompatibleClaudeStreamTest(t *testing.T, body string) (*gin.Context, *http.Response, *relaycommon.RelayInfo, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + + oldTimeout := constant.StreamingTimeout + constant.StreamingTimeout = 30 + t.Cleanup(func() { + constant.StreamingTimeout = oldTimeout + }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + common.SetContextKey(c, common.RequestIdKey, "test-req-id") + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + } + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatClaude, + RelayMode: relayconstant.RelayModeChatCompletions, + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-5.5"}, + ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{}, + IsStream: true, + } + info.SetEstimatePromptTokens(100) + + return c, resp, info, recorder +} + +func TestOaiStreamHandlerClaudeCompatibleReturnsErrorOnEmptyStream(t *testing.T) { + c, resp, info, _ := setupOpenAICompatibleClaudeStreamTest(t, "") + + usage, err := OaiStreamHandler(c, info, resp) + + require.Nil(t, usage) + require.NotNil(t, err) + require.Equal(t, types.ErrorCodeBadResponseBody, err.GetErrorCode()) +} + +func TestOaiStreamHandlerClaudeCompatibleReturnsErrorOnOpenBlockWithoutTerminal(t *testing.T) { + body := "data: {\"id\":\"chatcmpl_test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5.5\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}]}\n\n" + c, resp, info, _ := setupOpenAICompatibleClaudeStreamTest(t, body) + + usage, err := OaiStreamHandler(c, info, resp) + + require.Nil(t, usage) + require.NotNil(t, err) + require.Equal(t, types.ErrorCodeBadResponseBody, err.GetErrorCode()) +} diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 284560b8b385..8285af9bd1b1 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -90,7 +90,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp return } streamCtx.observe(streamResponse) - sendResponsesStreamData(c, streamResponse, data) + sendResponsesStreamData(c, streamResponse, ensureResponsesTerminalOutputField(streamResponse, data)) switch streamResponse.Type { case "response.completed": if streamResponse.Response != nil { diff --git a/relay/channel/openai/responses_fallback.go b/relay/channel/openai/responses_fallback.go index 831bd03c03cb..6e2574432872 100644 --- a/relay/channel/openai/responses_fallback.go +++ b/relay/channel/openai/responses_fallback.go @@ -246,6 +246,35 @@ func (ctx *responsesStreamCtx) writeSyntheticEvent(c *gin.Context, eventType str sendResponsesStreamData(c, syntheticEvent, string(data)) } +func ensureResponsesTerminalOutputField(streamResponse dto.ResponsesStreamResponse, data string) string { + switch streamResponse.Type { + case "response.completed", "response.failed", "response.incomplete": + default: + return data + } + if streamResponse.Response == nil || streamResponse.Response.Output != nil { + return data + } + + var payload map[string]any + if err := common.UnmarshalJsonStr(data, &payload); err != nil { + return data + } + response, ok := payload["response"].(map[string]any) + if !ok { + return data + } + if _, ok := response["output"]; ok { + return data + } + response["output"] = []any{} + patched, err := common.Marshal(payload) + if err != nil { + return data + } + return string(patched) +} + // usageToResponsesPayload converts an internal dto.Usage into the JSON shape // the Responses API uses (input_tokens / output_tokens / total_tokens with // nested details), which is what Codex's ResponseCompletedUsage deserializer diff --git a/relay/channel/openai/responses_fallback_test.go b/relay/channel/openai/responses_fallback_test.go index bb58c53d1900..eaba374981cf 100644 --- a/relay/channel/openai/responses_fallback_test.go +++ b/relay/channel/openai/responses_fallback_test.go @@ -295,6 +295,52 @@ func TestResponsesStreamCtx_EmitTerminal_ReasoningCountsAsOutput(t *testing.T) { assert.Equal(t, "response.completed", eventName) } +func TestResponsesStream_EnsureTerminalOutputFieldAddsMissingOutput(t *testing.T) { + t.Parallel() + + data := `{"type":"response.completed","response":{"id":"resp_test","status":"completed","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}` + patched := ensureResponsesTerminalOutputField(dto.ResponsesStreamResponse{ + Type: "response.completed", + Response: &dto.OpenAIResponsesResponse{}, + }, data) + + var payload map[string]any + require.NoError(t, common.UnmarshalJsonStr(patched, &payload)) + response := payload["response"].(map[string]any) + output, ok := response["output"].([]any) + require.True(t, ok) + assert.Empty(t, output) +} + +func TestResponsesStream_EnsureTerminalOutputFieldPreservesExistingOutput(t *testing.T) { + t.Parallel() + + data := `{"type":"response.completed","response":{"id":"resp_test","status":"completed","output":[{"type":"message"}]}}` + patched := ensureResponsesTerminalOutputField(dto.ResponsesStreamResponse{ + Type: "response.completed", + Response: &dto.OpenAIResponsesResponse{}, + }, data) + + var payload map[string]any + require.NoError(t, common.UnmarshalJsonStr(patched, &payload)) + response := payload["response"].(map[string]any) + output, ok := response["output"].([]any) + require.True(t, ok) + assert.Len(t, output, 1) +} + +func TestResponsesStream_EnsureTerminalOutputFieldIgnoresNonTerminalEvents(t *testing.T) { + t.Parallel() + + data := `{"type":"response.in_progress","response":{"id":"resp_test","status":"in_progress"}}` + patched := ensureResponsesTerminalOutputField(dto.ResponsesStreamResponse{ + Type: "response.in_progress", + Response: &dto.OpenAIResponsesResponse{}, + }, data) + + assert.Equal(t, data, patched) +} + // -------- Usage payload shape (matches Codex's ResponseCompletedUsage) -------- func TestUsageToResponsesPayload_AllFieldsForCodex(t *testing.T) { diff --git a/router/relay-router.go b/router/relay-router.go index 17a13cad7fd6..1e60d1663bba 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -79,6 +79,9 @@ func SetRelayRouter(router *gin.Engine) { controller.Relay(c, types.RelayFormatOpenAIRealtime) }) } + { + relayV1Router.POST("/messages/count_tokens", controller.ClaudeMessagesCountTokens) + } { //http router httpRouter := relayV1Router.Group("") diff --git a/service/channel_affinity_template_test.go b/service/channel_affinity_template_test.go index 91844fc33108..9698d8d32946 100644 --- a/service/channel_affinity_template_test.go +++ b/service/channel_affinity_template_test.go @@ -236,6 +236,48 @@ func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) { require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint) } +func TestChannelAffinityHitClaudeMessagesAllowsGPTModels(t *testing.T) { + gin.SetMode(gin.TestMode) + + setting := operation_setting.GetChannelAffinitySetting() + require.NotNil(t, setting) + + var claudeRule *operation_setting.ChannelAffinityRule + for i := range setting.Rules { + rule := &setting.Rules[i] + if strings.EqualFold(strings.TrimSpace(rule.Name), "claude cli trace") { + claudeRule = rule + break + } + } + require.NotNil(t, claudeRule) + + affinityValue := fmt.Sprintf("claude-user-%d", time.Now().UnixNano()) + cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*claudeRule, "gpt-5.5", "gpt pro", affinityValue) + + cache := getChannelAffinityCache() + require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 39, time.Minute)) + t.Cleanup(func() { + _, _ = cache.DeleteMany([]string{cacheKeySuffix}) + }) + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(fmt.Sprintf(`{"metadata":{"user_id":"%s"}}`, affinityValue))) + ctx.Request.Header.Set("Content-Type", "application/json") + + channelID, found := GetPreferredChannelByAffinity(ctx, "gpt-5.5", "gpt pro") + require.True(t, found) + require.Equal(t, 39, channelID) + + meta, ok := getChannelAffinityMeta(ctx) + require.True(t, ok) + require.Equal(t, "claude cli trace", meta.RuleName) + require.Equal(t, "gpt-5.5", meta.ModelName) + require.Equal(t, "/v1/messages", meta.RequestPath) + require.Equal(t, "metadata.user_id", meta.KeySourcePath) +} + func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/service/channel_cooldown.go b/service/channel_cooldown.go index 1f121311accb..d382efb1b9fc 100644 --- a/service/channel_cooldown.go +++ b/service/channel_cooldown.go @@ -10,7 +10,10 @@ import ( "github.com/QuantumNous/new-api/types" ) -const ChannelCooldownDuration = 30 * time.Minute +const ( + ChannelCooldownDuration = 30 * time.Minute + UpstreamErrorCooldownDuration = 15 * time.Minute +) var channelCooldownKeywords = []string{ "insufficient account balance", @@ -20,6 +23,30 @@ var channelCooldownKeywords = []string{ "余额不足", } +var upstreamErrorCooldownCodes = map[types.ErrorCode]bool{ + types.ErrorCodeDoRequestFailed: true, + types.ErrorCodeReadResponseBodyFailed: true, + types.ErrorCodeBadResponse: true, + types.ErrorCodeBadResponseBody: true, + types.ErrorCodeEmptyResponse: true, +} + +var upstreamErrorCooldownKeywords = []string{ + "openai_error", + "empty or malformed response", + "empty response", + "malformed", + "invalid character", + "cannot unmarshal", + "unexpected end of json", + "unexpected eof", + "http2: response body closed", + "connection reset", + "broken pipe", + "stream error", + "read/write", +} + func ShouldCooldownChannel(err *types.NewAPIError) bool { if err == nil { return false @@ -33,6 +60,31 @@ func ShouldCooldownChannel(err *types.NewAPIError) bool { return false } +func ShouldCooldownChannelForUpstreamError(err *types.NewAPIError) bool { + if err == nil || ShouldCooldownChannel(err) { + return false + } + if err.StatusCode >= 400 && err.StatusCode < 500 { + return false + } + if upstreamErrorCooldownCodes[err.GetErrorCode()] { + return true + } + if err.StatusCode == 502 || err.StatusCode == 503 { + return true + } + if types.IsSkipRetryError(err) { + return false + } + message := strings.ToLower(err.Error() + " " + string(err.GetErrorCode()) + " " + string(err.GetErrorType())) + for _, keyword := range upstreamErrorCooldownKeywords { + if strings.Contains(message, keyword) { + return true + } + } + return false +} + func CooldownChannel(channelError types.ChannelError, err *types.NewAPIError) { if !ShouldCooldownChannel(err) { return @@ -40,3 +92,12 @@ func CooldownChannel(channelError types.ChannelError, err *types.NewAPIError) { common.SysLog(fmt.Sprintf("通道冷却:#%d,持续 %s,原因:%s", channelError.ChannelId, ChannelCooldownDuration, err.Error())) model.CooldownChannel(channelError.ChannelId, err.Error(), ChannelCooldownDuration) } + +func CooldownChannelForUpstreamError(channelError types.ChannelError, err *types.NewAPIError) { + if !ShouldCooldownChannelForUpstreamError(err) { + return + } + reason := fmt.Sprintf("upstream_unstable status=%d code=%s type=%s error=%s", err.StatusCode, err.GetErrorCode(), err.GetErrorType(), err.Error()) + common.SysLog(fmt.Sprintf("通道冷却:#%d,持续 %s,原因:%s", channelError.ChannelId, UpstreamErrorCooldownDuration, reason)) + model.CooldownChannel(channelError.ChannelId, reason, UpstreamErrorCooldownDuration) +} diff --git a/service/channel_disable_cooldown_test.go b/service/channel_disable_cooldown_test.go index fc41abfc21b6..9b40c430a217 100644 --- a/service/channel_disable_cooldown_test.go +++ b/service/channel_disable_cooldown_test.go @@ -22,3 +22,43 @@ func TestShouldDisableChannelIgnoresCooldownBalanceError(t *testing.T) { t.Fatalf("expected balance error to cooldown without permanent auto-disable") } } + +func TestShouldCooldownChannelForUpstreamErrorCoolsMalformedResponses(t *testing.T) { + err := types.NewErrorWithStatusCode(errors.New("API returned an empty or malformed response (HTTP 200)"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + + if !ShouldCooldownChannelForUpstreamError(err) { + t.Fatalf("expected malformed upstream response to cooldown") + } +} + +func TestShouldCooldownChannelForUpstreamErrorCoolsSkipRetryMalformedResponses(t *testing.T) { + err := types.NewErrorWithStatusCode(errors.New("API returned an empty or malformed response (HTTP 200)"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError, types.ErrOptionWithSkipRetry()) + + if !ShouldCooldownChannelForUpstreamError(err) { + t.Fatalf("expected malformed upstream response to cooldown even when retry is skipped") + } +} + +func TestShouldCooldownChannelForUpstreamErrorCoolsBadGateway(t *testing.T) { + err := types.WithOpenAIError(types.OpenAIError{Message: "openai_error", Type: "openai_error", Code: "openai_error"}, http.StatusBadGateway) + + if !ShouldCooldownChannelForUpstreamError(err) { + t.Fatalf("expected upstream 502 to cooldown") + } +} + +func TestShouldCooldownChannelForUpstreamErrorIgnoresClientErrors(t *testing.T) { + err := types.NewErrorWithStatusCode(errors.New("invalid request"), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + + if ShouldCooldownChannelForUpstreamError(err) { + t.Fatalf("expected client validation error to avoid cooldown") + } +} + +func TestShouldCooldownChannelForUpstreamErrorIgnoresAuthErrors(t *testing.T) { + err := types.NewErrorWithStatusCode(errors.New("invalid token"), types.ErrorCodeAccessDenied, http.StatusUnauthorized) + + if ShouldCooldownChannelForUpstreamError(err) { + t.Fatalf("expected auth error to avoid cooldown") + } +} diff --git a/service/token_counter.go b/service/token_counter.go index 933d9fd1ee9b..432620e2e564 100644 --- a/service/token_counter.go +++ b/service/token_counter.go @@ -181,7 +181,14 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela if !constant.CountToken { return 0, nil } + return estimateRequestToken(c, meta, info) +} + +func EstimateRequestTokenAlways(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) { + return estimateRequestToken(c, meta, info) +} +func estimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) { if meta == nil { return 0, errors.New("token count meta is nil") } diff --git a/setting/operation_setting/channel_affinity_setting.go b/setting/operation_setting/channel_affinity_setting.go index bd573696d7df..ed4fc2b2af58 100644 --- a/setting/operation_setting/channel_affinity_setting.go +++ b/setting/operation_setting/channel_affinity_setting.go @@ -96,7 +96,7 @@ var channelAffinitySetting = ChannelAffinitySetting{ }, { Name: "claude cli trace", - ModelRegex: []string{"^claude-.*$"}, + ModelRegex: []string{"^claude-.*$", "^gpt-.*$"}, PathRegex: []string{"/v1/messages"}, KeySources: []ChannelAffinityKeySource{ {Type: "gjson", Path: "metadata.user_id"}, From c874413430e96c09b24c560a38591308bfacdfe3 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Fri, 29 May 2026 17:51:22 +0800 Subject: [PATCH 38/45] fix(relay): fall back to cooling channels instead of distributor 503 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When every candidate channel for a group/model is cooling down, the selector returned no channel, producing a distributor-stage 503 (无可用渠道) that is upstream of the relay retry loop and therefore never retried. Split candidates into available vs cooling and fall back to the cooling set when no non-cooling channel exists, so the request still attempts an upstream instead of hard-failing. --- model/ability.go | 16 +++++++++---- model/channel_cache.go | 42 ++++++++++++++++----------------- model/channel_selection_test.go | 34 +++++++++++++++++++++++--- 3 files changed, 64 insertions(+), 28 deletions(-) diff --git a/model/ability.go b/model/ability.go index 91b731eca464..3f731cbee027 100644 --- a/model/ability.go +++ b/model/ability.go @@ -116,21 +116,29 @@ func GetChannel(group string, model string, retry int) (*Channel, error) { } availableAbilities := make([]Ability, 0, len(abilities)) - uniquePriorities := make(map[int]bool) + coolingAbilities := make([]Ability, 0, len(abilities)) for _, ability := range abilities { if IsChannelCoolingDown(ability.ChannelId) { + coolingAbilities = append(coolingAbilities, ability) continue } availableAbilities = append(availableAbilities, ability) + } + if len(availableAbilities) == 0 { + availableAbilities = coolingAbilities + } + if len(availableAbilities) == 0 { + return nil, nil + } + + uniquePriorities := make(map[int]bool) + for _, ability := range availableAbilities { priority := int(0) if ability.Priority != nil { priority = int(*ability.Priority) } uniquePriorities[priority] = true } - if len(availableAbilities) == 0 { - return nil, nil - } var sortedUniquePriorities []int for priority := range uniquePriorities { diff --git a/model/channel_cache.go b/model/channel_cache.go index 55c9bc3db353..35291a8438de 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -118,24 +118,31 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, if len(channels) == 1 { if channel, ok := channelsIDM[channels[0]]; ok { - if IsChannelCoolingDown(channel.Id) { - return nil, nil - } return channel, nil } return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0]) } - uniquePriorities := make(map[int]bool) + availableChannels := make([]*Channel, 0, len(channels)) + coolingChannels := make([]*Channel, 0, len(channels)) for _, channelId := range channels { - if channel, ok := channelsIDM[channelId]; ok { - if IsChannelCoolingDown(channel.Id) { - continue - } - uniquePriorities[int(channel.GetPriority())] = true - } else { + channel, ok := channelsIDM[channelId] + if !ok { return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId) } + if IsChannelCoolingDown(channel.Id) { + coolingChannels = append(coolingChannels, channel) + continue + } + availableChannels = append(availableChannels, channel) + } + if len(availableChannels) == 0 { + availableChannels = coolingChannels + } + + uniquePriorities := make(map[int]bool) + for _, channel := range availableChannels { + uniquePriorities[int(channel.GetPriority())] = true } if len(uniquePriorities) == 0 { return nil, nil @@ -155,17 +162,10 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, // get the priority for the given retry number var sumWeight = 0 var targetChannels []*Channel - for _, channelId := range channels { - if channel, ok := channelsIDM[channelId]; ok { - if IsChannelCoolingDown(channel.Id) { - continue - } - if channel.GetPriority() == targetPriority { - sumWeight += channel.GetWeight() - targetChannels = append(targetChannels, channel) - } - } else { - return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId) + for _, channel := range availableChannels { + if channel.GetPriority() == targetPriority { + sumWeight += channel.GetWeight() + targetChannels = append(targetChannels, channel) } } diff --git a/model/channel_selection_test.go b/model/channel_selection_test.go index ebf18c778721..0da66d491a47 100644 --- a/model/channel_selection_test.go +++ b/model/channel_selection_test.go @@ -78,7 +78,35 @@ func TestGetChannelSkipsCoolingChannelWithoutMemoryCache(t *testing.T) { } } -func TestGetChannelReturnsNilWhenAllCandidatesCoolingWithoutMemoryCache(t *testing.T) { +func TestGetRandomSatisfiedChannelReturnsCoolingChannelWhenAllCandidatesCoolingWithMemoryCache(t *testing.T) { + oldMemoryCacheEnabled := common.MemoryCacheEnabled + common.MemoryCacheEnabled = true + ClearChannelCacheForTest() + clearChannelCooldownsForTest() + t.Cleanup(func() { + clearChannelCooldownsForTest() + ClearChannelCacheForTest() + common.MemoryCacheEnabled = oldMemoryCacheEnabled + }) + + priority := int64(10) + weight := uint(0) + channel := &Channel{Id: 17, Type: 1, Key: "key-17", Status: common.ChannelStatusEnabled, Name: "cooling", Weight: &weight, Priority: &priority, Models: "gpt-5.5", Group: "default"} + SetChannelCacheForTest(map[int]*Channel{17: channel}, map[string]map[string][]int{ + "default": {"gpt-5.5": {17}}, + }) + CooldownChannel(17, "Insufficient account balance", time.Minute) + + selected, err := GetRandomSatisfiedChannel("default", "gpt-5.5", 0) + if err != nil { + t.Fatalf("GetRandomSatisfiedChannel returned error: %v", err) + } + if selected == nil || selected.Id != 17 { + t.Fatalf("expected cooling fallback channel 17, got %#v", selected) + } +} + +func TestGetChannelReturnsCoolingChannelWhenAllCandidatesCoolingWithoutMemoryCache(t *testing.T) { setupChannelSelectionTestDB(t) priority := int64(10) @@ -98,7 +126,7 @@ func TestGetChannelReturnsNilWhenAllCandidatesCoolingWithoutMemoryCache(t *testi if err != nil { t.Fatalf("GetChannel returned error: %v", err) } - if selected != nil { - t.Fatalf("expected no channel, got %#v", selected) + if selected == nil || selected.Id != 17 { + t.Fatalf("expected cooling fallback channel 17, got %#v", selected) } } From 4f0328dd03790c5d8d4e7b82716fe395410fd013 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Fri, 29 May 2026 17:51:28 +0800 Subject: [PATCH 39/45] fix(relay): cooldown per-channel capability-gap 4xx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 403 like 'Image generation is not enabled for this group' is a per-channel capability gap, not a client error, but the blanket 4xx gate in ShouldCooldownChannelForUpstreamError skipped cooldown. The channel kept being re-selected — retried 3x within one request (21s hangs) and across requests — thrashing the pool and spilling over onto unrelated Codex traffic in the same group. Add capabilityCooldownKeywords checked before the 4xx gate so these cool for 15m and get skipped on retry. --- service/channel_cooldown.go | 23 ++++++++++++++++++++++- service/channel_disable_cooldown_test.go | 8 ++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/service/channel_cooldown.go b/service/channel_cooldown.go index d382efb1b9fc..6189425fbd12 100644 --- a/service/channel_cooldown.go +++ b/service/channel_cooldown.go @@ -31,6 +31,18 @@ var upstreamErrorCooldownCodes = map[types.ErrorCode]bool{ types.ErrorCodeEmptyResponse: true, } +// capabilityCooldownKeywords are 4xx upstream messages signalling that the +// channel cannot serve this request type right now — a per-channel capability +// gap (e.g. its upstream group has image generation disabled), not a problem +// with the client's request. The blanket 4xx gate below would normally skip +// cooldown, but retrying or re-selecting the same channel is futile and thrashes +// the pool (21s hangs that spill over onto unrelated traffic in the same group), +// so we cool it briefly until the upstream re-enables the capability. +var capabilityCooldownKeywords = []string{ + "image generation is not enabled", + "not enabled for this group", +} + var upstreamErrorCooldownKeywords = []string{ "openai_error", "empty or malformed response", @@ -64,6 +76,16 @@ func ShouldCooldownChannelForUpstreamError(err *types.NewAPIError) bool { if err == nil || ShouldCooldownChannel(err) { return false } + message := strings.ToLower(err.Error() + " " + string(err.GetErrorCode()) + " " + string(err.GetErrorType())) + // Per-channel capability gaps surface as 4xx but are the channel's + // limitation for this request type, not the client's. Cool them so retries + // and later requests skip the channel instead of thrashing it. Checked + // before the 4xx gate below, which would otherwise skip them. + for _, keyword := range capabilityCooldownKeywords { + if strings.Contains(message, keyword) { + return true + } + } if err.StatusCode >= 400 && err.StatusCode < 500 { return false } @@ -76,7 +98,6 @@ func ShouldCooldownChannelForUpstreamError(err *types.NewAPIError) bool { if types.IsSkipRetryError(err) { return false } - message := strings.ToLower(err.Error() + " " + string(err.GetErrorCode()) + " " + string(err.GetErrorType())) for _, keyword := range upstreamErrorCooldownKeywords { if strings.Contains(message, keyword) { return true diff --git a/service/channel_disable_cooldown_test.go b/service/channel_disable_cooldown_test.go index 9b40c430a217..0718b62cc6b9 100644 --- a/service/channel_disable_cooldown_test.go +++ b/service/channel_disable_cooldown_test.go @@ -47,6 +47,14 @@ func TestShouldCooldownChannelForUpstreamErrorCoolsBadGateway(t *testing.T) { } } +func TestShouldCooldownChannelForUpstreamErrorCoolsImageGenerationCapabilityGap(t *testing.T) { + err := types.NewErrorWithStatusCode(errors.New("Image generation is not enabled for this group"), types.ErrorCodeBadResponseStatusCode, http.StatusForbidden) + + if !ShouldCooldownChannelForUpstreamError(err) { + t.Fatalf("expected per-channel capability gap (image generation disabled) to cooldown despite being 4xx") + } +} + func TestShouldCooldownChannelForUpstreamErrorIgnoresClientErrors(t *testing.T) { err := types.NewErrorWithStatusCode(errors.New("invalid request"), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) From 1c7af3c6f6e50aadbae1a7d3ce460ca6523ff6bc Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Fri, 29 May 2026 18:32:31 +0800 Subject: [PATCH 40/45] feat(channel): show cooldown reason and remaining time on channel page Channels temporarily cooled down (auto-cooldown for transient upstream errors) were indistinguishable from healthy ones in the admin UI. Expose the in-memory cooldown state (reason + expiry) via GetChannelCooldown and annotate channel list/search/detail responses with cooling_down, cooldown_reason, cooldown_expires (transient, gorm:"-"). Both themes render a warning badge next to the status with remaining minutes and a tooltip showing the reason and recovery time, mirroring the existing auto-disabled (status=3) reason tooltip. --- controller/channel.go | 17 +++++++ model/channel.go | 5 ++ model/channel_cooldown.go | 13 +++++ .../table/channels/ChannelsColumnDefs.jsx | 40 +++++++++++++++- web/classic/src/i18n/locales/en.json | 2 + .../channels/components/channels-columns.tsx | 47 ++++++++++++++++++- web/default/src/features/channels/types.ts | 4 ++ web/default/src/i18n/locales/zh.json | 2 + 8 files changed, 127 insertions(+), 3 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index 217351703fc0..fe8d712181ea 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -69,6 +69,20 @@ func clearChannelInfo(channel *model.Channel) { } } +// fillChannelCooldown annotates a channel with its current in-memory cooldown +// status (reason + expiry) so the admin UI can show why and for how long a +// channel was temporarily taken out of selection. +func fillChannelCooldown(channel *model.Channel) { + if channel == nil { + return + } + if reason, expires, cooling := model.GetChannelCooldown(channel.Id); cooling { + channel.CoolingDown = true + channel.CooldownReason = reason + channel.CooldownExpires = expires + } +} + func applyChannelStatusFilter(query *gorm.DB, statusFilter int) *gorm.DB { if statusFilter == common.ChannelStatusEnabled { return query.Where("status = ?", common.ChannelStatusEnabled) @@ -159,6 +173,7 @@ func GetAllChannels(c *gin.Context) { for _, datum := range channelData { clearChannelInfo(datum) + fillChannelCooldown(datum) } countQuery := buildChannelListQuery(groupFilter, statusFilter, -1) @@ -365,6 +380,7 @@ func SearchChannels(c *gin.Context) { for _, datum := range pagedData { clearChannelInfo(datum) + fillChannelCooldown(datum) } c.JSON(http.StatusOK, gin.H{ @@ -392,6 +408,7 @@ func GetChannel(c *gin.Context) { } if channel != nil { clearChannelInfo(channel) + fillChannelCooldown(channel) } c.JSON(http.StatusOK, gin.H{ "success": true, diff --git a/model/channel.go b/model/channel.go index 3e6d1866a096..1bbf670fdc71 100644 --- a/model/channel.go +++ b/model/channel.go @@ -57,6 +57,11 @@ type Channel struct { // cache info Keys []string `json:"-" gorm:"-"` + + // transient cooldown status (in-memory, per instance), populated for list/detail responses only + CoolingDown bool `json:"cooling_down" gorm:"-"` + CooldownReason string `json:"cooldown_reason,omitempty" gorm:"-"` + CooldownExpires int64 `json:"cooldown_expires,omitempty" gorm:"-"` // unix seconds } type ChannelInfo struct { diff --git a/model/channel_cooldown.go b/model/channel_cooldown.go index 3710d05d242d..340af3524082 100644 --- a/model/channel_cooldown.go +++ b/model/channel_cooldown.go @@ -25,6 +25,19 @@ func CooldownChannel(channelId int, reason string, duration time.Duration) { } } +// GetChannelCooldown returns the active cooldown reason and expiry (unix seconds) +// for a channel. cooling is false when the channel is not currently cooling down +// (no record, or the record has already expired). +func GetChannelCooldown(channelId int) (reason string, expiresUnix int64, cooling bool) { + channelCooldowns.RLock() + cd, ok := channelCooldowns.items[channelId] + channelCooldowns.RUnlock() + if !ok || !time.Now().Before(cd.expires) { + return "", 0, false + } + return cd.reason, cd.expires.Unix(), true +} + func IsChannelCoolingDown(channelId int) bool { channelCooldowns.RLock() cooldown, ok := channelCooldowns.items[channelId] diff --git a/web/classic/src/components/table/channels/ChannelsColumnDefs.jsx b/web/classic/src/components/table/channels/ChannelsColumnDefs.jsx index 5d748c0f5343..1ad2efc7127a 100644 --- a/web/classic/src/components/table/channels/ChannelsColumnDefs.jsx +++ b/web/classic/src/components/table/channels/ChannelsColumnDefs.jsx @@ -493,6 +493,7 @@ export const getChannelsColumns = ({ title: t('状态'), dataIndex: 'status', render: (text, record, index) => { + let statusNode; if (text === 3) { if (record.other_info === '') { record.other_info = '{}'; @@ -500,7 +501,7 @@ export const getChannelsColumns = ({ let otherInfo = JSON.parse(record.other_info); let reason = otherInfo['status_reason']; let time = otherInfo['status_time']; - return ( + statusNode = (
); } else { - return renderStatus(text, record.channel_info, t); + statusNode = renderStatus(text, record.channel_info, t); } + + // 冷却中:与启用/禁用状态正交,额外显示原因和恢复时间 + if (record.cooling_down) { + const expires = record.cooldown_expires || 0; + const remainingMin = Math.max( + 0, + Math.ceil((expires * 1000 - Date.now()) / 60000), + ); + return ( +
+ {statusNode} + + + {t('冷却中')} {remainingMin}m + + +
+ ); + } + + return statusNode; }, }, { diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json index 17511d2a5552..5aa712501ce2 100644 --- a/web/classic/src/i18n/locales/en.json +++ b/web/classic/src/i18n/locales/en.json @@ -895,6 +895,8 @@ "原价": "Original price", "原价,和普通用户一样": "original price, same as regular users", "原因:": "Reason: ", + ",恢复时间:": ", recovers at: ", + "冷却中": "Cooling down", "原密码": "Original Password", "原生格式": "Native format", "原生额度": "Raw quota", diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index 8747c862a85b..4dd4ce80d8e5 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -831,7 +831,7 @@ export function useChannelsColumns(): ColumnDef[] { } } - return ( + const statusBadge = ( [] { copyable={false} /> ) + + // Cooling down: orthogonal to enable/disable status. Show a warning + // badge with the reason and remaining time so admins can see why a + // channel was temporarily skipped during selection. + if (channel.cooling_down) { + const expires = channel.cooldown_expires ?? 0 + const remainingMin = Math.max( + 0, + Math.ceil((expires * 1000 - Date.now()) / 60000), + ) + return ( + + + }> + + {statusBadge} + + + + +
+ {channel.cooldown_reason && ( +
+ {t('Reason:')} {channel.cooldown_reason} +
+ )} + {expires > 0 && ( +
+ {t('Recovers at:')} {formatTimestampToDate(expires)} +
+ )} +
+
+
+
+ ) + } + + return statusBadge }, filterFn: (row, id, value) => { if (!value || value.length === 0 || value.includes('all')) return true diff --git a/web/default/src/features/channels/types.ts b/web/default/src/features/channels/types.ts index a282053a3a95..541193ab6fa4 100644 --- a/web/default/src/features/channels/types.ts +++ b/web/default/src/features/channels/types.ts @@ -71,6 +71,10 @@ export const channelSchema = z.object({ multi_key_mode: 'random', }), settings: z.string().default('{}'), // other_settings JSON + // transient cooldown status (in-memory, per instance) + cooling_down: z.boolean().default(false), + cooldown_reason: z.string().nullish(), + cooldown_expires: z.number().nullish(), // unix seconds }) export type Channel = z.infer diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 116c8e503e40..deeeee91a769 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -923,6 +923,7 @@ "Content width": "内容宽度", "Context": "上下文", "Continue": "继续", + "Cooling down": "冷却中", "Continue with {{name}}": "使用 {{name}} 继续", "Continue with Discord": "使用 Discord 继续", "Continue with GitHub": "使用 GitHub 继续", @@ -3188,6 +3189,7 @@ "Real exchange rate between USD and your payment gateway currency": "美元与您的支付网关货币之间的实际汇率", "Reason": "原因", "Reason:": "原因:", + "Recovers at:": "恢复时间:", "Reasoning": "推理", "Reasoning Effort": "推理强度", "Receive Upstream Model Update Notifications": "接收上游模型更新通知", From 05ebed1f8b2a46f08af4e4705a8e4c7c1f9b4c64 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Fri, 29 May 2026 18:47:53 +0800 Subject: [PATCH 41/45] fix(relay): cool retried and slow channels for 30m Two additions so misbehaving channels leave the selection pool faster: - Any error that sends the request to retry another channel now cools the failing channel for the full ChannelCooldownDuration (30m). isRetryableChannelError mirrors shouldRetry's error classification (channel errors, retryable status codes, excluding skip-retry/pinned/client 4xx). This subsumes the previous 15m upstream-5xx / capability-4xx path for retryable cases; non-retryable cool-worthy errors (skip-retry malformed bodies) still use the 15m path. - A request that ultimately succeeds but whose first-response-time exceeds SlowChannelFRTThreshold (30s) cools the channel 30m. FRT (not total elapsed) is used so large prompts / high-reasoning requests that stream promptly are not punished; pinned-channel and non-streamed requests without a measured first response are skipped. --- controller/relay.go | 61 +++++++++++++++++++++++ controller/relay_retryable_test.go | 80 ++++++++++++++++++++++++++++++ service/channel_cooldown.go | 26 ++++++++++ service/channel_cooldown_test.go | 40 +++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 controller/relay_retryable_test.go diff --git a/controller/relay.go b/controller/relay.go index 365cdd529ed3..ddb9b857df78 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -222,6 +222,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { if newAPIError == nil { relayInfo.LastError = nil + cooldownSlowChannelIfNeeded(c, relayInfo, channel) return } @@ -353,12 +354,72 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b return operation_setting.ShouldRetryByStatusCode(code) } +// cooldownSlowChannelIfNeeded cools a channel after a successful request whose +// first-response-time exceeded SlowChannelFRTThreshold. FRT is only meaningful +// when the handler actually recorded a first response; pinned (specific) channel +// requests are skipped since they bypass selection anyway. +func cooldownSlowChannelIfNeeded(c *gin.Context, info *relaycommon.RelayInfo, channel *model.Channel) { + if info == nil || channel == nil { + return + } + if _, ok := c.Get("specific_channel_id"); ok { + return + } + if !info.HasSendResponse() { + return + } + frt := info.FirstResponseTime.Sub(info.StartTime) + if frt < service.SlowChannelFRTThreshold { + return + } + channelError := types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()) + service.CooldownSlowChannel(*channelError, frt) +} + +// isRetryableChannelError reports whether the error would cause the relay loop +// to retry on another channel. It mirrors shouldRetry's error classification +// but omits the remaining-retry-count gate, so a channel is still recognized as +// "caused a retry" even on the final attempt. Used to decide cooldown. +func isRetryableChannelError(c *gin.Context, openaiErr *types.NewAPIError) bool { + if openaiErr == nil { + return false + } + if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { + return false + } + if types.IsChannelError(openaiErr) { + return true + } + if types.IsSkipRetryError(openaiErr) { + return false + } + if _, ok := c.Get("specific_channel_id"); ok { + return false + } + code := openaiErr.StatusCode + if code >= 200 && code < 300 { + return false + } + if code < 100 || code > 599 { + return true + } + if operation_setting.IsAlwaysSkipRetryCode(openaiErr.GetErrorCode()) { + return false + } + return operation_setting.ShouldRetryByStatusCode(code) +} + func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) { logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error())) // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况 // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously if service.ShouldCooldownChannel(err) { service.CooldownChannel(channelError, err) + } else if isRetryableChannelError(c, err) { + // Any error that would send the request to retry another channel means + // this channel misbehaved; cool it for the full duration so it stops + // being re-picked. This subsumes upstream 5xx / capability 4xx. + service.CooldownChannelForRetry(channelError, err) } else if service.ShouldCooldownChannelForUpstreamError(err) { service.CooldownChannelForUpstreamError(channelError, err) } diff --git a/controller/relay_retryable_test.go b/controller/relay_retryable_test.go new file mode 100644 index 000000000000..e6c3ec02a3d3 --- /dev/null +++ b/controller/relay_retryable_test.go @@ -0,0 +1,80 @@ +package controller + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +func newTestContext() *gin.Context { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + return c +} + +func TestIsRetryableChannelError(t *testing.T) { + cases := []struct { + name string + err *types.NewAPIError + want bool + }{ + { + name: "upstream 503 retryable", + err: types.NewErrorWithStatusCode(errors.New("no available accounts"), types.ErrorCodeBadResponseStatusCode, http.StatusServiceUnavailable), + want: true, + }, + { + name: "upstream 502 retryable", + err: types.NewErrorWithStatusCode(errors.New("bad gateway"), types.ErrorCodeBadResponseStatusCode, http.StatusBadGateway), + want: true, + }, + { + name: "capability 403 retryable", + err: types.NewErrorWithStatusCode(errors.New("Image generation is not enabled for this group"), types.ErrorCodeBadResponseStatusCode, http.StatusForbidden), + want: true, + }, + { + name: "internal 500 retryable", + err: types.NewErrorWithStatusCode(errors.New("boom"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError), + want: true, + }, + { + name: "client 400 not retryable", + err: types.NewErrorWithStatusCode(errors.New("invalid request"), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()), + want: false, + }, + { + name: "success 200 not retryable", + err: types.NewErrorWithStatusCode(errors.New("ok"), types.ErrorCodeBadResponseStatusCode, http.StatusOK), + want: false, + }, + { + name: "nil error not retryable", + err: nil, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := newTestContext() + if got := isRetryableChannelError(c, tc.err); got != tc.want { + t.Fatalf("isRetryableChannelError(%s) = %v, want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestIsRetryableChannelErrorSkipsSpecificChannel(t *testing.T) { + c := newTestContext() + c.Set("specific_channel_id", 5) + err := types.NewErrorWithStatusCode(errors.New("bad gateway"), types.ErrorCodeBadResponseStatusCode, http.StatusBadGateway) + if isRetryableChannelError(c, err) { + t.Fatalf("expected pinned specific channel to skip retry classification") + } +} diff --git a/service/channel_cooldown.go b/service/channel_cooldown.go index 6189425fbd12..472c60ce5f63 100644 --- a/service/channel_cooldown.go +++ b/service/channel_cooldown.go @@ -13,6 +13,12 @@ import ( const ( ChannelCooldownDuration = 30 * time.Minute UpstreamErrorCooldownDuration = 15 * time.Minute + // SlowChannelFRTThreshold is the first-response-time (time to first token) + // above which an otherwise-successful request is treated as an unstably-slow + // upstream and the channel is cooled down. FRT (not total elapsed) is used so + // that large prompts / high-reasoning requests, which are legitimately slow to + // finish but still start streaming promptly, are not punished. + SlowChannelFRTThreshold = 30 * time.Second ) var channelCooldownKeywords = []string{ @@ -122,3 +128,23 @@ func CooldownChannelForUpstreamError(channelError types.ChannelError, err *types common.SysLog(fmt.Sprintf("通道冷却:#%d,持续 %s,原因:%s", channelError.ChannelId, UpstreamErrorCooldownDuration, reason)) model.CooldownChannel(channelError.ChannelId, reason, UpstreamErrorCooldownDuration) } + +// CooldownChannelForRetry cools a channel for the full ChannelCooldownDuration +// whenever it failed in a way that triggered a retry to another channel. The +// caller (relay loop) decides retryability; this just records the cooldown so a +// misbehaving channel is taken out of selection quickly instead of being +// re-picked on subsequent requests. +func CooldownChannelForRetry(channelError types.ChannelError, err *types.NewAPIError) { + reason := fmt.Sprintf("retryable_error status=%d code=%s type=%s error=%s", err.StatusCode, err.GetErrorCode(), err.GetErrorType(), err.Error()) + common.SysLog(fmt.Sprintf("通道冷却:#%d,持续 %s,原因:%s", channelError.ChannelId, ChannelCooldownDuration, reason)) + model.CooldownChannel(channelError.ChannelId, reason, ChannelCooldownDuration) +} + +// CooldownSlowChannel cools a channel for the full ChannelCooldownDuration when +// an otherwise-successful request had a first-response-time above +// SlowChannelFRTThreshold, i.e. the upstream is up but unstably slow. +func CooldownSlowChannel(channelError types.ChannelError, frt time.Duration) { + reason := fmt.Sprintf("slow_upstream first_token=%s threshold=%s", frt.Round(time.Millisecond), SlowChannelFRTThreshold) + common.SysLog(fmt.Sprintf("通道冷却:#%d,持续 %s,原因:%s", channelError.ChannelId, ChannelCooldownDuration, reason)) + model.CooldownChannel(channelError.ChannelId, reason, ChannelCooldownDuration) +} diff --git a/service/channel_cooldown_test.go b/service/channel_cooldown_test.go index 7089d1901fba..2111ed37739a 100644 --- a/service/channel_cooldown_test.go +++ b/service/channel_cooldown_test.go @@ -3,11 +3,51 @@ package service import ( "errors" "net/http" + "strings" "testing" + "time" + "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/types" ) +func TestCooldownChannelForRetryCoolsFullDuration(t *testing.T) { + model.ClearChannelCooldownsForTest() + chErr := types.NewChannelError(9001, 1, "test", false, "", true) + err := types.NewErrorWithStatusCode(errors.New("bad response status code 500"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError) + + CooldownChannelForRetry(*chErr, err) + + reason, expires, cooling := model.GetChannelCooldown(9001) + if !cooling { + t.Fatalf("expected retryable error to cool the channel") + } + if !strings.Contains(reason, "retryable_error") { + t.Fatalf("expected retryable_error reason, got %q", reason) + } + if remaining := time.Until(time.Unix(expires, 0)); remaining < 29*time.Minute || remaining > 31*time.Minute { + t.Fatalf("expected ~30m cooldown, got %s", remaining) + } +} + +func TestCooldownSlowChannelCoolsFullDuration(t *testing.T) { + model.ClearChannelCooldownsForTest() + chErr := types.NewChannelError(9002, 1, "test", false, "", true) + + CooldownSlowChannel(*chErr, 42*time.Second) + + reason, expires, cooling := model.GetChannelCooldown(9002) + if !cooling { + t.Fatalf("expected slow channel to be cooled") + } + if !strings.Contains(reason, "slow_upstream") { + t.Fatalf("expected slow_upstream reason, got %q", reason) + } + if remaining := time.Until(time.Unix(expires, 0)); remaining < 29*time.Minute || remaining > 31*time.Minute { + t.Fatalf("expected ~30m cooldown, got %s", remaining) + } +} + func TestShouldCooldownChannelForBalanceError(t *testing.T) { err := types.NewErrorWithStatusCode(errors.New("Insufficient account balance"), types.ErrorCodeBadResponseStatusCode, http.StatusForbidden) From 8bda8ba4baad0820cb1a03a6ccb7939ec2326abb Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Fri, 29 May 2026 21:28:18 +0800 Subject: [PATCH 42/45] fix(relay): short cooldown for transient 5xx, keep 30m for capability gaps The blanket 30m cooldown on any retryable error sidelined channels that only briefly blipped (a single upstream 5xx) for the full duration, which hurts recovery when the whole pool is flapping. Split CooldownChannelForRetry: transient retryable failures (mostly 5xx) now cool for ShortChannelCooldownDuration (5m) so a recovered channel rejoins rotation fast, while structural per-channel capability gaps (e.g. image generation disabled) still cool 30m since a quick retry won't fix them. Balance/quota (30m) and slow-channel (30m) unchanged. Extracted isCapabilityError and reused it in ShouldCooldownChannelForUpstreamError. --- service/channel_cooldown.go | 41 ++++++++++++++++++++++++++------ service/channel_cooldown_test.go | 27 +++++++++++++++++---- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/service/channel_cooldown.go b/service/channel_cooldown.go index 472c60ce5f63..9f32813f5ce6 100644 --- a/service/channel_cooldown.go +++ b/service/channel_cooldown.go @@ -13,6 +13,10 @@ import ( const ( ChannelCooldownDuration = 30 * time.Minute UpstreamErrorCooldownDuration = 15 * time.Minute + // ShortChannelCooldownDuration is used for transient retryable failures + // (mostly upstream 5xx). Kept short so a channel that only blipped returns + // to rotation quickly instead of being sidelined for the full duration. + ShortChannelCooldownDuration = 5 * time.Minute // SlowChannelFRTThreshold is the first-response-time (time to first token) // above which an otherwise-successful request is treated as an unstably-slow // upstream and the channel is cooled down. FRT (not total elapsed) is used so @@ -65,6 +69,22 @@ var upstreamErrorCooldownKeywords = []string{ "read/write", } +// isCapabilityError reports whether the error is a per-channel capability gap +// (e.g. the upstream group has image generation disabled). These won't recover +// on a quick retry, so they warrant a full-duration cooldown. +func isCapabilityError(err *types.NewAPIError) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error() + " " + string(err.GetErrorCode()) + " " + string(err.GetErrorType())) + for _, keyword := range capabilityCooldownKeywords { + if strings.Contains(message, keyword) { + return true + } + } + return false +} + func ShouldCooldownChannel(err *types.NewAPIError) bool { if err == nil { return false @@ -87,10 +107,8 @@ func ShouldCooldownChannelForUpstreamError(err *types.NewAPIError) bool { // limitation for this request type, not the client's. Cool them so retries // and later requests skip the channel instead of thrashing it. Checked // before the 4xx gate below, which would otherwise skip them. - for _, keyword := range capabilityCooldownKeywords { - if strings.Contains(message, keyword) { - return true - } + if isCapabilityError(err) { + return true } if err.StatusCode >= 400 && err.StatusCode < 500 { return false @@ -135,9 +153,18 @@ func CooldownChannelForUpstreamError(channelError types.ChannelError, err *types // misbehaving channel is taken out of selection quickly instead of being // re-picked on subsequent requests. func CooldownChannelForRetry(channelError types.ChannelError, err *types.NewAPIError) { - reason := fmt.Sprintf("retryable_error status=%d code=%s type=%s error=%s", err.StatusCode, err.GetErrorCode(), err.GetErrorType(), err.Error()) - common.SysLog(fmt.Sprintf("通道冷却:#%d,持续 %s,原因:%s", channelError.ChannelId, ChannelCooldownDuration, reason)) - model.CooldownChannel(channelError.ChannelId, reason, ChannelCooldownDuration) + // Transient retryable failures (mostly upstream 5xx) cool briefly so a + // recovered channel rejoins rotation fast; structural capability gaps cool + // for the full duration since a quick retry won't fix them. + duration := ShortChannelCooldownDuration + class := "retryable_transient" + if isCapabilityError(err) { + duration = ChannelCooldownDuration + class = "capability_gap" + } + reason := fmt.Sprintf("%s status=%d code=%s type=%s error=%s", class, err.StatusCode, err.GetErrorCode(), err.GetErrorType(), err.Error()) + common.SysLog(fmt.Sprintf("通道冷却:#%d,持续 %s,原因:%s", channelError.ChannelId, duration, reason)) + model.CooldownChannel(channelError.ChannelId, reason, duration) } // CooldownSlowChannel cools a channel for the full ChannelCooldownDuration when diff --git a/service/channel_cooldown_test.go b/service/channel_cooldown_test.go index 2111ed37739a..c643ae497aae 100644 --- a/service/channel_cooldown_test.go +++ b/service/channel_cooldown_test.go @@ -11,7 +11,7 @@ import ( "github.com/QuantumNous/new-api/types" ) -func TestCooldownChannelForRetryCoolsFullDuration(t *testing.T) { +func TestCooldownChannelForRetryUsesShortDurationFor5xx(t *testing.T) { model.ClearChannelCooldownsForTest() chErr := types.NewChannelError(9001, 1, "test", false, "", true) err := types.NewErrorWithStatusCode(errors.New("bad response status code 500"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError) @@ -20,10 +20,29 @@ func TestCooldownChannelForRetryCoolsFullDuration(t *testing.T) { reason, expires, cooling := model.GetChannelCooldown(9001) if !cooling { - t.Fatalf("expected retryable error to cool the channel") + t.Fatalf("expected retryable 5xx error to cool the channel") } - if !strings.Contains(reason, "retryable_error") { - t.Fatalf("expected retryable_error reason, got %q", reason) + if !strings.Contains(reason, "retryable_transient") { + t.Fatalf("expected retryable_transient reason, got %q", reason) + } + if remaining := time.Until(time.Unix(expires, 0)); remaining < 4*time.Minute || remaining > 6*time.Minute { + t.Fatalf("expected ~5m short cooldown, got %s", remaining) + } +} + +func TestCooldownChannelForRetryUsesFullDurationForCapabilityGap(t *testing.T) { + model.ClearChannelCooldownsForTest() + chErr := types.NewChannelError(9003, 1, "test", false, "", true) + err := types.NewErrorWithStatusCode(errors.New("Image generation is not enabled for this group"), types.ErrorCodeBadResponseStatusCode, http.StatusForbidden) + + CooldownChannelForRetry(*chErr, err) + + reason, expires, cooling := model.GetChannelCooldown(9003) + if !cooling { + t.Fatalf("expected capability gap to cool the channel") + } + if !strings.Contains(reason, "capability_gap") { + t.Fatalf("expected capability_gap reason, got %q", reason) } if remaining := time.Until(time.Unix(expires, 0)); remaining < 29*time.Minute || remaining > 31*time.Minute { t.Fatalf("expected ~30m cooldown, got %s", remaining) From 9e87b3d70775ab2d956eebcb4f7bd7b348279116 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Mon, 13 Jul 2026 15:48:37 +0800 Subject: [PATCH 43/45] fix: harden production relay failures Fix stream cancellation and empty-stream handling, bind upstream requests to client contexts, preserve zero-token fixed pricing and tool surcharges, normalize context-limit retries, secure model fetching, and support Claude file content.\n\nCo-Authored-By: Claude --- controller/channel.go | 40 +++++++--- controller/relay.go | 14 +++- controller/relay_retryable_test.go | 16 ++++ relay/channel/api_request.go | 6 +- relay/channel/claude/relay-claude.go | 56 +++++++++++++ relay/channel/openai/chat_via_responses.go | 1 + relay/channel/openai/relay-openai.go | 2 +- .../openai/relay_openai_stream_claude_test.go | 11 +++ relay/helper/stream_scanner.go | 80 ++++++++++++------- relay/helper/stream_scanner_test.go | 16 +++- service/log_info_generate.go | 4 +- service/text_quota.go | 16 ++-- service/text_quota_test.go | 44 ++++++++++ 13 files changed, 254 insertions(+), 52 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index fe8d712181ea..da7b214b8453 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "strconv" "strings" @@ -12,11 +13,13 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relaychannel "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/gemini" "github.com/QuantumNous/new-api/relay/channel/ollama" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -1071,10 +1074,27 @@ func FetchModels(c *gin.Context) { return } - client := &http.Client{} + client := service.GetHttpClient() + if client == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "success": false, + "message": "HTTP client is not initialized", + }) + return + } url := fmt.Sprintf("%s/v1/models", baseURL) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(url, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": fmt.Sprintf("Invalid models URL: %s", err.Error()), + }) + return + } - request, err := http.NewRequest("GET", url, nil) + requestCtx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, url, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -1087,29 +1107,31 @@ func FetchModels(c *gin.Context) { response, err := client.Do(request) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ + logger.LogError(c, "failed to fetch models: "+err.Error()) + c.JSON(http.StatusBadGateway, gin.H{ "success": false, - "message": err.Error(), + "message": "Failed to fetch models", }) return } - //check status code + defer response.Body.Close() if response.StatusCode != http.StatusOK { - c.JSON(http.StatusInternalServerError, gin.H{ + c.JSON(http.StatusBadGateway, gin.H{ "success": false, - "message": "Failed to fetch models", + "message": fmt.Sprintf("Failed to fetch models: upstream status %d", response.StatusCode), }) return } - defer response.Body.Close() + const maxModelsResponseBytes = 5 << 20 + limitedBody := io.LimitReader(response.Body, maxModelsResponseBytes+1) var result struct { Data []struct { ID string `json:"id"` } `json:"data"` } - if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + if err := json.NewDecoder(limitedBody).Decode(&result); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": err.Error(), diff --git a/controller/relay.go b/controller/relay.go index ddb9b857df78..42ef3ae3f2dc 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -322,6 +322,16 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service return channel, nil } +func isSemanticClientError(openaiErr *types.NewAPIError) bool { + if openaiErr == nil { + return false + } + message := strings.ToLower(openaiErr.Error()) + return strings.Contains(message, "exceeds the context window") || + strings.Contains(message, "context length exceeded") || + strings.Contains(message, "maximum context length") +} + func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) bool { if openaiErr == nil { return false @@ -332,7 +342,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b if types.IsChannelError(openaiErr) { return true } - if types.IsSkipRetryError(openaiErr) { + if types.IsSkipRetryError(openaiErr) || isSemanticClientError(openaiErr) { return false } if retryTimes <= 0 { @@ -390,7 +400,7 @@ func isRetryableChannelError(c *gin.Context, openaiErr *types.NewAPIError) bool if types.IsChannelError(openaiErr) { return true } - if types.IsSkipRetryError(openaiErr) { + if types.IsSkipRetryError(openaiErr) || isSemanticClientError(openaiErr) { return false } if _, ok := c.Get("specific_channel_id"); ok { diff --git a/controller/relay_retryable_test.go b/controller/relay_retryable_test.go index e6c3ec02a3d3..b4b283924364 100644 --- a/controller/relay_retryable_test.go +++ b/controller/relay_retryable_test.go @@ -70,6 +70,22 @@ func TestIsRetryableChannelError(t *testing.T) { } } +func TestShouldRetryStopsOnSemanticContextLimitError(t *testing.T) { + c := newTestContext() + err := types.NewErrorWithStatusCode( + errors.New("Your input exceeds the context window of this model. Please adjust your input and try again."), + types.ErrorCodeBadResponseStatusCode, + http.StatusBadGateway, + ) + + if shouldRetry(c, err, 2) { + t.Fatal("expected context-window errors to stop retrying even when upstream reports 502") + } + if isRetryableChannelError(c, err) { + t.Fatal("expected context-window errors not to trigger transient channel cooldown") + } +} + func TestIsRetryableChannelErrorSkipsSpecificChannel(t *testing.T) { c := newTestContext() c.Set("specific_channel_id", 5) diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index f945a8383821..aa298514caf7 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -310,7 +310,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody return nil, fmt.Errorf("get request url failed: %w", err) } logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL) - req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, fullRequestURL, requestBody) if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } @@ -340,7 +340,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod return nil, fmt.Errorf("get request url failed: %w", err) } logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL) - req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, fullRequestURL, requestBody) if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } @@ -537,7 +537,7 @@ func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, req if err != nil { return nil, err } - req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, fullRequestURL, requestBody) if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 046ccfe681a0..85cf384f8f6e 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -1,10 +1,12 @@ package claude import ( + "encoding/base64" "encoding/json" "fmt" "io" "net/http" + "path/filepath" "strings" "github.com/QuantumNous/new-api/common" @@ -44,6 +46,52 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) { } } +func createClaudeFileSource(file *dto.MessageFile) types.FileSource { + if file == nil || file.FileData == "" { + return nil + } + + mimeType := service.GetMimeTypeByExtension(strings.TrimPrefix(strings.ToLower(filepath.Ext(file.FileName)), ".")) + if mimeType == "application/octet-stream" { + return nil + } + return types.NewFileSourceFromData(file.FileData, mimeType) +} + +func buildClaudeFileMessage(c *gin.Context, file *dto.MessageFile) (*dto.ClaudeMediaMessage, error) { + source := createClaudeFileSource(file) + if source == nil { + return nil, nil + } + base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting document for Claude") + if err != nil { + return nil, fmt.Errorf("get file data failed: %w", err) + } + + switch strings.ToLower(mimeType) { + case "application/pdf": + return &dto.ClaudeMediaMessage{ + Type: "document", + Source: &dto.ClaudeMessageSource{ + Type: "base64", + MediaType: mimeType, + Data: base64Data, + }, + }, nil + case "text/plain": + textData, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return nil, fmt.Errorf("decode text file data failed: %w", err) + } + return &dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer(string(textData)), + }, nil + default: + return nil, nil + } +} + func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) { claudeTools := make([]any, 0, len(textRequest.Tools)) @@ -376,6 +424,14 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe Text: common.GetPointer[string](mediaMessage.Text), }) } + case dto.ContentTypeFile: + claudeFileMessage, err := buildClaudeFileMessage(c, mediaMessage.GetFile()) + if err != nil { + return nil, err + } + if claudeFileMessage != nil { + claudeMediaMessages = append(claudeMediaMessages, *claudeFileMessage) + } default: source := mediaMessage.ToFileSource() if source == nil { diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go index 01937c315f29..805b0994a683 100644 --- a/relay/channel/openai/chat_via_responses.go +++ b/relay/channel/openai/chat_via_responses.go @@ -598,6 +598,7 @@ func OaiResponsesSSEToChatJSON(c *gin.Context, info *relaycommon.RelayInfo, resp var streamResp dto.ResponsesStreamResponse if err := common.UnmarshalJsonStr(data, &streamResp); err != nil { logger.LogError(c, "failed to unmarshal responses stream event: "+err.Error()) + sr.Stop(err) return } diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index ffe87e4cc297..4aa33efb60e7 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -170,7 +170,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re &containStreamUsage, info, &shouldSendLastResp) if lastResponseErr != nil { logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", lastResponseErr.Error(), lastStreamData)) - if info.RelayFormat != types.RelayFormatOpenAI { + if lastStreamData == "" || info.RelayFormat != types.RelayFormatOpenAI { if responseTextBuilder.Len() == 0 && toolCount == 0 { return nil, types.NewOpenAIError(lastResponseErr, types.ErrorCodeBadResponseBody, http.StatusBadGateway) } diff --git a/relay/channel/openai/relay_openai_stream_claude_test.go b/relay/channel/openai/relay_openai_stream_claude_test.go index 62e3d8ee3086..67c3717c153f 100644 --- a/relay/channel/openai/relay_openai_stream_claude_test.go +++ b/relay/channel/openai/relay_openai_stream_claude_test.go @@ -48,6 +48,17 @@ func setupOpenAICompatibleClaudeStreamTest(t *testing.T, body string) (*gin.Cont return c, resp, info, recorder } +func TestOaiStreamHandlerOpenAICompatibleReturnsErrorOnEmptyStream(t *testing.T) { + c, resp, info, _ := setupOpenAICompatibleClaudeStreamTest(t, "") + info.RelayFormat = types.RelayFormatOpenAI + + usage, err := OaiStreamHandler(c, info, resp) + + require.Nil(t, usage) + require.NotNil(t, err) + require.Equal(t, types.ErrorCodeBadResponseBody, err.GetErrorCode()) +} + func TestOaiStreamHandlerClaudeCompatibleReturnsErrorOnEmptyStream(t *testing.T) { c, resp, info, _ := setupOpenAICompatibleClaudeStreamTest(t, "") diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index 033f3b022d2c..3a7d4d28dfca 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -64,7 +64,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon } var ( - stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞 + stopChan = make(chan bool, 1) scanner = bufio.NewScanner(resp.Body) ticker = time.NewTicker(streamingTimeout) pingTicker *time.Ticker @@ -89,40 +89,44 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon logger.LogDebug(c, "streaming timeout seconds: %d", int64(streamingTimeout.Seconds())) logger.LogDebug(c, "ping interval seconds: %d", int64(pingInterval.Seconds())) - // 改进资源清理,确保所有 goroutine 正确退出 - defer func() { - // 通知所有 goroutine 停止 - common.SafeSendBool(stopChan, true) + scanner.Buffer(make([]byte, InitialScannerBufferSize), getScannerBufferSize()) + scanner.Split(bufio.ScanLines) + SetEventStreamHeaders(c) + + ctx, cancel := context.WithCancel(c.Request.Context()) + defer cancel() + + ctx = context.WithValue(ctx, "stop_chan", stopChan) + signalStop := func() { + select { + case stopChan <- true: + default: + } + } + stopWorkers := func() { + cancel() + if resp.Body != nil { + _ = resp.Body.Close() + } ticker.Stop() if pingTicker != nil { pingTicker.Stop() } + } - // 等待所有 goroutine 退出,最多等待5秒 + waitForWorkers := func() { done := make(chan struct{}) gopool.Go(func() { wg.Wait() close(done) }) - select { case <-done: case <-time.After(5 * time.Second): logger.LogError(c, "timeout waiting for goroutines to exit") } - - close(stopChan) - }() - - scanner.Buffer(make([]byte, InitialScannerBufferSize), getScannerBufferSize()) - scanner.Split(bufio.ScanLines) - SetEventStreamHeaders(c) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - ctx = context.WithValue(ctx, "stop_chan", stopChan) + } // Handle ping data sending with improved error handling if pingEnabled && pingTicker != nil { @@ -133,7 +137,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon if r := recover(); r != nil { logger.LogError(c, fmt.Sprintf("ping goroutine panic: %v", r)) info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonPanic, fmt.Errorf("ping panic: %v", r), "ping_panic") - common.SafeSendBool(stopChan, true) + signalStop() } logger.LogDebug(c, "ping goroutine exited") }() @@ -196,7 +200,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon logger.LogError(c, fmt.Sprintf("data handler goroutine panic: %v", r)) info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonPanic, fmt.Errorf("handler panic: %v", r), "handler_panic") } - common.SafeSendBool(stopChan, true) + signalStop() }() sr := newStreamResult(info.StreamStatus) for data := range dataChan { @@ -220,7 +224,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon logger.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r)) info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonPanic, fmt.Errorf("scanner panic: %v", r), "scanner_panic") } - common.SafeSendBool(stopChan, true) + signalStop() logger.LogDebug(c, "scanner goroutine exited") }() @@ -241,14 +245,15 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon data := scanner.Text() logger.LogDebug(c, "stream scanner data: %s", data) - if len(data) < 6 { - continue + if strings.TrimSpace(data) == "[DONE]" { + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonDone, nil, "scanner_done") + logger.LogDebug(c, "received [DONE], stopping scanner") + return } - if data[:5] != "data:" && data[:6] != "[DONE]" { + if len(data) < 6 || data[:5] != "data:" { continue } - data = data[5:] - data = strings.TrimSpace(data) + data = strings.TrimSpace(data[5:]) if data == "" { continue } @@ -272,6 +277,15 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon } if err := scanner.Err(); err != nil { + select { + case <-ctx.Done(): + return + case <-stopChan: + return + case <-c.Request.Context().Done(): + return + default: + } if err != io.EOF { logger.LogError(c, "scanner error: "+err.Error()) info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonScannerErr, err, "scanner_error") @@ -290,9 +304,15 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonClientGone, c.Request.Context().Err(), "main_context_done") } - if info.StreamStatus.IsNormalEnd() && !info.StreamStatus.HasErrors() { - logger.LogInfo(c, fmt.Sprintf("stream ended: %s", info.StreamStatus.Summary())) + stopWorkers() + waitForWorkers() + + streamSummary := fmt.Sprintf("stream ended: %s", info.StreamStatus.Summary()) + if info.StreamStatus.Snapshot().EndReason == relaycommon.StreamEndReasonClientGone { + logger.LogInfo(c, streamSummary) + } else if info.StreamStatus.IsNormalEnd() && !info.StreamStatus.HasErrors() { + logger.LogInfo(c, streamSummary) } else { - logger.LogError(c, fmt.Sprintf("stream ended: %s, received=%d", info.StreamStatus.Summary(), info.ReceivedResponseCount)) + logger.LogError(c, fmt.Sprintf("%s, received=%d", streamSummary, info.ReceivedResponseCount)) } } diff --git a/relay/helper/stream_scanner_test.go b/relay/helper/stream_scanner_test.go index b27a0ead8dee..45957088a630 100644 --- a/relay/helper/stream_scanner_test.go +++ b/relay/helper/stream_scanner_test.go @@ -439,6 +439,17 @@ func TestStreamScannerHandler_PingDisabledByRelayInfo(t *testing.T) { // ---------- StreamStatus integration ---------- +func TestStreamScannerHandler_AcceptsRawDoneLine(t *testing.T) { + c, resp, info := setupStreamTest(t, strings.NewReader("[DONE]\n")) + + StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) { + t.Fatalf("raw [DONE] must not be dispatched as data: %q", data) + }) + + require.NotNil(t, info.StreamStatus) + assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.Snapshot().EndReason) +} + func TestStreamScannerHandler_StreamStatus_DoneReason(t *testing.T) { t.Parallel() @@ -568,6 +579,7 @@ func TestStreamScannerHandler_StreamStatus_ClientGoneBeforeUpstreamData(t *testi close(done) }() time.Sleep(10 * time.Millisecond) + start := time.Now() cancel() select { @@ -575,6 +587,7 @@ func TestStreamScannerHandler_StreamStatus_ClientGoneBeforeUpstreamData(t *testi case <-time.After(7 * time.Second): t.Fatal("timed out waiting for client_gone") } + assert.Less(t, time.Since(start), time.Second, "client disconnect should promptly stop the upstream scanner") require.NotNil(t, info.StreamStatus) assert.Equal(t, relaycommon.StreamEndReasonClientGone, info.StreamStatus.EndReason) @@ -656,7 +669,7 @@ func TestStreamScannerHandler_StreamStatus_ClientGoneAfterUpstreamData(t *testin case <-time.After(5 * time.Second): t.Fatal("timed out waiting for first data") } - time.Sleep(10 * time.Millisecond) + start := time.Now() cancel() select { @@ -664,6 +677,7 @@ func TestStreamScannerHandler_StreamStatus_ClientGoneAfterUpstreamData(t *testin case <-time.After(7 * time.Second): t.Fatal("timed out waiting for client_gone after data") } + assert.Less(t, time.Since(start), time.Second, "client disconnect should promptly stop the upstream scanner") require.NotNil(t, info.StreamStatus) assert.Equal(t, relaycommon.StreamEndReasonClientGone, info.StreamStatus.EndReason) diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 22066fcbcf4d..d5f7581890e5 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -97,7 +97,9 @@ func appendStreamStatus(relayInfo *relaycommon.RelayInfo, other map[string]inter ss := relayInfo.StreamStatus snapshot := ss.Snapshot() status := "ok" - if !ss.IsNormalEnd() || snapshot.ErrorCount > 0 { + if snapshot.EndReason == relaycommon.StreamEndReasonClientGone && snapshot.ErrorCount == 0 { + status = "canceled" + } else if !ss.IsNormalEnd() || snapshot.ErrorCount > 0 { status = "error" } streamInfo := map[string]interface{}{ diff --git a/service/text_quota.go b/service/text_quota.go index 3f344dc3e57b..de801515c5e0 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -300,8 +300,8 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf summary.Quota = int(quotaCalculateDecimal.Round(0).IntPart()) } - if summary.TotalTokens == 0 { - summary.Quota = 0 + if summary.TotalTokens == 0 && !relayInfo.PriceData.UsePrice { + summary.Quota = int(summary.ToolCallSurchargeQuota.Round(0).IntPart()) } else if !ratio.IsZero() && summary.Quota == 0 { summary.Quota = 1 } @@ -363,9 +363,15 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us } if summary.TotalTokens == 0 { - extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)") - logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, summary.ModelName, relayInfo.FinalPreConsumedQuota)) - } else { + extraContent = append(extraContent, "上游没有返回 Token 计费信息(可能是上游超时)") + message := fmt.Sprintf("total tokens is 0, userId %d, channelId %d, tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, summary.ModelName, relayInfo.FinalPreConsumedQuota) + if relayInfo.StreamStatus != nil && relayInfo.StreamStatus.Snapshot().EndReason == relaycommon.StreamEndReasonClientGone { + logger.LogInfo(ctx, message) + } else { + logger.LogError(ctx, message) + } + } + if summary.Quota > 0 { model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, summary.Quota) model.UpdateChannelUsedQuota(relayInfo.ChannelId, summary.Quota) } diff --git a/service/text_quota_test.go b/service/text_quota_test.go index 37ce1877482a..cb921c20890c 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -367,6 +367,50 @@ func TestComposeTieredTextQuotaKeepsToolCallSurcharges(t *testing.T) { require.Equal(t, 14000, quota) } +func TestCalculateTextQuotaSummaryKeepsFixedPriceWithZeroTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + + relayInfo := &relaycommon.RelayInfo{ + OriginModelName: "fixed-price-model", + PriceData: types.PriceData{ + UsePrice: true, + ModelPrice: 0.25, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + StartTime: time.Now(), + } + + summary := calculateTextQuotaSummary(ctx, relayInfo, &dto.Usage{}) + + require.Zero(t, summary.TotalTokens) + require.Equal(t, 125000, summary.Quota) +} + +func TestCalculateTextQuotaSummaryKeepsToolSurchargeWithZeroTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Set("claude_web_search_requests", 1) + + relayInfo := &relaycommon.RelayInfo{ + OriginModelName: "claude-3-7-sonnet", + PriceData: types.PriceData{ + ModelRatio: 1, + CompletionRatio: 1, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + StartTime: time.Now(), + } + + summary := calculateTextQuotaSummary(ctx, relayInfo, &dto.Usage{}) + + require.Zero(t, summary.TotalTokens) + require.Equal(t, int64(5000), summary.ToolCallSurchargeQuota.Round(0).IntPart()) + require.Equal(t, 5000, summary.Quota) +} + func TestComposeTieredTextQuotaFallbackKeepsToolCallSurcharges(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() From 07175433eaa7222fbde8d4823ce02d5e56cea599 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Mon, 13 Jul 2026 16:39:41 +0800 Subject: [PATCH 44/45] fix: fall back from unhealthy sticky channels Bound upstream response-header waits, allow transient affinity failures to use healthy alternatives, exclude attempted and cooling channels during retries, and make default CLI affinity rules soft.\n\nCo-Authored-By: Claude --- common/constants.go | 2 + common/init.go | 1 + controller/relay.go | 26 +++++--- controller/relay_retryable_test.go | 10 +++ middleware/distributor.go | 6 +- model/ability.go | 9 ++- model/channel_cache.go | 29 +++++++-- model/channel_selection_test.go | 61 +++++++++++++++++++ service/channel_affinity.go | 10 +-- service/channel_affinity_template_test.go | 4 +- service/channel_select.go | 21 ++++--- service/http_client.go | 25 ++++---- .../channel_affinity_setting.go | 4 +- 13 files changed, 159 insertions(+), 49 deletions(-) diff --git a/common/constants.go b/common/constants.go index c7d5637c8e9a..fdd6bfc03037 100644 --- a/common/constants.go +++ b/common/constants.go @@ -170,6 +170,8 @@ var BatchUpdateInterval int var RelayTimeout int // unit is second +var RelayResponseHeaderTimeout int // unit is second; timeout for receiving response headers from upstream + var RelayMaxIdleConns int var RelayMaxIdleConnsPerHost int diff --git a/common/init.go b/common/init.go index 35b4c6be17ee..19cb647a2529 100644 --- a/common/init.go +++ b/common/init.go @@ -102,6 +102,7 @@ func InitEnv() { SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60) BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5) RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0) + RelayResponseHeaderTimeout = GetEnvOrDefault("RELAY_RESPONSE_HEADER_TIMEOUT", 60) RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500) RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100) diff --git a/controller/relay.go b/controller/relay.go index 42ef3ae3f2dc..0e8a0e997b41 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -197,6 +197,10 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { } addUsedChannel(c, channel.Id) + if retryParam.ExcludedChannelIDs == nil { + retryParam.ExcludedChannelIDs = make(map[int]struct{}) + } + retryParam.ExcludedChannelIDs[channel.Id] = struct{}{} bodyStorage, bodyErr := common.GetBodyStorage(c) if bodyErr != nil { // Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path) @@ -336,15 +340,15 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b if openaiErr == nil { return false } - if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { + if types.IsSkipRetryError(openaiErr) || isSemanticClientError(openaiErr) { + return false + } + if service.ShouldSkipRetryAfterChannelAffinityFailure(c) && openaiErr.StatusCode < http.StatusInternalServerError { return false } if types.IsChannelError(openaiErr) { return true } - if types.IsSkipRetryError(openaiErr) || isSemanticClientError(openaiErr) { - return false - } if retryTimes <= 0 { return false } @@ -394,15 +398,15 @@ func isRetryableChannelError(c *gin.Context, openaiErr *types.NewAPIError) bool if openaiErr == nil { return false } - if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { + if types.IsSkipRetryError(openaiErr) || isSemanticClientError(openaiErr) { + return false + } + if service.ShouldSkipRetryAfterChannelAffinityFailure(c) && openaiErr.StatusCode < http.StatusInternalServerError { return false } if types.IsChannelError(openaiErr) { return true } - if types.IsSkipRetryError(openaiErr) || isSemanticClientError(openaiErr) { - return false - } if _, ok := c.Get("specific_channel_id"); ok { return false } @@ -612,6 +616,10 @@ func RelayTask(c *gin.Context) { } addUsedChannel(c, channel.Id) + if retryParam.ExcludedChannelIDs == nil { + retryParam.ExcludedChannelIDs = make(map[int]struct{}) + } + retryParam.ExcludedChannelIDs[channel.Id] = struct{}{} bodyStorage, bodyErr := common.GetBodyStorage(c) if bodyErr != nil { if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) { @@ -691,7 +699,7 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, if taskErr == nil { return false } - if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { + if service.ShouldSkipRetryAfterChannelAffinityFailure(c) && taskErr.StatusCode/100 != 5 { return false } if retryTimes <= 0 { diff --git a/controller/relay_retryable_test.go b/controller/relay_retryable_test.go index b4b283924364..6dfa18e3079a 100644 --- a/controller/relay_retryable_test.go +++ b/controller/relay_retryable_test.go @@ -70,6 +70,16 @@ func TestIsRetryableChannelError(t *testing.T) { } } +func TestShouldRetryAllowsTransientAffinityFailure(t *testing.T) { + c := newTestContext() + c.Set("channel_affinity_skip_retry_on_failure", true) + err := types.NewErrorWithStatusCode(errors.New("upstream unavailable"), types.ErrorCodeBadResponseStatusCode, http.StatusServiceUnavailable) + + if !shouldRetry(c, err, 1) { + t.Fatal("expected transient 5xx from a sticky channel to fall back") + } +} + func TestShouldRetryStopsOnSemanticContextLimitError(t *testing.T) { c := newTestContext() err := types.NewErrorWithStatusCode( diff --git a/middleware/distributor.go b/middleware/distributor.go index 75bbdab4dc60..74a5968b81e1 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -105,10 +105,8 @@ func Distribute() func(c *gin.Context) { preferred, err := model.CacheGetChannel(preferredChannelID) if err == nil && preferred != nil && !model.IsChannelCoolingDown(preferred.Id) { if preferred.Status != common.ChannelStatusEnabled { - if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { - abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorAffinityChannelDisabled)) - return - } + // Affinity channel is disabled, fall back to random selection + // Skip retry only applies if we actually used the affinity channel } else if usingGroup == "auto" { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) autoGroups := service.GetUserAutoGroup(userGroup) diff --git a/model/ability.go b/model/ability.go index 3f731cbee027..9af9c81afeab 100644 --- a/model/ability.go +++ b/model/ability.go @@ -105,6 +105,10 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) { } func GetChannel(group string, model string, retry int) (*Channel, error) { + return GetChannelWithOptions(group, model, retry, ChannelSelectionOptions{AllowCoolingFallback: true}) +} + +func GetChannelWithOptions(group string, model string, retry int, options ChannelSelectionOptions) (*Channel, error) { var abilities []Ability err := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true). @@ -118,13 +122,16 @@ func GetChannel(group string, model string, retry int) (*Channel, error) { availableAbilities := make([]Ability, 0, len(abilities)) coolingAbilities := make([]Ability, 0, len(abilities)) for _, ability := range abilities { + if _, excluded := options.ExcludedChannelIDs[ability.ChannelId]; excluded { + continue + } if IsChannelCoolingDown(ability.ChannelId) { coolingAbilities = append(coolingAbilities, ability) continue } availableAbilities = append(availableAbilities, ability) } - if len(availableAbilities) == 0 { + if len(availableAbilities) == 0 && options.AllowCoolingFallback { availableAbilities = coolingAbilities } if len(availableAbilities) == 0 { diff --git a/model/channel_cache.go b/model/channel_cache.go index 35291a8438de..0c8f9193b882 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -94,10 +94,19 @@ func SyncChannelCache(frequency int) { } } +type ChannelSelectionOptions struct { + ExcludedChannelIDs map[int]struct{} + AllowCoolingFallback bool +} + func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { + return GetRandomSatisfiedChannelWithOptions(group, model, retry, ChannelSelectionOptions{AllowCoolingFallback: true}) +} + +func GetRandomSatisfiedChannelWithOptions(group string, model string, retry int, options ChannelSelectionOptions) (*Channel, error) { // if memory cache is disabled, get channel directly from database if !common.MemoryCacheEnabled { - return GetChannel(group, model, retry) + return GetChannelWithOptions(group, model, retry, options) } channelSyncLock.RLock() @@ -117,10 +126,17 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, } if len(channels) == 1 { - if channel, ok := channelsIDM[channels[0]]; ok { - return channel, nil + channel, ok := channelsIDM[channels[0]] + if !ok { + return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0]) + } + if _, excluded := options.ExcludedChannelIDs[channel.Id]; excluded { + return nil, nil + } + if IsChannelCoolingDown(channel.Id) && !options.AllowCoolingFallback { + return nil, nil } - return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0]) + return channel, nil } availableChannels := make([]*Channel, 0, len(channels)) @@ -130,13 +146,16 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, if !ok { return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId) } + if _, excluded := options.ExcludedChannelIDs[channel.Id]; excluded { + continue + } if IsChannelCoolingDown(channel.Id) { coolingChannels = append(coolingChannels, channel) continue } availableChannels = append(availableChannels, channel) } - if len(availableChannels) == 0 { + if len(availableChannels) == 0 && options.AllowCoolingFallback { availableChannels = coolingChannels } diff --git a/model/channel_selection_test.go b/model/channel_selection_test.go index 0da66d491a47..aea2668e89b2 100644 --- a/model/channel_selection_test.go +++ b/model/channel_selection_test.go @@ -78,6 +78,67 @@ func TestGetChannelSkipsCoolingChannelWithoutMemoryCache(t *testing.T) { } } +func TestGetRandomSatisfiedChannelExcludesAttemptedChannelOnRetry(t *testing.T) { + oldMemoryCacheEnabled := common.MemoryCacheEnabled + common.MemoryCacheEnabled = true + ClearChannelCacheForTest() + clearChannelCooldownsForTest() + t.Cleanup(func() { + clearChannelCooldownsForTest() + ClearChannelCacheForTest() + common.MemoryCacheEnabled = oldMemoryCacheEnabled + }) + + priority := int64(10) + weight := uint(0) + failed := &Channel{Id: 17, Status: common.ChannelStatusEnabled, Weight: &weight, Priority: &priority} + healthy := &Channel{Id: 29, Status: common.ChannelStatusEnabled, Weight: &weight, Priority: &priority} + SetChannelCacheForTest(map[int]*Channel{17: failed, 29: healthy}, map[string]map[string][]int{ + "default": {"gpt-5.5": {17, 29}}, + }) + + selected, err := GetRandomSatisfiedChannelWithOptions("default", "gpt-5.5", 1, ChannelSelectionOptions{ + ExcludedChannelIDs: map[int]struct{}{17: {}}, + AllowCoolingFallback: false, + }) + if err != nil { + t.Fatalf("GetRandomSatisfiedChannelWithOptions returned error: %v", err) + } + if selected == nil || selected.Id != 29 { + t.Fatalf("expected unattempted channel 29, got %#v", selected) + } +} + +func TestGetRandomSatisfiedChannelDoesNotReuseCoolingChannelOnRetry(t *testing.T) { + oldMemoryCacheEnabled := common.MemoryCacheEnabled + common.MemoryCacheEnabled = true + ClearChannelCacheForTest() + clearChannelCooldownsForTest() + t.Cleanup(func() { + clearChannelCooldownsForTest() + ClearChannelCacheForTest() + common.MemoryCacheEnabled = oldMemoryCacheEnabled + }) + + priority := int64(10) + weight := uint(0) + channel := &Channel{Id: 17, Status: common.ChannelStatusEnabled, Weight: &weight, Priority: &priority} + SetChannelCacheForTest(map[int]*Channel{17: channel}, map[string]map[string][]int{ + "default": {"gpt-5.5": {17}}, + }) + CooldownChannel(17, "upstream timeout", time.Minute) + + selected, err := GetRandomSatisfiedChannelWithOptions("default", "gpt-5.5", 1, ChannelSelectionOptions{ + AllowCoolingFallback: false, + }) + if err != nil { + t.Fatalf("GetRandomSatisfiedChannelWithOptions returned error: %v", err) + } + if selected != nil { + t.Fatalf("expected no healthy retry channel, got %#v", selected) + } +} + func TestGetRandomSatisfiedChannelReturnsCoolingChannelWhenAllCandidatesCoolingWithMemoryCache(t *testing.T) { oldMemoryCacheEnabled := common.MemoryCacheEnabled common.MemoryCacheEnabled = true diff --git a/service/channel_affinity.go b/service/channel_affinity.go index 1b289d52be66..d65f814fc605 100644 --- a/service/channel_affinity.go +++ b/service/channel_affinity.go @@ -636,17 +636,11 @@ func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool { return false } v, ok := c.Get(ginKeyChannelAffinitySkipRetry) - if ok { - b, ok := v.(bool) - if ok { - return b - } - } - meta, ok := getChannelAffinityMeta(c) if !ok { return false } - return meta.SkipRetry + b, ok := v.(bool) + return ok && b } func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) { diff --git a/service/channel_affinity_template_test.go b/service/channel_affinity_template_test.go index 9698d8d32946..e5008bfa3f2e 100644 --- a/service/channel_affinity_template_test.go +++ b/service/channel_affinity_template_test.go @@ -144,7 +144,7 @@ func TestShouldSkipRetryAfterChannelAffinityFailure(t *testing.T) { want: true, }, { - name: "fallback to matched rule meta", + name: "matched rule without preferred channel use does not skip retry", ctx: func() *gin.Context { return buildChannelAffinityTemplateContextForTest(channelAffinityMeta{ RuleName: "rule-skip-retry", @@ -153,7 +153,7 @@ func TestShouldSkipRetryAfterChannelAffinityFailure(t *testing.T) { ModelName: "gpt-5", }) }, - want: true, + want: false, }, { name: "no flag and no skip retry meta", diff --git a/service/channel_select.go b/service/channel_select.go index a3710ef8cec3..2973f2ed5c13 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -12,11 +12,12 @@ import ( ) type RetryParam struct { - Ctx *gin.Context - TokenGroup string - ModelName string - Retry *int - resetNextTry bool + Ctx *gin.Context + TokenGroup string + ModelName string + Retry *int + ExcludedChannelIDs map[int]struct{} + resetNextTry bool } func (p *RetryParam) GetRetry() int { @@ -115,7 +116,10 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, } logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry) - channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry) + channel, _ = model.GetRandomSatisfiedChannelWithOptions(autoGroup, param.ModelName, priorityRetry, model.ChannelSelectionOptions{ + ExcludedChannelIDs: param.ExcludedChannelIDs, + AllowCoolingFallback: len(param.ExcludedChannelIDs) == 0, + }) if channel == nil { // Current group has no available channel for this model, try next group // 当前分组没有该模型的可用渠道,尝试下一个分组 @@ -153,7 +157,10 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, break } } else { - channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry()) + channel, err = model.GetRandomSatisfiedChannelWithOptions(param.TokenGroup, param.ModelName, param.GetRetry(), model.ChannelSelectionOptions{ + ExcludedChannelIDs: param.ExcludedChannelIDs, + AllowCoolingFallback: param.GetRetry() == 0, + }) if err != nil { return nil, param.TokenGroup, err } diff --git a/service/http_client.go b/service/http_client.go index 2c3168f24af9..ab4194ee9bdb 100644 --- a/service/http_client.go +++ b/service/http_client.go @@ -35,10 +35,11 @@ func checkRedirect(req *http.Request, via []*http.Request) error { func InitHttpClient() { transport := &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - ForceAttemptHTTP2: true, - Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars + MaxIdleConns: common.RelayMaxIdleConns, + MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, + ForceAttemptHTTP2: true, + Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars + ResponseHeaderTimeout: time.Duration(common.RelayResponseHeaderTimeout) * time.Second, } if common.TLSInsecureSkipVerify { transport.TLSClientConfig = common.InsecureTLSConfig @@ -106,10 +107,11 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { switch parsedURL.Scheme { case "http", "https": transport := &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - ForceAttemptHTTP2: true, - Proxy: http.ProxyURL(parsedURL), + MaxIdleConns: common.RelayMaxIdleConns, + MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, + ForceAttemptHTTP2: true, + Proxy: http.ProxyURL(parsedURL), + ResponseHeaderTimeout: time.Duration(common.RelayResponseHeaderTimeout) * time.Second, } if common.TLSInsecureSkipVerify { transport.TLSClientConfig = common.InsecureTLSConfig @@ -145,9 +147,10 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { } transport := &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - ForceAttemptHTTP2: true, + MaxIdleConns: common.RelayMaxIdleConns, + MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, + ForceAttemptHTTP2: true, + ResponseHeaderTimeout: time.Duration(common.RelayResponseHeaderTimeout) * time.Second, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.Dial(network, addr) }, diff --git a/setting/operation_setting/channel_affinity_setting.go b/setting/operation_setting/channel_affinity_setting.go index ed4fc2b2af58..7be6e5d9e9ac 100644 --- a/setting/operation_setting/channel_affinity_setting.go +++ b/setting/operation_setting/channel_affinity_setting.go @@ -89,7 +89,7 @@ var channelAffinitySetting = ChannelAffinitySetting{ ValueRegex: "", TTLSeconds: 0, ParamOverrideTemplate: buildPassHeaderTemplate(codexCliPassThroughHeaders), - SkipRetryOnFailure: true, + SkipRetryOnFailure: false, IncludeUsingGroup: true, IncludeRuleName: true, UserAgentInclude: nil, @@ -104,7 +104,7 @@ var channelAffinitySetting = ChannelAffinitySetting{ ValueRegex: "", TTLSeconds: 0, ParamOverrideTemplate: buildPassHeaderTemplate(claudeCliPassThroughHeaders), - SkipRetryOnFailure: true, + SkipRetryOnFailure: false, IncludeUsingGroup: true, IncludeRuleName: true, UserAgentInclude: nil, From a30f2f357798368210ff3fab6c86a5f795eaf414 Mon Sep 17 00:00:00 2001 From: chenlingzhi Date: Tue, 14 Jul 2026 11:52:23 +0800 Subject: [PATCH 45/45] fix: harden stream billing and affinity handling Propagate downstream stream write failures, make quota conversions saturating and observable, preserve RWMap state on decode errors, and retain Claude affinity billing headers. --- common/custom-event.go | 8 +- common/quota_math.go | 95 +++++++++++++++++++ common/quota_math_test.go | 64 +++++++++++++ controller/relay.go | 6 +- controller/relay_retryable_test.go | 12 +++ pkg/billingexpr/round.go | 19 +++- pkg/billingexpr/settle.go | 3 +- pkg/billingexpr/types.go | 11 ++- relay/channel/claude/relay-claude.go | 4 +- relay/channel/openai/helper.go | 6 +- relay/channel/openai/relay_responses.go | 18 +++- relay/channel/openai/responses_fallback.go | 30 +++--- .../channel/openai/responses_fallback_test.go | 15 ++- relay/common/relay_info.go | 3 + relay/helper/common.go | 39 ++++++-- relay/helper/common_disconnect_test.go | 26 +++++ relay/helper/price.go | 28 +++++- relay/helper/stream_scanner_test.go | 49 ++++++++++ relay/relay_task.go | 24 +++-- service/channel_affinity_template_test.go | 49 ++++++++++ service/log_info_generate.go | 4 + service/text_quota.go | 35 +++++-- service/text_quota_test.go | 60 ++++++++++++ service/tiered_settle.go | 1 + .../channel_affinity_setting.go | 2 + types/rw_map.go | 33 +++++-- types/rw_map_test.go | 67 +++++++++++++ 27 files changed, 636 insertions(+), 75 deletions(-) create mode 100644 common/quota_math.go create mode 100644 common/quota_math_test.go create mode 100644 relay/helper/common_disconnect_test.go create mode 100644 types/rw_map_test.go diff --git a/common/custom-event.go b/common/custom-event.go index 1bea2fd72b17..8759b5d52f37 100644 --- a/common/custom-event.go +++ b/common/custom-event.go @@ -63,9 +63,13 @@ func encode(writer io.Writer, event CustomEvent) error { } func writeData(w stringWriter, data interface{}) error { - dataReplacer.WriteString(w, fmt.Sprint(data)) + if _, err := dataReplacer.WriteString(w, fmt.Sprint(data)); err != nil { + return err + } if strings.HasPrefix(data.(string), "data") { - w.writeString("\n\n") + if _, err := w.writeString("\n\n"); err != nil { + return err + } } return nil } diff --git a/common/quota_math.go b/common/quota_math.go new file mode 100644 index 000000000000..39be578b8bc3 --- /dev/null +++ b/common/quota_math.go @@ -0,0 +1,95 @@ +package common + +import ( + "fmt" + "math" + "strconv" + + "github.com/shopspring/decimal" +) + +const ( + MaxQuota = math.MaxInt32 + MinQuota = math.MinInt32 +) + +type QuotaClampKind string + +const ( + QuotaClampOverflow QuotaClampKind = "overflow" + QuotaClampUnderflow QuotaClampKind = "underflow" + QuotaClampNaN QuotaClampKind = "nan" +) + +type QuotaClamp struct { + Op string `json:"op"` + Kind QuotaClampKind `json:"kind"` + Original string `json:"original"` + Clamped int `json:"clamped"` +} + +func (c *QuotaClamp) Error() string { + if c == nil { + return "" + } + return fmt.Sprintf("quota conversion (%s) %s: original=%s, clamped=%d", c.Op, c.Kind, c.Original, c.Clamped) +} + +func saturateQuota(value float64, op string) (int, *QuotaClamp) { + var clamp *QuotaClamp + switch { + case math.IsNaN(value): + clamp = &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: "NaN", Clamped: 0} + case value > MaxQuota: + clamp = &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: strconv.FormatFloat(value, 'g', -1, 64), Clamped: MaxQuota} + case value < MinQuota: + clamp = &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: strconv.FormatFloat(value, 'g', -1, 64), Clamped: MinQuota} + default: + return int(value), nil + } + return clamp.Clamped, clamp +} + +func QuotaFromFloat(value float64) int { + quota, _ := QuotaFromFloatChecked(value) + return quota +} + +func QuotaFromFloatChecked(value float64) (int, *QuotaClamp) { + return saturateQuota(value, "QuotaFromFloat") +} + +func QuotaFromFloatStrict(value float64) (int, error) { + quota, clamp := QuotaFromFloatChecked(value) + if clamp != nil { + return 0, clamp + } + return quota, nil +} + +func QuotaRound(value float64) int { + quota, _ := QuotaRoundChecked(value) + return quota +} + +func QuotaRoundChecked(value float64) (int, *QuotaClamp) { + return saturateQuota(math.Round(value), "QuotaRound") +} + +func QuotaRoundStrict(value float64) (int, error) { + quota, clamp := QuotaRoundChecked(value) + if clamp != nil { + return 0, clamp + } + return quota, nil +} + +func QuotaFromDecimal(value decimal.Decimal) int { + quota, _ := QuotaFromDecimalChecked(value) + return quota +} + +func QuotaFromDecimalChecked(value decimal.Decimal) (int, *QuotaClamp) { + rounded, _ := value.Round(0).Float64() + return saturateQuota(rounded, "QuotaFromDecimal") +} diff --git a/common/quota_math_test.go b/common/quota_math_test.go new file mode 100644 index 000000000000..f81dc6fcf316 --- /dev/null +++ b/common/quota_math_test.go @@ -0,0 +1,64 @@ +package common + +import ( + "math" + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" +) + +func TestQuotaFromFloatSaturatesOutOfRangeValues(t *testing.T) { + tests := []struct { + name string + in float64 + want int + }{ + {name: "overflow", in: math.MaxFloat64, want: MaxQuota}, + {name: "underflow", in: -math.MaxFloat64, want: MinQuota}, + {name: "nan", in: math.NaN(), want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := QuotaFromFloat(tt.in) + require.Equal(t, tt.want, got) + }) + } +} + +func TestQuotaRoundStrictRejectsSaturation(t *testing.T) { + quota, err := QuotaRoundStrict(float64(MaxQuota) + 1) + + require.Error(t, err) + require.Zero(t, quota) + clamp, ok := err.(*QuotaClamp) + require.True(t, ok) + require.Equal(t, QuotaClampOverflow, clamp.Kind) +} + +func TestQuotaRoundStrictAcceptsIntegerBounds(t *testing.T) { + maxQuota, maxErr := QuotaRoundStrict(float64(MaxQuota)) + minQuota, minErr := QuotaRoundStrict(float64(MinQuota)) + + require.NoError(t, maxErr) + require.NoError(t, minErr) + require.Equal(t, MaxQuota, maxQuota) + require.Equal(t, MinQuota, minQuota) +} + +func TestQuotaClampNaNIsJSONSafe(t *testing.T) { + _, clamp := QuotaFromFloatChecked(math.NaN()) + require.NotNil(t, clamp) + + data, err := Marshal(clamp) + + require.NoError(t, err) + require.Contains(t, string(data), `"original":"NaN"`) +} + +func TestQuotaFromDecimalRoundsBeforeSaturating(t *testing.T) { + quota := QuotaFromDecimal(decimal.NewFromFloat(1.5)) + + require.Equal(t, 2, quota) +} diff --git a/controller/relay.go b/controller/relay.go index 0e8a0e997b41..fc757f785e12 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -423,6 +423,10 @@ func isRetryableChannelError(c *gin.Context, openaiErr *types.NewAPIError) bool return operation_setting.ShouldRetryByStatusCode(code) } +func shouldCooldownForUpstreamError(err *types.NewAPIError) bool { + return !isSemanticClientError(err) && service.ShouldCooldownChannelForUpstreamError(err) +} + func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) { logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error())) // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况 @@ -434,7 +438,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t // this channel misbehaved; cool it for the full duration so it stops // being re-picked. This subsumes upstream 5xx / capability 4xx. service.CooldownChannelForRetry(channelError, err) - } else if service.ShouldCooldownChannelForUpstreamError(err) { + } else if shouldCooldownForUpstreamError(err) { service.CooldownChannelForUpstreamError(channelError, err) } diff --git a/controller/relay_retryable_test.go b/controller/relay_retryable_test.go index 6dfa18e3079a..c6be93e56a22 100644 --- a/controller/relay_retryable_test.go +++ b/controller/relay_retryable_test.go @@ -96,6 +96,18 @@ func TestShouldRetryStopsOnSemanticContextLimitError(t *testing.T) { } } +func TestProcessChannelErrorDoesNotCooldownSemanticContextLimitError(t *testing.T) { + err := types.NewErrorWithStatusCode( + errors.New("Your input exceeds the context window of this model. Please adjust your input and try again."), + types.ErrorCodeBadResponseStatusCode, + http.StatusBadGateway, + ) + + if shouldCooldownForUpstreamError(err) { + t.Fatal("expected semantic context errors not to trigger upstream cooldown") + } +} + func TestIsRetryableChannelErrorSkipsSpecificChannel(t *testing.T) { c := newTestContext() c.Set("specific_channel_id", 5) diff --git a/pkg/billingexpr/round.go b/pkg/billingexpr/round.go index 35a5534a4cc3..e5cba600e9aa 100644 --- a/pkg/billingexpr/round.go +++ b/pkg/billingexpr/round.go @@ -1,10 +1,21 @@ package billingexpr -import "math" +import "github.com/QuantumNous/new-api/common" // QuotaRound converts a float64 quota value to int using half-away-from-zero -// rounding. Every tiered billing path (pre-consume, settlement, breakdown -// validation, log fields) MUST use this function to avoid +-1 discrepancies. +// rounding with int32 saturation. Every tiered billing path (pre-consume, +// settlement, breakdown validation, log fields) MUST use this function to +// avoid +-1 discrepancies and integer wraparound. func QuotaRound(f float64) int { - return int(math.Round(f)) + return common.QuotaRound(f) +} + +// QuotaRoundChecked reports whether settlement saturated the result. +func QuotaRoundChecked(f float64) (int, *common.QuotaClamp) { + return common.QuotaRoundChecked(f) +} + +// QuotaRoundStrict rejects an unrepresentable pre-consume estimate. +func QuotaRoundStrict(f float64) (int, error) { + return common.QuotaRoundStrict(f) } diff --git a/pkg/billingexpr/settle.go b/pkg/billingexpr/settle.go index 7a6ca4401430..2674251f4ca2 100644 --- a/pkg/billingexpr/settle.go +++ b/pkg/billingexpr/settle.go @@ -23,12 +23,13 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re } quotaBeforeGroup := quotaConversion(cost, snap) - afterGroup := QuotaRound(quotaBeforeGroup * snap.GroupRatio) + afterGroup, clamp := QuotaRoundChecked(quotaBeforeGroup * snap.GroupRatio) crossed := trace.MatchedTier != snap.EstimatedTier return TieredResult{ ActualQuotaBeforeGroup: quotaBeforeGroup, ActualQuotaAfterGroup: afterGroup, + QuotaClamp: clamp, MatchedTier: trace.MatchedTier, CrossedTier: crossed, }, nil diff --git a/pkg/billingexpr/types.go b/pkg/billingexpr/types.go index 12e0d3c68599..3b8e3ceb67b2 100644 --- a/pkg/billingexpr/types.go +++ b/pkg/billingexpr/types.go @@ -3,6 +3,8 @@ package billingexpr import ( "crypto/sha256" "fmt" + + "github.com/QuantumNous/new-api/common" ) type RequestInput struct { @@ -53,10 +55,11 @@ type BillingSnapshot struct { // TieredResult holds everything needed after running tiered settlement. type TieredResult struct { - ActualQuotaBeforeGroup float64 `json:"actual_quota_before_group"` - ActualQuotaAfterGroup int `json:"actual_quota_after_group"` - MatchedTier string `json:"matched_tier"` - CrossedTier bool `json:"crossed_tier"` + ActualQuotaBeforeGroup float64 `json:"actual_quota_before_group"` + ActualQuotaAfterGroup int `json:"actual_quota_after_group"` + QuotaClamp *common.QuotaClamp `json:"quota_clamp,omitempty"` + MatchedTier string `json:"matched_tier"` + CrossedTier bool `json:"crossed_tier"` } // ExprHashString returns the SHA-256 hex digest of an expression string. diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 85cf384f8f6e..748c530017fd 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -870,7 +870,9 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud data = patchClaudeMessageDeltaUsageData(data, buildMessageDeltaPatchUsage(&claudeResponse, claudeInfo)) } } - helper.ClaudeChunkData(c, claudeResponse, data) + if err := helper.ClaudeChunkData(c, claudeResponse, data); err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody) + } } else if info.RelayFormat == types.RelayFormatOpenAI { response := StreamResponseClaude2OpenAI(&claudeResponse) diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index ab4c2cdf8bb8..419055425ce4 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -235,9 +235,9 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream } } -func sendResponsesStreamData(c *gin.Context, streamResponse dto.ResponsesStreamResponse, data string) { +func sendResponsesStreamData(c *gin.Context, streamResponse dto.ResponsesStreamResponse, data string) error { if data == "" { - return + return nil } - helper.ResponseChunkData(c, streamResponse, data) + return helper.ResponseChunkData(c, streamResponse, data) } diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 8285af9bd1b1..149f413a7594 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -90,7 +90,10 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp return } streamCtx.observe(streamResponse) - sendResponsesStreamData(c, streamResponse, ensureResponsesTerminalOutputField(streamResponse, data)) + if err := sendResponsesStreamData(c, streamResponse, ensureResponsesTerminalOutputField(streamResponse, data)); err != nil { + sr.Stop(err) + return + } switch streamResponse.Type { case "response.completed": if streamResponse.Response != nil { @@ -140,10 +143,17 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp // response.failed (timeout / scanner error / no output) based on // info.StreamStatus. if streamCtx.shouldSynthesize(c, info) { - if synthUsage := streamCtx.emitTerminal(c, info); synthUsage != nil { - usage = synthUsage + synthUsage, err := streamCtx.emitTerminal(c, info) + if err != nil { + if info != nil && info.StreamStatus != nil { + info.StreamStatus.SetEndReasonWithSource(relaycommon.StreamEndReasonHandlerStop, err, "synthetic_terminal_write") + } + } else { + if synthUsage != nil { + usage = synthUsage + } + logger.LogInfo(c, fmt.Sprintf("synthesized responses terminal event (status=%s)", streamStatusSummary(info))) } - logger.LogInfo(c, fmt.Sprintf("synthesized responses terminal event (status=%s)", streamStatusSummary(info))) } if usage.CompletionTokens == 0 { diff --git a/relay/channel/openai/responses_fallback.go b/relay/channel/openai/responses_fallback.go index 6e2574432872..c4eed042f787 100644 --- a/relay/channel/openai/responses_fallback.go +++ b/relay/channel/openai/responses_fallback.go @@ -110,7 +110,7 @@ func (ctx *responsesStreamCtx) shouldSynthesize(c *gin.Context, info *relaycommo // ended normally (EOF / [DONE] / handler-stop), emit response.completed so // the client preserves partial output. Otherwise emit response.failed with a // diagnostic message reflecting the EndReason. -func (ctx *responsesStreamCtx) emitTerminal(c *gin.Context, info *relaycommon.RelayInfo) *dto.Usage { +func (ctx *responsesStreamCtx) emitTerminal(c *gin.Context, info *relaycommon.RelayInfo) (*dto.Usage, error) { usage := ctx.buildUsage(info) responseID := ctx.resolveResponseID(c) model := ctx.resolveModel(info) @@ -119,12 +119,16 @@ func (ctx *responsesStreamCtx) emitTerminal(c *gin.Context, info *relaycommon.Re normalEnd := info == nil || info.StreamStatus == nil || info.StreamStatus.IsNormalEnd() hadOutput := ctx.outputTextLen > 0 || ctx.reasoningTextLen > 0 + var err error if normalEnd && hadOutput { - ctx.writeCompletedEvent(c, responseID, model, createdAt, usage) + err = ctx.writeCompletedEvent(c, responseID, model, createdAt, usage) } else { - ctx.writeFailedEvent(c, responseID, model, createdAt, usage, info) + err = ctx.writeFailedEvent(c, responseID, model, createdAt, usage, info) } - return usage + if err != nil { + return nil, err + } + return usage, nil } func (ctx *responsesStreamCtx) buildUsage(info *relaycommon.RelayInfo) *dto.Usage { @@ -184,7 +188,7 @@ func (ctx *responsesStreamCtx) resolveCreatedAt() int64 { // writeCompletedEvent emits a response.completed event with the minimum // shape required by Codex (id + usage) and the optional fields most other // clients consult (model, created_at, status, output=[]). -func (ctx *responsesStreamCtx) writeCompletedEvent(c *gin.Context, id, model string, createdAt int64, usage *dto.Usage) { +func (ctx *responsesStreamCtx) writeCompletedEvent(c *gin.Context, id, model string, createdAt int64, usage *dto.Usage) error { response := map[string]any{ "id": id, "object": "response", @@ -194,12 +198,12 @@ func (ctx *responsesStreamCtx) writeCompletedEvent(c *gin.Context, id, model str "output": []any{}, "usage": usageToResponsesPayload(usage), } - ctx.writeSyntheticEvent(c, "response.completed", response) + return ctx.writeSyntheticEvent(c, "response.completed", response) } // writeFailedEvent emits a response.failed event with an error object that // Codex's parser maps to a human-readable error message. -func (ctx *responsesStreamCtx) writeFailedEvent(c *gin.Context, id, model string, createdAt int64, usage *dto.Usage, info *relaycommon.RelayInfo) { +func (ctx *responsesStreamCtx) writeFailedEvent(c *gin.Context, id, model string, createdAt int64, usage *dto.Usage, info *relaycommon.RelayInfo) error { message := "upstream stream interrupted" code := "stream_disconnect" if info != nil && info.StreamStatus != nil { @@ -225,13 +229,13 @@ func (ctx *responsesStreamCtx) writeFailedEvent(c *gin.Context, id, model string }, "usage": usageToResponsesPayload(usage), } - ctx.writeSyntheticEvent(c, "response.failed", response) + return ctx.writeSyntheticEvent(c, "response.failed", response) } // writeSyntheticEvent serializes the payload and writes the // `event:` + `data:` SSE pair using the same format helper.ResponseChunkData // uses for upstream-passthrough events. -func (ctx *responsesStreamCtx) writeSyntheticEvent(c *gin.Context, eventType string, response map[string]any) { +func (ctx *responsesStreamCtx) writeSyntheticEvent(c *gin.Context, eventType string, response map[string]any) error { payload := map[string]any{ "type": eventType, "response": response, @@ -239,11 +243,15 @@ func (ctx *responsesStreamCtx) writeSyntheticEvent(c *gin.Context, eventType str data, err := common.Marshal(payload) if err != nil { logger.LogError(c, fmt.Sprintf("synthesize %s: marshal failed: %s", eventType, err.Error())) - return + return err } syntheticEvent := dto.ResponsesStreamResponse{Type: eventType} - sendResponsesStreamData(c, syntheticEvent, string(data)) + if err := sendResponsesStreamData(c, syntheticEvent, string(data)); err != nil { + logger.LogError(c, fmt.Sprintf("synthesize %s: write failed: %s", eventType, err.Error())) + return err + } + return nil } func ensureResponsesTerminalOutputField(streamResponse dto.ResponsesStreamResponse, data string) string { diff --git a/relay/channel/openai/responses_fallback_test.go b/relay/channel/openai/responses_fallback_test.go index eaba374981cf..b3ef5524a3cb 100644 --- a/relay/channel/openai/responses_fallback_test.go +++ b/relay/channel/openai/responses_fallback_test.go @@ -187,7 +187,8 @@ func TestResponsesStreamCtx_EmitTerminal_CompletedOnGracefulEOFWithOutput(t *tes }) ctx.observe(dto.ResponsesStreamResponse{Type: "response.output_text.delta", Delta: "hello world"}) - usage := ctx.emitTerminal(c, info) + usage, err := ctx.emitTerminal(c, info) + require.NoError(t, err) require.NotNil(t, usage) assert.Greater(t, usage.CompletionTokens, 0, "should estimate output tokens locally") assert.Equal(t, 100, usage.PromptTokens, "prompt tokens come from estimate") @@ -219,7 +220,8 @@ func TestResponsesStreamCtx_EmitTerminal_FailedOnTimeout(t *testing.T) { ctx := newResponsesStreamCtx() ctx.observe(dto.ResponsesStreamResponse{Type: "response.output_text.delta", Delta: "partial"}) - usage := ctx.emitTerminal(c, info) + usage, err := ctx.emitTerminal(c, info) + require.NoError(t, err) require.NotNil(t, usage) eventName, dataJSON := extractSyntheticEvent(t, recorder) @@ -245,7 +247,8 @@ func TestResponsesStreamCtx_EmitTerminal_FailedWhenNoOutput(t *testing.T) { ctx := newResponsesStreamCtx() // EOF without any output/reasoning deltas — synthesize failed, not completed - ctx.emitTerminal(c, info) + _, err := ctx.emitTerminal(c, info) + require.NoError(t, err) eventName, _ := extractSyntheticEvent(t, recorder) assert.Equal(t, "response.failed", eventName, "no output => failed even on graceful EOF") @@ -267,7 +270,8 @@ func TestResponsesStreamCtx_EmitTerminal_PrefersUpstreamUsage(t *testing.T) { }) ctx.observe(dto.ResponsesStreamResponse{Type: "response.output_text.delta", Delta: "hi"}) - ctx.emitTerminal(c, info) + _, err := ctx.emitTerminal(c, info) + require.NoError(t, err) _, dataJSON := extractSyntheticEvent(t, recorder) var payload map[string]any @@ -289,7 +293,8 @@ func TestResponsesStreamCtx_EmitTerminal_ReasoningCountsAsOutput(t *testing.T) { // because the client/Codex should preserve reasoning state for the next turn. ctx.observe(dto.ResponsesStreamResponse{Type: "response.reasoning_text.delta", Delta: "thinking about this..."}) - ctx.emitTerminal(c, info) + _, err := ctx.emitTerminal(c, info) + require.NoError(t, err) eventName, _ := extractSyntheticEvent(t, recorder) assert.Equal(t, "response.completed", eventName) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index a6caf3f64b51..de759fa7be66 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -163,6 +163,9 @@ type RelayInfo struct { PriceData types.PriceData + // QuotaClamp records a quota conversion that saturated or handled NaN. + QuotaClamp *common.QuotaClamp + // TieredBillingSnapshot is a frozen snapshot of tiered billing rules // captured at pre-consume time. Non-nil only when billing mode is "tiered_expr". TieredBillingSnapshot *billingexpr.BillingSnapshot diff --git a/relay/helper/common.go b/relay/helper/common.go index 17ce79d2a10a..644d8b9c3a92 100644 --- a/relay/helper/common.go +++ b/relay/helper/common.go @@ -38,6 +38,10 @@ func FlushWriter(c *gin.Context) (err error) { return nil } +func requestContextDone(c *gin.Context) bool { + return c != nil && c.Request != nil && c.Request.Context().Err() != nil +} + func SetEventStreamHeaders(c *gin.Context) { // 检查是否已经设置过头部 if _, exists := c.Get("event_stream_headers_set"); exists { @@ -66,16 +70,35 @@ func ClaudeData(c *gin.Context, resp dto.ClaudeResponse) error { return nil } -func ClaudeChunkData(c *gin.Context, resp dto.ClaudeResponse, data string) { - c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}) - c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("data: %s\n", data)}) - _ = FlushWriter(c) +func renderCustomEvent(c *gin.Context, event common.CustomEvent) error { + return event.Render(c.Writer) } -func ResponseChunkData(c *gin.Context, resp dto.ResponsesStreamResponse, data string) { - c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}) - c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("data: %s", data)}) - _ = FlushWriter(c) +func ClaudeChunkData(c *gin.Context, resp dto.ClaudeResponse, data string) error { + if requestContextDone(c) { + return fmt.Errorf("request context done: %w", c.Request.Context().Err()) + } + if err := renderCustomEvent(c, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}); err != nil { + return err + } + if err := renderCustomEvent(c, common.CustomEvent{Data: fmt.Sprintf("data: %s\n", data)}); err != nil { + return err + } + return FlushWriter(c) +} + +func ResponseChunkData(c *gin.Context, resp dto.ResponsesStreamResponse, data string) error { + if requestContextDone(c) { + return fmt.Errorf("request context done: %w", c.Request.Context().Err()) + } + + if err := renderCustomEvent(c, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}); err != nil { + return err + } + if err := renderCustomEvent(c, common.CustomEvent{Data: fmt.Sprintf("data: %s", data)}); err != nil { + return err + } + return FlushWriter(c) } func StringData(c *gin.Context, str string) error { diff --git a/relay/helper/common_disconnect_test.go b/relay/helper/common_disconnect_test.go new file mode 100644 index 000000000000..8db89fee10e2 --- /dev/null +++ b/relay/helper/common_disconnect_test.go @@ -0,0 +1,26 @@ +package helper + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/dto" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestResponseChunkDataDoesNotWriteAfterRequestCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(ctx) + + err := ResponseChunkData(c, dto.ResponsesStreamResponse{Type: "response.output_text.delta"}, `{"delta":"stale"}`) + + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, recorder.Body.String()) +} diff --git a/relay/helper/price.go b/relay/helper/price.go index d1e16bca6085..3d97308ae02c 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -85,6 +85,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens var audioRatio float64 var audioCompletionRatio float64 var freeModel bool + var quotaErr error if !usePrice { preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota) if meta.MaxTokens != 0 { @@ -112,12 +113,18 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName) audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName) ratio := modelRatio * groupRatioInfo.GroupRatio - preConsumedQuota = int(float64(preConsumedTokens) * ratio) + preConsumedQuota, quotaErr = common.QuotaFromFloatStrict(float64(preConsumedTokens) * ratio) + if quotaErr != nil { + return types.PriceData{}, quotaErr + } } else { if meta.ImagePriceRatio != 0 { modelPrice = modelPrice * meta.ImagePriceRatio } - preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + preConsumedQuota, quotaErr = common.QuotaFromFloatStrict(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + if quotaErr != nil { + return types.PriceData{}, quotaErr + } } // check if free model pre-consume is disabled @@ -194,7 +201,11 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types freeModel := false if usePrice { - quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + var err error + quota, err = common.QuotaFromFloatStrict(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + if err != nil { + return types.PriceData{}, err + } if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 { quota = 0 @@ -203,7 +214,11 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types } } else { // 按量计费:以模型倍率的一半作为预扣额度 - quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + var err error + quota, err = common.QuotaFromFloatStrict(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + if err != nil { + return types.PriceData{}, err + } modelPrice = -1 if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 { @@ -265,7 +280,10 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT // Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does. quotaBeforeGroup := rawCost / 1_000_000 * common.QuotaPerUnit - preConsumedQuota := billingexpr.QuotaRound(quotaBeforeGroup * groupRatioInfo.GroupRatio) + preConsumedQuota, err := billingexpr.QuotaRoundStrict(quotaBeforeGroup * groupRatioInfo.GroupRatio) + if err != nil { + return types.PriceData{}, err + } freeModel := false if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { diff --git a/relay/helper/stream_scanner_test.go b/relay/helper/stream_scanner_test.go index 45957088a630..387d446496f8 100644 --- a/relay/helper/stream_scanner_test.go +++ b/relay/helper/stream_scanner_test.go @@ -227,6 +227,55 @@ func TestStreamScannerHandler_DataWithExtraSpaces(t *testing.T) { assert.Equal(t, "{\"trimmed\":true}", got) } +func TestStreamScannerHandler_ClientCancelClosesUpstreamBeforeReturn(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + pr, pw := io.Pipe() + t.Cleanup(func() { + _ = pr.Close() + _ = pw.Close() + }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx) + resp := &http.Response{Body: pr, StatusCode: http.StatusOK} + info := &relaycommon.RelayInfo{DisablePing: true, ChannelMeta: &relaycommon.ChannelMeta{}} + + var handled atomic.Int64 + firstHandled := make(chan struct{}) + done := make(chan struct{}) + go func() { + StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) { + handled.Add(1) + if data == "first" { + close(firstHandled) + } + }) + close(done) + }() + + _, err := fmt.Fprint(pw, "data: first\n") + require.NoError(t, err) + select { + case <-firstHandled: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for first chunk") + } + + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("handler retained stream workers after client disconnect") + } + + _, err = fmt.Fprint(pw, "data: stale\n") + require.ErrorIs(t, err, io.ErrClosedPipe) + assert.Equal(t, int64(1), handled.Load()) + assert.Equal(t, relaycommon.StreamEndReasonClientGone, info.StreamStatus.Snapshot().EndReason) +} + // ---------- Decoupling ---------- func TestStreamScannerHandler_ScannerDecoupledFromSlowHandler(t *testing.T) { diff --git a/relay/relay_task.go b/relay/relay_task.go index 098e23828b6c..c3f3c43b592b 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -197,7 +197,11 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe if !common.StringsContains(constant.TaskPricePatches, modelName) { for _, ra := range info.PriceData.OtherRatios { if ra != 1.0 { - info.PriceData.Quota = int(float64(info.PriceData.Quota) * ra) + quota, quotaErr := common.QuotaFromFloatStrict(float64(info.PriceData.Quota) * ra) + if quotaErr != nil { + return nil, service.TaskErrorWrapper(quotaErr, "quota_out_of_range", http.StatusBadRequest) + } + info.PriceData.Quota = quota } } } @@ -260,22 +264,26 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe // recalcQuotaFromRatios 根据 adjustedRatios 重新计算 quota。 // 公式: baseQuota × ∏(ratio) — 其中 baseQuota 是不含 OtherRatios 的基础额度。 func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float64) int { - // 从 PriceData 获取不含 OtherRatios 的基础价格 - baseQuota := info.PriceData.Quota - // 先除掉原有的 OtherRatios 恢复基础额度 + // From PriceData get the base quota without OtherRatios. + baseQuota := float64(info.PriceData.Quota) + // Remove the original OtherRatios before applying the adjusted values. for _, ra := range info.PriceData.OtherRatios { if ra != 1.0 && ra > 0 { - baseQuota = int(float64(baseQuota) / ra) + baseQuota /= ra } } - // 应用新的 ratios - result := float64(baseQuota) + // Apply the new ratios and preserve clamp metadata for settlement logs. + result := baseQuota for _, ra := range ratios { if ra != 1.0 { result *= ra } } - return int(result) + quota, clamp := common.QuotaFromFloatChecked(result) + if clamp != nil && info.QuotaClamp == nil { + info.QuotaClamp = clamp + } + return quota } var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){ diff --git a/service/channel_affinity_template_test.go b/service/channel_affinity_template_test.go index e5008bfa3f2e..8319936b9302 100644 --- a/service/channel_affinity_template_test.go +++ b/service/channel_affinity_template_test.go @@ -265,6 +265,7 @@ func TestChannelAffinityHitClaudeMessagesAllowsGPTModels(t *testing.T) { ctx, _ := gin.CreateTestContext(rec) ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(fmt.Sprintf(`{"metadata":{"user_id":"%s"}}`, affinityValue))) ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Header.Set("User-Agent", "claude-cli/2.1.207") channelID, found := GetPreferredChannelByAffinity(ctx, "gpt-5.5", "gpt pro") require.True(t, found) @@ -278,6 +279,54 @@ func TestChannelAffinityHitClaudeMessagesAllowsGPTModels(t *testing.T) { require.Equal(t, "metadata.user_id", meta.KeySourcePath) } +func TestChannelAffinityHitClaudeTemplatePassesBillingHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + + setting := operation_setting.GetChannelAffinitySetting() + require.NotNil(t, setting) + + var claudeRule *operation_setting.ChannelAffinityRule + for i := range setting.Rules { + rule := &setting.Rules[i] + if strings.EqualFold(strings.TrimSpace(rule.Name), "claude cli trace") { + claudeRule = rule + break + } + } + require.NotNil(t, claudeRule) + + affinityValue := fmt.Sprintf("claude-user-%d", time.Now().UnixNano()) + cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*claudeRule, "claude-sonnet-4-6", "default", affinityValue) + cache := getChannelAffinityCache() + require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 40, time.Minute)) + t.Cleanup(func() { + _, _ = cache.DeleteMany([]string{cacheKeySuffix}) + }) + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(fmt.Sprintf(`{"metadata":{"user_id":"%s"}}`, affinityValue))) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Header.Set("User-Agent", "claude-cli/2.1.207") + + channelID, found := GetPreferredChannelByAffinity(ctx, "claude-sonnet-4-6", "default") + require.True(t, found) + require.Equal(t, 40, channelID) + + mergedOverride, applied := ApplyChannelAffinityOverrideTemplate(ctx, nil) + require.True(t, applied) + info := &relaycommon.RelayInfo{ + RequestHeaders: map[string]string{ + "X-Anthropic-Billing-Header": "cc_version=2.1.207.6dd; cc_entrypoint=cli;", + }, + ChannelMeta: &relaycommon.ChannelMeta{ParamOverride: mergedOverride}, + } + + _, err := relaycommon.ApplyParamOverrideWithRelayInfo([]byte(`{"model":"claude-sonnet-4-6"}`), info) + require.NoError(t, err) + require.Equal(t, "cc_version=2.1.207.6dd; cc_entrypoint=cli;", info.RuntimeHeadersOverride["x-anthropic-billing-header"]) +} + func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/service/log_info_generate.go b/service/log_info_generate.go index d5f7581890e5..17c46dc1f32c 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -71,6 +71,10 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m adminInfo["local_count_tokens"] = isLocalCountTokens } + if relayInfo.QuotaClamp != nil { + adminInfo["quota_clamp"] = relayInfo.QuotaClamp + } + AppendChannelAffinityAdminInfo(ctx, adminInfo) other["admin_info"] = adminInfo diff --git a/service/text_quota.go b/service/text_quota.go index de801515c5e0..0dcacea810a2 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -138,6 +138,13 @@ func calculateTextToolCallSurcharge(ctx *gin.Context, relayInfo *relaycommon.Rel return surcharge } +func noteQuotaClamp(relayInfo *relaycommon.RelayInfo, clamp *common.QuotaClamp) { + if clamp == nil || relayInfo == nil || relayInfo.QuotaClamp != nil { + return + } + relayInfo.QuotaClamp = clamp +} + func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaSummary, tieredQuota int, tieredResult *billingexpr.TieredResult) int { if summary.ToolCallSurchargeQuota.IsZero() { return tieredQuota @@ -145,15 +152,17 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS if tieredResult != nil { if snap := relayInfo.TieredBillingSnapshot; snap != nil { - return int(decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup). + quota, clamp := common.QuotaFromDecimalChecked(decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup). Mul(decimal.NewFromFloat(snap.GroupRatio)). - Add(summary.ToolCallSurchargeQuota). - Round(0). - IntPart()) + Add(summary.ToolCallSurchargeQuota)) + noteQuotaClamp(relayInfo, clamp) + return quota } } - return tieredQuota + int(summary.ToolCallSurchargeQuota.Round(0).IntPart()) + total, clamp := common.QuotaFromDecimalChecked(decimal.NewFromInt(int64(tieredQuota)).Add(summary.ToolCallSurchargeQuota)) + noteQuotaClamp(relayInfo, clamp) + return total } func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary { @@ -272,6 +281,10 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf } } + if baseTokens.IsNegative() { + baseTokens = decimal.Zero + } + promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio).Add(cachedCreationTokensWithRatio) completionQuota := dCompletionTokens.Mul(dCompletionRatio) quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio) @@ -287,7 +300,9 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) { quotaCalculateDecimal = decimal.NewFromInt(1) } - summary.Quota = int(quotaCalculateDecimal.Round(0).IntPart()) + quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal) + summary.Quota = quota + noteQuotaClamp(relayInfo, clamp) } else { quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota) @@ -297,11 +312,15 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio)) } } - summary.Quota = int(quotaCalculateDecimal.Round(0).IntPart()) + quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal) + summary.Quota = quota + noteQuotaClamp(relayInfo, clamp) } if summary.TotalTokens == 0 && !relayInfo.PriceData.UsePrice { - summary.Quota = int(summary.ToolCallSurchargeQuota.Round(0).IntPart()) + quota, clamp := common.QuotaFromDecimalChecked(summary.ToolCallSurchargeQuota) + summary.Quota = quota + noteQuotaClamp(relayInfo, clamp) } else if !ratio.IsZero() && summary.Quota == 0 { summary.Quota = 1 } diff --git a/service/text_quota_test.go b/service/text_quota_test.go index cb921c20890c..1258eadf9fbc 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/pkg/billingexpr" @@ -12,9 +13,57 @@ import ( "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" "github.com/stretchr/testify/require" ) +func TestCalculateTextQuotaSummaryClampsNegativeUncachedRemainder(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + relayInfo := &relaycommon.RelayInfo{ + OriginModelName: "gpt-5", + PriceData: types.PriceData{ + ModelRatio: 1, + CompletionRatio: 1, + CacheRatio: 0.1, + CacheCreationRatio: 1.25, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + StartTime: time.Now(), + } + usage := &dto.Usage{ + PromptTokens: 100, + PromptTokensDetails: dto.InputTokenDetails{ + CachedTokens: 80, + CachedCreationTokens: 80, + }, + } + + summary := calculateTextQuotaSummary(ctx, relayInfo, usage) + + // The overlapping cache counters cost 80*0.1 + 80*1.25. They must not + // create a negative 60-token base charge. + require.Equal(t, 108, summary.Quota) +} + +func TestCalculateTextQuotaSummarySaturatesOversizedQuota(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + relayInfo := &relaycommon.RelayInfo{ + OriginModelName: "oversized-model", + PriceData: types.PriceData{ + ModelRatio: 1e20, + CompletionRatio: 1, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + StartTime: time.Now(), + } + + summary := calculateTextQuotaSummary(ctx, relayInfo, &dto.Usage{PromptTokens: 1}) + + require.Equal(t, common.MaxQuota, summary.Quota) +} + func TestCalculateTextQuotaSummaryUnifiedForClaudeSemantic(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -411,6 +460,17 @@ func TestCalculateTextQuotaSummaryKeepsToolSurchargeWithZeroTokens(t *testing.T) require.Equal(t, 5000, summary.Quota) } +func TestComposeTieredTextQuotaSaturatesFinalSurcharge(t *testing.T) { + gin.SetMode(gin.TestMode) + relayInfo := &relaycommon.RelayInfo{} + summary := textQuotaSummary{ToolCallSurchargeQuota: decimal.NewFromInt(int64(common.MaxQuota))} + + quota := composeTieredTextQuota(relayInfo, summary, common.MaxQuota, nil) + + require.Equal(t, common.MaxQuota, quota) + require.NotNil(t, relayInfo.QuotaClamp) +} + func TestComposeTieredTextQuotaFallbackKeepsToolCallSurcharges(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() diff --git a/service/tiered_settle.go b/service/tiered_settle.go index a97ec088d0df..844be61bfaaf 100644 --- a/service/tiered_settle.go +++ b/service/tiered_settle.go @@ -112,5 +112,6 @@ func TryTieredSettle(relayInfo *relaycommon.RelayInfo, params billingexpr.TokenP return true, quota, nil } + noteQuotaClamp(relayInfo, tr.QuotaClamp) return true, tr.ActualQuotaAfterGroup, &tr } diff --git a/setting/operation_setting/channel_affinity_setting.go b/setting/operation_setting/channel_affinity_setting.go index 7be6e5d9e9ac..529a8be41d03 100644 --- a/setting/operation_setting/channel_affinity_setting.go +++ b/setting/operation_setting/channel_affinity_setting.go @@ -57,6 +57,7 @@ var claudeCliPassThroughHeaders = []string{ "Anthropic-Beta", "Anthropic-Dangerous-Direct-Browser-Access", "Anthropic-Version", + "X-Anthropic-Billing-Header", } func buildPassHeaderTemplate(headers []string) map[string]interface{} { @@ -100,6 +101,7 @@ var channelAffinitySetting = ChannelAffinitySetting{ PathRegex: []string{"/v1/messages"}, KeySources: []ChannelAffinityKeySource{ {Type: "gjson", Path: "metadata.user_id"}, + {Type: "context_int", Key: "token_id"}, }, ValueRegex: "", TTLSeconds: 0, diff --git a/types/rw_map.go b/types/rw_map.go index 3d296816f2a3..aa89bfca3fcf 100644 --- a/types/rw_map.go +++ b/types/rw_map.go @@ -12,10 +12,17 @@ type RWMap[K comparable, V any] struct { } func (m *RWMap[K, V]) UnmarshalJSON(b []byte) error { + var next map[K]V + if err := common.Unmarshal(b, &next); err != nil { + return err + } + if next == nil { + next = make(map[K]V) + } m.mutex.Lock() defer m.mutex.Unlock() - m.data = make(map[K]V) - return common.Unmarshal(b, &m.data) + m.data = next + return nil } func (m *RWMap[K, V]) MarshalJSON() ([]byte, error) { @@ -75,22 +82,28 @@ func (m *RWMap[K, V]) Len() int { } func LoadFromJsonString[K comparable, V any](m *RWMap[K, V], jsonStr string) error { + var next map[K]V + if err := common.Unmarshal([]byte(jsonStr), &next); err != nil { + return err + } + if next == nil { + next = make(map[K]V) + } m.mutex.Lock() defer m.mutex.Unlock() - m.data = make(map[K]V) - return common.Unmarshal([]byte(jsonStr), &m.data) + m.data = next + return nil } // LoadFromJsonStringWithCallback loads a JSON string into the RWMap and calls the callback on success. func LoadFromJsonStringWithCallback[K comparable, V any](m *RWMap[K, V], jsonStr string, onSuccess func()) error { - m.mutex.Lock() - defer m.mutex.Unlock() - m.data = make(map[K]V) - err := common.Unmarshal([]byte(jsonStr), &m.data) - if err == nil && onSuccess != nil { + if err := LoadFromJsonString(m, jsonStr); err != nil { + return err + } + if onSuccess != nil { onSuccess() } - return err + return nil } // MarshalJSONString returns the JSON string representation of the RWMap. diff --git a/types/rw_map_test.go b/types/rw_map_test.go new file mode 100644 index 000000000000..d8465284d81e --- /dev/null +++ b/types/rw_map_test.go @@ -0,0 +1,67 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRWMapUnmarshalJSONPreservesExistingDataOnDecodeError(t *testing.T) { + m := NewRWMap[string, int]() + m.Set("existing", 1) + + err := m.UnmarshalJSON([]byte(`{"replacement":2,"invalid":"not-an-int"}`)) + + require.Error(t, err) + require.Equal(t, map[string]int{"existing": 1}, m.ReadAll()) +} + +func TestLoadFromJsonStringPreservesExistingDataOnDecodeError(t *testing.T) { + m := NewRWMap[string, int]() + m.Set("existing", 1) + + err := LoadFromJsonString(m, `{"replacement":2,"invalid":"not-an-int"}`) + + require.Error(t, err) + require.Equal(t, map[string]int{"existing": 1}, m.ReadAll()) +} + +func TestLoadFromJsonStringWithCallbackCommitsAtomically(t *testing.T) { + t.Run("success replaces data and invokes callback once", func(t *testing.T) { + m := NewRWMap[string, int]() + m.Set("existing", 1) + callbackCount := 0 + + err := LoadFromJsonStringWithCallback(m, `{"replacement":2}`, func() { + callbackCount++ + }) + + require.NoError(t, err) + require.Equal(t, map[string]int{"replacement": 2}, m.ReadAll()) + require.Equal(t, 1, callbackCount) + }) + + t.Run("failure preserves data and skips callback", func(t *testing.T) { + m := NewRWMap[string, int]() + m.Set("existing", 1) + callbackCount := 0 + + err := LoadFromJsonStringWithCallback(m, `{"replacement":2,"invalid":"not-an-int"}`, func() { + callbackCount++ + }) + + require.Error(t, err) + require.Equal(t, map[string]int{"existing": 1}, m.ReadAll()) + require.Zero(t, callbackCount) + }) + + t.Run("nil callback still loads successfully", func(t *testing.T) { + m := NewRWMap[string, int]() + m.Set("existing", 1) + + err := LoadFromJsonStringWithCallback(m, `{"replacement":2}`, nil) + + require.NoError(t, err) + require.Equal(t, map[string]int{"replacement": 2}, m.ReadAll()) + }) +}