Developer Portal

拿到 key 后,按文档直接调。

这里给你一条最小可跑链路:自助开通、带鉴权请求、多模态入口、异步任务结果查询、实时语音会话。

Base URL https://omnimodal.shenliu.cc/api
Auth Header x-omnimodal-api-key: 你的租户 key
最小链路 开通租户 → 发请求 → 查结果 → 接实时语音
商用策略 稳定版放量,候选版观察,异常可回退

Integration Pack

把网址、key、顺序、错误码和回调规则一次讲清楚。

如果你要把全模通交给另一个后端团队、前端团队或外部客户,优先把这一节发给他。

最小接入顺序
  1. 先调用 POST /api/v1/public/self-serve/signup,拿到 tenantIdx-omnimodal-api-key
  2. 先跑 POST /api/v1/multimodal-entries,确认鉴权、tenant 和最小业务入口是通的。
  3. 如果做异步文档链路,再跑 ingestions -> tasks -> result,最后再接租户回调。
  4. 如果做实时语音,先用本页联调台确认公网主链路通,再接你自己的前端和 WebSocket。
稳定接入边界
  • 实时语音默认正式主链路是 pcm16 / 16000 / mono
  • webm_opus 仍是实验路径,不能默认当商用接法。
  • 实时音频分片字段名必须是 audioBase64,不是 audio
  • 同一 realtime 会话里的 sequenceNo 必须单调递增。
放量验收口径
  • 核心链路成功率建议 >= 85%,失败样本必须能追踪到 sessionIdtaskIdproviderCode
  • 实时语音联调单次健康分建议 >= 90,多轮平均建议 >= 85
  • 死信、失败回调、上游异常要能解释、能重试、能归档。
  • 成本口径要能看到账单预估、provider 估算、套餐含量和超额用量。
问题回传模板
  • 必带:tenantIdsessionIdtaskId、测试时间、浏览器/设备/网络。
  • 实时语音必带:audio.append 数、最后 sequenceNo、最后 ACK、rms/peakAbs、final 文本。
  • 如果识别不准,请同时给出“实际说的话”和“识别出来的话”。
  • 如果是异步任务,请带 providerCode、任务状态、错误码、回调投递结果。
你给别人最少要给什么

`API Base URL`、`tenantId`、`x-omnimodal-api-key`、推荐链路、当前稳定边界。如果没有这 4 个,外部团队很容易把平台问题、协议问题和业务问题混在一起排。

最推荐的说法

先用 `multimodal-entries` 或开发者页联调台确认公网链路,再按你自己的业务场景补异步任务、回调或 realtime,不要直接跳到复杂链路。

如果要发给外部团队

建议直接把 交付简报 一起发出去。开发者页负责“跑通”,交付简报负责“统一说法”,两者配合最好。

常见错误码

最值得先看这 8 个

UNAUTHORIZEDFORBIDDENINVALID_MULTIMODAL_ENTRYINVALID_REALTIME_SESSIONREALTIME_CODEC_REQUIRES_PCM16REALTIME_MESSAGE_FAILEDTASK_NOT_FOUNDRESULT_NOT_FOUND

回调验签

先按真实 secret 验签

优先用你租户回调配置里的真实签名密钥验 x-omnimodal-signature,不是直接拿 x-omnimodal-signature-ref 字符串做签名。

幂等建议

按 taskId 去重,按 deliveryId 记投递

回调可能重试,所以你的业务系统建议按 taskId 做最终状态幂等,按 deliveryId 记录每次投递尝试。

联调顺序

先公网验证,再业务嵌入

先在开发者页或 curl 跑通,再接到你的前后端。这样问题边界最清楚,返工最少。

Quick Start

先开通,再拿 key,再跑第一条请求。

01

自助开通租户

调用公开注册接口,返回 tenantId 和首个 integration_system key。

02

记录 API Base URL

统一请求入口是 `https://omnimodal.shenliu.cc/api`。

03

带鉴权头请求

所有租户业务请求统一使用 `x-omnimodal-api-key`。

04

先跑一条最小链路

建议先跑 `multimodal-entries` 或 `ingestions + tasks + result`。

curl -X POST https://omnimodal.shenliu.cc/api/v1/public/self-serve/signup \
  -H 'content-type: application/json' \
  -d '{
    "companyName": "你的公司",
    "contactName": "你的名字",
    "phone": "13800000000",
    "email": "you@example.com",
    "desiredPlan": "starter",
    "scenario": "public api test",
    "message": "public api test"
  }'
返回关键字段

`data.tenant.tenantId` 是你的租户 ID,`data.apiKey.secret` 是真实可用的租户级 key。

统一入口

后续所有业务调用都走 `https://omnimodal.shenliu.cc/api`,请求头带 `x-omnimodal-api-key`。

回调和查询是同一套结果语义

如果你配置了租户回调,回调 payload 会和 `GET /api/v1/tasks/{taskId}/result` 保持同一套业务结果语义,并额外带 `deliveryId`,方便你做幂等处理和补偿。

Step 1 先联调

先在本页跑最小链路和 `Realtime Playground`,确认鉴权、ACK、partial、final 和服务端诊断都正常。

Step 2 再试商用

联调通过后,再把实时语音、多模态入口或异步任务接到你的业务系统,不要把协议问题和业务问题混在一起排。

Step 3 最后看证据

要给客户或内部团队汇报时,用受控证据页而不是聊天截图,统一说明当前链路、量化结果和边界。

Trial Boundary

这页不只是“怎么调接口”,还要告诉你现在能讲到哪一步。

先讲清主链、证据和边界,后面的试点推进才不会失真。

现在可以讲

公网可接入、主链可联调、Realtime Playground 可自助验收、主场景证据和商用门禁已形成统一材料。

现在不要夸大

不要直接讲“所有场景都已经达到最终商用品质”。真实效果仍取决于输入质量、行业词包、真实样本和当前稳定版窗口。

什么时候该给客户看证据页

当你已经跑完联调、拿到一轮真实结果、需要对齐销售/客户/实施时,用证据页展示当前 Readiness、样板链路和边界最合适。

推荐顺序

开发者页负责“跑通”,证据页负责“讲清楚”,后台负责“持续运营和治理”。三者不要混着替代。

适合直接推进的团队已经有工程能力、能自测、目标是 1-3 天先跑通体验的团队,先从本页开始。
适合转实施流程的团队关心正式交付、SLA、套餐、私有化或多系统接入的团队,在本页联调通过后应转到受控证据与商务方案。

Auth

对外调用统一使用租户级 API key。

Base URL: `https://omnimodal.shenliu.cc/api`

Header: `x-omnimodal-api-key: 你的 key`

建议由你的后端调用,不要把真实 key 长期暴露在浏览器前端。

`integration_system` 角色适合业务接入,但不能改平台/计费/权限配置。

Multimodal Entry

最简单的统一入口:直接提交文本、文件、图片或语音元数据。

curl -X POST https://omnimodal.shenliu.cc/api/v1/multimodal-entries \
  -H 'content-type: application/json' \
  -H 'x-omnimodal-api-key: YOUR_KEY' \
  -d '{
    "tenantId": "YOUR_TENANT_ID",
    "scene": "assistant",
    "intent": "lookup",
    "mode": "text",
    "text": "帮我查一下订单 12345 的状态",
    "businessContext": {
      "sourceSystem": "ziin"
    }
  }'
{
  "success": true,
  "data": {
    "entryId": "entry_7ad5f81e",
    "tenantId": "YOUR_TENANT_ID",
    "routingStatus": "accepted",
    "scene": "assistant",
    "mode": "text",
    "skillCode": "order_lookup",
    "linkedIngestionIds": [],
    "linkedTaskIds": [],
    "createdAt": "2026-04-17T13:31:00.000Z"
  }
}

Camera Capture

浏览器拍照也能直接接进全模通,但 PoC 和商用接法要分开。

先说边界

现在开发者页里的相机联调台,已经按正式链路工作:先上传图片拿到受控 `fileUri`,再提交到 `multimodal-entries`。如果你自己的业务系统已有对象存储,也可以继续使用你自己的 `https fileUri + documentType`。

Preset Lane

先选场景,再拍照,参数就不容易填错。

当前是“通用验收”:只验证拍照上传和统一图片入口,不强制生成 OCR 任务。

PoC 最快接法

浏览器相机拍照后,先调用 `POST /api/v1/uploads` 上传,再把返回的 `fileUri` 作为 `images[]` 提交给 `POST /api/v1/multimodal-entries`。

商用推荐接法

正式环境不要长期依赖超长 data URL。推荐一律先上传拿 `fileUri`,再提交,并补上 `documentType`,这样全模通才能继续自动建 ingestion / task。

什么时候会自动建任务

当 `images[]` 同时带上 `fileUri + documentType`,并且你的租户对该 `documentType` 已配置可用 provider 路由时,全模通会继续自动生成 ingestion 和解析任务。

先别猜上传协议

当前公开稳定合同已经包含 `POST /api/v1/uploads` 和 `multimodal-entries.images[]`。如果你要做完整“拍照即 OCR/识别”的商用链路,按这两步接就行,不需要再自己猜上传协议。

质量信号建议一并上传

浏览器端如果已经做了亮度、清晰度、构图预检,建议一并放进 `businessContext`。这样日志、运营台、`GET /tasks/{taskId}/result` 返回体和回调接收方都能统一拿到“是否建议重拍”的信号。

curl -X POST https://omnimodal.shenliu.cc/api/v1/uploads \
  -H 'content-type: application/json' \
  -H 'x-omnimodal-api-key: YOUR_KEY' \
  -d '{
    "tenantId": "YOUR_TENANT_ID",
    "fileName": "camera-capture.jpg",
    "dataUrl": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...",
    "purpose": "camera_capture",
    "sourceSystem": "mobile_web"
  }'
curl -X POST https://omnimodal.shenliu.cc/api/v1/multimodal-entries \
  -H 'content-type: application/json' \
  -H 'x-omnimodal-api-key: YOUR_KEY' \
  -d '{
    "tenantId": "YOUR_TENANT_ID",
    "scene": "camera_capture",
    "intent": "document_scan",
    "mode": "image",
    "images": [
      {
        "fileUri": "https://example.com/uploads/invoice-001.jpg",
        "name": "invoice-001.jpg",
        "mimeType": "image/jpeg",
        "documentType": "finance.invoice"
      }
    ],
    "businessContext": {
      "sourceSystem": "mobile_web",
      "captureSource": "browser_camera",
      "cameraQualityOverall": "suggest_submit",
      "cameraBrightnessScore": "72",
      "cameraSharpnessScore": "61",
      "cameraCoverageScore": "84"
    }
  }'
回调里也会带 sourceContext

只要你在入口侧提交了 `businessContext`,后续任务结果查询和租户回调都会原样带回 `sourceContext`。相机场景里这通常包括 `sourceSystem`、`captureSource`、`cameraQualityOverall` 以及亮度/清晰度/构图分数。

建议用 deliveryId 做幂等

同一任务在下游超时、重试或人工补投时,回调投递会有不同 `deliveryId`。你的业务系统建议同时按 `taskId + deliveryId` 记录投递日志,按 `taskId` 做最终状态去重。

POST https://your-system.example.com/omnimodal/callback
content-type: application/json
x-omnimodal-signature: sha256=YOUR_SIGNATURE
x-omnimodal-signature-ref: vault://tenant_camera_loop_test_d19636/callback-signing

{
  "deliveryId": "cb_0025",
  "taskId": "task_1776528644197_ipo5t4",
  "tenantId": "tenant_camera_loop_test_d19636",
  "schemaType": "finance.receipt",
  "schemaVersion": "1.0.0",
  "confidenceLevel": "medium",
  "payload": {
    "documentType": "finance.receipt",
    "quality": {
      "overall": "submit_with_caution",
      "missingFields": ["merchantName"],
      "retakeAdvice": "请补充票据上沿并提高对焦清晰度。"
    }
  },
  "rawPayloadRef": "result://task_1776528644197_ipo5t4/raw",
  "sourceContext": {
    "sourceSystem": "developer_portal_camera_receipt",
    "captureSource": "browser_camera",
    "cameraQualityOverall": "submit_with_caution",
    "cameraBrightnessScore": "53",
    "cameraSharpnessScore": "41",
    "cameraCoverageScore": "77"
  }
}
当前签名算法

如果租户回调配置启用了 `hmac_sha256`,当前公网实现会对 `taskId` 做 HMAC-SHA256,放进 `x-omnimodal-signature`,格式是 `sha256=...`。`x-omnimodal-signature-ref` 会告诉你当前使用的是哪把签名密钥引用。

验签基准先按 taskId

当前公开实现是对 `taskId` 签名,不是对整个请求体签名。也就是说,下游要先用你配置的回调密钥对 `taskId` 重新算 HMAC,再与请求头比对。后续如果升级成 body-based 签名,会在开发者页和 OpenAPI 一起公告。

import crypto from "node:crypto";

function verifyOmniModalSignature(headers, payload, signingSecret) {
  const headerValue = headers["x-omnimodal-signature"] || "";
  const expected = "sha256=" + crypto
    .createHmac("sha256", signingSecret)
    .update(String(payload.taskId))
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(headerValue),
    Buffer.from(expected),
  );
}
相机联调台

先开相机、拍一张、上传拿 `fileUri`,再提交到 `multimodal-entries`。这条链路适合你快速验证浏览器侧拍照入口、上传能力、鉴权和公网 API 是否都已打通。

拍照预览
相机未启动

等待打开相机。

亮度 -

拍照后评估

清晰度 -

拍照后评估

构图 -

拍照后评估

拍照完成后会先上传,再提交到多模态入口。

Payload Preview
等待拍照后生成 payload。
Upload Snapshot
拍照后会先上传,返回受控 fileUri。
API Response
提交后显示 entry / routing / linked ids。
Task Snapshot
如生成任务,这里会显示 task 状态。
Readable Summary
如果生成结果,这里会把关键字段翻成更容易读的摘要。
Result Cards

如果生成结果,这里会把关键字段拆成卡片,方便一眼判断能不能商用。

Result Snapshot
如生成结果,这里会自动查询并展示解析结果。

Async Task

文件上传编排建议走异步链路:创建入口、发起任务、查询结果。

公网接入要点

异步任务的 `providerCode` 必须使用当前租户已经开通并可路由的真实 provider,不要把示例里的占位值当成公网默认值。

商用建议

对外文档里应固定一条你自己环境里已验证通过的 OCR/解析 provider 组合,再让客户照抄;否则任务可能进入 `dead_lettered`。

现在支持调度策略

如果你希望某类任务更偏低延迟或更偏低成本,可以在 `POST /api/v1/tasks` 的 `sourcePayload.executionPreferences` 里显式传 `strategy`、`preferredProviderCodes`、`blockedProviderCodes`、`disableFallbackProviderCodes`。

curl -X POST https://omnimodal.shenliu.cc/api/v1/ingestions \
  -H 'content-type: application/json' \
  -H 'x-omnimodal-api-key: YOUR_KEY' \
  -d '{
    "tenantId": "YOUR_TENANT_ID",
    "fileName": "invoice.pdf",
    "mimeType": "application/pdf",
    "fileUri": "https://example.com/invoice.pdf",
    "documentType": "finance.invoice"
  }'

curl -X POST https://omnimodal.shenliu.cc/api/v1/tasks \
  -H 'content-type: application/json' \
  -H 'x-omnimodal-api-key: YOUR_KEY' \
  -d '{
    "tenantId": "YOUR_TENANT_ID",
    "ingestionId": "INGESTION_ID",
    "taskType": "document.parse",
    "providerCode": "YOUR_ENABLED_PROVIDER",
    "sourcePayload": {
      "executionPreferences": {
        "strategy": "latency_first",
        "preferredProviderCodes": ["YOUR_ENABLED_PROVIDER"],
        "blockedProviderCodes": [],
        "disableFallbackProviderCodes": []
      }
    }
  }'

curl -H 'x-omnimodal-api-key: YOUR_KEY' \
  https://omnimodal.shenliu.cc/api/v1/tasks/TASK_ID/result
{
  "success": true,
  "data": {
    "resultId": "res_84f9d7a2",
    "taskId": "TASK_ID",
    "tenantId": "YOUR_TENANT_ID",
    "schemaType": "finance.invoice",
    "schemaVersion": "1.0.0",
    "payload": {
      "schemaType": "finance.invoice",
      "schemaVersion": "1.0.0",
      "extractionConfidence": "high",
      "fields": {
        "invoiceNo": "INV-2026-001",
        "amount": "2088.00"
      }
    },
    "rawPayloadRef": "mysql:provider_raw_outputs/raw_123",
    "confidenceLevel": "high",
    "createdAt": "2026-04-17T13:32:10.000Z"
  }
}

Realtime Voice

实时语音先创建会话,再通过 WebSocket 持续送音频块。

先说结论

如果你是第一次接 OmniModal realtime,不要先自己猜 WebSocket 协议。最稳的顺序是:先用本页联调台确认公网链路通,再按下面的 `pcm16 + audioBase64 + 递增 sequenceNo + audio.commit` 去接你的业务前端。

curl -X POST https://omnimodal.shenliu.cc/api/v1/realtime/sessions \
  -H 'content-type: application/json' \
  -H 'x-omnimodal-api-key: YOUR_KEY' \
  -d '{
    "tenantId": "YOUR_TENANT_ID",
    "scene": "assistant",
    "intent": "voice_query",
    "mode": "voice",
    "preflight": true,
    "audioConfig": {
      "codec": "pcm16",
      "sampleRate": 16000,
      "channelCount": 1
    },
    "tuning": {
      "profile": "short_query"
    },
    "businessContext": {
      "sourceSystem": "web"
    }
  }'

# 返回里会有 asrRouting 和 wsUrl
# asrRouting.status=ready 说明当前租户已解析到可用实时 ASR
# preflight=true 时会额外探测上游连通性,返回 upstreamConnectivity / preflightLatencyMs
# asrRouting.providerCode / adapter 会告诉你这次会话实际走哪条语音链路
# asrRouting.codecSupport / runtimeConfig.codecSupport 会告诉你当前 codec 是否可直接用于公网 realtime
# 然后通过 wss://omnimodal.shenliu.cc/api/v1/realtime/ws?... 建链
# 持续发送 audio.append / audio.commit / session.close
# 注意:audio.append 里的音频字段名必须是 audioBase64
参数必须对齐

实时语音会话要求 `audioConfig.codec`、`audioConfig.sampleRate`、`audioConfig.channelCount` 三个字段。

按场景调稳

现在支持 `tuning.profile` 预设档位。`short_query` 适合短问答和命令式操作,优先更早收尾;`balanced` 适合默认通用场景;`rapid_speech` 适合急促语速和短停顿连续表达,避免过早断句;`dictation` 适合访谈、长句和弱声环境;`noisy_environment` 适合地铁、站台、路边、多人说话等嘈杂环境,会明显拉长收尾等待,避免噪声里过早断句。需要更细调时,再覆盖 `tuning.vadThreshold`、`tuning.silenceDurationMs`、`tuning.commitTimeoutMs`。`daily_life` 场景里,短动作命令如“从头播放 / 继续播放 / 挂断电话”更适合 `short_query`;唤醒词、导航词、音量词如“天猫精灵 / 上一首 / 音量调到50%”更稳的默认是 `balanced`。

先看 ASR 路由

生产接入前先检查返回里的 `asrRouting`。如果不是 `ready`,说明当前租户的实时语音提供方还没准备好;要更严格,可以把 `preflight` 设成 `true`。

WebSocket 地址

接口返回的是相对 `wsUrl`,生产接入时拼成 `wss://omnimodal.shenliu.cc/api...` 再建链。

看原始事件

如果没有 `transcript.partial/final`,请查 `GET /api/v1/realtime/sessions/{sessionId}/events`,重点看是否有 `asr.upstream_event`。

先看音频能量

如果 `audio.append` 事件里的 `byteLength/rms/peakAbs` 都是 0,说明客户端虽然在发包,但音频字节实际上是空的。

先看弱音频诊断

现在 `session.error`、`/metrics`、以及联调台的服务端诊断里会返回 `weakAudioDiagnosis`。如果 level 是 `silent`,优先检查麦克风权限、静音和采集链;如果是 `weak`,优先检查说话距离、增益和前端 PCM 转码强度,不要先怀疑 ASR。

先看 ACK 有没有推进

如果前端显示发了很多片,但 `audio.ack.lastAckedSequenceNo` 一直不增长,多半不是 ASR 不准,而是 `sequenceNo`、断线补发或消息格式发错了。

行业词库要显式传入

如果你的场景是物流、客服、财务、电商、制造、医疗,建议在 `businessContext.industryCode` 里显式传 `logistics/customer_service/finance/ecommerce/manufacturing/healthcare`。如果是更高频的通用使用,也建议直接传 `daily_life/life_services/business_work`。当前 realtime final transcript 会在落库前按有效词库做一次纠正。

准确率要看趋势

现在可以调用 `GET /api/v1/tenants/{tenantId}/realtime-accuracy-overview` 看最近 realtime 的 `lexiconRefinedCount`、`feedbackRefinedCount`、低置信度 final 和场景分布,不要只凭主观感觉判断词库是否有效。

继续看两个榜单

`GET /api/v1/tenants/{tenantId}/lexicon-benefit-ranking` 可以看哪些场景词库在真正起作用,`GET /api/v1/tenants/{tenantId}/confusion-ranking` 可以看哪些错词最值得优先修。

候选池适合运营节奏

`GET /api/v1/tenants/{tenantId}/transcript-feedback-candidates` 会返回达到阈值的错词候选,适合放进后台审核池,批量提升成租户词条。

Release Governance

公网可接入,而且仓库侧主场景证据与商用门禁已经跑通;正式放量仍要按稳定版、候选版和回退目标来接。

对外怎么理解这件事

全模通现在不是“所有能力一次性全量开放然后听天由命”,而是按商用平台的方式管理:仓库侧先补齐场景证据、跑过商用门禁,再进入稳定版候选;生产默认走稳定主链路,候选版本先观察,再决定是否放量;一旦出现健康分回落、死信上升或回调失败放大,就应立即切回回退目标版本。

截至当前仓库验证的明确结论

`daily_life / life_services / business_work` 三条主场景都已有真实 run 证据,`npm run quality:commercial` 已通过。这说明当前仓库状态已经具备下一稳定版候选条件,但不等同于所有公网节点已经自动切换到新 stable。

Stable

稳定版

适合正式生产流量。对外宣传时,应以稳定版当前能力、默认 codec 和已通过的验收线为准,不要把实验路径一起打包宣称。

Candidate

候选版

适合少量租户、专项语料和场景打磨。可以联调、可以灰度,但不应默认作为所有客户的生产入口。

Rollback

回退目标

一旦出现明显回归,优先回退到上一稳定结论对应的版本,而不是继续扩大放量或临时改接入协议。

Audit

审计归档

后台现在会记录观察结论、准入决定和回退依据。接入方做生产验收时,也应保留自己的放量、回退和异常记录。

推荐的商用放量顺序:
1. 先确认仓库侧或候选版本已通过 build / smoke / scene readiness / quality:commercial
2. 再用开发者页联调台验证当前租户 realtime 主链路是否稳定出 partial / final
3. 再看 health score、弱音频诊断、死信和失败回调是否处于健康区间
4. 满足验收线后,把当前版本作为稳定版放量
5. 新版本先作为 candidate 观察,不要直接替换稳定版
6. 如果出现回归,优先切回 rollback target,而不是临时修改协议或前端发包方式
生产默认怎么接

当前公网默认商用 realtime 主链路仍然是 `pcm16 / 16000 / mono`。如果你的系统需要正式放量,建议把这条链路视为稳定默认值,`webm_opus` 只做专项观察,不要作为默认接入策略。

什么时候可以说“可商用放量”

至少同时满足:仓库侧商用门禁已通过、联调台健康分达标、真实业务链路能稳定出 final、租户级回归里没有明显 unhealthy 样本、后台发布结论已记录为“准入稳定版”。

什么时候应该继续观察

如果能出 final,但 health score 还在 75-89、final latency 较波动、弱音频占比偏高,或者只是某个行业词包刚上线,此时更适合维持 candidate,不要过早扩大放量。

什么时候应该回退

如果同样输入下准确率明显回落、死信上升、回调失败放大,或用户稳定反馈“比上一轮差”,优先回到上一个稳定结论对应的版本,再继续排障和打磨。

SDK Samples

直接复制到项目里跑,先把第一条链路接通。

Node.js

提交多模态入口

const API_BASE = "https://omnimodal.shenliu.cc/api";
const API_KEY = process.env.OMNIMODAL_API_KEY;
const TENANT_ID = process.env.OMNIMODAL_TENANT_ID;

const response = await fetch(`${API_BASE}/v1/multimodal-entries`, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-omnimodal-api-key": API_KEY,
  },
  body: JSON.stringify({
    tenantId: TENANT_ID,
    scene: "assistant",
    intent: "lookup",
    mode: "text",
    text: "帮我查一下订单 12345 的状态",
    businessContext: {
      sourceSystem: "ziin",
    },
  }),
});

const result = await response.json();
console.log(result);
Python

发起异步任务并查询结果

import os
import requests

API_BASE = "https://omnimodal.shenliu.cc/api"
API_KEY = os.environ["OMNIMODAL_API_KEY"]
TENANT_ID = os.environ["OMNIMODAL_TENANT_ID"]

headers = {
    "content-type": "application/json",
    "x-omnimodal-api-key": API_KEY,
}

ingestion = requests.post(
    f"{API_BASE}/v1/ingestions",
    headers=headers,
    json={
        "tenantId": TENANT_ID,
        "fileName": "invoice.pdf",
        "mimeType": "application/pdf",
        "fileUri": "https://example.com/invoice.pdf",
        "documentType": "finance.invoice",
    },
).json()

ingestion_id = ingestion["data"]["ingestionId"]

task = requests.post(
    f"{API_BASE}/v1/tasks",
    headers=headers,
    json={
        "tenantId": TENANT_ID,
        "ingestionId": ingestion_id,
        "taskType": "document.parse",
        "providerCode": "YOUR_ENABLED_PROVIDER",
    },
).json()

task_id = task["data"]["taskId"]
result = requests.get(f"{API_BASE}/v1/tasks/{task_id}/result", headers=headers).json()
print(result)
浏览器实时语音 SDK 已可直接引用

如果你不想自己处理 `audioBase64`、重连、ACK 跟踪和补发逻辑,可以直接引入全模通提供的浏览器 SDK 文件。

<script src="https://omnimodal.shenliu.cc/assets/omnimodal-realtime-sdk.js"></script>
<script>
  const client = new OmniModalRealtime.OmniModalRealtimeClient({
    apiOrigin: "https://omnimodal.shenliu.cc",
    apiKey: "YOUR_KEY",
    tenantId: "YOUR_TENANT_ID",
    sessionRequest: {
      scene: "assistant",
      intent: "voice_query",
      mode: "voice",
      preflight: true,
      audioConfig: {
        codec: "pcm16",
        sampleRate: 16000,
        channelCount: 1,
      },
      tuning: {
        profile: "short_query",
      },
      businessContext: {
        sourceSystem: "browser_sdk",
      },
    },
  });

  client.addEventListener("partial", (event) => {
    console.log("partial", event.detail.text);
  });

  client.addEventListener("final", (event) => {
    console.log("final", event.detail.text);
  });

  client.addEventListener("runtimeconfig", (event) => {
    console.log("runtime config", event.detail.runtimeConfig);
  });

  await client.startSession();
  await client.connect();
</script>
为什么要保存分片

服务端现在支持会话级重连恢复,`session.ready.resume.lastAckedSequenceNo` 会告诉你已经确认到哪一片;客户端只要补发更大的 `sequenceNo` 即可。

为什么不要重置序号

同一会话里 `sequenceNo` 必须单调递增。重复补发老分片时,服务端会返回 `audio.ack.duplicate=true`,但客户端不应把所有分片重新从 1 开始发。

浏览器编码建议

真实语料回归已经证明:当前 realtime 主链路应默认使用 `pcm16/16000/mono`。`webm_opus` 直推在真实中文语料上不稳定,只能作为实验路径;如果输入来自浏览器录音文件,建议先转成 pcm16 再推 realtime。当前公网节点如果不接受 `webm_opus/opus`,会在 `codecSupport` 和 `session.error` 里明确返回“需要先转 pcm16”。

真实浏览器录音结论

截至 2026-04-19,我们已用历史浏览器 `m4a` 真实录音做过公网黑盒复测。结论是:`m4a -> pcm16/16000/mono -> realtime` 这条链路已经可用,partial/final 能稳定产出;当前更主要的问题是较长真实语音上的 `final latency` 仍偏高,而不是“压缩录音无法识别”。

为什么会感觉 final 稍慢

这组真实 `m4a` 样本的 `commitToFinalLatencyMs` 实测平均只有约 238ms,说明 `audio.commit` 之后系统并没有再额外卡很久。当前更像是 realtime 仍按整句或整段语音收尾后再给 final,所以用户会感觉“比很早之前稳了,但 final 还是稍慢一点”。

排障先看三处

先看 `session.ready.runtimeConfig.codecSupport.accepted` 和 `session.ready.asrProviderCode`,再看 `audio.ack.lastAckedSequenceNo` 是否持续增长,最后看 `/events` 里有没有 `asr.upstream_event` 和 `transcript.partial/final`。

服务端诊断看哪里

点击联调台里的“刷新服务端诊断”后,优先看 `/metrics` 里的 `latestWeakAudioDiagnosis`、`latestFinalMetrics.weakAudioDiagnosis` 和 `latestFinalMetrics.audioSceneDiagnosis`。如果 `peakPercentMax`、`rmsPercentAvg` 很低,且 `weakRatio/silentRatio` 很高,说明问题主要在音频输入;如果 `audioSceneDiagnosis.noiseRiskLevel` 升高,则更像地铁广播、旁人说话或嘈杂环境混入。

健康评分怎么看

联调台和 CLI 脚本现在共用同一套 `health score`。`90-100` 代表这次会话可以作为商用主链路验收;`75-89` 说明主链路基本健康但还有优化空间;`55-74` 代表可用但不建议直接宣称稳定商用;`55` 以下应先排障再放量。

对外验收建议

如果你要把全模通作为外部产品的实时语音底座,建议至少满足这 3 条:`realtime-smoke` 单次健康分 `>= 90`、`realtime-benchmark` 平均健康分 `>= 85`、`realtime-commercial-regression` 各 suite 的 `unhealthyCount = 0`。这样才更接近“可商用、可放量、可交付”的状态。

Callback FAQ 1

如果你收到回调但验签总是不通过,先确认你用的是租户回调配置里的真实签名密钥,而不是 `signingSecretRef` 字符串本身。只有没有解析到真实密钥时,系统才会回退使用 ref 字符串。

Callback FAQ 2

如果头里有 `x-omnimodal-signature-ref`,但你下游没有多租户密钥管理,建议在自己的配置中心维护 `ref -> secret` 映射;不要硬编码到业务代码里。

Callback FAQ 3

如果回调重复投递,不要把它当成异常。先按 `taskId` 做最终结果幂等,再按 `deliveryId` 记录每次投递尝试,这样既能保留审计,又不会重复入账或重复落单。

Browser Mic

浏览器麦克风直连:PCM16 采集、100ms 分片、自动 commit。

录音侧能力已封装

`OmniModalPcm16Recorder` 已内置浏览器麦克风采集、优先 AudioWorklet、降采样到 16k、转 `pcm16`、按 100ms 切片。你不需要再自己处理 WAV 头或 Float32 转 Int16。

推荐用法

生产环境建议在用户按住说话或点击开始后,`recorder.start()` 和 `client.connect()` 同时开始;结束说话时先 `recorder.stop()`,再 `client.commit()`。

短问答可自动收尾

如果你的场景主要是“怎么查订单”“帮我新建客户”这种短句,可以在浏览器侧加 `OmniModalAutoCommitController`。它会在本地检测到一句话结束后的短静音窗口后自动 `commit`,体感会更利落。

<script src="https://omnimodal.shenliu.cc/assets/omnimodal-realtime-sdk.js"></script>
<script>
  const client = new OmniModalRealtime.OmniModalRealtimeClient({
    apiOrigin: "https://omnimodal.shenliu.cc",
    apiKey: "YOUR_KEY",
    tenantId: "YOUR_TENANT_ID",
  });

  const recorder = new OmniModalRealtime.OmniModalPcm16Recorder({
    targetSampleRate: 16000,
    chunkDurationMs: 100,
  });

  const autoCommit = new OmniModalRealtime.OmniModalAutoCommitController(
    OmniModalRealtime.getRecommendedAutoCommitProfileConfig("short_query"),
  );

  recorder.addEventListener("chunk", (event) => {
    const { pcm16Bytes, stats } = event.detail;
    client.sendPcm16Chunk(pcm16Bytes);
    autoCommit.observeChunk(stats);
    console.log("chunk", stats.sampleCount, stats.rms, stats.peakAbs);
  });

  autoCommit.addEventListener("autocommit", async () => {
    await recorder.stop();
    client.commit();
  });

  client.addEventListener("ack", (event) => {
    console.log("acked to", event.detail.lastAckedSequenceNo);
  });

  client.addEventListener("final", (event) => {
    console.log("final", event.detail.text);
  });

  async function startTalking() {
    await client.startSession();
    await client.connect();
    await recorder.start();
  }

  async function stopTalking() {
    await recorder.stop();
    client.commit();
  }
</script>
不用 SDK,也可以按纯 H5 方式直接接

如果你的项目不想直接引入浏览器 SDK,至少要自己做这 6 件事:创建 session、连接 ws、把浏览器采样降到 `16k`、转成 `pcm16`、把每片音频放进 `audioBase64`、结束时显式发送 `audio.commit`。

<script>
const API_BASE = "https://omnimodal.shenliu.cc/api";
const API_KEY = "YOUR_KEY";

let socket;
let mediaStream;
let audioContext;
let sourceNode;
let workletNode;
let chunkBuffer = [];
let chunkSampleCount = 0;
let chunkCount = 0;
let startedAt = 0;

function floatToPcm16(samples) {
  const pcm = new Int16Array(samples.length);
  for (let i = 0; i < samples.length; i += 1) {
    const s = Math.max(-1, Math.min(1, samples[i]));
    pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
  }
  return pcm;
}

function downsampleTo16k(samples, sourceRate) {
  if (sourceRate === 16000) return samples;
  const ratio = sourceRate / 16000;
  const out = new Float32Array(Math.floor(samples.length / ratio));
  for (let i = 0; i < out.length; i += 1) {
    const start = Math.floor(i * ratio);
    const end = Math.min(Math.floor((i + 1) * ratio), samples.length);
    let sum = 0;
    let count = 0;
    for (let j = start; j < end; j += 1) {
      sum += samples[j];
      count += 1;
    }
    out[i] = count ? sum / count : 0;
  }
  return out;
}

function base64FromInt16(int16) {
  const bytes = new Uint8Array(int16.buffer);
  let binary = "";
  const batchSize = 0x8000;
  for (let i = 0; i < bytes.length; i += batchSize) {
    binary += String.fromCharCode(...bytes.subarray(i, i + batchSize));
  }
  return btoa(binary);
}

async function createRealtimeSession() {
  const response = await fetch(API_BASE + "/v1/realtime/sessions", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-omnimodal-api-key": API_KEY,
    },
    body: JSON.stringify({
      tenantId: "YOUR_TENANT_ID",
      scene: "assistant",
      intent: "voice_query",
      mode: "voice",
      audioConfig: {
        codec: "pcm16",
        sampleRate: 16000,
        channelCount: 1,
      },
      tuning: {
        profile: "short_query",
      },
    }),
  });
  const payload = await response.json();
  return payload.data;
}

function sendVoiceChunk(force = false) {
  if (!socket || socket.readyState !== WebSocket.OPEN) return;
  if (!force && chunkSampleCount < 1600) return; // 约 100ms
  if (chunkSampleCount === 0) return;

  const merged = new Float32Array(chunkSampleCount);
  let offset = 0;
  for (const chunk of chunkBuffer) {
    merged.set(chunk, offset);
    offset += chunk.length;
  }

  chunkBuffer = [];
  chunkSampleCount = 0;
  chunkCount += 1;

  const pcm16 = floatToPcm16(merged);
  const audioBase64 = base64FromInt16(pcm16);

  socket.send(JSON.stringify({
    type: "audio.append",
    seq: chunkCount,
    sequenceNo: chunkCount,
    audioBase64,
  }));
}

async function startTalking() {
  const session = await createRealtimeSession();
  const wsUrl = new URL("api" + session.wsUrl, window.location.origin);
  wsUrl.protocol = location.protocol === "https:" ? "wss:" : "ws:";
  socket = new WebSocket(wsUrl);

  socket.addEventListener("message", (event) => {
    const message = JSON.parse(event.data);
    if (message.type === "transcript.partial") console.log("partial", message.text);
    if (message.type === "transcript.final") console.log("final", message.text);
  });

  await new Promise((resolve, reject) => {
    socket.addEventListener("open", resolve, { once: true });
    socket.addEventListener("error", () => reject(new Error("ws connect failed")), { once: true });
  });

  startedAt = Date.now();
  chunkBuffer = [];
  chunkSampleCount = 0;
  chunkCount = 0;

  mediaStream = await navigator.mediaDevices.getUserMedia({
    audio: {
      channelCount: 1,
      echoCancellation: true,
      noiseSuppression: true,
      autoGainControl: true,
    },
  });

  audioContext = new AudioContext();
  sourceNode = audioContext.createMediaStreamSource(mediaStream);

  const processorCode = `
    class OmniModalPcmProcessor extends AudioWorkletProcessor {
      process(inputs) {
        const channel = inputs[0]?.[0];
        if (channel?.length) this.port.postMessage(channel.slice(0));
        return true;
      }
    }
    registerProcessor("omnimodal-pcm-processor", OmniModalPcmProcessor);
  `;
  const blob = new Blob([processorCode], { type: "text/javascript" });
  const url = URL.createObjectURL(blob);
  await audioContext.audioWorklet.addModule(url);
  URL.revokeObjectURL(url);

  workletNode = new AudioWorkletNode(audioContext, "omnimodal-pcm-processor");
  workletNode.port.onmessage = (event) => {
    const downsampled = downsampleTo16k(event.data, audioContext.sampleRate);
    chunkBuffer.push(downsampled);
    chunkSampleCount += downsampled.length;
    sendVoiceChunk(false);
  };

  sourceNode.connect(workletNode);
  workletNode.connect(audioContext.destination);
}

async function stopTalking() {
  sendVoiceChunk(true);
  workletNode?.disconnect();
  sourceNode?.disconnect();
  await audioContext?.close();
  mediaStream?.getTracks().forEach((track) => track.stop());

  if (socket?.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify({
      type: "audio.commit",
      sequenceNo: Math.max(chunkCount, 1),
      durationMs: Math.max(0, Date.now() - startedAt),
    }));
    setTimeout(() => {
      if (socket?.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify({ type: "session.close" }));
      }
    }, 1200);
  }
}
</script>
最容易接错的地方

`audio.append` 里的音频字段名必须是 `audioBase64`,不是 `audio`、`data`、`payload.audio`。如果字段名错了,公网看起来像“ws 已连接、也在发包”,但服务端会把音频当空包处理。

为什么必须前端转 PCM16

当前公网默认商用 realtime 主链路是 `pcm16 / 16000 / mono`。浏览器真实语料回归已经证明,这条路稳定;`webm_opus` 只保留为实验兼容路径,不建议拿来做默认商用接入。

Accuracy Boost

要逼近豆包级体验,不能只靠一个 ASR 模型,要靠“行业词库 + final 修正 + 反馈回灌”。

第一层:通用库 + 行业库

全模通已内置通用中文词库,以及物流、客服、财务、企业助手、电商、制造、医疗行业词库。Realtime 目前先对 `final transcript` 做词库纠正,优先保守,避免影响实时 partial 的低延迟体验。

第二层:租户专属词库

每个租户都可以维护自己的专属热词、错词、品牌词、业务词。推荐把组织名、产品名、模块名、客户名、单据名都沉淀进去。

第三层:纠错反馈回灌

如果用户发现 final transcript 有误,不要只在前端改字。把“原识别文本 -> 用户修正文本”提交回全模通,系统会形成高频混淆对,帮助你决定哪些词应该提升为租户词库。

curl -X POST https://omnimodal.shenliu.cc/api/v1/realtime/sessions \
  -H 'content-type: application/json' \
  -H 'x-omnimodal-api-key: YOUR_KEY' \
  -d '{
    "tenantId": "YOUR_TENANT_ID",
    "scene": "assistant",
    "intent": "voice_query",
    "mode": "voice",
    "audioConfig": {
      "codec": "pcm16",
      "sampleRate": 16000,
      "channelCount": 1
    },
    "businessContext": {
      "sourceSystem": "ziin",
      "industryCode": "logistics"
    }
  }'
curl -X PUT https://omnimodal.shenliu.cc/api/v1/tenants/YOUR_TENANT_ID/lexicons \
  -H 'content-type: application/json' \
  -H 'x-omnimodal-api-key: YOUR_KEY' \
  -d '{
    "industryCode": "logistics",
    "name": "物流高频词补丁",
    "description": "补充我们真实业务里的高频词",
    "priority": 70,
    "terms": [
      {
        "canonical": "签收回单",
        "aliases": ["签售回单", "签收回到"],
        "category": "document",
        "weight": 95,
        "source": "manual",
        "status": "active"
      }
    ]
  }'
curl -X POST https://omnimodal.shenliu.cc/api/v1/tenants/YOUR_TENANT_ID/transcript-feedback \
  -H 'content-type: application/json' \
  -H 'x-omnimodal-api-key: YOUR_KEY' \
  -d '{
    "sessionId": "rtsess_xxx",
    "utteranceId": "utt_xxx",
    "industryCode": "logistics",
    "sourceSystem": "ziin",
    "originalText": "签售回单怎么查询",
    "correctedText": "签收回单怎么查询",
    "note": "业务员口音导致误识别"
  }'
curl -H 'x-omnimodal-api-key: YOUR_KEY' \
  'https://omnimodal.shenliu.cc/api/v1/tenants/YOUR_TENANT_ID/transcript-feedback?industryCode=logistics&limit=20'

Realtime Playground

直接在页面里开麦联调,验证公网实时语音主链路。

外部团队自助验收

填入 `API key` 和 `tenantId` 后,直接开始录音。你会看到会话状态、ACK 推进、实时 partial/final、最近一片音频能量,以及服务端指标与事件。

`daily_life` 建议先按这条边界选档:短动作命令优先 `short_query`,唤醒词 / 上一首 / 跳到第九首 / 音量调到50% 这类优先 `balanced`。拿不准时先用 `balanced`,不要按“短句 = short_query”一刀切。

等待输入凭证。

1 分钟快速验收

给第一次接入的人看的最短流程,不需要懂协议。按顺序做,最后看“是否达到上线试用线”和“通过 / 有条件通过 / 不通过”即可。

  1. 填入 `tenantId` 和 `API key`,点击“开始录音”。
  2. 连续说 1 条完整短句,不要只说“喂喂喂”。
  3. 点击“停止并提交”,等待自动诊断结束。
  4. 确认是否出现 `transcript.final`,并看本地麦克风是否不是静音。
  5. 如果状态是“通过”或“有条件通过”,再继续做 5 条推荐短句回归。
连接状态 idle

尚未创建会话

Session ID -

provider: -

ACK 进度 0

chunks: 0 / reconnect: 0

最后一片 0 / 0

sampleCount / rms

Partial Transcript
等待语音输入...
Final Transcript
等待最终结果...
unknown 等待麦克风输入
0.0s

开始录音后会判断本地 PCM16 是否接近静音、偏弱或可用。

Peak 0%
RMS 0%
Weak Ratio 0%
Chunks 0
最近事件(可点击)
显示 0 / 0
会话时序总结
健康评分 -

等待服务端 metrics。拿到 final / weakAudioDiagnosis / 关键事件后会计算单次会话健康分。

当前判断 等待会话

创建会话并说一句完整短句后,这里会总结建链、ACK、VAD、partial 和 final 的推进情况。

关键计数 ack=0 / partial=0 / final=0

speech_started=无 / speech_stopped=无 / commit_failed=无

关键时延 ready=- / partial=- / final=-

avgVad=- / avgFinal=-

诊断结论
等待验收

先完成一次录音联调。

报告会给使用者一个非技术结论:能不能继续接入、问题更像在哪一层、下一步该做什么。

建议下一步:说一句完整短句,停止并提交,然后等待自动诊断。

上线试用线

暂未判定

完成一次录音并拿到诊断后,这里会明确告诉你本次是否达到上线试用线。

建议下一步:先录一条完整短句,再看是否有 final、健康分和本地麦克风状态。

接入方自助验收清单

第一次接入时,不要随便说几句就下结论。先按这 5 条短句测,再对照右侧状态判断是否达到“可上线试用”的最低标准。

推荐短句
  1. 你好,请帮我查一下今天的订单。
  2. 我想查询客户资料在哪里。
  3. 怎么新建一个订单。
  4. 帮我看一下这个运单状态。
  5. 请告诉我刚才那条记录怎么修改。
通过标准
  • 结论为“通过”或“有条件通过”。
  • 本地麦克风不是 silent
  • ACK 能持续推进,且至少出现一次 transcript.final
  • 健康分建议至少 85 / 100
  • 5 条里至少 4 条能稳定出 final,再讨论准确率调优。
本轮验收结果
待补测

请把 5 条推荐短句测完,再根据每条结果汇总本轮验收结论。

建议下一步:每测完一条短句,就手动标记本条结果,直到 5 条全部完成。

等待诊断

点击“刷新服务端诊断”后生成中文摘要。

当前还没有服务端诊断结果。

建议下一步:先创建会话并说一句完整短句,再刷新服务端诊断。

服务端诊断
点击“刷新服务端诊断”后显示 metrics / events。

API Catalog

机器可读接口清单已经公开,而且带字段和示例。

OpenAPI JSON /api/v1/public/developer/openapi.json
健康检查 `GET /api/health`
租户列表 `GET /api/v1/tenants`
任务结果 `GET /api/v1/tasks/{taskId}/result`
适合用途 可直接给外部系统生成 SDK、做接口校验、接入 API 平台。
错误码怎么用

公开接口统一返回 { "success": false, "error": { "code", "message" } }。接入时建议业务侧至少记录 error.code、HTTP 状态码、请求链路 ID 和当前 tenantId。

最常见的接入失败

`UNAUTHORIZED` 通常是 key 不对;`FORBIDDEN` 通常是租户或角色越界;`INVALID_REALTIME_SESSION` 通常是 realtime 请求体不合规;`REALTIME_CODEC_REQUIRES_PCM16` 说明当前节点要求你先转 pcm16;`REALTIME_MESSAGE_FAILED` 更像上游 ASR 或实时消息阶段失败。

接入建议最少处理这些错误码:

- UNAUTHORIZED: 没带有效 key,或 key 已停用
- FORBIDDEN: 当前 key 不能访问这个租户或这类接口
- INVALID_MULTIMODAL_ENTRY: 多模态入口请求体不合法
- INVALID_INGESTION_INPUT / INVALID_TASK_INPUT: 异步任务参数不合法
- INVALID_REALTIME_SESSION: realtime session 请求体不合法
- REALTIME_CODEC_REQUIRES_PCM16: 当前节点要求你先转成 pcm16 / 16k / mono
- REALTIME_MESSAGE_FAILED: realtime 消息链路或上游 ASR 阶段失败
- TASK_NOT_FOUND / RESULT_NOT_FOUND: taskId/resultId 不存在或未准备好
回调验签最小规则

如果你接租户回调,建议固定记录 x-omnimodal-signaturex-omnimodal-signature-refdeliveryIdtaskId。验签通过后再落业务结果,不通过要保留原始报文方便排查。

文档用途

这一页适合“人看”,OpenAPI 适合“机器接”。推荐把这一页发给工程负责人,把 OpenAPI JSON 交给生成 SDK、校验或 API 网关的团队。