鸡鸡出没APIDEVELOPER DOCUMENTATION

开始接入

鉴权与 Base URL

先确认接口地址和 KEY,再验证分组权限。

  1. 在API 密钥页创建 KEY,选择对应的文本、图片或视频分组。
  2. 把 KEY 存在服务端环境变量 XCM_API_KEY;不要把它嵌进网页 JavaScript 或移动端安装包。
  3. 用同一 KEY 请求模型目录;使用返回的完整模型 ID。
配置项填写内容适用方式
Originhttps://xcmapi.org直接拼接完整路径,如 /v1/models
Base URLhttps://xcmapi.org/v1OpenAI 兼容 SDK,SDK 会追加 /responses 等路径
鉴权Authorization: Bearer $XCM_API_KEYOpenAI 风格接口;Gemini 见原生协议章节
请求类型Content-Type: application/jsonJSON 请求;上传文件时采用对应 multipart 格式
Shell 环境变量
export XCM_API_KEY="<YOUR_API_KEY>"

export XCM_MODEL="<MODEL_ID>"

export XCM_API_ORIGIN="https://xcmapi.org"
Windows PowerShell
$env:XCM_API_KEY = "<YOUR_API_KEY>"

$env:XCM_MODEL = "<MODEL_ID>"

$env:XCM_API_ORIGIN = "https://xcmapi.org"

开始接入

创建和管理 KEY

KEY 与分组权限一起决定可调用模型。

1

访问 鸡出没API 控制台 并登录。

2

进入“API 密钥”,点击“创建密钥”,按需要设置名称、额度和可用分组。

3

创建后立即复制 Key。页面不会再次显示完整 Key,遗失后请撤销旧 Key 并重新创建。

安全提示不要把完整 API Key 发给他人,也不要写进前端网页、截图、公共仓库或日志。怀疑泄露时,请立即在API 密钥中撤销。

开始接入

模型与分组

模型目录由当前 KEY 的分组权限决定,不同 KEY 的返回列表可能不同。

读取可用模型
curl --fail-with-body --max-time 30 "$XCM_API_ORIGIN/v1/models" \
  -H "Authorization: Bearer $XCM_API_KEY"

取返回的 data[].id 填入 model。模型名称中的中文、数字、标点和大小写均应保留。模型在目录里出现不等于所有多模态接口均已开放,还需检查对应渠道的能力说明。

使用目标选择分组验证内容
文本或代码支持该模型的文本分组模型目录、协议、流式响应
图片生成明确支持生图的分组模型、尺寸、图片输出格式
H3 视频视频综合分组-全球顶尖视频模型-渠道版模型 h3;独立请求参数、按秒价格与创建/查询/下载权限
混合视频视频综合分组-全球顶尖视频模型-渠道版原 10 款按条型号;完整模型 ID、参数与实际验收范围见视频章节

开始接入

完整路径速查

以下为本文覆盖的现有接口。未列出的路径不应按名称自行推断。

用途地址说明
统一 API 地址https://xcmapi.com/v1填写到支持 OpenAI 兼容格式的客户端。
模型目录GET /v1/models查看当前 Key 可用的模型。
对话接口POST /v1/chat/completions兼容 Chat Completions 请求格式。
Responses 接口POST /v1/responses兼容 OpenAI Responses 请求格式。
图片接口POST /v1/images/generations文生图;结果可能返回 URL 或 Base64。
H3 创建任务POST /v1/videos模型固定为 h3,异步创建视频任务。
H3 查询任务GET /v1/videos/<REQUEST_ID>轮询状态,完成后读取视频地址。
H3 下载视频GET /v1/videos/<REQUEST_ID>/content任务完成后下载 MP4 内容。

开始接入

基础请求示例

用于最小连通检查;请选择当前 KEY 可用的模型。

查看可用模型

curl
curl https://xcmapi.com/v1/models \
  -H "Authorization: Bearer $XCM_API_KEY"

发送一次对话请求

curl
curl https://xcmapi.com/v1/chat/completions \
  -H "Authorization: Bearer $XCM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{

    "model": "你的模型名",

    "messages": [{"role": "user", "content": "你好"}]

  }'

Python

python
import os
from openai import OpenAI



client = OpenAI(api_key=os.environ["XCM_API_KEY"], base_url="https://xcmapi.com/v1")

response = client.chat.completions.create(

    model="你的模型名",

    messages=[{"role": "user", "content": "你好"}],

)

print(response.choices[0].message.content)

文本 API 对接

文本 API

Chat Completions 与 Responses 是两套请求结构,按你的客户端协议选择。

接口请求内容输出位置
POST /v1/chat/completionsmessages 数组choices[].message / delta
POST /v1/responsesinput 字符串或消息 / 工具项output 数组或 Responses SSE 事件
Chat Completions
curl "https://xcmapi.org/v1/chat/completions" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{

    "model": "<MODEL_ID>",

    "messages": [{"role": "user", "content": "Hello"}],

    "stream": false

  }'
Responses
curl "https://xcmapi.org/v1/responses" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{

    "model": "<MODEL_ID>",

    "input": "Hello"

  }'
Responses · Python 标准库
import json, os, urllib.request

origin = os.environ.get("XCM_API_ORIGIN", "https://xcmapi.org").rstrip("/")

payload = {"model": os.environ["XCM_MODEL"], "input": "Reply OK", "stream": False}

request = urllib.request.Request(origin + "/v1/responses",

    data=json.dumps(payload).encode(), method="POST",

    headers={"Authorization": "Bearer " + os.environ["XCM_API_KEY"],

             "Content-Type": "application/json"})

with urllib.request.urlopen(request, timeout=90) as response:

    result = json.load(response)

for item in result.get("output", []):

    if item.get("type") == "message":

        for content in item.get("content", []):

            if content.get("type") == "output_text":

                print(content["text"])

文本 API 对接

流式响应与工具结果

按完整 SSE 事件处理增量数据,并把工具结果与对应 call_id 配对。

POST/v1/responsesstream: true
curl · 不缓冲输出
curl -N --fail-with-body --max-time 120 "$XCM_API_ORIGIN/v1/responses"   -H "Authorization: Bearer $XCM_API_KEY"   -H "Content-Type: application/json"   -d '{"model":"<MODEL_ID>","input":"Reply OK","stream":true}'
处理阶段客户端动作
分片到达TCP 数据块不等于完整 JSON;按空行分割 SSE,合并同一事件的数据行后解析
文本增量Chat 使用 choices[].delta;Responses 使用 response.output_text.delta
工具调用收齐工具参数,再执行本地工具;保留完整工具项与 call_id
回传结果function_call 对应 function_call_output;custom_tool_call 对应 custom_tool_call_output
终态检查完成、失败或不完整状态;HTTP 200 或连接关闭都不能单独证明任务成功
Responses 工具结果结构(替换真实 call_id)
{

  "model": "<MODEL_ID>",

  "input": [

    {"role": "user", "content": "查询测试数据"},

    {"type":"function_call","call_id":"call_example","name":"lookup","arguments":"{\"id\":1}"},

    {"type":"function_call_output","call_id":"call_example","output":"{\"result\":\"ok\"}"}

  ]

}

图文生成对接

图片生成与编辑

图片 API 使用支持生图的 KEY 分组,上传和生成的请求格式不同。

图片结果通常位于响应的 data[].b64_json 或 data[].url 字段。图片模型、尺寸和可用能力以模型目录为准。

Images Generations
curl "https://xcmapi.org/v1/images/generations" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{

    "model": "<IMAGE_MODEL_ID>",

    "prompt": "A clean product photo",

    "size": "1024x1024",

    "n": 1

  }'
Images Edits
curl "https://xcmapi.org/v1/images/edits" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -F "model=<IMAGE_MODEL_ID>" \
  -F "prompt=Remove the background" \
  -F "image[]=@input.png"

文本 API 对接

Messages / Gemini

只有具备对应能力的模型与 KEY 分组才能使用原生协议入口。

Claude 系列

Anthropic Messages
curl "https://xcmapi.org/v1/messages" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -d '{

    "model": "<MODEL_ID>",

    "max_tokens": 1024,

    "messages": [{"role": "user", "content": "Hello"}]

  }'

Gemini 系列

Gemini generateContent
curl "https://xcmapi.org/v1beta/models/<MODEL_ID>:generateContent" \
  -H "x-goog-api-key: <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{

    "contents": [{"parts": [{"text": "Hello"}]}]

  }'

视频 API 对接

原生 h3:独立协议参考

一次 POST 创建一个视频任务。创建超时后不要自动重复提交。

创建任务

H3 使用 OpenAI 风格的异步视频接口。模型名称固定为 h3;创建任务后保存返回的任务 ID,每 3 秒左右查询一次,状态完成后读取视频地址或通过 content 接口下载。

