Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0bad71e
fix video task successful upstream result parsing
CarminBack Jun 29, 2026
6cff1be
fix task per-call billing ratio recalculation
CarminBack Jun 29, 2026
275c96a
show per-second task pricing in marketplace
CarminBack Jun 29, 2026
bf45c77
document seedance video aliases
CarminBack Jun 29, 2026
37a6e53
document grok video api
CarminBack Jun 30, 2026
4529a18
add Chinese video integration docs
CarminBack Jun 30, 2026
2b5c72e
fix seedance per-second pricing display
CarminBack Jul 1, 2026
8f8d4cc
add configurable task billing units
CarminBack Jul 1, 2026
03afab9
add GHCR image workflow
CarminBack Jul 1, 2026
fa0af9e
use native arm runner for GHCR image
CarminBack Jul 1, 2026
3372d35
add aistarslab config sync
CarminBack Jul 3, 2026
bbbf288
add aistarslab profit rate control
CarminBack Jul 3, 2026
2bb1955
fix sora task string error parsing
CarminBack Jul 4, 2026
99384cb
fix sora task error responses without status
CarminBack Jul 4, 2026
b1cd944
fix pricing completion ratio override
CarminBack Jul 4, 2026
a3fe3df
fix topup return url override
CarminBack Jul 6, 2026
e63e970
skip task retry on forbidden upstream responses
CarminBack Jul 8, 2026
d99bfbc
add built-in image resolution pricing
CarminBack Jul 9, 2026
2f618a0
price image group by resolution
CarminBack Jul 9, 2026
5346e64
support gemini native image generation
CarminBack Jul 9, 2026
8012ee7
show image generations in drawing logs
CarminBack Jul 9, 2026
533f49c
store image generation results locally
CarminBack Jul 9, 2026
e102141
fix image generation log previews
CarminBack Jul 9, 2026
6c1ad65
default image generation quality label
CarminBack Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/carmin-ghcr-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Carmin GHCR image

on:
push:
branches:
- video-task-result-fix
workflow_dispatch:

env:
IMAGE_NAME: ghcr.io/carminback/new-api

jobs:
build:
name: Build and push Docker image
runs-on: ubuntu-24.04-arm
permissions:
contents: read
packages: write

steps:
- name: Check out
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 1

- name: Write version
id: version
run: |
VERSION="carmin-$(date +'%Y%m%d')-${GITHUB_SHA::7}"
echo "$VERSION" > VERSION
echo "value=$VERSION" >> "$GITHUB_OUTPUT"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=video-task-result-fix
type=raw,value=${{ steps.version.outputs.value }}
type=sha,prefix=sha-

- name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
39 changes: 39 additions & 0 deletions controller/aistarslab_sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package controller

import (
"errors"
"io"
"net/http"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/service"

"github.com/gin-gonic/gin"
)

func SyncAistarsLabConfig(c *gin.Context) {
var req service.AistarsLabSyncRequest
if c.Request.Body != nil {
err := common.DecodeJson(c.Request.Body, &req)
if err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
}
result, err := service.SyncAistarsLabConfig(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": result,
})
}
52 changes: 52 additions & 0 deletions controller/image_generation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package controller

import (
"net/http"
"os"
"strconv"

"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"

"github.com/gin-gonic/gin"
)

func GetImageGenerationContent(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.Status(http.StatusNotFound)
return
}

record, err := model.GetImageGenerationByID(id)
if err != nil || record == nil {
c.Status(http.StatusNotFound)
return
}

expires, err := strconv.ParseInt(c.Query("expires"), 10, 64)
if err != nil || !model.ValidateImageGenerationContentSignature(record, expires, c.Query("signature")) {
c.Status(http.StatusUnauthorized)
return
}
if record.Status != model.ImageGenerationStatusSuccess || record.FilePath == "" {
c.Status(http.StatusGone)
return
}

absolutePath := service.GetImageGenerationAbsolutePath(record)
if absolutePath == "" {
c.Status(http.StatusGone)
return
}
if _, err := os.Stat(absolutePath); err != nil {
c.Status(http.StatusGone)
return
}

if record.MimeType != "" {
c.Header("Content-Type", record.MimeType)
}
c.Header("Cache-Control", "private, max-age=3600")
c.File(absolutePath)
}
86 changes: 86 additions & 0 deletions controller/image_generation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package controller

import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"

"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupImageGenerationControllerTestDB(t *testing.T) *gorm.DB {
t.Helper()

gin.SetMode(gin.TestMode)
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false
common.RedisEnabled = false

db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
model.DB = db
model.LOG_DB = db
require.NoError(t, db.AutoMigrate(&model.ImageGeneration{}))

