# senseaudiomore **Repository Path**: opencode-plugin/senseaudiomore ## Basic Information - **Project Name**: senseaudiomore - **Description**: senseaudiomore-支持多key轮询 - **Primary Language**: TypeScript - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-20 - **Last Updated**: 2026-10-07 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # SenseAudio More OpenCode Provider Plugin **提供商 ID**: `senseaudiomore` | **显示名称**: SenseAudio More | **Base URL**: `https://api.senseaudio.cn/v1` OpenCode 的 SenseAudio 提供商插件(api.senseaudio.cn 网关),支持多 API Key 轮询与对话、多模态输入,兼容 OpenAI Chat Completions 与 Responses API。 ## 特性 - **多 Key 轮询** —— 多个 API Key(逗号分隔)自动循环切换,均衡 `429` 限流压力 - **错误兜底** —— 单把 Key 连续失败 3 次自动跳过;全部失败时重置计数 - **模型支持** —— 收录上游 8 款模型:Qwen / GLM / DeepSeek / SenseNova / SenseAudio - **双适配器** —— s2 系列走 `@ai-sdk/openai`(Responses API),其余走 `@ai-sdk/openai-compatible`(Chat Completions) - **SSE 归一化** —— 自动将网关 `reasoning_text` 事件映射为 SDK 期望的 `reasoning_summary_text` 事件 - **思考内容分离** —— 按各模型实际返回字段声明 `interleaved`,OpenCode 正确渲染思考过程 - **模型标识契约** —— key(OpenCode 上送别名)与 id(上游真实 id)解耦,`transformRequestBody` 自动改写 - **错误体归一** —— 上游非标准错误体统一重建为标准 JSON,避免 OpenCode 无限等待 - **短暂性错误重试** —— 5xx / `upstream_stream_error` 统一转 429,交由 OpenCode 自动重试 - **未授权兜底** —— 未配置 Key 时直接返回 401 并提示授权,不发起真实请求 - **双档超时** —— 请求 60s / 首 chunk 30s,超时转 429 交由 OpenCode 重试(插件不自行重试) - **调试日志** —— `~/.senseaudiomore/debug.log`,可按请求追踪 key 轮询 - **凭证存储** —— API Key 持久化至 `~/.senseaudiomore/auth.json` ## 安装 ### 前置 - Node.js 22+ - OpenCode ### 步骤 ```bash # 1. 进入项目 cd senseaudiomore # 2. 安装依赖 npm install # 3. 构建 npm run build ``` ## 使用 ### 1. 配置 opencode.json ```json { "plugin": [ "E:/work/nreg/ai-provider/senseaudiomore" ] } ``` > 注意是 `plugin` 数组(不是 `plugins`),路径指向项目根目录。 ### 2. 配置凭证 在 OpenCode 中执行 `/connect` 命令,选择 **SenseAudio More**: ``` 创建 apiKey:https://tokenforge.ai.studio/dashboard/keys 邀请链接注册双方各得 20 代金券: https://senseaudio.cn/login?inviteCode=YSNC3QKZ https://senseaudio.cn/login?inviteCode=G23HEXQY https://senseaudio.cn/login?inviteCode=QBNKZHWB 输入 SenseAudio API Keys(多 key 用逗号分隔) ┌──────────────────────────────────────────────────────────┐ │ sk-key1,sk-key2,sk-key3 │ └──────────────────────────────────────────────────────────┘ ``` > 多把 Key 用逗号分隔,插件会自动轮询使用。未授权时模型请求会返回 > `未授权:请通过 /connect 命令点击 SenseAudio More 进行授权`。 ### 3. 选择模型 执行 `/models` 命令,选择所需模型: | Model ID (key) | 上游真实 id | 上下文 | 最大输出 | 输入模态 | 思考 | 适配器 | |----------------|-------------|--------|----------|----------|------|--------| | `qwen3.8-27b` | `qwen3.8-27b` | 256K | 131K | text+image | ⚠️ 不输出 | openai-compatible | | `qwen3.6-35b-a3b` | `qwen3.6-35b-a3b` | 256K | 64K | text+image | ⚠️ 不输出 | openai-compatible | | `glm-5.3-flash` | `glm-5.3-flash` | 1M | 128K | text+image | ⚠️ 不输出 | openai-compatible | | `deepseek-v4-flash` | `deepseek-v4-flash-0731` | 1M | 384K | text+image | ✅ 始终输出 | openai-compatible | | `deepseek-v4.1-flash` | `deepseek-v4.1-flash` | 1M | 384K | text+image | ✅ 始终输出 | openai-compatible | | `senseaudio-s2-flash` | `senseaudio-s2-flash` | 256K | 64K | text+image | ✅ 走 Responses | openai | | `senseaudio-s2-lite` | `senseaudio-s2-lite` | 256K | 64K | text+image | ✅ 走 Responses | openai | | `sensenova-6.8-flash-lite` | `sensenova-6.8-flash-lite` | 256K | 64K | text+image | ✅ 需 enabled | openai-compatible | > 上下文换算:`256K = 262144` / `200K = 204800` / `1M = 1048576` > > ⚠️ `qwen3.8-27b` / `qwen3.6-35b-a3b` / `glm-5.3-flash` 声明了 `reasoning: true`,但实测所有参数组合均不输出思考内容(属上游行为,非插件问题)。 ## 模型标识契约(key ↔ id) OpenCode 上送的是 **模型对象的 key**(小写别名),插件用其 **id**(上游真实 id)请求上游: ``` OpenCode 上送 key → queryModelConfig 查表 → transformRequestBody 改写为 id → 请求上游 ``` 本网关多数模型 `key === id`,但 `deepseek-v4-flash` 的 key 与 id 不同(key=`deepseek-v4-flash`,id=`deepseek-v4-flash-0731`),该机制确保请求上游时使用正确标识。 **key 是给 OpenCode 用的展示/匹配标识,id 是请求上游的真实标识,两者不可混用。** ## 双适配器与 SSE 归一化 ### 双适配器架构 s2 系列(`senseaudio-s2-flash` / `senseaudio-s2-lite`)仅在 `/v1/responses` 端点产出思考内容,Chat Completions 与 Messages 端点均返回 0。因此: - **s2 系列**:模型级 `provider: { npm: '@ai-sdk/openai' }`,SDK 默认走 `/v1/responses` - **其余模型**:provider 级 `npm: '@ai-sdk/openai-compatible'`,走 `/v1/chat/completions` ### SSE 归一化 `@ai-sdk/openai@3.0.88` 期望 `reasoning_summary_text` 事件,但网关发送 `reasoning_text` 事件。插件在 `/v1/responses` 路径自动归一化: | 网关发送 | 归一化后 | 字段调整 | |----------|----------|----------| | `response.reasoning_part.added` | `response.reasoning_summary_part.added` | `content_index` → `summary_index` | | `response.reasoning_text.delta` | `response.reasoning_summary_text.delta` | `content_index` → `summary_index` | | `response.reasoning_part.done` | `response.reasoning_summary_part.done` | `content_index` → `summary_index` | > 注:`3.0.88` 为 OpenCode 运行时内置 SDK 版本,非项目本地安装版本。 详细说明见 [`docs/think-mode.md`](docs/think-mode.md)。 ## 思考内容处理 上游思考字段统一为 **`reasoning`**(SSE delta 全量检查确认,无一返回 `reasoning_content`)。 | 模型 | 思考字段 | 端点 | 说明 | |------|----------|------|------| | `senseaudio-s2-flash` | `reasoning` | `/v1/responses` | chat/completions 与 messages 均 0;需 reasoning.effort | | `senseaudio-s2-lite` | `reasoning` | `/v1/responses` | 同上,responses 产出更丰富 | | `sensenova-6.8-flash-lite` | `reasoning` | `/chat/completions` | `thinking.type='enabled'` 时输出;`adaptive` 抑制 | | `deepseek-v4-flash` | `reasoning` | `/chat/completions` | 始终输出,thinking 参数对其无效 | | `deepseek-v4.1-flash` | `reasoning` | `/chat/completions` | 始终输出 | | `glm-5.3-flash` | `reasoning` | — | **三端点均 0**,上游能力限制 | | `qwen3.8-27b` | — | — | 实测不输出思考 | | `qwen3.6-35b-a3b` | — | — | 实测不输出思考 | 关键事实: - `@ai-sdk/openai-compatible` 的读取顺序为 `delta.reasoning_content ?? delta.reasoning`,因此 `reasoning` 字段同样会被正确识别为思考内容。 - `thinking.type` 取值必须为 `enabled` / `disabled` / `adaptive`(`'enable'` 会被上游 400 拒绝)。 - `reasoning.effort` 由 OpenCode/SDK 预注入并透传,插件仅在缺失时兜底补 `{ effort: 'medium' }`,严禁覆盖已有值(含 `'none'`)。 - s2 系列经 SSE 归一化后,SDK 正确产出 `reasoning-start` / `reasoning-delta` 事件。 详细实测数据与参数建议见 [`docs/think-mode.md`](docs/think-mode.md)。 ## 多 Key 轮询机制 ``` 请求 → chat.headers 钩子 → KeyManager.getNextKey() ↓ ┌─────────────────┐ │ key1 → 首次 │ │ key2 → 下次 │ │ key3 → 再次 │ └─────────────────┘ ↓ 写入 Authorization → 请求上游 ``` - 循环轮询:`key1 → key2 → key3 → key1 → ...` - 失败追踪:每把 Key 连续失败 3 次后跳过 - 自动恢复:某把 Key 成功请求时重置其计数 - 成功清零:请求成功会清零实际使用那把 Key 的错误计数 ## 错误处理 自定义 `fetch` 统一将上游错误转换为 OpenCode 可解析的标准 JSON: | 情况 | 行为 | |------|------| | 401 / 403 | 保留原状态码 + 标准错误体(`isRetryable=false`,立即终止不重试,保留上游原始消息) | | 429 | 保留原状态码 + 透传 `Retry-After`,由 OpenCode 判断重试 | | 5xx | 返回固定 429 错误流,交由 OpenCode 重试(上游 500 实测为偶发,重试可恢复) | | `upstream_stream_error` | 返回 429 交由 OpenCode 重试(消息含 retry 语义) | | 408 / 409 | 保留原状态码,OpenCode 视为可重试 | | 400 / 404 / 422 | 统一转 402(非瞬态,`isRetryable=false`,立即终止不重试) | | 调用方取消 | 返回 402(`isRetryable=false`,立即终止不重试),不记 `Fetch network error`、不计 502 | | 网络异常 | 返回 502 标准错误流,不向 UI 抛异常 | | 未授权(无 Key) | 返回 401 并提示通过 `/connect` 授权,不发起真实请求 | | 请求超时 60s | 返回 429 交由 OpenCode 重试(不自行重试) | | 首 chunk 超时 30s | 返回 429 交由 OpenCode 重试(不自行重试) | > **为什么非瞬态统一转 402**:`402` 经 OpenCode 源码实证为非重试(`isRetryable=false`), > 立即终止不重试;参数/账号级持久错误重试无意义。 > > ⚠️ **消息文案约束**:OpenCode 的重试判定除了 `isRetryable` 还会匹配错误消息正则 > (`RETRYABLE_MESSAGE_PATTERNS`,含 `/429|500|502|503|504|524/i`、`rate limit`、 > `overloaded`、`internal error`、`try again` 等)。因此 402 分支的错误消息 > **不得包含这些可重试关键词**,否则会被误判为可重试。 > > **为什么必须重建错误体**:上游错误体为 `{"code":"...","message":"..."}`, > 缺少 OpenAI 标准的 `error` 对象。若不重建,OpenCode 的 `failedResponseHandler` > 无法解析 → 会一直等待流结束(无限等待)。 ### 上游错误码参考 | HTTP | code | 含义 | |------|------|------| | 400 | `invalid` | 本地请求解析或参数校验失败 | | 400 | `400033` | 模型不存在或未启用 | | 400 | `400500` | 输入或输出命中文本风控 | | 401 | `authentication_error` | API Key 无效 | | 402 | `quota_exceeded_error` | API Key 自身配额耗尽 | | 403 | `permission_error` | API Key 无权限 | | 429 | `429002` | 套餐次数或积分限制 | | 500 | `internal` / `500000` | 模型平台、上游、代理或系统异常 | ## 调试日志 通过环境变量启用日志输出: ```bash # 方式一:全局 DEBUG DEBUG=1 opencode # 方式二:仅 SenseAudio More OPENCODE_SENSEAUDIOMORE_DEBUG=1 opencode ``` 日志位置:`%USERPROFILE%\.senseaudiomore\debug.log` > 环境变量在**模块加载时**读取,必须在启动 OpenCode 前设置。 ### 日志格式 可读 TEXT 单行格式(对齐 agnesmore / sensemore 家族约定),采用 append 追加: ``` [2026-09-20T08:25:05.573Z] [INFO ] Auth loader initialized {"keyCount":3,"keys":["1:sk-QiDrg...a9a4","2:sk-wXwI5...9145","3:sk-Kp7Tm...3d2f"]} [2026-09-20T08:25:05.595Z] [INFO ] Request start {"seq":1,"key":"sk-QiDrg...a9a4","keyPos":"1/3","model":"glm-5.3-flash","endpoint":"/v1/chat/completions"} [2026-09-20T08:25:09.864Z] [INFO ] Request success {"seq":1,"key":"sk-QiDrg...a9a4","keyPos":"1/3","status":200,"stream":true,"ms":4046} ``` ### 确认 Key 轮询生效 **每次请求(无论成功或失败)都会记录所用的 key**,这是确认轮询是否有效的依据: | 字段 | 含义 | |------|------| | `seq` | 请求序号,从 1 递增,用于串联同一次请求 | | `key` | 本次实际使用的 API Key(掩码,保留前 8 位 + 末 4 位) | | `keyPos` | 该 key 在池中的位置,如 `3/8` | | `model` | 请求的模型(已改写为上游真实 id) | | `endpoint` | 请求路径,如 `/v1/chat/completions` | | `status` / `stream` / `ms` | 响应状态码 / 是否流式 / 耗时毫秒 | 观察 `seq` 与 `keyPos` 的对应关系即可确认轮询: ``` seq:1 keyPos:1/3 seq:2 keyPos:2/3 seq:3 keyPos:3/3 seq:4 keyPos:1/3 ← 3 把 key 轮完一圈后回到第 1 把 ``` ### 关键事件 | 事件 | 级别 | 说明 | |------|------|------| | `Provider config updated` | INFO | config 钩子注册 provider 与模型 | | `Auth loader initialized` | INFO | 加载 key 池(列出全部掩码 key) | | `Request start` | INFO | 请求发出前(含 key / keyPos / model) | | `Request success` | INFO | 请求成功(含状态码 / 是否流式 / 耗时) | | `Upstream error response` | WARN | 上游返回错误(含状态码 / 错误码 / 原始消息) | | `Key rotated` | DEBUG | chat.headers 钩子轮换到下一把 key | | `Key error count increased` | WARN | key 请求失败计数增加(达 3 次将跳过) | | `Key recovered, error count reset` | INFO | 成功请求后该 key 计数清零 | | `Unauthenticated request blocked: no API key configured` | WARN | 未授权请求拦截 | | `Request timeout, returning 429 to trigger retry` | WARN | 请求 60s 超时,转 429 | | `First chunk timeout, returning 429 to trigger retry` | WARN | SSE 首 chunk 30s 超时,转 429 | | `Fetch network error` | ERROR | 网络异常 | | `Dispose hook called, global state cleared` | INFO | 注销钩子,全局状态已清空 | > 注:`Request start` / `Request success` 为**成功路径**的重要埋点记录。 > 若日志中只有 `Dispose` 一行,说明本次会话未发生任何请求。 ## Key 黑名单(blacklist.json) KeyManager 会为每个 Key 维护错误计数,持久性错误(`401/403/400` 等)触发 `reportError`(计数 +1), 当 `errorCount >= 3` 时该 Key 被临时拉黑,轮询时自动跳过;请求成功一次即触发 `reportSuccess` 复位计数。 黑名单状态通过快照文件跨重启保留,重启后自动恢复各 Key 的错误计数。 快照文件格式(与插件写盘格式一致): ```json { "updatedAt": 1759800000000, "keys": [ { "key": "sk-xxx1", "errorCount": 3, "blocked": true }, { "key": "sk-xxx2", "errorCount": 0, "blocked": false } ] } ``` 目标路径:`%USERPROFILE%\.senseaudiomore\blacklist.json` 复制方法(可选):将项目 `jsonc/blacklist.jsonc` 复制到 `%USERPROFILE%\.senseaudiomore\` 并重命名为 `blacklist.json`。不复制也不影响运行——插件仅在内存中计数,不跨重启保留;快照读写失败仅记录 debug 警告,绝不中断主流程。 ## 多协议接口(实测记录) 上游同时提供三种 API,本插件使用 **Chat Completions**(OpenAI 兼容)与 **Responses**(s2 系列): | API | 路径 | 请求字段 | 鉴权头 | 适用模型 | |-----|------|----------|--------|----------| | Chat Completions | `/v1/chat/completions` | `messages` | `Authorization: Bearer` | 除 s2 系列外 | | Messages | `/v1/messages` | `messages` | `Authorization: Bearer` | 未使用 | | Responses | `/v1/responses` | `input` | `Authorization: Bearer` | s2-flash / s2-lite | 实测要点: - **思考字段统一为 `reasoning`**(SSE delta 全量检查确认,非 `reasoning_content`) - **s2 系列仅 `/v1/responses` 产出思考**:chat/completions 与 messages 均 0 - **流式**:标准 SSE,无空 `tool_calls` 占位字段(不会导致思考碎片化) - **tools 回传**:对历史 assistant 的 `reasoning` / `reasoning_content` 回传完全宽容(缺失、空串、两种字段名均返回 200) - **视觉输入**:`qwen` / `glm` / `deepseek` / `sensenova` 实测可正确识别图片;`senseaudio-s2-flash` / `senseaudio-s2-lite` 经 `/v1/responses`(input_image)纯色对照实验 3/3 正确,同样支持图像输入 - **上游稳定性**:部分模型偶发 `500`(`ref_code=500000 服务繁忙`),已统一转 429 交由 OpenCode 重试 ### 已排除模型说明 以下模型经实测(3 把 Key、多轮交叉验证)不稳定或无思考输出,**已排除**: | 模型 | 实测结果 | 状态 | |------|----------|------| | `senseaudio-s2` | 三端点各 5/4/3 组共 12 组实测 reasoning 全 0 + 响应延迟过长 | 已注释(constants.ts) | | `kimi-k2.6` | 上游已移除 | 已删除 | | `glm-5.2` | 上游已移除 | 已删除 | | `minimax-m2.7` | 上游已移除 | 已删除 | > 若上游恢复稳定或重新上架,可按 `constants.ts` 中的模型定义格式重新收录。 ## 项目结构 ``` senseaudiomore/ ├── package.json ├── tsconfig.json ├── src/ │ ├── index.ts # 入口:auth hook + config hook + chat.headers + 自定义 fetch + SSE 归一化 │ ├── constants.ts # 提供商配置、模型定义、RESPONSES_MODELS │ ├── plugin/ │ │ ├── auth.ts # 凭证读写 ~/.senseaudiomore/auth.json │ │ └── key-manager.ts # 多 Key 轮询算法 │ └── utils/ │ └── debug-logger.ts # 文件日志(禁止 console.error) ├── docs/ │ └── think-mode.md # 思考模式适配文档(实测数据、SSE 归一化、参数建议) ├── scripts/ # 验证脚本 │ ├── check-error-mapping.mjs # 错误映射离线验证 │ ├── check-key-rotation.mjs # 多 Key 轮询验证 │ ├── check-think-mode.mjs # 思考模式离线验证(SSE 归一化 + reasoning 兜底) │ └── check-e2e.mjs # 端到端真实上游验证(需 Key) └── dist/ # 编译产物 ``` ## 验证脚本 | 脚本 | 用途 | 是否需要 Key | |------|------|--------------| | `scripts/check-error-mapping.mjs` | 错误映射离线验证(mock Response):401/403/429/5xx/408/409/400/404/422/网络异常/未授权/调用方取消/SSE | 否 | | `scripts/check-key-rotation.mjs` | 多 Key 轮询 + 日志归因验证(不写凭证、不发真实请求) | 否(可用假 key) | | `scripts/check-think-mode.mjs` | 思考模式离线验证:SSE 归一化(事件映射/字段修正/半行缓冲)+ reasoning 兜底逻辑 | 否 | | `scripts/check-e2e.mjs` | 端到端真实上游验证:各模型可用性 / 思考字段 / 轮询 / 错误映射 / RESPONSES_MODELS | 是 | ```bash # 错误映射(离线,无需 Key) node scripts/check-error-mapping.mjs # 多 Key 轮询(假 key 即可,验证轮询顺序与日志归因) node scripts/check-key-rotation.mjs sk-key1,sk-key2,sk-key3 # 思考模式(离线,无需 Key) node scripts/check-think-mode.mjs # 端到端(需真实 Key) node scripts/check-e2e.mjs sk-xxxxxxxx ``` ## 开发 ```bash npm run typecheck # 类型检查 npm run build # 构建到 dist/ ``` ## 许可 Apache 2.0 ## 配置文件 插件运行所需的配置文件位于本仓库 ./jsonc/ 目录下(provider.jsonc 与 models.jsonc)。使用前将这两个文件复制到用户目录 %USERPROFILE%\.senseaudiomore/ 下(缺失时插件启动会 fail fast 报错)。复制后可按需编辑模型列表与提供商信息(支持注释),修改后重启插件生效。