调用前确认先请求 GET /v1/models,确认当前 Key 的模型列表包含 h3。创建任务会产生真实费用,请勿在网络等待时自动重复提交;停止轮询或关闭页面不会取消已提交的服务端任务。
步骤方法与路径说明
创建任务POST /v1/videos
或 POST /v1/videos/generations
提交模型、提示词、时长、画幅、分辨率和可选媒体引用。
查询状态GET /v1/videos/<REQUEST_ID>建议约每 3 秒查询一次,最长等待 10 分钟。
下载视频GET /v1/videos/<REQUEST_ID>/content任务完成后下载;也可读取状态响应中的视频 URL。
1. H3 文生视频
curl "https://xcmapi.org/v1/videos" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{

    "model": "h3",

    "prompt": "一只白色鹈鹕在海边骑自行车,固定镜头,动作自然,画面清晰",

    "duration": 6,

    "aspect_ratio": "16:9",

    "resolution": "480p"

  }'

创建成功后,从响应顶层或 data 对象中读取 request_id、id 或 task_id,后续统一作为 REQUEST_ID 使用。

查询与下载

GET/v1/videos/{task_id}
查询与下载
curl "https://xcmapi.org/v1/videos/<REQUEST_ID>" \
  -H "Authorization: Bearer <YOUR_API_KEY>"



curl -L "https://xcmapi.org/v1/videos/<REQUEST_ID>/content" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -o h3-output.mp4
状态含义处理方式
pending / queued任务已创建,等待执行继续轮询,不要重复创建。
in_progress正在生成约 3 秒后再次查询。
done / completed / succeeded / success生成完成读取 video.url、video_url、顶层 url,或请求 content。
failed / error生成失败记录任务 ID 和错误信息后联系管理员。
首尾帧与多素材
H3 高级能力以下示例使用 H3 1.5 线路的公开请求字段。图片统一使用 {"url":"<IMAGE_URL>"};音频使用 reference_audios 中的预设 voice_id。是否开放首尾帧、多图或音频能力,以当前 Key 的模型目录和分组权限为准。
图片地址请替换示例里的 <IMAGE_URL_n> 只是占位符,不是本站地址。请替换成你自己的、无需登录即可访问的公网 HTTPS 图片地址;不要填写 localhost、127.0.0.1、局域网 IP 或只在本机浏览器中可打开的地址。

首尾帧:固定开始和结束画面

image 固定首帧,last_frame 固定尾帧;只传 last_frame 也可以让模型自动生成开场并落到指定尾帧。提示词可省略,但建议用来描述两帧之间的运动。

H3 首尾帧
curl "https://xcmapi.org/v1/videos/generations" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{

    "model": "h3",

    "prompt": "镜头平滑向前推进,人物从画面左侧走到右侧",

    "image": {"url": "<IMAGE_URL_FIRST>"},

    "last_frame": {"url": "<IMAGE_URL_LAST>"},

    "duration": 8,

    "aspect_ratio": "16:9",

    "resolution": "720p"

  }'

多图参考:同时引用多个主体

把人物、服装、道具或场景分别放入 reference_images,并在提示词中用 <IMAGE_1>、<IMAGE_2> 等标签指代。H3 1.5 单次最多 7 张参考图,超过上限会返回 400。

H3 多图参考
curl "https://xcmapi.org/v1/videos/generations" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{

    "model": "h3",

    "prompt": "<IMAGE_1> 中的人物穿着 <IMAGE_2> 中的衣服,站在 <IMAGE_3> 的场景里,慢慢转身",

    "reference_images": [

      {"url": "<IMAGE_URL_1>"},

      {"url": "<IMAGE_URL_2>"},

      {"url": "<IMAGE_URL_3>"}

    ],

    "duration": 8,

    "aspect_ratio": "9:16",

    "resolution": "720p"

  }'

多图多音频:多角色分别绑定声音

使用 reference_images 和 reference_audios 组合多个角色与声音,在提示词中用 <AUDIO_0>、<AUDIO_1> 绑定说话角色。公开接口的音频示例使用预设声音;自有音频文件需要当前渠道账号单独开通。

H3 多图多音频
curl "https://xcmapi.org/v1/videos/generations" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{

    "model": "h3",

    "prompt": "<IMAGE_1> 中的人物使用 <AUDIO_0> 的声音介绍产品,<IMAGE_2> 中的人物使用 <AUDIO_1> 的声音回应",

    "reference_images": [

      {"url": "<IMAGE_URL_1>"},

      {"url": "<IMAGE_URL_2>"}

    ],

    "reference_audios": [

      {"voice_id": "eve"},

      {"voice_id": "leo"}

    ],

    "duration": 8,

    "aspect_ratio": "16:9",

    "resolution": "720p"

  }'

自动对口型:用参考声音驱动人物说话

当前 H3 公开请求不需要额外传 lip_sync 字段。给人物图片配置 reference_audios,并在提示词中标记对应的 <AUDIO_0>,模型会按声音生成说话动作和口型;最终效果仍取决于人物画面、提示词和当前分组能力。

H3 自动对口型
curl "https://xcmapi.org/v1/videos/generations" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{

    "model": "h3",

    "prompt": "<IMAGE_1> 中的人物正面看向镜头,用 <AUDIO_0> 的声音自然说话,嘴型与语音同步,保持面部身份一致",

    "reference_images": [

      {"url": "<IMAGE_URL_1>"}

    ],

    "reference_audios": [

      {"voice_id": "eve"}

    ],

    "duration": 8,

    "aspect_ratio": "9:16",

    "resolution": "720p"

  }'
字段可用值说明
modelh3必须使用当前 Key 模型目录中返回的名称。
prompt可选或非空字符串描述主体、动作、镜头和画面风格;使用多图/多音频时用 <IMAGE_n>、<AUDIO_n> 指代输入。
duration1–15 秒不传时按 8 秒处理;在线测试默认使用 6 秒。
aspect_ratio16:9、9:16、1:1 等视频画幅;首尾帧和参考图尽量使用与素材接近的比例。
resolution480p、720p、1080p参考图/音频模式通常最高 720p;实际以当前分组配置为准。
image{"url":"..."}固定首帧或进行单图生视频。
last_frame{"url":"..."}固定尾帧;只传它时模型自动生成开场。
reference_images对象数组多图参考,H3 1.5 单次最多 7 张。
reference_audios{"voice_id":"..."} 数组多音频/对口型;公开预设声音最多 3 个,自有音频需单独开通。
Python / Node 完整代码
命令
# Choose Python OR Node.js, not both for the same intended task.

python h3_client.py --submit

# Resume later without another creation:

python h3_client.py --task-id <TASK_ID>



# Alternative Node.js implementation:

node h3_client.mjs --submit

node h3_client.mjs --task-id <TASK_ID>
Python · 标准库 完整示例
Python · 标准库
"""H3 example: POST once, persist task ID, resume polling without resubmission.

Environment: XCM_API_KEY, optionally XCM_API_ORIGIN (HTTPS origin, no /v1).

Usage: python h3_client.py --submit  OR  python h3_client.py --task-id TASK_ID

Submitting generates a billable video. Polling success is not a billing guarantee.

"""

import argparse

import json

import os

from pathlib import Path

import time

import urllib.error

import urllib.parse

import urllib.request

class NoRedirect(urllib.request.HTTPRedirectHandler):

    def redirect_request(self, req, fp, code, msg, headers, newurl):

        return None  # Never forward the API key to a redirected host.

def request(method, path, payload=None):

    origin = os.environ.get('XCM_API_ORIGIN', 'https://xcmapi.org').rstrip('/')

    parts = urllib.parse.urlsplit(origin)

    if parts.scheme != 'https' or not parts.netloc or parts.path or parts.query or parts.fragment or parts.username:

        raise ValueError('XCM_API_ORIGIN must be an HTTPS origin without /v1')

    key = os.environ['XCM_API_KEY']

    req = urllib.request.Request(origin + path, method=method,

        headers={'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json'},

        data=json.dumps(payload).encode() if payload is not None else None)

    try:

        with urllib.request.build_opener(NoRedirect).open(req, timeout=30) as response:

            data = json.load(response)

            return response.status, data, None

    except urllib.error.HTTPError as error:

        retry = error.headers.get('Retry-After', '')

        retry_seconds = min(15, max(3, int(retry))) if retry.isdigit() else None

        # Do not print a raw body that might contain user content or credentials.

        return error.code, {}, retry_seconds

def unwrap(data):

    return data['data'] if isinstance(data.get('data'), dict) else data

def poll(task_id, attempts=120):

    path = '/v1/videos/' + urllib.parse.quote(task_id, safe='')

    for _ in range(attempts):

        try:

            status, response, retry = request('GET', path)

        except (TimeoutError, urllib.error.URLError):

            time.sleep(5)

            continue  # Retrying GET does not create another video.

        if status in (429, 502, 503, 504):

            time.sleep(retry or 5)

            continue

        if status != 200:

            raise RuntimeError(f'Query HTTP {status}; keep task ID and check the documentation')

        data = unwrap(response)

        state = data.get('status')

        if state in ('failed', 'error', 'cancelled', 'canceled', 'expired'):

            raise RuntimeError('Task ended without success; keep task ID for support')

        if state in ('done', 'completed', 'succeeded', 'success'):

            return data

        if state not in ('pending', 'queued', 'in_progress', 'processing', 'running'):

            raise RuntimeError('Unrecognized task status; inspect the response privately')

        time.sleep(5)

    raise TimeoutError('Polling stopped. Resume using --task-id; do not resubmit the POST')

def download(ident, path):
    """Fetch /content, following at most five HTTPS redirects/JSON links."""
    import re
    from urllib import request as http, error as errors, parse as urls
    if not isinstance(ident, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}', ident):
        raise ValueError('Invalid task ID')
    if not path.is_absolute():
        raise ValueError('Choose an explicit absolute output path')
    if path.exists():
        raise FileExistsError('Output exists; choose a new path')

    def checked_url(value, base=None):
        if not isinstance(value, str) or not value or any(ord(c) <= 32 or ord(c) == 127 for c in value) or '\\' in value:
            raise ValueError('Invalid download URL')
        target = urls.urljoin(base, value) if base else value
        p = urls.urlsplit(target)
        if p.scheme != 'https' or not p.hostname or p.username is not None or p.password is not None or p.fragment or p.port == 0:
            raise ValueError('Download URL must be HTTPS without credentials or fragment')
        return target, (p.hostname.lower(), p.port or 443)

    origin = os.environ.get('XCM_API_ORIGIN', 'https://xcmapi.org').rstrip('/')
    origin, auth_origin = checked_url(origin)
    parsed = urls.urlsplit(origin)
    if parsed.path or parsed.query:
        raise ValueError('Use an HTTPS origin without /v1')
    key = os.environ.get('XCM_API_KEY', '')
    if not key.strip() or '\n' in key or '\r' in key:
        raise ValueError('Set XCM_API_KEY in the server environment')
    url = origin + '/v1/videos/' + urls.quote(ident, safe='') + '/content'
    opener = http.build_opener(NoRedirect)
    send_auth = True
    hops = 0
    limit = 250 * 1024 * 1024
    part = path.with_name(path.name + '.partial')
    created = False
    try:
        while True:
            url, current_origin = checked_url(url)
            send_auth = send_auth and current_origin == auth_origin
            headers = {'Accept': 'video/mp4, application/octet-stream, application/json'}
            if send_auth:
                headers['Authorization'] = 'Bearer ' + key
            req = http.Request(url, headers=headers)
            try:
                response = opener.open(req, timeout=90)
            except errors.HTTPError as exc:
                response = exc
            with response:
                kind = response.headers.get('Content-Type', '').split(';')[0].strip().lower()
                if response.status in (301, 302, 303, 307, 308):
                    target = response.headers.get('Location')
                elif response.status != 200:
                    raise RuntimeError(f'Download HTTP {response.status}; retain task ID')
                elif kind == 'application/json' or kind.endswith('+json'):
                    raw = response.read(1024 * 1024 + 1)
                    if len(raw) > 1024 * 1024:
                        raise RuntimeError('Download JSON exceeds 1MiB')
                    try:
                        data = json.loads(raw)
                    except (ValueError, UnicodeError):
                        raise RuntimeError('Invalid download JSON') from None
                    if isinstance(data, dict) and isinstance(data.get('data'), dict):
                        data = data['data']
                    if not isinstance(data, dict):
                        raise RuntimeError('Expected a download URL object')
                    video = data.get('video')
                    results = data.get('result_urls')
                    target = (data.get('url') or data.get('download_url') or data.get('video_url') or data.get('content_url')
                              or (video.get('url') if isinstance(video, dict) else None)
                              or (results[0] if isinstance(results, list) and results else None))
                else:
                    if kind not in ('video/mp4', 'application/octet-stream'):
                        raise RuntimeError('Not an MP4 download response')
                    length = response.headers.get('Content-Length')
                    if length is not None and (not length.isdigit() or not 0 < int(length) <= limit):
                        raise RuntimeError('Invalid or excessive media length')
                    first = response.read(4096)
                    if len(first) < 12 or first[4:8] != b'ftyp':
                        raise RuntimeError('Response is not an MP4')
                    size = len(first)
                    with part.open('xb') as stream:
                        created = True
                        stream.write(first)
                        while True:
                            chunk = response.read(1024 * 1024)
                            if not chunk:
                                break
                            size += len(chunk)
                            if size > limit:
                                raise RuntimeError('Media exceeds the 250MiB client limit')
                            stream.write(chunk)
                    if length is not None and size != int(length):
                        raise RuntimeError('Incomplete media response; retain task ID')
                    break
                if hops >= 5:
                    raise RuntimeError('Too many download redirects/JSON links (maximum 5)')
                url, _ = checked_url(target, url)
                hops += 1
        # Publish exclusively: a concurrent download must not overwrite this file.
        for attempt in range(6):
            try:
                os.link(part, path)
                break
            except PermissionError:
                if attempt == 5:
                    raise
                time.sleep(.1)
        return size
    finally:
        if created:
            part.unlink(missing_ok=True)

def main():

    parser = argparse.ArgumentParser(description=__doc__)

    action = parser.add_mutually_exclusive_group(required=True)

    action.add_argument('--submit', action='store_true')

    action.add_argument('--task-id')

    parser.add_argument('--output', type=Path, default=Path.cwd() / 'h3-output.mp4')

    args = parser.parse_args()

    task_id = args.task_id

    if args.submit:

        status, response, _ = request('POST', '/v1/videos', {

            'model': 'h3', 'prompt': 'A cat walking slowly on a sunny beach',

            'duration': 6, 'resolution': '480p', 'aspect_ratio': '16:9',

        })

        if status not in (200, 201, 202):

            raise RuntimeError(f'Create HTTP {status}; do not automatically retry POST')

        data = unwrap(response)

        task_id = data.get('request_id') or data.get('id') or data.get('task_id')

        if not isinstance(task_id, str) or not task_id:

            raise RuntimeError('No task ID found; do not automatically resubmit')

        # Contains only your task ID; use a private application database in production.

        Path('h3-last-task.json').write_text(json.dumps({'task_id': task_id}), encoding='utf-8')

    import re
    if not isinstance(task_id, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}', task_id):
        raise ValueError('Invalid task ID; do not resubmit')

    print('Task ID:', task_id)

    poll(task_id)

    size = download(task_id, args.output)
    print('Downloaded:', args.output, 'bytes:', size)

if __name__ == '__main__':

    main()
Node.js 20+ 完整示例
Node.js 20+
// Node.js 20+. XCM_API_KEY is read only from the environment.

// node h3_client.mjs --submit   OR   node h3_client.mjs --task-id TASK_ID

import {writeFile} from 'node:fs/promises';

const origin = (process.env.XCM_API_ORIGIN || 'https://xcmapi.org').replace(/\/$/, '');

const endpoint = new URL(origin);

if (endpoint.protocol !== 'https:' || endpoint.pathname !== '/' || endpoint.search || endpoint.hash || endpoint.username || endpoint.password)

  throw new Error('XCM_API_ORIGIN must be an HTTPS origin without /v1');

if (!process.env.XCM_API_KEY) throw new Error('Set XCM_API_KEY');

const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

async function request(method, path, body) {

  return fetch(origin + path, {method, redirect:'error', signal:AbortSignal.timeout(30000),

    headers:{Authorization:`Bearer ${process.env.XCM_API_KEY}`, 'Content-Type':'application/json'},

    body:body === undefined ? undefined : JSON.stringify(body)});

}

async function download(taskId, output='h3-output.mp4') {
  if(typeof taskId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(taskId))
    throw new Error('Invalid task ID');
  function checkedURL(value, base) {
    if(typeof value !== 'string' || !value || /[\s\\\x00-\x1f\x7f]/.test(value)) throw new Error('Invalid download URL');
    const u=new URL(value,base);
    if(u.protocol!=='https:' || !u.hostname || u.username || u.password || u.hash || u.port==='0')
      throw new Error('Download URL must be HTTPS without credentials or fragment');
    return u;
  }
  async function readBounded(response, limit) {
    const chunks=[]; let size=0;
    if(!response.body) throw new Error('Missing download body');
    for await(const chunk of response.body) {
      size+=chunk.byteLength;
      if(size>limit) throw new Error('Download body exceeds its limit');
      chunks.push(Buffer.from(chunk));
    }
    return Buffer.concat(chunks,size);
  }
  let url=checkedURL(origin+'/v1/videos/'+encodeURIComponent(taskId)+'/content');
  let sendAuth=true, hops=0;
  const key=process.env.XCM_API_KEY;
  if(!key || !key.trim() || /[\r\n]/.test(key)) throw new Error('Set XCM_API_KEY');
  while(true) {
    sendAuth=sendAuth && url.origin===endpoint.origin;
    const headers={Accept:'video/mp4, application/octet-stream, application/json'};
    if(sendAuth) headers.Authorization='Bearer '+key;
    const response=await fetch(url.href,{method:'GET',redirect:'manual',headers,signal:AbortSignal.timeout(90000)});
    const kind=(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
    let target;
    if([301,302,303,307,308].includes(response.status)) {
      target=response.headers.get('location');
      await response.body?.cancel();
    } else if(response.status!==200) {
      await response.body?.cancel();
      throw new Error('Download HTTP '+response.status+'; retain task ID');
    } else if(kind==='application/json' || kind.endsWith('+json')) {
      let data=JSON.parse((await readBounded(response,1024*1024)).toString('utf8'));
      if(data && typeof data.data==='object' && data.data!==null && !Array.isArray(data.data)) data=data.data;
      if(!data || typeof data!=='object' || Array.isArray(data)) throw new Error('Expected a download URL object');
      target=data.url || data.download_url || data.video_url || data.content_url || data.video?.url ||
        (Array.isArray(data.result_urls)?data.result_urls[0]:undefined);
    } else {
      if(!['video/mp4','application/octet-stream'].includes(kind)) {
        await response.body?.cancel(); throw new Error('Not an MP4 download response');
      }
      const limit=250*1024*1024, length=response.headers.get('content-length');
      if(length!==null && (!/^\d+$/.test(length) || Number(length)<=0 || Number(length)>limit)) {
        await response.body?.cancel(); throw new Error('Invalid or excessive media length');
      }
      const media=await readBounded(response,limit);
      if(media.length<12 || media.toString('ascii',4,8)!=='ftyp') throw new Error('Response is not an MP4');
      if(length!==null && media.length!==Number(length)) throw new Error('Incomplete media response');
      await writeFile(output,media,{flag:'wx',mode:0o600});
      return media.length;
    }
    if(hops>=5) throw new Error('Too many download redirects/JSON links (maximum 5)');
    url=checkedURL(target,url);
    hops++;
  }
}

const args=process.argv.slice(2);

let taskId;

if(args.length === 2 && args[0] === '--task-id') taskId=args[1];

else if(args.length === 1 && args[0] === '--submit') {

  // One POST only. Do not retry creation automatically after a network timeout.

  const response=await request('POST','/v1/videos',{model:'h3',prompt:'A cat walking slowly on a sunny beach',duration:6,resolution:'480p',aspect_ratio:'16:9'});

  if(!response.ok) throw new Error(`Create HTTP ${response.status}; do not auto-retry POST`);

  const result=await response.json(), data=result.data ?? result;

  taskId=data.request_id ?? data.id ?? data.task_id;

  if(typeof taskId !== 'string' || !taskId) throw new Error('Missing task ID; do not resubmit');

  await writeFile('h3-last-task.json',JSON.stringify({task_id:taskId}),{mode:0o600});

} else throw new Error('Use --submit OR --task-id TASK_ID');

if(typeof taskId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(taskId)) throw new Error('Invalid task ID; do not resubmit');

console.log('Task ID:',taskId);

const taskPath='/v1/videos/'+encodeURIComponent(taskId);

let completed=false;

for(let attempt=0;attempt<120;attempt++) {

  let response;

  try {response=await request('GET',taskPath);} catch {await delay(5000);continue;}

  if([429,502,503,504].includes(response.status)){

    const retry=Number(response.headers.get('retry-after'));

    await delay((Number.isFinite(retry)&&retry>0?Math.min(15,Math.max(3,retry)):5)*1000);continue;

  }

  if(!response.ok) throw new Error(`Query HTTP ${response.status}; retain task ID`);

  const result=await response.json(),data=result.data ?? result,state=data.status;

  if(['failed','error','cancelled','canceled','expired'].includes(state))throw new Error('Task failed; retain task ID');

  if(['done','completed','succeeded','success'].includes(state)){completed=true;break;}

  if(!['pending','queued','in_progress','processing','running'].includes(state))throw new Error('Unknown task status');

  await delay(5000);

}

if(!completed)throw new Error('Polling stopped; resume with --task-id, do not resubmit');

const savedBytes=await download(taskId);
console.log('Downloaded: h3-output.mp4 bytes:',savedBytes);

视频 API 对接

动态视频模型与价格表

同一模型按分组、计费单位和档位分别展示。暂停、待适配及禁用项目仍保留并注明原因;仅已发布档位标为本站现行价。

模型 / 分组状态计费单位档位 / 本站售价
正在读取…

完整价格页 →

视频 API 对接

模型参数与支持范围

按本站实时目录展示参数、开放状态与请求结构,素材范围随目录同步。

参数类型 / 必填范围 / 默认说明

当前模型分组与价格

    正在读取…

    请先等待目录校验。模板不是自动执行请求;使用前替换所有占位内容。

    查看图片、视频、音频的完整数组结构

    视频 API 对接

    视频计费与重复提交保护

    价格与目录同步更新;每个档位保持原计费单位。

    计费单位计算方式
    元/条按对应档位的单次价格计费
    元/秒对应分辨率单价 × 计费时长;具体时长取值须按该接口合同确认
    元/百万 Token输入、输出与缓存等分别按量计算,不按视频条数折算

    同一请求只创建一次

    客户端应保存业务请求号与返回的 task_id;轮询使用同一个 task_id。创建请求超时或连接断开时,不自动重复创建。当前文档不承诺支持 Idempotency-Key,也不以用户停止等待作为取消或退款依据。

    状态与异常

    上游下架、人工禁用、缺少适配、数据过期或价格保护不通过时,不作为可调用报价展示。恢复上架仍须重新通过同步与本站发布检查;以最新开放状态为准。

    查看同源实时价格 · 回到接入流程

    上线与排查

    生产 API 对接方案

    将鉴权、请求生命周期、任务恢复和计费核对放到服务端。

    1. CLIENT用户请求
    2. YOUR SERVER权限 / 限流 / 业务单
    3. SITE API文本或视频任务
    4. WORKER保存结果与账单关联
    模块实现要求验收标准
    鉴权配置以环境变量 / 密钥服务保存 KEY;按业务选择明确的分组请求中仅向本站携带 KEY,错误日志脱敏
    模型选择按 KEY 获取 /v1/models,保存用户选择的完整模型 ID模型不存在时明确报错,不静默切换昂贵模型
    文本转发透传当前协议支持的字段;设置连接和读取超时JSON / SSE 各自解析,终态与业务成功分开判断
    视频创建先建立本地业务单;创建成功立即记录任务 ID重复点击只创建一次,进程重启不会自动重发 POST
    状态 Worker3–5 秒轮询,有限并发;429 依据 Retry-After 退避断线后可通过 task_id 恢复,失败与过期状态终止
    结果存储记录返回格式与过期时间;必要时安全保存生成文件不会把本站 KEY 传给外部下载 URL
    计费关联业务单与 request_id / task_id、模型、分辨率、用量记录关联调价后采用实时分组价,异常可追踪,不推测退款政策

    超时建议(客户端配置,不是服务承诺)

    模型目录 30 秒;文本请求按模型设置合理的首包及读取超时;视频创建可用 60 秒、单次查询 30 秒、后台轮询最多 120 次。每次网络请求自身也有超时,因此总耗时可能高于轮询间隔之和。长期任务应由后台 Worker 承担,不依赖浏览器页面一直打开。

    上线与排查

    错误码与排查

    先按请求阶段排查,再检查模型、分组和协议。

    状态码常见场景处理方式
    400字段、枚举、媒体组合或 JSON 不合法按错误消息修正请求。
    401Key 缺失、无效或已撤销检查鉴权头和 Key。
    402余额、订阅或额度不足检查账户余额和分组额度。
    403分组未开启对应能力更换有权限的 Key 或联系管理员。
    404模型、任务或接口不存在检查模型目录、任务 ID 和路径。
    429频率或并发受限降低并发并稍后重试。
    502/503渠道不可用或暂无可调度账号稍后重试;持续出现时联系售后。
    现象优先检查
    200 但没有内容响应终态是否 failed/incomplete,SSE 是否有 error 事件
    工具调用后续接失败call_id 是否匹配、工具结果类型是否正确、前次 output 是否完整
    视频参数 400duration/resolution 范围、素材数量、首尾帧和音频是否混用
    视频接口 404该分组是否支持视频路径;内测混合分组目前暂未开放
    任务已完成但下载失败结果 URL 是否过期、是否使用正确 KEY / 任务归属、content 是否可用

    上线与排查

    接入验收清单

    完成协议验收后再开通业务流量。不要用一次成功代表所有模型和素材组合都可用。

    • □ 未登录可阅读文档和价格;API 使用无效 KEY 时正确拒绝。
    • □ 文本、图片、视频各使用正确分组,模型 ID 完整。
    • □ 非流式 JSON 与流式 SSE 的成功、失败和中断均可处理。
    • □ 视频创建只提交一次;查询成功、失败、未知、超时均有终态处理。
    • □ 进程重启能凭 task_id 续查;并发请求不会重复建单。
    • □ 480p/720p/1080p 和按条/按秒分别核对;售价只乘一次倍率。
    • □ 返回链接的下载不携带本站 KEY 给其他域名。
    • □ 素材超限、无效 URL、过期任务、429 与 5xx 有明确处理。
    • □ 测试使用独立 KEY 和额度;生成视频会产生真实费用。
    • □ 混合视频仍为内测时,不发布可生成的承诺或执行示例。

    客户端配置

    环境与依赖

    使用对应客户端的配置说明;模型权限与协议仍以 KEY 分组为准。

    1

    打开终端,确认已安装 Node.js 20 或更高版本。

    2

    如果命令不存在,请安装 Node.js 后重新打开终端。

    检查版本
    node --version
    
    npm --version

    按 Esc 或点击关闭返回教程。