feat: 支持图片编辑功能并优化多模态计费
refactor: 重构 Gemini 适配器以支持图片编辑和生成 feat(relay): 添加图片编辑模式支持 feat(controller): 实现 UsageAPIURL 用于获取真实 token 用量 feat(web): 在渠道测试中添加模型选择功能 perf(token): 优化多模态 token 计算逻辑 fix(web): 修复日志分页组件显示问题 docs: 更新渠道配置中的 UsageAPIURL 说明 style: 清理调试日志和注释 feat(gemini): 支持 Imagen 3+ 图片生成模型 feat(openai): 添加生成 ID 捕获和元数据获取功能
This commit is contained in:
parent
c734c541b2
commit
5730b2a798
@ -475,7 +475,7 @@ type zenmuxChannelDef struct {
|
|||||||
var allZenmuxChannelDefs = []zenmuxChannelDef{
|
var allZenmuxChannelDefs = []zenmuxChannelDef{
|
||||||
{
|
{
|
||||||
Name: "Zenmux",
|
Name: "Zenmux",
|
||||||
Type: 50, // OpenAICompatible
|
Type: channeltype.OpenAICompatible,
|
||||||
Protocols: []string{"chat.completions", "responses", "messages", "gemini", "generate"},
|
Protocols: []string{"chat.completions", "responses", "messages", "gemini", "generate"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import (
|
|||||||
func relayHelper(c *gin.Context, relayMode int) *model.ErrorWithStatusCode {
|
func relayHelper(c *gin.Context, relayMode int) *model.ErrorWithStatusCode {
|
||||||
var err *model.ErrorWithStatusCode
|
var err *model.ErrorWithStatusCode
|
||||||
switch relayMode {
|
switch relayMode {
|
||||||
case relaymode.ImagesGenerations:
|
case relaymode.ImagesGenerations, relaymode.ImagesEdits:
|
||||||
err = controller.RelayImageHelper(c, relayMode)
|
err = controller.RelayImageHelper(c, relayMode)
|
||||||
case relaymode.AudioSpeech:
|
case relaymode.AudioSpeech:
|
||||||
fallthrough
|
fallthrough
|
||||||
|
|||||||
@ -50,6 +50,11 @@ type ChannelConfig struct {
|
|||||||
Plugin string `json:"plugin,omitempty"`
|
Plugin string `json:"plugin,omitempty"`
|
||||||
VertexAIProjectID string `json:"vertex_ai_project_id,omitempty"`
|
VertexAIProjectID string `json:"vertex_ai_project_id,omitempty"`
|
||||||
VertexAIADC string `json:"vertex_ai_adc,omitempty"`
|
VertexAIADC string `json:"vertex_ai_adc,omitempty"`
|
||||||
|
// UsageAPIURL is an optional URL template for fetching accurate per-request
|
||||||
|
// token usage after a generation completes. Use {id} as a placeholder for
|
||||||
|
// the generation/request ID returned in the upstream response.
|
||||||
|
// Example: https://zenmux.ai/api/v1/generation?ctoken=xxx&id={id}
|
||||||
|
UsageAPIURL string `json:"usage_api_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllChannels(startIdx int, num int, scope string) ([]*Channel, error) {
|
func GetAllChannels(startIdx int, num int, scope string) ([]*Channel, error) {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ func DoRequestHelper(a Adaptor, c *gin.Context, meta *meta.Meta, requestBody io.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get request url failed: %w", err)
|
return nil, fmt.Errorf("get request url failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
|
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("new request failed: %w", err)
|
return nil, fmt.Errorf("new request failed: %w", err)
|
||||||
|
|||||||
@ -39,7 +39,22 @@ func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {
|
|||||||
modelName := meta.ActualModelName
|
modelName := meta.ActualModelName
|
||||||
|
|
||||||
switch meta.Mode {
|
switch meta.Mode {
|
||||||
|
case relaymode.ImagesEdits:
|
||||||
|
// Image editing uses :predict (same as generation) with referenceImages in instances[]
|
||||||
|
if strings.Contains(modelName, "/") {
|
||||||
|
return fmt.Sprintf("%s/v1/%s:predict", meta.BaseURL, publisherModelPath(modelName)), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/v1beta/models/%s:predict", meta.BaseURL, modelName), nil
|
||||||
case relaymode.ImagesGenerations:
|
case relaymode.ImagesGenerations:
|
||||||
|
// Imagen 3+ models use :generateImages endpoint
|
||||||
|
if isImagen3Model(modelName) {
|
||||||
|
return fmt.Sprintf("%s/v1/models/%s:generateImages", meta.BaseURL, publisherModelPath(modelName)), nil
|
||||||
|
}
|
||||||
|
// Publisher models (e.g. klingai/kling-v2, volcengine/doubao-*) use v1 :predict
|
||||||
|
if strings.Contains(modelName, "/") {
|
||||||
|
return fmt.Sprintf("%s/v1/%s:predict", meta.BaseURL, publisherModelPath(modelName)), nil
|
||||||
|
}
|
||||||
|
// Legacy imagegeneration models (no publisher prefix) use v1beta :predict
|
||||||
return fmt.Sprintf("%s/v1beta/models/%s:predict", meta.BaseURL, modelName), nil
|
return fmt.Sprintf("%s/v1beta/models/%s:predict", meta.BaseURL, modelName), nil
|
||||||
case relaymode.Embeddings:
|
case relaymode.Embeddings:
|
||||||
return fmt.Sprintf("%s/%s/models/%s:batchEmbedContents", meta.BaseURL, version, modelName), nil
|
return fmt.Sprintf("%s/%s/models/%s:batchEmbedContents", meta.BaseURL, version, modelName), nil
|
||||||
@ -49,7 +64,7 @@ func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {
|
|||||||
if meta.IsStream {
|
if meta.IsStream {
|
||||||
action = "streamGenerateContent?alt=sse"
|
action = "streamGenerateContent?alt=sse"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s/%s/models/%s:%s", meta.BaseURL, version, modelName, action), nil
|
return fmt.Sprintf("%s/%s/%s:%s", meta.BaseURL, version, publisherModelPath(modelName), action), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {
|
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {
|
||||||
@ -80,6 +95,41 @@ func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error)
|
|||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
n = 1
|
n = 1
|
||||||
}
|
}
|
||||||
|
// If an image is provided, convert to :editImage format (image-to-image)
|
||||||
|
if request.Image != nil {
|
||||||
|
imageStr, ok := request.Image.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("image field must be a base64 string or data URL")
|
||||||
|
}
|
||||||
|
// Strip data URL prefix if present (e.g. "data:image/png;base64,")
|
||||||
|
base64Data := imageStr
|
||||||
|
if idx := strings.Index(imageStr, ";base64,"); idx != -1 {
|
||||||
|
base64Data = imageStr[idx+8:]
|
||||||
|
}
|
||||||
|
return EditImageRequest{
|
||||||
|
Instances: []EditImageInstance{
|
||||||
|
{
|
||||||
|
Prompt: request.Prompt,
|
||||||
|
ReferenceImages: []ReferenceImageItem{
|
||||||
|
{
|
||||||
|
ReferenceType: "REFERENCE_TYPE_RAW",
|
||||||
|
ReferenceId: 1,
|
||||||
|
ReferenceImage: ReferenceImageData{
|
||||||
|
BytesBase64Encoded: base64Data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if isImagen3Model(request.Model) {
|
||||||
|
return GenerateImagesRequest{
|
||||||
|
Prompt: request.Prompt,
|
||||||
|
NumberOfImages: n,
|
||||||
|
AspectRatio: sizeToAspectRatio(request.Size),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
return ImagenRequest{
|
return ImagenRequest{
|
||||||
Instances: []ImagenInstance{{Prompt: request.Prompt}},
|
Instances: []ImagenInstance{{Prompt: request.Prompt}},
|
||||||
Parameters: ImagenParameters{
|
Parameters: ImagenParameters{
|
||||||
@ -89,7 +139,28 @@ func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sizeToAspectRatio converts OpenAI size strings to Imagen aspect ratios.
|
// publisherModelPath converts publisher/model names to the correct Vertex AI URL path segment.
|
||||||
|
// Google's own models (gemini/, google/) keep the raw /models/publisher/model format.
|
||||||
|
// Third-party publisher models use /publishers/{pub}/models/{model} format.
|
||||||
|
func publisherModelPath(modelName string) string {
|
||||||
|
if idx := strings.Index(modelName, "/"); idx != -1 {
|
||||||
|
publisher := modelName[:idx]
|
||||||
|
model := modelName[idx+1:]
|
||||||
|
if publisher == "gemini" || publisher == "google" {
|
||||||
|
return fmt.Sprintf("models/%s", modelName)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("publishers/%s/models/%s", publisher, model)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("models/%s", modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isImagen3Model returns true for image-generation-only models that use the :generateImages endpoint.
|
||||||
|
func isImagen3Model(modelName string) bool {
|
||||||
|
lower := strings.ToLower(modelName)
|
||||||
|
return strings.HasPrefix(lower, "imagen-3") ||
|
||||||
|
strings.HasPrefix(lower, "imagen-4") ||
|
||||||
|
strings.Contains(lower, "imagegeneration@00")
|
||||||
|
}
|
||||||
func sizeToAspectRatio(size string) string {
|
func sizeToAspectRatio(size string) string {
|
||||||
switch size {
|
switch size {
|
||||||
case "1792x1024":
|
case "1792x1024":
|
||||||
@ -109,7 +180,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Read
|
|||||||
|
|
||||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {
|
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {
|
||||||
if meta.IsStream {
|
if meta.IsStream {
|
||||||
err, usage = StreamHandler(c, resp)
|
err, usage, meta.GenerationId = StreamHandler(c, resp)
|
||||||
if usage != nil {
|
if usage != nil {
|
||||||
usage.PromptTokens = meta.PromptTokens
|
usage.PromptTokens = meta.PromptTokens
|
||||||
}
|
}
|
||||||
@ -117,7 +188,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Met
|
|||||||
switch meta.Mode {
|
switch meta.Mode {
|
||||||
case relaymode.Embeddings:
|
case relaymode.Embeddings:
|
||||||
err, usage = EmbeddingHandler(c, resp)
|
err, usage = EmbeddingHandler(c, resp)
|
||||||
case relaymode.ImagesGenerations:
|
case relaymode.ImagesGenerations, relaymode.ImagesEdits:
|
||||||
err = ImagenHandler(c, resp)
|
err = ImagenHandler(c, resp)
|
||||||
default:
|
default:
|
||||||
err, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName)
|
err, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName)
|
||||||
@ -126,7 +197,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Met
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImagenHandler converts a Google Imagen predict response to OpenAI image response format.
|
// ImagenHandler converts a Google Imagen predict/generateImages response to OpenAI image response format.
|
||||||
|
// Handles both Vertex AI format (predictions[].bytesBase64Encoded) and OpenAI format (data[].b64_json).
|
||||||
func ImagenHandler(c *gin.Context, resp *http.Response) *model.ErrorWithStatusCode {
|
func ImagenHandler(c *gin.Context, resp *http.Response) *model.ErrorWithStatusCode {
|
||||||
responseBody, readErr := io.ReadAll(resp.Body)
|
responseBody, readErr := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@ -134,11 +206,40 @@ func ImagenHandler(c *gin.Context, resp *http.Response) *model.ErrorWithStatusCo
|
|||||||
return openai.ErrorWrapper(readErr, "read_response_body_failed", http.StatusInternalServerError)
|
return openai.ErrorWrapper(readErr, "read_response_body_failed", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
var imagenResp ImagenResponse
|
fmt.Printf("[ImagenHandler] raw response body: %s\n", string(responseBody))
|
||||||
if err := json.Unmarshal(responseBody, &imagenResp); err != nil {
|
|
||||||
return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError)
|
// Safety check: empty response
|
||||||
|
if len(responseBody) == 0 || string(responseBody) == "{}" || string(responseBody) == "null" {
|
||||||
|
return openai.ErrorWrapper(
|
||||||
|
fmt.Errorf("upstream returned empty response body"),
|
||||||
|
"empty_response_body",
|
||||||
|
http.StatusBadGateway,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data := make([]openai.ImageData, 0)
|
||||||
|
|
||||||
|
// Format 1: :generateImages / :editImage → generatedImages[].image.imageBytes
|
||||||
|
var generateResp GenerateImagesResponse
|
||||||
|
if err := json.Unmarshal(responseBody, &generateResp); err == nil {
|
||||||
|
if generateResp.Error != nil {
|
||||||
|
return openai.ErrorWrapper(
|
||||||
|
fmt.Errorf("%s", generateResp.Error.Message),
|
||||||
|
"imagen_error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for _, img := range generateResp.GeneratedImages {
|
||||||
|
if img.Image.ImageBytes != "" {
|
||||||
|
data = append(data, openai.ImageData{B64Json: img.Image.ImageBytes})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format 2: legacy :predict → predictions[].bytesBase64Encoded or predictions[].gcsUri
|
||||||
|
if len(data) == 0 {
|
||||||
|
var imagenResp ImagenResponse
|
||||||
|
if err := json.Unmarshal(responseBody, &imagenResp); err == nil {
|
||||||
if imagenResp.Error != nil {
|
if imagenResp.Error != nil {
|
||||||
return openai.ErrorWrapper(
|
return openai.ErrorWrapper(
|
||||||
fmt.Errorf("%s", imagenResp.Error.Message),
|
fmt.Errorf("%s", imagenResp.Error.Message),
|
||||||
@ -146,12 +247,38 @@ func ImagenHandler(c *gin.Context, resp *http.Response) *model.ErrorWithStatusCo
|
|||||||
http.StatusInternalServerError,
|
http.StatusInternalServerError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to OpenAI image response format
|
|
||||||
data := make([]openai.ImageData, 0, len(imagenResp.Predictions))
|
|
||||||
for _, p := range imagenResp.Predictions {
|
for _, p := range imagenResp.Predictions {
|
||||||
|
if p.BytesBase64Encoded != "" {
|
||||||
data = append(data, openai.ImageData{B64Json: p.BytesBase64Encoded})
|
data = append(data, openai.ImageData{B64Json: p.BytesBase64Encoded})
|
||||||
|
} else if p.GcsUri != "" {
|
||||||
|
data = append(data, openai.ImageData{Url: p.GcsUri})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format 3: OpenAI format data[].b64_json — used by models like openai/gpt-image-*
|
||||||
|
if len(data) == 0 {
|
||||||
|
var openaiImgResp struct {
|
||||||
|
Data []struct {
|
||||||
|
B64Json string `json:"b64_json"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(responseBody, &openaiImgResp); err == nil {
|
||||||
|
for _, item := range openaiImgResp.Data {
|
||||||
|
data = append(data, openai.ImageData{B64Json: item.B64Json, Url: item.URL})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return openai.ErrorWrapper(
|
||||||
|
fmt.Errorf("upstream returned no image data (response: %s)", string(responseBody)),
|
||||||
|
"no_image_data",
|
||||||
|
http.StatusBadGateway,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
openaiResp := openai.ImageResponse{
|
openaiResp := openai.ImageResponse{
|
||||||
Created: time.Now().Unix(),
|
Created: time.Now().Unix(),
|
||||||
Data: data,
|
Data: data,
|
||||||
|
|||||||
@ -321,13 +321,21 @@ func ConvertEmbeddingRequest(request model.GeneralOpenAIRequest) *BatchEmbedding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TokensDetail struct {
|
||||||
|
Modality string `json:"modality"`
|
||||||
|
TokenCount int `json:"tokenCount"`
|
||||||
|
}
|
||||||
|
|
||||||
type UsageMetadata struct {
|
type UsageMetadata struct {
|
||||||
PromptTokenCount int `json:"promptTokenCount"`
|
PromptTokenCount int `json:"promptTokenCount"`
|
||||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||||
TotalTokenCount int `json:"totalTokenCount"`
|
TotalTokenCount int `json:"totalTokenCount"`
|
||||||
|
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||||
|
CandidatesTokensDetails []TokensDetail `json:"candidatesTokensDetails"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
|
Id string `json:"id,omitempty"` // set by some proxies; used for metadata fetches
|
||||||
Candidates []ChatCandidate `json:"candidates"`
|
Candidates []ChatCandidate `json:"candidates"`
|
||||||
PromptFeedback ChatPromptFeedback `json:"promptFeedback"`
|
PromptFeedback ChatPromptFeedback `json:"promptFeedback"`
|
||||||
UsageMetadata *UsageMetadata `json:"usageMetadata"`
|
UsageMetadata *UsageMetadata `json:"usageMetadata"`
|
||||||
@ -475,9 +483,15 @@ func embeddingResponseGemini2OpenAI(response *EmbeddingResponse) *openai.Embeddi
|
|||||||
return &openAIEmbeddingResponse
|
return &openAIEmbeddingResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {
|
// StreamHandler processes a Gemini SSE stream and returns
|
||||||
|
// (error, usage, generationId).
|
||||||
|
// generationId is captured from the response's "id" field if present (set by
|
||||||
|
// some proxies); callers can use it for post-response metadata fetches.
|
||||||
|
func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage, string) {
|
||||||
var usage *model.Usage
|
var usage *model.Usage
|
||||||
|
var generationId string
|
||||||
responseText := ""
|
responseText := ""
|
||||||
|
outputImageCount := 0
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
// Default bufio.Scanner buffer is 64KB which is too small for inline image data (base64).
|
// Default bufio.Scanner buffer is 64KB which is too small for inline image data (base64).
|
||||||
// Allocate 20MB to handle large image payloads from Gemini image-generation models.
|
// Allocate 20MB to handle large image payloads from Gemini image-generation models.
|
||||||
@ -503,14 +517,36 @@ func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusC
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if generationId == "" && geminiResponse.Id != "" {
|
||||||
|
generationId = geminiResponse.Id
|
||||||
|
}
|
||||||
|
|
||||||
// Extract usageMetadata from the last chunk that carries it.
|
// Extract usageMetadata from the last chunk that carries it.
|
||||||
// This includes image/video/audio generation costs that cannot be
|
|
||||||
// estimated from text tokenisation alone.
|
|
||||||
if geminiResponse.UsageMetadata != nil {
|
if geminiResponse.UsageMetadata != nil {
|
||||||
|
meta := geminiResponse.UsageMetadata
|
||||||
|
// Image output tokens are priced at $60/M vs text completion $3/M (20× ratio).
|
||||||
|
// Separate image and text tokens from candidatesTokensDetails so we can
|
||||||
|
// represent image tokens as equivalent text completion tokens for billing.
|
||||||
|
imageOutputTokens := 0
|
||||||
|
textOutputTokens := 0
|
||||||
|
if len(meta.CandidatesTokensDetails) > 0 {
|
||||||
|
for _, d := range meta.CandidatesTokensDetails {
|
||||||
|
if d.Modality == "IMAGE" {
|
||||||
|
imageOutputTokens += d.TokenCount
|
||||||
|
} else {
|
||||||
|
textOutputTokens += d.TokenCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
textOutputTokens = meta.CandidatesTokenCount
|
||||||
|
}
|
||||||
|
// ThoughtsTokenCount billed at text completion rate; image tokens at 20× that rate.
|
||||||
|
const imageToTextRatio = 20
|
||||||
|
completionTokens := textOutputTokens + imageOutputTokens*imageToTextRatio + meta.ThoughtsTokenCount
|
||||||
usage = &model.Usage{
|
usage = &model.Usage{
|
||||||
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
|
PromptTokens: meta.PromptTokenCount,
|
||||||
CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount,
|
CompletionTokens: completionTokens,
|
||||||
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
|
TotalTokens: meta.TotalTokenCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -520,11 +556,13 @@ func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusC
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Accumulate text for fallback token estimation (used only when
|
// Accumulate text for fallback token estimation (used only when
|
||||||
// usageMetadata is absent from the stream).
|
// usageMetadata is absent from the stream). Also count output images.
|
||||||
if len(geminiResponse.Candidates) > 0 {
|
if len(geminiResponse.Candidates) > 0 {
|
||||||
for _, part := range geminiResponse.Candidates[0].Content.Parts {
|
for _, part := range geminiResponse.Candidates[0].Content.Parts {
|
||||||
if part.InlineData == nil {
|
if part.InlineData == nil {
|
||||||
responseText += part.Text
|
responseText += part.Text
|
||||||
|
} else if strings.HasPrefix(part.InlineData.MimeType, "image/") {
|
||||||
|
outputImageCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -543,15 +581,14 @@ func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusC
|
|||||||
|
|
||||||
err := resp.Body.Close()
|
err := resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return openai.ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
|
return openai.ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// If upstream provided usageMetadata, use it (accurate, includes image costs).
|
// If upstream provided usageMetadata, use it (image tokens already adjusted above).
|
||||||
// Otherwise fall back to local tiktoken estimation on text-only content.
|
|
||||||
if usage != nil {
|
if usage != nil {
|
||||||
return nil, usage
|
return nil, usage, generationId
|
||||||
}
|
}
|
||||||
return nil, openai.ResponseText2Usage(responseText, "", 0)
|
return nil, openai.ResponseText2Usage(responseText, "", 0), generationId
|
||||||
}
|
}
|
||||||
|
|
||||||
func Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) {
|
func Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) {
|
||||||
|
|||||||
@ -92,7 +92,7 @@ type ChatGenerationConfig struct {
|
|||||||
|
|
||||||
// --- Google Imagen ---
|
// --- Google Imagen ---
|
||||||
|
|
||||||
// ImagenRequest is the request body for Google Imagen (predict endpoint).
|
// ImagenRequest is the request body for legacy Imagen (predict endpoint).
|
||||||
// POST /v1beta/models/{model}:predict
|
// POST /v1beta/models/{model}:predict
|
||||||
type ImagenRequest struct {
|
type ImagenRequest struct {
|
||||||
Instances []ImagenInstance `json:"instances"`
|
Instances []ImagenInstance `json:"instances"`
|
||||||
@ -108,14 +108,61 @@ type ImagenParameters struct {
|
|||||||
AspectRatio string `json:"aspectRatio,omitempty"`
|
AspectRatio string `json:"aspectRatio,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImagenResponse is the response from Google Imagen.
|
// GenerateImagesRequest is the request body for Imagen 3+ (generateImages endpoint).
|
||||||
|
// POST /v1/models/{model}:generateImages
|
||||||
|
type GenerateImagesRequest struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
NumberOfImages int `json:"number_of_images"`
|
||||||
|
AspectRatio string `json:"aspectRatio,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditImageRequest is the request body for Vertex AI image editing via :predict.
|
||||||
|
// Same endpoint as generation, uses instances[] with referenceImages. No parameters field.
|
||||||
|
type EditImageRequest struct {
|
||||||
|
Instances []EditImageInstance `json:"instances"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditImageInstance struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
ReferenceImages []ReferenceImageItem `json:"referenceImages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReferenceImageItem struct {
|
||||||
|
ReferenceType string `json:"referenceType"`
|
||||||
|
ReferenceId int `json:"referenceId"`
|
||||||
|
ReferenceImage ReferenceImageData `json:"referenceImage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReferenceImageData struct {
|
||||||
|
BytesBase64Encoded string `json:"bytesBase64Encoded"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImagenResponse is the response from legacy Imagen :predict endpoint.
|
||||||
type ImagenResponse struct {
|
type ImagenResponse struct {
|
||||||
Predictions []ImagenPrediction `json:"predictions"`
|
Predictions []ImagenPrediction `json:"predictions"`
|
||||||
Error *Error `json:"error,omitempty"`
|
Error *Error `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImagenPrediction struct {
|
// GenerateImagesResponse is the response from :generateImages and :editImage endpoints.
|
||||||
BytesBase64Encoded string `json:"bytesBase64Encoded"`
|
type GenerateImagesResponse struct {
|
||||||
|
GeneratedImages []GeneratedImageItem `json:"generatedImages"`
|
||||||
|
Error *Error `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeneratedImageItem struct {
|
||||||
|
Image GeneratedImageData `json:"image"`
|
||||||
MimeType string `json:"mimeType"`
|
MimeType string `json:"mimeType"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GeneratedImageData struct {
|
||||||
|
// ImageBytes is base64-encoded image data returned by :generateImages / :editImage
|
||||||
|
ImageBytes string `json:"imageBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImagenPrediction struct {
|
||||||
|
BytesBase64Encoded string `json:"bytesBase64Encoded"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
GcsUri string `json:"gcsUri"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -109,7 +109,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Read
|
|||||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {
|
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {
|
||||||
if meta.IsStream {
|
if meta.IsStream {
|
||||||
var responseText string
|
var responseText string
|
||||||
err, responseText, usage = StreamHandler(c, resp, meta.Mode)
|
err, responseText, usage, meta.GenerationId = StreamHandler(c, resp, meta.Mode)
|
||||||
if usage == nil || usage.TotalTokens == 0 {
|
if usage == nil || usage.TotalTokens == 0 {
|
||||||
usage = ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens)
|
usage = ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,77 @@
|
|||||||
package openai
|
package openai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/songquanpeng/one-api/common/logger"
|
||||||
"github.com/songquanpeng/one-api/relay/channeltype"
|
"github.com/songquanpeng/one-api/relay/channeltype"
|
||||||
"github.com/songquanpeng/one-api/relay/model"
|
"github.com/songquanpeng/one-api/relay/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var metadataHTTPClient = &http.Client{Timeout: 8 * time.Second}
|
||||||
|
|
||||||
|
// usageMetadataResponse is a generic structure for upstream metadata endpoints
|
||||||
|
// that return nativeTokens (e.g. Zenmux /api/v1/generation).
|
||||||
|
type usageMetadataResponse struct {
|
||||||
|
NativeTokens struct {
|
||||||
|
PromptTokenCount int `json:"promptTokenCount"`
|
||||||
|
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||||
|
TotalTokenCount int `json:"totalTokenCount"`
|
||||||
|
// ThoughtsTokenCount is billed at the completion rate, included in CompletionTokens.
|
||||||
|
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||||
|
} `json:"nativeTokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchUsageFromMetadataURL fetches accurate token usage from an upstream metadata
|
||||||
|
// endpoint. urlTemplate must contain {id} which is replaced with generationId.
|
||||||
|
// Returns nil if the fetch fails or the response contains no usable token data.
|
||||||
|
func FetchUsageFromMetadataURL(urlTemplate, generationId string) (*model.Usage, error) {
|
||||||
|
url := strings.ReplaceAll(urlTemplate, "{id}", generationId)
|
||||||
|
resp, err := metadataHTTPClient.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("metadata API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var meta usageMetadataResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if meta.NativeTokens.TotalTokenCount == 0 {
|
||||||
|
return nil, fmt.Errorf("metadata API returned zero total tokens")
|
||||||
|
}
|
||||||
|
prompt := meta.NativeTokens.PromptTokenCount
|
||||||
|
completion := meta.NativeTokens.CandidatesTokenCount + meta.NativeTokens.ThoughtsTokenCount
|
||||||
|
logger.SysLog(fmt.Sprintf("usage from metadata API (id=%s): prompt=%d completion=%d", generationId, prompt, completion))
|
||||||
|
return &model.Usage{
|
||||||
|
PromptTokens: prompt,
|
||||||
|
CompletionTokens: completion,
|
||||||
|
TotalTokens: prompt + completion,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countOutputMediaTokens returns a fixed token estimate for any embedded
|
||||||
|
// media data URIs found in text (image/video/audio), consistent with the
|
||||||
|
// fixed estimates used for input media in CountTokenMessages.
|
||||||
|
func countOutputMediaTokens(text string) int {
|
||||||
|
tokens := 0
|
||||||
|
tokens += strings.Count(text, "data:image/") * 2500
|
||||||
|
tokens += strings.Count(text, "data:video/") * 10000
|
||||||
|
tokens += strings.Count(text, "data:audio/") * 1500
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
func ResponseText2Usage(responseText string, modelName string, promptTokens int) *model.Usage {
|
func ResponseText2Usage(responseText string, modelName string, promptTokens int) *model.Usage {
|
||||||
usage := &model.Usage{}
|
usage := &model.Usage{}
|
||||||
usage.PromptTokens = promptTokens
|
usage.PromptTokens = promptTokens
|
||||||
usage.CompletionTokens = CountTokenText(responseText, modelName)
|
usage.CompletionTokens = CountTokenText(stripBase64Payloads(responseText), modelName) +
|
||||||
|
countOutputMediaTokens(responseText)
|
||||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
return usage
|
return usage
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,11 +24,16 @@ const (
|
|||||||
dataPrefixLength = len(dataPrefix)
|
dataPrefixLength = len(dataPrefix)
|
||||||
)
|
)
|
||||||
|
|
||||||
func StreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*model.ErrorWithStatusCode, string, *model.Usage) {
|
// StreamHandler processes an OpenAI-compatible SSE stream and returns
|
||||||
|
// (error, responseText, usage, generationId).
|
||||||
|
// generationId is the "id" field captured from the first stream chunk; it can
|
||||||
|
// be used by callers that need to fetch per-request metadata from an upstream API.
|
||||||
|
func StreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*model.ErrorWithStatusCode, string, *model.Usage, string) {
|
||||||
responseText := ""
|
responseText := ""
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
scanner.Split(bufio.ScanLines)
|
scanner.Split(bufio.ScanLines)
|
||||||
var usage *model.Usage
|
var usage *model.Usage
|
||||||
|
var generationId string
|
||||||
|
|
||||||
common.SetEventStreamHeaders(c)
|
common.SetEventStreamHeaders(c)
|
||||||
|
|
||||||
@ -63,6 +68,9 @@ func StreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*model.E
|
|||||||
for _, choice := range streamResponse.Choices {
|
for _, choice := range streamResponse.Choices {
|
||||||
responseText += conv.AsString(choice.Delta.Content)
|
responseText += conv.AsString(choice.Delta.Content)
|
||||||
}
|
}
|
||||||
|
if generationId == "" && streamResponse.Id != "" {
|
||||||
|
generationId = streamResponse.Id
|
||||||
|
}
|
||||||
if streamResponse.Usage != nil {
|
if streamResponse.Usage != nil {
|
||||||
usage = streamResponse.Usage
|
usage = streamResponse.Usage
|
||||||
}
|
}
|
||||||
@ -90,10 +98,10 @@ func StreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*model.E
|
|||||||
|
|
||||||
err := resp.Body.Close()
|
err := resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), "", nil
|
return ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), "", nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, responseText, usage
|
return nil, responseText, usage, generationId
|
||||||
}
|
}
|
||||||
|
|
||||||
func Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) {
|
func Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) {
|
||||||
|
|||||||
@ -73,6 +73,33 @@ func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int {
|
|||||||
return len(tokenEncoder.Encode(text, nil, nil))
|
return len(tokenEncoder.Encode(text, nil, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stripBase64Payloads removes the base64 payload from data URIs so that huge
|
||||||
|
// image/video/audio blobs do not inflate the local token estimate.
|
||||||
|
// Example: "data:image/jpeg;base64,/9j/4AAQ..." → "data:image/jpeg;base64,"
|
||||||
|
func stripBase64Payloads(s string) string {
|
||||||
|
const marker = ";base64,"
|
||||||
|
out := s
|
||||||
|
for {
|
||||||
|
idx := strings.Index(out, marker)
|
||||||
|
if idx < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
payloadStart := idx + len(marker)
|
||||||
|
end := payloadStart
|
||||||
|
for end < len(out) {
|
||||||
|
c := out[end]
|
||||||
|
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||||
|
(c >= '0' && c <= '9') || c == '+' || c == '/' || c == '=' {
|
||||||
|
end++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = out[:payloadStart] + out[end:]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func CountTokenMessages(messages []model.Message, model string) int {
|
func CountTokenMessages(messages []model.Message, model string) int {
|
||||||
tokenEncoder := getTokenEncoder(model)
|
tokenEncoder := getTokenEncoder(model)
|
||||||
// Reference:
|
// Reference:
|
||||||
@ -94,7 +121,7 @@ func CountTokenMessages(messages []model.Message, model string) int {
|
|||||||
tokenNum += tokensPerMessage
|
tokenNum += tokensPerMessage
|
||||||
switch v := message.Content.(type) {
|
switch v := message.Content.(type) {
|
||||||
case string:
|
case string:
|
||||||
tokenNum += getTokenNum(tokenEncoder, v)
|
tokenNum += getTokenNum(tokenEncoder, stripBase64Payloads(v))
|
||||||
case []any:
|
case []any:
|
||||||
for _, it := range v {
|
for _, it := range v {
|
||||||
m := it.(map[string]any)
|
m := it.(map[string]any)
|
||||||
@ -102,7 +129,7 @@ func CountTokenMessages(messages []model.Message, model string) int {
|
|||||||
case "text":
|
case "text":
|
||||||
if textValue, ok := m["text"]; ok {
|
if textValue, ok := m["text"]; ok {
|
||||||
if textString, ok := textValue.(string); ok {
|
if textString, ok := textValue.(string); ok {
|
||||||
tokenNum += getTokenNum(tokenEncoder, textString)
|
tokenNum += getTokenNum(tokenEncoder, stripBase64Payloads(textString))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "image_url":
|
case "image_url":
|
||||||
@ -114,12 +141,24 @@ func CountTokenMessages(messages []model.Message, model string) int {
|
|||||||
detail = imageUrl["detail"].(string)
|
detail = imageUrl["detail"].(string)
|
||||||
}
|
}
|
||||||
imageTokens, err := countImageTokens(url, detail, model)
|
imageTokens, err := countImageTokens(url, detail, model)
|
||||||
|
if err != nil || imageTokens == 0 {
|
||||||
|
// Fallback for base64 images (payload stripped, dimensions
|
||||||
|
// unavailable) or any other failure: use a conservative
|
||||||
|
// fixed estimate (~768x768 high-detail image).
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.SysError("error counting image tokens: " + err.Error())
|
logger.SysError("error counting image tokens, using fallback: " + err.Error())
|
||||||
} else {
|
}
|
||||||
|
imageTokens = 765
|
||||||
|
}
|
||||||
tokenNum += imageTokens
|
tokenNum += imageTokens
|
||||||
}
|
}
|
||||||
}
|
case "video_url":
|
||||||
|
// Videos cannot be tokenised locally; conservative fixed estimate.
|
||||||
|
// Final billing always uses the upstream's real usage.
|
||||||
|
tokenNum += 2000
|
||||||
|
case "input_audio", "audio":
|
||||||
|
// Audio cannot be tokenised locally; conservative fixed estimate.
|
||||||
|
tokenNum += 500
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ func (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.G
|
|||||||
|
|
||||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {
|
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {
|
||||||
if meta.IsStream {
|
if meta.IsStream {
|
||||||
err, usage = gemini.StreamHandler(c, resp)
|
err, usage, meta.GenerationId = gemini.StreamHandler(c, resp)
|
||||||
if usage != nil {
|
if usage != nil {
|
||||||
usage.PromptTokens = meta.PromptTokens
|
usage.PromptTokens = meta.PromptTokens
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Read
|
|||||||
|
|
||||||
func (a *Adaptor) DoResponseV4(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {
|
func (a *Adaptor) DoResponseV4(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {
|
||||||
if meta.IsStream {
|
if meta.IsStream {
|
||||||
err, _, usage = openai.StreamHandler(c, resp, meta.Mode)
|
err, _, usage, _ = openai.StreamHandler(c, resp, meta.Mode)
|
||||||
} else {
|
} else {
|
||||||
err, usage = openai.Handler(c, resp, meta.PromptTokens, meta.ActualModelName)
|
err, usage = openai.Handler(c, resp, meta.PromptTokens, meta.ActualModelName)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,12 @@ var ImageSizeRatios = map[string]map[string]float64{
|
|||||||
"1024x1792": 2,
|
"1024x1792": 2,
|
||||||
"1792x1024": 2,
|
"1792x1024": 2,
|
||||||
},
|
},
|
||||||
|
// gpt-image-1.5: base=medium 1024x1024 ($0.034), size ratio for other sizes
|
||||||
|
"openai/gpt-image-1.5": {
|
||||||
|
"1024x1024": 1.0, // medium $0.034
|
||||||
|
"1024x1536": 1.47, // medium $0.050
|
||||||
|
"1536x1024": 1.47, // medium $0.050
|
||||||
|
},
|
||||||
"ali-stable-diffusion-xl": {
|
"ali-stable-diffusion-xl": {
|
||||||
"512x1024": 1,
|
"512x1024": 1,
|
||||||
"1024x768": 1,
|
"1024x768": 1,
|
||||||
|
|||||||
@ -87,6 +87,14 @@ var ModelRatio = map[string]float64{
|
|||||||
"text-moderation-latest": 0.1,
|
"text-moderation-latest": 0.1,
|
||||||
"dall-e-2": 0.02 * USD, // $0.016 - $0.020 / image
|
"dall-e-2": 0.02 * USD, // $0.016 - $0.020 / image
|
||||||
"dall-e-3": 0.04 * USD, // $0.040 - $0.120 / image
|
"dall-e-3": 0.04 * USD, // $0.040 - $0.120 / image
|
||||||
|
// Zenmux image generation models (calibrated for 1M quota/dollar)
|
||||||
|
"openai/gpt-image-1.5": 34, // $0.034 / image (medium 1024x1024 base)
|
||||||
|
"qwen/qwen-image-2.0": 28.9, // $0.0289 / image
|
||||||
|
"qwen/qwen-image-2.0-pro": 73, // $0.073 / image
|
||||||
|
"volcengine/doubao-seedream-5.0-lite": 32, // $0.032 / image
|
||||||
|
"z-ai/glm-image": 15, // $0.015 / image
|
||||||
|
"tencent/hunyuan-image3": 29, // $0.029 / image
|
||||||
|
"klingai/kling-v2": 14, // $0.014 / image
|
||||||
// https://docs.anthropic.com/en/docs/about-claude/models
|
// https://docs.anthropic.com/en/docs/about-claude/models
|
||||||
"claude-instant-1.2": 0.8 / 1000 * USD,
|
"claude-instant-1.2": 0.8 / 1000 * USD,
|
||||||
"claude-2.0": 8.0 / 1000 * USD,
|
"claude-2.0": 8.0 / 1000 * USD,
|
||||||
|
|||||||
@ -82,6 +82,16 @@ func RelayTextHelper(c *gin.Context) *model.ErrorWithStatusCode {
|
|||||||
billing.ReturnPreConsumedQuota(ctx, preConsumedQuota, meta.TokenId)
|
billing.ReturnPreConsumedQuota(ctx, preConsumedQuota, meta.TokenId)
|
||||||
return respErr
|
return respErr
|
||||||
}
|
}
|
||||||
|
// If the channel has a UsageAPIURL configured and the adaptor captured a
|
||||||
|
// generation ID, fetch accurate multimodal token counts now (works for any
|
||||||
|
// channel type that sets meta.GenerationId during DoResponse).
|
||||||
|
if meta.Config.UsageAPIURL != "" && meta.GenerationId != "" {
|
||||||
|
if fetchedUsage, fetchErr := openai.FetchUsageFromMetadataURL(meta.Config.UsageAPIURL, meta.GenerationId); fetchErr == nil {
|
||||||
|
usage = fetchedUsage
|
||||||
|
} else {
|
||||||
|
logger.Warnf(ctx, "failed to fetch usage from metadata URL: %s", fetchErr.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
// post-consume quota
|
// post-consume quota
|
||||||
go postConsumeQuota(ctx, usage, meta, textRequest, ratio, preConsumedQuota, modelRatio, groupRatio, systemPromptReset)
|
go postConsumeQuota(ctx, usage, meta, textRequest, ratio, preConsumedQuota, modelRatio, groupRatio, systemPromptReset)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -35,6 +35,9 @@ type Meta struct {
|
|||||||
PromptTokens int // only for DoResponse
|
PromptTokens int // only for DoResponse
|
||||||
ForcedSystemPrompt string
|
ForcedSystemPrompt string
|
||||||
StartTime time.Time
|
StartTime time.Time
|
||||||
|
// GenerationId is the upstream generation/request ID captured during DoResponse.
|
||||||
|
// Adaptors should set this when available; it is used for post-response metadata fetches.
|
||||||
|
GenerationId string
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetByContext(c *gin.Context) *Meta {
|
func GetByContext(c *gin.Context) *Meta {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package model
|
|||||||
type ImageRequest struct {
|
type ImageRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Prompt string `json:"prompt" binding:"required"`
|
Prompt string `json:"prompt" binding:"required"`
|
||||||
|
Image any `json:"image,omitempty"`
|
||||||
N int `json:"n,omitempty"`
|
N int `json:"n,omitempty"`
|
||||||
Size string `json:"size,omitempty"`
|
Size string `json:"size,omitempty"`
|
||||||
Quality string `json:"quality,omitempty"`
|
Quality string `json:"quality,omitempty"`
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const (
|
|||||||
Embeddings
|
Embeddings
|
||||||
Moderations
|
Moderations
|
||||||
ImagesGenerations
|
ImagesGenerations
|
||||||
|
ImagesEdits
|
||||||
Edits
|
Edits
|
||||||
AudioSpeech
|
AudioSpeech
|
||||||
AudioTranscription
|
AudioTranscription
|
||||||
|
|||||||
@ -16,6 +16,8 @@ func GetByPath(path string) int {
|
|||||||
relayMode = Moderations
|
relayMode = Moderations
|
||||||
} else if strings.HasPrefix(path, "/v1/images/generations") {
|
} else if strings.HasPrefix(path, "/v1/images/generations") {
|
||||||
relayMode = ImagesGenerations
|
relayMode = ImagesGenerations
|
||||||
|
} else if strings.HasPrefix(path, "/v1/images/edits") {
|
||||||
|
relayMode = ImagesEdits
|
||||||
} else if strings.HasPrefix(path, "/v1/edits") {
|
} else if strings.HasPrefix(path, "/v1/edits") {
|
||||||
relayMode = Edits
|
relayMode = Edits
|
||||||
} else if strings.HasPrefix(path, "/v1/audio/speech") {
|
} else if strings.HasPrefix(path, "/v1/audio/speech") {
|
||||||
|
|||||||
@ -25,7 +25,7 @@ func SetRelayRouter(router *gin.Engine) {
|
|||||||
relayV1Router.POST("/chat/completions", controller.Relay)
|
relayV1Router.POST("/chat/completions", controller.Relay)
|
||||||
relayV1Router.POST("/edits", controller.Relay)
|
relayV1Router.POST("/edits", controller.Relay)
|
||||||
relayV1Router.POST("/images/generations", controller.Relay)
|
relayV1Router.POST("/images/generations", controller.Relay)
|
||||||
relayV1Router.POST("/images/edits", controller.RelayNotImplemented)
|
relayV1Router.POST("/images/edits", controller.Relay)
|
||||||
relayV1Router.POST("/images/variations", controller.RelayNotImplemented)
|
relayV1Router.POST("/images/variations", controller.RelayNotImplemented)
|
||||||
relayV1Router.POST("/embeddings", controller.Relay)
|
relayV1Router.POST("/embeddings", controller.Relay)
|
||||||
relayV1Router.POST("/engines/:model/embeddings", controller.Relay)
|
relayV1Router.POST("/engines/:model/embeddings", controller.Relay)
|
||||||
|
|||||||
@ -44,7 +44,7 @@ const validationSchema = Yup.object().shape({
|
|||||||
is: (is_edit, type) => !is_edit && type !== 33,
|
is: (is_edit, type) => !is_edit && type !== 33,
|
||||||
then: Yup.string().required('密钥 不能为空')
|
then: Yup.string().required('密钥 不能为空')
|
||||||
}),
|
}),
|
||||||
other: Yup.string(),
|
other: Yup.string().nullable(),
|
||||||
models: Yup.array().min(1, '模型 不能为空'),
|
models: Yup.array().min(1, '模型 不能为空'),
|
||||||
groups: Yup.array().min(1, '用户组 不能为空'),
|
groups: Yup.array().min(1, '用户组 不能为空'),
|
||||||
base_url: Yup.string().when('type', {
|
base_url: Yup.string().when('type', {
|
||||||
|
|||||||
@ -19,6 +19,9 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Button,
|
Button,
|
||||||
|
Select,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
import Label from "ui-component/Label";
|
import Label from "ui-component/Label";
|
||||||
@ -28,7 +31,7 @@ import ResponseTimeLabel from "./ResponseTimeLabel";
|
|||||||
import GroupLabel from "./GroupLabel";
|
import GroupLabel from "./GroupLabel";
|
||||||
import NameLabel from "./NameLabel";
|
import NameLabel from "./NameLabel";
|
||||||
|
|
||||||
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
|
import { IconDotsVertical, IconEdit, IconTrash, IconBrandSpeedtest } from "@tabler/icons-react";
|
||||||
|
|
||||||
export default function ChannelTableRow({
|
export default function ChannelTableRow({
|
||||||
item,
|
item,
|
||||||
@ -36,9 +39,12 @@ export default function ChannelTableRow({
|
|||||||
handleOpenModal,
|
handleOpenModal,
|
||||||
setModalChannelId,
|
setModalChannelId,
|
||||||
}) {
|
}) {
|
||||||
|
const modelOptions = item.models ? item.models.split(',').filter(Boolean) : [];
|
||||||
const [open, setOpen] = useState(null);
|
const [open, setOpen] = useState(null);
|
||||||
const [openDelete, setOpenDelete] = useState(false);
|
const [openDelete, setOpenDelete] = useState(false);
|
||||||
|
const [openTest, setOpenTest] = useState(false);
|
||||||
const [statusSwitch, setStatusSwitch] = useState(item.status);
|
const [statusSwitch, setStatusSwitch] = useState(item.status);
|
||||||
|
const [testModel, setTestModel] = useState(modelOptions[0] || '');
|
||||||
const [priorityValve, setPriority] = useState(item.priority);
|
const [priorityValve, setPriority] = useState(item.priority);
|
||||||
const [responseTimeData, setResponseTimeData] = useState({
|
const [responseTimeData, setResponseTimeData] = useState({
|
||||||
test_time: item.test_time,
|
test_time: item.test_time,
|
||||||
@ -87,7 +93,8 @@ export default function ChannelTableRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleResponseTime = async () => {
|
const handleResponseTime = async () => {
|
||||||
const { success, time } = await manageChannel(item.id, "test", "");
|
setOpenTest(false);
|
||||||
|
const { success, time } = await manageChannel(item.id, "test", testModel);
|
||||||
if (success) {
|
if (success) {
|
||||||
setResponseTimeData({
|
setResponseTimeData({
|
||||||
test_time: Date.now() / 1000,
|
test_time: Date.now() / 1000,
|
||||||
@ -223,6 +230,15 @@ export default function ChannelTableRow({
|
|||||||
<IconEdit style={{ marginRight: "16px" }} />
|
<IconEdit style={{ marginRight: "16px" }} />
|
||||||
编辑
|
编辑
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleCloseMenu();
|
||||||
|
setOpenTest(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconBrandSpeedtest style={{ marginRight: "16px" }} />
|
||||||
|
测试
|
||||||
|
</MenuItem>
|
||||||
<MenuItem onClick={handleDeleteOpen} sx={{ color: "error.main" }}>
|
<MenuItem onClick={handleDeleteOpen} sx={{ color: "error.main" }}>
|
||||||
<IconTrash style={{ marginRight: "16px" }} />
|
<IconTrash style={{ marginRight: "16px" }} />
|
||||||
删除
|
删除
|
||||||
@ -241,6 +257,28 @@ export default function ChannelTableRow({
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={openTest} onClose={() => setOpenTest(false)} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>测试渠道:{item.name}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<FormControl fullWidth sx={{ mt: 1 }}>
|
||||||
|
<InputLabel>选择测试模型</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={testModel}
|
||||||
|
label="选择测试模型"
|
||||||
|
onChange={(e) => setTestModel(e.target.value)}
|
||||||
|
>
|
||||||
|
{modelOptions.map((m) => (
|
||||||
|
<MenuItem key={m} value={m}>{m}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOpenTest(false)}>取消</Button>
|
||||||
|
<Button onClick={handleResponseTime} variant="contained">测试</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,7 +108,7 @@ export default function ChannelPage() {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'test':
|
case 'test':
|
||||||
res = await API.get(url + `test/${id}`);
|
res = await API.get(url + `test/${id}` + (value ? `?model=${encodeURIComponent(value)}` : ''));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -123,13 +123,19 @@ const typeConfig = {
|
|||||||
},
|
},
|
||||||
24: {
|
24: {
|
||||||
inputLabel: {
|
inputLabel: {
|
||||||
other: '版本号'
|
other: '版本号',
|
||||||
|
config: {
|
||||||
|
usage_api_url: 'Usage API URL'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
models: ['gemini-pro']
|
models: ['gemini-pro']
|
||||||
},
|
},
|
||||||
prompt: {
|
prompt: {
|
||||||
other: '请输入版本号,例如:v1'
|
other: '请输入版本号,例如:v1',
|
||||||
|
config: {
|
||||||
|
usage_api_url: '可选,填写后将在每次请求结束后从该接口获取真实的多模态 Token 用量,使用 {id} 作为 generation ID 占位符,例如:https://zenmux.ai/api/v1/generation?ctoken=xxx&id={id}'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modelGroup: 'google gemini'
|
modelGroup: 'google gemini'
|
||||||
},
|
},
|
||||||
@ -228,6 +234,18 @@ const typeConfig = {
|
|||||||
45: {
|
45: {
|
||||||
modelGroup: 'xai'
|
modelGroup: 'xai'
|
||||||
},
|
},
|
||||||
|
50: {
|
||||||
|
inputLabel: {
|
||||||
|
config: {
|
||||||
|
usage_api_url: 'Usage API URL'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
config: {
|
||||||
|
usage_api_url: '可选,填写后将在每次请求结束后从该接口获取真实的多模态 Token 用量,使用 {id} 作为 generation ID 占位符,例如:https://zenmux.ai/api/v1/generation?ctoken=xxx&id={id}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { defaultConfig, typeConfig };
|
export { defaultConfig, typeConfig };
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import Table from '@mui/material/Table';
|
|||||||
import TableBody from '@mui/material/TableBody';
|
import TableBody from '@mui/material/TableBody';
|
||||||
import TableContainer from '@mui/material/TableContainer';
|
import TableContainer from '@mui/material/TableContainer';
|
||||||
import PerfectScrollbar from 'react-perfect-scrollbar';
|
import PerfectScrollbar from 'react-perfect-scrollbar';
|
||||||
import TablePagination from '@mui/material/TablePagination';
|
import Pagination from '@mui/material/Pagination';
|
||||||
import LinearProgress from '@mui/material/LinearProgress';
|
import LinearProgress from '@mui/material/LinearProgress';
|
||||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||||
import Toolbar from '@mui/material/Toolbar';
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
@ -143,14 +143,16 @@ export default function Log() {
|
|||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
</PerfectScrollbar>
|
</PerfectScrollbar>
|
||||||
<TablePagination
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||||
page={activePage}
|
<Pagination
|
||||||
component="div"
|
count={Math.ceil(logs.length / ITEMS_PER_PAGE) + (logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}
|
||||||
count={logs.length + (logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}
|
page={activePage + 1}
|
||||||
rowsPerPage={ITEMS_PER_PAGE}
|
onChange={(e, page) => onPaginationChange(e, page - 1)}
|
||||||
onPageChange={onPaginationChange}
|
color="primary"
|
||||||
rowsPerPageOptions={[ITEMS_PER_PAGE]}
|
showFirstButton
|
||||||
|
showLastButton
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user