t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}

func TestGetImageGenerationContentRequiresValidSignature(t *testing.T) {
db := setupImageGenerationControllerTestDB(t)

storageDir := t.TempDir()
t.Setenv("IMAGE_GENERATION_STORAGE_DIR", storageDir)

relativePath := filepath.Join("20260710", "user-1", "image.png")
absolutePath := filepath.Join(storageDir, relativePath)
require.NoError(t, os.MkdirAll(filepath.Dir(absolutePath), 0750))
require.NoError(t, os.WriteFile(absolutePath, []byte("png-data"), 0600))

record := &model.ImageGeneration{
UserId: 1,
RequestId: "req_image",
FilePath: relativePath,
MimeType: "image/png",
Status: model.ImageGenerationStatusSuccess,
CreatedAt: time.Now().Unix(),
ExpireAt: time.Now().Add(time.Hour).Unix(),
}
require.NoError(t, db.Create(record).Error)

router := gin.New()
router.GET("/api/image-generations/:id/content", GetImageGenerationContent)

missingSignature := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/image-generations/%d/content", record.Id), nil)
router.ServeHTTP(missingSignature, req)
require.Equal(t, http.StatusUnauthorized, missingSignature.Code)

expires := record.ExpireAt
signature := model.GenerateImageGenerationContentSignature(record, expires)
valid := httptest.NewRecorder()
req = httptest.NewRequest(
http.MethodGet,
fmt.Sprintf("/api/image-generations/%d/content?expires=%d&signature=%s", record.Id, expires, signature),
nil,
)
router.ServeHTTP(valid, req)
require.Equal(t, http.StatusOK, valid.Code)
require.Equal(t, "png-data", valid.Body.String())
}
16 changes: 10 additions & 6 deletions controller/midjourney.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,14 @@ func GetAllMidjourney(c *gin.Context) {
EndTimestamp: c.Query("end_timestamp"),
}

items := model.GetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.CountAllTasks(queryParams)
items := model.GetAllDrawingLogs(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.CountAllDrawingLogs(queryParams)

if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
if midjourney.Id > 0 {
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
}
items[i] = midjourney
}
}
Expand All @@ -290,12 +292,14 @@ func GetUserMidjourney(c *gin.Context) {
EndTimestamp: c.Query("end_timestamp"),
}

items := model.GetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.CountAllUserTask(userId, queryParams)
items := model.GetAllUserDrawingLogs(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.CountAllUserDrawingLogs(userId, queryParams)

if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
if midjourney.Id > 0 {
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
}
items[i] = midjourney
}
}
Expand Down
6 changes: 5 additions & 1 deletion controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"

"github.com/bytedance/gopkg/util/gopool"
Expand Down Expand Up @@ -581,7 +582,7 @@ func RelayTask(c *gin.Context) {
ModelRatio: relayInfo.PriceData.ModelRatio,
OtherRatios: relayInfo.PriceData.OtherRatios,
OriginModelName: relayInfo.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
PerCallBilling: ratio_setting.IsTaskPerItemBilling(relayInfo.OriginModelName),
}
task.Quota = result.Quota
task.Data = result.TaskData
Expand Down Expand Up @@ -633,6 +634,9 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError,
if taskErr.StatusCode == http.StatusBadRequest {
return false
}
if taskErr.StatusCode == http.StatusForbidden {
return false
}
if taskErr.StatusCode == 408 {
// azure处理超时不重试
return false
Expand Down
19 changes: 19 additions & 0 deletions controller/relay_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package controller

import (
"net/http"
"testing"

"github.com/QuantumNous/new-api/dto"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

func TestShouldRetryTaskRelaySkipsForbidden(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(nil)

retry := shouldRetryTaskRelay(ctx, 19, &dto.TaskError{StatusCode: http.StatusForbidden}, 5)

require.False(t, retry)
}
11 changes: 10 additions & 1 deletion controller/topup.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ func GetTopUpInfo(c *gin.Context) {
type EpayRequest struct {
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
ReturnUrl string `json:"return_url,omitempty"`
}

type AmountRequest struct {
Expand Down Expand Up @@ -218,7 +219,15 @@ func RequestEpay(c *gin.Context) {
}

callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
returnUrlValue := system_setting.ServerAddress + "/console/log"
if req.ReturnUrl != "" {
if err := common.ValidateRedirectURL(req.ReturnUrl); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "支付完成重定向URL不在可信任域名列表中", "data": ""})
return
}
returnUrlValue = req.ReturnUrl
}
returnUrl, _ := url.Parse(returnUrlValue)
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
Expand Down
Loading