Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
417614a1
authored
Sep 15, 2026
by
ccran
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 增加cdance模型;修改plugin;
parent
d908e226
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
174 additions
and
28 deletions
+174
-28
bin/docker-upload-ubuntu.sh
+23
-9
plugins/tasks/ctyun/plugin.js
+8
-5
relay/channel/ctyun/adaptor_test.go
+27
-0
token模型需求表-0831-合并阶梯计费.xlsx
+0
-0
token模型需求表-0915.xlsx
+0
-0
web/src/components/ai-elements/__tests__/code-block-copy.test.tsx
+106
-0
web/src/components/ai-elements/code-block.tsx
+10
-14
No files found.
bin/docker-upload-ubuntu.sh
View file @
417614a1
...
...
@@ -2,7 +2,7 @@
# 在 Mac(包括 Apple Silicon)上构建目标架构镜像,上传到 Ubuntu 并导入。
# 用法:bash bin/docker-upload-ubuntu.sh [user@host]
# 示例:SSH_PORT=22 IMAGE_TAG=new-api:export bash bin/docker-upload-ubuntu.sh
# SSH 使用本机 ~/.ssh/config、密钥或交互式密码认证。
# SSH 使用本机 ~/.ssh/config、密钥或交互式密码认证
,同次执行复用连接
。
set
-euo
pipefail
if
[[
"
${
1
:-}
"
==
"--help"
||
"
${
1
:-}
"
==
"-h"
]]
;
then
...
...
@@ -36,19 +36,33 @@ done
docker info
>
/dev/null
docker buildx version
>
/dev/null
# 使用短路径,避免 macOS 的临时目录导致 SSH 控制套接字路径过长。
# 临时文件放在构建上下文之外,避免将导出的镜像再次打包进镜像。
export_dir
=
$(
mktemp
-d
/tmp/new-api-export.XXXXXX
)
control_path
=
"
$export_dir
/ssh"
ssh_options
=(
-o
"ControlPath=
$control_path
"
-o
ControlMaster
=
no
-o
BatchMode
=
yes
)
cleanup
()
{
ssh
"
${
ssh_options
[@]
}
"
-p
"
$ssh_port
"
-O
exit
"
$remote_host
"
>
/dev/null 2>&1
||
true
rm
-rf
--
"
$export_dir
"
}
trap
cleanup EXIT
trap
'exit 130'
INT
trap
'exit 143'
TERM
archive_path
=
"
$export_dir
/new-api-export.tar"
# 主连接只认证一次,并在耗时的镜像构建期间保持连接。
ssh
-p
"
$ssh_port
"
-o
"ControlPath=
$control_path
"
-o
ControlMaster
=
yes
\
-o
ControlPersist
=
no
-o
ServerAliveInterval
=
30
-o
ServerAliveCountMax
=
3
\
-fN
"
$remote_host
"
echo
"检查 Ubuntu 架构及 Docker 权限:
$remote_host
"
remote_arch
=
$(
ssh
-p
"
$ssh_port
"
"
$remote_host
"
'set -e; docker info >/dev/null; uname -m'
)
remote_arch
=
$(
ssh
"
${
ssh_options
[@]
}
"
-p
"
$ssh_port
"
"
$remote_host
"
'set -e; docker info >/dev/null; uname -m'
)
case
"
$remote_arch
"
in
x86_64
)
platform
=
linux/amd64
;;
aarch64|arm64
)
platform
=
linux/arm64
;;
*
)
echo
"不支持的远端架构:
$remote_arch
"
>
&2
;
exit
1
;;
esac
# 临时文件放在构建上下文之外,避免将导出的镜像再次打包进镜像。
export_dir
=
$(
mktemp
-d
"
${
TMPDIR
:-
/tmp
}
/new-api-export.XXXXXX"
)
trap
'rm -rf -- "$export_dir"'
EXIT
archive_path
=
"
$export_dir
/new-api-export.tar"
echo
"构建
${
image_tag
}
,目标平台:
${
platform
}
(跨架构构建可能较慢)"
docker buildx build
--platform
"
$platform
"
--load
\
--tag
"
$image_tag
"
--file
"
$project_dir
/Dockerfile"
"
$project_dir
"
...
...
@@ -57,10 +71,10 @@ echo "导出镜像"
docker save
-o
"
$archive_path
"
"
$image_tag
"
echo
"上传至
$remote_host
:~/new-api-export.tar"
scp
-P
"
$ssh_port
"
"
$archive_path
"
"
$remote_host
:new-api-export.tar"
scp
"
${
ssh_options
[@]
}
"
-P
"
$ssh_port
"
"
$archive_path
"
"
$remote_host
:new-api-export.tar"
echo
"在 Ubuntu 导入镜像"
ssh
-p
"
$ssh_port
"
"
$remote_host
"
'docker load -i "$HOME/new-api-export.tar"'
ssh
"
${
ssh_options
[@]
}
"
-p
"
$ssh_port
"
"
$remote_host
"
'docker load -i "$HOME/new-api-export.tar"'
echo
"完成:远端已导入
${
image_tag
}
(
${
platform
}
)。"
echo
"远端归档保留在 ~/new-api-export.tar;本地临时归档会自动删除。"
...
...
plugins/tasks/ctyun/plugin.js
View file @
417614a1
...
...
@@ -3,10 +3,10 @@
export
const
meta
=
{
apiVersion
:
1
,
key
:
"ctyun"
,
name
:
"CTyun
Video
"
,
name
:
"CTyun"
,
description
:
{
en
:
"CTyun TokenHub Seedance, MiniMax H3, Wan and HappyHorse video"
,
zh
:
"天翼云 TokenHub Seedance、MiniMax H3、万相和 HappyHorse 视频"
},
version
:
"1.0.
1
"
,
author
:
{
name
:
"
QuantumNous
"
},
version
:
"1.0.
0
"
,
author
:
{
name
:
"
ccran
"
},
channelTypes
:
[
62
],
models
:
[
"cdance2.0-fast-0807"
,
...
...
@@ -38,7 +38,7 @@ export const meta = {
input_video_seconds
:
{
type
:
"number"
,
unit
:
"second"
,
description
:
{
en
:
"Input video duration"
,
zh
:
"输入视频秒数"
}
},
input_audio_seconds
:
{
type
:
"number"
,
unit
:
"second"
,
description
:
{
en
:
"Input audio duration"
,
zh
:
"输入音频秒数"
}
},
input_images
:
{
type
:
"number"
,
unit
:
"count"
,
description
:
{
en
:
"Input image count"
,
zh
:
"输入图片数量"
}
},
resolution
:
{
enum
:
[
"480P"
,
"720P"
,
"768P"
,
"1080P"
,
"2K"
],
description
:
{
en
:
"Output resolution"
,
zh
:
"输出分辨率"
}
},
resolution
:
{
enum
:
[
"480P"
,
"720P"
,
"768P"
,
"1080P"
,
"2K"
,
"4K"
],
description
:
{
en
:
"Output resolution"
,
zh
:
"输出分辨率"
}
},
video_input
:
{
enum
:
[
"none"
,
"video"
],
description
:
{
en
:
"Reference video input"
,
zh
:
"参考视频输入"
}
},
},
};
...
...
@@ -92,6 +92,9 @@ function videoRequest(ctx) {
let
resolution
=
req
.
resolution
||
req
.
size
||
parameters
.
resolution
||
body
.
resolution
||
(
kind
===
"h3"
?
"768P"
:
"1080P"
);
resolution
=
String
(
resolution
).
toUpperCase
();
const
resolutions
=
kind
===
"h3"
?
[
"480P"
,
"768P"
,
"2K"
]
:
[
"480P"
,
"720P"
,
"1080P"
];
// Cdance resolution availability is decided by the upstream model.
if
([
ctx
.
upstreamModel
,
ctx
.
model
].
some
((
name
)
=>
String
(
name
||
""
).
toLowerCase
().
startsWith
(
"cdance2.0"
)))
resolutions
.
push
(
"4K"
);
if
(
!
resolutions
.
includes
(
resolution
))
throw
new
Error
(
"unsupported video resolution"
);
const
seed
=
parameters
.
seed
===
undefined
?
body
.
seed
:
parameters
.
seed
;
if
(
seed
!==
undefined
&&
(
!
Number
.
isInteger
(
Number
(
seed
))
||
Number
(
seed
)
<
0
||
Number
(
seed
)
>
2147483647
))
...
...
@@ -251,7 +254,7 @@ export function extractUsage(ctx) {
if
(
kind
===
"seedance"
)
{
// Same conservative reservation formula as the Doubao plugin; actual
// upstream tokens replace this estimate at settlement.
const
pixels
=
{
"480P"
:
854
*
480
,
"720P"
:
1280
*
720
,
"1080P"
:
1920
*
1080
};
const
pixels
=
{
"480P"
:
854
*
480
,
"720P"
:
1280
*
720
,
"1080P"
:
1920
*
1080
,
"4K"
:
3840
*
2160
};
facts
.
tokens
=
Math
.
ceil
(((
facts
.
seconds
+
facts
.
input_video_seconds
)
*
pixels
[
facts
.
resolution
]
*
24
)
/
1024
);
}
return
facts
;
...
...
relay/channel/ctyun/adaptor_test.go
View file @
417614a1
...
...
@@ -305,6 +305,33 @@ func TestCTyunCdanceRoutingAndTieredSettlement(t *testing.T) {
require
.
NoError
(
t
,
callErr
)
assert
.
Equal
(
t
,
"https://ai.ctaigw.cn/v1/contents/generations/tasks/job"
,
query
.
(
map
[
string
]
any
)[
"url"
])
})
t
.
Run
(
name
+
"/4K"
,
func
(
t
*
testing
.
T
)
{
ctx
:=
map
[
string
]
any
{
"model"
:
"custom-video"
,
"upstreamModel"
:
name
,
"baseUrl"
:
"https://example.com"
,
"apiKey"
:
"test"
,
"requestBody"
:
map
[
string
]
any
{
"prompt"
:
"waves"
,
"seconds"
:
15
,
"resolution"
:
"4K"
,
"content"
:
[]
any
{
map
[
string
]
any
{
"type"
:
"video_url"
,
"video_url"
:
map
[
string
]
any
{
"url"
:
"https://example.com/input.mp4"
}}}}}
submit
,
err
:=
plugin
.
Engine
.
Call
(
t
.
Context
(),
"buildSubmitRequest"
,
ctx
)
require
.
NoError
(
t
,
err
)
raw
,
err
:=
common
.
Marshal
(
submit
)
require
.
NoError
(
t
,
err
)
assert
.
Equal
(
t
,
"4k"
,
gjson
.
GetBytes
(
raw
,
"body.resolution"
)
.
String
())
value
,
err
:=
plugin
.
Engine
.
Call
(
t
.
Context
(),
"extractUsage"
,
ctx
)
require
.
NoError
(
t
,
err
)
facts
:=
value
.
(
map
[
string
]
any
)
assert
.
EqualValues
(
t
,
5832000
,
facts
[
"tokens"
])
assert
.
Equal
(
t
,
"4K"
,
facts
[
"resolution"
])
assert
.
Equal
(
t
,
"video"
,
facts
[
"video_input"
])
body
:=
map
[
string
]
any
{
"status"
:
"succeeded"
,
"resolution"
:
"4k"
,
"usage"
:
map
[
string
]
any
{
"total_tokens"
:
100000
}}
value
,
err
=
plugin
.
Engine
.
Call
(
t
.
Context
(),
"extractUsageOnComplete"
,
ctx
,
map
[
string
]
any
{
"status"
:
"SUCCESS"
},
body
)
require
.
NoError
(
t
,
err
)
for
key
,
fact
:=
range
value
.
(
map
[
string
]
any
)
{
facts
[
key
]
=
fact
}
assert
.
Equal
(
t
,
"4K"
,
facts
[
"resolution"
])
expression
:=
`u("resolution") == "4K" && u("video_input") == "video" ? tier("4K_video", u("tokens") * 15.68 / 1000000) : tier("4K_none", u("tokens") * 25.48 / 1000000)`
result
,
err
:=
billingexpr
.
ComputeTieredQuotaWithRequest
(
&
billingexpr
.
BillingSnapshot
{
ExprString
:
expression
,
ExprHash
:
billingexpr
.
ExprHashString
(
expression
),
GroupRatio
:
1
,
QuotaPerUnit
:
500000
,
ExprVersion
:
1
,
TaskUsageBilling
:
true
},
billingexpr
.
TokenParams
{},
billingexpr
.
RequestInput
{
Usage
:
facts
})
require
.
NoError
(
t
,
err
)
assert
.
Equal
(
t
,
784000
,
result
.
ActualQuotaAfterGroup
)
_
,
err
=
plugin
.
Engine
.
Call
(
t
.
Context
(),
"parseSubmitResponse"
,
ctx
,
map
[
string
]
any
{
"body"
:
map
[
string
]
any
{
"error"
:
map
[
string
]
any
{
"message"
:
"upstream model does not support 4K"
}}})
require
.
ErrorContains
(
t
,
err
,
"upstream model does not support 4K"
)
})
}
standard
:=
`u("resolution") == "1080P" && u("video_input") == "video" ? tier("1080P_video", u("tokens") * 28.861 / 1000000) : u("resolution") == "1080P" ? tier("1080P_none", u("tokens") * 47.481 / 1000000) : u("video_input") == "video" ? tier("480P_720P_video", u("tokens") * 26.068 / 1000000) : tier("480P_720P_none", u("tokens") * 42.826 / 1000000)`
mini
:=
`u("video_input") == "video" ? tier("video", u("tokens") * 16.0524 / 1000000) : tier("none", u("tokens") * 26.3718 / 1000000)`
...
...
token模型需求表-0831-合并阶梯计费.xlsx
deleted
100644 → 0
View file @
d908e226
File deleted
token模型需求表-0915.xlsx
0 → 100644
View file @
417614a1
File added
web/src/components/ai-elements/__tests__/code-block-copy.test.tsx
0 → 100644
View file @
417614a1
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import
{
fireEvent
,
render
,
screen
,
waitFor
}
from
'@testing-library/react'
import
{
afterEach
,
beforeEach
,
expect
,
test
,
vi
}
from
'vitest'
import
{
CodeBlock
,
CodeBlockCopyButton
}
from
'../code-block'
const
code
=
'curl https://api.example.com/v1/models'
const
clipboardDescriptor
=
Object
.
getOwnPropertyDescriptor
(
navigator
,
'clipboard'
)
const
commandDescriptor
=
Object
.
getOwnPropertyDescriptor
(
document
,
'execCommand'
)
beforeEach
(()
=>
{
Object
.
defineProperty
(
navigator
,
'clipboard'
,
{
configurable
:
true
,
get
:
()
=>
undefined
,
})
Object
.
defineProperty
(
document
,
'execCommand'
,
{
configurable
:
true
,
value
:
()
=>
false
,
})
})
afterEach
(()
=>
{
if
(
clipboardDescriptor
)
{
Object
.
defineProperty
(
navigator
,
'clipboard'
,
clipboardDescriptor
)
}
else
Reflect
.
deleteProperty
(
navigator
,
'clipboard'
)
if
(
commandDescriptor
)
{
Object
.
defineProperty
(
document
,
'execCommand'
,
commandDescriptor
)
}
else
Reflect
.
deleteProperty
(
document
,
'execCommand'
)
})
test
.
each
([
'unavailable'
,
'rejected'
,
'available'
]
as
const
)(
'copies the full sample when Clipboard API is %s'
,
async
(
mode
)
=>
{
const
writeText
=
vi
.
fn
().
mockResolvedValue
(
undefined
)
if
(
mode
===
'rejected'
)
writeText
.
mockRejectedValue
(
new
Error
(
'Denied'
))
if
(
mode
!==
'unavailable'
)
{
vi
.
spyOn
(
navigator
,
'clipboard'
,
'get'
).
mockReturnValue
({
writeText
,
}
as
unknown
as
Clipboard
)
}
let
copied
=
''
const
execCommand
=
vi
.
spyOn
(
document
,
'execCommand'
)
.
mockImplementation
(()
=>
{
copied
=
(
document
.
activeElement
as
HTMLTextAreaElement
).
value
return
true
})
const
onCopy
=
vi
.
fn
()
render
(
<
CodeBlock
code=
{
code
}
language=
'bash'
>
<
CodeBlockCopyButton
onCopy=
{
onCopy
}
/>
</
CodeBlock
>
)
fireEvent
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Copy code'
}))
await
screen
.
findByRole
(
'button'
,
{
name
:
'Copied!'
})
if
(
mode
===
'available'
)
{
expect
(
writeText
).
toHaveBeenCalledWith
(
code
)
expect
(
execCommand
).
not
.
toHaveBeenCalled
()
}
else
{
expect
(
copied
).
toBe
(
code
)
expect
(
execCommand
).
toHaveBeenCalledWith
(
'copy'
)
}
expect
(
onCopy
).
toHaveBeenCalledOnce
()
}
)
test
(
'reports failure without showing copied when both copy methods fail'
,
async
()
=>
{
const
onError
=
vi
.
fn
()
const
onCopy
=
vi
.
fn
()
render
(
<
CodeBlock
code=
{
code
}
language=
'bash'
>
<
CodeBlockCopyButton
onCopy=
{
onCopy
}
onError=
{
onError
}
/>
</
CodeBlock
>
)
fireEvent
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Copy code'
}))
await
waitFor
(()
=>
expect
(
onError
).
toHaveBeenCalledWith
(
expect
.
any
(
Error
)))
expect
(
onCopy
).
not
.
toHaveBeenCalled
()
expect
(
screen
.
getByRole
(
'button'
,
{
name
:
'Copy code'
})).
toBeInTheDocument
()
})
web/src/components/ai-elements/code-block.tsx
View file @
417614a1
...
...
@@ -57,6 +57,7 @@ import {
TooltipContent
,
TooltipTrigger
,
}
from
'@/components/ui/tooltip'
import
{
useCopyToClipboard
}
from
'@/hooks/use-copy-to-clipboard'
import
{
cn
}
from
'@/lib/utils'
type
CodeBlockProps
=
HTMLAttributes
<
HTMLDivElement
>
&
{
...
...
@@ -648,22 +649,17 @@ export const CodeBlockCopyButton = ({
...
props
}:
CodeBlockCopyButtonProps
)
=>
{
const
{
t
}
=
useTranslation
()
const
[
isCopied
,
setIsCopied
]
=
useState
(
false
)
const
{
code
}
=
useContext
(
CodeBlockContext
)
const
{
copiedText
,
copyToClipboard
}
=
useCopyToClipboard
({
resetAfterMs
:
timeout
,
})
const
isCopied
=
copiedText
===
code
const
copyToClipboard
=
async
()
=>
{
if
(
typeof
window
===
'undefined'
||
!
navigator
?.
clipboard
?.
writeText
)
{
onError
?.(
new
Error
(
'Clipboard API not available'
))
return
}
try
{
await
navigator
.
clipboard
.
writeText
(
code
)
setIsCopied
(
true
)
const
handleCopy
=
async
()
=>
{
if
(
await
copyToClipboard
(
code
))
{
onCopy
?.()
setTimeout
(()
=>
setIsCopied
(
false
),
timeout
)
}
catch
(
error
)
{
onError
?.(
error
as
Error
)
}
else
{
onError
?.(
new
Error
(
'Failed to copy to clipboard'
))
}
}
...
...
@@ -673,7 +669,7 @@ export const CodeBlockCopyButton = ({
<
Button
aria
-
label=
{
isCopied
?
t
(
'Copied!'
)
:
t
(
'Copy code'
)
}
className=
{
cn
(
'size-8 shrink-0'
,
className
)
}
onClick=
{
copyToClipboard
}
onClick=
{
handleCopy
}
size=
'icon-sm'
type=
'button'
variant=
'ghost'
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment