【Suno SDK 系列 01】Crazyrouter Suno SDK 怎么接入?从 0 提交一个 suno/generate 任务,附 2026-07-06 实测

【Suno SDK 系列 01】Crazyrouter Suno SDK 怎么接入?从 0 提交一个 suno/generate 任务,附 2026-07-06 实测

适合读者:后端开发、AI 工具开发者、正在接入 AI 音乐生成能力的 SaaS 团队。 文章重点:不是只复制文档示例,而是按真实接口跑一遍submit -> task_id -> poll -> content url -> audio_url,并记录本次遇到的请求体差异。

先说结论

我在2026-07-06 +08:00按 Crazyrouter 的 Suno SDK 文档做了一次最小实测。

文档地址:

https://docs.crazyrouter.com/sdks/suno-sdk

本次测的是最基础的歌曲生成模型:

Base URL: https://api.crazyrouter.com Endpoint: POST /v1/video/generations Model: suno/generate Version: V5_5

实测结果如下:

步骤请求结果
文档input写法提交POST /v1/video/generations返回prompt is required
顶层prompt+metadata写法提交POST /v1/video/generations返回task_id,状态queued
查询任务GET /v1/video/generations/{task_id}返回succeeded,旧格式显示mp4
下载公开内容 URLGET /v1/videos/{task_id}/content原先返回 404/502,代理未拿到媒体文件
下载真实媒体 URLresultJson.data[0].audio_urlHTTP 200,audio/mp3,412326 bytes

所以这篇文章会给出一个更谨慎的结论:

suno/generate 的任务提交和轮询链路可以跑通; 但生产判断不能停在 succeeded,必须继续验证 content url 或真实 audio_url 是否真的返回媒体文件。

这点很重要。音频/视频/音乐生成都是异步任务,HTTP 200、queued、succeeded分别代表不同阶段,不能混在一起。

1. Suno SDK 这套接口到底是什么

Crazyrouter 文档里的 Suno SDK 指的是一组 Suno 统一任务模型族,不是 OpenAI 官方 SDK 里的client.audio.*。

当前文档列出的模型包括:

suno/generate suno/extend suno/upload-extend suno/cover suno/timestamped-lyrics suno/vocal-separation

统一任务入口是:

POST /v1/video/generations GET /v1/video/generations/{task_id} GET /v1/videos/{task_id}/content

也就是说,它的调用方式更像“提交一个异步任务”,而不是“调用一个同步音频生成函数”。

推荐你按下面这个思路理解:

提交任务:告诉服务端我要生成什么音乐 拿 task_id:服务端确认任务已经进入队列 轮询任务:等待上游真正生成完成 下载内容:拿到最终 mp3/audio 内容并校验

2. 准备 API Key 和环境变量

不要把 API Key 写死在代码里。建议先放到环境变量。

Linux / macOS:

exportCRAZYROUTER_API_KEY="你的 Crazyrouter API Key"

PowerShell:

$env:CRAZYROUTER_API_KEY="你的 Crazyrouter API Key"

本文使用的 Base URL:

https://api.crazyrouter.com

文档也提到,如果业务里已经统一使用主站域名,也可以用:

https://crazyrouter.com

我本次两个域名都测了,后面会说差异。

3. 第一次按文档 input 写法提交:失败

文档里的通用示例大致是这种结构:

{"model":"suno/generate","channel":"auto","input":{"model_version":"V5_5","customMode":true,"instrumental":false,"prompt":"[Verse]\nMorning lights across the wire\nTiny sparks climb higher","style":"bright synth pop, short, clean vocals","title":"CR Suno SDK Demo"}}

我用中文歌词做了一个最小请求:

{"model":"suno/generate","channel":"auto","input":{"model_version":"V5_5","customMode":true,"instrumental":false,"prompt":"[Verse]\n清晨的键盘亮起光\n请求穿过云端的墙\n[Chorus]\n用一次实测把链路唱响\n从任务到结果都清清爽爽","style":"bright synth pop, short, clean vocals, Mandarin pop","title":"Crazyrouter Suno SDK Smoke Test 20260706"}}

提交命令:

curl-sS-XPOST https://api.crazyrouter.com/v1/video/generations\-H"Authorization: Bearer$CRAZYROUTER_API_KEY"\-H"Content-Type: application/json"\--data-binary @suno_sdk_generate_payload_20260706.json

返回:

{"code":"invalid_request","message":"prompt is required","data":null}

我又把域名换成:

https://crazyrouter.com/v1/video/generations

返回仍然是:

{"code":"invalid_request","message":"prompt is required","data":null}

这说明它不是单一域名导致的问题,而是当前生产接口对顶层prompt有校验。

4. 第二次用顶层 prompt + metadata:提交成功

本地历史生产测试脚本里使用过一种兼容写法:

{"model":"suno/generate","prompt":"这里放主提示词","metadata":{"model_version":"V5_5","customMode":true,"instrumental":false,"prompt":"这里也放完整歌词或描述","style":"bright synth pop, short, clean vocals, Mandarin pop","title":"Crazyrouter Suno SDK Smoke Test 20260706"}}

完整请求体:

{"model":"suno/generate","prompt":"[Verse]\n清晨的键盘亮起光\n请求穿过云端的墙\n[Chorus]\n用一次实测把链路唱响\n从任务到结果都清清爽爽","metadata":{"model_version":"V5_5","customMode":true,"instrumental":false,"prompt":"[Verse]\n清晨的键盘亮起光\n请求穿过云端的墙\n[Chorus]\n用一次实测把链路唱响\n从任务到结果都清清爽爽","style":"bright synth pop, short, clean vocals, Mandarin pop","title":"Crazyrouter Suno SDK Smoke Test 20260706"}}

提交:

curl-sS-XPOST https://api.crazyrouter.com/v1/video/generations\-H"Authorization: Bearer$CRAZYROUTER_API_KEY"\-H"Content-Type: application/json"\--data-binary @suno_sdk_generate_payload_metadata_20260706.json

返回:

{"id":"task_ac90b23619334516","task_id":"task_ac90b23619334516","object":"video","model":"suno/generate","status":"queued","progress":0,"created_at":1783267658}

这里要注意,queued只代表任务提交成功,还没有生成完成。

5. 轮询任务状态

拿到task_id后,开始查:

curl-sShttps://api.crazyrouter.com/v1/video/generations/task_ac90b23619334516\-H"Authorization: Bearer$CRAZYROUTER_API_KEY"

本次轮询结果:

{"code":"success","message":"","data":{"error":null,"format":"mp4","metadata":null,"status":"succeeded","task_id":"task_ac90b23619334516","url":"https://crazyrouter.com/v1/videos/task_ac90b23619334516/content"}}

到这里,任务层面的结果是成功的:

status: succeeded format: mp4 url: https://crazyrouter.com/v1/videos/task_ac90b23619334516/content

但是注意,url出现不等于内容已经成功下载。下一步还要校验媒体文件。

6. JavaScript / TypeScript 最小示例

下面这段代码把提交和轮询合在一起。这里我保留顶层prompt + metadata写法,因为这是本次实测能通过生产接口校验的结构。

constapiKey=process.env.CRAZYROUTER_API_KEY;constbaseURL=process.env.CRAZYROUTER_BASE_URL??"https://api.crazyrouter.com";if(!apiKey){thrownewError("Missing CRAZYROUTER_API_KEY");}asyncfunctionrequest(path:string,init:RequestInit={}){constresponse=awaitfetch(`${baseURL}${path}`,{...init,headers:{Authorization:`Bearer${apiKey}`,Accept:"application/json",...(init.body?{"Content-Type":"application/json"}:{}),...init.headers,},});constbody=awaitresponse.json().catch(()=>({}));if(!response.ok){thrownewError(`HTTP${response.status}:${JSON.stringify(body)}`);}returnbody;}asyncfunctionsubmitSunoGenerate(){constprompt="[Verse]\n清晨的键盘亮起光\n请求穿过云端的墙\n[Chorus]\n用一次实测把链路唱响\n从任务到结果都清清爽爽";consttask=awaitrequest("/v1/video/generations",{method:"POST",body:JSON.stringify({model:"suno/generate",prompt,metadata:{model_version:"V5_5",customMode:true,instrumental:false,prompt,style:"bright synth pop, short, clean vocals, Mandarin pop",title:"Crazyrouter Suno SDK Demo",},}),});consttaskId=task.data?.task_id??task.task_id??task.id;if(!taskId){thrownewError(`Missing task_id:${JSON.stringify(task)}`);}returntaskId;}asyncfunctionwaitForTask(taskId:string,timeoutMs=10*60*1000){constdeadline=Date.now()+timeoutMs;while(Date.now()<deadline){constresult=awaitrequest(`/v1/video/generations/${encodeURIComponent(taskId)}`);conststatus=String(result.data?.status??result.status??"").toLowerCase();console.log("status:",status);if(["succeeded","success","completed","done"].includes(status)){returnresult;}if(["failed","failure","error","cancelled","canceled"].includes(status)){thrownewError(`Task failed:${JSON.stringify(result)}`);}awaitnewPromise((resolve)=>setTimeout(resolve,8000));}thrownewError(`Task timeout:${taskId}`);}consttaskId=awaitsubmitSunoGenerate();console.log("taskId:",taskId);constresult=awaitwaitForTask(taskId);console.log("final:",result.data);

7. Python 最小示例

Python 版本如下:

importosimporttimeimportrequests BASE_URL=os.getenv("CRAZYROUTER_BASE_URL","https://api.crazyrouter.com")API_KEY=os.getenv("CRAZYROUTER_API_KEY")ifnotAPI_KEY:raiseRuntimeError("Missing CRAZYROUTER_API_KEY")headers={"Authorization":f"Bearer{API_KEY}","Content-Type":"application/json","Accept":"application/json",}prompt="[Verse]\n清晨的键盘亮起光\n请求穿过云端的墙\n[Chorus]\n用一次实测把链路唱响\n从任务到结果都清清爽爽"submit=requests.post(f"{BASE_URL}/v1/video/generations",headers=headers,json={"model":"suno/generate","prompt":prompt,"metadata":{"model_version":"V5_5","customMode":True,"instrumental":False,"prompt":prompt,"style":"bright synth pop, short, clean vocals, Mandarin pop","title":"Crazyrouter Suno SDK Demo",},},timeout=120,)submit.raise_for_status()task=submit.json()task_id=task.get("data",{}).get("task_id")ortask.get("task_id")ortask.get("id")ifnottask_id:raiseRuntimeError(f"Missing task_id:{task}")print("task_id:",task_id)whileTrue:response=requests.get(f"{BASE_URL}/v1/video/generations/{task_id}",headers={"Authorization":f"Bearer{API_KEY}","Accept":"application/json"},timeout=120,)response.raise_for_status()data=response.json()status=str(data.get("data",{}).get("status","")).lower()print("status:",status)ifstatusin{"succeeded","success","completed","done"}:print("content url:",data.get("data",{}).get("url"))breakifstatusin{"failed","failure","error","cancelled","canceled"}:raiseRuntimeError(data)time.sleep(8)

8. 这次实测暴露的两个坑

坑一:文档示例和当前生产校验可能不完全一致

文档里写的是:

{"model":"suno/generate","channel":"auto","input":{"prompt":"..."}}

但我在2026-07-06 +08:00用两个域名实测,均返回:

{"code":"invalid_request","message":"prompt is required","data":null}

兼容写法是:

{"model":"suno/generate","prompt":"...","metadata":{"prompt":"...","model_version":"V5_5"}}

如果你上线前只照抄文档,很可能会卡在第一步。

坑二:succeeded 不代表公开 content URL 一定可下载

本次查询接口返回:

status=succeeded format=mp4 url=https://crazyrouter.com/v1/videos/task_ac90b23619334516/content

但公开内容 URL 校验时:

HEAD crazyrouter.com content URL: 404 GET api.crazyrouter.com content URL + Authorization: 502

继续追上游原始结果后,真实媒体地址是:

resultJson.data[0].audio_url

直链下载成功:

HTTP 200 Content-Type: audio/mp3 Downloaded: 412326 bytes

所以严谨的生产判断应该是:

submit 成功 task_id 存在 poll 到 succeeded content url 或 audio_url 返回 HTTP 200 Content-Type 是 audio/* 文件大小明显大于错误页 可播放或可被 ffprobe 识别

9. 最小接入检查清单

第一次接入时,建议按这个清单排查:

  1. CRAZYROUTER_API_KEY是否存在。
  2. Base URL 是否是https://api.crazyrouter.com或https://crazyrouter.com。
  3. endpoint 是否是/v1/video/generations,不是/v1/chat/completions。
  4. model是否明确传了suno/generate。
  5. 是否有顶层prompt。
  6. model_version是否使用V5_5、V5、V4_5PLUS等文档列出的枚举值。
  7. 是否拿到了task_id。
  8. 是否轮询到终态。
  9. 是否验证了内容 URL 或resultJson.data[0].audio_url。
  10. 是否记录了失败响应体和 request id。

10. FAQ

Q1:Suno SDK 可以用 OpenAI SDK 直接调吗?

不建议按client.audio.*或client.responses.*来理解。文档明确说这套suno/*当前属于 Crazyrouter 原生任务接口,应该走/v1/video/generations。

Q2:为什么是 video/generations,不是 audio/generations?

这是 Crazyrouter 当前统一任务接口命名。旧查询结果里返回format=mp4,但本次真实audio_url下载到的是audio/mp3。接口路径叫 video 不影响它作为音乐生成任务使用,最终格式要看实际媒体 URL 和Content-Type。

Q3:我应该用input还是metadata?

文档展示的是input。但我在2026-07-06的生产实测中,纯input写法返回prompt is required,顶层prompt + metadata写法可以提交成功。上线前建议你用自己的 API Key 对当前生产接口复测。

Q4:拿到succeeded后是不是就可以给用户展示下载按钮?

不建议。还要验证内容 URL 能否下载到真实媒体文件。本次实测是任务succeeded后,公开/content代理未拿到媒体文件;继续解析resultJson.data[0].audio_url后,真实音频文件可以下载。

最后总结

如果你只是想先跑通 Suno SDK,最小路径是:

POST /v1/video/generations model=suno/generate 顶层 prompt + metadata 拿 task_id 轮询 /v1/video/generations/{task_id} 继续验证 /v1/videos/{task_id}/content 或 resultJson.data[0].audio_url

这篇的重点不是证明“所有能力都稳定”,而是把接入的第一步跑清楚:提交任务和查询任务可以跑通,但真正上线还必须做内容 URL 或audio_url校验。

下一篇我会专门讲异步任务轮询、内容下载、404/502 的排查方式,以及如何从resultJson.data[0].audio_url找到真正可下载的 Suno 结果。