Commit 417614a1 by ccran

feat: 增加cdance模型;修改plugin;

parent d908e226
......@@ -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;本地临时归档会自动删除。"
......
......@@ -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;
......
......@@ -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)`
......
/*
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()
})
......@@ -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'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment